@openneuro/server 4.36.0-alpha.0 → 4.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/server",
3
- "version": "4.36.0-alpha.0",
3
+ "version": "4.36.0",
4
4
  "description": "Core service for the OpenNeuro platform.",
5
5
  "license": "MIT",
6
6
  "main": "src/server.js",
@@ -21,7 +21,7 @@
21
21
  "@elastic/elasticsearch": "8.13.1",
22
22
  "@graphql-tools/schema": "^10.0.0",
23
23
  "@keyv/redis": "^2.7.0",
24
- "@openneuro/search": "^4.36.0-alpha.0",
24
+ "@openneuro/search": "^4.36.0",
25
25
  "@sentry/node": "^8.25.0",
26
26
  "@sentry/profiling-node": "^8.25.0",
27
27
  "base64url": "^3.0.0",
@@ -86,5 +86,5 @@
86
86
  "publishConfig": {
87
87
  "access": "public"
88
88
  },
89
- "gitHead": "0d754964177331113f4279c48d6c0fae0f59adc2"
89
+ "gitHead": "ac7fa1782779cfdf1f46c9a5add8865155177b99"
90
90
  }
@@ -1,27 +1,352 @@
1
- import { vi } from "vitest"
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ } from "vitest"
10
+ import { MongoMemoryServer } from "mongodb-memory-server"
11
+ import mongoose, { Types } from "mongoose"
12
+ import User from "../../../models/user"
2
13
  import { users } from "../user.js"
14
+ import type { GraphQLContext } from "../user.js"
3
15
 
4
16
  vi.mock("ioredis")
5
17
 
