@openneuro/server 4.47.6 → 5.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +10 -7
  2. package/src/app.ts +1 -1
  3. package/src/cache/__tests__/tree.spec.ts +212 -0
  4. package/src/cache/tree.ts +148 -0
  5. package/src/datalad/__tests__/dataRetentionNotifications.spec.ts +11 -0
  6. package/src/datalad/__tests__/files.spec.ts +249 -0
  7. package/src/datalad/dataRetentionNotifications.ts +5 -0
  8. package/src/datalad/dataset.ts +29 -1
  9. package/src/datalad/files.ts +362 -39
  10. package/src/datalad/snapshots.ts +29 -54
  11. package/src/graphql/resolvers/__tests__/response-status.spec.ts +42 -0
  12. package/src/graphql/resolvers/__tests__/user.spec.ts +55 -1
  13. package/src/graphql/resolvers/build-search-query.ts +391 -0
  14. package/src/graphql/resolvers/cache.ts +5 -1
  15. package/src/graphql/resolvers/dataset-search.ts +40 -23
  16. package/src/graphql/resolvers/datasetEvents.ts +48 -78
  17. package/src/graphql/resolvers/draft.ts +5 -2
  18. package/src/graphql/resolvers/holdDeletion.ts +21 -0
  19. package/src/graphql/resolvers/index.ts +6 -0
  20. package/src/graphql/resolvers/mutation.ts +2 -0
  21. package/src/graphql/resolvers/response-status.ts +43 -0
  22. package/src/graphql/resolvers/snapshots.ts +9 -18
  23. package/src/graphql/resolvers/summary.ts +17 -0
  24. package/src/graphql/resolvers/user.ts +1 -1
  25. package/src/graphql/schema.ts +54 -14
  26. package/src/handlers/datalad.ts +4 -0
  27. package/src/handlers/doi.ts +32 -36
  28. package/src/libs/doi/__tests__/doi.spec.ts +50 -12
  29. package/src/libs/doi/__tests__/validate.spec.ts +110 -0
  30. package/src/libs/doi/index.ts +108 -71
  31. package/src/libs/doi/metadata.ts +101 -0
  32. package/src/libs/doi/validate.ts +59 -0
  33. package/src/libs/presign.ts +137 -0
  34. package/src/models/dataset.ts +2 -0
  35. package/src/models/doi.ts +7 -0
  36. package/src/queues/producer-methods.ts +9 -5
  37. package/src/queues/queue-schedule.ts +1 -1
  38. package/src/queues/queues.ts +2 -2
  39. package/src/routes.ts +10 -2
  40. package/src/types/datacite/LICENSE +37 -0
  41. package/src/types/datacite/README.md +3 -0
  42. package/src/types/datacite/datacite-v4.5.json +643 -0
  43. package/src/types/datacite/datacite-v4.5.ts +281 -0
  44. package/src/types/datacite.ts +53 -63
  45. package/src/utils/datacite-mapper.ts +7 -3
  46. package/src/utils/datacite-utils.ts +12 -15
  47. package/src/libs/doi/__tests__/__snapshots__/doi.spec.ts.snap +0 -17
@@ -1,44 +1,43 @@
1
1
  import config from "../config"
2
- import doi from "../libs/doi"
2
+ import { createDraftDoi } from "../libs/doi"
3
+ import { assembleMetadata } from "../libs/doi/metadata"
3
4
  import Doi from "../models/doi"
4
5
  import Snapshot from "../models/snapshot"
5
6
 
