404: The page you are looking for does not exist.
{message && {message}
}
diff --git a/packages/openneuro-app/src/scripts/index.tsx b/packages/openneuro-app/src/scripts/index.tsx
index 630d9f811..264379a0b 100644
--- a/packages/openneuro-app/src/scripts/index.tsx
+++ b/packages/openneuro-app/src/scripts/index.tsx
@@ -1,37 +1,54 @@
import React, { useEffect } from "react"
-import Uploader from "./uploader/uploader.jsx"
+import { useLocation, useNavigate } from "react-router-dom"
+import Uploader from "./uploader/uploader"
import AppRoutes from "./routes"
import HeaderContainer from "./common/containers/header"
import FooterContainer from "./common/containers/footer"
import { SearchParamsProvider } from "./search/search-params-ctx"
import { UserModalOpenProvider } from "./utils/user-login-modal-ctx"
import { useAnalytics } from "./utils/analytics"
-import { useLocation, useNavigate } from "react-router-dom"
+import { useUser } from "./queries/user"
+import { NotificationsProvider } from "./users/user-notifications-context"
import "../assets/email-header.png"
-import { useUser } from "./queries/user.js"
-const Index = (): React.ReactElement => {
+const Index: React.FC = () => {
useAnalytics()
- // Redirect authenticated Google users to the migration step if they are in any other route
+
const navigate = useNavigate()
const location = useLocation()
const { user, loading, error } = useUser()
+
useEffect(() => {
if (
- !loading && !error && location.pathname !== "/orcid-link" &&
+ !loading &&
+ !error &&
+ location.pathname !== "/orcid-link" &&
user?.provider === "google"
) {
navigate("/orcid-link")
}
- }, [location.pathname, user])
+ }, [location.pathname, user, loading, error, navigate])
+
+ if (loading || error) return null
+
+ const initialNotifications = user?.notifications?.map((n) => ({
+ id: n.id,
+ status: Array.isArray(n.notificationStatus)
+ ? n.notificationStatus.map((ns) => ns.status.toLowerCase())[0] || "unread"
+ : n.notificationStatus?.status?.toLowerCase() || "unread",
+ ...n,
+ })) || []
+
return (
-
+
+
+
diff --git a/packages/openneuro-app/src/scripts/queries/datasetEvents.ts b/packages/openneuro-app/src/scripts/queries/datasetEvents.ts
new file mode 100644
index 000000000..e4f5ea478
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/queries/datasetEvents.ts
@@ -0,0 +1,119 @@
+import { gql } from "@apollo/client"
+
+export const GET_DATASET_EVENTS = gql`
+ query GetDatasetEvents($datasetId: ID!) {
+ dataset(id: $datasetId) {
+ events {
+ id
+ note
+ success
+ timestamp
+ user {
+ email
+ name
+ orcid
+ id
+ }
+ event {
+ type
+ requestId
+ status
+ targetUserId
+ resolutionStatus
+ }
+ hasBeenRespondedTo
+ responseStatus
+ }
+ }
+ }
+`
+
+export const SAVE_ADMIN_NOTE_MUTATION = gql`
+ mutation SaveAdminNote($datasetId: ID!, $note: String!) {
+ saveAdminNote(datasetId: $datasetId, note: $note) {
+ note
+ }
+ }
+`
+
+export const UPDATE_ADMIN_NOTE_MUTATION = gql`
+ mutation SaveAdminNote(
+ $note: String!
+ $datasetId: ID!
+ $saveAdminNoteId: ID
+ ) {
+ saveAdminNote(note: $note, datasetId: $datasetId, id: $saveAdminNoteId) {
+ id
+ note
+ }
+ }
+`
+
+export const PROCESS_CONTRIBUTOR_REQUEST_MUTATION = gql`
+ mutation ProcessContributorRequest(
+ $datasetId: ID!
+ $requestId: ID!
+ $targetUserId: ID!
+ $status: String!
+ $reason: String
+ ) {
+ processContributorRequest(
+ datasetId: $datasetId
+ requestId: $requestId
+ targetUserId: $targetUserId
+ status: $status
+ reason: $reason
+ ) {
+ id
+ event {
+ type
+ status
+ requestId
+ }
+ note
+ }
+ }
+`
+
+export const UPDATE_NOTIFICATION_STATUS_MUTATION = gql`
+ mutation UpdateEventStatus($eventId: ID!, $status: NotificationStatusType!) {
+ updateEventStatus(eventId: $eventId, status: $status) {
+ status
+ }
+ }
+`
+
+export const CREATE_CONTRIBUTOR_REQUEST_EVENT = gql`
+ mutation CreateContributorRequestEvent($datasetId: ID!) {
+ createContributorRequestEvent(datasetId: $datasetId) {
+ id
+ timestamp
+ event {
+ type
+ }
+ success
+ note
+ }
+ }
+`
+
+export const DATASET_EVENTS_QUERY = gql`
+ query DatasetEvents($datasetId: ID!) {
+ dataset(id: $datasetId) {
+ id
+ events {
+ id
+ timestamp
+ user {
+ id
+ name
+ }
+ event {
+ type
+ }
+ success
+ note
+ }
+ }
+ }
+`
diff --git a/packages/openneuro-app/src/scripts/queries/user.ts b/packages/openneuro-app/src/scripts/queries/user.ts
index fcf8a53d1..e0d7ed19b 100644
--- a/packages/openneuro-app/src/scripts/queries/user.ts
+++ b/packages/openneuro-app/src/scripts/queries/user.ts
@@ -3,7 +3,7 @@ import { useCookies } from "react-cookie"
import { getProfile } from "../authentication/profile"
import * as Sentry from "@sentry/react"
-// GraphQL query to fetch user data
+// GraphQL query to fetch detailed user information including nested notifications and event metadata
export const GET_USER = gql`
query User($userId: ID!) {
user(id: $userId) {
@@ -22,6 +22,41 @@ export const GET_USER = gql`
blocked
githubSynced
github
+ notifications {
+ id
+ timestamp
+ note
+ success
+ user {
+ id
+ name
+ email
+ orcid
+ }
+ event {
+ type
+ version
+ public
+ level
+ ref
+ message
+ requestId
+ targetUserId
+ status
+ reason
+ datasetId
+ resolutionStatus
+ target {
+ id
+ name
+ email
+ orcid
+ }
+ }
+ notificationStatus {
+ status
+ }
+ }
orcidConsent
}
}
@@ -175,13 +210,14 @@ export const useUser = (userId?: string) => {
const finalUserId = userId || profileSub
- const { data: userData, loading: userLoading, error: userError } = useQuery(
- GET_USER,
- {
- variables: { userId: finalUserId },
- skip: !finalUserId,
- },
- )
+ const {
+ data: userData,
+ loading: userLoading,
+ error: userError,
+ } = useQuery(GET_USER, {
+ variables: { userId: finalUserId },
+ skip: !finalUserId,
+ })
if (userError) {
Sentry.captureException(userError)
diff --git a/packages/openneuro-app/src/scripts/scss/dataset/dataset-page.scss b/packages/openneuro-app/src/scripts/scss/dataset/dataset-page.scss
index 01b5a1804..13ff7e5c6 100644
--- a/packages/openneuro-app/src/scripts/scss/dataset/dataset-page.scss
+++ b/packages/openneuro-app/src/scripts/scss/dataset/dataset-page.scss
@@ -172,8 +172,15 @@
}
}
}
-
+.sidebar .request-contributor-button{
+ position: absolute;
+ top: 0;
+ right: 0;
+ font-size: 12px;
+ padding: 3px 10px;
+}
.sidebar .dataset-meta-block {
+ position: relative;
margin-bottom: 25px;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji;
diff --git a/packages/openneuro-app/src/scripts/search/components/SearchResultDetails.tsx b/packages/openneuro-app/src/scripts/search/components/SearchResultDetails.tsx
index 07c9df511..0468c9df7 100644
--- a/packages/openneuro-app/src/scripts/search/components/SearchResultDetails.tsx
+++ b/packages/openneuro-app/src/scripts/search/components/SearchResultDetails.tsx
@@ -7,8 +7,7 @@ import { Link } from "react-router-dom"
import type { SearchResultItemProps } from "./SearchResultItem"
import { ModalityLabel } from "../../components/formatting/modality-label"
import { MetaListItemList } from "./MetaListItemList"
-import { CreatorListDisplay } from "../../users/creators-list"
-import { ContributorsListDisplay } from "../../users/contributors-list"
+import { ContributorsListDisplay } from "../../contributors/contributors-list"
import "../scss/search-result-details.scss"
interface SearchResultDetailsProps {
@@ -128,13 +127,6 @@ export const SearchResultDetails: FC = (
{itemData?.id}
,
)
- const creators = renderMetaItem(
- "Creators",
- ,
- )
const contributors = renderMetaItem(
"Contributors",
@@ -163,7 +155,6 @@ export const SearchResultDetails: FC = (
×
{moreDetailsHeader}
- {creators}
{contributors}
{modalityList}
{taskList}
diff --git a/packages/openneuro-app/src/scripts/search/components/SearchResultItem.tsx b/packages/openneuro-app/src/scripts/search/components/SearchResultItem.tsx
index 01a485398..9ca970a95 100644
--- a/packages/openneuro-app/src/scripts/search/components/SearchResultItem.tsx
+++ b/packages/openneuro-app/src/scripts/search/components/SearchResultItem.tsx
@@ -9,7 +9,7 @@ import "../scss/search-result.scss"
import activityPulseIcon from "../../../assets/activity-icon.png"
import { hasEditPermissions } from "../../authentication/profile"
import { ModalityHexagon } from "../../components/modality-cube/ModalityHexagon"
-import type { Contributor, Creator } from "../../types/datacite"
+import type { Contributor } from "../../types/datacite"
import { SearchResultsCitation } from "../../components/citation/search-results-citation"
export const formatDate = (dateObject) =>
@@ -87,7 +87,6 @@ export interface SearchResultItemProps {
Name: string
DatasetDOI: string
}
- creators: Creator[]
contributors: Contributor[]
}
analytics: {
diff --git a/packages/openneuro-app/src/scripts/search/use-search-results.tsx b/packages/openneuro-app/src/scripts/search/use-search-results.tsx
index af086a05e..89925a704 100644
--- a/packages/openneuro-app/src/scripts/search/use-search-results.tsx
+++ b/packages/openneuro-app/src/scripts/search/use-search-results.tsx
@@ -98,12 +98,6 @@ const searchQuery = gql`
Authors
DatasetDOI
}
- creators {
- name
- givenName
- familyName
- orcid
- }
contributors {
name
givenName
@@ -182,7 +176,6 @@ export const useSearchResults = () => {
"latestSnapshot.readme",
"latestSnapshot.description.Name^6",
"latestSnapshot.description.Authors^3", // TODO: Nell - do we need this still?
- "latestSnapshot.creators.name^3",
"latestSnapshot.contributors.name^2",
]),
)
@@ -278,13 +271,8 @@ export const useSearchResults = () => {
]),
)
}
- if (authors.length) { // TODO - NELL - this was switched to creators - is that correct?
+ if (authors.length) { // TODO - NELL - does this look right?
const authorQuery = matchQuery(
- "latestSnapshot.creators.name",
- joinWithOR(authors),
- "2",
- )
- const contributorQuery = matchQuery(
"latestSnapshot.contributors.name",
joinWithOR(authors),
"2",
@@ -293,7 +281,7 @@ export const useSearchResults = () => {
"must",
{
bool: {
- should: [authorQuery, contributorQuery],
+ should: [authorQuery],
},
},
)
diff --git a/packages/openneuro-app/src/scripts/types/datacite.ts b/packages/openneuro-app/src/scripts/types/datacite.ts
index fd2c24010..3e029064a 100644
--- a/packages/openneuro-app/src/scripts/types/datacite.ts
+++ b/packages/openneuro-app/src/scripts/types/datacite.ts
@@ -11,4 +11,5 @@ export interface Contributor {
familyName?: string
orcid?: string
contributorType?: string
+ order?: number
}
diff --git a/packages/openneuro-app/src/scripts/types/event-types.ts b/packages/openneuro-app/src/scripts/types/event-types.ts
new file mode 100644
index 000000000..ebe8b5671
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/types/event-types.ts
@@ -0,0 +1,120 @@
+import type { User } from "./user-types"
+
+export type RequestStatus = "pending" | "accepted" | "denied"
+
+export interface EventDescription {
+ type: string
+ targetUserId?: string
+ status?: RequestStatus
+ requestId?: string
+ message?: string
+ reason?: string
+ datasetId?: string
+ resolutionStatus?: RequestStatus
+ target?: User
+ version?: string
+ public?: boolean
+ level?: string
+ ref?: string
+}
+
+export interface Event {
+ id: string
+ timestamp: string
+ note?: string
+ success?: boolean
+ event: EventDescription
+ user?: User
+ hasBeenRespondedTo?: boolean
+ responseStatus?: string
+ dataset?: {
+ id: string
+ name?: string
+ }
+ datasetId?: string
+ notificationStatus?: {
+ status: "UNREAD" | "SAVED" | "ARCHIVED"
+ }
+}
+
+export interface MappedNotification {
+ id: string
+ title: string
+ content: string
+ status: "unread" | "saved" | "archived"
+ type: "general" | "approval" | "response"
+ approval?: "pending" | "accepted" | "denied"
+ originalNotification: Event
+ datasetId?: string
+ requestId?: string
+ targetUserId?: string
+ requesterUser?: User
+ adminUser?: User
+ reason?: string
+}
+
+export const mapRawEventToMappedNotification = (
+ rawNotification: Event,
+): MappedNotification => {
+ const { event, note, user, dataset, datasetId: rawDatasetId } =
+ rawNotification
+ const {
+ type,
+ resolutionStatus,
+ status: eventStatus,
+ requestId,
+ targetUserId,
+ reason,
+ } = event
+
+ let title = note || "General Notification"
+ let mappedType: MappedNotification["type"] = "general"
+ let approval: MappedNotification["approval"]
+ let requesterUser: User | undefined
+ let adminUser: User | undefined
+
+ switch (type) {
+ case "contributorRequest":
+ title = "Contributor Request for Dataset"
+ mappedType = "approval"
+ approval = resolutionStatus ?? "pending"
+ requesterUser = user
+ break
+ case "contributorResponse":
+ title = `Contributor ${eventStatus} for Dataset`
+ mappedType = "response"
+ approval = eventStatus as "accepted" | "denied"
+ adminUser = user
+ break
+ case "note":
+ title = note || "Admin Note on Dataset"
+ break
+ default:
+ title = note || `Dataset ${type || "Unknown Type"}`
+ break
+ }
+
+ const datasetId = dataset?.id || rawDatasetId || event.datasetId || ""
+
+ const notificationStatus =
+ (rawNotification.notificationStatus?.status?.toLowerCase() as
+ | "unread"
+ | "saved"
+ | "archived") ?? "unread"
+
+ return {
+ id: rawNotification.id,
+ title,
+ content: note || "",
+ status: notificationStatus,
+ type: mappedType,
+ approval,
+ datasetId,
+ requestId,
+ targetUserId: targetUserId || user?.id,
+ originalNotification: rawNotification,
+ requesterUser,
+ adminUser,
+ reason,
+ }
+}
diff --git a/packages/openneuro-app/src/scripts/types/user-types.ts b/packages/openneuro-app/src/scripts/types/user-types.ts
index d4a0fdd6a..bf511595f 100644
--- a/packages/openneuro-app/src/scripts/types/user-types.ts
+++ b/packages/openneuro-app/src/scripts/types/user-types.ts
@@ -1,3 +1,6 @@
+import type { Event, MappedNotification } from "./event-types"
+
+/** ------------------ User ------------------ */
export interface User {
id: string
name: string
@@ -15,6 +18,7 @@ export interface User {
provider?: string
modified?: string
githubSynced?: Date
+ notifications?: Event[]
orcidConsent?: boolean | null
}
@@ -31,6 +35,7 @@ export interface UserAccountViewProps {
orcidUser: User
}
+/** ------------------ Dataset ------------------ */
export interface Dataset {
id: string
created: string
@@ -40,28 +45,52 @@ export interface Dataset {
views: number
downloads: number
}
- stars?: { userId: string; datasetId: string }[]
- followers?: { userId: string; datasetId: string }[]
- latestSnapshot?: {
- id: string
- size: number
- issues: { severity: string }[]
- created?: string
- description?: {
- Authors: string[]
- DatasetDOI?: string | null
- Name: string
- }
- summary?: {
- primaryModality?: string
- }
+ stars?: DatasetUserRelation[]
+ followers?: DatasetUserRelation[]
+ latestSnapshot?: DatasetSnapshot
+ draft?: DatasetDraft
+}
+
+export interface DatasetUserRelation {
+ userId: string
+ datasetId: string
+}
+
+export interface DatasetSnapshot {
+ id: string
+ size: number
+ issues: { severity: string }[]
+ created?: string
+ description?: {
+ Authors: string[]
+ DatasetDOI?: string | null
+ Name: string
}
- draft?: {
- size?: number
- created?: string
+ summary?: {
+ primaryModality?: string
}
}
+export interface DatasetDraft {
+ size?: number
+ created?: string
+}
+
+/** ------------------ User Routes / Pages ------------------ */
+export interface UserRoutesProps {
+ orcidUser: User
+ hasEdit: boolean
+ isUser: boolean
+}
+
+export interface UserCardProps {
+ orcidUser: User
+}
+
+export interface UserAccountViewProps {
+ orcidUser: User
+}
+
export interface DatasetCardProps {
dataset: Dataset
hasEdit: boolean
@@ -72,8 +101,21 @@ export interface UserDatasetsViewProps {
hasEdit: boolean
}
+export interface UserNotificationsViewProps {
+ orcidUser: User
+}
+
export interface AccountContainerProps {
orcidUser: User
hasEdit: boolean
isUser: boolean
}
+
+/** ------------------ Outlet Context ------------------ */
+export type OutletContextType = {
+ notifications: MappedNotification[]
+ handleUpdateNotification: (
+ id: string,
+ updates: Partial,
+ ) => void
+}
diff --git a/packages/openneuro-app/src/scripts/users/__tests__/creator.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/creator.spec.tsx
deleted file mode 100644
index daaf5e268..000000000
--- a/packages/openneuro-app/src/scripts/users/__tests__/creator.spec.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import React from "react"
-import { render, screen } from "@testing-library/react"
-import { MemoryRouter } from "react-router-dom"
-import { MockedProvider } from "@apollo/client/testing"
-import { vi } from "vitest"
-import { SingleCreatorDisplay } from "../creator"
-
-// --- Mock Dependencies ---
-vi.mock("../../queries/user", () => ({
- useUser: vi.fn(() => ({
- user: null,
- loading: false,
- error: undefined,
- })),
-}))
-
-import { useUser } from "../../queries/user"
-
-vi.mock("../../assets/ORCIDiD_iconvector.svg", () => ({
- default: "mock-orcid-logo.svg",
-}))
-
-describe("SingleCreatorDisplay - Basic Loading", () => {
- const renderComponent = (props: {
- creator: { name?: string; orcid?: string | null }
- isLast?: boolean
- separator?: React.ReactNode
- }) => {
- return render(
-
-
-
-
- ,
- )
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- vi.mocked(useUser).mockImplementation((userId) => {
- if (userId) {
- return {
- user: { id: userId, name: `Mock User ${userId}` },
- loading: false,
- error: undefined,
- }
- }
- return { user: null, loading: false, error: undefined }
- })
- })
-
- it("renders the component and displays the creator name", async () => {
- const creator = { name: "Jane Doe", orcid: null }
- renderComponent({ creator })
- expect(screen.getByText("Jane Doe")).toBeInTheDocument()
- })
-
- it("renders 'Unknown Creator' if the creator name is missing", async () => {
- const creator = { name: undefined, orcid: null }
- renderComponent({ creator })
- expect(screen.getByText("Unknown Creator")).toBeInTheDocument()
- })
-
- it("renders the component and displays the ORCID link if an ID is provided", async () => {
- const testOrcid = "0000-0000-0000-0000"
- const creator = { name: "Author With ORCID", orcid: testOrcid }
- renderComponent({ creator })
- const orcidLink = screen.getByLabelText(
- `ORCID profile for ${creator.name}`,
- )
- expect(orcidLink).toBeInTheDocument()
- expect(orcidLink).toHaveAttribute(
- "href",
- expect.stringContaining(testOrcid),
- )
- })
-})
diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-query.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-query.spec.tsx
index c42bae26d..5af762131 100644
--- a/packages/openneuro-app/src/scripts/users/__tests__/user-query.spec.tsx
+++ b/packages/openneuro-app/src/scripts/users/__tests__/user-query.spec.tsx
@@ -1,137 +1,93 @@
import { vi } from "vitest"
import React from "react"
-import { render, screen } from "@testing-library/react"
-import { MockedProvider } from "@apollo/client/testing"
+import { cleanup, render, screen } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
-
-// Component
+import { MockedProvider } from "@apollo/client/testing"
import { UserQuery } from "../user-query"
-import { isValidOrcid } from "../../utils/validationUtils"
-import { getProfile } from "../../authentication/profile"
-import { isAdmin } from "../../authentication/admin-user"
-import { useCookies } from "react-cookie"
-import { useUser } from "../../queries/user"
-import type { User } from "../../types/user-types"
-import type { UserRoutesProps } from "../../types/user-types"
-
-// --- generate a random valid ORCID - maybe unnecessary ---
-const generateRandomValidOrcid = (): string => {
- const segments = Array.from(
- { length: 4 },
- () => Math.floor(1000 + Math.random() * 9000).toString(),
- )
- return segments.join("-")
-}
-
-vi.mock("./user-routes", () => ({
- UserRoutes: vi.fn((props: UserRoutesProps) => (
-
- Mocked UserRoutes Component
-
User ORCID: {props.orcidUser?.orcid}
-
Has Edit: {props.hasEdit ? "true" : "false"}
-
Is User: {props.isUser ? "true" : "false"}
-
- )),
-}))
+import { GET_USER } from "../../queries/user"
+
+// Declare a mock function for the useUser hook
+const mockUseUser = vi.fn()
-vi.mock("../../utils/validationUtils", () => ({
- isValidOrcid: vi.fn(),
+vi.mock("../queries/user", () => ({
+ useUser: mockUseUser,
}))
+// Mock dependencies
vi.mock("react-cookie", () => ({
- useCookies: vi.fn(),
+ useCookies: vi.fn(() => [{}, vi.fn()]),
}))
-vi.mock("../../authentication/profile", () => ({
- getProfile: vi.fn(),
+vi.mock("../authentication/profile", () => ({
+ getProfile: vi.fn(() => ({ sub: "0000-0000-0000-0000" })),
}))
-vi.mock("../../authentication/admin-user", () => ({
- isAdmin: vi.fn(),
+vi.mock("../authentication/admin-user", () => ({
+ isAdmin: vi.fn(() => false),
}))
-vi.mock("../../queries/user", () => ({
- useUser: vi.fn(),
- ADVANCED_SEARCH_DATASETS_QUERY: {
- kind: "Document",
- definitions: [
- {
- kind: "OperationDefinition",
- operation: "query",
- name: { kind: "Name", value: "AdvancedSearchDatasets" },
- variableDefinitions: [],
- selectionSet: { kind: "SelectionSet", selections: [] },
- },
- ],
- loc: {
- start: 0,
- end: 0,
- source: {
- body: "",
- name: "Mocked",
- locationOffset: { line: 1, column: 1 },
- },
- },
- },
+vi.mock("../utils/validationUtils", () => ({
+ isValidOrcid: vi.fn((orcid: string) => orcid === "0000-0000-0000-0000"),
}))
-export interface OpenNeuroTokenProfile {
- sub: string
- admin: boolean
- iat: number
- exp: number
- scopes?: string[]
-}
-
-describe("UserQuery component - Dynamic ORCID Loading", () => {
- const mockedIsValidOrcid = vi.mocked(isValidOrcid)
- const mockedGetProfile = vi.mocked(getProfile)
- const mockedIsAdmin = vi.mocked(isAdmin)
- const mockedUseCookies = vi.mocked(useCookies)
- const mockedUseUser = vi.mocked(useUser)
+vi.mock("../user-routes", () => ({
+ UserRoutes: vi.fn((props) => (
+
+ Mocked UserRoutes
+
{JSON.stringify(props, null, 2)}
+
+ )),
+}))
- let testOrcid: string
+vi.mock("../errors/404page", () => ({
+ default: vi.fn(() => Mocked 404 Page
),
+}))
- beforeEach(() => {
+describe("UserQuery component - Dynamic ORCID Loading", () => {
+ afterEach(() => {
+ cleanup()
vi.clearAllMocks()
- vi.resetAllMocks()
- mockedIsValidOrcid.mockReturnValue(true)
-
- mockedGetProfile.mockReturnValue({
- sub: "11111",
- admin: false,
- iat: Date.now(),
- exp: Date.now() + (1000 * 60 * 60),
- scopes: [],
- })
-
- mockedIsAdmin.mockReturnValue(false)
- mockedUseCookies.mockReturnValue([{}, vi.fn(), vi.fn()])
- testOrcid = generateRandomValidOrcid()
-
- mockedUseUser.mockImplementation((orcidParam: string) => {
- const dynamicUser: User = {
- id: orcidParam,
- name: `Mock User for ${orcidParam}`,
- location: "Dynamic Location",
- institution: "Dynamic Institution",
- email: `${orcidParam}@example.com`,
- avatar: "https://dummyimage.com/200x200/000/fff",
- orcid: orcidParam,
- links: [],
- }
- return {
- user: dynamicUser,
- loading: false,
- error: undefined,
- }
- })
})
+ // Define mocks for the MockedProvider
+ const mocks = [
+ {
+ request: {
+ query: GET_USER,
+ variables: { userId: "0000-0000-0000-0000" },
+ },
+ result: {
+ data: {
+ user: {
+ id: "0000-0000-0000-0000",
+ name: "Test User",
+ orcid: "0000-0000-0000-0000",
+ email: "test@example.com",
+ avatar: null,
+ location: null,
+ institution: null,
+ links: [],
+ provider: null,
+ admin: false,
+ created: "2020-01-01",
+ lastSeen: "2024-01-01",
+ blocked: false,
+ githubSynced: false,
+ github: null,
+ notifications: [],
+ __typename: "User",
+ },
+ },
+ },
+ },
+ ]
+
it("loads the page and displays user data for a valid ORCID from the URL", async () => {
+ const orcidFromUrl = "0000-0000-0000-0000"
+
render(
-
-
+
+
} />
@@ -139,28 +95,32 @@ describe("UserQuery component - Dynamic ORCID Loading", () => {
,
)
- expect(screen.getByText(`${testOrcid}`)).toBeInTheDocument()
+ const userRoutesComponent = await screen.findByTestId("mock-user-routes")
+ expect(userRoutesComponent).toBeInTheDocument()
+
+ const propsElement = screen.getByTestId("user-routes-props")
+ expect(propsElement).toHaveTextContent(/"id": "0000-0000-0000-0000"/)
+ expect(propsElement).toHaveTextContent(/"name": "Test User"/)
})
it("displays 404 if the ORCID in the URL is invalid", async () => {
- const invalidOrcid = "invalid-orcid-string"
-
- mockedUseUser.mockReturnValue({
- user: undefined,
+ mockUseUser.mockReturnValue({
+ user: null,
loading: false,
- error: undefined,
+ error: new Error("User not found"),
})
+
render(
-
-
+
+
- } />
+ } />
,
)
- expect(
- screen.getByText(/404: The page you are looking for does not exist./i),
- ).toBeInTheDocument()
+
+ const fourOFourPage = await screen.findByTestId("404-page")
+ expect(fourOFourPage).toBeInTheDocument()
})
})
diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx
index 123816a2c..1eb231194 100644
--- a/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx
+++ b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx
@@ -1,56 +1,125 @@
-import { vi } from "vitest"
import React from "react"
import { cleanup, render, screen } from "@testing-library/react"
-import { MemoryRouter } from "react-router-dom"
+import { vi } from "vitest"
+import { MemoryRouter, Outlet } from "react-router-dom"
import { MockedProvider } from "@apollo/client/testing"
-
-// Component under test
import { UserRoutes } from "../user-routes"
-
-// Types and Queries
-import type { User } from "../../types/user-types"
+import type { Event, MappedNotification } from "../../types/event-types"
+import type { OutletContextType, User } from "../../types/user-types"
import { ADVANCED_SEARCH_DATASETS_QUERY, GET_USER } from "../../queries/user"
+// A minimal test user object, replacing the need for an external "testUser" import
+const testUser = {
+ id: "1",
+ name: "John Doe",
+ location: "Unknown",
+ github: "",
+ institution: "Unknown Institution",
+ email: "john.doe@example.com",
+ avatar: "https://dummyimage.com/200x200/000/fff",
+ orcid: "0000-0000-0000-0000",
+ links: [],
+ notifications: [],
+}
+
+const setupUserRoutes = (
+ orcidUser: User,
+ route: string,
+ hasEdit: boolean,
+ isUser: boolean,
+) => {
+ const mocks = [
+ {
+ request: {
+ query: ADVANCED_SEARCH_DATASETS_QUERY,
+ variables: {
+ first: 26,
+ query: {
+ bool: {
+ filter: [
+ {
+ terms: {
+ "permissions.userPermissions.user.id": [orcidUser.id],
+ },
+ },
+ ],
+ must: [{ match_all: {} }],
+ },
+ },
+ sortBy: null,
+ cursor: null,
+ allDatasets: true,
+ datasetStatus: undefined,
+ },
+ },
+ result: {
+ data: {
+ datasets: {
+ edges: [],
+ },
+ },
+ },
+ },
+ {
+ request: {
+ query: GET_USER,
+ variables: { userId: orcidUser.id },
+ },
+ result: {
+ data: {
+ user: orcidUser,
+ },
+ },
+ },
+ ]
+
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+// --- Component Mocks ---
+
+vi.mock("../../config", () => ({
+ config: {
+ url: "https://test-server.com",
+ api: "https://test-server.com/crn/",
+ graphql: {
+ api_url: "https://test-server.com/crn/graphql",
+ subscription_url: "wss://test-server.com/crn/graphql",
+ },
+ },
+}))
+
+vi.mock("../username", () => ({
+ default: vi.fn((props) => (
+ {props.children}
+ )),
+}))
+
vi.mock("./user-container", () => {
return {
UserAccountContainer: vi.fn((props) => (
- Mocked UserAccountContainer
{props.children}
-
Container ORCID: {props.orcidUser?.orcid}
-
Container Has Edit: {props.hasEdit ? "true" : "false"}
-
Container Is User: {props.isUser ? "true" : "false"}
)),
}
})
vi.mock("./user-account-view", () => ({
- UserAccountView: vi.fn((props) => (
-
- Mocked UserAccountView
-
View ORCID: {props.orcidUser?.orcid}
-
- )),
-}))
-
-vi.mock("./user-notifications-view", () => ({
- UserNotificationsView: vi.fn((props) => (
-
- Mocked UserNotificationsView
- {props.children}
-
Notifications ORCID: {props.orcidUser?.orcid}
-
+ UserAccountView: vi.fn(() => (
+ Mocked UserAccountView
)),
}))
vi.mock("./user-datasets-view", () => ({
- UserDatasetsView: vi.fn((props) => (
-
- Mocked UserDatasetsView
-
Datasets ORCID: {props.orcidUser?.orcid}
-
Datasets Has Edit: {props.hasEdit ? "true" : "false"}
-
+ UserDatasetsView: vi.fn(() => (
+ Mocked UserDatasetsView
)),
}))
@@ -70,127 +139,90 @@ vi.mock("../errors/403page", () => ({
)),
}))
+// Mock the notification tab content
vi.mock("./user-notifications-tab-content", () => ({
UnreadNotifications: vi.fn(() => (
- Unread Notifications
+ Unread Notifications Content
)),
SavedNotifications: vi.fn(() => (
- Saved Notifications
+ Saved Notifications Content
)),
ArchivedNotifications: vi.fn(() => (
- Archived Notifications
+
+ Archived Notifications Content
+
)),
}))
-const defaultUser: User = {
- id: "1",
- name: "John Doe",
- location: "Unknown",
- github: "",
- institution: "Unknown Institution",
- email: "john.doe@example.com",
- avatar: "https://dummyimage.com/200x200/000/fff",
- orcid: "0000-0000-0000-0000", // Ensure ORCID is present for mocks
- links: [],
-}
+// Mock the UserNotificationsView
+vi.mock("./user-notifications-view", () => {
+ const baseDatasetEvent: Event = {
+ id: "1",
+ timestamp: "2023-01-01T12:00:00Z",
+ event: { type: "published", message: "A dataset has been published." },
+ // The status field is nested here now
+ notificationStatus: { status: "UNREAD" },
+ }
-const mocks = [
- {
- request: {
- query: ADVANCED_SEARCH_DATASETS_QUERY,
- variables: {
- first: 26,
- query: {
- bool: {
- filter: [
- {
- terms: {
- "permissions.userPermissions.user.id": [defaultUser.id],
- },
- },
- ],
- must: [{ match_all: {} }],
- },
- },
- sortBy: null,
- cursor: null,
- allDatasets: true,
- datasetStatus: undefined,
+ const mockNotifications: MappedNotification[] = [
+ {
+ id: "1",
+ title: "Dataset Published",
+ content: "Dataset 'My Awesome Dataset' has been published.",
+ status: "unread",
+ type: "general",
+ originalNotification: {
+ ...baseDatasetEvent,
+ id: "1",
+ notificationStatus: { status: "UNREAD" },
},
},
- result: {
- data: {
- datasets: {
- edges: [
- {
- node: {
- id: "ds001012",
- created: "2025-01-22T19:55:49.997Z",
- name: "The DBS-fMRI dataset",
- public: null,
- analytics: {
- views: 9,
- downloads: 0,
- },
- stars: [],
- followers: [
- {
- userId: "47e6a401-5edf-4022-801f-c05fffbf1d10",
- datasetId: "ds001012",
- },
- ],
- latestSnapshot: {
- id: "ds001012:1.0.0",
- size: 635,
- created: "2025-01-22T19:55:49.997Z",
- description: {
- Name: "The DBS-fMRI dataset",
- Authors: [
- "Jianxun Ren",
- " Changqing Jiang",
- "Wei Zhang",
- "Louisa Dahmani",
- "Lunhao Shen",
- "Feng Zhang",
- ],
- },
- },
- },
- },
- ],
- },
+ {
+ id: "2",
+ title: "Dataset Saved",
+ content: "Dataset 'Another Dataset' has been saved.",
+ status: "saved",
+ type: "general",
+ originalNotification: {
+ ...baseDatasetEvent,
+ id: "2",
+ notificationStatus: { status: "SAVED" },
},
},
- },
- {
- request: {
- query: GET_USER,
- variables: { id: defaultUser.orcid },
- },
- result: {
- data: {
- user: defaultUser,
+ {
+ id: "3",
+ title: "Dataset Archived",
+ content: "Dataset 'Old Dataset' has been archived.",
+ status: "archived",
+ type: "general",
+ originalNotification: {
+ ...baseDatasetEvent,
+ id: "3",
+ notificationStatus: { status: "ARCHIVED" },
},
},
- },
-]
-const renderWithRouter = (
- orcidUser: User,
- route: string,
- hasEdit: boolean,
- isUser: boolean,
-) => {
- return render(
-
-
-
-
- ,
- )
-}
+ ]
+
+ const handleUpdateNotification = vi.fn()
+
+ return {
+ UserNotificationsView: vi.fn(() => (
+
+ )),
+ }
+})
describe("UserRoutes Component", () => {
- const userToPass: User = defaultUser
+ const userToPass: User = testUser
afterEach(() => {
cleanup()
@@ -199,74 +231,76 @@ describe("UserRoutes Component", () => {
})
it("renders UserDatasetsView for the default route", async () => {
- renderWithRouter(userToPass, "/", true, true)
- // Expect the default to be the datasets view
+ setupUserRoutes(userToPass, "/", true, true)
const datasetsView = await screen.findByTestId("user-datasets-view")
expect(datasetsView).toBeInTheDocument()
- expect(screen.getByText(userToPass.orcid)).toBeInTheDocument()
})
- it("renders FourOFourPage for an invalid route", async () => {
- renderWithRouter(userToPass, "/nonexistent-route", true, true)
- // Expect the mocked 404 page
- expect(
- screen.getByText(/404: The page you are looking for does not exist./i),
- ).toBeInTheDocument()
+ it("renders 404 for an invalid route", async () => {
+ setupUserRoutes(userToPass, "/nonexistent-route", true, true)
+ expect(screen.getByTestId("404-page")).toBeInTheDocument()
})
it("renders UserAccountView when hasEdit is true", async () => {
- renderWithRouter(userToPass, "/account", true, true)
- // Expect the mocked account view
+ setupUserRoutes(userToPass, "/account", true, true)
const accountView = await screen.findByTestId("user-account-view")
expect(accountView).toBeInTheDocument()
})
- it("renders UserNotificationsView when hasEdit is true", async () => {
- renderWithRouter(userToPass, "/notifications", true, true)
- // Expect the mocked notifications view
+ it("renders UserNotificationsView for the default notifications route", async () => {
+ setupUserRoutes(userToPass, "/notifications", true, true)
const notificationsView = await screen.findByTestId(
"user-notifications-view",
)
expect(notificationsView).toBeInTheDocument()
})
+ it("renders UnreadNotifications within UserNotificationsView for the index route", async () => {
+ setupUserRoutes(userToPass, "/notifications", true, true)
+ const notificationsView = await screen.findByTestId(
+ "user-notifications-view",
+ )
+ expect(notificationsView).toBeInTheDocument()
+ const unreadNotifications = await screen.findByTestId(
+ "unread-notifications",
+ )
+ expect(unreadNotifications).toBeInTheDocument()
+ })
+
it("renders SavedNotifications within UserNotificationsView", async () => {
- renderWithRouter(userToPass, "/notifications/saved", true, true)
+ setupUserRoutes(userToPass, "/notifications/saved", true, true)
const notificationsView = await screen.findByTestId(
"user-notifications-view",
)
expect(notificationsView).toBeInTheDocument()
- expect(screen.getByText("Saved Notification Example")).toBeInTheDocument()
+ const savedNotifications = await screen.findByTestId("saved-notifications")
+ expect(savedNotifications).toBeInTheDocument()
})
it("renders ArchivedNotifications within UserNotificationsView", async () => {
- renderWithRouter(userToPass, "/notifications/archived", true, true)
+ setupUserRoutes(userToPass, "/notifications/archived", true, true)
const notificationsView = await screen.findByTestId(
"user-notifications-view",
)
expect(notificationsView).toBeInTheDocument()
- expect(screen.getByText("Archived Notification Example"))
- .toBeInTheDocument()
+ const archivedNotifications = await screen.findByTestId(
+ "archived-notifications",
+ )
+ expect(archivedNotifications).toBeInTheDocument()
})
it("renders 404 for unknown notification sub-route", async () => {
- renderWithRouter(userToPass, "/notifications/nonexistent", true, true)
- expect(
- screen.getByText(/404: The page you are looking for does not exist./i),
- ).toBeInTheDocument()
+ setupUserRoutes(userToPass, "/notifications/nonexistent", true, true)
+ expect(await screen.findByTestId("404-page")).toBeInTheDocument()
})
- it("renders FourOThreePage when hasEdit is false for restricted routes", async () => {
- const restrictedRoutes = ["/account", "/notifications"]
+ it("renders FourOThreePage for restricted route /account when hasEdit is false", async () => {
+ setupUserRoutes(userToPass, "/account", false, true)
+ expect(await screen.findByTestId("403-page")).toBeInTheDocument()
+ })
- for (const route of restrictedRoutes) {
- renderWithRouter(userToPass, route, false, true)
- expect(
- screen.getByText(
- /403: You do not have access to this page, you may need to sign in./i,
- ),
- ).toBeInTheDocument()
- cleanup()
- }
+ it("renders FourOThreePage for restricted route /notifications when hasEdit is false", async () => {
+ setupUserRoutes(userToPass, "/notifications", false, true)
+ expect(await screen.findByTestId("403-page")).toBeInTheDocument()
})
})
diff --git a/packages/openneuro-app/src/scripts/users/components/status-action-buttons.tsx b/packages/openneuro-app/src/scripts/users/components/status-action-buttons.tsx
new file mode 100644
index 000000000..c1217091d
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/users/components/status-action-buttons.tsx
@@ -0,0 +1,47 @@
+import React from "react"
+import { Tooltip } from "../../components/tooltip/Tooltip"
+import styles from "../scss/usernotifications.module.scss"
+
+interface StatusActionButtonProps {
+ status: "unread" | "saved" | "archived"
+ targetStatus: "unread" | "saved" | "archived"
+ iconSrc: string
+ altText: string
+ tooltipText: string
+ srText: string
+ onClick: (newStatus: "unread" | "saved" | "archived") => Promise
+ disabled: boolean
+ className?: string
+}
+
+export const StatusActionButton: React.FC = ({
+ targetStatus,
+ iconSrc,
+ altText,
+ tooltipText,
+ srText,
+ onClick,
+ disabled,
+ className,
+}) => {
+ const buttonClass = [styles[targetStatus], className].filter(Boolean).join(
+ " ",
+ )
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/openneuro-app/src/scripts/users/contributor.tsx b/packages/openneuro-app/src/scripts/users/contributor.tsx
deleted file mode 100644
index 5abc56d33..000000000
--- a/packages/openneuro-app/src/scripts/users/contributor.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from "react"
-import type { FC } from "react"
-import { Link } from "react-router-dom"
-import { useUser } from "../queries/user"
-import type { Contributor } from "../types/datacite"
-import ORCIDiDLogo from "../../assets/ORCIDiD_iconvector.svg"
-
-interface SingleContributorDisplayProps {
- contributor: Contributor
- isLast: boolean
- separator: React.ReactNode
-}
-
-/**
- * Displays a single contributor's name and ORCID link.
- * Conditionally links the name to a user profile if a user with the ORCID exists.
- */
-export const SingleContributorDisplay: FC = ({
- contributor,
- isLast,
- separator,
-}) => {
- const { user, loading } = useUser(contributor.orcid || undefined)
- const displayName = contributor.name || "Unknown Contributor"
- const orcidBaseURL = "https://orcid.org/"
-
- if (loading) {
- return (
- <>
- {displayName} (checking user...)
- {!isLast && separator}
- >
- )
- }
-
- // Check if a user was successfully found for this ORCID
- const userExists = !!user?.id
-
- return (
- <>
- {contributor.orcid && userExists
- ? (
-
- {displayName}
-
- )
- : displayName}
-
- {contributor.orcid && (
- <>
- {" "}
-
-
-
- >
- )}
- {!isLast && separator}
- >
- )
-}
diff --git a/packages/openneuro-app/src/scripts/users/contributors-list.tsx b/packages/openneuro-app/src/scripts/users/contributors-list.tsx
deleted file mode 100644
index 1d4babd34..000000000
--- a/packages/openneuro-app/src/scripts/users/contributors-list.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from "react"
-import type { FC } from "react"
-
-import { SingleContributorDisplay } from "./contributor"
-import type { Contributor } from "../types/datacite"
-
-interface ContributorsListDisplayProps {
- contributors: Contributor[] | null | undefined
- separator?: React.ReactNode
-}
-
-/**
- * displays a list of contributors with optional ORCID links and icons.
- * It maps over the contributors and renders a SingleContributorsDisplay for each.
- */
-export const ContributorsListDisplay: FC = ({
- contributors,
- separator =
,
-}) => {
- if (!contributors || contributors.length === 0) {
- return <>N/A>
- }
-
- return (
- <>
- {contributors.map((contributor, index) => (
-
- ))}
- >
- )
-}
diff --git a/packages/openneuro-app/src/scripts/users/creator.tsx b/packages/openneuro-app/src/scripts/users/creator.tsx
deleted file mode 100644
index 6fb209299..000000000
--- a/packages/openneuro-app/src/scripts/users/creator.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from "react"
-import type { FC } from "react"
-import { Link } from "react-router-dom"
-import { useUser } from "../queries/user"
-import type { Creator } from "../types/datacite"
-import ORCIDiDLogo from "../../assets/ORCIDiD_iconvector.svg"
-
-interface SingleCreatorDisplayProps {
- creator: Creator
- isLast: boolean
- separator: React.ReactNode
-}
-
-/**
- * Displays a single creator's name and ORCID link.
- * Conditionally links the name to a user profile if a user with the ORCID exists.
- */
-export const SingleCreatorDisplay: FC = ({
- creator,
- isLast,
- separator,
-}) => {
- const { user, loading } = useUser(creator.orcid || undefined)
- const displayName = creator.name || "Unknown Creator"
- const orcidBaseURL = "https://orcid.org/"
-
- if (loading) {
- return (
- <>
- {displayName} (checking user...)
- {!isLast && separator}
- >
- )
- }
-
- // Check if a user was successfully found for this ORCID
- const userExists = !!user?.id
-
- return (
- <>
- {creator.orcid && userExists
- ? (
-
- {displayName}
-
- )
- : displayName}
-
- {creator.orcid && (
- <>
- {" "}
-
-
-
- >
- )}
- {!isLast && separator}
- >
- )
-}
diff --git a/packages/openneuro-app/src/scripts/users/creators-list.tsx b/packages/openneuro-app/src/scripts/users/creators-list.tsx
deleted file mode 100644
index 9f0e4f183..000000000
--- a/packages/openneuro-app/src/scripts/users/creators-list.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from "react"
-import type { FC } from "react"
-
-import { SingleCreatorDisplay } from "./creator"
-import type { Creator } from "../types/datacite"
-
-interface CreatorListDisplayProps {
- creators: Creator[] | null | undefined
- separator?: React.ReactNode
-}
-
-/**
- * displays a list of creators with optional ORCID links and icons.
- * It maps over the creators and renders a SingleCreatorDisplay for each.
- */
-export const CreatorListDisplay: FC = ({
- creators,
- separator =
,
-}) => {
- if (!creators || creators.length === 0) {
- return <>N/A>
- }
-
- return (
- <>
- {creators.map((creator, index) => (
-
- ))}
- >
- )
-}
diff --git a/packages/openneuro-app/src/scripts/users/scss/usernotifications.module.scss b/packages/openneuro-app/src/scripts/users/scss/usernotifications.module.scss
index aebe81d81..63eb3b257 100644
--- a/packages/openneuro-app/src/scripts/users/scss/usernotifications.module.scss
+++ b/packages/openneuro-app/src/scripts/users/scss/usernotifications.module.scss
@@ -157,3 +157,7 @@
}
}
}
+
+.reasonButtons{
+ text-align: right;
+}
\ No newline at end of file
diff --git a/packages/openneuro-app/src/scripts/users/user-menu.tsx b/packages/openneuro-app/src/scripts/users/user-menu.tsx
index c5930443a..302441409 100644
--- a/packages/openneuro-app/src/scripts/users/user-menu.tsx
+++ b/packages/openneuro-app/src/scripts/users/user-menu.tsx
@@ -2,33 +2,33 @@ import React from "react"
import { Link } from "react-router-dom"
import { Dropdown } from "../components/dropdown/Dropdown"
import { useUser } from "../queries/user"
+import { useNotifications } from "./user-notifications-context"
import "./scss/user-menu.scss"
export interface UserMenuProps {
signOutAndRedirect: () => void
}
-export const UserMenu = (
- { signOutAndRedirect }: UserMenuProps,
-) => {
- //const inboxCount = 99
-
+export const UserMenu: React.FC = ({ signOutAndRedirect }) => {
const { user } = useUser()
+ const { notifications } = useNotifications()
+
+ const inboxCount =
+ notifications?.filter((n) => n.status === "unread").length || 0
return (
- {
- /* {user?.orcid && (
+ {user?.orcid && (
-
+
{inboxCount > 0 && (
{inboxCount > 99
? (
-
+ <>
99+
-
+ >
)
: inboxCount}
@@ -37,73 +37,71 @@ export const UserMenu = (
Account Info
- )} */
- }
+ )}
+
)
: My Account
}
- children={
-
+
)
}
diff --git a/packages/openneuro-app/src/scripts/users/user-notification-accordion-actions.tsx b/packages/openneuro-app/src/scripts/users/user-notification-accordion-actions.tsx
new file mode 100644
index 000000000..48d916e53
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/users/user-notification-accordion-actions.tsx
@@ -0,0 +1,132 @@
+import React from "react"
+import type { MappedNotification } from "../types/event-types"
+import { StatusActionButton } from "./components/status-action-buttons"
+import iconUnread from "../../assets/icon-unread.png"
+import iconSaved from "../../assets/icon-saved.png"
+import iconArchived from "../../assets/icon-archived.png"
+import styles from "./scss/usernotifications.module.scss"
+
+interface NotificationActionButtonsProps {
+ notification: MappedNotification
+ isProcessing: boolean
+ onUpdate: (id: string, updates: Partial) => void
+ setError: (error: string | null) => void
+ handleProcessAction: (action: "accepted" | "denied") => void
+ handleStatusChange: (
+ newStatus: "unread" | "saved" | "archived",
+ ) => Promise
+}
+
+export const NotificationActionButtons: React.FC<
+ NotificationActionButtonsProps
+> = ({
+ notification,
+ isProcessing,
+ handleProcessAction,
+ handleStatusChange,
+}) => {
+ const { status, type, approval } = notification
+ const isContributorRequest = type === "approval"
+
+ return (
+
+ {isContributorRequest && (
+ <>
+ {approval !== "denied" && (
+
+ )}
+
+ {approval !== "accepted" && (
+
+ )}
+ >
+ )}
+
+ {status === "unread" && (
+ <>
+
+
+ >
+ )}
+
+ {status === "saved" && (
+ <>
+
+
+ >
+ )}
+
+ {status === "archived" && (
+
+ )}
+
+ )
+}
diff --git a/packages/openneuro-app/src/scripts/users/user-notification-accordion-header.tsx b/packages/openneuro-app/src/scripts/users/user-notification-accordion-header.tsx
new file mode 100644
index 000000000..4deb7dbf1
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/users/user-notification-accordion-header.tsx
@@ -0,0 +1,62 @@
+import React from "react"
+import styles from "./scss/usernotifications.module.scss"
+
+interface NotificationHeaderProps {
+ title: string
+ isOpen: boolean
+ toggleAccordion: () => void
+ showReviewButton: boolean
+ isProcessing: boolean
+ children?: React.ReactNode
+ datasetId?: string
+}
+
+export const NotificationHeader: React.FC = ({
+ title,
+ isOpen,
+ toggleAccordion,
+ showReviewButton,
+ isProcessing,
+ children,
+ datasetId,
+}) => {
+ const renderTitle = () => {
+ if (datasetId) {
+ const datasetLink = `/datasets/${datasetId}/`
+ return (
+
+ {title}{" "}
+
+ {datasetId}
+
+
+ )
+ }
+ return title
+ }
+ return (
+
+
{renderTitle()}
+ {showReviewButton && (
+
+ )}
+ {children}
+
+ )
+}
diff --git a/packages/openneuro-app/src/scripts/users/user-notification-accordion.tsx b/packages/openneuro-app/src/scripts/users/user-notification-accordion.tsx
index 566cfc8cf..995c483d6 100644
--- a/packages/openneuro-app/src/scripts/users/user-notification-accordion.tsx
+++ b/packages/openneuro-app/src/scripts/users/user-notification-accordion.tsx
@@ -1,159 +1,241 @@
-import React, { useState } from "react"
+import React, { useCallback, useState } from "react"
+import * as Sentry from "@sentry/react"
+import { toast } from "react-toastify"
+import { useMutation, useQuery } from "@apollo/client"
+import {
+ PROCESS_CONTRIBUTOR_REQUEST_MUTATION,
+ UPDATE_NOTIFICATION_STATUS_MUTATION,
+} from "../queries/datasetEvents"
+import { GET_USER, useUser } from "../queries/user"
+import { NotificationHeader } from "./user-notification-accordion-header"
+import { NotificationBodyContent } from "./user-notifications-accordion-body"
+import { NotificationReasonInput } from "./user-notification-reason-input"
+import { NotificationActionButtons } from "./user-notification-accordion-actions"
+import ToastContent from "../common/partials/toast-content"
import styles from "./scss/usernotifications.module.scss"
-import { Tooltip } from "../components/tooltip/Tooltip"
-import iconUnread from "../../assets/icon-unread.png"
-import iconSaved from "../../assets/icon-saved.png"
-import iconArchived from "../../assets/icon-archived.png"
-export const NotificationAccordion = ({ notification, onUpdate }) => {
- const { id, title, content, status, type, approval } = notification
+import type { MappedNotification } from "../types/event-types"
+export const NotificationAccordion = ({
+ notification,
+ onUpdate,
+}: {
+ notification: MappedNotification
+ onUpdate: (id: string, updates: Partial) => void
+}) => {
+ const { user } = useUser()
+ const {
+ id,
+ title,
+ content,
+ type,
+ approval,
+ datasetId,
+ requestId,
+ targetUserId,
+ requesterUser,
+ adminUser,
+ reason,
+ } = notification
+
+ const isContributorRequest = type === "approval"
+ const isContributorResponse = type === "response"
+
+ const { data: targetUserData, loading: targetUserLoading } = useQuery(
+ GET_USER,
+ {
+ variables: { userId: targetUserId },
+ skip: !targetUserId,
+ },
+ )
+
+ const targetUser = targetUserData?.user
const hasContent = content && content.trim().length > 0
const [isOpen, setIsOpen] = useState(false)
- const toggleAccordion = () => setIsOpen(!isOpen)
+ const [showReasonInput, setShowReasonInput] = useState(false)
+ const [reasonInput, setReasonInput] = useState("")
+ const [currentApprovalAction, setCurrentApprovalAction] = useState<
+ "accepted" | "denied" | null
+ >(null)
+ const [localError, setLocalError] = useState(null)
+ const [processContributorRequest, { loading: processRequestLoading }] =
+ useMutation(PROCESS_CONTRIBUTOR_REQUEST_MUTATION)
+ const isProcessing = processRequestLoading || targetUserLoading
+
+ const [updateNotificationStatus] = useMutation(
+ UPDATE_NOTIFICATION_STATUS_MUTATION,
+ )
+
+ const toggleAccordion = useCallback(() => {
+ setIsOpen((prev) => !prev)
+ if (isOpen) {
+ setShowReasonInput(false)
+ setReasonInput("")
+ setCurrentApprovalAction(null)
+ setLocalError(null)
+ }
+ }, [isOpen])
+
+ const handleProcessAction = useCallback((action: "accepted" | "denied") => {
+ setIsOpen(true)
+ setShowReasonInput(true)
+ setReasonInput("")
+ setCurrentApprovalAction(action)
+ setLocalError(null)
+ }, [])
+
+ const handleReasonSubmit = useCallback(async () => {
+ if (!reasonInput.trim()) {
+ const errorMessage = "Please provide a reason for this action."
+ toast.error()
+ setLocalError(errorMessage)
+ return
+ }
+
+ if (isProcessing || !currentApprovalAction) return
- const handleApprovalChange = (approvalStatus) => {
- onUpdate(id, { approval: approvalStatus })
- }
+ if (!datasetId || !requestId || !targetUserId) {
+ const missingDataError =
+ "Missing required data for processing contributor request."
+ Sentry.captureException(missingDataError)
+ toast.error()
+ setLocalError(missingDataError)
+ return
+ }
- const handleStatusChange = (newStatus) => {
- onUpdate(id, { status: newStatus })
- }
+ setLocalError(null)
+
+ try {
+ await processContributorRequest({
+ variables: {
+ datasetId,
+ requestId,
+ targetUserId,
+ status: currentApprovalAction,
+ reason: reasonInput,
+ },
+ })
+ toast.success(
+ ,
+ )
+
+ setShowReasonInput(false)
+ setReasonInput("")
+ setCurrentApprovalAction(null)
+ } catch (error: any) {
+ const errorMessage = `Error processing contributor request: ${
+ error.message || "Unknown error"
+ }`
+ Sentry.captureException(error)
+ toast.error(
+ ,
+ )
+ setLocalError(errorMessage)
+ }
+ }, [
+ reasonInput,
+ isProcessing,
+ currentApprovalAction,
+ datasetId,
+ requestId,
+ targetUserId,
+ processContributorRequest,
+ ])
+
+ const handleReasonCancel = useCallback(() => {
+ setShowReasonInput(false)
+ setReasonInput("")
+ setCurrentApprovalAction(null)
+ setLocalError(null)
+ }, [])
+
+ const handleStatusChange = useCallback(
+ async (newStatus: "unread" | "saved" | "archived") => {
+ onUpdate(id, { status: newStatus })
+
+ try {
+ const backendStatus = newStatus.toUpperCase()
+
+ await updateNotificationStatus({
+ variables: { eventId: id, status: backendStatus },
+ refetchQueries: [{ query: GET_USER, variables: { userId: user.id } }],
+ })
+
+ toast.success(
+ ,
+ )
+ } catch (error) {
+ Sentry.captureException(error)
+ toast.error(
+ ,
+ )
+ }
+ },
+ [id, updateNotificationStatus, user, onUpdate],
+ )
+
+ const showReviewButton = hasContent || isContributorRequest ||
+ isContributorResponse
return (
-
- {/* Render title as button if content exists, otherwise as plain text */}
-
{title}
-
- {hasContent && (
-
- )}
-
- {type === "approval" && (
- <>
- {(approval === "not provided" || approval === "approved") && (
-
- )}
-
- {(approval === "not provided" || approval === "denied") && (
-
- )}
- >
- )}
- {/* Render actions based on the notification's status */}
- {status === "unread" && (
- <>
-
-
-
-
-
-
- >
- )}
- {status === "saved" && (
- <>
-
-
-
-
-
-
- >
- )}
- {status === "archived" && (
-
-
-
- )}
+
+
+
+
+ {isOpen && (
+
+ {showReasonInput
+ ? (
+
+ )
+ : (
+
+ )}
-
- {isOpen && hasContent && (
-
{content}
)}
)
diff --git a/packages/openneuro-app/src/scripts/users/user-notification-list.tsx b/packages/openneuro-app/src/scripts/users/user-notification-list.tsx
index 9c018d0ac..9af2ab564 100644
--- a/packages/openneuro-app/src/scripts/users/user-notification-list.tsx
+++ b/packages/openneuro-app/src/scripts/users/user-notification-list.tsx
@@ -1,27 +1,24 @@
-import React, { useState } from "react"
+import React from "react"
import styles from "./scss/usernotifications.module.scss"
import { NotificationAccordion } from "./user-notification-accordion"
+import type { MappedNotification } from "../types/event-types"
-// NotificationsList Component
-export const NotificationsList = ({ notificationdata }) => {
- const [notifications, setNotifications] = useState(notificationdata)
-
- const handleUpdateNotification = (id, updates) => {
- setNotifications((prevNotifications) =>
- prevNotifications.map((notification) =>
- notification.id === id ? { ...notification, ...updates } : notification
- )
- )
- }
- return (
-
- {notifications.map((notification) => (
-
- ))}
-
- )
+interface NotificationsListProps {
+ notificationdata: MappedNotification[]
+ onUpdate: (id: string, updates: Partial
) => void
}
+
+export const NotificationsList: React.FC = ({
+ notificationdata,
+ onUpdate,
+}) => (
+
+ {notificationdata.map((notification) => (
+
+ ))}
+
+)
diff --git a/packages/openneuro-app/src/scripts/users/user-notification-reason-input.tsx b/packages/openneuro-app/src/scripts/users/user-notification-reason-input.tsx
new file mode 100644
index 000000000..11049673a
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/users/user-notification-reason-input.tsx
@@ -0,0 +1,59 @@
+import React from "react"
+import styles from "./scss/usernotifications.module.scss"
+
+interface NotificationReasonInputProps {
+ reasonInput: string
+ setReasonInput: (reason: string) => void
+ currentApprovalAction: "accepted" | "denied" | null
+ handleReasonCancel: () => void
+ handleReasonSubmit: () => void
+ isProcessing: boolean
+}
+
+export const NotificationReasonInput: React.FC = (
+ {
+ reasonInput,
+ setReasonInput,
+ currentApprovalAction,
+ handleReasonCancel,
+ handleReasonSubmit,
+ isProcessing,
+ },
+) => {
+ const textareaId = "notification-reason-input"
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/openneuro-app/src/scripts/users/user-notifications-accordion-body.tsx b/packages/openneuro-app/src/scripts/users/user-notifications-accordion-body.tsx
new file mode 100644
index 000000000..5aa2a94f8
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/users/user-notifications-accordion-body.tsx
@@ -0,0 +1,73 @@
+import React from "react"
+import { Username } from "./username"
+import type { User } from "../types/user-types"
+
+interface NotificationBodyContentProps {
+ content?: string
+ isContributorRequest: boolean
+ isContributorResponse: boolean
+ approval?: "pending" | "accepted" | "denied"
+ requesterUser?: User
+ adminUser?: User
+ targetUser?: User
+ targetUserLoading: boolean
+ reason?: string
+}
+
+export const NotificationBodyContent: React.FC = (
+ {
+ content,
+ isContributorRequest,
+ isContributorResponse,
+ approval,
+ requesterUser,
+ adminUser,
+ targetUser,
+ targetUserLoading,
+ reason,
+ },
+) => {
+ if (isContributorRequest) {
+ if (approval === "accepted") {
+ return (
+
+ Contributor request from was{" "}
+ approved.
+
+ )
+ } else if (approval === "denied") {
+ return (
+
+ Contributor request from was{" "}
+ denied.
+
+ )
+ }
+ return (
+
+ {" "}
+ requested contributor status for this dataset.
+
+ )
+ } else if (isContributorResponse) {
+ return (
+ <>
+ {approval} contributor request
+ {targetUserLoading ? for ... : (
+ <>
+ {" "}for
+ {" "}
+ >
+ )}
+
+
+ {" Reason:"}
+
+ {reason || "No reason provided."}
+
+
+ >
+ )
+ }
+ return <>{content}>
+}
diff --git a/packages/openneuro-app/src/scripts/users/user-notifications-context.tsx b/packages/openneuro-app/src/scripts/users/user-notifications-context.tsx
new file mode 100644
index 000000000..bf7e7b87d
--- /dev/null
+++ b/packages/openneuro-app/src/scripts/users/user-notifications-context.tsx
@@ -0,0 +1,71 @@
+import React, { createContext, useCallback, useContext, useState } from "react"
+import { useMutation } from "@apollo/client"
+import { UPDATE_NOTIFICATION_STATUS_MUTATION } from "../queries/datasetEvents"
+import type { MappedNotification } from "../types/event-types"
+
+interface NotificationsContextValue {
+ notifications: MappedNotification[]
+ setNotifications: React.Dispatch>
+ handleUpdateNotification: (
+ id: string,
+ updates: Partial,
+ ) => Promise
+}
+
+interface NotificationsProviderProps {
+ children: React.ReactNode
+ initialNotifications?: MappedNotification[]
+}
+
+const NotificationsContext = createContext<
+ NotificationsContextValue | undefined
+>(undefined)
+
+export const NotificationsProvider: React.FC = ({
+ children,
+ initialNotifications = [],
+}) => {
+ const [notifications, setNotifications] = useState(
+ initialNotifications,
+ )
+ const [updateEventStatus] = useMutation(UPDATE_NOTIFICATION_STATUS_MUTATION)
+
+ const handleUpdateNotification = useCallback(
+ async (id: string, updates: Partial) => {
+ // Update local state immediately
+ setNotifications((prev) =>
+ prev.map((n) => (n.id === id ? { ...n, ...updates } : n))
+ )
+
+ // Persist change to backend if status is updated
+ if (updates.status) {
+ try {
+ await updateEventStatus({
+ variables: { eventId: id, status: updates.status.toUpperCase() },
+ })
+ } catch (err) {
+ console.error("Failed to update notification status:", err)
+ }
+ }
+ },
+ [updateEventStatus],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useNotifications = () => {
+ const context = useContext(NotificationsContext)
+ if (!context) {
+ throw new Error(
+ "useNotifications must be used within a NotificationsProvider",
+ )
+ }
+ return context
+}
diff --git a/packages/openneuro-app/src/scripts/users/user-notifications-tab-content.tsx b/packages/openneuro-app/src/scripts/users/user-notifications-tab-content.tsx
index 6ecddc454..cf5b5c906 100644
--- a/packages/openneuro-app/src/scripts/users/user-notifications-tab-content.tsx
+++ b/packages/openneuro-app/src/scripts/users/user-notifications-tab-content.tsx
@@ -1,85 +1,49 @@
import React from "react"
import { NotificationsList } from "./user-notification-list"
+import { useNotifications } from "./user-notifications-context"
-// Dummy notifications
-const dummyNotifications = [
- {
- id: 1,
- title: "New Comment on Your dataset",
- content: "A user has commented on your dataset. View here",
- status: "unread",
- type: "general",
- approval: "",
- },
- {
- id: 2,
- title: "Example No Approval State ",
- content: "",
- status: "unread",
- type: "approval",
- approval: "not provided",
- },
- {
- id: 3,
- title: "Example Denied State",
- content: "",
- status: "unread",
- type: "approval",
- approval: "denied",
- },
- {
- id: 4,
- title: "Example Approved State",
- content: "",
- status: "unread",
- type: "approval",
- approval: "approved",
- },
- {
- id: 5,
- title: "Saved Notification Example",
- content: "This is an example of a saved notification.",
- status: "saved",
- type: "general",
- approval: "",
- },
- {
- id: 6,
- title: "Archived Notification Example",
- content: "This is an example of an archived notification.",
- status: "archived",
- type: "general",
- approval: "",
- },
-]
+interface NotificationTabProps {
+ status: "unread" | "saved" | "archived"
+ testId: string
+ className: string
+}
+
+const NotificationTab: React.FC = (
+ { status, testId, className },
+) => {
+ const { notifications, handleUpdateNotification } = useNotifications()
+ const filteredNotifications = notifications.filter((n) => n.status === status)
+
+ return (
+
+
+
+ )
+}
-// Tab Components for Different Notifications
export const UnreadNotifications = () => (
-
- notification.status === "unread",
- )}
- />
-
+
)
export const SavedNotifications = () => (
-
- notification.status === "saved",
- )}
- />
-
+
)
export const ArchivedNotifications = () => (
-
- notification.status === "archived",
- )}
- />
-
+
)
diff --git a/packages/openneuro-app/src/scripts/users/user-notifications-view.tsx b/packages/openneuro-app/src/scripts/users/user-notifications-view.tsx
index e1149410f..e851f7357 100644
--- a/packages/openneuro-app/src/scripts/users/user-notifications-view.tsx
+++ b/packages/openneuro-app/src/scripts/users/user-notifications-view.tsx
@@ -10,14 +10,25 @@ import styles from "./scss/usernotifications.module.scss"
import iconUnread from "../../assets/icon-unread.png"
import iconSaved from "../../assets/icon-saved.png"
import iconArchived from "../../assets/icon-archived.png"
+import { useUser } from "../queries/user"
+import { Loading } from "../components/loading/Loading"
+import * as Sentry from "@sentry/react"
+import { mapRawEventToMappedNotification } from "../types/event-types"
+import {
+ NotificationsProvider,
+ useNotifications,
+} from "./user-notifications-context"
+import type { UserNotificationsViewProps } from "../types/user-types"
-export const UserNotificationsView = ({ orcidUser }) => {
- const tabsRef = useRef(null)
+export const UserNotificationsView: React.FC = (
+ { orcidUser },
+) => {
+ const tabsRef = useRef(null)
const { tab = "unread" } = useParams()
const navigate = useNavigate()
const location = useLocation()
+ const { user, loading, error } = useUser(orcidUser.id)
- // Explicitly define the type of indicatorStyle
const [indicatorStyle, setIndicatorStyle] = useState({
width: "0px",
transform: "translateX(0px)",
@@ -28,26 +39,22 @@ export const UserNotificationsView = ({ orcidUser }) => {
transition: "transform 0.3s ease, width 0.3s ease",
})
- // Update the indicator position based on active tab whenever location changes
useEffect(() => {
const activeLink = tabsRef.current?.querySelector(`.${styles.active}`)
+ ?.parentElement
if (activeLink) {
- const li = activeLink.parentElement as HTMLElement
- if (li) {
- setIndicatorStyle({
- width: `${li.offsetWidth}px`,
- transform: `translateX(${li.offsetLeft}px)`,
- position: "absolute",
- bottom: "0px",
- height: "2px",
- backgroundColor: "#000",
- transition: "transform 0.3s ease, width 0.3s ease",
- })
- }
+ setIndicatorStyle({
+ width: `${activeLink.offsetWidth}px`,
+ transform: `translateX(${activeLink.offsetLeft}px)`,
+ position: "absolute",
+ bottom: "0px",
+ height: "2px",
+ backgroundColor: "#000",
+ transition: "transform 0.3s ease, width 0.3s ease",
+ })
}
}, [location])
- // Redirect to default tab if no tab is specified
useEffect(() => {
if (!["unread", "saved", "archived"].includes(tab)) {
navigate(`/user/${orcidUser.orcid}/notifications/unread`, {
@@ -56,56 +63,73 @@ export const UserNotificationsView = ({ orcidUser }) => {
}
}, [tab, orcidUser.orcid, navigate])
- return (
-
-
Notifications for {orcidUser.name}
-
-
- -
-
- isActive
- ? `${styles.active} ${styles.tabUnread}`
- : styles.tabUnread}
- >
-
Unread
- 121
-
-
- -
-
- isActive
- ? `${styles.active} ${styles.tabSaved}`
- : styles.tabSaved}
- >
-
Saved
- 121
-
-
- -
-
- isActive
- ? `${styles.active} ${styles.tabArchived}`
- : styles.tabArchived}
- >
-
{" "}
- Archived
- 121
-
-
-
+ if (loading) return
+ if (error) {
+ Sentry.captureException(error)
+ return (
+
Error loading notifications: {error.message}. Please try again.
+ )
+ }
- {/* This is the indicator that will follow the active tab */}
-
-
-
-
+ const initialNotifications =
+ user?.notifications.map(mapRawEventToMappedNotification) || []
+
+ return (
+
+
+
Notifications for {orcidUser.name}
+
+
+ {[
+ {
+ status: "unread",
+ icon: iconUnread,
+ label: "Unread",
+ tabClass: styles.tabUnread,
+ },
+ {
+ status: "saved",
+ icon: iconSaved,
+ label: "Saved",
+ tabClass: styles.tabSaved,
+ },
+ {
+ status: "archived",
+ icon: iconArchived,
+ label: "Archived",
+ tabClass: styles.tabArchived,
+ },
+ ].map(({ status, icon, label, tabClass }) => (
+ -
+
+ isActive ? `${styles.active} ${tabClass}` : tabClass}
+ >
+
{label}
+
+
+
+ ))}
+
+
+
+
+
+
-
+
)
}
+
+interface TabCountProps {
+ status: "unread" | "saved" | "archived"
+}
+
+const TabCount: React.FC
= ({ status }) => {
+ const { notifications } = useNotifications()
+ const count = notifications.filter((n) => n.status === status).length
+ return {count}
+}
diff --git a/packages/openneuro-app/src/scripts/users/user-routes.tsx b/packages/openneuro-app/src/scripts/users/user-routes.tsx
index 07b7fb00f..72d78963a 100644
--- a/packages/openneuro-app/src/scripts/users/user-routes.tsx
+++ b/packages/openneuro-app/src/scripts/users/user-routes.tsx
@@ -1,5 +1,5 @@
-import React from "react"
-import { Route, Routes } from "react-router-dom"
+import React, { createContext, useContext } from "react"
+import { Outlet, Route, Routes } from "react-router-dom"
import { UserAccountContainer } from "./user-container"
import { UserAccountView } from "./user-account-view"
import { UserNotificationsView } from "./user-notifications-view"
@@ -11,27 +11,35 @@ import {
SavedNotifications,
UnreadNotifications,
} from "./user-notifications-tab-content"
-
-import type { UserRoutesProps } from "../types/user-types"
+import type { OutletContextType, UserRoutesProps } from "../types/user-types"
import { OrcidConsentModal } from "./user-orcid-consent-modal"
+// This context is for managing notifications state
+export const NotificationsContext = createContext(
+ null,
+)
+
+// This hook provides a way to consume the notifications context
+export const useNotificationsContext = (): OutletContextType => {
+ const context = useContext(NotificationsContext)
+ if (!context) {
+ throw new Error(
+ "useNotificationsContext must be used within NotificationsContextProvider",
+ )
+ }
+ return context
+}
+
export const UserRoutes: React.FC = (
{ orcidUser, hasEdit, isUser },
) => {
return (
<>
- {
- /*
- The OrcidConsent component will render itself if orcidConsent is null
- This modal is currently designed to appear on UserRoutes and only on initial page load if the orcidConsent is null.
- It will NOT reappear if the user navigates to other routes within UserRoutes after the first page load without a full page refresh, even if orcidConsent remains null.
- The account page will always show the ORCID Consent Form
- */
- }
} />
+ {/* The main route that contains all other user routes within a container */}
= (
/>
}
>
+ {/* This is the default route for the user's datasets */}
}
/>
+ {/* This route handles the user account page */}
: }
/>
+ {/* This route handles the user notifications and its sub-routes */}
: }
>
- } />
- } />
- } />
- } />
- } />
+ {},
+ }}
+ >
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Fallback route for any other path within the user account */}
} />
diff --git a/packages/openneuro-app/src/scripts/users/user-tabs.tsx b/packages/openneuro-app/src/scripts/users/user-tabs.tsx
index a1d2b1cb4..2ec1aebf7 100644
--- a/packages/openneuro-app/src/scripts/users/user-tabs.tsx
+++ b/packages/openneuro-app/src/scripts/users/user-tabs.tsx
@@ -50,8 +50,7 @@ export const UserAccountTabs: React.FC = (
{isUser ? "My" : "User"} Datasets
- {
- /*
+
= (
>
Notifications
- */
- }
+
+
{
+ if (!user) {
+ return Unknown User
+ }
+
if (user.orcid) {
let orcidURL = "https://orcid.org/"
if (config.auth.orcid.ORCID_API_ENDPOINT.includes("sandbox")) {
@@ -13,13 +18,13 @@ export const Username = ({ user }): JSX.Element => {
}
return (
<>
- {user.name}{" "}
+ {user.name}{" "}
>
)
} else {
- return user.name as JSX.Element
+ return <>{user.name}>
}
}
diff --git a/packages/openneuro-server/src/datalad/__tests__/contributors.spec.ts b/packages/openneuro-server/src/datalad/__tests__/contributors.spec.ts
index aa186c7e6..36c77f887 100644
--- a/packages/openneuro-server/src/datalad/__tests__/contributors.spec.ts
+++ b/packages/openneuro-server/src/datalad/__tests__/contributors.spec.ts
@@ -48,6 +48,7 @@ vi.mocked(CacheItem).mockImplementation((_redis, _type, _key) => {
describe("contributors (core functionality)", () => {
const MOCK_DATASET_ID = "ds000001"
const MOCK_REVISION = "dce4b7b6653bcde9bdb7226a7c2b9499e77f2724"
+ const MOCK_REV_SHORT = MOCK_REVISION.substring(0, 7)
beforeEach(() => {
vi.clearAllMocks()
@@ -105,7 +106,7 @@ describe("contributors (core functionality)", () => {
})
expect(result).toEqual([])
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} found but resourceTypeGeneral is 'Software', not 'Dataset'.`,
+ `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} found but resourceTypeGeneral is 'Software', not 'Dataset'.`,
)
expect(mockSentryCaptureException).not.toHaveBeenCalled()
})
@@ -135,7 +136,7 @@ describe("contributors (core functionality)", () => {
expect(result).toEqual([])
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} is Dataset type but provided no contributors.`,
+ `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} is Dataset type but provided no contributors.`,
)
expect(mockSentryCaptureException).not.toHaveBeenCalled()
})
@@ -150,6 +151,7 @@ describe("contributors (core functionality)", () => {
contributors: [],
},
},
+ contentType: "text/plain", // simulate unexpected content type
}
mockFetch.mockResolvedValueOnce({
@@ -162,15 +164,14 @@ describe("contributors (core functionality)", () => {
id: MOCK_DATASET_ID,
revision: MOCK_REVISION,
})
+
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- expect.stringContaining(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} served with unexpected Content-Type: text/plain. Attempting YAML parse anyway.`,
- ),
+ `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} served with unexpected Content-Type: text/plain. Attempting YAML parse anyway.`,
)
- expect(mockSentryCaptureException).not.toHaveBeenCalled()
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} is Dataset type but provided no contributors.`,
+ `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} is Dataset type but provided no contributors.`,
)
+ expect(mockSentryCaptureException).not.toHaveBeenCalled()
expect(result).toEqual([])
})
})
diff --git a/packages/openneuro-server/src/datalad/__tests__/creators.spec.ts b/packages/openneuro-server/src/datalad/__tests__/creators.spec.ts
deleted file mode 100644
index f08bc2eb6..000000000
--- a/packages/openneuro-server/src/datalad/__tests__/creators.spec.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest"
-import yaml from "js-yaml"
-import * as Sentry from "@sentry/node"
-import CacheItem from "../../cache/item"
-import { fileUrl } from "../files"
-import { datasetOrSnapshot } from "../../utils/datasetOrSnapshot"
-import { description } from "../description"
-import { creators } from "../creators"
-
-vi.mock("../../libs/authentication/jwt", () => ({
- sign: vi.fn(() => "mock_jwt_token"),
- verify: vi.fn(() => ({ userId: "mock_user_id" })),
-}))
-
-vi.mock("js-yaml", () => ({
- default: {
- load: vi.fn(),
- },
-}))
-
-vi.mock("@sentry/node", () => ({
- captureMessage: vi.fn(),
- captureException: vi.fn(),
-}))
-
-vi.mock("../../cache/item")
-vi.mock("../files")
-vi.mock("../../utils/datasetOrSnapshot")
-vi.mock("../description")
-vi.mock("../libs/redis", () => ({
- redis: vi.fn(),
-}))
-
-const mockYamlLoad = vi.mocked(yaml.load)
-const mockSentryCaptureMessage = vi.mocked(Sentry.captureMessage)
-const mockSentryCaptureException = vi.mocked(Sentry.captureException)
-const mockFileUrl = vi.mocked(fileUrl)
-const mockDatasetOrSnapshot = vi.mocked(datasetOrSnapshot)
-const mockDescription = vi.mocked(description)
-
-const mockFetch = vi.fn()
-global.fetch = mockFetch
-
-const mockCacheItemGet = vi.fn()
-vi.mocked(CacheItem).mockImplementation((_redis, _type, _key) => {
- return {
- get: mockCacheItemGet,
- } as unknown as CacheItem
-})
-
-describe("creators (core functionality)", () => {
- const MOCK_DATASET_ID = "ds000001"
- const MOCK_REVISION = "dce4b7b6653bcde9bdb7226a7c2b9499e77f2724"
-
- beforeEach(() => {
- vi.clearAllMocks()
-
- mockDatasetOrSnapshot.mockReturnValue({
- datasetId: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
- mockFileUrl.mockImplementation(
- (datasetId, path, filename, revision) =>
- `http://example.com/${datasetId}/${revision}/${filename}`,
- )
-
- mockCacheItemGet.mockImplementation((fetcher) => fetcher())
- })
-
- it("should fall back to dataset_description.json if datacite file is 404", async () => {
- const datasetDescriptionJson = {
- Authors: ["Author One", "Author Two"],
- }
-
- mockFetch.mockResolvedValueOnce({
- status: 404,
- headers: new Headers(),
- text: () => Promise.resolve("Not Found"),
- })
-
- mockCacheItemGet.mockImplementationOnce((fetcher) =>
- fetcher().then(() => null)
- )
- mockDescription.mockResolvedValueOnce(datasetDescriptionJson)
- const result = await creators({
- id: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
- expect(result).toEqual([{ name: "Author One" }, { name: "Author Two" }])
- expect(mockDescription).toHaveBeenCalledWith({
- id: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
- expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Loaded creators from dataset_description.json via description resolver for ${MOCK_DATASET_ID}:${MOCK_REVISION}`,
- )
- })
-
- it("should fall back to dataset_description.json if datacite file parsing fails", async () => {
- const dataciteYamlContent = `invalid: - yaml`
- const datasetDescriptionJson = {
- Authors: ["BIDS Author A"],
- }
-
- mockFetch.mockResolvedValueOnce({
- status: 200,
- headers: new Headers({ "Content-Type": "application/yaml" }),
- text: () => Promise.resolve(dataciteYamlContent),
- })
- mockYamlLoad.mockImplementationOnce(() => {
- throw new Error("YAML parsing error")
- })
- mockCacheItemGet.mockImplementationOnce((fetcher) =>
- fetcher().catch(() => null)
- )
- mockDescription.mockResolvedValueOnce(datasetDescriptionJson)
- const result = await creators({
- id: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
- expect(result).toEqual([{ name: "BIDS Author A" }])
- expect(mockDescription).toHaveBeenCalled()
- expect(mockSentryCaptureException).toHaveBeenCalledWith(expect.any(Error))
- expect(mockSentryCaptureException).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining(
- `Found datacite file for dataset ${MOCK_DATASET_ID}`,
- ),
- }),
- )
- expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Loaded creators from dataset_description.json via description resolver for ${MOCK_DATASET_ID}:${MOCK_REVISION}`,
- )
- })
-
- it("should return empty array if both datacite file and dataset_description.json fail", async () => {
- mockFetch.mockResolvedValueOnce({
- status: 500,
- headers: new Headers(),
- text: () => Promise.resolve("Server Error"),
- })
- mockCacheItemGet.mockImplementationOnce((fetcher) =>
- fetcher().catch(() => null)
- )
- mockDescription.mockRejectedValueOnce(
- new Error("Description fetch failed"),
- )
- const result = await creators({
- id: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
- expect(result).toEqual([])
- expect(mockSentryCaptureException).toHaveBeenCalledTimes(2)
- })
-
- it("should return default empty array if no creators array in datacite file or dataset_description.json (or wrong resourceTypeGeneral in datacite file)", async () => {
- const dataciteYamlContent =
- `data:\n attributes:\n types:\n resourceTypeGeneral: Software\n creators: []`
- const parsedDatacite = {
- data: {
- attributes: {
- types: { resourceTypeGeneral: "Software" },
- creators: [],
- },
- },
- }
-
- mockFetch.mockResolvedValueOnce({
- status: 200,
- headers: new Headers({ "Content-Type": "application/yaml" }),
- text: () => Promise.resolve(dataciteYamlContent),
- })
- mockYamlLoad.mockReturnValueOnce(parsedDatacite)
- mockDescription.mockResolvedValueOnce(null)
- const result = await creators({
- id: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
- expect(result).toEqual([])
- expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} found but resourceTypeGeneral is 'Software', not 'Dataset'.`,
- )
- expect(mockDescription).toHaveBeenCalled()
- expect(mockSentryCaptureException).not.toHaveBeenCalled()
- })
-
- it("should return default empty array if datacite file is Dataset type but provides no creators", async () => {
- const dataciteYamlContent =
- `data:\n attributes:\n types:\n resourceTypeGeneral: Dataset\n creators: []`
- const parsedDatacite = {
- data: {
- attributes: {
- types: { resourceTypeGeneral: "Dataset" },
- creators: [],
- },
- },
- }
-
- mockFetch.mockResolvedValueOnce({
- status: 200,
- headers: new Headers({ "Content-Type": "application/yaml" }),
- text: () => Promise.resolve(dataciteYamlContent),
- })
- mockYamlLoad.mockReturnValueOnce(parsedDatacite)
- mockDescription.mockResolvedValueOnce(null)
-
- const result = await creators({
- id: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
-
- expect(result).toEqual([])
- expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} is Dataset type but provided no creators.`,
- )
- expect(mockDescription).toHaveBeenCalled()
- expect(mockSentryCaptureException).not.toHaveBeenCalled()
- })
-
- it("should capture message if datacite file has unexpected content type but still parses", async () => {
- const dataciteYamlContent =
- `data:\n attributes:\n types:\n resourceTypeGeneral: Dataset\n creators: []`
- const parsedDatacite = {
- data: {
- attributes: {
- types: { resourceTypeGeneral: "Dataset" },
- creators: [],
- },
- },
- }
- const datasetDescriptionJson = {
- Authors: ["Fallback Author"],
- }
-
- mockFetch.mockResolvedValueOnce({
- status: 200,
- headers: new Headers({ "Content-Type": "text/plain" }),
- text: () => Promise.resolve(dataciteYamlContent),
- })
- mockYamlLoad.mockReturnValueOnce(parsedDatacite)
- mockDescription.mockResolvedValueOnce(datasetDescriptionJson)
- const result = await creators({
- id: MOCK_DATASET_ID,
- revision: MOCK_REVISION,
- })
- expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- expect.stringContaining(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} served with unexpected Content-Type: text/plain. Attempting YAML parse anyway.`,
- ),
- )
- expect(mockSentryCaptureException).not.toHaveBeenCalled()
- expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Datacite file for ${MOCK_DATASET_ID}:${MOCK_REVISION} is Dataset type but provided no creators.`,
- )
- expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
- `Loaded creators from dataset_description.json via description resolver for ${MOCK_DATASET_ID}:${MOCK_REVISION}`,
- )
- expect(result).toEqual([{ name: "Fallback Author" }])
- })
-})
diff --git a/packages/openneuro-server/src/datalad/contributors.ts b/packages/openneuro-server/src/datalad/contributors.ts
index b62bacc05..c0128d1e4 100644
--- a/packages/openneuro-server/src/datalad/contributors.ts
+++ b/packages/openneuro-server/src/datalad/contributors.ts
@@ -5,99 +5,149 @@ import {
type DatasetOrSnapshot,
datasetOrSnapshot,
} from "../utils/datasetOrSnapshot"
-import { getDataciteYml } from "../utils/datacite-utils"
-import { validateOrcid } from "../utils/orcid-utils"
-import type {
- Contributor,
- NameIdentifier,
- RawDataciteContributor,
-} from "../types/datacite"
+import {
+ getDataciteYml,
+ normalizeRawContributors,
+ updateContributorsUtil,
+} from "../utils/datacite-utils"
+import type { Contributor, RawDataciteYml } from "../types/datacite"
+import { description } from "./description"
/**
- * Normalizes datacite contributors to the Contributor interface, extracting ORCID IDs.
- * Returns Contributor[]
+ * GraphQL resolver: fetch contributors for a dataset or snapshot
+ * Pure function: reads Datacite.yml or dataset_description.json and returns the list
*/
-export const normalizeDataciteContributors = (
- rawContributors: RawDataciteContributor[] | undefined,
-): Contributor[] => {
- if (!rawContributors) {
- return []
- }
+export const contributors = async (
+ obj: DatasetOrSnapshot,
+): Promise => {
+ if (!obj) return []
- return rawContributors.map((rawContributor) => {
- const orcidIdentifier = rawContributor.nameIdentifiers?.find(
- (ni: NameIdentifier) =>
- ni.nameIdentifierScheme?.toUpperCase() === "ORCID",
- )
+ const { datasetId, revision } = datasetOrSnapshot(obj)
+ if (!datasetId) return []
+
+ const revisionShort = revision ? revision.substring(0, 7) : "HEAD"
+ const dataciteCache = new CacheItem(redis, CacheType.dataciteYml, [
+ datasetId,
+ revisionShort,
+ ])
+
+ try {
+ const dataciteData: RawDataciteYml & { contentType?: string } | null =
+ await dataciteCache.get(() => getDataciteYml(datasetId, revision))
- const orcidNumber = validateOrcid(orcidIdentifier?.nameIdentifier)
+ if (!dataciteData) return []
- return {
- name: rawContributor.name ||
- [rawContributor.givenName, rawContributor.familyName].filter(Boolean)
- .join(
- " ",
- ) ||
- "Unknown Contributor",
- givenName: rawContributor.givenName,
- familyName: rawContributor.familyName,
- orcid: orcidNumber, // ORCID number or undefined
- contributorType: rawContributor.contributorType,
+ // --- Capture unexpected content type ---
+ if (
+ dataciteData.contentType &&
+ dataciteData.contentType !== "application/yaml"
+ ) {
+ Sentry.captureMessage(
+ `Datacite file for ${datasetId}:${revisionShort} served with unexpected Content-Type: ${dataciteData.contentType}. Attempting YAML parse anyway.`,
+ )
+ }
+
+ const attributes = dataciteData.data?.attributes
+ const resourceType = attributes?.types?.resourceTypeGeneral
+
+ // --- Wrong resourceTypeGeneral ---
+ if (resourceType && resourceType !== "Dataset") {
+ Sentry.captureMessage(
+ `Datacite file for ${datasetId}:${revisionShort} found but resourceTypeGeneral is '${resourceType}', not 'Dataset'.`,
+ )
+ return []
+ }
+
+ // --- Contributors from Datacite.yml ---
+ if (attributes?.contributors?.length) {
+ const normalized = await normalizeRawContributors(attributes.contributors)
+ return normalized
+ .map((c, index) => ({ ...c, order: c.order ?? index + 1 }))
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+ }
+
+ // --- Dataset type but no contributors ---
+ if (resourceType === "Dataset") {
+ Sentry.captureMessage(
+ `Datacite file for ${datasetId}:${revisionShort} is Dataset type but provided no contributors.`,
+ )
+ }
+
+ // --- Fallback: dataset_description.json authors ---
+ const datasetDescription = await description(obj)
+ if (datasetDescription?.Authors?.length) {
+ return datasetDescription.Authors.map((
+ author: string,
+ index: number,
+ ) => ({
+ name: author.trim(),
+ givenName: undefined,
+ familyName: undefined,
+ orcid: undefined,
+ contributorType: "Contributor",
+ order: index + 1,
+ userId: undefined,
+ }))
}
- })
+
+ return []
+ } catch (err) {
+ Sentry.captureException(err)
+ return []
+ }
}
/**
- * Get contributors for a dataset, prioritizing datacite
- * checking resourceTypeGeneral for Dataset.
+ * GraphQL mutation resolver
*/
-export const contributors = async (
- obj: DatasetOrSnapshot,
-): Promise => {
- const { datasetId, revision } = datasetOrSnapshot(obj)
- const revisionShort = revision.substring(0, 7)
+export interface UserInfo {
+ id?: string
+ _id?: string
+}
- let parsedContributors: Contributor[] = []
+export interface GraphQLContext {
+ userInfo: UserInfo | null
+}
+
+export const updateContributors = async (
+ _parent: DatasetOrSnapshot,
+ args: { datasetId: string; newContributors: Contributor[] },
+ context: GraphQLContext,
+) => {
+ const userId = context?.userInfo?.id || context?.userInfo?._id
+ if (!userId) {
+ return { success: false, dataset: null }
+ }
- // 1. Try to get contributors from datacite
- const dataciteCache = new CacheItem(redis, CacheType.dataciteYml, [
- datasetId,
- revisionShort,
- ])
try {
- const dataciteData = await dataciteCache.get(() =>
- getDataciteYml(datasetId, revision)
+ const contributorsToSave = args.newContributors.map((c, index) => ({
+ ...c,
+ contributorType: c.contributorType || "Researcher",
+ order: c.order ?? index + 1,
+ }))
+
+ const result = await updateContributorsUtil(
+ args.datasetId,
+ contributorsToSave,
+ userId,
)
- // --- Check resourceTypeGeneral and access new contributors path ---
- if (dataciteData) {
- const resourceTypeGeneral = dataciteData?.data?.attributes?.types
- ?.resourceTypeGeneral
-
- if (resourceTypeGeneral === "Dataset") {
- parsedContributors = normalizeDataciteContributors(
- dataciteData.data.attributes.contributors,
- )
- if (parsedContributors.length > 0) {
- Sentry.captureMessage(
- `Loaded contributors from datacite file for ${datasetId}:${revision} (ResourceType: ${resourceTypeGeneral})`,
- )
- } else {
- // No contributors found in datacite file even if resourceTypeGeneral is Dataset
- Sentry.captureMessage(
- `Datacite file for ${datasetId}:${revision} is Dataset type but provided no contributors.`,
- )
- }
- } else {
- Sentry.captureMessage(
- `Datacite file for ${datasetId}:${revision} found but resourceTypeGeneral is '${resourceTypeGeneral}', not 'Dataset'.`,
- )
- }
+ return {
+ success: true,
+ dataset: {
+ id: args.datasetId,
+ draft: {
+ id: args.datasetId,
+ contributors: contributorsToSave.sort((a, b) =>
+ (a.order ?? 0) - (b.order ?? 0)
+ ),
+ files: result.draft.files || [],
+ modified: new Date().toISOString(),
+ },
+ },
}
- } catch (error) {
- Sentry.captureException(error)
+ } catch (err) {
+ Sentry.captureException(err)
+ return { success: false, dataset: null }
}
-
- // Return the parsed contributors or the default empty array
- return parsedContributors
}
diff --git a/packages/openneuro-server/src/datalad/creators.ts b/packages/openneuro-server/src/datalad/creators.ts
deleted file mode 100644
index 9281ea071..000000000
--- a/packages/openneuro-server/src/datalad/creators.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-import * as Sentry from "@sentry/node"
-import CacheItem, { CacheType } from "../cache/item"
-import { redis } from "../libs/redis"
-import {
- type DatasetOrSnapshot,
- datasetOrSnapshot,
-} from "../utils/datasetOrSnapshot"
-import { getDataciteYml } from "../utils/datacite-utils"
-import { description } from "./description"
-import { validateOrcid } from "../utils/orcid-utils"
-
-import type {
- Creator,
- NameIdentifier,
- RawDataciteCreator,
-} from "../types/datacite"
-
-/**
- * Normalizes datacite creators to the Creator interface, extracting ORCID IDs.
- * Returns Creator[]
- */
-export const normalizeDataciteCreators = (
- rawCreators: RawDataciteCreator[] | undefined,
-): Creator[] => {
- if (!rawCreators) {
- return []
- }
-
- return rawCreators.map((rawCreator) => {
- const orcidIdentifier = rawCreator.nameIdentifiers?.find(
- (ni: NameIdentifier) =>
- ni.nameIdentifierScheme?.toUpperCase() === "ORCID",
- )
-
- const orcidNumber = validateOrcid(orcidIdentifier?.nameIdentifier)
-
- return {
- name: rawCreator.name ||
- [rawCreator.givenName, rawCreator.familyName].filter(Boolean).join(
- " ",
- ) || "Unknown Creator",
- givenName: rawCreator.givenName,
- familyName: rawCreator.familyName,
- orcid: orcidNumber, // ORCID number or undefined
- }
- })
-}
-
-/**
- * Normalizes dataset_description.json authors.
- * Returns Creator[] with only the 'name' field populated from the author string.
- */
-const normalizeBidsAuthors = (authors: unknown): Creator[] => {
- if (!Array.isArray(authors)) {
- return []
- }
- return authors
- .map((authorString) => {
- if (typeof authorString === "string") {
- const trimmedAuthorString = authorString.trim()
- return {
- name: trimmedAuthorString,
- }
- }
- return null
- })
- .filter(Boolean) as Creator[]
-}
-
-/**
- * Get creators (authors) for a dataset, prioritizing datacite
- * checking resourceTypeGeneral for Dataset.
- */
-export const creators = async (obj: DatasetOrSnapshot): Promise => {
- const { datasetId, revision } = datasetOrSnapshot(obj)
- const revisionShort = revision.substring(0, 7)
-
- // Default fallback for authors if neither file provides them
- const defaultAuthors: Creator[] = []
- let parsedCreators: Creator[] | null = null
-
- // 1. Try to get creators from datacite
- const dataciteCache = new CacheItem(redis, CacheType.dataciteYml, [
- datasetId,
- revisionShort,
- ])
- try {
- const dataciteData = await dataciteCache.get(() =>
- getDataciteYml(datasetId, revision)
- )
-
- // --- Check resourceTypeGeneral and access new creators path ---
- if (dataciteData) {
- const resourceTypeGeneral = dataciteData?.data?.attributes?.types
- ?.resourceTypeGeneral
-
- if (resourceTypeGeneral === "Dataset") {
- parsedCreators = normalizeDataciteCreators(
- dataciteData.data.attributes.creators,
- )
- if (parsedCreators.length > 0) {
- Sentry.captureMessage(
- `Loaded creators from datacite file for ${datasetId}:${revision} (ResourceType: ${resourceTypeGeneral})`,
- )
- } else {
- // No creators found in datacite file even if resourceTypeGeneral is Dataset
- Sentry.captureMessage(
- `Datacite file for ${datasetId}:${revision} is Dataset type but provided no creators.`,
- )
- }
- } else {
- Sentry.captureMessage(
- `Datacite file for ${datasetId}:${revision} found but resourceTypeGeneral is '${resourceTypeGeneral}', not 'Dataset'.`,
- )
- }
- }
- } catch (error) {
- Sentry.captureException(error)
- // Continue to fallback if datacite file processing failed
- }
-
- // 2. If datacite file didn't provide creators or was not a 'Dataset', try dataset_description.json
- if (!parsedCreators || parsedCreators.length === 0) {
- try {
- const datasetDescription = await description(obj)
- if (
- datasetDescription &&
- Array.isArray(datasetDescription.Authors)
- ) {
- parsedCreators = normalizeBidsAuthors(
- datasetDescription.Authors,
- )
- Sentry.captureMessage(
- `Loaded creators from dataset_description.json via description resolver for ${datasetId}:${revision}`,
- )
- }
- } catch (error) {
- Sentry.captureException(error)
- }
- }
-
- // Return the parsed creators or the default empty array
- return parsedCreators || defaultAuthors
-}
diff --git a/packages/openneuro-server/src/graphql/resolvers/datasetEvents.ts b/packages/openneuro-server/src/graphql/resolvers/datasetEvents.ts
index 0bed28245..0690d637b 100644
--- a/packages/openneuro-server/src/graphql/resolvers/datasetEvents.ts
+++ b/packages/openneuro-server/src/graphql/resolvers/datasetEvents.ts
@@ -1,25 +1,165 @@
import DatasetEvent from "../../models/datasetEvents"
+import User from "../../models/user"
+import type { UserDocument } from "../../models/user"
+import { checkDatasetAdmin } from "../permissions"
+import type {
+ DatasetEventContributorCitation,
+ DatasetEventContributorRequest,
+ DatasetEventContributorResponse,
+ DatasetEventDocument,
+} from "../../models/datasetEvents"
+import { UserNotificationStatus } from "../../models/userNotificationStatus"
+import type { UserNotificationStatusDocument } from "../../models/userNotificationStatus"
+import {
+ getDataciteYml,
+ updateContributorsUtil,
+} from "../../utils/datacite-utils"
+import type { Contributor } from "../../types/datacite"
+
+/** Helper type guards */
+function isContributorRequest(
+ event: DatasetEventDocument,
+): event is DatasetEventDocument & { event: DatasetEventContributorRequest } {
+ return event.event.type === "contributorRequest"
+}
+
+function isContributorResponse(
+ event: DatasetEventDocument,
+): event is DatasetEventDocument & { event: DatasetEventContributorResponse } {
+ return event.event.type === "contributorResponse"
+}
+
+function isContributorCitation(
+ event: DatasetEventDocument,
+): event is DatasetEventDocument & { event: DatasetEventContributorCitation } {
+ return event.event.type === "contributorCitation"
+}
+
+/** Enriched type for GraphQL */
+export type EnrichedDatasetEvent =
+ & Omit<
+ DatasetEventDocument,
+ "notificationStatus"
+ >
+ & {
+ hasBeenRespondedTo?: boolean
+ responseStatus?: "accepted" | "denied"
+ citationStatus?: "pending" | "approved" | "denied"
+ notificationStatus?: UserNotificationStatusDocument
+ }
/**
* Get all events for a dataset
*/
-export function datasetEvents(obj, _, { userInfo }) {
- if (userInfo.admin) {
- // Site admins can see all events
- return DatasetEvent.find({ datasetId: obj.id })
- .sort({ timestamp: -1 })
- .populate("user")
- .exec()
- } else {
- // Non-admin users can only see notes without the admin flag
- return DatasetEvent.find({
- datasetId: obj.id,
- event: { admin: { $ne: true } },
+export async function datasetEvents(
+ obj,
+ _,
+ { userInfo, user },
+): Promise {
+ const allEvents: DatasetEventDocument[] = await DatasetEvent.find({
+ datasetId: obj.id,
+ })
+ .sort({ timestamp: -1 })
+ .populate("user")
+ .populate({ path: "notificationStatus", match: { userId: user } })
+
+ const responsesMap = new Map()
+ allEvents.forEach((e) => {
+ if (e.event.type === "contributorResponse" && "requestId" in e.event) {
+ responsesMap.set(e.event.requestId, e)
+ }
+ })
+
+ const enriched: EnrichedDatasetEvent[] = allEvents
+ .filter((e) => {
+ // Only include contributorCitation if it's approved or denied
+ if (isContributorCitation(e)) {
+ return e.event.resolutionStatus !== "pending"
+ }
+ return true
})
- .sort({ timestamp: -1 })
- .populate("user")
- .exec()
+ .map((e) => {
+ const ev = e.toObject() as EnrichedDatasetEvent
+
+ if (!ev.notificationStatus || typeof ev.notificationStatus === "string") {
+ ev.notificationStatus = new UserNotificationStatus({
+ userId: user,
+ datasetEventId: e.id,
+ status: "UNREAD",
+ }) as UserNotificationStatusDocument
+ }
+
+ if (isContributorRequest(e)) {
+ const response = responsesMap.get(e.event.requestId)
+ if (response && isContributorResponse(response)) {
+ ev.hasBeenRespondedTo = true
+ ev.responseStatus = response.event.status
+ }
+ } else if (isContributorCitation(e)) {
+ ev.hasBeenRespondedTo = true
+ ev.citationStatus = e.event.resolutionStatus
+ }
+
+ return ev
+ })
+
+ return userInfo?.admin ? enriched : enriched.filter(
+ (ev) =>
+ !(ev.event.type === "note" && ev.event.admin) &&
+ ev.event.type !== "permissionChange",
+ )
+}
+
+// --- Field-level resolvers ---
+export const DatasetEventResolvers = {
+ hasBeenRespondedTo: (ev: EnrichedDatasetEvent) =>
+ ev.hasBeenRespondedTo ?? false,
+ responseStatus: (ev: EnrichedDatasetEvent) => ev.responseStatus ?? null,
+ citationStatus: (ev: EnrichedDatasetEvent) => ev.citationStatus ?? null,
+ notificationStatus: (ev: EnrichedDatasetEvent) =>
+ ev.notificationStatus?.status ?? "UNREAD",
+ requestId: (ev: EnrichedDatasetEvent) =>
+ isContributorRequest(ev) || isContributorResponse(ev)
+ ? ev.event.requestId
+ : null,
+ target: async (ev: EnrichedDatasetEvent): Promise => {
+ let targetUserId: string | undefined
+
+ if (isContributorResponse(ev) || isContributorCitation(ev)) {
+ targetUserId = ev.event.targetUserId
+ }
+
+ if (!targetUserId) return null
+ return User.findById(targetUserId)
+ },
+ user: async (ev: EnrichedDatasetEvent): Promise =>
+ ev.userId ? User.findById(ev.userId) : null,
+}
+
+/**
+ * Create a 'contributor request' event
+ */
+export async function createContributorRequestEvent(
+ obj,
+ { datasetId },
+ { user },
+) {
+ if (!user) {
+ throw new Error("Authentication required to request contributor status.")
}
+
+ const event = new DatasetEvent({
+ datasetId,
+ userId: user,
+ event: { type: "contributorRequest", datasetId },
+ success: true,
+ note: "User requested contributor status for this dataset.",
+ })
+ ;(event.event as DatasetEventContributorRequest).requestId = event.id
+
+ await event.save()
+ await event.populate("user")
+ return event
}
/**
@@ -30,30 +170,291 @@ export async function saveAdminNote(
{ id, datasetId, note },
{ user, userInfo },
) {
- // Only site admin users can create an admin note
- if (!userInfo?.admin) {
- throw new Error("Not authorized")
- }
+ // Only site admin users can create or update a note
+ if (!userInfo?.admin) throw new Error("Not authorized")
+
if (id) {
- const event = await DatasetEvent.findOne({ id, datasetId })
- event.note = note
- await event.save()
- await event.populate("user")
- return event
+ const updatedEvent = await DatasetEvent.findOneAndUpdate(
+ { id, datasetId },
+ { note },
+ { new: true },
+ )
+ if (!updatedEvent) {
+ throw new Error(`Event with ID ${id} not found for dataset ${datasetId}.`)
+ }
+ await updatedEvent.populate("user")
+ return updatedEvent
} else {
- const event = new DatasetEvent({
- id,
+ const newEvent = new DatasetEvent({
datasetId,
userId: user,
- event: {
- type: "note",
- admin: true,
- },
+ event: { type: "note", admin: true, datasetId },
success: true,
note,
})
- await event.save()
- await event.populate("user")
- return event
+ await newEvent.save()
+ await newEvent.populate("user")
+ return newEvent
}
}
+
+/**
+ * Process a contributor request (accept or deny) and log an event.
+ * This mutation should only be callable by users with admin privileges on the dataset.
+ */
+export async function processContributorRequest(
+ obj: unknown,
+ {
+ datasetId,
+ requestId,
+ targetUserId,
+ status,
+ reason,
+ }: {
+ datasetId: string
+ requestId: string
+ targetUserId: string
+ status: "accepted" | "denied"
+ reason?: string
+ },
+ {
+ user: currentUserId,
+ userInfo,
+ }: {
+ user: string
+ userInfo: { admin: boolean }
+ },
+) {
+ if (!currentUserId) {
+ throw new Error("Authentication required to process contributor requests.")
+ }
+
+ // --- Authorization Check ---
+ await checkDatasetAdmin(datasetId, currentUserId, userInfo)
+
+ if (status !== "accepted" && status !== "denied") {
+ throw new Error("Invalid status. Must be 'accepted' or 'denied'.")
+ }
+
+ // Populate original requester (TODO - perms)
+ const originalRequestEvent = await DatasetEvent.findOne({
+ "event.type": "contributorRequest",
+ "event.requestId": requestId,
+ }).populate("user")
+ // Check if originalRequestEvent is found and is of the correct type
+ if (
+ !originalRequestEvent ||
+ originalRequestEvent.event.type !== "contributorRequest"
+ ) {
+ throw new Error(
+ "Original contributor request event not found or is not a contributorRequest type.",
+ )
+ }
+
+ // Check if it has already been responded to
+ const existingResponse = await DatasetEvent.findOne({
+ "event.type": "contributorResponse",
+ "event.requestId": requestId,
+ })
+ if (existingResponse) {
+ throw new Error("This contributor request has already been processed.")
+ }
+
+ originalRequestEvent.event.resolutionStatus = status
+ await originalRequestEvent.save()
+
+ // Create the response event
+ const responseEvent = new DatasetEvent({
+ datasetId,
+ userId: currentUserId, // Admin processed the request
+ event: {
+ type: "contributorResponse",
+ requestId,
+ targetUserId,
+ status,
+ reason,
+ datasetId,
+ },
+ success: true,
+ note: reason?.trim() ||
+ `Admin ${currentUserId} processed contributor request for user ${targetUserId} as '${status}'.`,
+ })
+
+ await responseEvent.save()
+ await responseEvent.populate("user")
+
+ if (status === "accepted") {
+ // TODO: Add logic here to modify permissions if ADMIN approved
+ }
+
+ return responseEvent
+}
+
+/**
+ * Update a user's notification status for a specific event
+ */
+export async function updateEventStatus(obj, { eventId, status }, { user }) {
+ if (!user) throw new Error("Authentication required.")
+
+ const updatedStatus = await UserNotificationStatus.findOneAndUpdate(
+ { userId: user, datasetEventId: eventId },
+ { status },
+ { new: true, upsert: true },
+ )
+
+ return updatedStatus
+}
+
+/**
+ * Create a 'contributor citation' event
+ */
+export async function createContributorCitationEvent(
+ obj,
+ {
+ datasetId,
+ targetUserId,
+ contributorType,
+ contributorData,
+ }: {
+ datasetId: string
+ targetUserId: string
+ contributorType: string
+ contributorData: {
+ orcid?: string
+ name?: string
+ email?: string
+ userId?: string
+ }
+ },
+ { user }: { user: string },
+) {
+ if (!user) {
+ throw new Error("Authentication required to create contributor citation.")
+ }
+
+ const event = new DatasetEvent({
+ datasetId,
+ userId: user,
+ event: {
+ type: "contributorCitation",
+ note: "Contributorship request",
+ datasetId,
+ addedBy: user,
+ targetUserId,
+ contributorType,
+ contributorData,
+ resolutionStatus: "pending",
+ },
+ success: true,
+ note: `User ${user} added a contributor citation for user ${targetUserId}.`,
+ })
+
+ await event.save()
+ await event.populate("user")
+ return event
+}
+/**
+ * Process a contributor citation (approve or deny)
+ * Only the target user can approve/deny
+ */
+export async function processContributorCitation(
+ obj,
+ {
+ eventId,
+ status,
+ }: {
+ eventId: string
+ status: "approved" | "denied"
+ },
+ { user, userInfo }: { user: string; userInfo: { admin?: boolean } },
+) {
+ if (!user) {
+ throw new Error("Authentication required to process contributor citation.")
+ }
+
+ // Fetch the citation event
+ const citationEvent = await DatasetEvent.findOne({ id: eventId })
+
+ if (!citationEvent || citationEvent.event.type !== "contributorCitation") {
+ throw new Error("Contributor citation event not found.")
+ }
+
+ // Fetch current user
+ const currentUser = await User.findOne({ id: user })
+
+ // Authorization: target user OR admin
+ const isTargetUser = citationEvent.event.targetUserId === user ||
+ citationEvent.event.targetUserId === currentUser?.orcid
+ const isAdmin = userInfo?.admin === true
+
+ if (!isTargetUser && !isAdmin) {
+ throw new Error("Not authorized to respond to this contributor citation.")
+ }
+
+ // Must still be pending
+ if (citationEvent.event.resolutionStatus !== "pending") {
+ throw new Error("This contributor citation has already been responded to.")
+ }
+
+ // --- Create a new DatasetEvent for the approval/denial ---
+ const responseEvent = new DatasetEvent({
+ datasetId: citationEvent.datasetId,
+ userId: user,
+ event: {
+ type: "contributorCitation",
+ note: status + " contributor request",
+ datasetId: citationEvent.datasetId,
+ addedBy: citationEvent.event.addedBy,
+ targetUserId: citationEvent.event.targetUserId,
+ contributorType: citationEvent.event.contributorType,
+ contributorData: citationEvent.event.contributorData,
+ resolutionStatus: status,
+ },
+ success: true,
+ note:
+ `User ${user} ${status} contributor citation for ${citationEvent.event.targetUserId}.`,
+ })
+
+ await responseEvent.save()
+ await responseEvent.populate("user")
+
+ // If approved, update contributors in Datacite YAML
+ if (status === "approved") {
+ const { contributorData } = citationEvent.event
+ if (!contributorData) {
+ throw new Error("Contributor data missing in citation event.")
+ }
+
+ const existingDatacite = await getDataciteYml(citationEvent.datasetId)
+ const existingContributors =
+ existingDatacite?.data.attributes.contributors || []
+
+ const mappedExisting: Contributor[] = existingContributors.map(
+ (c, index) => ({
+ name: c.name || "Unknown Contributor",
+ givenName: c.givenName || "",
+ familyName: c.familyName || "",
+ orcid: c.nameIdentifiers?.[0]?.nameIdentifier,
+ contributorType: c.contributorType || "Researcher",
+ order: index + 1,
+ }),
+ )
+
+ const newContributor: Contributor = {
+ name: contributorData.name || "Unknown Contributor",
+ givenName: "", //contributorData.givenName || '',
+ familyName: "", //contributorData.familyName || '',
+ orcid: contributorData.orcid,
+ contributorType: "Researcher", //contributorData.contributorType || 'Researcher',
+ order: mappedExisting.length + 1,
+ }
+
+ await updateContributorsUtil(
+ citationEvent.datasetId,
+ [...mappedExisting, newContributor],
+ user,
+ )
+ }
+
+ return responseEvent
+}
diff --git a/packages/openneuro-server/src/graphql/resolvers/draft.ts b/packages/openneuro-server/src/graphql/resolvers/draft.ts
index a48878785..f5d4a20f2 100644
--- a/packages/openneuro-server/src/graphql/resolvers/draft.ts
+++ b/packages/openneuro-server/src/graphql/resolvers/draft.ts
@@ -8,7 +8,6 @@ import { checkDatasetWrite } from "../permissions.js"
import { getFiles } from "../../datalad/files"
import { filterRemovedAnnexObjects } from "../utils/file.js"
import { validation } from "./validation"
-import { creators } from "../../datalad/creators"
import FileCheck from "../../models/fileCheck"
import { contributors } from "../../datalad/contributors"
@@ -60,7 +59,6 @@ const draft = {
description,
readme,
head: (obj) => obj.revision,
- creators: (parent) => creators(parent),
fileCheck,
contributors: (parent) => contributors(parent),
}
diff --git a/packages/openneuro-server/src/graphql/resolvers/mutation.ts b/packages/openneuro-server/src/graphql/resolvers/mutation.ts
index 37b93bd9f..88da2b1fe 100644
--- a/packages/openneuro-server/src/graphql/resolvers/mutation.ts
+++ b/packages/openneuro-server/src/graphql/resolvers/mutation.ts
@@ -43,9 +43,17 @@ import {
finishImportRemoteDataset,
importRemoteDataset,
} from "./importRemoteDataset"
-import { saveAdminNote } from "./datasetEvents"
+import {
+ createContributorCitationEvent,
+ createContributorRequestEvent,
+ processContributorCitation,
+ processContributorRequest,
+ saveAdminNote,
+ updateEventStatus,
+} from "./datasetEvents"
import { createGitEvent } from "./gitEvents"
import { updateFileCheck } from "./fileCheck"
+import { updateContributors } from "../../datalad/contributors"
import { updateWorkerTask } from "./worker"
const Mutation = {
@@ -94,8 +102,14 @@ const Mutation = {
finishImportRemoteDataset,
updateUser,
saveAdminNote,
+ createContributorRequestEvent,
+ createContributorCitationEvent,
+ processContributorRequest,
+ processContributorCitation,
createGitEvent,
updateFileCheck,
+ updateEventStatus,
+ updateContributors,
updateWorkerTask,
}
diff --git a/packages/openneuro-server/src/graphql/resolvers/snapshots.ts b/packages/openneuro-server/src/graphql/resolvers/snapshots.ts
index c3a70fb74..1c5cc100e 100644
--- a/packages/openneuro-server/src/graphql/resolvers/snapshots.ts
+++ b/packages/openneuro-server/src/graphql/resolvers/snapshots.ts
@@ -18,7 +18,6 @@ import { getDraftHead } from "../../datalad/dataset"
import { downloadFiles } from "../../datalad/snapshots"
import { snapshotValidation } from "./validation"
import { advancedDatasetSearchConnection } from "./dataset-search"
-import { creators } from "../../datalad/creators"
import { contributors } from "../../datalad/contributors"
export const snapshots = (obj) => {
@@ -30,6 +29,7 @@ export const snapshot = (obj, { datasetId, tag }, context) => {
() => {
return datalad.getSnapshot(datasetId, tag).then((snapshot) => ({
...snapshot,
+ datasetId,
dataset: () => dataset(snapshot, { id: datasetId }, context),
description: () => description(snapshot),
readme: () => readme(snapshot),
@@ -312,8 +312,14 @@ const Snapshot = {
issues: (snapshot) => snapshotIssues(snapshot),
issuesStatus: (snapshot) => issuesSnapshotStatus(snapshot),
validation: (snapshot) => snapshotValidation(snapshot),
- creators: (parent) => creators(parent),
- contributors: (parent) => contributors(parent),
+ contributors: (snapshot) => {
+ const datasetId = snapshot.datasetId
+ return contributors({
+ id: `${datasetId}:${snapshot.hexsha}`,
+ tag: snapshot.tag,
+ hexsha: snapshot.hexsha,
+ })
+ },
}
export default Snapshot
diff --git a/packages/openneuro-server/src/graphql/resolvers/user.ts b/packages/openneuro-server/src/graphql/resolvers/user.ts
index b21d21969..f4700ba04 100644
--- a/packages/openneuro-server/src/graphql/resolvers/user.ts
+++ b/packages/openneuro-server/src/graphql/resolvers/user.ts
@@ -1,7 +1,7 @@
-/**
- * User resolvers
- */
+import type { PipelineStage } from "mongoose"
import User from "../../models/user"
+import DatasetEvent from "../../models/datasetEvents"
+import type { UserNotificationStatusDocument } from "../../models/userNotificationStatus"
function isValidOrcid(orcid: string): boolean {
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(
let user
if (isValidOrcid(id)) {
user = await User.findOne({
- $or: [{ "provider": "orcid", "providerId": id }],
+ $or: [{ provider: "orcid", providerId: id }],
}).exec()
} else {
- // If it's not a valid ORCID, fall back to querying by user id
- user = await User.findOne({ "id": id }).exec()
+ user = await User.findOne({ id }).exec()
}
+
+ if (!user) {
+ return null // Fail silently
+ }
+
if (userInfo?.admin || user.id === userInfo?.id) {
return user.toObject()
} else {
@@ -69,6 +73,7 @@ type MongoFilterValue =
interface MongoQueryCondition {
[key: string]: MongoFilterValue
}
+
export const users = async (
obj: unknown,
{ isAdmin, isBlocked, search, limit = 100, offset = 0, orderBy }: {
@@ -171,7 +176,7 @@ export const updateUser = async (
{ id, location, institution, links, orcidConsent },
) => {
try {
- let user // Declare user outside the if block
+ let user
if (isValidOrcid(id)) {
user = await User.findOne({
@@ -191,15 +196,123 @@ export const updateUser = async (
if (links !== undefined) user.links = links
if (orcidConsent !== undefined) user.orcidConsent = orcidConsent
- // Save the updated user
await user.save()
- return user // Return the updated user object
+ return user
} catch (err) {
throw new Error("Failed to update user: " + err.message)
}
}
+/**
+ * Get all events associated with a specific user (for their notifications feed).
+ * Uses a single aggregation pipeline for improved performance.
+ */
+export async function notifications(obj, _, { userInfo }) {
+ const userId = obj.id
+
+ // Authorization check: Only the user themselves or a site admin can view their notifications
+ if (!userInfo || (userInfo.id !== userId && !userInfo.admin)) {
+ throw new Error("Not authorized to view these notifications.")
+ }
+
+ const pipeline: PipelineStage[] = [
+ // Lookup permissions for dataset admin checks
+ {
+ $lookup: {
+ from: "permissions",
+ let: { datasetId: "$datasetId" },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $and: [
+ { $eq: ["$datasetId", "$$datasetId"] },
+ { $eq: ["$userId", userId] },
+ ],
+ },
+ },
+ },
+ ],
+ as: "permissions",
+ },
+ },
+ {
+ $unwind: { path: "$permissions", preserveNullAndEmptyArrays: true },
+ },
+ // Match relevant events
+ {
+ $match: {
+ $or: [
+ // Condition 1: All events for a user where they are the creator
+ { userId: userId },
+ // Condition 2: All events for a user where they are the target
+ { "event.targetUserId": userId },
+ // Condition 3: All contributor requests for a site admin
+ ...(userInfo.admin ? [{ "event.type": "contributorRequest" }] : []),
+ // Condition 4: All contributor requests for a dataset admin (using the Permission model)
+ {
+ "event.type": "contributorRequest",
+ "permissions.level": "admin",
+ },
+ ],
+ },
+ },
+ {
+ $sort: { timestamp: -1 },
+ },
+ {
+ $lookup: {
+ from: "users",
+ localField: "userId",
+ foreignField: "id",
+ as: "user",
+ },
+ },
+ {
+ $unwind: { path: "$user", preserveNullAndEmptyArrays: true },
+ },
+ {
+ $lookup: {
+ from: "usernotificationstatuses",
+ let: { eventId: "$id" },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $and: [
+ { $eq: ["$datasetEventId", "$$eventId"] },
+ { $eq: ["$userId", userId] },
+ ],
+ },
+ },
+ },
+ ],
+ as: "notificationStatus",
+ },
+ },
+ {
+ $unwind: {
+ path: "$notificationStatus",
+ preserveNullAndEmptyArrays: true,
+ },
+ },
+ ]
+
+ const events = await DatasetEvent.aggregate(pipeline).exec()
+
+ return events.map((event) => {
+ const notificationStatus = event.notificationStatus
+ ? event.notificationStatus
+ : ({ status: "UNREAD" } as UserNotificationStatusDocument)
+
+ return {
+ ...event,
+ notificationStatus,
+ }
+ })
+}
+
const UserResolvers = {
id: (obj) => obj.id,
provider: (obj) => obj.provider,
@@ -216,6 +329,7 @@ const UserResolvers = {
links: (obj) => obj.links,
orcidConsent: (obj) => obj.orcidConsent,
modified: (obj) => obj.updatedAt,
+ notifications: notifications,
}
export default UserResolvers
diff --git a/packages/openneuro-server/src/graphql/schema.ts b/packages/openneuro-server/src/graphql/schema.ts
index c48e72b90..eb6e080e9 100644
--- a/packages/openneuro-server/src/graphql/schema.ts
+++ b/packages/openneuro-server/src/graphql/schema.ts
@@ -206,6 +206,16 @@ export const typeDefs = `
saveAdminNote(id: ID, datasetId: ID!, note: String!): DatasetEvent
# Create a git event log for dataset changes
createGitEvent(datasetId: ID!, commit: String!, reference: String!): DatasetEvent
+ # Request contributor status for a dataset
+ createContributorRequestEvent(datasetId: ID!): DatasetEvent
+ # Save contributor request response data
+ processContributorRequest(
+ datasetId: ID!
+ targetUserId: ID!
+ requestId: ID!
+ status: String!
+ reason: String
+ ): DatasetEvent
# Create or update a fileCheck document
updateFileCheck(
datasetId: ID!
@@ -214,6 +224,21 @@ export const typeDefs = `
annexFsck: [AnnexFsckInput!]!
remote: String
): FileCheck
+ # Profile Event Status updates
+ updateEventStatus(eventId: ID!, status: NotificationStatusType!): UserNotificationStatus
+ updateContributors(
+ datasetId: String!
+ newContributors: [ContributorInput!]!
+ ): UpdateContributorsPayload!
+ createContributorCitationEvent(
+ datasetId: ID!
+ targetUserId: ID!
+ contributorData: ContributorInput!
+ ): DatasetEvent
+ processContributorCitation(
+ eventId: ID!
+ status: String!
+ ): DatasetEvent
# Update worker task queue status
updateWorkerTask(
id: ID!,
@@ -352,7 +377,7 @@ export const typeDefs = `
# OpenNeuro user records from all providers
type User {
- id: ID!
+ id: ID
provider: UserProvider
avatar: String
orcid: String
@@ -368,6 +393,7 @@ export const typeDefs = `
github: String
githubSynced: Date
links: [String]
+ notifications: [DatasetEvent!]
orcidConsent: Boolean
}
@@ -561,11 +587,9 @@ export const typeDefs = `
head: String
# Total size in bytes of this draft
size: BigInt
- # Creators list from datacite.yml || Authors list from dataset_description.json
- creators: [Creator]
# File issues
fileCheck: FileCheck
- # NEW: Contributors list from datacite.yml
+ # Contributors list from datacite.yml
contributors: [Contributor]
}
@@ -606,9 +630,7 @@ export const typeDefs = `
size: BigInt
# Single list of files to download this snapshot (only available on snapshots)
downloadFiles: [DatasetFile]
- # Authors list from datacite.yml || dataset_description.json
- creators: [Creator]
- # NEW: Contributors list from datacite.yml
+ # Contributors list from datacite.yml
contributors: [Contributor]
}
@@ -679,21 +701,30 @@ export const typeDefs = `
EthicsApprovals: [String]
}
- # Defines the Creator type in creators.ts
- type Creator {
- name: String!
- givenName: String
- familyName: String
- orcid: String
- }
- # NEW: Defines the Contributor type in contributors.ts
+ # Defines the Contributor type in contributors.ts
type Contributor {
name: String!
givenName: String
familyName: String
orcid: String
contributorType: String!
+ order: Int
+ }
+
+ # ContributorInput input type
+ input ContributorInput {
+ name: String
+ givenName: String
+ familyName: String
+ orcid: String
+ contributorType: String
+ order: Int
+ }
+
+ type UpdateContributorsPayload {
+ success: Boolean!
+ dataset: Dataset
}
@@ -931,12 +962,20 @@ export const typeDefs = `
version: String
public: Boolean
target: User
+ targetUserId: ID
level: String
ref: String
message: String
+ requestId: ID
+ status: String
+ reason: String
+ datasetId: ID
+ resolutionStatus: String
+ contributorType: String
+ contributorData: Contributor
}
- # Dataset events
+ # Dataset events
type DatasetEvent {
# Unique identifier for the event
id: ID
@@ -950,8 +989,29 @@ export const typeDefs = `
success: Boolean
# Notes associated with the event
note: String
+ # top-level datasetId field
+ datasetId: ID
+ # User's notification status event
+ notificationStatus: UserNotificationStatus
+ responseStatus: String
+ hasBeenRespondedTo: Boolean
+ }
+
+
+ # Possible statuses for user notification/events
+ enum NotificationStatusType {
+ UNREAD
+ SAVED
+ ARCHIVED
+ }
+
+ # User's notification status
+ type UserNotificationStatus {
+ status: NotificationStatusType!
}
+
+
type FileCheck {
datasetId: String!
hexsha: String!
diff --git a/packages/openneuro-server/src/libs/events.ts b/packages/openneuro-server/src/libs/events.ts
index 6e822a697..f45c7318b 100644
--- a/packages/openneuro-server/src/libs/events.ts
+++ b/packages/openneuro-server/src/libs/events.ts
@@ -27,6 +27,11 @@ export async function createEvent(
},
}
Sentry.addBreadcrumb(breadcrumb)
+
+ if (!event.datasetId) {
+ event.datasetId = datasetId
+ }
+
const created = new DatasetEvent({
datasetId,
userId: user,
diff --git a/packages/openneuro-server/src/models/datasetEvents.ts b/packages/openneuro-server/src/models/datasetEvents.ts
index 1488d335e..5c19982ca 100644
--- a/packages/openneuro-server/src/models/datasetEvents.ts
+++ b/packages/openneuro-server/src/models/datasetEvents.ts
@@ -3,6 +3,7 @@ import type { Document } from "mongoose"
import type { OpenNeuroUserId } from "../types/user"
import { v4 as uuidv4 } from "uuid"
import type { UserDocument } from "./user"
+import type { UserNotificationStatusDocument } from "./userNotificationStatus"
const { Schema, model } = mongoose
const _datasetEventTypes = [
@@ -14,6 +15,9 @@ const _datasetEventTypes = [
"git",
"upload",
"note",
+ "contributorRequest",
+ "contributorResponse",
+ "contributorCitation",
] as const
/**
@@ -27,6 +31,8 @@ const _datasetEventTypes = [
* git - A git event modified the dataset's repository (git history provides details)
* upload - A non-git upload occurred (typically one file changed)
* note - A note unrelated to another event
+ * contributorRequest - a request event is created for user access
+ * contributorResponse - response of deny or approve is granted
*/
export type DatasetEventName = typeof _datasetEventTypes[number]
@@ -36,44 +42,87 @@ export type DatasetEventCommon = {
export type DatasetEventCreated = DatasetEventCommon & {
type: "created"
+ datasetId?: string
}
export type DatasetEventVersioned = DatasetEventCommon & {
type: "versioned"
version: string
+ datasetId?: string
}
export type DatasetEventDeleted = DatasetEventCommon & {
type: "deleted"
+ datasetId?: string
}
export type DatasetEventPublished = DatasetEventCommon & {
type: "published"
- // True if made public, false if made private
public: boolean
+ datasetId?: string
}
export type DatasetEventPermissionChange = DatasetEventCommon & {
type: "permissionChange"
- // User with the permission being changed
target: OpenNeuroUserId
level: string
+ datasetId?: string
}
export type DatasetEventGit = DatasetEventCommon & {
type: "git"
commit: string
reference: string
+ datasetId?: string
}
export type DatasetEventUpload = DatasetEventCommon & {
type: "upload"
+ datasetId?: string
}
export type DatasetEventNote = DatasetEventCommon & {
type: "note"
- // Is this note visible only to site admins?
admin: boolean
+ datasetId?: string
+}
+
+export type DatasetEventContributorRequest = DatasetEventCommon & {
+ type: "contributorRequest"
+ requestId?: string
+ resolutionStatus?: "pending" | "accepted" | "denied"
+ datasetId?: string
+ contributorType: string
+ contributorData: {
+ orcid?: string
+ name?: string
+ email?: string
+ userId?: string
+ }
+}
+
+export type DatasetEventContributorResponse = DatasetEventCommon & {
+ type: "contributorResponse"
+ requestId: string
+ targetUserId: OpenNeuroUserId
+ status: "accepted" | "denied"
+ reason?: string
+ datasetId?: string
+}
+
+export type DatasetEventContributorCitation = DatasetEventCommon & {
+ type: "contributorCitation"
+ datasetId: string
+ addedBy: OpenNeuroUserId
+ targetUserId: OpenNeuroUserId
+ contributorType: string
+ contributorData: {
+ orcid?: string
+ name?: string
+ email?: string
+ userId?: string
+ }
+ resolutionStatus: "pending" | "approved" | "denied"
}
/**
@@ -88,42 +137,66 @@ export type DatasetEventType =
| DatasetEventGit
| DatasetEventUpload
| DatasetEventNote
+ | DatasetEventContributorRequest
+ | DatasetEventContributorResponse
+ | DatasetEventContributorCitation
/**
* Dataset events log changes to a dataset
*/
export interface DatasetEventDocument extends Document {
- // Unique id for the event
id: string
- // Affected dataset
datasetId: string
- // Timestamp of the event
timestamp: Date
- // User id that triggered the event
userId: string
- // User that triggered the event
user: UserDocument
- // A description of the event, optional but recommended to provide context
event: DatasetEventType
- // Did the action logged succeed?
success: boolean
- // Admin notes
note: string
+ notificationStatus?: UserNotificationStatusDocument | null
}
-const datasetEventSchema = new Schema({
- id: { type: String, required: true, default: uuidv4 },
- datasetId: { type: String, required: true },
- timestamp: { type: Date, default: Date.now },
- userId: { type: String, required: true },
- event: {
- type: Object,
- required: true,
+const datasetEventSchema = new Schema(
+ {
+ id: { type: String, required: true, default: uuidv4 },
+ datasetId: { type: String, required: true },
+ timestamp: { type: Date, default: Date.now },
+ userId: { type: String, required: true },
+ event: {
+ type: { type: String, required: true, enum: _datasetEventTypes },
+ version: { type: String },
+ public: { type: Boolean },
+ target: { type: String },
+ level: { type: String },
+ commit: { type: String },
+ reference: { type: String },
+ admin: { type: Boolean, default: false },
+ requestId: { type: String, sparse: true, index: true },
+ targetUserId: { type: String },
+ status: { type: String, enum: ["accepted", "denied"] },
+ reason: { type: String },
+ datasetId: { type: String },
+ resolutionStatus: {
+ type: String,
+ enum: ["pending", "approved", "denied"],
+ default: "pending",
+ },
+ contributorType: { type: String },
+ contributorData: {
+ type: Object,
+ default: {},
+ },
+ },
+ success: { type: Boolean, default: false },
+ note: { type: String, default: "" },
},
- success: { type: Boolean, default: false },
- note: { type: String, default: "" },
-})
+ {
+ toObject: { virtuals: true },
+ toJSON: { virtuals: true },
+ },
+)
+// Virtual for the user who triggered the event
datasetEventSchema.virtual("user", {
ref: "User",
localField: "userId",
@@ -131,6 +204,14 @@ datasetEventSchema.virtual("user", {
justOne: true,
})
+// Virtual for the notification status associated with this event
+datasetEventSchema.virtual("notificationStatus", {
+ ref: "UserNotificationStatus",
+ localField: "id",
+ foreignField: "datasetEventId",
+ justOne: true,
+})
+
const DatasetEvent = model(
"DatasetEvent",
datasetEventSchema,
diff --git a/packages/openneuro-server/src/models/userNotificationStatus.ts b/packages/openneuro-server/src/models/userNotificationStatus.ts
new file mode 100644
index 000000000..e56ce0a8d
--- /dev/null
+++ b/packages/openneuro-server/src/models/userNotificationStatus.ts
@@ -0,0 +1,37 @@
+import mongoose from "mongoose"
+import type { Document } from "mongoose"
+const { Schema, model } = mongoose
+
+export type NotificationStatusType = "UNREAD" | "SAVED" | "ARCHIVED"
+
+export interface UserNotificationStatusDocument extends Document {
+ _id: string
+ userId: string
+ datasetEventId: string
+ status: NotificationStatusType
+ createdAt: Date
+ updatedAt: Date
+}
+
+const userNotificationStatusSchema = new Schema(
+ {
+ userId: { type: String, ref: "User", required: true },
+ datasetEventId: { type: String, ref: "DatasetEvent", required: true },
+ status: {
+ type: String,
+ enum: ["UNREAD", "SAVED", "ARCHIVED"],
+ default: "UNREAD",
+ required: true,
+ },
+ },
+ { timestamps: true },
+)
+
+userNotificationStatusSchema.index({ userId: 1, datasetEventId: 1 }, {
+ unique: true,
+})
+
+export const UserNotificationStatus = model(
+ "UserNotificationStatus",
+ userNotificationStatusSchema,
+)
diff --git a/packages/openneuro-server/src/types/datacite.ts b/packages/openneuro-server/src/types/datacite.ts
index b9e81392b..738dcfc7c 100644
--- a/packages/openneuro-server/src/types/datacite.ts
+++ b/packages/openneuro-server/src/types/datacite.ts
@@ -4,7 +4,7 @@
*/
/**
- * unique identifier for a person or organization.
+ * Unique identifier for a person or organization.
*/
export interface NameIdentifier {
nameIdentifier: string
@@ -23,7 +23,7 @@ export interface Affiliation {
}
/**
- * Contributor object.
+ * Contributor object (normalized form used internally in app).
*/
export interface Contributor {
name: string
@@ -31,41 +31,34 @@ export interface Contributor {
familyName?: string
orcid?: string
contributorType?: string
+ order?: number
+ userId?: string
}
/**
- * Creator object.
+ * Base interface shared by both creators and contributors in datacite.yml
*/
-export interface Creator {
- name: string
- givenName?: string
- familyName?: string
- orcid?: string
-}
-
-/**
- * raw Contributor object as it appears in datacite.yml.
- */
-export interface RawDataciteContributor {
+export interface RawDataciteBaseContributor {
name: string
nameType: "Personal" | "Organizational"
givenName?: string
familyName?: string
nameIdentifiers?: NameIdentifier[]
affiliation?: Affiliation[]
- contributorType: string
}
/**
- * raw Creator object as it appears in datacite.yml.
+ * Raw Creator object as it appears in datacite.yml creators array.
+ * Does NOT have contributorType.
*/
-export interface RawDataciteCreator {
- name: string
- nameType: "Personal" | "Organizational"
- givenName?: string
- familyName?: string
- nameIdentifiers?: NameIdentifier[]
- affiliation?: Affiliation[]
+export type RawDataciteCreator = RawDataciteBaseContributor
+
+/**
+ * Raw Contributor object as it appears in datacite.yml contributors array.
+ * Adds contributorType, which is required.
+ */
+export interface RawDataciteContributor extends RawDataciteBaseContributor {
+ contributorType: string
}
/**
@@ -77,11 +70,11 @@ export interface RawDataciteTypes {
}
/**
- * the main attributes section of the datacite.yml file.
+ * The main attributes section of the datacite.yml file.
*/
export interface RawDataciteAttributes {
- creators?: RawDataciteCreator[]
contributors?: RawDataciteContributor[]
+ creators?: RawDataciteCreator[]
types: RawDataciteTypes
}
diff --git a/packages/openneuro-server/src/utils/datacite-mapper.ts b/packages/openneuro-server/src/utils/datacite-mapper.ts
new file mode 100644
index 000000000..543a48432
--- /dev/null
+++ b/packages/openneuro-server/src/utils/datacite-mapper.ts
@@ -0,0 +1,21 @@
+import type { Contributor, RawDataciteContributor } from "../types/datacite"
+
+export const mapToRawContributor = (
+ c: Contributor,
+): RawDataciteContributor => ({
+ name: c.name,
+ nameType: "Personal",
+ contributorType: c.contributorType || "Researcher",
+ givenName: c.givenName,
+ familyName: c.familyName,
+ nameIdentifiers: c.orcid
+ ? [
+ {
+ nameIdentifier: c.orcid,
+ nameIdentifierScheme: "ORCID",
+ schemeUri: "https://orcid.org",
+ },
+ ]
+ : undefined,
+ affiliation: [],
+})
diff --git a/packages/openneuro-server/src/utils/datacite-utils.ts b/packages/openneuro-server/src/utils/datacite-utils.ts
index d734a71ae..62a8e1fff 100644
--- a/packages/openneuro-server/src/utils/datacite-utils.ts
+++ b/packages/openneuro-server/src/utils/datacite-utils.ts
@@ -1,61 +1,208 @@
-import yaml from "js-yaml"
import * as Sentry from "@sentry/node"
+import yaml from "js-yaml"
+import superagent from "superagent"
+import User from "../models/user"
import { fileUrl } from "../datalad/files"
-import type { RawDataciteYml } from "../types/datacite"
+import { commitFiles } from "../datalad/dataset"
+import { getDatasetWorker } from "../libs/datalad-service"
+import type {
+ Contributor,
+ RawDataciteContributor,
+ RawDataciteYml,
+} from "../types/datacite"
+import { validateOrcid } from "../utils/orcid-utils"
+
+/**
+ * Returns a minimal datacite.yml structure
+ */
+export const emptyDataciteYml = (): RawDataciteYml => ({
+ data: {
+ attributes: {
+ types: { resourceTypeGeneral: "Dataset" },
+ contributors: [],
+ creators: [],
+ },
+ },
+})
/**
- * Attempts to read and parse the datacite metadata file from the remote source.
- *
- * @param datasetId The ID of the dataset.
- * @param revision The specific revision of the dataset.
- * @returns A Promise that resolves to the parsed RawDataciteYml object or null if not found.
+ * Fetch datacite.yml for a dataset revision
*/
export const getDataciteYml = async (
datasetId: string,
- revision: string,
+ revision?: string,
): Promise => {
- // Construct the URL to the datacite.yml file for the given dataset and revision.
const dataciteFileUrl = fileUrl(datasetId, "", "datacite", revision)
try {
const res = await fetch(dataciteFileUrl)
- const contentType = res.headers.get("content-type")
-
if (res.status === 200) {
- // Log a message if the content type is not what we expect, but proceed with parsing.
- if (
- !contentType?.includes("application/yaml") &&
- !contentType?.includes("text/yaml")
- ) {
- Sentry.captureMessage(
- `Datacite file for ${datasetId}:${revision} served with unexpected Content-Type: ${contentType}. Attempting YAML parse anyway.`,
- )
- }
-
const text = await res.text()
- try {
- // Parse the YAML content into a JavaScript object with the specified type.
- const parsedYaml: RawDataciteYml = yaml.load(text) as RawDataciteYml
- return parsedYaml
- } catch (parseErr) {
- // If parsing fails, throw a detailed error.
- throw new Error(
- `Found datacite file for dataset ${datasetId} (revision: ${revision}), but failed to parse it as YAML:`,
- { cause: parseErr },
- )
- }
+ const parsed: RawDataciteYml = yaml.load(text) as RawDataciteYml
+
+ return parsed
} else if (res.status === 404) {
- // If the file is not found (404) return null.
return null
} else {
- // For any other unexpected status code, throw an error.
throw new Error(
- `Attempted to read datacite file for dataset ${datasetId} (revision: ${revision}) and received status ${res.status}.`,
+ `Unexpected status ${res.status} when fetching datacite.yml`,
)
}
- } catch (fetchErr) {
- // Catch any network or other errors during the fetch and report them to Sentry.
- Sentry.captureException(fetchErr)
+ } catch (err) {
+ Sentry.captureException(err)
return null
}
}
+
+/**
+ * Save datacite.yml back to dataset
+ */
+export const saveDataciteYmlToRepo = async (
+ datasetId: string,
+ cookies: string,
+ dataciteData: RawDataciteYml,
+) => {
+ const url = `${
+ getDatasetWorker(datasetId)
+ }/datasets/${datasetId}/files/datacite.yml`
+
+ try {
+ // Directly PUT the file using the user's request cookies
+ await superagent
+ .post(url)
+ .set("Cookie", cookies)
+ .set("Accept", "application/json")
+ .set("Content-Type", "text/yaml")
+ .send(yaml.dump(dataciteData))
+
+ // Commit the draft after upload
+ const gitRef = await commitFiles(datasetId, cookies)
+ return { id: gitRef }
+ } catch (err) {
+ Sentry.captureException(err)
+ throw err
+ }
+}
+/**
+ * Converts RawDataciteContributor -> internal Contributor type.
+ * Optionally attaches a `userId` if the contributor exists as a site user.
+ */
+export const normalizeRawContributors = async (
+ raw: RawDataciteContributor[] | undefined,
+): Promise => {
+ if (!Array.isArray(raw)) return []
+
+ const orcids = raw
+ .map((c) => validateOrcid(c.nameIdentifiers?.[0]?.nameIdentifier))
+ .filter(Boolean) as string[]
+
+ const users = await User.find({ orcid: { $in: orcids } }).exec()
+ const orcidToUserId = new Map(users.map((u) => [u.orcid, u.id]))
+
+ return raw.map((c, index) => {
+ const contributorOrcid = validateOrcid(
+ c.nameIdentifiers?.[0]?.nameIdentifier,
+ )
+ return {
+ name: c.name ||
+ [c.familyName, c.givenName].filter(Boolean).join(", ") ||
+ "Unknown Contributor",
+ givenName: c.givenName,
+ familyName: c.familyName,
+ orcid: contributorOrcid,
+ contributorType: c.contributorType || "Researcher",
+ userId: contributorOrcid
+ ? orcidToUserId.get(contributorOrcid)
+ : undefined,
+ order: index + 1,
+ }
+ })
+}
+/**
+ * Update contributors in datacite.yml
+ */
+export const updateContributors = async (
+ datasetId: string,
+ revision: string | undefined,
+ newContributors: Contributor[],
+ user: string,
+): Promise => {
+ try {
+ let dataciteData = await getDataciteYml(datasetId, revision)
+
+ // If no datacite.yml, create a new one
+ if (!dataciteData) {
+ dataciteData = emptyDataciteYml()
+ }
+
+ // Map contributors to RawDataciteContributor format
+ const rawContributors: RawDataciteContributor[] = newContributors.map((
+ c,
+ ) => ({
+ name: c.name,
+ givenName: c.givenName,
+ familyName: c.familyName,
+ contributorType: c.contributorType || "Researcher",
+ nameType: "Personal" as const,
+ nameIdentifiers: c.orcid
+ ? [{ nameIdentifier: c.orcid, nameIdentifierScheme: "ORCID" }]
+ : [],
+ }))
+
+ dataciteData.data.attributes.contributors = rawContributors
+ dataciteData.data.attributes.creators = rawContributors
+
+ await saveDataciteYmlToRepo(datasetId, user, dataciteData)
+
+ return true
+ } catch (err) {
+ Sentry.captureException(err)
+ return false
+ }
+}
+
+/**
+ * Utility function to update contributors in datacite.yml
+ */
+export const updateContributorsUtil = async (
+ datasetId: string,
+ newContributors: Contributor[],
+ userId: string,
+) => {
+ let dataciteData = await getDataciteYml(datasetId)
+ if (!dataciteData) dataciteData = emptyDataciteYml()
+
+ const contributorsCopy: RawDataciteContributor[] = newContributors.map(
+ (c) => ({
+ name: c.name,
+ givenName: c.givenName || "",
+ familyName: c.familyName || "",
+ order: c.order ?? null,
+ nameType: "Personal" as const,
+ nameIdentifiers: c.orcid
+ ? [{
+ nameIdentifier: `https://orcid.org/${c.orcid}`,
+ nameIdentifierScheme: "ORCID",
+ schemeUri: "https://orcid.org",
+ }]
+ : [],
+ contributorType: c.contributorType || "Researcher",
+ }),
+ )
+
+ dataciteData.data.attributes.contributors = contributorsCopy
+ dataciteData.data.attributes.creators = contributorsCopy.map((
+ { contributorType: _, ...rest },
+ ) => rest)
+
+ await saveDataciteYmlToRepo(datasetId, userId, dataciteData)
+
+ return {
+ draft: {
+ id: datasetId,
+ contributors: contributorsCopy,
+ files: [],
+ modified: new Date().toISOString(),
+ },
+ }
+}