From 96402de6dcd55631da021cf8db0573cf0d79dc35 Mon Sep 17 00:00:00 2001 From: Serhii Zautkin Date: Thu, 12 Mar 2026 17:43:59 -0700 Subject: [PATCH] CADC-14486 Fix publishing --- rafts/frontend/messages/en.json | 15 +- rafts/frontend/messages/fr.json | 15 +- rafts/frontend/src/actions/assignReviewer.ts | 71 ++--- .../src/actions/fetchDoiCitationXml.ts | 34 +++ rafts/frontend/src/actions/getDOIRAFT.ts | 36 ++- .../frontend/src/actions/getDOIsForReview.ts | 71 +++-- .../src/actions/getPublishedRaftById.ts | 96 +++++-- .../frontend/src/actions/getPublishedRafts.ts | 213 ++++++++++++++ rafts/frontend/src/actions/getRafts.ts | 77 +++--- rafts/frontend/src/actions/publishRaftDoi.ts | 207 ++++++++++---- rafts/frontend/src/actions/submitForReview.ts | 21 +- rafts/frontend/src/actions/updateDOIStatus.ts | 98 +++---- .../[locale]/public-view/rafts/[id]/page.tsx | 4 +- .../[locale]/public-view/rafts/loading.tsx | 94 +++++++ .../app/[locale]/public-view/rafts/page.tsx | 33 +-- .../src/app/[locale]/review/rafts/page.tsx | 4 +- .../src/app/[locale]/view/rafts/page.tsx | 2 + .../components/DOIRaftTable/ActionMenu.tsx | 5 +- .../src/components/RaftDetail/RaftDetail.tsx | 5 +- .../RaftDetail/components/DOILinks.tsx | 259 +++++++++++++++--- .../RaftDetail/components/RaftHeader.tsx | 4 +- .../components/ReviewerSidePanel.tsx | 223 +++++++++++---- .../RaftDetail/components/StatusFilter.tsx | 9 +- .../RaftTable/PublishedViewButton.tsx | 25 ++ .../components/RaftTable/publishedColumns.tsx | 4 +- .../src/components/common/StatusBadge.tsx | 19 ++ rafts/frontend/src/services/canfarStorage.ts | 54 ++++ rafts/frontend/src/shared/backendStatus.ts | 13 + rafts/frontend/src/shared/constants.ts | 2 + .../__tests__/dataCiteParser.test.ts | 156 +++++++++++ rafts/frontend/src/utilities/constants.ts | 2 + .../frontend/src/utilities/dataCiteParser.ts | 51 ++++ 32 files changed, 1557 insertions(+), 365 deletions(-) create mode 100644 rafts/frontend/src/actions/fetchDoiCitationXml.ts create mode 100644 rafts/frontend/src/actions/getPublishedRafts.ts create mode 100644 rafts/frontend/src/app/[locale]/public-view/rafts/loading.tsx create mode 100644 rafts/frontend/src/components/RaftTable/PublishedViewButton.tsx create mode 100644 rafts/frontend/src/utilities/__tests__/dataCiteParser.test.ts create mode 100644 rafts/frontend/src/utilities/dataCiteParser.ts diff --git a/rafts/frontend/messages/en.json b/rafts/frontend/messages/en.json index 0bcbd273..8c899596 100644 --- a/rafts/frontend/messages/en.json +++ b/rafts/frontend/messages/en.json @@ -338,14 +338,25 @@ "tooltip_rejected": "This RAFTS was rejected and needs revision", "tooltip_review_ready": "This RAFTS is ready for review", "tooltip_in_review": "This RAFTS has been submitted and is awaiting review", - "tooltip_draft": "This is a draft RAFTS that has not been submitted for review" + "tooltip_draft": "This is a draft RAFTS that has not been submitted for review", + "publishing": "Publishing...", + "publish_error": "Publish Error", + "locking data directory": "Publishing...", + "locked data directory": "Publishing...", + "registering to DataCite": "Publishing...", + "error locking data directory": "Error Locking Data", + "error registering to DataCite": "Error Registering", + "tooltip_publishing": "This RAFTS is being published — locking data and registering DOI", + "tooltip_publish_error": "An error occurred during the publishing process" }, "review_page": { "filter_by_status": "Filter by status:", + "status_all": "All", "status_review_ready": "Ready for Review", "status_under_review": "Under Review", "status_approved": "Approved", - "status_rejected": "Rejected" + "status_rejected": "Rejected", + "status_publishing": "Publishing" }, "raft_details": { "overview": "Overview", diff --git a/rafts/frontend/messages/fr.json b/rafts/frontend/messages/fr.json index 68aac313..83e754fe 100644 --- a/rafts/frontend/messages/fr.json +++ b/rafts/frontend/messages/fr.json @@ -347,14 +347,25 @@ "tooltip_rejected": "Ce RAFTS a été rejeté et nécessite une révision", "tooltip_review_ready": "Ce RAFTS est prêt pour révision", "tooltip_in_review": "Ce RAFTS a été soumis et attend la révision", - "tooltip_draft": "Ceci est un brouillon RAFTS qui n'a pas été soumis pour révision" + "tooltip_draft": "Ceci est un brouillon RAFTS qui n'a pas été soumis pour révision", + "publishing": "Publication...", + "publish_error": "Erreur de publication", + "locking data directory": "Publication...", + "locked data directory": "Publication...", + "registering to DataCite": "Publication...", + "error locking data directory": "Erreur de verrouillage des données", + "error registering to DataCite": "Erreur d'enregistrement", + "tooltip_publishing": "Ce RAFTS est en cours de publication — verrouillage des données et enregistrement du DOI", + "tooltip_publish_error": "Une erreur s'est produite lors du processus de publication" }, "review_page": { "filter_by_status": "Filtrer par statut :", + "status_all": "Tous", "status_review_ready": "Prêt pour révision", "status_under_review": "En cours de révision", "status_approved": "Approuvé", - "status_rejected": "Rejeté" + "status_rejected": "Rejeté", + "status_publishing": "Publication" }, "raft_details": { "overview": "Aperçu", diff --git a/rafts/frontend/src/actions/assignReviewer.ts b/rafts/frontend/src/actions/assignReviewer.ts index 680e87e6..a0a2102a 100644 --- a/rafts/frontend/src/actions/assignReviewer.ts +++ b/rafts/frontend/src/actions/assignReviewer.ts @@ -72,8 +72,7 @@ import { SUBMIT_DOI_URL, MESSAGE, SUCCESS } from '@/actions/constants' import { createDoiFormData } from '@/actions/utils/doiFormData' import { IResponseData } from '@/actions/types' import { BACKEND_STATUS } from '@/shared/backendStatus' -import { updateRaftMetadata } from '@/services/canfarStorage' -import { RaftStatusChange } from '@/types/doi' +import { getDOICurrentStatus } from '@/actions/updateDOIStatus' /** * Assigns a reviewer to a RAFT/DOI. @@ -154,7 +153,6 @@ export const assignReviewer = async ( */ export const claimForReview = async ( doiId: string, - dataDirectory?: string, ): Promise> => { try { const session = await auth() @@ -169,6 +167,12 @@ export const claimForReview = async ( } const reviewerName = session.user.name + + // Log current status before claiming + const beforeStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[claimForReview] ${doiId}: "${beforeStatus}" → "${BACKEND_STATUS.IN_REVIEW}" (reviewer: ${reviewerName})`) + + // Claim via DOI backend API (updates VOSpace node properties) const url = `${SUBMIT_DOI_URL}/${doiId}` // Backend supports setting both reviewer and status in one call @@ -186,29 +190,9 @@ export const claimForReview = async ( }) if (response.status === 303 || response.ok) { - // Update RAFT.json metadata with status history - if (dataDirectory) { - try { - const statusChange: RaftStatusChange = { - fromStatus: BACKEND_STATUS.REVIEW_READY, - toStatus: BACKEND_STATUS.IN_REVIEW, - changedBy: reviewerName, - changedAt: new Date().toISOString(), - } - - await updateRaftMetadata( - dataDirectory, - { - updatedAt: new Date().toISOString(), - updatedBy: reviewerName, - statusHistory: [statusChange], - }, - accessToken, - ) - } catch (metaError) { - console.warn('[claimForReview] Metadata update failed (non-critical):', metaError) - } - } + // Verify status after claim + const afterStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[claimForReview] ${doiId}: confirmed status is now "${afterStatus}"`) return { [SUCCESS]: true, @@ -217,7 +201,7 @@ export const claimForReview = async ( } const errorText = await response.text().catch(() => '') - console.error('[claimForReview] Error response:', response.status, errorText) + console.error(`[claimForReview] ${doiId}: POST failed ${response.status}`, errorText) return { [SUCCESS]: false, [MESSAGE]: `Failed to claim for review: ${response.status} ${errorText}`, @@ -244,7 +228,6 @@ export const claimForReview = async ( */ export const releaseReview = async ( doiId: string, - dataDirectory?: string, ): Promise> => { try { const session = await auth() @@ -254,6 +237,11 @@ export const releaseReview = async ( return { [SUCCESS]: false, [MESSAGE]: 'Not authenticated' } } + // Log current status before releasing + const beforeStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[releaseReview] ${doiId}: "${beforeStatus}" → "${BACKEND_STATUS.REVIEW_READY}"`) + + // Release via DOI backend API (updates VOSpace node properties) const url = `${SUBMIT_DOI_URL}/${doiId}` // Send empty reviewer and status back to review ready @@ -271,34 +259,13 @@ export const releaseReview = async ( }) if (response.status === 303 || response.ok) { - // Update RAFT.json metadata with status history - if (dataDirectory) { - try { - const statusChange: RaftStatusChange = { - fromStatus: BACKEND_STATUS.IN_REVIEW, - toStatus: BACKEND_STATUS.REVIEW_READY, - changedBy: session?.user?.name || '', - changedAt: new Date().toISOString(), - } - - await updateRaftMetadata( - dataDirectory, - { - updatedAt: new Date().toISOString(), - updatedBy: session?.user?.name || '', - statusHistory: [statusChange], - }, - accessToken, - ) - } catch (metaError) { - console.warn('[releaseReview] Metadata update failed (non-critical):', metaError) - } - } - + const afterStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[releaseReview] ${doiId}: confirmed status is now "${afterStatus}"`) return { [SUCCESS]: true, data: 'Review released successfully' } } const errorText = await response.text().catch(() => '') + console.error(`[releaseReview] ${doiId}: POST failed ${response.status}`, errorText) return { [SUCCESS]: false, [MESSAGE]: `Failed to release review: ${response.status} ${errorText}`, diff --git a/rafts/frontend/src/actions/fetchDoiCitationXml.ts b/rafts/frontend/src/actions/fetchDoiCitationXml.ts new file mode 100644 index 00000000..206d98c7 --- /dev/null +++ b/rafts/frontend/src/actions/fetchDoiCitationXml.ts @@ -0,0 +1,34 @@ +'use server' + +import { STORAGE_VAULT_FILE_URL, DOI_XML_PREFIX } from '@/utilities/constants' + +/** + * Fetches the DataCite citation XML for a RAFT from the VOSpace vault. + * Proxied through the server to avoid CORS restrictions. + */ +export const fetchDoiCitationXml = async ( + dataDirectory: string, +): Promise<{ success: boolean; xml?: string; error?: string }> => { + try { + const raftRootDir = dataDirectory.replace(/\/data\/?$/, '') + const raftFolderName = raftRootDir.split('/').pop() + if (!raftFolderName) { + return { success: false, error: 'Could not determine RAFT folder name' } + } + + const url = `${STORAGE_VAULT_FILE_URL}${raftRootDir.startsWith('/') ? '' : '/'}${raftRootDir}/${DOI_XML_PREFIX}${raftFolderName}.xml` + console.log(`[fetchDoiCitationXml] Fetching: ${url}`) + + const response = await fetch(url, { redirect: 'follow' }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + return { success: true, xml } + } catch (error) { + console.error('[fetchDoiCitationXml] Error:', error) + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } + } +} diff --git a/rafts/frontend/src/actions/getDOIRAFT.ts b/rafts/frontend/src/actions/getDOIRAFT.ts index 604e86fd..d43928d9 100644 --- a/rafts/frontend/src/actions/getDOIRAFT.ts +++ b/rafts/frontend/src/actions/getDOIRAFT.ts @@ -75,6 +75,7 @@ import { SUBMIT_DOI_URL } from '@/actions/constants' import { parseXmlToJson } from '@/utilities/xmlParser' import { DOIData } from '@/types/doi' import { dataCiteToRaft, parseDataCiteXml } from '@/utilities/dataCiteToRaft' +import { getDOICurrentStatus } from '@/actions/updateDOIStatus' export const getDOIRaft = async (dataIdentifier: string): Promise> => { try { @@ -104,17 +105,40 @@ export const getDOIRaft = async (dataIdentifier: string): Promise doi.identifier.endsWith(`/${dataIdentifier}`)) + let matchingDoi = doiDataList.find((doi) => doi.identifier.endsWith(`/${dataIdentifier}`)) + // Fallback: DOI may not appear in the list during minting transitions. + // Use the dedicated /status endpoint to get the current status. if (!matchingDoi) { - console.error('[getDOIRaft] No matching DOI found for:', dataIdentifier) - return { success: false, message: `No matching DOI found for: ${dataIdentifier}` } + console.warn(`[getDOIRaft] ${dataIdentifier}: not in list, trying /status endpoint`) + const status = await getDOICurrentStatus(dataIdentifier, accessToken) + if (status) { + matchingDoi = { + identifier: dataIdentifier, + identifierType: 'DOI', + title: '', + titleLang: null, + status, + dataDirectory: '', + journalRef: null, + reviewer: null, + } + console.log(`[getDOIRaft] ${dataIdentifier}: recovered status="${status}" from /status endpoint`) + } else { + console.error('[getDOIRaft] No matching DOI found for:', dataIdentifier) + return { success: false, message: `No matching DOI found for: ${dataIdentifier}` } + } + } else { + console.log(`[getDOIRaft] ${dataIdentifier}: backend status="${matchingDoi.status}", reviewer="${matchingDoi.reviewer || 'none'}"`) } - // Try to download RAFT.json first - const response = await downloadRaftFile(matchingDoi.dataDirectory, accessToken) - if (response.success && response.data) { + // Try to download RAFT.json first (skip if dataDirectory is not available) + const response = matchingDoi.dataDirectory + ? await downloadRaftFile(matchingDoi.dataDirectory, accessToken) + : null + + if (response?.success && response.data) { // Override status from DOI list (RAFT.json may have stale status) const raftData = response.data if (raftData.generalInfo && matchingDoi.status) { diff --git a/rafts/frontend/src/actions/getDOIsForReview.ts b/rafts/frontend/src/actions/getDOIsForReview.ts index 52616da1..2e44919f 100644 --- a/rafts/frontend/src/actions/getDOIsForReview.ts +++ b/rafts/frontend/src/actions/getDOIsForReview.ts @@ -78,16 +78,38 @@ import { OPTION_UNDER_REVIEW, OPTION_APPROVED, OPTION_REJECTED, + OPTION_ALL, + OPTION_PUBLISHING, } from '@/shared/constants' import { BACKEND_STATUS } from '@/shared/backendStatus' -// Map frontend status constants to backend status values -const STATUS_MAPPING: Record = { - [OPTION_REVIEW]: BACKEND_STATUS.REVIEW_READY, // review_ready -> review ready (waiting for reviewer) - [OPTION_UNDER_REVIEW]: BACKEND_STATUS.IN_REVIEW, // under_review -> in review (reviewer claimed) - [OPTION_APPROVED]: BACKEND_STATUS.APPROVED, // approved -> approved - [OPTION_REJECTED]: BACKEND_STATUS.REJECTED, // rejected -> rejected +// Backend statuses that count as "publishing" (intermediate minting states) +const PUBLISHING_STATUSES: string[] = [ + BACKEND_STATUS.LOCKING_DATA, + BACKEND_STATUS.ERROR_LOCKING_DATA, + BACKEND_STATUS.LOCKED_DATA, + BACKEND_STATUS.REGISTERING, + BACKEND_STATUS.ERROR_REGISTERING, +] + +// All statuses visible on the review page (everything except draft and minted) +const ALL_REVIEW_STATUSES = [ + BACKEND_STATUS.REVIEW_READY, + BACKEND_STATUS.IN_REVIEW, + BACKEND_STATUS.APPROVED, + BACKEND_STATUS.REJECTED, + ...PUBLISHING_STATUSES, +] + +// Map frontend filter options to backend status values +const STATUS_MAPPING: Record = { + [OPTION_REVIEW]: BACKEND_STATUS.REVIEW_READY, + [OPTION_UNDER_REVIEW]: BACKEND_STATUS.IN_REVIEW, + [OPTION_APPROVED]: BACKEND_STATUS.APPROVED, + [OPTION_REJECTED]: BACKEND_STATUS.REJECTED, + [OPTION_PUBLISHING]: PUBLISHING_STATUSES, + [OPTION_ALL]: ALL_REVIEW_STATUSES, } export interface ReviewRaftsResponse { @@ -131,33 +153,48 @@ export const getDOIsForReview = async ( const xmlString = await response.text() const doiDataList: DOIData[] = await parseXmlToJson(xmlString) + console.log(`[getDOIsForReview] Loaded ${doiDataList.length} DOIs from backend:`) + doiDataList.forEach((doi) => { + console.log(` - ${doi.identifier}: status="${doi.status}", reviewer="${doi.reviewer || 'none'}"`) + }) + // Calculate counts for all statuses const counts: Record = { + [OPTION_ALL]: 0, [OPTION_REVIEW]: 0, [OPTION_UNDER_REVIEW]: 0, [OPTION_APPROVED]: 0, [OPTION_REJECTED]: 0, + [OPTION_PUBLISHING]: 0, } // Count DOIs by status doiDataList.forEach((doi) => { - const backendStatus = doi.status?.toLowerCase() - if (backendStatus === BACKEND_STATUS.REVIEW_READY) { - counts[OPTION_REVIEW]++ // review ready (waiting for reviewer) - } else if (backendStatus === BACKEND_STATUS.IN_REVIEW) { - counts[OPTION_UNDER_REVIEW]++ // in review (reviewer claimed) - } else if (backendStatus === BACKEND_STATUS.APPROVED) { + const status = doi.status?.toLowerCase() + if (status === BACKEND_STATUS.REVIEW_READY) { + counts[OPTION_REVIEW]++ + counts[OPTION_ALL]++ + } else if (status === BACKEND_STATUS.IN_REVIEW) { + counts[OPTION_UNDER_REVIEW]++ + counts[OPTION_ALL]++ + } else if (status === BACKEND_STATUS.APPROVED) { counts[OPTION_APPROVED]++ - } else if (backendStatus === BACKEND_STATUS.REJECTED) { + counts[OPTION_ALL]++ + } else if (status === BACKEND_STATUS.REJECTED) { counts[OPTION_REJECTED]++ + counts[OPTION_ALL]++ + } else if (status && PUBLISHING_STATUSES.includes(status)) { + counts[OPTION_PUBLISHING]++ + counts[OPTION_ALL]++ } }) // Filter DOIs by requested status - const backendStatus = filterStatus ? STATUS_MAPPING[filterStatus] : null - const filteredDois = backendStatus - ? doiDataList.filter((doi) => doi.status?.toLowerCase() === backendStatus) - : doiDataList.filter((doi) => doi.status?.toLowerCase() === BACKEND_STATUS.REVIEW_READY) + const mappedStatus = filterStatus ? STATUS_MAPPING[filterStatus] : STATUS_MAPPING[OPTION_ALL] + const targetStatuses = Array.isArray(mappedStatus) ? mappedStatus : [mappedStatus] + const filteredDois = doiDataList.filter( + (doi) => doi.status && targetStatuses.includes(doi.status.toLowerCase()), + ) // Fetch full RAFT data for each filtered DOI const rafts: RaftData[] = [] diff --git a/rafts/frontend/src/actions/getPublishedRaftById.ts b/rafts/frontend/src/actions/getPublishedRaftById.ts index 681b2603..9c7c982a 100644 --- a/rafts/frontend/src/actions/getPublishedRaftById.ts +++ b/rafts/frontend/src/actions/getPublishedRaftById.ts @@ -67,38 +67,100 @@ 'use server' +import { auth } from '@/auth/cadc-auth/credentials' +import { SUBMIT_DOI_URL } from '@/actions/constants' +import { parseXmlToJson } from '@/utilities/xmlParser' import { RaftData } from '@/types/doi' +import { downloadRaftFilePublic } from '@/services/canfarStorage' +import { TRaftContext } from '@/context/types' export const getPublishedRaftById = async (id: string) => { try { - // Get the session with the access token + // Try authenticated fetch to get DOI list, fall back to unauthenticated + const session = await auth().catch(() => null) + const accessToken = session?.accessToken - // Make the API call with the access token as a Bearer token - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/rafts/published/${id}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }, - ) + const headers: Record = { + Accept: 'application/xml', + } + if (accessToken) { + headers.Cookie = `CADC_SSO=${accessToken}` + } + + const response = await fetch(SUBMIT_DOI_URL, { + method: 'GET', + headers, + }) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - console.error(`Failed to fetch RAFT with ID ${id}:`, errorData) + console.error(`[getPublishedRaftById] DOI list fetch failed: ${response.status}`) return { success: false, - error: errorData.message || `Request failed with status ${response.status}`, + error: `Request failed with status ${response.status}`, } } - const responseData = await response.json() - const data: RaftData = responseData.data + const xmlString = await response.text() + const doiDataList = await parseXmlToJson(xmlString) + + // Find the matching DOI by identifier suffix + const matchingDoi = doiDataList.find((doi) => doi.identifier.endsWith(`/${id}`)) + + if (!matchingDoi) { + console.error(`[getPublishedRaftById] No DOI found matching id: ${id}`) + return { success: false, error: 'RAFTS not found' } + } + + // Download RAFT.json from public VOSpace (minted data directory is public) + const raftResponse = await downloadRaftFilePublic(matchingDoi.dataDirectory) + + let data: RaftData + + if (raftResponse.success && raftResponse.data) { + const raftData = raftResponse.data as TRaftContext + + if (raftData.generalInfo && matchingDoi.status) { + raftData.generalInfo.status = matchingDoi.status as typeof raftData.generalInfo.status + } + + data = { + _id: id, + id: id, + ...raftData, + relatedRafts: [], + generateForumPost: false, + createdBy: + ((raftData as Record).createdBy as string) || + raftData.authorInfo?.correspondingAuthor?.email || + '', + createdAt: ((raftData as Record).createdAt as string) || '', + updatedAt: ((raftData as Record).updatedAt as string) || '', + doi: matchingDoi.identifier, + dataDirectory: matchingDoi.dataDirectory, + } as RaftData + } else { + // No RAFT.json — build RaftData from DOI XML metadata + console.warn(`[getPublishedRaftById] No RAFT.json for ${id}, using DOI metadata`) + data = { + _id: id, + id: id, + generalInfo: { + title: matchingDoi.title || id, + status: (matchingDoi.status || 'minted') as 'minted', + }, + relatedRafts: [], + generateForumPost: false, + createdBy: '', + createdAt: '', + updatedAt: '', + doi: matchingDoi.identifier, + dataDirectory: matchingDoi.dataDirectory, + } as unknown as RaftData + } return { success: true, data } } catch (error) { - console.error(`Error fetching RAFT with ID ${id}:`, error) + console.error(`[getPublishedRaftById] Error fetching RAFT with ID ${id}:`, error) return { success: false, error: error instanceof Error ? error.message : 'An unknown error occurred', diff --git a/rafts/frontend/src/actions/getPublishedRafts.ts b/rafts/frontend/src/actions/getPublishedRafts.ts new file mode 100644 index 00000000..64e2b5b9 --- /dev/null +++ b/rafts/frontend/src/actions/getPublishedRafts.ts @@ -0,0 +1,213 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2026. (c) 2026. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la "GNU Affero General Public + * License as published by the License" telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l'espoir qu'il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d'ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n'est + * . pas le cas, consultez : + * . + * + ************************************************************************ + */ + +'use server' + +import { auth } from '@/auth/cadc-auth/credentials' +import { SUBMIT_DOI_URL } from '@/actions/constants' +import { parseXmlToJson } from '@/utilities/xmlParser' +import { DOIData, RaftData } from '@/types/doi' +import { downloadRaftFilePublic } from '@/services/canfarStorage' +import { TRaftContext } from '@/context/types' +import { BACKEND_STATUS } from '@/shared/backendStatus' +import { RaftApiResponse } from '@/actions/getRafts' + +/** + * Fetch all published (minted) DOIs from the DOI backend and enrich with RAFT.json data. + * Minted DOIs have public data directories, so RAFT.json can be fetched without auth. + */ +export const getPublishedRafts = async (): Promise<{ + success: boolean + data?: RaftApiResponse + error?: string +}> => { + try { + // Try authenticated fetch first (server-side), fall back to unauthenticated + const session = await auth().catch(() => null) + const accessToken = session?.accessToken + + const headers: Record = { + Accept: 'application/xml', + } + if (accessToken) { + headers.Cookie = `CADC_SSO=${accessToken}` + } + + console.log(`[getPublishedRafts] Fetching DOIs from: ${SUBMIT_DOI_URL}`) + console.log(`[getPublishedRafts] Auth token present: ${!!accessToken}`) + + const response = await fetch(SUBMIT_DOI_URL, { + method: 'GET', + headers, + }) + + console.log(`[getPublishedRafts] Response status: ${response.status}`) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + console.error('[getPublishedRafts] Error response:', response.status, errorText) + return { + success: false, + error: `Request failed with status ${response.status}`, + } + } + + const xmlString = await response.text() + console.log(`[getPublishedRafts] Full XML response:\n`, xmlString) + + const doiDataList: DOIData[] = await parseXmlToJson(xmlString) + console.log(`[getPublishedRafts] Parsed ${doiDataList.length} DOIs total`) + console.log(`[getPublishedRafts] All statuses:`, doiDataList.map((d) => `${d.identifier}: "${d.status}"`)) + + // Filter to only minted DOIs + const mintedDois = doiDataList.filter( + (doi) => doi.status?.toLowerCase() === BACKEND_STATUS.MINTED, + ) + console.log(`[getPublishedRafts] Minted DOIs: ${mintedDois.length}`) + + // Fetch RAFT.json for each minted DOI (public data, no auth needed) + const rafts: RaftData[] = [] + + for (const doi of mintedDois) { + try { + const identifierParts = doi.identifier.split('/') + const raftSuffix = identifierParts[identifierParts.length - 1] + console.log(`[getPublishedRafts] Fetching RAFT.json for: ${doi.identifier}, dataDir: ${doi.dataDirectory}`) + + const raftResponse = await downloadRaftFilePublic(doi.dataDirectory) + + console.log(`[getPublishedRafts] RAFT.json response for ${doi.identifier}: success=${raftResponse.success}, hasData=${!!raftResponse.data}, message=${raftResponse.message || 'none'}`) + + if (raftResponse.success && raftResponse.data) { + const raftData = raftResponse.data as TRaftContext + + if (raftData.generalInfo && doi.status) { + raftData.generalInfo.status = doi.status as typeof raftData.generalInfo.status + } + + rafts.push({ + _id: raftSuffix, + id: raftSuffix, + ...raftData, + relatedRafts: [], + generateForumPost: false, + createdBy: + ((raftData as Record).createdBy as string) || + raftData.authorInfo?.correspondingAuthor?.email || + '', + createdAt: ((raftData as Record).createdAt as string) || '', + updatedAt: ((raftData as Record).updatedAt as string) || '', + doi: doi.identifier, + dataDirectory: doi.dataDirectory, + } as RaftData) + } else { + // No RAFT.json — build RaftData from DOI XML metadata + console.warn('[getPublishedRafts] No RAFT.json for:', doi.identifier, '— using DOI metadata') + rafts.push({ + _id: raftSuffix, + id: raftSuffix, + generalInfo: { + title: doi.title || raftSuffix, + status: (doi.status || 'minted') as 'minted', + }, + relatedRafts: [], + generateForumPost: false, + createdBy: '', + createdAt: '', + updatedAt: '', + doi: doi.identifier, + dataDirectory: doi.dataDirectory, + } as unknown as RaftData) + } + } catch (err) { + console.error('[getPublishedRafts] Error fetching RAFT for:', doi.identifier, err) + } + } + + console.log(`[getPublishedRafts] Final result: ${rafts.length} RAFTs enriched with RAFT.json`) + + return { + success: true, + data: { + message: 'Published RAFTs loaded', + data: rafts, + meta: { + total: rafts.length, + page: 1, + limit: rafts.length, + totalPages: 1, + }, + }, + } + } catch (error) { + console.error('[getPublishedRafts] Exception:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'An unknown error occurred', + } + } +} diff --git a/rafts/frontend/src/actions/getRafts.ts b/rafts/frontend/src/actions/getRafts.ts index 1deb3318..a0b0a7b6 100644 --- a/rafts/frontend/src/actions/getRafts.ts +++ b/rafts/frontend/src/actions/getRafts.ts @@ -67,8 +67,7 @@ 'use server' import { RaftData } from '@/types/doi' -import { loadMockRaftData, getMockRaftCounts } from '@/tests/mock-data-loader' -import { useMockData } from '@/config/environment' +import { getPublishedRafts } from '@/actions/getPublishedRafts' export interface RaftApiResponse { message: string @@ -90,58 +89,46 @@ export interface GetRaftsOptions { export const getRafts = async (options: GetRaftsOptions = {}) => { try { - // Use mock data if enabled - if (useMockData) { - const mockData = options.status ? loadMockRaftData(options.status) : loadMockRaftData() - const allCounts = getMockRaftCounts() - const total = options.status ? allCounts[options.status] || 0 : mockData.length + // Fetch published (minted) DOIs from the DOI backend + public VOSpace + const result = await getPublishedRafts() - const response: RaftApiResponse = { - message: 'Mock data loaded', - data: mockData, - meta: { - total: total, - page: options.page || 1, - limit: options.limit || 10, - totalPages: Math.ceil(total / (options.limit || 10)), - }, + if (!result.success || !result.data) { + return { + success: false, + error: result.error || 'Failed to fetch published RAFTs', } - return { success: true, data: response } } - // Get the session with the access token + let rafts = result.data.data - // Build query parameters - const queryParams = new URLSearchParams() - if (options.page) queryParams.append('page', options.page.toString()) - if (options.limit) queryParams.append('limit', options.limit.toString()) - if (options.status) queryParams.append('status', options.status) - if (options.search) queryParams.append('search', options.search) + // Apply client-side search filter if provided + if (options.search) { + const searchLower = options.search.toLowerCase() + rafts = rafts.filter( + (raft) => + raft.generalInfo?.title?.toLowerCase().includes(searchLower) || + raft.doi?.toLowerCase().includes(searchLower) || + raft._id?.toLowerCase().includes(searchLower), + ) + } - const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '' + const total = rafts.length + const page = options.page || 1 + const limit = options.limit || total + const start = (page - 1) * limit + const paginatedRafts = rafts.slice(start, start + limit) - // Make the API call with the access token as a Bearer token - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/rafts${queryString}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + const response: RaftApiResponse = { + message: 'Published RAFTs loaded', + data: paginatedRafts, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit) || 1, }, - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - console.error('Failed to fetch RAFTs:', errorData) - return { - success: false, - error: errorData.message || `Request failed with status ${response.status}`, - } } - - const data: RaftApiResponse = await response.json() - return { success: true, data: data } + return { success: true, data: response } } catch (error) { console.error('Error fetching RAFTs:', error) return { diff --git a/rafts/frontend/src/actions/publishRaftDoi.ts b/rafts/frontend/src/actions/publishRaftDoi.ts index 9f608237..27e8103b 100644 --- a/rafts/frontend/src/actions/publishRaftDoi.ts +++ b/rafts/frontend/src/actions/publishRaftDoi.ts @@ -70,14 +70,47 @@ import { auth } from '@/auth/cadc-auth/credentials' import { SUBMIT_DOI_URL, SUCCESS, MESSAGE } from '@/actions/constants' import { IResponseData } from '@/actions/types' -import { updateRaftMetadata } from '@/services/canfarStorage' -import { RaftStatusChange } from '@/types/doi' +import { BACKEND_STATUS } from '@/shared/backendStatus' +import { getDOICurrentStatus } from '@/actions/updateDOIStatus' + +const POLL_INTERVAL_MS = 3000 +const MAX_POLL_ATTEMPTS = 40 // 2 minutes max for locking phase + +const ERROR_STATUSES: string[] = [BACKEND_STATUS.ERROR_LOCKING_DATA, BACKEND_STATUS.ERROR_REGISTERING] + +/** + * Call POST /mint once. + */ +async function callMint( + raftId: string, + accessToken: string, +): Promise<{ ok: boolean; status: number; text: string }> { + const url = `${SUBMIT_DOI_URL}/${raftId}/mint` + console.log(`[publishRAFTDOI] POST ${url}`) + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Cookie: `CADC_SSO=${accessToken}`, + }, + body: '', + }) + + const text = await response.text() + console.log(`[publishRAFTDOI] ${raftId}: mint response ${response.status}`, text) + + return { ok: response.ok, status: response.status, text } +} /** * Mints/Publishes a DOI for a RAFT submission via the DOI backend. - * Calls the dedicated /mint endpoint to trigger the minting workflow. * - * Endpoint: POST /rafts/instances/{raftId}/mint + * The backend mint workflow is a 3-step state machine (confirmed from Java source): + * + * Step 1: POST /mint → starts async data locking → status: LOCKING_DATA + * Step 2: Poll GET /status → the GET triggers LOCKING_DATA → LOCKED_DATA when job completes + * Step 3: POST /mint → synchronous DataCite registration → status: MINTED * * @param raftId - The DOI identifier suffix (e.g., "RAFTS-7rtut-gkryn.test") * @returns Response object with success status and message @@ -94,75 +127,147 @@ export const publishRAFTDOI = async ( const accessToken = session?.accessToken if (!accessToken) { - return { - [SUCCESS]: false, - [MESSAGE]: 'Not authenticated', + return { [SUCCESS]: false, [MESSAGE]: 'Not authenticated' } + } + + // Check current status to determine where to resume + const currentStatus = await getDOICurrentStatus(raftId, accessToken) + console.log(`[publishRAFTDOI] ${raftId}: starting, current status: "${currentStatus}"`) + + // --- Step 1: Start locking (if not already locked) --- + if ( + currentStatus === BACKEND_STATUS.APPROVED || + currentStatus === BACKEND_STATUS.ERROR_LOCKING_DATA + ) { + console.log(`[publishRAFTDOI] ${raftId}: Step 1 - starting data lock`) + const result = await callMint(raftId, accessToken) + if (!result.ok) { + return { [SUCCESS]: false, [MESSAGE]: `Mint failed: ${result.status} ${result.text}` } + } + + const statusAfterMint = await getDOICurrentStatus(raftId, accessToken) + console.log(`[publishRAFTDOI] ${raftId}: status after Step 1: "${statusAfterMint}"`) + + if (statusAfterMint && ERROR_STATUSES.includes(statusAfterMint)) { + return { [SUCCESS]: false, [MESSAGE]: `Publishing failed: ${statusAfterMint}` } } } - const url = `${SUBMIT_DOI_URL}/${raftId}/mint` + // --- Step 2: Poll status until LOCKED_DATA (GET /status triggers the transition) --- + const statusBeforePoll = await getDOICurrentStatus(raftId, accessToken) + if ( + statusBeforePoll === BACKEND_STATUS.LOCKING_DATA || + statusBeforePoll === BACKEND_STATUS.APPROVED + ) { + console.log(`[publishRAFTDOI] ${raftId}: Step 2 - polling for lock completion`) - console.log(`[publishRAFTDOI] POST ${url}`) + for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Cookie: `CADC_SSO=${accessToken}`, - }, - body: '', - }) + // GET /status itself triggers the LOCKING_DATA → LOCKED_DATA transition + const status = await getDOICurrentStatus(raftId, accessToken) + console.log(`[publishRAFTDOI] ${raftId}: poll ${i + 1}/${MAX_POLL_ATTEMPTS}, status: "${status}"`) - const responseText = await response.text() + if (status === BACKEND_STATUS.LOCKED_DATA) { + console.log(`[publishRAFTDOI] ${raftId}: data locked`) + break + } - console.log(`[publishRAFTDOI] Response: ${response.status} ${response.statusText}`, responseText) + if (status === BACKEND_STATUS.MINTED) { + console.log(`[publishRAFTDOI] ${raftId}: already minted`) + return { [SUCCESS]: true, [MESSAGE]: 'RAFTS status changed to Published.' } + } + + if (status && ERROR_STATUSES.includes(status)) { + return { [SUCCESS]: false, [MESSAGE]: `Publishing failed: ${status}` } + } - if (!response.ok) { - console.error(`[publishRAFTDOI] Failed: ${response.status}`, { - url, - raftId, - responseText, - }) - return { - [SUCCESS]: false, - [MESSAGE]: `Failed to mint DOI: ${response.status} ${responseText}`, + if (i === MAX_POLL_ATTEMPTS - 1) { + return { + [SUCCESS]: false, + [MESSAGE]: 'Data locking timed out. Use "Resume Publishing" to continue.', + } + } } } - // Update RAFT.json metadata with status history after successful mint - if (options?.dataDirectory) { - try { - const statusChange: RaftStatusChange = { - fromStatus: options.previousStatus || 'approved', - toStatus: 'minted', - changedBy: session?.user?.name || '', - changedAt: new Date().toISOString(), - } + // --- Step 3: Register with DataCite (synchronous) --- + const statusBeforeRegister = await getDOICurrentStatus(raftId, accessToken) + if (statusBeforeRegister === BACKEND_STATUS.MINTED) { + return { [SUCCESS]: true, [MESSAGE]: 'RAFTS status changed to Published.' } + } + + if ( + statusBeforeRegister === BACKEND_STATUS.LOCKED_DATA || + statusBeforeRegister === BACKEND_STATUS.ERROR_REGISTERING + ) { + console.log(`[publishRAFTDOI] ${raftId}: Step 3 - registering with DataCite`) + const result = await callMint(raftId, accessToken) + if (!result.ok) { + return { [SUCCESS]: false, [MESSAGE]: `Mint failed: ${result.status} ${result.text}` } + } - await updateRaftMetadata( - options.dataDirectory, - { - updatedAt: new Date().toISOString(), - updatedBy: session?.user?.name || '', - statusHistory: [statusChange], - }, - accessToken, - ) - } catch (metaError) { - console.warn('[publishRAFTDOI] Metadata update failed (non-critical):', metaError) + const finalStatus = await getDOICurrentStatus(raftId, accessToken) + console.log(`[publishRAFTDOI] ${raftId}: final status: "${finalStatus}"`) + + if (finalStatus === BACKEND_STATUS.MINTED) { + console.log(`[publishRAFTDOI] ${raftId}: minted successfully`) + return { [SUCCESS]: true, [MESSAGE]: 'RAFTS status changed to Published.' } + } + + if (finalStatus && ERROR_STATUSES.includes(finalStatus)) { + return { [SUCCESS]: false, [MESSAGE]: `Publishing failed: ${finalStatus}` } } } + // Unexpected state + const unexpectedStatus = await getDOICurrentStatus(raftId, accessToken) + console.error(`[publishRAFTDOI] ${raftId}: unexpected status: "${unexpectedStatus}"`) return { - [SUCCESS]: true, - [MESSAGE]: 'RAFTS status changed to Published.', + [SUCCESS]: false, + [MESSAGE]: `Unexpected status: ${unexpectedStatus}. Use "Resume Publishing" to continue.`, } } catch (error) { console.error('[publishRAFTDOI] Error:', error) - return { [SUCCESS]: false, [MESSAGE]: error instanceof Error ? error.message : 'An unknown error occurred', } } } + +/** + * Get the current minting status for UI progress display. + * Can be called by the UI to poll progress while publishRAFTDOI is running. + */ +export const getMintingStatus = async ( + raftId: string, +): Promise<{ status: string | null; step: number; label: string }> => { + const session = await auth() + const accessToken = session?.accessToken + + if (!accessToken) { + return { status: null, step: 0, label: 'Not authenticated' } + } + + const status = await getDOICurrentStatus(raftId, accessToken) + + switch (status) { + case BACKEND_STATUS.APPROVED: + return { status, step: 0, label: 'Starting...' } + case BACKEND_STATUS.LOCKING_DATA: + return { status, step: 1, label: 'Locking data...' } + case BACKEND_STATUS.LOCKED_DATA: + return { status, step: 2, label: 'Registering...' } + case BACKEND_STATUS.REGISTERING: + return { status, step: 2, label: 'Registering...' } + case BACKEND_STATUS.MINTED: + return { status, step: 3, label: 'Published' } + case BACKEND_STATUS.ERROR_LOCKING_DATA: + return { status, step: 1, label: 'Error locking data' } + case BACKEND_STATUS.ERROR_REGISTERING: + return { status, step: 2, label: 'Error registering' } + default: + return { status, step: 0, label: status || 'Unknown' } + } +} diff --git a/rafts/frontend/src/actions/submitForReview.ts b/rafts/frontend/src/actions/submitForReview.ts index 6eea2b30..3ef3b62b 100644 --- a/rafts/frontend/src/actions/submitForReview.ts +++ b/rafts/frontend/src/actions/submitForReview.ts @@ -74,6 +74,7 @@ import { IResponseData } from '@/actions/types' import { BACKEND_STATUS } from '@/shared/backendStatus' import { updateRaftMetadata, downloadRaftFile } from '@/services/canfarStorage' import { RaftStatusChange } from '@/types/doi' +import { getDOICurrentStatus } from '@/actions/updateDOIStatus' /** * Submits a RAFT for review by changing its status from 'in progress' to 'review ready' @@ -102,6 +103,10 @@ export const submitForReview = async ( return { [SUCCESS]: false, [MESSAGE]: 'Not authenticated' } } + // Log current status before submission + const beforeStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[submitForReview] ${doiId}: "${beforeStatus}" → "${BACKEND_STATUS.REVIEW_READY}"`) + const url = `${SUBMIT_DOI_URL}/${doiId}` // Backend expects multipart form data with JSON blob labeled 'doiNodeData' @@ -117,13 +122,17 @@ export const submitForReview = async ( if (!response.ok) { const errorText = await response.text().catch(() => '') - console.error('[submitForReview] Error response:', response.status, errorText) + console.error(`[submitForReview] ${doiId}: POST failed ${response.status}`, errorText) return { [SUCCESS]: false, [MESSAGE]: `Failed to submit for review: ${response.status} ${errorText}`, } } + // Verify status after submission + const afterStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[submitForReview] ${doiId}: confirmed status is now "${afterStatus}"`) + // Update RAFT.json with submittedAt, statusHistory, and version if (dataDirectory) { try { @@ -204,6 +213,10 @@ export const revertToDraft = async ( return { [SUCCESS]: false, [MESSAGE]: 'Not authenticated' } } + // Log current status before reverting + const beforeStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[revertToDraft] ${doiId}: "${beforeStatus}" → "${BACKEND_STATUS.IN_PROGRESS}"`) + const url = `${SUBMIT_DOI_URL}/${doiId}` // Backend expects multipart form data with JSON blob labeled 'doiNodeData' @@ -219,13 +232,17 @@ export const revertToDraft = async ( if (!response.ok) { const errorText = await response.text().catch(() => '') - console.error('[revertToDraft] Error response:', response.status, errorText) + console.error(`[revertToDraft] ${doiId}: POST failed ${response.status}`, errorText) return { [SUCCESS]: false, [MESSAGE]: `Failed to revert to draft: ${response.status} ${errorText}`, } } + // Verify status after revert + const afterStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[revertToDraft] ${doiId}: confirmed status is now "${afterStatus}"`) + // Update RAFT.json metadata with status history if (dataDirectory) { try { diff --git a/rafts/frontend/src/actions/updateDOIStatus.ts b/rafts/frontend/src/actions/updateDOIStatus.ts index 78e4a244..6931f9c7 100644 --- a/rafts/frontend/src/actions/updateDOIStatus.ts +++ b/rafts/frontend/src/actions/updateDOIStatus.ts @@ -72,26 +72,52 @@ import { SUBMIT_DOI_URL, SUCCESS, MESSAGE } from '@/actions/constants' import { createDoiFormData } from '@/actions/utils/doiFormData' import { IResponseData } from '@/actions/types' import { BackendStatusType, getStatusDisplayName } from '@/shared/backendStatus' -import { downloadRaftFile, updateRaftMetadata } from '@/services/canfarStorage' -import { RaftStatusChange } from '@/types/doi' -import { BACKEND_STATUS } from '@/shared/backendStatus' +import { parseXmlToJson } from '@/utilities/xmlParser' + +/** + * Fetch the current status of a DOI from the backend via GET /instances/{DOINum}/status. + * This is the authoritative status from the VOSpace node properties. + */ +export const getDOICurrentStatus = async ( + doiId: string, + accessToken: string, +): Promise => { + try { + const url = `${SUBMIT_DOI_URL}/${doiId}/status` + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/xml', + Cookie: `CADC_SSO=${accessToken}`, + }, + }) + + if (!response.ok) { + console.warn(`[getDOICurrentStatus] ${doiId}: HTTP ${response.status}`) + return null + } + + const xmlString = await response.text() + console.log(`[getDOICurrentStatus] ${doiId}: raw response:`, xmlString) + const parsed = await parseXmlToJson(`${xmlString}`) + return parsed[0]?.status || null + } catch (error) { + console.warn('[getDOICurrentStatus] Error:', error) + return null + } +} /** * Updates the status of a RAFT/DOI via the DOI backend service. - * Optionally updates RAFT.json metadata (statusHistory, version) when dataDirectory is provided. + * The backend updates VOSpace node properties (the authoritative status source). * * @param doiId - The DOI identifier suffix (e.g., "RAFTS-7rtut-gkryn.test") * @param newStatus - The new backend status value - * @param options - Optional: dataDirectory for metadata update, previousStatus for history * @returns Response with success/error information */ export const updateDOIStatus = async ( doiId: string, newStatus: BackendStatusType, - options?: { - dataDirectory?: string - previousStatus?: string - }, ): Promise> => { try { const session = await auth() @@ -101,6 +127,11 @@ export const updateDOIStatus = async ( return { [SUCCESS]: false, [MESSAGE]: 'Not authenticated' } } + // Log current status before update + const beforeStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[updateDOIStatus] ${doiId}: "${beforeStatus}" → "${newStatus}"`) + + // Update status via DOI backend API (updates VOSpace node properties) const url = `${SUBMIT_DOI_URL}/${doiId}` // Backend expects multipart form data with JSON blob labeled 'doiNodeData' @@ -117,57 +148,16 @@ export const updateDOIStatus = async ( const responseText = await response.text() if (!response.ok) { + console.error(`[updateDOIStatus] ${doiId}: POST failed ${response.status}`, responseText) return { [SUCCESS]: false, [MESSAGE]: `Failed to update status: ${response.status} ${responseText}`, } } - // After successful backend status change, update RAFT.json metadata if dataDirectory provided - if (options?.dataDirectory) { - try { - // Fetch current RAFT.json to get actual status for accurate fromStatus - const currentRaft = await downloadRaftFile(options.dataDirectory, accessToken) - const existing = ( - currentRaft.success && currentRaft.data ? currentRaft.data : {} - ) as Record - const existingHistory = (existing.statusHistory as RaftStatusChange[]) || [] - const lastEntry = existingHistory[existingHistory.length - 1] - - // Skip if already in the target state (prevents duplicate entries) - if (lastEntry && lastEntry.toStatus === newStatus) { - console.info(`[updateDOIStatus] Already "${newStatus}", skipping history update`) - } else { - // Use actual current status from history, fall back to passed previousStatus - const actualFromStatus = lastEntry?.toStatus || options.previousStatus || 'unknown' - - const statusChange: RaftStatusChange = { - fromStatus: actualFromStatus, - toStatus: newStatus, - changedBy: session?.user?.name || '', - changedAt: new Date().toISOString(), - } - - const metaUpdate: Record = { - updatedAt: new Date().toISOString(), - updatedBy: session?.user?.name || '', - } - - metaUpdate.statusHistory = [statusChange] - - if (newStatus === BACKEND_STATUS.REVIEW_READY) { - metaUpdate.submittedAt = new Date().toISOString() - if (existingHistory.length > 0) { - metaUpdate.version = ((existing.version as number) || 1) + 1 - } - } - - await updateRaftMetadata(options.dataDirectory, metaUpdate, accessToken) - } - } catch (metaError) { - console.warn('[updateDOIStatus] Metadata update failed (non-critical):', metaError) - } - } + // Verify status after update + const afterStatus = await getDOICurrentStatus(doiId, accessToken) + console.log(`[updateDOIStatus] ${doiId}: confirmed status is now "${afterStatus}"`) return { [SUCCESS]: true, diff --git a/rafts/frontend/src/app/[locale]/public-view/rafts/[id]/page.tsx b/rafts/frontend/src/app/[locale]/public-view/rafts/[id]/page.tsx index 1e8879ad..fc56217c 100644 --- a/rafts/frontend/src/app/[locale]/public-view/rafts/[id]/page.tsx +++ b/rafts/frontend/src/app/[locale]/public-view/rafts/[id]/page.tsx @@ -67,16 +67,14 @@ import { Metadata } from 'next' import RaftDetail from '@/components/RaftDetail/PublishedRaftDetail' -import { getRaftById } from '@/actions/getRaftById' import { notFound } from 'next/navigation' import { getPublishedRaftById } from '@/actions/getPublishedRaftById' export async function generateMetadata(props: { params: Promise<{ id: string }> }): Promise { - // Fetch RAFT data for metadata const params = await props.params - const { success, data } = await getRaftById(params?.id) + const { success, data } = await getPublishedRaftById(params?.id) if (!success || !data) { return { diff --git a/rafts/frontend/src/app/[locale]/public-view/rafts/loading.tsx b/rafts/frontend/src/app/[locale]/public-view/rafts/loading.tsx new file mode 100644 index 00000000..9a0332e1 --- /dev/null +++ b/rafts/frontend/src/app/[locale]/public-view/rafts/loading.tsx @@ -0,0 +1,94 @@ +/* + ************************************************************************ + ******************* CANADIAN ASTRONOMY DATA CENTRE ******************* + ************** CENTRE CANADIEN DE DONNÉES ASTRONOMIQUES ************** + * + * (c) 2026. (c) 2026. + * Government of Canada Gouvernement du Canada + * National Research Council Conseil national de recherches + * Ottawa, Canada, K1A 0R6 Ottawa, Canada, K1A 0R6 + * All rights reserved Tous droits réservés + * + * NRC disclaims any warranties, Le CNRC dénie toute garantie + * expressed, implied, or énoncée, implicite ou légale, + * statutory, of any kind with de quelque nature que ce + * respect to the software, soit, concernant le logiciel, + * including without limitation y compris sans restriction + * any warranty of merchantability toute garantie de valeur + * or fitness for a particular marchande ou de pertinence + * purpose. NRC shall not be pour un usage particulier. + * liable in any event for any Le CNRC ne pourra en aucun cas + * damages, whether direct or être tenu responsable de tout + * indirect, special or general, dommage, direct ou indirect, + * consequential or incidental, particulier ou général, + * arising from the use of the accessoire ou fortuit, résultant + * software. Neither the name de l'utilisation du logiciel. Ni + * of the National Research le nom du Conseil National de + * Council of Canada nor the Recherches du Canada ni les noms + * names of its contributors may de ses participants ne peuvent + * be used to endorse or promote être utilisés pour approuver ou + * products derived from this promouvoir les produits dérivés + * software without specific prior de ce logiciel sans autorisation + * written permission. préalable et particulière + * par écrit. + * + * This file is part of the Ce fichier fait partie du projet + * OpenCADC project. OpenCADC. + * + * OpenCADC is free software: OpenCADC est un logiciel libre ; + * you can redistribute it and/or vous pouvez le redistribuer ou le + * modify it under the terms of modifier suivant les termes de + * the GNU Affero General Public la "GNU Affero General Public + * License as published by the License" telle que publiée + * Free Software Foundation, par la Free Software Foundation + * either version 3 of the : soit la version 3 de cette + * License, or (at your option) licence, soit (à votre gré) + * any later version. toute version ultérieure. + * + * OpenCADC is distributed in the OpenCADC est distribué + * hope that it will be useful, dans l'espoir qu'il vous + * but WITHOUT ANY WARRANTY; sera utile, mais SANS AUCUNE + * without even the implied GARANTIE : sans même la garantie + * warranty of MERCHANTABILITY implicite de COMMERCIALISABILITÉ + * or FITNESS FOR A PARTICULAR ni d'ADÉQUATION À UN OBJECTIF + * PURPOSE. See the GNU Affero PARTICULIER. Consultez la Licence + * General Public License for Générale Publique GNU Affero + * more details. pour plus de détails. + * + * You should have received Vous devriez avoir reçu une + * a copy of the GNU Affero copie de la Licence Générale + * General Public License along Publique GNU Affero avec + * with OpenCADC. If not, see OpenCADC ; si ce n'est + * . pas le cas, consultez : + * . + * + ************************************************************************ + */ + +import { Skeleton, Box } from '@mui/material' + +export default function Loading() { + return ( +
+
+ +
+
+ {/* Table header skeleton */} + + + + + {/* Table rows skeleton */} + + {[...Array(5)].map((_, i) => ( + + ))} + +
+
+ +
+
+ ) +} diff --git a/rafts/frontend/src/app/[locale]/public-view/rafts/page.tsx b/rafts/frontend/src/app/[locale]/public-view/rafts/page.tsx index 0b7ac4b8..e7609a4c 100644 --- a/rafts/frontend/src/app/[locale]/public-view/rafts/page.tsx +++ b/rafts/frontend/src/app/[locale]/public-view/rafts/page.tsx @@ -65,9 +65,8 @@ ************************************************************************ */ -//rafts/page.tsx 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import RaftTable from '@/components/RaftTable/PublishedRaftTable' import { RaftData } from '@/types/doi' import { getRafts } from '@/actions/getRafts' @@ -77,21 +76,21 @@ export default function View() { const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - useEffect(() => { - const fetchData = async () => { - setIsLoading(true) - const { success, data, error } = await getRafts() - if (success && data) { - setRaftData(data.data) - } else { - console.error('Error fetching DOI data:', error) - setError('Failed to load RAFTS data. Please try again later.') - } - setIsLoading(false) + const fetchData = useCallback(async () => { + setIsLoading(true) + const { success, data, error } = await getRafts() + if (success && data) { + setRaftData(data.data) + } else { + console.error('Error fetching DOI data:', error) + setError('Failed to load RAFTS data. Please try again later.') } + setIsLoading(false) + }, []) + useEffect(() => { fetchData() - }, []) + }, [fetchData]) return (
@@ -99,11 +98,7 @@ export default function View() {

All Published (RAFTSs)

- {isLoading ? ( -
- Loading RAFTSs... -
- ) : error ? ( + {error ? (
{error}
) : ( diff --git a/rafts/frontend/src/app/[locale]/review/rafts/page.tsx b/rafts/frontend/src/app/[locale]/review/rafts/page.tsx index baa0ad75..d017276a 100644 --- a/rafts/frontend/src/app/[locale]/review/rafts/page.tsx +++ b/rafts/frontend/src/app/[locale]/review/rafts/page.tsx @@ -70,7 +70,7 @@ import { useState, useEffect } from 'react' import RaftTable from '@/components/RaftTable/ReviewRaftTable' import { RaftData } from '@/types/doi' import { getDOIsForReview } from '@/actions/getDOIsForReview' -import { OPTION_REVIEW } from '@/shared/constants' +import { OPTION_ALL } from '@/shared/constants' import { Typography, Paper, Alert } from '@mui/material' import StatusFilter from '@/components/RaftDetail/components/StatusFilter' @@ -79,7 +79,7 @@ export default function ReviewRafts() { const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [isAuthError, setIsAuthError] = useState(false) - const [currentStatus, setCurrentStatus] = useState(OPTION_REVIEW) + const [currentStatus, setCurrentStatus] = useState(OPTION_ALL) const [counts, setCounts] = useState>({}) const fetchData = async (status: string) => { diff --git a/rafts/frontend/src/app/[locale]/view/rafts/page.tsx b/rafts/frontend/src/app/[locale]/view/rafts/page.tsx index c7cb8b71..75dc0f36 100644 --- a/rafts/frontend/src/app/[locale]/view/rafts/page.tsx +++ b/rafts/frontend/src/app/[locale]/view/rafts/page.tsx @@ -85,6 +85,8 @@ export default function View() { setIsLoading(true) const { success, data, error } = await getDOIData() if (success && data) { + console.log('RAFTS list - items:', data.map(d => ({ title: d.title, status: d.status }))) + console.log('RAFTS list - item count:', data.length) setDoiData(data) } else { if (error === AUTH_FAILED) { diff --git a/rafts/frontend/src/components/DOIRaftTable/ActionMenu.tsx b/rafts/frontend/src/components/DOIRaftTable/ActionMenu.tsx index 76d3775b..7ba41da2 100644 --- a/rafts/frontend/src/components/DOIRaftTable/ActionMenu.tsx +++ b/rafts/frontend/src/components/DOIRaftTable/ActionMenu.tsx @@ -97,10 +97,7 @@ export default function ActionMenu({ rowData, onStatusChange }: ActionMenuProps) if (rowData.status === BACKEND_STATUS.REJECTED) { setIsSubmitting(true) try { - const result = await updateDOIStatus(raftId, BACKEND_STATUS.IN_PROGRESS, { - dataDirectory: rowData.dataDirectory, - previousStatus: rowData.status, - }) + const result = await updateDOIStatus(raftId, BACKEND_STATUS.IN_PROGRESS) if (!result.success) { console.error('[ActionMenu] Failed to update status:', result.message) onStatusChange?.(result.message || 'Failed to revert to draft', 'error') diff --git a/rafts/frontend/src/components/RaftDetail/RaftDetail.tsx b/rafts/frontend/src/components/RaftDetail/RaftDetail.tsx index 8e96451f..af0e5074 100644 --- a/rafts/frontend/src/components/RaftDetail/RaftDetail.tsx +++ b/rafts/frontend/src/components/RaftDetail/RaftDetail.tsx @@ -147,10 +147,7 @@ export default function RaftDetail({ raftData }: RaftDetailProps) { // If the RAFT is rejected, change status to "in progress" (draft) before editing if (isRejected && raftData.id) { try { - const result = await updateDOIStatus(raftData.id, BACKEND_STATUS.IN_PROGRESS, { - dataDirectory: raftData.dataDirectory, - previousStatus: raftData.generalInfo?.status, - }) + const result = await updateDOIStatus(raftData.id, BACKEND_STATUS.IN_PROGRESS) if (!result.success) { console.error('[RaftDetail] Failed to update status:', result.message) setSnackbar({ diff --git a/rafts/frontend/src/components/RaftDetail/components/DOILinks.tsx b/rafts/frontend/src/components/RaftDetail/components/DOILinks.tsx index f3921dfc..f02b3148 100644 --- a/rafts/frontend/src/components/RaftDetail/components/DOILinks.tsx +++ b/rafts/frontend/src/components/RaftDetail/components/DOILinks.tsx @@ -67,47 +67,238 @@ 'use client' -import { Button, Box, Tooltip } from '@mui/material' -import { ExternalLink, Database } from 'lucide-react' -import { CITATION_PARTIAL_URL, STORAGE_PARTIAL_URL } from '@/utilities/constants' -import { CITE_ULR } from '@/services/constants' +import { useState, useCallback } from 'react' +import { + Button, + Box, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, + Divider, + CircularProgress, + Chip, + IconButton, +} from '@mui/material' +import { ExternalLink, Database, FileText, X, Copy, Check } from 'lucide-react' +import { STORAGE_PARTIAL_URL, STORAGE_VAULT_FILE_URL, DOI_XML_PREFIX } from '@/utilities/constants' +import { parseDataCiteXml, formatCitationText, DOICitation } from '@/utilities/dataCiteParser' +import { fetchDoiCitationXml } from '@/actions/fetchDoiCitationXml' interface DOILinksProps { - doi: string + dataDirectory?: string } -const DOILinks = ({ doi }: DOILinksProps) => { - // Extract the DOI identifier part (e.g., "25.0042" from "10.11570/25.0042") - const doiIdentifier = doi.split('/').pop() || doi +const DOILinks = ({ dataDirectory }: DOILinksProps) => { + const [open, setOpen] = useState(false) + const [citation, setCitation] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) - // Construct the URLs - const landingPageUrl = `${CITATION_PARTIAL_URL}${doiIdentifier}` - const storageUrl = `${STORAGE_PARTIAL_URL}/${CITE_ULR}/${doiIdentifier}/data` + const raftRootDir = dataDirectory?.replace(/\/data\/?$/, '') || null + const raftFolderName = raftRootDir?.split('/').pop() || null + + const doiXmlUrl = + raftRootDir && raftFolderName + ? `${STORAGE_VAULT_FILE_URL}${raftRootDir.startsWith('/') ? '' : '/'}${raftRootDir}/${DOI_XML_PREFIX}${raftFolderName}.xml` + : null + + const storageUrl = dataDirectory + ? `${STORAGE_PARTIAL_URL}${dataDirectory.startsWith('/') ? '' : '/'}${dataDirectory}` + : null + + const handleOpenCitation = useCallback(async () => { + if (!dataDirectory) return + setOpen(true) + setLoading(true) + setError(null) + + try { + const result = await fetchDoiCitationXml(dataDirectory) + if (!result.success || !result.xml) throw new Error(result.error || 'No XML returned') + const parsed = parseDataCiteXml(result.xml) + if (!parsed) throw new Error('Failed to parse citation XML') + setCitation(parsed) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load citation') + } finally { + setLoading(false) + } + }, [dataDirectory]) + + const handleCopyCitation = useCallback(() => { + if (!citation) return + navigator.clipboard.writeText(formatCitationText(citation)) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [citation]) return ( - - - - - - - - - + <> + + {doiXmlUrl && ( + + + + )} + + {storageUrl && ( + + + + )} + + + setOpen(false)} maxWidth="sm" fullWidth> + + + DOI Citation + + setOpen(false)}> + + + + + + {loading && ( + + + + )} + + {error && ( + + {error} + + )} + + {citation && !loading && ( + + + + Title + + {citation.title} + + + + + + + Identifier + + + + + {citation.identifier} + + + + + + + + + {citation.creators.length === 1 ? 'Creator' : 'Creators'} + + {citation.creators.map((c, i) => ( + + + {c.givenName} {c.familyName} + + {c.affiliation && ( + + {c.affiliation} + + )} + + ))} + + + + + + + + Publisher + + {citation.publisher} + + + + Year + + {citation.publicationYear} + + + + Type + + {citation.resourceType} + + + + {citation.dates.length > 0 && ( + <> + + + + Dates + + {citation.dates.map((d, i) => ( + + + {d.date} + + ))} + + + )} + + )} + + + + + + {doiXmlUrl && ( + + )} + + + + + ) } diff --git a/rafts/frontend/src/components/RaftDetail/components/RaftHeader.tsx b/rafts/frontend/src/components/RaftDetail/components/RaftHeader.tsx index 16a27994..81c72a47 100644 --- a/rafts/frontend/src/components/RaftDetail/components/RaftHeader.tsx +++ b/rafts/frontend/src/components/RaftDetail/components/RaftHeader.tsx @@ -127,7 +127,7 @@ export default function RaftHeader({ createdAt, updatedAt, createdBy, - doi, + dataDirectory, generalInfo, reviewer, version, @@ -232,7 +232,7 @@ export default function RaftHeader({ - {doi && } + {dataDirectory && } {/* Action buttons - below on mobile, right side on desktop */} diff --git a/rafts/frontend/src/components/RaftDetail/components/ReviewerSidePanel.tsx b/rafts/frontend/src/components/RaftDetail/components/ReviewerSidePanel.tsx index aaf5d189..6b5120ae 100644 --- a/rafts/frontend/src/components/RaftDetail/components/ReviewerSidePanel.tsx +++ b/rafts/frontend/src/components/RaftDetail/components/ReviewerSidePanel.tsx @@ -67,7 +67,7 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { useRouter } from 'next/navigation' import { RaftData } from '@/types/doi' import { RaftReview } from '@/types/reviews' @@ -87,12 +87,15 @@ import { Tooltip, CircularProgress, Chip, + Stepper, + Step, + StepLabel, } from '@mui/material' import { CheckCircle, XCircle, Undo2, UserCheck, UserX } from 'lucide-react' import StatusBadge from '@/components/common/StatusBadge' import { formatDate, formatUserName } from '@/utilities/formatter' import { useTranslations } from 'next-intl' -import { publishRAFTDOI } from '@/actions/publishRaftDoi' +import { publishRAFTDOI, getMintingStatus } from '@/actions/publishRaftDoi' interface ReviewerSidePanelProps { raftData?: Partial @@ -106,8 +109,39 @@ export default function ReviewerSidePanel({ raftData, review, onNotify }: Review const [statusAction, setStatusAction] = useState(null) const [actionLoading, setActionLoading] = useState(false) const [showAllHistory, setShowAllHistory] = useState(false) + const [mintingStep, setMintingStep] = useState(-1) // -1 = not minting + const [mintingLabel, setMintingLabel] = useState('') + const mintingRef = useRef(false) const t = useTranslations('raft_table') + const MINT_STEPS = ['Locking Data', 'Registering', 'Published'] + + // Poll minting status while publish is in progress + const pollMintingStatus = useCallback(async () => { + if (!raftData?._id && !raftData?.id) return + const id = raftData._id || raftData.id || '' + + mintingRef.current = true + while (mintingRef.current) { + try { + const result = await getMintingStatus(id) + setMintingStep(result.step) + setMintingLabel(result.label) + if (result.step >= 3 || !mintingRef.current) break + } catch { + break + } + await new Promise((r) => setTimeout(r, 2000)) + } + }, [raftData]) + + // Clean up polling on unmount + useEffect(() => { + return () => { + mintingRef.current = false + } + }, []) + if (!raftData) { return null } @@ -124,7 +158,7 @@ export default function ReviewerSidePanel({ raftData, review, onNotify }: Review setStatusAction('claim') // claimForReview sets both reviewer and status to "in review" in one call - const result = await claimForReview(raftId, raftData.dataDirectory) + const result = await claimForReview(raftId) if (result.success) { onNotify('success', 'RAFTS status changed to In Review.') @@ -150,7 +184,7 @@ export default function ReviewerSidePanel({ raftData, review, onNotify }: Review setStatusAction('release') // releaseReview unassigns reviewer and sets status to "review ready" in one call - const result = await releaseReview(raftId, raftData.dataDirectory) + const result = await releaseReview(raftId) if (result.success) { onNotify('success', 'RAFTS status changed to Review Ready.') @@ -171,32 +205,39 @@ export default function ReviewerSidePanel({ raftData, review, onNotify }: Review const handlePublishingDOI = async () => { try { setActionLoading(true) - console.log('[handlePublishingDOI] Publishing DOI for:', raftId, { - dataDirectory: raftData.dataDirectory, - previousStatus: raftData.generalInfo?.status, - }) + setMintingStep(0) + setMintingLabel('Starting...') + + // Start polling status in parallel + pollMintingStatus() const result = await publishRAFTDOI(raftId, { dataDirectory: raftData.dataDirectory, previousStatus: raftData.generalInfo?.status, }) - console.log('[handlePublishingDOI] Result:', result) + // Stop polling + mintingRef.current = false if (result.success) { + setMintingStep(3) + setMintingLabel('Published') onNotify('success', result.message || `RAFTS DOI published successfully.`) - // Refresh the page after a short delay to get updated data setTimeout(() => { - router.refresh() + router.push(`/public-view/rafts/${raftId}`) }, 2000) } else { - console.error('[handlePublishingDOI] Failed:', result.message) onNotify('error', result.message || 'Failed to publish RAFTS DOI.') + setMintingStep(-1) } } catch (error) { + mintingRef.current = false + setMintingStep(-1) console.error('[handlePublishingDOI] Error:', error) onNotify('error', 'An unexpected error occurred.') + } finally { + setActionLoading(false) } } @@ -206,10 +247,7 @@ export default function ReviewerSidePanel({ raftData, review, onNotify }: Review setActionLoading(true) setStatusAction(newStatus) - const result = await updateDOIStatus(raftId, newStatus, { - dataDirectory: raftData.dataDirectory, - previousStatus: raftData.generalInfo?.status, - }) + const result = await updateDOIStatus(raftId, newStatus) if (result.success) { onNotify( @@ -479,36 +517,52 @@ export default function ReviewerSidePanel({ raftData, review, onNotify }: Review case BACKEND_STATUS.APPROVED: return ( - - - - - - + {mintingStep >= 0 ? ( + + + {MINT_STEPS.map((label) => ( + + {label} + + ))} + + + + + {mintingLabel} + + + + ) : ( + <> + + + + + + + + )} ) @@ -534,6 +588,83 @@ export default function ReviewerSidePanel({ raftData, review, onNotify }: Review ) + // Intermediate minting statuses: may be stuck from a previous attempt + case BACKEND_STATUS.LOCKING_DATA: + case BACKEND_STATUS.LOCKED_DATA: + case BACKEND_STATUS.REGISTERING: { + const resumeStep = + currentStatus === BACKEND_STATUS.LOCKING_DATA ? 1 : + currentStatus === BACKEND_STATUS.LOCKED_DATA ? 2 : + 2 + return ( + + = 0 ? mintingStep : resumeStep} alternativeLabel> + {MINT_STEPS.map((label) => ( + + {label} + + ))} + + {mintingStep >= 0 ? ( + + + + {mintingLabel} + + + ) : ( + <> + + Publishing was started but did not complete. + + + + )} + + ) + } + + // Error during minting: allow retry + case BACKEND_STATUS.ERROR_LOCKING_DATA: + case BACKEND_STATUS.ERROR_REGISTERING: { + const errorStep = currentStatus === BACKEND_STATUS.ERROR_LOCKING_DATA ? 1 : 2 + return ( + + + {MINT_STEPS.map((label, i) => ( + + {label} + + ))} + + + An error occurred during publishing. You can retry. + + + + ) + } + // "minted" status: no actions available (published) case BACKEND_STATUS.MINTED: return ( diff --git a/rafts/frontend/src/components/RaftDetail/components/StatusFilter.tsx b/rafts/frontend/src/components/RaftDetail/components/StatusFilter.tsx index 3fbb5d4d..e8813fdb 100644 --- a/rafts/frontend/src/components/RaftDetail/components/StatusFilter.tsx +++ b/rafts/frontend/src/components/RaftDetail/components/StatusFilter.tsx @@ -70,6 +70,7 @@ import React from 'react' import { Paper, Box, Typography, ButtonGroup, Button, Chip } from '@mui/material' import { + OPTION_ALL, OPTION_REVIEW, OPTION_UNDER_REVIEW, OPTION_APPROVED, @@ -99,7 +100,13 @@ const StatusFilter: React.FC = ({ currentStatus, counts, onSt return 'inherit' } - const statusOptions = [OPTION_REVIEW, OPTION_UNDER_REVIEW, OPTION_APPROVED, OPTION_REJECTED] + const statusOptions = [ + OPTION_ALL, + OPTION_REVIEW, + OPTION_UNDER_REVIEW, + OPTION_APPROVED, + OPTION_REJECTED, + ] return ( diff --git a/rafts/frontend/src/components/RaftTable/PublishedViewButton.tsx b/rafts/frontend/src/components/RaftTable/PublishedViewButton.tsx new file mode 100644 index 00000000..beb937ed --- /dev/null +++ b/rafts/frontend/src/components/RaftTable/PublishedViewButton.tsx @@ -0,0 +1,25 @@ +'use client' + +import { IconButton, Tooltip } from '@mui/material' +import { Eye } from 'lucide-react' +import { useRouter } from 'next/navigation' + +interface PublishedViewButtonProps { + raftId: string +} + +export default function PublishedViewButton({ raftId }: PublishedViewButtonProps) { + const router = useRouter() + + return ( + + router.push(`/public-view/rafts/${raftId}`)} + > + + + + ) +} diff --git a/rafts/frontend/src/components/RaftTable/publishedColumns.tsx b/rafts/frontend/src/components/RaftTable/publishedColumns.tsx index fed7f3b0..75eebca3 100644 --- a/rafts/frontend/src/components/RaftTable/publishedColumns.tsx +++ b/rafts/frontend/src/components/RaftTable/publishedColumns.tsx @@ -69,7 +69,7 @@ import { ColumnDef } from '@tanstack/react-table' import type { RaftData } from '@/types/doi' -import ActionMenu from './PublishedActionMenu' +import PublishedViewButton from './PublishedViewButton' import { Typography, Tooltip } from '@mui/material' import dayjs from 'dayjs' @@ -163,7 +163,7 @@ export const columns: ColumnDef[] = [ id: 'actions', header: '', cell: ({ row }) => { - return + return }, }, ] diff --git a/rafts/frontend/src/components/common/StatusBadge.tsx b/rafts/frontend/src/components/common/StatusBadge.tsx index cbb34886..2c7e98de 100644 --- a/rafts/frontend/src/components/common/StatusBadge.tsx +++ b/rafts/frontend/src/components/common/StatusBadge.tsx @@ -124,6 +124,25 @@ const getStatusInfo = ( displayKey: 'rejected', } + case BACKEND_STATUS.LOCKING_DATA: + case BACKEND_STATUS.LOCKED_DATA: + case BACKEND_STATUS.REGISTERING: + return { + bg: 'info.light', + color: 'white', + tooltipKey: 'tooltip_publishing', + displayKey: 'publishing', + } + + case BACKEND_STATUS.ERROR_LOCKING_DATA: + case BACKEND_STATUS.ERROR_REGISTERING: + return { + bg: 'error.light', + color: 'white', + tooltipKey: 'tooltip_publish_error', + displayKey: 'publish_error', + } + case BACKEND_STATUS.IN_PROGRESS: case 'draft': default: diff --git a/rafts/frontend/src/services/canfarStorage.ts b/rafts/frontend/src/services/canfarStorage.ts index f9542ce2..678e4d7f 100644 --- a/rafts/frontend/src/services/canfarStorage.ts +++ b/rafts/frontend/src/services/canfarStorage.ts @@ -406,6 +406,60 @@ export const updateRaftMetadata = async ( } } +// Production vault endpoint — minted/public data may live on prod even when DOI service is on RC +const VAULT_PROD_ENDPOINT = 'https://ws-cadc.canfar.net/vault/files' + +/** + * Download RAFT.json from a public (minted) data directory without authentication. + * Minted DOIs have isPublic=true so VOSpace serves them anonymously. + * Tries the configured vault first, then falls back to production vault. + */ +export const downloadRaftFilePublic = async ( + dataDirectory: string, +): Promise> => { + try { + const cleanedDataDirectory = dataDirectory.startsWith('/') + ? dataDirectory.slice(1) + : dataDirectory + const filePath = `${cleanedDataDirectory}/${DEFAULT_RAFT_NAME}` + + // Try configured vault endpoint first + const url = `${VAULT_BASE_ENDPOINT}/${filePath}` + const response = await fetch(url, { method: 'GET' }) + + if (response.ok) { + const data = await response.json() + return { success: true, data } + } + + // If configured vault is not production, try production as fallback + if (VAULT_BASE_ENDPOINT !== VAULT_PROD_ENDPOINT) { + const prodUrl = `${VAULT_PROD_ENDPOINT}/${filePath}` + console.log(`[downloadRaftFilePublic] RC returned ${response.status}, trying prod: ${prodUrl}`) + const prodResponse = await fetch(prodUrl, { method: 'GET' }) + + if (prodResponse.ok) { + const data = await prodResponse.json() + return { success: true, data } + } + + const errorText = (await prodResponse.text()) || 'Failed to download public RAFT file' + console.error('[downloadRaftFilePublic] Prod also failed:', prodResponse.status, errorText) + return { success: false, message: errorText } + } + + const errorText = (await response.text()) || 'Failed to download public RAFT file' + console.error('[downloadRaftFilePublic] Error:', response.status, errorText) + return { success: false, message: errorText } + } catch (error) { + console.error('[downloadRaftFilePublic] Exception:', error) + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + } + } +} + export const downloadRaftFile = async ( dataDirectory: string, accessToken: string, diff --git a/rafts/frontend/src/shared/backendStatus.ts b/rafts/frontend/src/shared/backendStatus.ts index 3bf9aa5a..bd43d49f 100644 --- a/rafts/frontend/src/shared/backendStatus.ts +++ b/rafts/frontend/src/shared/backendStatus.ts @@ -74,6 +74,11 @@ export const BACKEND_STATUS = { IN_REVIEW: 'in review', APPROVED: 'approved', REJECTED: 'rejected', + LOCKING_DATA: 'locking data directory', + ERROR_LOCKING_DATA: 'error locking data directory', + LOCKED_DATA: 'locked data directory', + REGISTERING: 'registering to DataCite', + ERROR_REGISTERING: 'error registering to DataCite', MINTED: 'minted', } as const @@ -87,6 +92,11 @@ const STATUS_DISPLAY_NAMES: Record = { 'in review': 'In Review', approved: 'Approved', rejected: 'Rejected', + 'locking data directory': 'Publishing...', + 'error locking data directory': 'Error Locking Data', + 'locked data directory': 'Publishing...', + 'registering to DataCite': 'Publishing...', + 'error registering to DataCite': 'Error Registering', minted: 'Published', } @@ -106,4 +116,7 @@ export const getStatusDisplayName = (status?: string): string => { * in review → request revision → in progress * approved → in progress (revision) * rejected → in progress (revision) + * + * Minting workflow (multi-step via /mint endpoint): + * approved → locking data directory → locked data directory → registering to DataCite → minted */ diff --git a/rafts/frontend/src/shared/constants.ts b/rafts/frontend/src/shared/constants.ts index 3772db8c..dd81b74d 100644 --- a/rafts/frontend/src/shared/constants.ts +++ b/rafts/frontend/src/shared/constants.ts @@ -125,6 +125,8 @@ export const OPTION_UNDER_REVIEW = 'under_review' export const OPTION_APPROVED = 'approved' export const OPTION_REJECTED = 'rejected' export const OPTION_PUBLISHED = 'published' +export const OPTION_ALL = 'all' +export const OPTION_PUBLISHING = 'publishing' // Backend status values — re-exported from single source of truth // Use BACKEND_STATUS from '@/shared/backendStatus' for new code export { BACKEND_STATUS } from './backendStatus' diff --git a/rafts/frontend/src/utilities/__tests__/dataCiteParser.test.ts b/rafts/frontend/src/utilities/__tests__/dataCiteParser.test.ts new file mode 100644 index 00000000..8c31e29b --- /dev/null +++ b/rafts/frontend/src/utilities/__tests__/dataCiteParser.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest' +import { parseDataCiteXml, formatCitationText, DOICitation } from '../dataCiteParser' + +const SAMPLE_XML = ` + + 10.80791/RAFTS-1u7s1-50b76.test + + + Zautkin, Serhii + Serhii + Zautkin + NRC + + + + RAFTS PUB 14 + + NRC CADC + 2026 + Dataset + + 2026-03-12 + +` + +const MULTI_CREATOR_XML = ` + + 10.80791/RAFTS-test + + + Smith, Jane + Jane + Smith + MIT + + + Doe, John + John + Doe + NASA + + + + Multi Author Dataset + + Publisher + 2025 + Dataset + + 2025-01-01 + 2025-06-15 + +` + +describe('parseDataCiteXml', () => { + it('parses a valid DataCite XML', () => { + const result = parseDataCiteXml(SAMPLE_XML) + expect(result).not.toBeNull() + expect(result!.identifier).toBe('10.80791/RAFTS-1u7s1-50b76.test') + expect(result!.identifierType).toBe('DOI') + expect(result!.title).toBe('RAFTS PUB 14') + expect(result!.publisher).toBe('NRC CADC') + expect(result!.publicationYear).toBe('2026') + expect(result!.resourceType).toBe('Dataset') + }) + + it('parses creators correctly', () => { + const result = parseDataCiteXml(SAMPLE_XML)! + expect(result.creators).toHaveLength(1) + expect(result.creators[0]).toEqual({ + name: 'Zautkin, Serhii', + givenName: 'Serhii', + familyName: 'Zautkin', + affiliation: 'NRC', + }) + }) + + it('parses dates correctly', () => { + const result = parseDataCiteXml(SAMPLE_XML)! + expect(result.dates).toHaveLength(1) + expect(result.dates[0]).toEqual({ + date: '2026-03-12', + dateType: 'Created', + info: 'The date the DOI was created', + }) + }) + + it('parses multiple creators', () => { + const result = parseDataCiteXml(MULTI_CREATOR_XML)! + expect(result.creators).toHaveLength(2) + expect(result.creators[0].familyName).toBe('Smith') + expect(result.creators[1].familyName).toBe('Doe') + }) + + it('parses multiple dates', () => { + const result = parseDataCiteXml(MULTI_CREATOR_XML)! + expect(result.dates).toHaveLength(2) + expect(result.dates[0].dateType).toBe('Created') + expect(result.dates[1].dateType).toBe('Updated') + }) + + it('returns null for invalid XML', () => { + const result = parseDataCiteXml(' { + const minimalXml = ` + + 10.80791/test + Test +` + const result = parseDataCiteXml(minimalXml)! + expect(result.identifier).toBe('10.80791/test') + expect(result.title).toBe('Test') + expect(result.publisher).toBe('') + expect(result.publicationYear).toBe('') + expect(result.creators).toHaveLength(0) + expect(result.dates).toHaveLength(0) + }) +}) + +describe('formatCitationText', () => { + it('formats a single-author citation', () => { + const citation: DOICitation = { + identifier: '10.80791/RAFTS-test', + identifierType: 'DOI', + creators: [{ name: 'Zautkin, Serhii', givenName: 'Serhii', familyName: 'Zautkin', affiliation: 'NRC' }], + title: 'Test Dataset', + publisher: 'NRC CADC', + publicationYear: '2026', + resourceType: 'Dataset', + dates: [], + } + const text = formatCitationText(citation) + expect(text).toBe('Zautkin, Serhii (2026). Test Dataset. NRC CADC. DOI: 10.80791/RAFTS-test') + }) + + it('formats a multi-author citation with semicolons', () => { + const citation: DOICitation = { + identifier: '10.80791/RAFTS-multi', + identifierType: 'DOI', + creators: [ + { name: 'Smith, Jane', givenName: 'Jane', familyName: 'Smith', affiliation: 'MIT' }, + { name: 'Doe, John', givenName: 'John', familyName: 'Doe', affiliation: 'NASA' }, + ], + title: 'Joint Research', + publisher: 'Publisher', + publicationYear: '2025', + resourceType: 'Dataset', + dates: [], + } + const text = formatCitationText(citation) + expect(text).toBe('Smith, Jane; Doe, John (2025). Joint Research. Publisher. DOI: 10.80791/RAFTS-multi') + }) +}) diff --git a/rafts/frontend/src/utilities/constants.ts b/rafts/frontend/src/utilities/constants.ts index 26b1ce9e..72748dd6 100644 --- a/rafts/frontend/src/utilities/constants.ts +++ b/rafts/frontend/src/utilities/constants.ts @@ -67,4 +67,6 @@ export const CITATION_PARTIAL_URL = 'https://www.canfar.net/citation/landing?doi=' export const STORAGE_PARTIAL_URL = 'https://www.canfar.net/storage/list' +export const STORAGE_VAULT_FILE_URL = 'https://www.canfar.net/storage/vault/file' +export const DOI_XML_PREFIX = 'CISTI_CADC_' export const COOKIE_SSO_KEY = process.env.NEXT_COOKIE_SSO_KEY || 'CADC_SSO' diff --git a/rafts/frontend/src/utilities/dataCiteParser.ts b/rafts/frontend/src/utilities/dataCiteParser.ts new file mode 100644 index 00000000..92ad1325 --- /dev/null +++ b/rafts/frontend/src/utilities/dataCiteParser.ts @@ -0,0 +1,51 @@ +export interface DOICitation { + identifier: string + identifierType: string + creators: { name: string; givenName: string; familyName: string; affiliation: string }[] + title: string + publisher: string + publicationYear: string + resourceType: string + dates: { date: string; dateType: string; info: string }[] +} + +export function parseDataCiteXml(xmlString: string): DOICitation | null { + const parser = new DOMParser() + const doc = parser.parseFromString(xmlString, 'text/xml') + + const parseError = doc.querySelector('parsererror') + if (parseError) return null + + const getText = (parent: Element | Document, tag: string) => + parent.getElementsByTagName(tag)[0]?.textContent?.trim() || '' + + const identifierEl = doc.getElementsByTagName('identifier')[0] + const creators = Array.from(doc.getElementsByTagName('creator')).map((c) => ({ + name: getText(c, 'creatorName'), + givenName: getText(c, 'givenName'), + familyName: getText(c, 'familyName'), + affiliation: getText(c, 'affiliation'), + })) + + const dates = Array.from(doc.getElementsByTagName('date')).map((d) => ({ + date: d.textContent?.trim() || '', + dateType: d.getAttribute('dateType') || '', + info: d.getAttribute('dateInformation') || '', + })) + + return { + identifier: identifierEl?.textContent?.trim() || '', + identifierType: identifierEl?.getAttribute('identifierType') || '', + creators, + title: getText(doc, 'title'), + publisher: getText(doc, 'publisher'), + publicationYear: getText(doc, 'publicationYear'), + resourceType: getText(doc, 'resourceType'), + dates, + } +} + +export function formatCitationText(citation: DOICitation): string { + const authors = citation.creators.map((c) => `${c.familyName}, ${c.givenName}`).join('; ') + return `${authors} (${citation.publicationYear}). ${citation.title}. ${citation.publisher}. ${citation.identifierType}: ${citation.identifier}` +}