@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.
@@ -1,7 +1,7 @@
1
- /**
2
- * User resolvers
3
- */
1
+ import type { PipelineStage } from "mongoose"
4
2
  import User from "../../models/user"
3
+ import DatasetEvent from "../../models/datasetEvents"
4
+ import type { UserNotificationStatusDocument } from "../../models/userNotificationStatus"
5
5
 
6
6
  function isValidOrcid(orcid: string): boolean {
7
7
  return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "")
@@ -15,12 +15,16 @@ export async function user(
15
15
  let user
16
16
  if (isValidOrcid(id)) {
17
17
  user = await User.findOne({
18
- $or: [{ "provider": "orcid", "providerId": id }],
18
+ $or: [{ provider: "orcid", providerId: id }],
19
19
  }).exec()
20
20
  } else {
21
- // If it's not a valid ORCID, fall back to querying by user id
22
- user = await User.findOne({ "id": id }).exec()
21
+ user = await User.findOne({ id }).exec()
22
+ }
23
+
24
+ if (!user) {
25
+ return null // Fail silently
23
26
  }
27
+
24
28
  if (userInfo?.admin || user.id === userInfo?.id) {
25
29
  return user.toObject()
26
30
  } else {
@@ -37,6 +41,7 @@ export interface UserInfo {
37
41
  provider?: string
38
42
  providerId?: string
39
43
  blocked?: boolean
44
+ orcidConsent?: boolean | null
40
45
  }
41
46
 
42
47
  export interface GraphQLContext {
@@ -50,16 +55,18 @@ type MongoOperatorValue =
50
55
  | RegExp
51
56
  | (string | number | boolean | RegExp)[]
52
57
 
53
- type MongoQueryOperator<T> = T | {
54
- $ne?: T
55
- $regex?: string
56
- $options?: string
57
- $gt?: T
58
- $gte?: T
59
- $lt?: T
60
- $lte?: T
61
- $in?: T[]
62
- }
58
+ type MongoQueryOperator<T> =
59
+ | T
60
+ | {
61
+ $ne?: T
62
+ $regex?: string
63
+ $options?: string
64
+ $gt?: T
65
+ $gte?: T
66
+ $lt?: T
67
+ $lte?: T
68
+ $in?: T[]
69
+ }
63
70
 
64
71
  type MongoFilterValue =
65
72
  | MongoOperatorValue
@@ -68,9 +75,17 @@ type MongoFilterValue =
68
75
  interface MongoQueryCondition {
69
76
  [key: string]: MongoFilterValue
70
77
  }
78
+
71
79
  export const users = async (
72
80
  obj: unknown,
73
- { isAdmin, isBlocked, search, limit = 100, offset = 0, orderBy }: {
81
+ {
82
+ isAdmin,
83
+ isBlocked,
84
+ search,
85
+ limit = 100,
86
+ offset = 0,
87
+ orderBy,
88
+ }: {
74
89
  isAdmin?: boolean
75
90
  isBlocked?: boolean
76
91
  search?: string
@@ -80,13 +95,9 @@ export const users = async (
80
95
  },
81
96
  context: GraphQLContext,
82
97
  ) => {
83
- // --- check admin ---
84
- if (!context.userInfo?.admin) {
85
- return Promise.reject(
86
- new Error("You must be a site admin to retrieve users"),
87
- )
88
- }
98
+ const isSiteAdmin = context.userInfo?.admin === true
89
99
 
100
+ // Build filter for all users (admin or not)
90
101
  const filter: {
91
102
  admin?: MongoQueryOperator<boolean>
92
103
  blocked?: MongoQueryOperator<boolean>
@@ -105,6 +116,7 @@ export const users = async (
105
116
  filter.$or = [
106
117
  { name: { $regex: search, $options: "i" } },
107
118
  { email: { $regex: search, $options: "i" } },
119
+ { orcid: { $regex: search, $options: "i" } },
108
120
  ]
109
121
  }
110
122
 
@@ -133,9 +145,18 @@ export const users = async (
133
145
 
134
146
  const users = await query.exec()
135
147
 
148
+ // If the requester is not a site admin, hide sensitive fields
149
+ const sanitizedUsers = isSiteAdmin ? users : users.map((u) => {
150
+ const obj = u.toObject()
151
+ obj.email = null
152
+ obj.blocked = null
153
+ obj.admin = null
154
+ return obj
155
+ })
156
+
136
157
  return {
137
- users: users,
138
- totalCount: totalCount,
158
+ users: sanitizedUsers,
159
+ totalCount,
139
160
  }
140
161
  }
141
162
 
@@ -165,16 +186,19 @@ export const setBlocked = (obj, { id, blocked }, { userInfo }) => {
165
186
  }
166
187
  }
167
188
 
168
- export const updateUser = async (obj, { id, location, institution, links }) => {
189
+ export const updateUser = async (
190
+ obj,
191
+ { id, location, institution, links, orcidConsent },
192
+ ) => {
169
193
  try {
170
- let user // Declare user outside the if block
194
+ let user
171
195
 
172
196
  if (isValidOrcid(id)) {
173
197
  user = await User.findOne({
174
- $or: [{ "orcid": id }, { "providerId": id }],
198
+ $or: [{ orcid: id }, { providerId: id }],
175
199
  }).exec()
176
200
  } else {
177
- user = await User.findOne({ "id": id }).exec()
201
+ user = await User.findOne({ id: id }).exec()
178
202
  }
179
203
 
180
204
  if (!user) {
@@ -185,16 +209,141 @@ export const updateUser = async (obj, { id, location, institution, links }) => {
185
209
  if (location !== undefined) user.location = location
186
210
  if (institution !== undefined) user.institution = institution
187
211
  if (links !== undefined) user.links = links
212
+ if (orcidConsent !== undefined) user.orcidConsent = orcidConsent
188
213
 
189
- // Save the updated user
190
214
  await user.save()
191
215
 
192
- return user // Return the updated user object
216
+ return user
193
217
  } catch (err) {
194
218
  throw new Error("Failed to update user: " + err.message)
195
219
  }
196
220
  }
197
221
 
222
+ /**
223
+ * Get all events associated with a specific user (for their notifications feed).
224
+ * Uses a single aggregation pipeline for improved performance.
225
+ */
226
+ export async function notifications(obj, _, { userInfo }) {
227
+ const userId = obj.id
228
+
229
+ // --- authorization ---
230
+ if (!userInfo || (userInfo.id !== userId && !userInfo.admin)) {
231
+ throw new Error("Not authorized to view these notifications.")
232
+ }
233
+
234
+ // --- get user and orcid ---
235
+ const currentUser = await User.findOne({ id: userId }).exec()
236
+ const orcid = currentUser?.orcid
237
+
238
+ // --- base match conditions: either the user created it OR it targets them ---
239
+ const matchConditions: Record<string, unknown>[] = [
240
+ { userId },
241
+ { "event.targetUserId": userId },
242
+ ]
243
+ if (orcid) matchConditions.push({ "event.targetUserId": orcid })
244
+
245
+ const pipeline: PipelineStage[] = [
246
+ { $match: { $or: matchConditions } },
247
+ {
248
+ $lookup: {
249
+ from: "permissions",
250
+ let: { datasetId: "$datasetId", currentUserId: userId },
251
+ pipeline: [
252
+ {
253
+ $match: {
254
+ $expr: {
255
+ $and: [
256
+ { $eq: ["$datasetId", "$$datasetId"] },
257
+ { $eq: ["$userId", "$$currentUserId"] },
258
+ ],
259
+ },
260
+ },
261
+ },
262
+ ],
263
+ as: "permissions",
264
+ },
265
+ },
266
+ { $unwind: { path: "$permissions", preserveNullAndEmptyArrays: true } },
267
+ { $sort: { timestamp: -1 } },
268
+ {
269
+ $lookup: {
270
+ from: "users",
271
+ localField: "userId",
272
+ foreignField: "id",
273
+ as: "user",
274
+ },
275
+ },
276
+ { $unwind: { path: "$user", preserveNullAndEmptyArrays: true } },
277
+ {
278
+ $lookup: {
279
+ from: "usernotificationstatuses",
280
+ let: { eventId: "$id" },
281
+ pipeline: [
282
+ {
283
+ $match: {
284
+ $expr: {
285
+ $and: [
286
+ { $eq: ["$datasetEventId", "$$eventId"] },
287
+ { $eq: ["$userId", userId] },
288
+ ],
289
+ },
290
+ },
291
+ },
292
+ ],
293
+ as: "notificationStatus",
294
+ },
295
+ },
296
+ {
297
+ $unwind: {
298
+ path: "$notificationStatus",
299
+ preserveNullAndEmptyArrays: true,
300
+ },
301
+ },
302
+ ]
303
+
304
+ const events = await DatasetEvent.aggregate(pipeline).exec()
305
+
306
+ // --- apply visibility rules ---
307
+ const filtered = events.filter((ev) => {
308
+ if (!ev.event || !ev.event.type) return false
309
+
310
+ const type = ev.event.type
311
+ const targetId = ev.event.targetUserId
312
+ const isDatasetAdmin = userInfo.admin || ev.permissions?.level === "admin"
313
+ const isTargetUser = targetId === userId || (orcid && targetId === orcid)
314
+
315
+ switch (type) {
316
+ case "contributorRequest":
317
+ return isDatasetAdmin
318
+ case "contributorCitation":
319
+ return isTargetUser
320
+ case "contributorRequestResponse":
321
+ case "contributorCitationResponse":
322
+ return isDatasetAdmin || isTargetUser
323
+ // Reduce the notification noise by hiding non-actionable events
324
+ case "created":
325
+ case "versioned":
326
+ case "deleted":
327
+ case "published":
328
+ case "permissionChange":
329
+ case "git":
330
+ case "upload":
331
+ return false
332
+ default:
333
+ return isDatasetAdmin
334
+ }
335
+ })
336
+
337
+ // --- map results with notification status ---
338
+ return filtered.map((event) => {
339
+ const notificationStatus = event.notificationStatus
340
+ ? event.notificationStatus
341
+ : ({ status: "UNREAD" } as UserNotificationStatusDocument)
342
+
343
+ return { ...event, notificationStatus }
344
+ })
345
+ }
346
+
198
347
  const UserResolvers = {
199
348
  id: (obj) => obj.id,
200
349
  provider: (obj) => obj.provider,
@@ -209,7 +358,9 @@ const UserResolvers = {
209
358
  location: (obj) => obj.location,
210
359
  institution: (obj) => obj.institution,
211
360
  links: (obj) => obj.links,
361
+ orcidConsent: (obj) => obj.orcidConsent,
212
362
  modified: (obj) => obj.updatedAt,
363
+ notifications: notifications,
213
364
  }
214
365
 
215
366
  export default UserResolvers
@@ -110,6 +110,7 @@ export const typeDefs = `
110
110
  ): [FlaggedFile]
111
111
  # All public dataset metadata
112
112
  publicMetadata: [Metadata] @cacheControl(maxAge: 86400, scope: PUBLIC)
113
+ orcidConsent: Boolean
113
114
  }
114
115
 
115
116
  type Mutation {
@@ -146,7 +147,7 @@ export const typeDefs = `
146
147
  # Sets a users admin status
147
148
  setBlocked(id: ID!, blocked: Boolean!): User
148
149
  # Mutation for updating user data
149
- updateUser(id: ID!, location: String, institution: String, links: [String]): User
150
+ updateUser(id: ID!, location: String, institution: String, links: [String], orcidConsent: Boolean): User
150
151
  # Tracks a view or download for a dataset
151
152
  trackAnalytics(datasetId: ID!, tag: String, type: AnalyticTypes): Boolean
152
153
  # Follow dataset
@@ -175,8 +176,8 @@ export const typeDefs = `
175
176
  prepareUpload(datasetId: ID!, uploadId: ID!): UploadMetadata
176
177
  # Add files from a completed upload to the dataset draft
177
178
  finishUpload(uploadId: ID!): Boolean
178
- # Drop download cache for a snapshot - requires site admin access
179
- cacheClear(datasetId: ID!, tag: String!): Boolean
179
+ # Drop cached data for a dataset - requires site admin access
180
+ cacheClear(datasetId: ID!): Boolean
180
181
  # Rerun the latest validator on a given commit
181
182
  revalidate(datasetId: ID!, ref: String!): Boolean
182
183
  # Request a temporary token for git access
@@ -205,6 +206,16 @@ export const typeDefs = `
205
206
  saveAdminNote(id: ID, datasetId: ID!, note: String!): DatasetEvent
206
207
  # Create a git event log for dataset changes
207
208
  createGitEvent(datasetId: ID!, commit: String!, reference: String!): DatasetEvent
209
+ # Request contributor status for a dataset
210
+ createContributorRequestEvent(datasetId: ID!): DatasetEvent
211
+ # Save contributor request response data
212
+ processContributorRequest(
213
+ datasetId: ID!
214
+ targetUserId: ID!
215
+ requestId: ID!
216
+ resolutionStatus: String!
217
+ reason: String
218
+ ): DatasetEvent
208
219
  # Create or update a fileCheck document
209
220
  updateFileCheck(
210
221
  datasetId: ID!
@@ -213,6 +224,21 @@ export const typeDefs = `
213
224
  annexFsck: [AnnexFsckInput!]!
214
225
  remote: String
215
226
  ): FileCheck
227
+ # Profile Event Status updates
228
+ updateEventStatus(eventId: ID!, status: NotificationStatusType!): UserNotificationStatus
229
+ updateContributors(
230
+ datasetId: String!
231
+ newContributors: [ContributorInput!]!
232
+ ): UpdateContributorsPayload!
233
+ createContributorCitationEvent(
234
+ datasetId: ID!
235
+ targetUserId: ID!
236
+ contributorData: ContributorInput!
237
+ ): DatasetEvent
238
+ processContributorCitation(
239
+ eventId: ID!
240
+ status: String!
241
+ ): DatasetEvent
216
242
  # Update worker task queue status
217
243
  updateWorkerTask(
218
244
  id: ID!,
@@ -351,7 +377,7 @@ export const typeDefs = `
351
377
 
352
378
  # OpenNeuro user records from all providers
353
379
  type User {
354
- id: ID!
380
+ id: ID
355
381
  provider: UserProvider
356
382
  avatar: String
357
383
  orcid: String
@@ -367,6 +393,8 @@ export const typeDefs = `
367
393
  github: String
368
394
  githubSynced: Date
369
395
  links: [String]
396
+ notifications: [DatasetEvent!]
397
+ orcidConsent: Boolean
370
398
  }
371
399
 
372
400
  type UserList {
@@ -561,6 +589,8 @@ export const typeDefs = `
561
589
  size: BigInt
562
590
  # File issues
563
591
  fileCheck: FileCheck
592
+ # Contributors list from datacite.yml
593
+ contributors: [Contributor]
564
594
  }
565
595
 
566
596
  # Tagged snapshot of a draft
@@ -600,6 +630,8 @@ export const typeDefs = `
600
630
  size: BigInt
601
631
  # Single list of files to download this snapshot (only available on snapshots)
602
632
  downloadFiles: [DatasetFile]
633
+ # Contributors list from datacite.yml
634
+ contributors: [Contributor]
603
635
  }
604
636
 
605
637
  # RelatedObject nature of relationship
@@ -669,6 +701,33 @@ export const typeDefs = `
669
701
  EthicsApprovals: [String]
670
702
  }
671
703
 
704
+
705
+ # Defines the Contributor type in contributors.ts
706
+ type Contributor {
707
+ name: String!
708
+ givenName: String
709
+ familyName: String
710
+ orcid: String
711
+ contributorType: String!
712
+ order: Int
713
+ }
714
+
715
+ # ContributorInput input type
716
+ input ContributorInput {
717
+ name: String
718
+ givenName: String
719
+ familyName: String
720
+ orcid: String
721
+ contributorType: String
722
+ order: Int
723
+ }
724
+
725
+ type UpdateContributorsPayload {
726
+ success: Boolean!
727
+ dataset: Dataset
728
+ }
729
+
730
+
672
731
  # User permissions on a dataset
673
732
  type Permission {
674
733
  datasetId: ID!
@@ -903,12 +962,18 @@ export const typeDefs = `
903
962
  version: String
904
963
  public: Boolean
905
964
  target: User
965
+ targetUserId: ID
906
966
  level: String
907
967
  ref: String
908
968
  message: String
969
+ requestId: ID
970
+ reason: String
971
+ datasetId: ID
972
+ resolutionStatus: String
973
+ contributorData: Contributor
909
974
  }
910
975
 
911
- # Dataset events
976
+ # Dataset events
912
977
  type DatasetEvent {
913
978
  # Unique identifier for the event
914
979
  id: ID
@@ -922,8 +987,36 @@ export const typeDefs = `
922
987
  success: Boolean
923
988
  # Notes associated with the event
924
989
  note: String
990
+ # top-level datasetId field
991
+ datasetId: ID
992
+ # User's notification status event
993
+ notificationStatus: UserNotificationStatus
994
+ responseStatus: String
995
+ hasBeenRespondedTo: Boolean
996
+ }
997
+
998
+
999
+ # Possible statuses for user notification/events
1000
+ enum NotificationStatusType {
1001
+ UNREAD
1002
+ SAVED
1003
+ ARCHIVED
1004
+ }
1005
+
1006
+ # Define the enum for responseStatus
1007
+ enum ResponseStatusType {
1008
+ PENDING
1009
+ ACCEPTED
1010
+ DENIED
925
1011
  }
926
1012
 
1013
+ # User's notification status
1014
+ type UserNotificationStatus {
1015
+ status: NotificationStatusType!
1016
+ }
1017
+
1018
+
1019
+
927
1020
  type FileCheck {
928
1021
  datasetId: String!
929
1022
  hexsha: String!
@@ -27,6 +27,11 @@ export async function createEvent(
27
27
  },
28
28
  }
29
29
  Sentry.addBreadcrumb(breadcrumb)
30
+
31
+ if (!event.datasetId) {
32
+ event.datasetId = datasetId
33
+ }
34
+
30
35
  const created = new DatasetEvent({
31
36
  datasetId,
32
37
  userId: user,