6
7
  export async function createSnapshotDoi(req, res) {
7
- let doiRes = null
8
8
  if (!config.doi.username || !config.doi.password) {
9
- return res.send({ doiRes: null })
9
+ return res.send({ doi: null })
10
10
  }
11
11
  const datasetId = req.params.datasetId
12
12
  const snapshotId = req.params.snapshotId
13
- const oldDesc = req.body
14
- const doiExists = await Doi.findOne({
15
- datasetId: datasetId,
16
- snapshotId: snapshotId,
17
- })
13
+
14
+ // Return existing DOI if already registered
15
+ const doiExists = await Doi.findOne({ datasetId, snapshotId })
18
16
  if (doiExists) {
19
- doiRes = doiExists.doi
20
- return res.send({ doi: doiRes })
21
- } else {
22
- const snapExists = Snapshot.findOne({
23
- datasetId: datasetId,
24
- tag: snapshotId,
25
- }).exec()
26
- if (!snapExists) {
27
- return
28
- }
29
- await doi
30
- .registerSnapshotDoi(datasetId, snapshotId, oldDesc)
31
- .then((doiRes) => {
32
- if (doiRes) {
33
- Doi.updateOne(
34
- { datasetId: datasetId, snapshotId: snapshotId },
35
- { $set: { doi: doiRes } },
36
- { upsert: true },
37
- )
38
- return res.send({ doi: doiRes })
39
- }
40
- return res.send({ doiRes: null })
41
- })
17
+ return res.send({ doi: doiExists.doi })
18
+ }
19
+
20
+ const snapExists = await Snapshot.findOne({
21
+ datasetId,
22
+ tag: snapshotId,
23
+ }).exec()
24
+ if (!snapExists) {
25
+ return res.status(404).send({ error: "Snapshot not found" })
26
+ }
27
+
28
+ try {
29
+ const attributes = await assembleMetadata(datasetId, snapshotId)
30
+ const doi = await createDraftDoi(attributes)
31
+
32
+ await Doi.updateOne(
33
+ { datasetId, snapshotId },
34
+ { $set: { doi, state: "draft" } },
35
+ { upsert: true },
36
+ )
37
+
38
+ return res.send({ doi })
39
+ } catch (err) {
40
+ return res.status(500).send({ error: err.message })
42
41
  }
43
42
  }
44
43
 
@@ -47,11 +46,8 @@ export async function getDoi(req, res) {
47
46
  const datasetId = req.params.datasetId
48
47
  const snapshotId = req.params.snapshotId
49
48
  const doi = await Doi.findOne(
50
- {
51
- datasetId: datasetId,
52
- snapshotId: snapshotId,
53
- },
54
- "doi",
49
+ { datasetId, snapshotId },
50
+ "doi state",
55
51
  ).exec()
56
52
  return res.send(doi)
57
53
  }
@@ -1,25 +1,63 @@
1
1
  import { vi } from "vitest"
2
- import { formatBasicAuth, template } from "../index.js"
2
+ import { buildPayload, createDOI, formatBasicAuth } from "../index.js"
3
3
 
4
4
  vi.mock("ioredis")
5
5
 
