From 055071875c22aaaee31400c74eeec2f90150b783 Mon Sep 17 00:00:00 2001 From: Finlay Birnie Date: Thu, 2 Nov 2023 11:30:40 +0000 Subject: [PATCH 1/2] allow coauthors to accept with different email --- api/prisma/schema.prisma | 106 +++++++++--------- .../coauthor/__tests__/linkCoAuthor.test.ts | 31 ----- api/src/components/coauthor/controller.ts | 18 ++- 3 files changed, 60 insertions(+), 95 deletions(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index e7b05be1a..78c0cd7bc 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -53,34 +53,34 @@ model Images { } model Publication { - id String @id @default(cuid()) - url_slug String @unique @default(cuid()) - type PublicationType - doi String - publicationFlags PublicationFlags[] - PublicationBookmarks PublicationBookmarks[] - linkedTo Links[] @relation("from") - linkedFrom Links[] @relation("to") - topics Topic[] - versions PublicationVersion[] + id String @id @default(cuid()) + url_slug String @unique @default(cuid()) + type PublicationType + doi String + publicationFlags PublicationFlags[] + PublicationBookmarks PublicationBookmarks[] + linkedTo Links[] @relation("from") + linkedFrom Links[] @relation("to") + topics Topic[] + versions PublicationVersion[] } model PublicationVersion { - id String @id @default(cuid()) + id String @id @default(cuid()) versionOf String versionNumber Int - isLatestVersion Boolean @default(true) - isLatestLiveVersion Boolean @default(false) - publication Publication @relation(fields: [versionOf], references: [id], onDelete: Cascade) + isLatestVersion Boolean @default(true) + isLatestLiveVersion Boolean @default(false) + publication Publication @relation(fields: [versionOf], references: [id], onDelete: Cascade) createdBy String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [createdBy], references: [id], onDelete: Cascade) - currentStatus PublicationStatusEnum @default(DRAFT) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + currentStatus PublicationStatusEnum @default(DRAFT) publicationStatus PublicationStatus[] publishedDate DateTime? title String? - licence LicenceType @default(CC_BY) + licence LicenceType @default(CC_BY) conflictOfInterestStatus Boolean? conflictOfInterestText String? ethicalStatement String? @@ -88,11 +88,11 @@ model PublicationVersion { dataPermissionsStatement String? dataPermissionsStatementProvidedBy String? dataAccessStatement String? - selfDeclaration Boolean? @default(false) + selfDeclaration Boolean? @default(false) description String? keywords String[] content String? - language Languages @default(en) + language Languages @default(en) coAuthors CoAuthors[] References References[] funders Funders[] @@ -137,27 +137,25 @@ model PublicationStatus { status PublicationStatusEnum createdAt DateTime @default(now()) publicationVersionId String - publicationVersion PublicationVersion @relation(fields: [publicationVersionId], references: [id], onDelete: Cascade) + publicationVersion PublicationVersion @relation(fields: [publicationVersionId], references: [id], onDelete: Cascade) } model CoAuthors { - id String @id @default(cuid()) - email String - code String @default(cuid()) - confirmedCoAuthor Boolean @default(false) - approvalRequested Boolean @default(false) - linkedUser String? - position Int @default(0) - createdAt DateTime @default(now()) - reminderDate DateTime? - affiliations Json[] - isIndependent Boolean @default(false) - user User? @relation(fields: [linkedUser], references: [id]) - publicationVersion PublicationVersion @relation(fields: [publicationVersionId], references: [id], onDelete: Cascade) - publicationVersionId String + id String @id @default(cuid()) + email String + code String @default(cuid()) + confirmedCoAuthor Boolean @default(false) + approvalRequested Boolean @default(false) + linkedUser String? + position Int @default(0) + createdAt DateTime @default(now()) + reminderDate DateTime? + affiliations Json[] + isIndependent Boolean @default(false) + user User? @relation(fields: [linkedUser], references: [id]) + publicationVersion PublicationVersion @relation(fields: [publicationVersionId], references: [id], onDelete: Cascade) + publicationVersionId String - // This @@unique needs to be disabled for the migration step because the fields involved must be mandatory. - // It needs to be reinstated after migration with publicationVersionId, not publicationId. @@unique([publicationVersionId, email]) } @@ -195,24 +193,24 @@ model PublicationBookmarks { } model Topic { - id String @id @default(cuid()) - title String - language Languages @default(en) - translations TopicTranslation[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - parents Topic[] @relation("TopicHierarchy") - children Topic[] @relation("TopicHierarchy") - publications Publication[] + id String @id @default(cuid()) + title String + language Languages @default(en) + translations TopicTranslation[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parents Topic[] @relation("TopicHierarchy") + children Topic[] @relation("TopicHierarchy") + publications Publication[] } model Bookmark { - id String @id @default(cuid()) - type BookmarkType - entityId String - userId String - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + type BookmarkType + entityId String + userId String + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([entityId, type, userId]) } @@ -471,4 +469,4 @@ enum ReferenceType { enum BookmarkType { PUBLICATION TOPIC -} \ No newline at end of file +} diff --git a/api/src/components/coauthor/__tests__/linkCoAuthor.test.ts b/api/src/components/coauthor/__tests__/linkCoAuthor.test.ts index b2cd0920b..cb3cfbd58 100644 --- a/api/src/components/coauthor/__tests__/linkCoAuthor.test.ts +++ b/api/src/components/coauthor/__tests__/linkCoAuthor.test.ts @@ -94,35 +94,4 @@ describe('Link co-author', () => { expect(link.status).toEqual(404); }); - - // the following test covers an edge case, but still possible - test('Cannot link co-author with a different email address', async () => { - // trying to accept invitation with a user which is not a co-author - const response = await testUtils.agent - .patch('/publication-versions/publication-problem-draft-v1/link-coauthor') - .query({ apiKey: '000000004' }) - .send({ - email: 'test-user-7@jisc.ac.uk', - code: 'test-code-user-7', - approve: true - }); - - expect(response.status).toEqual(404); - expect(response.body.message).toBe('You are not currently listed as an author on this draft'); - - // trying to accept invitation with a different co-author account - const response2 = await testUtils.agent - .patch('/publication-versions/publication-problem-draft-v1/link-coauthor') - .query({ apiKey: '000000008' }) - .send({ - email: 'test-user-7@jisc.ac.uk', - code: 'test-code-user-7', - approve: true - }); - - expect(response2.status).toEqual(403); - expect(response2.body.message).toBe( - 'Your email address does not match the one to which the invitation has been sent.' - ); - }); }); diff --git a/api/src/components/coauthor/controller.ts b/api/src/components/coauthor/controller.ts index c3267fdc5..7010000ff 100644 --- a/api/src/components/coauthor/controller.ts +++ b/api/src/components/coauthor/controller.ts @@ -269,19 +269,17 @@ export const link = async ( }); } - // check if the user email is the same as the one the invitation has been sent to - if (event.user.email !== coAuthorByEmail.email) { - const isCoAuthor = version.coAuthors.some((coAuthor) => coAuthor.email === event.user?.email); // check that this user is a coAuthor + await coAuthorService.linkUser(event.user.id, version.id, event.body.email, event.body.code); - return response.json(isCoAuthor ? 403 : 404, { - message: isCoAuthor - ? 'Your email address does not match the one to which the invitation has been sent.' - : 'You are not currently listed as an author on this draft' - }); + // The email of the linked user may not match the email the invitation was sent to + // (e.g. user manages their orcid account with a different email to their work email). + // In this case, we need to update the coauthor's email field because it becomes outdated. + if (event.user.email !== coAuthorByEmail.email) { + // We already check that the logged in user's email is not already a coauthor on this version, + // so this is safe. + await coAuthorService.update(coAuthorByEmail.id, { email: event.user.email }); } - await coAuthorService.linkUser(event.user.id, version.id, event.body.email, event.body.code); - return response.json(200, 'Linked user account'); } catch (err) { console.log(err); From d8b4aa65052e35af775a5404024f7f34d61aa39c Mon Sep 17 00:00:00 2001 From: florin-holhos <48569671+florin-holhos@users.noreply.github.com> Date: Fri, 3 Nov 2023 15:17:40 +0200 Subject: [PATCH 2/2] OC-389 Versioned DOIs (#527) * finished script, API and UI work * fixed ts issue after rebase * fixed alias paths when running seed * fixed createVersionedDOIs script --------- Co-authored-by: Florin H --- api/package.json | 3 +- api/prisma/createVersionedDOIs.ts | 119 +++ .../migration.sql | 2 + api/prisma/schema.prisma | 1 + .../publicationVersion/controller.ts | 19 +- .../components/publicationVersion/service.ts | 98 +- api/src/lib/helpers.ts | 271 ++++- api/src/scripts/updateDoi.ts | 17 +- .../Publication/SidebarCard/General/index.tsx | 33 +- ui/src/lib/interfaces.ts | 1 + ui/src/pages/publications/[id]/index.tsx | 16 +- .../[id]/versions/[versionId].tsx | 926 ++++++++++++++++++ 12 files changed, 1400 insertions(+), 106 deletions(-) create mode 100644 api/prisma/createVersionedDOIs.ts create mode 100644 api/prisma/migrations/20231026083835_added_version_doi/migration.sql create mode 100644 ui/src/pages/publications/[id]/versions/[versionId].tsx diff --git a/api/package.json b/api/package.json index 7b29adc15..f89e3c6c2 100644 --- a/api/package.json +++ b/api/package.json @@ -13,7 +13,7 @@ "node": "18.x" }, "prisma": { - "seed": "ts-node prisma/seed.ts" + "seed": "ts-node -r tsconfig-paths/register prisma/seed.ts" }, "scripts": { "prepare": "cd .. && husky install", @@ -27,6 +27,7 @@ "convertResearchTopics": "ts-node -r tsconfig-paths/register prisma/convertResearchTopics.ts", "convertPublicationBookmarks": "ts-node prisma/convertPublicationBookmarks.ts", "removeResearchTopicProblems": "ts-node -r tsconfig-paths/register prisma/removeResearchTopicProblems.ts", + "createVersionedDOIs": "ts-node -r tsconfig-paths/register prisma/createVersionedDOIs.ts", "format:check": "npx prettier --check src/", "format:write": "npx prettier --write src/", "lint:check": "eslint src/", diff --git a/api/prisma/createVersionedDOIs.ts b/api/prisma/createVersionedDOIs.ts new file mode 100644 index 000000000..085521e77 --- /dev/null +++ b/api/prisma/createVersionedDOIs.ts @@ -0,0 +1,119 @@ +import * as client from '../src/lib/client'; +import * as helpers from '../src/lib/helpers'; +import * as publicationVersionService from '../src/components/publicationVersion/service'; + +const createVersionedDOIs = async (): Promise => { + // get the latest LIVE version of each publication + const latestLiveVersions = await client.prisma.publicationVersion.findMany({ + where: { + isLatestLiveVersion: true, + createdBy: { + not: 'octopus' // ignore seed data publications + }, + doi: null // only get versions without DOI + }, + include: { + publication: { + select: { + id: true, + type: true, + doi: true, + topics: true, + publicationFlags: true, + url_slug: true + } + }, + publicationStatus: { + select: { + status: true, + createdAt: true, + id: true + }, + orderBy: { + createdAt: 'desc' + } + }, + funders: { + select: { + id: true, + city: true, + country: true, + name: true, + link: true, + ror: true + } + }, + coAuthors: { + select: { + id: true, + email: true, + linkedUser: true, + publicationVersionId: true, + confirmedCoAuthor: true, + approvalRequested: true, + createdAt: true, + reminderDate: true, + isIndependent: true, + affiliations: true, + user: { + select: { + firstName: true, + lastName: true, + orcid: true + } + } + }, + orderBy: { + position: 'asc' + } + }, + user: { + select: { + id: true, + orcid: true, + firstName: true, + lastName: true, + email: true, + createdAt: true, + updatedAt: true + } + } + } + }); + + console.log(`Found ${latestLiveVersions.length} without a DOI.`); + + let createdVersionDOIsCount = 0; + + for (const version of latestLiveVersions) { + // create a new DOI for each version + console.log(`Creating version ${version.versionNumber} DOI for publication ${version.versionOf}`); + const versionDOIResponse = await helpers.createPublicationVersionDOI(version); + const versionDOI = versionDOIResponse.data.attributes.doi; + + console.log(`Successfully created version ${version.versionNumber} DOI: ${versionDOI}`); + + // update version DOI + console.log(`Updating version DOI: ${versionDOI} in DB`); + const updatedVersion = await publicationVersionService.update(version.id, { + doi: versionDOI + }); + + // update publication "HasVersion" with the created DOI + console.log(`Updating canonical DOI: ${version.publication.doi} "HasVersion" with version DOI: ${versionDOI}`); + await helpers.updatePublicationDOI(version.publication.doi, updatedVersion); + + console.log(`Successfully updated canonical DOI: ${version.publication.doi}`); + + createdVersionDOIsCount += 1; + console.log(); // new line + } + + return createdVersionDOIsCount; +}; + +createVersionedDOIs() + .then((versionedDOIsCount) => + console.log(`Successfully created ${versionedDOIsCount} versioned DOIs and updated their canonical DOIs`) + ) + .catch((err) => console.log(err)); diff --git a/api/prisma/migrations/20231026083835_added_version_doi/migration.sql b/api/prisma/migrations/20231026083835_added_version_doi/migration.sql new file mode 100644 index 000000000..37bc3330f --- /dev/null +++ b/api/prisma/migrations/20231026083835_added_version_doi/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PublicationVersion" ADD COLUMN "doi" TEXT; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 78c0cd7bc..3986d705e 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -67,6 +67,7 @@ model Publication { model PublicationVersion { id String @id @default(cuid()) + doi String? versionOf String versionNumber Int isLatestVersion Boolean @default(true) diff --git a/api/src/components/publicationVersion/controller.ts b/api/src/components/publicationVersion/controller.ts index e157801b0..2c51ab7a6 100644 --- a/api/src/components/publicationVersion/controller.ts +++ b/api/src/components/publicationVersion/controller.ts @@ -4,7 +4,6 @@ import * as response from 'lib/response'; import * as publicationVersionService from 'publicationVersion/service'; import * as publicationService from 'publication/service'; import * as coAuthorService from 'coauthor/service'; -import * as referenceService from 'reference/service'; import * as helpers from 'lib/helpers'; import * as sqs from 'lib/sqs'; @@ -228,7 +227,16 @@ export const updateStatus = async ( } } - const updatedVersion = await publicationVersionService.updateStatus(publicationVersion.id, newStatus); + // create version DOI + const publicationVersionDOI = await helpers.createPublicationVersionDOI(publicationVersion); + + // update version status first so that published date is available for Open Search + await publicationVersionService.updateStatus(publicationVersion.id, 'LIVE'); + + // update version DOI into DB + const updatedVersion = await publicationVersionService.update(publicationVersion.id, { + doi: publicationVersionDOI.data.attributes.doi + }); // now that the publication version is LIVE, add/update the opensearch record await publicationService.createOpenSearchRecord({ @@ -243,11 +251,8 @@ export const updateStatus = async ( cleanContent: htmlToText.convert(updatedVersion.content) }); - const references = await referenceService.getAllByPublicationVersion(updatedVersion.id); - const { linkedTo } = await publicationService.getDirectLinksForPublication(publicationVersion.versionOf, true); - - // Publication version is live, so update the DOI - await helpers.updateDOI(publicationVersion.publication.doi, publicationVersion, linkedTo, references); + // Publication version is live, so update the canonical DOI with this version info + await helpers.updatePublicationDOI(publicationVersion.publication.doi, updatedVersion); // send message to the pdf generation queue // currently only on deployed instances while a local solution is developed diff --git a/api/src/components/publicationVersion/service.ts b/api/src/components/publicationVersion/service.ts index c17f2714a..5986c7c10 100644 --- a/api/src/components/publicationVersion/service.ts +++ b/api/src/components/publicationVersion/service.ts @@ -209,32 +209,21 @@ export const getAllByPublicationIds = async (ids: string[]) => { return latestVersions; }; -export const update = (id: string, updateContent: I.UpdatePublicationVersionRequestBody) => +export const update = (id: string, data: Prisma.PublicationVersionUpdateInput) => client.prisma.publicationVersion.update({ where: { id }, - data: updateContent - }); - -export const updateStatus = async (id: string, status: I.PublicationStatusEnum) => { - const query = { - where: { - id - }, - data: { - currentStatus: status, - publicationStatus: { - create: { - status + data, + include: { + publication: { + select: { + id: true, + type: true, + doi: true, + url_slug: true } }, - ...(status === 'LIVE' && { - publishedDate: new Date().toISOString(), - isLatestLiveVersion: true - }) - }, - include: { publicationStatus: { select: { status: true, @@ -242,28 +231,75 @@ export const updateStatus = async (id: string, status: I.PublicationStatusEnum) id: true }, orderBy: { - createdAt: Prisma.SortOrder.desc + createdAt: 'desc' } }, - user: { + funders: { select: { id: true, - firstName: true, - lastName: true + city: true, + country: true, + name: true, + link: true, + ror: true } }, - publication: { + coAuthors: { select: { - type: true + id: true, + email: true, + linkedUser: true, + publicationVersionId: true, + confirmedCoAuthor: true, + approvalRequested: true, + createdAt: true, + reminderDate: true, + isIndependent: true, + affiliations: true, + user: { + select: { + firstName: true, + lastName: true, + orcid: true + } + } + }, + orderBy: { + position: 'asc' + } + }, + user: { + select: { + id: true, + orcid: true, + firstName: true, + lastName: true, + email: true, + createdAt: true, + updatedAt: true } } } - }; - - const updatedPublication = await client.prisma.publicationVersion.update(query); + }); - return updatedPublication; -}; +export const updateStatus = async (id: string, status: I.PublicationStatusEnum) => + client.prisma.publicationVersion.update({ + where: { + id + }, + data: { + currentStatus: status, + publicationStatus: { + create: { + status + } + }, + ...(status === 'LIVE' && { + publishedDate: new Date().toISOString(), + isLatestLiveVersion: true + }) + } + }); export const validateConflictOfInterest = (version: I.PublicationVersion) => { if (version.conflictOfInterestStatus) { diff --git a/api/src/lib/helpers.ts b/api/src/lib/helpers.ts index 1a2e5d5f6..d9fb3fa36 100644 --- a/api/src/lib/helpers.ts +++ b/api/src/lib/helpers.ts @@ -2,6 +2,8 @@ import axios from 'axios'; import fs from 'fs'; import * as cheerio from 'cheerio'; import * as I from 'interface'; +import * as referenceService from 'reference/service'; +import * as publicationService from 'publication/service'; import { licences } from './enum'; import { webcrypto } from 'crypto'; @@ -82,23 +84,27 @@ export const getFullDOIsStrings = (text: string): [] | RegExpMatchArray => /(\s+)?(\(|\(\s+)?(?:DOI((\s+)?([:-])?(\s+)?))?(10\.[0-9a-zA-Z]+\/(?:(?!["&\'])\S)+)\b(\)|\s+\))?(\.)?/gi //eslint-disable-line ) || []; -export const updateDOI = async ( +export const updatePublicationDOI = async ( doi: string, - publicationVersion: I.PublicationVersion, - linkedTo: I.LinkedToPublication[], - references: I.Reference[] + latestPublicationVersion: I.PublicationVersion ): Promise => { - if (!publicationVersion) { + if (!latestPublicationVersion) { throw Error('Publication not found'); } - if (!publicationVersion.isLatestVersion) { + if (!latestPublicationVersion.isLatestVersion) { throw Error('Supplied version is not current'); } + const references = await referenceService.getAllByPublicationVersion(latestPublicationVersion.id); + const { linkedTo } = await publicationService.getDirectLinksForPublication( + latestPublicationVersion.versionOf, + true + ); + const creators: I.DataCiteCreator[] = []; - publicationVersion.coAuthors.forEach((author) => { + latestPublicationVersion.coAuthors.forEach((author) => { if (author.user) { creators.push( createCreatorObject({ @@ -111,58 +117,71 @@ export const updateDOI = async ( } }); - const linkedPublications = linkedTo.map((link) => ({ + const linkedPublicationDOIs = linkedTo.map((link) => ({ relatedIdentifier: link.doi, relatedIdentifierType: 'DOI', relationType: link.type === 'PEER_REVIEW' ? 'Reviews' : 'Continues' })); - const doiReferences = references.map((reference) => { - if (reference.type !== 'DOI' || !reference.location) return; + const referenceDOIs = references + .map((reference) => { + if (reference.type !== 'DOI' || !reference.location) return; - const doi = getFullDOIsStrings(reference.location); + const doi = getFullDOIsStrings(reference.location); - return { - relatedIdentifier: doi[0], - relatedIdentifierType: 'DOI', - relationType: 'References' - }; - }); + return { + relatedIdentifier: doi[0], + relatedIdentifierType: 'DOI', + relationType: 'References' + }; + }) + .filter((reference) => reference); - const allReferencesWithDOI = doiReferences.concat(linkedPublications); - - const otherReferences = references.map((reference) => { - if (reference.type === 'DOI') return; + const publicationVersionDOIs = [ + { + relatedIdentifier: latestPublicationVersion.doi, + relatedIdentifierType: 'DOI', + relationType: 'HasVersion', + resourceTypeGeneral: latestPublicationVersion.publication.type === 'PEER_REVIEW' ? 'PeerReview' : 'Other' + } + ]; - const mutatedReference = { - titles: [ - { - title: reference.text.replace(/(<([^>]+)>)/gi, '') - } - ], - relationType: 'References', - relatedItemType: 'Other' - }; + const otherReferences = references + .map((reference) => { + if (reference.type === 'DOI') return; - return reference.location - ? { - ...mutatedReference, - relatedItemIdentifier: { - relatedItemIdentifier: reference.location, - relatedItemIdentifierType: 'URL' + const mutatedReference = { + titles: [ + { + title: reference.text.replace(/(<([^>]+)>)/gi, '') + } + ], + relationType: 'References', + relatedItemType: 'Other' + }; + + return reference.location + ? { + ...mutatedReference, + relatedItemIdentifier: { + relatedItemIdentifier: reference.location, + relatedItemIdentifierType: 'URL' + } } - } - : mutatedReference; - }); + : mutatedReference; + }) + .filter((reference) => reference); // check if the creator of this version of the publication is not listed as an author - if (!publicationVersion.coAuthors.find((author) => author.linkedUser === publicationVersion.createdBy)) { + if ( + !latestPublicationVersion.coAuthors.find((author) => author.linkedUser === latestPublicationVersion.createdBy) + ) { // add creator to authors list as first author creators?.unshift( createCreatorObject({ - firstName: publicationVersion.user.firstName, - lastName: publicationVersion.user.lastName, - orcid: publicationVersion.user.orcid, + firstName: latestPublicationVersion.user.firstName, + lastName: latestPublicationVersion.user.lastName, + orcid: latestPublicationVersion.user.orcid, affiliations: [] }) ); @@ -173,7 +192,7 @@ export const updateDOI = async ( types: 'doi', attributes: { event: 'publish', - url: `${process.env.BASE_URL}/publications/${publicationVersion.versionOf}`, + url: `${process.env.BASE_URL}/publications/${latestPublicationVersion.versionOf}`, doi: doi, identifiers: [ { @@ -182,6 +201,157 @@ export const updateDOI = async ( } ], creators, + titles: [ + { + title: latestPublicationVersion.title, + lang: 'en' + } + ], + publisher: 'Octopus', + publicationYear: latestPublicationVersion.createdAt.getFullYear(), + contributors: [ + { + name: `${latestPublicationVersion.user.lastName} ${latestPublicationVersion.user.firstName}`, + contributorType: 'ContactPerson', + nameType: 'Personal', + givenName: latestPublicationVersion.user.firstName, + familyName: latestPublicationVersion.user.lastName, + nameIdentifiers: [ + { + nameIdentifier: latestPublicationVersion.user.orcid, + nameIdentifierScheme: 'ORCID', + schemeUri: 'https://orcid.org/' + } + ] + } + ], + language: 'en', + types: { + resourceTypeGeneral: 'Other', + resourceType: latestPublicationVersion.publication.type + }, + relatedIdentifiers: [...linkedPublicationDOIs, ...referenceDOIs, ...publicationVersionDOIs], + relatedItems: otherReferences, + fundingReferences: latestPublicationVersion.funders.map((funder) => ({ + funderName: funder.name, + funderReference: funder.ror || funder.link, + funderIdentifierType: funder.ror ? 'ROR' : 'Other' + })) + } + } + }; + + const doiRes = await axios.put(`${process.env.DATACITE_ENDPOINT as string}/${doi}`, payload, { + auth: { + username: process.env.DATACITE_USER as string, + password: process.env.DATACITE_PASSWORD as string + } + }); + + return doiRes.data; +}; + +export const createPublicationVersionDOI = async (publicationVersion: I.PublicationVersion): Promise => { + if (!publicationVersion) { + throw Error('Publication not found'); + } + + if (!publicationVersion.isLatestVersion) { + throw Error('Supplied version is not current'); + } + + const references = await referenceService.getAllByPublicationVersion(publicationVersion.id); + const { linkedTo } = await publicationService.getDirectLinksForPublication(publicationVersion.versionOf, true); + + const creators: I.DataCiteCreator[] = []; + + publicationVersion.coAuthors.forEach((author) => { + if (author.user) { + creators.push( + createCreatorObject({ + firstName: author.user.firstName, + lastName: author.user.lastName, + orcid: author.user.orcid, + affiliations: author.affiliations as unknown as I.MappedOrcidAffiliation[] + }) + ); + } + }); + + const linkedPublicationDOIs = linkedTo.map((link) => ({ + relatedIdentifier: link.doi, + relatedIdentifierType: 'DOI', + relationType: link.type === 'PEER_REVIEW' ? 'Reviews' : 'Continues' + })); + + const referenceDOIs = references + .map((reference) => { + if (reference.type !== 'DOI' || !reference.location) return; + + const doi = getFullDOIsStrings(reference.location); + + return { + relatedIdentifier: doi[0], + relatedIdentifierType: 'DOI', + relationType: 'References' + }; + }) + .filter((reference) => reference); + + const publicationDOI = { + relatedIdentifier: publicationVersion.publication.doi, + relatedIdentifierType: 'DOI', + relationType: 'IsVersionOf', // these relationTypes will be updated once we provide functionality to create new versions + resourceTypeGeneral: publicationVersion.publication.type === 'PEER_REVIEW' ? 'PeerReview' : 'Other' + }; + + const otherReferences = references + .map((reference) => { + if (reference.type === 'DOI') return; + + const mutatedReference = { + titles: [ + { + title: reference.text.replace(/(<([^>]+)>)/gi, '') + } + ], + relationType: 'References', + relatedItemType: 'Other' + }; + + return reference.location + ? { + ...mutatedReference, + relatedItemIdentifier: { + relatedItemIdentifier: reference.location, + relatedItemIdentifierType: 'URL' + } + } + : mutatedReference; + }) + .filter((reference) => reference); + + // check if the creator of this version of the publication is not listed as an author + if (!publicationVersion.coAuthors.find((author) => author.linkedUser === publicationVersion.createdBy)) { + // add creator to authors list as first author + creators?.unshift( + createCreatorObject({ + firstName: publicationVersion.user.firstName, + lastName: publicationVersion.user.lastName, + orcid: publicationVersion.user.orcid, + affiliations: [] + }) + ); + } + + const payload = { + data: { + types: 'dois', + attributes: { + prefix: process.env.DOI_PREFIX, + event: 'publish', + url: `${process.env.BASE_URL}/publications/${publicationVersion.versionOf}/versions/${publicationVersion.versionNumber}`, + creators, titles: [ { title: publicationVersion.title, @@ -208,28 +378,29 @@ export const updateDOI = async ( ], language: 'en', types: { - resourceTypeGeneral: 'Other', + resourceTypeGeneral: publicationVersion.publication.type === 'PEER_REVIEW' ? 'PeerReview' : 'Other', resourceType: publicationVersion.publication.type }, - relatedIdentifiers: allReferencesWithDOI, + relatedIdentifiers: [...linkedPublicationDOIs, ...referenceDOIs, publicationDOI], relatedItems: otherReferences, fundingReferences: publicationVersion.funders.map((funder) => ({ funderName: funder.name, funderReference: funder.ror || funder.link, funderIdentifierType: funder.ror ? 'ROR' : 'Other' - })) + })), + version: publicationVersion.versionNumber } } }; - const doiRes = await axios.put(`${process.env.DATACITE_ENDPOINT as string}/${doi}`, payload, { + const doi = await axios.post(`${process.env.DATACITE_ENDPOINT}`, payload, { auth: { username: process.env.DATACITE_USER as string, password: process.env.DATACITE_PASSWORD as string } }); - return doiRes.data; + return doi.data; }; export const octopusInformation: I.OctopusInformation = { @@ -770,7 +941,7 @@ export const createPublicationHTMLTemplate = ( .map( (author) => `${author.user?.firstName} ${author.user?.lastName}` + - (author.affiliationNumbers.length ? '' + author.affiliationNumbers + '' : '') + + (author.affiliationNumbers.length ? `${author.affiliationNumbers}` : '') + '' ) .join(', ')} diff --git a/api/src/scripts/updateDoi.ts b/api/src/scripts/updateDoi.ts index 8c10e9662..c799d4964 100644 --- a/api/src/scripts/updateDoi.ts +++ b/api/src/scripts/updateDoi.ts @@ -1,9 +1,11 @@ import * as client from '../lib/client'; import * as helpers from '../lib/helpers'; -import * as referenceService from 'reference/service'; -import * as publicationService from 'publication/service'; -const updateDoi = async (): Promise => { +/** + * This script will update canonical DOIs with their latest versions + */ + +const updatePublicationDOIs = async (): Promise => { const latestLiveVersions = await client.prisma.publicationVersion.findMany({ where: { isLatestLiveVersion: true @@ -80,16 +82,11 @@ const updateDoi = async (): Promise => { let index = 1; for (const version of latestLiveVersions) { - const references = await referenceService.getAllByPublicationVersion(version.id); - const { linkedTo } = await publicationService.getDirectLinksForPublication(version.versionOf); - - await helpers - .updateDOI(version.publication.doi, version, linkedTo, references) - .catch((err) => console.log(err)); + await helpers.updatePublicationDOI(version.publication.doi, version).catch((err) => console.log(err)); console.log(`No: ${index}. ${version.title} doi updated (${version.publication.doi})`); index++; } }; -updateDoi().catch((err) => console.log(err)); +updatePublicationDOIs().catch((err) => console.log(err)); diff --git a/ui/src/components/Publication/SidebarCard/General/index.tsx b/ui/src/components/Publication/SidebarCard/General/index.tsx index 4debef787..83b4a22e6 100644 --- a/ui/src/components/Publication/SidebarCard/General/index.tsx +++ b/ui/src/components/Publication/SidebarCard/General/index.tsx @@ -74,21 +74,44 @@ const General: React.FC = (props): React.ReactElement => { + {/** + * @TODO - remove stage check when the versioned DOIs are released + */} + {['local', 'int'].includes(process.env.NEXT_PUBLIC_STAGE!) && props.publicationVersion.doi && ( +
+ + DOI (This Version): + + +

https://doi.org/{props.publicationVersion.doi}

+ +
+
+ )} -
- - DOI: +
+ + {/** + * @TODO - remove stage check when the versioned DOIs are released + */} + {['local', 'int'].includes(process.env.NEXT_PUBLIC_STAGE!) ? 'DOI (All Versions):' : 'DOI:'}

https://doi.org/{props.publicationVersion.publication.doi}

- +
+ {props.publicationVersion.publication.type !== 'PEER_REVIEW' && (
diff --git a/ui/src/lib/interfaces.ts b/ui/src/lib/interfaces.ts index 914512a0d..74087aca2 100644 --- a/ui/src/lib/interfaces.ts +++ b/ui/src/lib/interfaces.ts @@ -67,6 +67,7 @@ export interface PublicationVersionUser { } export interface PublicationVersion { id: string; + doi?: string; versionOf: string; versionNumber: number; isLatestVersion: boolean; diff --git a/ui/src/pages/publications/[id]/index.tsx b/ui/src/pages/publications/[id]/index.tsx index 2101cb2bc..feced6267 100755 --- a/ui/src/pages/publications/[id]/index.tsx +++ b/ui/src/pages/publications/[id]/index.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo } from 'react'; import parse from 'html-react-parser'; import Head from 'next/head'; -import useSWR, { KeyedMutator } from 'swr'; -import axios, { AxiosResponse } from 'axios'; +import useSWR from 'swr'; +import axios from 'axios'; import * as OutlineIcons from '@heroicons/react/24/outline'; import * as api from '@api'; @@ -44,6 +44,18 @@ export const getServerSideProps: Types.GetServerSideProps = async (context) => { const requestedId = context.query.id; const token = Helpers.getJWT(context); + /** + * TODO - remove this when we decide to deploy versioned DOIs & creating new versions on prod + */ + if (['local', 'int'].includes(process.env.NEXT_PUBLIC_STAGE!)) { + return { + redirect: { + destination: `/publications/${requestedId}/versions/latest`, // this url might change in OC-391 + permanent: false + } + }; + } + // fetch data concurrently const promises: [ Promise, diff --git a/ui/src/pages/publications/[id]/versions/[versionId].tsx b/ui/src/pages/publications/[id]/versions/[versionId].tsx new file mode 100644 index 000000000..d50df119a --- /dev/null +++ b/ui/src/pages/publications/[id]/versions/[versionId].tsx @@ -0,0 +1,926 @@ +/** + * This page is a temporary copy of /publications/[id] page and will be only available on local and int stages until we release versioning work on prod + * If trying to access this page on prod, the user will be redirected back to the /publications/[id] + */ +import React, { useEffect, useMemo } from 'react'; +import parse from 'html-react-parser'; +import Head from 'next/head'; +import useSWR from 'swr'; +import axios from 'axios'; + +import * as OutlineIcons from '@heroicons/react/24/outline'; +import * as api from '@api'; +import * as Components from '@components'; +import * as Config from '@config'; +import * as Helpers from '@helpers'; +import * as Interfaces from '@interfaces'; +import * as Layouts from '@layouts'; +import * as Stores from '@stores'; +import * as Types from '@types'; +import * as Assets from '@assets'; +import * as Contexts from '@contexts'; + +import { useRouter } from 'next/router'; + +type SidebarCardProps = { + publicationVersion: Interfaces.PublicationVersion; + linkedFrom: Interfaces.LinkedFromPublication[]; + flags: Interfaces.Flag[]; + sectionList: { + title: string; + href: string; + }[]; +}; + +const SidebarCard: React.FC = (props): React.ReactElement => ( +
+ + + +
+); + +export const getServerSideProps: Types.GetServerSideProps = async (context) => { + const requestedId = context.query.id; + const versionId = context.query.versionId; + + /** + * TODO - remove this when we decide to deploy versioned DOIs & creating new versions on prod + */ + if (!['local', 'int'].includes(process.env.NEXT_PUBLIC_STAGE!)) { + return { + redirect: { + destination: `/publications/${requestedId}`, + permanent: false + } + }; + } + + const token = Helpers.getJWT(context); + + // fetch data concurrently + const promises: [ + Promise, + Promise, + Promise, + Promise, + Promise + ] = [ + api + .get(`${Config.endpoints.publications}/${requestedId}/publication-versions/${versionId}`, token) + .then((res) => res.data) + .catch((error) => console.log(error)), + api + .get(`${Config.endpoints.bookmarks}?type=PUBLICATION&entityId=${requestedId}`, token) + .then((res) => res.data) + .catch((error) => console.log(error)), + api + .get(`${Config.endpoints.publications}/${requestedId}/links?direct=true`, token) + .then((res) => res.data) + .catch((error) => console.log(error)), + api + .get(`${Config.endpoints.publications}/${requestedId}/flags`, token) + .then((res) => res.data) + .catch((error) => console.log(error)), + api + .get(`${Config.endpoints.publications}/${requestedId}/topics`, token) + .then((res) => res.data) + .catch((error) => console.log(error)) + ]; + + const [ + publicationVersion, + bookmarks = [], + directLinks = { publication: null, linkedTo: [], linkedFrom: [] }, + flags = [], + topics = [] + ] = await Promise.all(promises); + + if (!publicationVersion) { + return { + notFound: true + }; + } + + return { + props: { + publicationVersion, + userToken: token || '', + bookmarkId: bookmarks.length ? bookmarks[0].id : null, + publicationId: publicationVersion.publication.id, + protectedPage: ['LOCKED', 'DRAFT'].includes(publicationVersion.currentStatus), + directLinks, + flags, + topics + } + }; +}; + +type Props = { + publicationVersion: Interfaces.PublicationVersion; + publicationId: string; + bookmarkId: string | null; + userToken: string; + directLinks: Interfaces.PublicationWithLinks; + flags: Interfaces.Flag[]; + topics: Interfaces.BaseTopic[]; +}; + +const Publication: Types.NextPage = (props): React.ReactElement => { + const router = useRouter(); + const confirmation = Contexts.useConfirmationModal(); + const [bookmarkId, setBookmarkId] = React.useState(props.bookmarkId); + const isBookmarked = bookmarkId ? true : false; + const [isPublishing, setPublishing] = React.useState(false); + const [approvalError, setApprovalError] = React.useState(''); + const [serverError, setServerError] = React.useState(''); + const [isEditingAffiliations, setIsEditingAffiliations] = React.useState(false); + + useEffect(() => { + setBookmarkId(props.bookmarkId); + }, [props.bookmarkId, props.publicationId]); + + const { data: publicationVersion, mutate } = useSWR( + `${Config.endpoints.publications}/${props.publicationId}/publication-versions/${props.publicationVersion.id}`, + null, + { fallbackData: props.publicationVersion } + ); + + const { data: references = [] } = useSWR( + `${Config.endpoints.publicationVersions}/${props.publicationVersion.id}/references`, + null, + { + fallbackData: [] + } + ); + + const { data: { linkedTo = [], linkedFrom = [] } = {} } = useSWR( + `${Config.endpoints.publications}/${props.publicationVersion.publication.id}/links?direct=true`, + null, + { + fallbackData: props.directLinks + } + ); + + const { data: flags = [] } = useSWR( + `${Config.endpoints.publications}/${props.publicationId}/flags`, + null, + { fallbackData: props.flags } + ); + + const { data: topics = [] } = useSWR( + `${Config.endpoints.publications}/${props.publicationId}/topics`, + null, + { + fallbackData: props.topics + } + ); + + const peerReviews = linkedFrom.filter((link) => link.type === 'PEER_REVIEW') || []; + + // problems this publication is linked to + const parentProblems = linkedTo.filter((link) => link.type === 'PROBLEM') || []; + + // problems linked from this publication + const childProblems = linkedFrom.filter((link) => link.type === 'PROBLEM') || []; + + const user = Stores.useAuthStore((state: Types.AuthStoreType) => state.user); + const isBookmarkButtonVisible = useMemo(() => { + if (!user || !publicationVersion) { + return false; + } else { + return true; + } + }, [publicationVersion, user]); + + const list = []; + + const showReferences = Boolean(references?.length); + const showChildProblems = Boolean(childProblems?.length); + const showParentProblems = Boolean(parentProblems?.length); + const showTopics = Boolean(topics?.length); + const showPeerReviews = Boolean(peerReviews?.length); + const showEthicalStatement = + publicationVersion?.publication.type === 'DATA' && Boolean(publicationVersion.ethicalStatement); + const showRedFlags = !!flags.length; + + if (showReferences) list.push({ title: 'References', href: 'references' }); + if (showChildProblems || showParentProblems) list.push({ title: 'Linked problems', href: 'problems' }); + if (showTopics) list.push({ title: 'Linked topics', href: 'topics' }); + if (showPeerReviews) list.push({ title: 'Peer reviews', href: 'peer-reviews' }); + if (showEthicalStatement) list.push({ title: 'Ethical statement', href: 'ethical-statement' }); + if (publicationVersion?.dataPermissionsStatement) + list.push({ title: 'Data permissions statement', href: 'data-permissions-statement' }); + if (publicationVersion?.dataAccessStatement) + list.push({ title: 'Data access statement', href: 'data-access-statement' }); + if (publicationVersion?.selfDeclaration) list.push({ title: 'Self-declaration', href: 'self-declaration' }); + if (showRedFlags) list.push({ title: 'Red flags', href: 'red-flags' }); + + const sectionList = [ + { title: 'Main text', href: 'main-text' }, + ...list, + { title: 'Funders', href: 'funders' }, + { title: 'Conflict of interest', href: 'coi' } + ]; + + const currentCoAuthor = React.useMemo( + () => publicationVersion?.coAuthors?.find((coAuthor) => coAuthor.linkedUser === user?.id), + [publicationVersion, user?.id] + ); + + const alertSeverity = React.useMemo(() => { + if (publicationVersion?.user?.id === user?.id) { + return 'INFO'; + } + if (publicationVersion?.currentStatus === 'DRAFT') { + return 'WARNING'; + } + if (currentCoAuthor?.confirmedCoAuthor) { + return 'SUCCESS'; + } + if (!currentCoAuthor?.confirmedCoAuthor) { + return 'WARNING'; + } + return 'INFO'; + }, [publicationVersion, user?.id, currentCoAuthor?.confirmedCoAuthor]); + + const updateCoAuthor = React.useCallback( + async (confirm: boolean) => { + setApprovalError(''); + try { + await api.patch( + `/publication-versions/${publicationVersion?.id}/coauthor-confirmation`, + { + confirm + }, + user?.token + ); + } catch (err) { + setApprovalError(axios.isAxiosError(err) ? err.response?.data?.message : (err as Error).message); + } + }, + [publicationVersion?.id, user?.token] + ); + + const onBookmarkHandler = async () => { + if (isBookmarked) { + //delete the bookmark + try { + await api.destroy(`bookmarks/${bookmarkId}`, user?.token); + setBookmarkId(null); + } catch (err) { + console.log(err); + } + } else { + //create the bookmark + try { + const newBookmarkResponse = await api.post<{ + id: string; + type: string; + entityId: string; + userId: string; + }>( + `bookmarks`, + { + type: 'PUBLICATION', + entityId: publicationVersion?.versionOf + }, + user?.token + ); + + setBookmarkId(newBookmarkResponse.data?.id); + } catch (err) { + console.log(err); + } + } + }; + + const handlePublish = React.useCallback(async () => { + const confirmed = await confirmation( + 'Are you sure you want to publish?', + 'It is not possible to make any changes post-publication.', +