18
+ let mongod: MongoMemoryServer
19
+
20
+ // Test data loaded before each test
21
+ const testUsersSeedData = [
22
+ {
23
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f1"),
24
+ id: "u1",
25
+ email: "atest1@example.com",
26
+ name: "Alice Admin",
27
+ admin: true,
28
+ blocked: false,
29
+ migrated: false,
30
+ updatedAt: new Date("2023-01-05T00:00:00.000Z"),
31
+ providerId: "0000-0000-0000-0001",
32
+ provider: "orcid",
33
+ orcid: "0000-0000-0000-0001",
34
+ },
35
+ {
36
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f2"),
37
+ id: "u2",
38
+ email: "btest2@example.com",
39
+ name: "Test User",
40
+ admin: false,
41
+ blocked: false,
42
+ migrated: false,
43
+ updatedAt: new Date("2023-01-04T00:00:00.000Z"),
44
+ providerId: "google2",
45
+ provider: "google",
46
+ },
47
+ {
48
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f3"),
49
+ id: "u3",
50
+ email: "ctest3@example.com",
51
+ name: "Test User",
52
+ admin: false,
53
+ blocked: true,
54
+ migrated: false,
55
+ updatedAt: new Date("2023-01-03T00:00:00.000Z"),
56
+ providerId: "0000-0000-0000-0003",
57
+ provider: "orcid",
58
+ },
59
+ {
60
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f4"),
61
+ id: "u4",
62
+ email: "dtest4@example.com",
63
+ name: "Test Admin User",
64
+ admin: true,
65
+ blocked: true,
66
+ migrated: false,
67
+ updatedAt: new Date("2023-01-02T00:00:00.000Z"),
68
+ providerId: "0000-0000-0000-0004",
69
+ provider: "orcid",
70
+ orcid: "0000-0000-0000-0004",
71
+ },
72
+ {
73
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f5"),
74
+ id: "u5",
75
+ email: "etest2@example.com",
76
+ name: "Test User",
77
+ admin: false,
78
+ blocked: false,
79
+ migrated: true,
80
+ updatedAt: new Date("2023-01-04T00:00:00.000Z"),
81
+ providerId: "google2",
82
+ provider: "google",
83
+ },
84
+ {
85
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f6"),
86
+ id: "u6",
87
+ email: "ftest6@example.com",
88
+ name: "Test Admin User",
89
+ admin: true,
90
+ blocked: false,
91
+ migrated: false,
92
+ updatedAt: new Date("2023-01-06T00:00:00.000Z"),
93
+ providerId: "orcid6",
94
+ provider: "orcid",
95
+ orcid: "0000-0000-0000-0006",
96
+ }, // Most recent
97
+ {
98
+ _id: new Types.ObjectId("60f0f0f0f0f0f0f0f0f0f0f7"),
99
+ id: "u7",
100
+ email: "gtest7@example.com",
101
+ name: "Test User",
102
+ admin: false,
103
+ blocked: false,
104
+ migrated: false,
105
+ updatedAt: new Date("2023-01-05T00:00:00.000Z"),
106
+ }, // Same updatedAt as u1, for secondary sort testing
107
+ ]
108
+
109
+ // Admin context for tests
110
+ const adminContext: GraphQLContext = {
111
+ userInfo: { userId: "admin-user", admin: true, username: "adminUser" },
112
+ }
113
+ // Non-admin context for tests
114
+ const nonAdminContext: GraphQLContext = {
115
+ userInfo: { userId: "normal-user", admin: false, username: "normalUser" },
116
+ }
117
+
6
118
  describe("user resolvers", () => {
119
+ beforeAll(async () => {
120
+ mongod = await MongoMemoryServer.create()
121
+ const uri = mongod.getUri()
122
+ await mongoose.connect(uri)
123
+ })
124
+
125
+ afterAll(async () => {
126
+ await mongoose.disconnect()
127
+ await mongod.stop()
128
+ })
129
+
130
+ beforeEach(async () => {
131
+ await User.deleteMany({})
132
+ await User.insertMany(testUsersSeedData)
133
+ })
134
+
7
135
  describe("users()", () => {
8
- it("throws an error for non-admins", () => {
9
- expect(
10
- users(
11
- null,
12
- { id: "3311cfe8-9764-434d-b80e-1b1ee72c686d" },
13
- { userInfo: {} },
14
- ),
15
- ).rejects.toEqual(new Error("You must be a site admin to retrieve users"))
16
- })
17
- it("throws an error for non-admins", () => {
18
- expect(
19
- users(
20
- null,
21
- { id: "0000-0000-0000-000X" },
22
- { userInfo: {} },
23
- ),
24
- ).rejects.toEqual(new Error("You must be a site admin to retrieve users"))
136
+ it("rejects data for non-admin context", async () => {
137
+ await expect(users(null, {}, nonAdminContext)).rejects.toThrow(
138
+ "You must be a site admin to retrieve users",
139
+ )
140
+ })
141
+
142
+ it("returns all non-migrated users by default, sorted by updatedAt desc then _id desc", async () => {
143
+ const result = await users(null, {}, adminContext)
144
+ // u5 is migrated. 6 non-migrated users: u1, u2, u3, u4, u6, u7
145
+ expect(result.users.length).toBe(6)
146
+ expect(result.totalCount).toBe(6)
147
+
148
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
149
+ .sort({ updatedAt: -1, _id: -1 })
150
+ .exec()
151
+ const expectedIds = dbSortedUsers.map((u) => u.id)
152
+ expect(result.users.map((u) => u.id)).toEqual(expectedIds)
153
+ // Expected order based on seed data _ids: u6, u7, u1, u2, u3, u4
154
+ // (u7 _id > u1 _id, so u7 comes before u1 in _id desc sort when updatedAt is same)
155
+ expect(expectedIds).toEqual(["u6", "u7", "u1", "u2", "u3", "u4"])
156
+ })
157
+
158
+ it("handles pagination with limit", async () => {
159
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
160
+ .sort({ updatedAt: -1, _id: -1 })
161
+ .exec()
162
+ const expectedLimitedIds = dbSortedUsers.slice(0, 2).map((u) => u.id)
163
+
164
+ const result = await users(null, { limit: 2 }, adminContext)
165
+ expect(result.users.length).toBe(2)
166
+ expect(result.totalCount).toBe(6)
167
+ expect(result.users.map((u) => u.id)).toEqual(expectedLimitedIds)
168
+ })
169
+
170
+ it("handles pagination with offset", async () => {
171
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
172
+ .sort({ updatedAt: -1, _id: -1 })
173
+ .exec()
174
+ const expectedOffsetIds = dbSortedUsers.slice(4).map((u) => u.id) // Skips 4
175
+
176
+ const result = await users(null, { offset: 4 }, adminContext)
177
+ expect(result.users.length).toBe(2) // Total 6, offset 4, so 2 remaining
178
+ expect(result.totalCount).toBe(6)
179
+ expect(result.users.map((u) => u.id)).toEqual(expectedOffsetIds) // Should be ['u3', 'u4']
180
+ })
181
+
182
+ it("handles pagination with limit and offset", async () => {
183
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
184
+ .sort({ updatedAt: -1, _id: -1 })
185
+ .exec()
186
+ const expectedPaginatedIds = dbSortedUsers.slice(1, 1 + 2).map((u) =>
187
+ u.id
188
+ ) // Skip 1, take 2
189
+
190
+ const result = await users(null, { limit: 2, offset: 1 }, adminContext)
191
+ expect(result.users.length).toBe(2)
192
+ expect(result.totalCount).toBe(6)
193
+ expect(result.users.map((u) => u.id)).toEqual(expectedPaginatedIds) // Should be ['u7', 'u1']
194
+ })
195
+
196
+ it("filters by isAdmin: true", async () => {
197
+ const result = await users(null, { isAdmin: true }, adminContext)
198
+ expect(result.users.length).toBe(3)
199
+ expect(result.totalCount).toBe(3)
200
+ const ids = result.users.map((u) => u.id)
201
+ expect(ids).toEqual(expect.arrayContaining(["u1", "u4", "u6"]))
202
+ })
203
+
204
+ it("filters by isAdmin: false", async () => {
205
+ const result = await users(null, { isAdmin: false }, adminContext)
206
+ expect(result.users.length).toBe(3)
207
+ expect(result.totalCount).toBe(3)
208
+ const ids = result.users.map((u) => u.id)
209
+ expect(ids).toEqual(expect.arrayContaining(["u2", "u3", "u7"]))
210
+ })
211
+
212
+ it("filters by isBlocked: true", async () => {
213
+ const result = await users(null, { isBlocked: true }, adminContext)
214
+ expect(result.users.length).toBe(2)
215
+ expect(result.totalCount).toBe(2)
216
+ const ids = result.users.map((u) => u.id)
217
+ expect(ids).toEqual(expect.arrayContaining(["u3", "u4"]))
218
+ })
219
+
220
+ it("filters by isBlocked: false", async () => {
221
+ const result = await users(null, { isBlocked: false }, adminContext)
222
+ // Not blocked (non-migrated): u1, u2, u6, u7 -> 4 users
223
+ expect(result.users.length).toBe(4)
224
+ expect(result.totalCount).toBe(4)
225
+ const ids = result.users.map((u) => u.id)
226
+ expect(ids).toEqual(expect.arrayContaining(["u1", "u2", "u6", "u7"]))
227
+ })
228
+
229
+ it("searches by name (case-insensitive, partial)", async () => {
230
+ const result = await users(null, { search: "Alice" }, adminContext)
231
+ expect(result.users.length).toBe(1)
232
+ expect(result.totalCount).toBe(1)
233
+ expect(result.users[0].id).toBe("u1")
234
+ })
235
+
236
+ it("searches by email (case-insensitive, partial)", async () => {
237
+ const result = await users(
238
+ null,
239
+ { search: "btest2@EXAMPLE" },
240
+ adminContext,
241
+ )
242
+ expect(result.users.length).toBe(1)
243
+ expect(result.totalCount).toBe(1)
244
+ expect(result.users[0].id).toBe("u2")
245
+ })
246
+
247
+ it("search returns empty if no match", async () => {
248
+ const result = await users(null, { search: "NoSuchUser" }, adminContext)
249
+ expect(result.users.length).toBe(0)
250
+ expect(result.totalCount).toBe(0)
251
+ })
252
+
253
+ it("combines search with other filters (e.g., isAdmin)", async () => {
254
+ await User.create({
255
+ _id: new Types.ObjectId(),
256
+ id: "u8",
257
+ email: "search_admin@example.com",
258
+ name: "Searchable Admin",
259
+ admin: true,
260
+ blocked: false,
261
+ migrated: false,
262
+ updatedAt: new Date("2023-01-07T00:00:00.000Z"),
263
+ })
264
+ // Search for "Admin" (matches u1 name, u6 name, u8 name) and isAdmin: true
265
+ const result = await users(
266
+ null,
267
+ { search: "Admin", isAdmin: true },
268
+ adminContext,
269
+ )
270
+ expect(result.users.length).toBe(4) // u1, u6, u8
271
+ expect(result.totalCount).toBe(4)
272
+ const ids = result.users.map((u) => u.id)
273
+ expect(ids).toEqual(expect.arrayContaining(["u1", "u6", "u8"]))
274
+ })
275
+
276
+ it("sorts by name ascending (with _id ascending as secondary)", async () => {
277
+ const result = await users(null, {
278
+ orderBy: [{ field: "name", order: "ascending" }],
279
+ }, adminContext)
280
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
281
+ .sort({ name: 1, _id: 1 })
282
+ .exec()
283
+ expect(result.users.map((u) => u.id)).toEqual(
284
+ dbSortedUsers.map((u) => u.id),
285
+ )
286
+ // Expected: u1, u4, u6, u2, u3, u7
287
+ expect(dbSortedUsers.map((u) => u.id)).toEqual([
288
+ "u1",
289
+ "u4",
290
+ "u6",
291
+ "u2",
292
+ "u3",
293
+ "u7",
294
+ ])
295
+ })
296
+
297
+ it("sorts by email descending (with _id descending as secondary)", async () => {
298
+ const result = await users(null, {
299
+ orderBy: [{ field: "email", order: "descending" }],
300
+ }, adminContext)
301
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
302
+ .sort({ email: -1, _id: -1 })
303
+ .exec()
304
+ expect(result.users.map((u) => u.id)).toEqual(
305
+ dbSortedUsers.map((u) => u.id),
306
+ )
307
+ // Expected: u7, u6, u4, u3, u2, u1
308
+ expect(dbSortedUsers.map((u) => u.id)).toEqual([
309
+ "u7",
310
+ "u6",
311
+ "u4",
312
+ "u3",
313
+ "u2",
314
+ "u1",
315
+ ])
316
+ })
317
+
318
+ it("handles multiple sort fields specified in orderBy (e.g. admin asc, name asc)", async () => {
319
+ const result = await users(null, {
320
+ orderBy: [
321
+ { field: "admin", order: "ascending" },
322
+ { field: "name", order: "ascending" },
323
+ ],
324
+ }, adminContext)
325
+ // Mongoose sort: { admin: 1, name: 1, _id: 1 } (since _id is added based on first sort field's order)
326
+ const dbSortedUsers = await User.find({ migrated: { $ne: true } })
327
+ .sort({ admin: 1, name: 1, _id: 1 })
328
+ .exec()
329
+ expect(result.users.map((u) => u.id)).toEqual(
330
+ dbSortedUsers.map((u) => u.id),
331
+ )
332
+ expect(dbSortedUsers.map((u) => u.id)).toEqual([
333
+ "u2",
334
+ "u3",
335
+ "u7",
336
+ "u1",
337
+ "u4",
338
+ "u6",
339
+ ])
340
+ })
341
+
342
+ it("correctly calculates totalCount regardless of pagination", async () => {
343
+ const result = await users(
344
+ null,
345
+ { isAdmin: true, limit: 1 },
346
+ adminContext,
347
+ )
348
+ expect(result.users.length).toBe(1)
349
+ expect(result.totalCount).toBe(3) // u1, u4, u6 are admins
25
350
  })
26
351
  })
27
352
  })
@@ -105,7 +105,7 @@ export const deleteComment = async (
105
105
  const CommentFields = {
106
106
  parent: (obj) => comment(obj, { id: obj.parentId }),
107
107
  replies,
108
- user: (obj) => user(obj, { id: obj.user._id }),
108
+ user: (obj) => user(obj, { id: obj.user._id }, null),
109
109
  }
110
110
 
111
111
  export default CommentFields
@@ -278,7 +278,7 @@ const worker = (obj) => getDatasetWorker(obj.id)
278
278
  * Dataset object
279
279
  */
280
280
  const Dataset = {
281
- uploader: (ds) => user(ds, { id: ds.uploader }),
281
+ uploader: (ds, _, context) => user(ds, { id: ds.uploader }, context),
282
282
  draft: async (obj) => {
283
283
  const draftHead = await datalad.getDraftHead(obj.id)
284
284
  return {
@@ -27,7 +27,7 @@ export const metadata = async (
27
27
  // TODO - This could be a user object that is resolved with the full type instead of just email
28
28
  // Email matches the existing records however and the user object would require other changes
29
29
  const adminUsers = []
30
- const { userPermissions } = await permissions(dataset)
30
+ const { userPermissions } = await permissions(dataset, null, context)
31
31
  for (const user of userPermissions) {
32
32
  if (user.level === "admin") {
33
33
  const userObj = await user.user
@@ -11,14 +11,14 @@ interface DatasetPermission {
11
11
  userPermissions: (PermissionDocument & { user: Promise<UserDocument> })[]
12
12
  }
13
13
 
14
- export async function permissions(ds): Promise<DatasetPermission> {
14
+ export async function permissions(ds, _, context): Promise<DatasetPermission> {
15
15
  const permissions = await Permission.find({ datasetId: ds.id }).exec()
16
16
  return {
17
17
  id: ds.id,
18
18
  userPermissions: permissions.map(
19
19
  (userPermission) => ({
20
20
  ...userPermission.toJSON(),
21
- user: user(ds, { id: userPermission.userId }),
21
+ user: user(ds, { id: userPermission.userId }, context),
22
22
  } as unknown as PermissionDocument & { user: Promise<UserDocument> }),
23
23
  ),
24
24
  }
@@ -28,13 +28,13 @@ const publishPermissions = async (datasetId) => {
28
28
  // Create permissionsUpdated object with DatasetPermissions in Dataset
29
29
  // and resolve all promises before publishing
30
30
  const ds = { id: datasetId }
31
- const { id, userPermissions } = await permissions(ds)
31
+ const { id, userPermissions } = await permissions(ds, null, null)
32
32
  const permissionsUpdated = {
33
33
  id,
34
34
  userPermissions: await Promise.all(
35
35
  userPermissions.map(async (userPermission) => ({
36
36
  ...userPermission,
37
- user: await user(ds, { id: userPermission.userId }),
37
+ user: await user(ds, { id: userPermission.userId }, null),
38
38
  })),
39
39
  ),
40
40
  }
@@ -2,29 +2,137 @@
2
2
  * User resolvers
3
3
  */
4
4
  import User from "../../models/user"
5
+
5
6
  function isValidOrcid(orcid: string): boolean {
6
7
  return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "")
7
8
  }
8
9
 
9
- export const user = (obj, { id }) => {
10
+ export async function user(obj, { id }, { userInfo }) {
11
+ let user
10
12
  if (isValidOrcid(id)) {
11
- return User.findOne({
13
+ user = await User.findOne({
12
14
  $or: [{ "provider": "orcid", "providerId": id }],
13
15
  }).exec()
14
16
  } else {
15
17
  // If it's not a valid ORCID, fall back to querying by user id
16
- return User.findOne({ "id": id }).exec()
18
+ user = await User.findOne({ "id": id }).exec()
19
+ }
20
+ if (userInfo?.admin || user.id === userInfo?.id) {
21
+ return user.toObject()
22
+ } else {
23
+ const obj = user.toObject()
24
+ delete obj.email
25
+ return obj
17
26
  }
18
27
  }
19
28
 
20
- export const users = (obj, args, { userInfo }) => {
21
- if (userInfo.admin) {
22
- return User.find().exec()
23
- } else {
29
+ export interface UserInfo {
30
+ userId: string
31
+ admin: boolean
32
+ username?: string
33
+ provider?: string
34
+ providerId?: string
35
+ blocked?: boolean
36
+ }
37
+
38
+ export interface GraphQLContext {
39
+ userInfo: UserInfo | null
40
+ }
41
+
42
+ type MongoOperatorValue =
43
+ | string
44
+ | number
45
+ | boolean
46
+ | RegExp
47
+ | (string | number | boolean | RegExp)[]
48
+
49
+ type MongoQueryOperator<T> = T | {
50
+ $ne?: T
51
+ $regex?: string
52
+ $options?: string
53
+ $gt?: T
54
+ $gte?: T
55
+ $lt?: T
56
+ $lte?: T
57
+ $in?: T[]
58
+ }
59
+
60
+ type MongoFilterValue =
61
+ | MongoOperatorValue
62
+ | MongoQueryOperator<MongoOperatorValue>
63
+
64
+ interface MongoQueryCondition {
65
+ [key: string]: MongoFilterValue
66
+ }
67
+ export const users = async (
68
+ obj: unknown,
69
+ { isAdmin, isBlocked, search, limit = 100, offset = 0, orderBy }: {
70
+ isAdmin?: boolean
71
+ isBlocked?: boolean
72
+ search?: string
73
+ limit?: number
74
+ offset?: number
75
+ orderBy?: { field: string; order?: "ascending" | "descending" }[]
76
+ },
77
+ context: GraphQLContext,
78
+ ) => {
79
+ // --- check admin ---
80
+ if (!context.userInfo?.admin) {
24
81
  return Promise.reject(
25
82
  new Error("You must be a site admin to retrieve users"),
26
83
  )
27
84
  }
85
+
86
+ const filter: {
87
+ admin?: MongoQueryOperator<boolean>
88
+ blocked?: MongoQueryOperator<boolean>
89
+ migrated?: MongoQueryOperator<boolean>
90
+ $or?: MongoQueryCondition[]
91
+ name?: MongoQueryOperator<string | RegExp>
92
+ email?: MongoQueryOperator<string | RegExp>
93
+ } = {}
94
+
95
+ if (isAdmin !== undefined) filter.admin = isAdmin
96
+ if (isBlocked !== undefined) filter.blocked = isBlocked
97
+
98
+ filter.migrated = { $ne: true }
99
+
100
+ if (search) {
101
+ filter.$or = [
102
+ { name: { $regex: search, $options: "i" } },
103
+ { email: { $regex: search, $options: "i" } },
104
+ ]
105
+ }
106
+
107
+ let sort: Record<string, "asc" | "desc"> = {}
108
+ if (orderBy && orderBy.length > 0) {
109
+ orderBy.forEach((sortRule) => {
110
+ sort[sortRule.field] = sortRule.order
111
+ ? sortRule.order === "ascending" ? "asc" : "desc"
112
+ : "asc"
113
+ })
114
+
115
+ if (!sort._id) {
116
+ const primarySortOrder = sort[orderBy[0].field] || "asc"
117
+ sort._id = primarySortOrder
118
+ }
119
+ } else {
120
+ sort = { updatedAt: "desc", _id: "desc" }
121
+ }
122
+
123
+ const totalCount = await User.countDocuments(filter).exec()
124
+
125
+ let query = User.find(filter)
126
+ if (offset !== undefined) query = query.skip(offset)
127
+ if (limit !== undefined) query = query.limit(limit)
128
+ query = query.sort(sort)
129
+
130
+ const users = await query.exec()
131
+
132
+ return {
133
+ users: users,
134
+ totalCount: totalCount,
135
+ }
28
136
  }
29
137
 
30
138
  export const removeUser = (obj, { id }, { userInfo }) => {
@@ -89,7 +197,6 @@ const UserResolvers = {
89
197
  avatar: (obj) => obj.avatar,
90
198
  orcid: (obj) => obj.orcid,
91
199
  created: (obj) => obj.created,
92
- modified: (obj) => obj.modified,
93
200
  lastSeen: (obj) => obj.lastSeen,
94
201
  email: (obj) => obj.email,
95
202
  name: (obj) => obj.name,
@@ -98,6 +205,7 @@ const UserResolvers = {
98
205
  location: (obj) => obj.location,
99
206
  institution: (obj) => obj.institution,
100
207
  links: (obj) => obj.links,
208
+ modified: (obj) => obj.updatedAt,
101
209
  }
102
210
 
103
211
  export default UserResolvers
@@ -26,6 +26,12 @@ export const typeDefs = `
26
26
  descending
27
27
  }
28
28
 
29
+ # Sorting order for users
30
+ input UserSortInput {
31
+ field: String!
32
+ order: SortOrdering = ascending
33
+ }
34
+
29
35
  # Sorting order for datasets
30
36
  input DatasetSort {
31
37
  # Dataset created time
@@ -83,7 +89,14 @@ export const typeDefs = `
83
89
  # Get one user
84
90
  user(id: ID!): User
85
91
  # Get a list of users
86
- users: [User]
92
+ users(
93
+ orderBy: [UserSortInput!]
94
+ isAdmin: Boolean
95
+ isBlocked: Boolean
96
+ search: String
97
+ limit: Int
98
+ offset: Int
99
+ ): UserList!
87
100
  # Get the total number of dataset participants
88
101
  participantCount(modality: String): Int @cacheControl(maxAge: 3600, scope: PUBLIC)
89
102
  # Request one snapshot
@@ -335,6 +348,11 @@ export const typeDefs = `
335
348
  links: [String]
336
349
  }
337
350
 
351
+ type UserList {
352
+ users: [User!]!
353
+ totalCount: Int!
354
+ }
355
+
338
356
  # Which provider a user login comes from
339
357
  enum UserProvider {
340
358
  google
@@ -2,6 +2,7 @@ import passport from "passport"
2
2
  import { parsedJwtFromRequest } from "./jwt"
3
3
  import * as Sentry from "@sentry/node"
4
4
  import { userMigration } from "./user-migration"
5
+ import User from "../../models/user"
5
6
 
6
7
  export const requestAuth = passport.authenticate("orcid", {
7
8
  session: false,
@@ -29,7 +30,7 @@ export function completeRequestLogin(req, res, next, user) {
29
30
  }
30
31
 
31
32
  export const authCallback = (req, res, next) =>
32
- passport.authenticate("orcid", (err, user) => {
33
+ passport.authenticate("orcid", async (err, user) => {
33
34
  if (err) {
34
35
  Sentry.captureException(err)
35
36
  if (err.type) {
@@ -41,6 +42,19 @@ export const authCallback = (req, res, next) =>
41
42
  if (!user) {
42
43
  return res.redirect("/")
43
44
  }
45
+
46
+ try {
47
+ // adds new date for login/lastSeen
48
+ await User.findByIdAndUpdate(user._id, { lastSeen: new Date() })
49
+ } catch (error: unknown) {
50
+ if (error instanceof Error) {
51
+ Sentry.captureException(error)
52
+ } else {
53
+ Sentry.captureException(new Error(String(error)))
54
+ }
55
+ // Don't block the login flow
56
+ }
57
+
44
58
  // Google user
45
59
  const existingAuth = parsedJwtFromRequest(req)
46
60
  if (existingAuth) {
@@ -30,6 +30,7 @@ export interface UserDocument extends Document {
30
30
  created: Date
31
31
  // Last time the user authenticated
32
32
  lastSeen: Date
33
+ // Location populated from User or Github
33
34
  location: string
34
35
  // Institution populated from ORCID
35
36
  institution: string
@@ -37,7 +38,11 @@ export interface UserDocument extends Document {
37
38
  github: string
38
39
  // User profile links
39
40
  links: string[]
41
+ // Added for Mongoose timestamps
42
+ updatedAt: Date
43
+ // Avatar populated from Github
40
44
  avatar: string
45
+ // githubSynced populated from Github OAuth use
41
46
  githubSynced: Date
42
47
  }
43
48
 
@@ -61,7 +66,7 @@ const userSchema = new Schema({
61
66
  github: { type: String, default: "" },
62
67
  githubSynced: { type: Date },
63
68
  links: { type: [String], default: [] },
64
- })
69
+ }, { timestamps: { createdAt: false, updatedAt: true } })
65
70
 
66
71
  userSchema.index({ id: 1, provider: 1 }, { unique: true })
67
72
  // Allow case-insensitive email queries