6
6
  describe("DOI minting utils", () => {
7
- describe("auth()", () => {
7
+ describe("formatBasicAuth()", () => {
8
8
  it("returns a base64 basic auth string", () => {
9
9
  const doiConfig = { username: "test", password: "12345" }
10
10
  expect(formatBasicAuth(doiConfig)).toBe("Basic dGVzdDoxMjM0NQ==")
11
11
  })
12
12
  })
13
- describe("template()", () => {
14
- it("accepts expected arguments", () => {
15
- const context = {
16
- doi: "12345",
17
- creators: ["A. User", "B. User"],
18
- title: "Test Dataset",
19
- year: "1999",
20
- resourceType: "fMRI",
21
- }
22
- expect(template(context)).toMatchSnapshot()
13
+
14
+ describe("createDOI()", () => {
15
+ it("creates a DOI without snapshot", () => {
16
+ const doi = createDOI("ds000001")
17
+ expect(doi).toMatch(/\/openneuro\.ds000001$/)
18
+ })
19
+ it("creates a DOI with snapshot", () => {
20
+ const doi = createDOI("ds000001", "1.0.0")
21
+ expect(doi).toMatch(/\/openneuro\.ds000001\.v1\.0\.0$/)
22
+ })
23
+ })
24
+
25
+ describe("buildPayload()", () => {
26
+ const attributes = {
27
+ doi: "10.18112/openneuro.ds000001.v1.0.0",
28
+ url: "https://openneuro.org/datasets/ds000001/versions/1.0.0",
29
+ creators: [{ name: "A. User", nameType: "Personal" as const }],
30
+ titles: [{ title: "Test Dataset" }],
31
+ publisher: { name: "OpenNeuro" },
32
+ publicationYear: "2024",
33
+ types: { resourceTypeGeneral: "Dataset" as const },
34
+ schemaVersion: "http://datacite.org/schema/kernel-4" as const,
35
+ }
36
+
37
+ it("builds a valid Datacite JSON API payload", () => {
38
+ const payload = buildPayload(attributes)
39
+ expect(payload.data.type).toBe("dois")
40
+ expect(payload.data.attributes.doi).toBe(
41
+ "10.18112/openneuro.ds000001.v1.0.0",
42
+ )
43
+ expect(payload.data.attributes.event).toBeUndefined()
44
+ expect(payload.data.attributes.schemaVersion).toBe(
45
+ "http://datacite.org/schema/kernel-4",
46
+ )
47
+ })
48
+
49
+ it("omits event when not provided", () => {
50
+ const payload = buildPayload(attributes)
51
+ expect(payload.data.attributes.event).toBeUndefined()
52
+ })
53
+
54
+ it("preserves all metadata attributes", () => {
55
+ const payload = buildPayload(attributes, "publish")
56
+ expect(payload.data.attributes.creators).toHaveLength(1)
57
+ expect(payload.data.attributes.titles[0].title).toBe("Test Dataset")
58
+ expect(payload.data.attributes.publisher.name).toBe("OpenNeuro")
59
+ expect(payload.data.attributes.publicationYear).toBe("2024")
60
+ expect(payload.data.attributes.types.resourceTypeGeneral).toBe("Dataset")
23
61
  })
24
62
  })
25
63
  })
@@ -0,0 +1,110 @@
1
+ import { vi } from "vitest"
2
+ import { validateDataciteMetadata } from "../validate.js"
3
+ import type { ResourceTypeGeneral } from "../../../types/datacite/datacite-v4.5.ts"
4
+
5
+ vi.mock("ioredis")
6
+
7
+ describe("validateDataciteMetadata", () => {
8
+ const validAttrs = {
9
+ doi: "10.18112/openneuro.ds000001.v1.0.0",
10
+ url: "https://openneuro.org/datasets/ds000001/versions/1.0.0",
11
+ creators: [{ name: "A. User", nameType: "Personal" as const }],
12
+ titles: [{ title: "Test Dataset" }],
13
+ publisher: { name: "OpenNeuro" },
14
+ publicationYear: "2024",
15
+ types: { resourceTypeGeneral: "Dataset" as const },
16
+ schemaVersion: "http://datacite.org/schema/kernel-4" as const,
17
+ }
18
+
19
+ it("returns no errors for valid metadata", () => {
20
+ expect(validateDataciteMetadata(validAttrs)).toEqual([])
21
+ })
22
+
23
+ it("requires at least one creator", () => {
24
+ const errors = validateDataciteMetadata({ ...validAttrs, creators: [] })
25
+ expect(errors).toEqual(
26
+ expect.arrayContaining([
27
+ expect.objectContaining({ field: "creators" }),
28
+ ]),
29
+ )
30
+ })
31
+
32
+ it("requires each creator to have a name", () => {
33
+ const errors = validateDataciteMetadata({
34
+ ...validAttrs,
35
+ creators: [{ name: "", nameType: "Personal" }],
36
+ })
37
+ expect(errors).toEqual(
38
+ expect.arrayContaining([
39
+ expect.objectContaining({ field: "creators" }),
40
+ ]),
41
+ )
42
+ })
43
+
44
+ it("requires at least one title", () => {
45
+ const errors = validateDataciteMetadata({ ...validAttrs, titles: [] })
46
+ expect(errors).toEqual(
47
+ expect.arrayContaining([
48
+ expect.objectContaining({ field: "titles" }),
49
+ ]),
50
+ )
51
+ })
52
+
53
+ it("requires a non-empty title", () => {
54
+ const errors = validateDataciteMetadata({
55
+ ...validAttrs,
56
+ titles: [{ title: "" }],
57
+ })
58
+ expect(errors).toEqual(
59
+ expect.arrayContaining([
60
+ expect.objectContaining({ field: "titles" }),
61
+ ]),
62
+ )
63
+ })
64
+
65
+ it("requires publisher name", () => {
66
+ const errors = validateDataciteMetadata({
67
+ ...validAttrs,
68
+ publisher: { name: "" },
69
+ })
70
+ expect(errors).toEqual(
71
+ expect.arrayContaining([
72
+ expect.objectContaining({ field: "publisher" }),
73
+ ]),
74
+ )
75
+ })
76
+
77
+ it("requires a four-digit year string for publicationYear", () => {
78
+ const errors = validateDataciteMetadata({
79
+ ...validAttrs,
80
+ publicationYear: "0",
81
+ })
82
+ expect(errors).toEqual(
83
+ expect.arrayContaining([
84
+ expect.objectContaining({ field: "publicationYear" }),
85
+ ]),
86
+ )
87
+ })
88
+
89
+ it("requires resourceTypeGeneral", () => {
90
+ const errors = validateDataciteMetadata({
91
+ ...validAttrs,
92
+ types: {
93
+ resourceTypeGeneral: "" as unknown as ResourceTypeGeneral,
94
+ },
95
+ })
96
+ expect(errors).toEqual(
97
+ expect.arrayContaining([
98
+ expect.objectContaining({ field: "types" }),
99
+ ]),
100
+ )
101
+ })
102
+
103
+ it("returns multiple errors when multiple fields are invalid", () => {
104
+ const errors = validateDataciteMetadata({
105
+ doi: "10.18112/test",
106
+ url: "https://example.com",
107
+ })
108
+ expect(errors.length).toBeGreaterThanOrEqual(4)
109
+ })
110
+ })
@@ -1,32 +1,5 @@
1
- import request from "superagent"
2
1
  import config from "../../config"
3
-
4
- export const template = ({
5
- doi,
6
- creators,
7
- title,
8
- year,
9
- resourceType,
10
- }) =>
11
- `<?xml version="1.0" encoding="UTF-8"?>
12
- <resource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://datacite.org/schema/kernel-4" xsi:schemaLocation="http://datacite.org/schema/kernel-4 http://schema.datacite.org/meta/kernel-4/metadata.xsd">
13
- <identifier identifierType="DOI">${doi}</identifier>
14
- <creators>
15
- ${
16
- creators
17
- .map((creator) =>
18
- `<creator><creatorName>${creator}</creatorName></creator>`
19
- )
20
- .join("")
21
- }
22
- </creators>
23
- <titles>
24
- <title xml:lang="en-us">${title}</title>
25
- </titles>
26
- <publisher>Openneuro</publisher>
27
- <publicationYear>${year}</publicationYear>
28
- <resourceType resourceTypeGeneral="Dataset">${resourceType}</resourceType>
29
- </resource>`
2
+ import type { DataCite, DataciteDoiRequest } from "../../types/datacite"
30
3
 
31
4
  /**
32
5
  * @param {Object} doiConfig
@@ -37,50 +10,114 @@ export const formatBasicAuth = (doiConfig) =>
37
10
  "Basic " +
38
11
  Buffer.from(doiConfig.username + ":" + doiConfig.password).toString("base64")
39
12
 
40
- export default {
41
- auth: formatBasicAuth(config.doi),
42
- createDOI(accNumber, snapshotId) {
43
- let doi = config.doi.prefix + "/openneuro." + accNumber
44
- if (snapshotId) {
45
- doi = doi + ".v" + snapshotId
46
- }
47
- return doi
48
- },
13
+ /**
14
+ * Build a DOI string from dataset accession number and optional snapshot ID.
15
+ */
16
+ export function createDOI(accNumber: string, snapshotId?: string): string {
17
+ let doi = config.doi.prefix + "/openneuro." + accNumber
18
+ if (snapshotId) {
19
+ doi = doi + ".v" + snapshotId
20
+ }
21
+ return doi
22
+ }
23
+
24
+ /**
25
+ * Build the Datacite JSON API request payload.
26
+ */
27
+ export function buildPayload(
28
+ attributes: DataCite,
29
+ event?: DataCite["event"],
30
+ ): DataciteDoiRequest {
31
+ return {
32
+ data: {
33
+ type: "dois",
34
+ attributes: {
35
+ ...attributes,
36
+ ...(event ? { event } : {}),
37
+ schemaVersion: "http://datacite.org/schema/kernel-4",
38
+ },
39
+ },
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Create or update a DOI via the Datacite JSON REST API.
45
+ * Uses PUT to {baseUrl}dois/{doi} which handles both create and update.
46
+ */
47
+ export async function upsertDoi(
48
+ payload: DataciteDoiRequest,
49
+ ): Promise<Response> {
50
+ const doi = payload.data.attributes.doi
51
+ const url = `${config.doi.url}dois/${encodeURIComponent(doi)}`
52
+ const response = await fetch(url, {
53
+ method: "PUT",
54
+ headers: {
55
+ "Authorization": formatBasicAuth(config.doi),
56
+ "Content-Type": "application/vnd.api+json",
57
+ },
58
+ body: JSON.stringify(payload),
59
+ })
60
+ if (!response.ok) {
61
+ const body = await response.text()
62
+ throw new Error(
63
+ `Datacite API error ${response.status} for ${doi}: ${body}`,
64
+ )
65
+ }
66
+ return response
67
+ }
68
+
69
+ /**
70
+ * Transition a DOI's state without re-sending full metadata.
71
+ */
72
+ export async function updateDoiState(
73
+ doi: string,
74
+ event: DataCite["event"],
75
+ ): Promise<void> {
76
+ const url = `${config.doi.url}dois/${encodeURIComponent(doi)}`
77
+ const payload = {
78
+ data: {
79
+ type: "dois",
80
+ attributes: { event },
81
+ },
82
+ }
83
+ const response = await fetch(url, {
84
+ method: "PUT",
85
+ headers: {
86
+ "Authorization": formatBasicAuth(config.doi),
87
+ "Content-Type": "application/vnd.api+json",
88
+ },
89
+ body: JSON.stringify(payload),
90
+ })
91
+ if (!response.ok) {
92
+ const body = await response.text()
93
+ throw new Error(
94
+ `Datacite API state transition error ${response.status} for ${doi}: ${body}`,
95
+ )
96
+ }
97
+ }
49
98
 
50
- async mintDOI(doi, url) {
51
- return await request
52
- .put(config.doi.url + "doi/" + doi)
53
- .set("Authorization", this.auth)
54
- .set("Content-Type", "text/plain;charset=UTF-8")
55
- .send("doi=" + doi + "\nurl=" + url)
56
- },
99
+ /**
100
+ * Create a draft DOI for a dataset snapshot.
101
+ * Returns the DOI string.
102
+ */
103
+ export async function createDraftDoi(
104
+ attributes: DataCite,
105
+ ): Promise<string> {
106
+ const payload = buildPayload(attributes)
107
+ await upsertDoi(payload)
108
+ return attributes.doi
109
+ }
57
110
 
58
- registerMetadata(context) {
59
- const xml = template(context)
60
- return request
61
- .post(config.doi.url + "metadata/")
62
- .set("Authorization", this.auth)
63
- .set("Content-Type", "application/xml;charset=UTF-8")
64
- .send(xml)
65
- },
111
+ /**
112
+ * Transition a DOI from draft to findable.
113
+ */
114
+ export async function publishDoi(doi: string): Promise<void> {
115
+ await updateDoiState(doi, "publish")
116
+ }
66
117
 
67
- registerSnapshotDoi(datasetId, snapshotId, oldDesc) {
68
- const baseDoi = this.createDOI(datasetId, snapshotId)
69
- const url =
70
- `https://openneuro.org/datasets/${datasetId}/versions/${snapshotId}`
71
- const context = {
72
- doi: baseDoi,
73
- creators: oldDesc.Authors.filter((x) => x),
74
- title: oldDesc.Name,
75
- year: new Date().getFullYear(),
76
- resourceType: "fMRI",
77
- }
78
- return this.registerMetadata(context)
79
- .then(() => {
80
- return this.mintDOI(baseDoi, url)
81
- })
82
- .then(() => {
83
- return baseDoi
84
- })
85
- },
118
+ /**
119
+ * Transition a DOI from findable to registered (hidden but reserved).
120
+ */
121
+ export async function hideDoi(doi: string): Promise<void> {
122
+ await updateDoiState(doi, "hide")
86
123
  }
@@ -0,0 +1,101 @@
1
+ import config from "../../config"
2
+ import { createDOI } from "./index"
3
+ import { validateDataciteMetadata } from "./validate"
4
+ import { getDataciteYml } from "../../utils/datacite-utils"
5
+ import { description } from "../../datalad/description"
6
+ import { getPrimaryModality } from "../../graphql/resolvers/summary"
7
+ import type { Creator, DataCite } from "../../types/datacite"
8
+
9
+ /**
10
+ * Assemble Datacite metadata for a DOI from datacite.yml or BIDS fallback.
11
+ *
12
+ * Priority:
13
+ * 1. If datacite.yml exists and has creators, use its attributes as the base.
14
+ * 2. Otherwise, build minimal metadata from dataset_description.json.
15
+ *
16
+ * Always ensures publisher, publicationYear, types.resourceTypeGeneral,
17
+ * doi, and url are set.
18
+ */
19
+ export async function assembleMetadata(
20
+ datasetId: string,
21
+ snapshotId: string,
22
+ revision?: string,
23
+ ): Promise<DataCite> {
24
+ const doi = createDOI(datasetId, snapshotId)
25
+ const url = `${config.url}/datasets/${datasetId}/versions/${snapshotId}`
26
+
27
+ const dataciteYml = await getDataciteYml(datasetId, revision)
28
+ const ymlAttrs = dataciteYml?.data?.attributes
29
+
30
+ // Check if datacite.yml provided meaningful creator data
31
+ const hasDataciteCreators = Array.isArray(ymlAttrs?.creators) &&
32
+ ymlAttrs.creators.length > 0
33
+
34
+ let creators: Creator[]
35
+ let titles: DataCite["titles"]
36
+ let descriptions: DataCite["descriptions"]
37
+ let contributors: DataCite["contributors"]
38
+ let resourceType: string | undefined
39
+
40
+ if (hasDataciteCreators) {
41
+ // Use datacite.yml metadata
42
+ creators = ymlAttrs.creators
43
+ titles = ymlAttrs.descriptions?.length
44
+ ? [{ title: ymlAttrs.descriptions[0].description }]
45
+ : []
46
+ descriptions = ymlAttrs.descriptions
47
+ contributors = ymlAttrs.contributors
48
+ resourceType = ymlAttrs.types?.resourceType
49
+ } else {
50
+ // Fall back to BIDS dataset_description.json
51
+ const desc = await description({
52
+ id: datasetId,
53
+ revision: revision || "HEAD",
54
+ })
55
+ creators = (desc.Authors || [])
56
+ .filter((author: string) => author)
57
+ .map((author: string) => ({
58
+ name: author,
59
+ nameType: "Personal" as const,
60
+ }))
61
+ titles = [{ title: desc.Name || datasetId }]
62
+ descriptions = desc.Description
63
+ ? [{ description: desc.Description, descriptionType: "Abstract" }]
64
+ : undefined
65
+ contributors = undefined
66
+ resourceType = await getPrimaryModality(datasetId)
67
+ }
68
+
69
+ // If datacite.yml had titles via a different path, use them
70
+ if (hasDataciteCreators && titles.length === 0) {
71
+ const desc = await description({
72
+ id: datasetId,
73
+ revision: revision || "HEAD",
74
+ })
75
+ titles = [{ title: desc.Name || datasetId }]
76
+ }
77
+
78
+ const attributes: DataCite = {
79
+ doi,
80
+ url,
81
+ creators: creators as DataCite["creators"],
82
+ titles: titles as DataCite["titles"],
83
+ publisher: { name: "OpenNeuro" },
84
+ publicationYear: String(new Date().getFullYear()),
85
+ types: {
86
+ resourceTypeGeneral: "Dataset",
87
+ ...(resourceType ? { resourceType } : {}),
88
+ },
89
+ schemaVersion: "http://datacite.org/schema/kernel-4",
90
+ ...(descriptions ? { descriptions } : {}),
91
+ ...(contributors?.length ? { contributors } : {}),
92
+ }
93
+
94
+ const errors = validateDataciteMetadata(attributes)
95
+ if (errors.length > 0) {
96
+ const messages = errors.map((e) => `${e.field}: ${e.message}`).join("; ")
97
+ throw new Error(`DOI metadata validation failed: ${messages}`)
98
+ }
99
+
100
+ return attributes
101
+ }
@@ -0,0 +1,59 @@
1
+ import type { DataCite } from "../../types/datacite"
2
+
3
+ export interface ValidationError {
4
+ field: string
5
+ message: string
6
+ }
7
+
8
+ /**
9
+ * Validate required Datacite metadata fields before submitting to the API.
10
+ * Returns an empty array if valid.
11
+ */
12
+ export function validateDataciteMetadata(
13
+ attrs: Partial<DataCite>,
14
+ ): ValidationError[] {
15
+ const errors: ValidationError[] = []
16
+
17
+ if (!Array.isArray(attrs.creators) || attrs.creators.length === 0) {
18
+ errors.push({
19
+ field: "creators",
20
+ message: "At least one creator is required",
21
+ })
22
+ } else {
23
+ for (const creator of attrs.creators) {
24
+ if (!creator.name) {
25
+ errors.push({
26
+ field: "creators",
27
+ message: "Each creator must have a name",
28
+ })
29
+ break
30
+ }
31
+ }
32
+ }
33
+
34
+ if (!Array.isArray(attrs.titles) || attrs.titles.length === 0) {
35
+ errors.push({ field: "titles", message: "At least one title is required" })
36
+ } else if (!attrs.titles[0].title) {
37
+ errors.push({ field: "titles", message: "Title must not be empty" })
38
+ }
39
+
40
+ if (!attrs.publisher?.name) {
41
+ errors.push({ field: "publisher", message: "Publisher name is required" })
42
+ }
43
+
44
+ if (!attrs.publicationYear || !/^[0-9]{4}$/.test(attrs.publicationYear)) {
45
+ errors.push({
46
+ field: "publicationYear",
47
+ message: "Publication year must be a four-digit year string",
48
+ })
49
+ }
50
+
51
+ if (!attrs.types?.resourceTypeGeneral) {
52
+ errors.push({
53
+ field: "types",
54
+ message: "resourceTypeGeneral is required",
55
+ })
56
+ }
57
+
58
+ return errors
59
+ }