Skip to content

Commit

Permalink
feat: external publications feat
Browse files Browse the repository at this point in the history
  • Loading branch information
shadrach-tayo committed Dec 24, 2024
1 parent 3aeb30d commit 9e6b451
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 60 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ExternalPublications" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"score" DOUBLE PRECISION NOT NULL,
"doi" TEXT NOT NULL,
"publisher" TEXT NOT NULL,
"publishYear" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ExternalPublications_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "ExternalPublications" ADD CONSTRAINT "ExternalPublications_uuid_fkey" FOREIGN KEY ("uuid") REFERENCES "Node"("uuid") ON DELETE RESTRICT ON UPDATE CASCADE;
104 changes: 60 additions & 44 deletions desci-server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,51 @@ datasource db {
}

model Node {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
cid String @default("")
state NodeState @default(NEW)
isFeatured Boolean @default(false)
manifestUrl String
restBody Json @default("{}")
replicationFactor Int
ownerId Int
uuid String? @unique @default(uuid())
manifestDocumentId String @default("")
owner User @relation(fields: [ownerId], references: [id])
authorInvites AuthorInvite[]
transactions ChainTransaction[]
interactionLogs InteractionLog[]
authors NodeAuthor[]
versions NodeVersion[]
votes NodeVote[]
DataReference DataReference[]
PublicDataReference PublicDataReference[]
CidPruneList CidPruneList[]
NodeCover NodeCover[]
isDeleted Boolean @default(false)
deletedAt DateTime?
UploadJobs UploadJobs[]
DraftNodeTree DraftNodeTree[]
ceramicStream String?
NodeAttestation NodeAttestation[]
NodeThumbnails NodeThumbnails[]
PublishTaskQueue PublishTaskQueue[]
NodeContribution NodeContribution[]
PrivateShare PrivateShare[]
OrcidPutCodes OrcidPutCodes[]
DistributionPdfs DistributionPdfs[]
PdfPreviews PdfPreviews[]
DoiRecord DoiRecord[]
dpidAlias Int?
DoiSubmissionQueue DoiSubmissionQueue[]
BookmarkedNode BookmarkedNode[]
DeferredEmails DeferredEmails[]
UserNotifications UserNotifications[]
Annotation Annotation[]
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
cid String @default("")
state NodeState @default(NEW)
isFeatured Boolean @default(false)
manifestUrl String
restBody Json @default("{}")
replicationFactor Int
ownerId Int
uuid String? @unique @default(uuid())
manifestDocumentId String @default("")
owner User @relation(fields: [ownerId], references: [id])
authorInvites AuthorInvite[]
transactions ChainTransaction[]
interactionLogs InteractionLog[]
authors NodeAuthor[]
versions NodeVersion[]
votes NodeVote[]
DataReference DataReference[]
PublicDataReference PublicDataReference[]
CidPruneList CidPruneList[]
NodeCover NodeCover[]
isDeleted Boolean @default(false)
deletedAt DateTime?
UploadJobs UploadJobs[]
DraftNodeTree DraftNodeTree[]
ceramicStream String?
NodeAttestation NodeAttestation[]
NodeThumbnails NodeThumbnails[]
PublishTaskQueue PublishTaskQueue[]
NodeContribution NodeContribution[]
PrivateShare PrivateShare[]
OrcidPutCodes OrcidPutCodes[]
DistributionPdfs DistributionPdfs[]
PdfPreviews PdfPreviews[]
DoiRecord DoiRecord[]
dpidAlias Int?
DoiSubmissionQueue DoiSubmissionQueue[]
BookmarkedNode BookmarkedNode[]
DeferredEmails DeferredEmails[]
UserNotifications UserNotifications[]
Annotation Annotation[]
ExternalPublications ExternalPublications[]
@@index([ownerId])
@@index([uuid])
Expand Down Expand Up @@ -936,6 +937,21 @@ model UserNotifications {
user User @relation(fields: [userId], references: [id])
}

model ExternalPublications {
id Int @id @default(autoincrement())
uuid String
node Node @relation(fields: [uuid], references: [uuid])
score Float
doi String
publisher String
publishYear String
sourceUrl String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// @@unique([uuid, publisher])
}

enum ORCIDRecord {
WORK
QUALIFICATION
Expand Down
172 changes: 160 additions & 12 deletions desci-server/src/controllers/nodes/externalPublications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,52 +5,200 @@ import z from 'zod';

import { prisma } from '../../client.js';
import { NotFoundError } from '../../core/ApiError.js';
import { SuccessResponse } from '../../core/ApiResponse.js';
import { logger } from '../../logger.js';
import { SuccessMessageResponse, SuccessResponse } from '../../core/ApiResponse.js';
import { logger as parentLogger } from '../../logger.js';
import { RequestWithNode } from '../../middleware/authorisation.js';
import { crossRefClient } from '../../services/index.js';
import { NodeUuid } from '../../services/manifestRepo.js';
import repoService from '../../services/repoService.js';
import { ensureUuidEndsWithDot } from '../../utils.js';

const logger = parentLogger.child({ module: 'ExternalPublications' });
export const externalPublicationsSchema = z.object({
params: z.object({
// quickly disqualify false uuid strings
uuid: z.string().min(10),
}),
});

export const addExternalPublicationsSchema = z.object({
params: z.object({
// quickly disqualify false uuid strings
uuid: z.string().min(10),
}),
body: z.object({
// uuid: z.string(),
score: z.coerce.number(),
doi: z.string(),
publisher: z.string(),
publishYear: z.string(),
sourceUrl: z.string(),
}),
});

export const externalPublications = async (req: RequestWithNode, res: Response, _next: NextFunction) => {
const { uuid } = req.params as z.infer<typeof externalPublicationsSchema>['params'];
const node = await prisma.node.findFirst({ where: { uuid: ensureUuidEndsWithDot(uuid) } });
if (!node) throw new NotFoundError(`Node ${uuid} not found`);

const userIsNodeOwner = req.user?.id === node?.ownerId;

logger.trace({ uuid, userIsNodeOwner });

const externalPublication = await prisma.externalPublications.findMany({
where: { uuid: ensureUuidEndsWithDot(uuid) },
});

if (externalPublication.length > 0) return new SuccessResponse(externalPublication).send(res);

// return empty list if user is not node owner
if (!userIsNodeOwner) return new SuccessResponse([]).send(res);

const manifest = await repoService.getDraftManifest({ uuid: uuid as NodeUuid, documentId: node.manifestDocumentId });
const data = await crossRefClient.searchWorks({ queryTitle: manifest?.title });

logger.trace({ data }, 'CrossRef search result');

if (data.length > 0) {
const titleSearcher = new Searcher(data, { keySelector: (entry) => entry.title });
const titleResult = titleSearcher.search(manifest.title, { returnMatchData: true });
logger.trace({ titleResult }, 'Title search result');
// logger.trace(
// {
// data: titleResult.map((data) => ({
// title: data.item.title,
// publisher: data.item.publisher,
// source_url: data.item?.resource?.primary?.URL || data.item.URL || '',
// doi: data.item.DOI,
// key: data.key,
// match: data.match,
// score: data.score,
// })),
// },
// 'Title search result',
// );

const descSearcher = new Searcher(data, { keySelector: (entry) => entry.abstract ?? '' });
const descSearcher = new Searcher(data, { keySelector: (entry) => entry?.abstract ?? '' });
const descResult = descSearcher.search(manifest.description ?? '', { returnMatchData: true });
logger.trace({ descResult }, 'Desc search result');
// logger.trace(
// {
// data: descResult.map((data) => ({
// title: data.item.title,
// key: data.key,
// match: data.match,
// score: data.score,
// })),
// },
// 'Desc search result',
// );

const authorsSearchScores = data.map((work) => {
const authorSearcher = new Searcher(work.author, { keySelector: (entry) => entry.name });
const authorSearcher = new Searcher(work.author, { keySelector: (entry) => `${entry.given} ${entry.family}` });

const nodeAuthorsMatch = manifest.authors.map((author) =>
authorSearcher.search(author.name, { returnMatchData: true }),
);
return manifest.authors.length / nodeAuthorsMatch.flat().reduce((total, match) => (total += match.score), 0);
return {
publisher: work.publisher,
score: nodeAuthorsMatch.flat().reduce((total, match) => (total += match.score), 0) / manifest.authors.length,
match: nodeAuthorsMatch.flat().map((data) => ({
key: data.key,
match: data.match,
score: data.score,
author: data.item,
publisher: work.publisher,
doi: work.DOI,
})),
};
});
logger.trace({ authorsSearchScores }, 'AuthorsSearchScores');

return new SuccessResponse({ titleResult, descResult, authorsSearchScores });
const publications = data
.map((data) => ({
publisher: data.publisher,
sourceUrl: data?.resource?.primary?.URL || data.URL || '',
doi: data.DOI,
'is-referenced-by-count': data['is-referenced-by-count'] ?? 0,
publishYear:
data.published['date-parts']?.[0]?.[0].toString() ??
data.license
.map((licence) => licence.start['date-parts']?.[0]?.[0])
.filter(Boolean)?.[0]
.toString(),
title: titleResult
.filter((res) => res.item.publisher === data.publisher)
.map((data) => ({
title: data.item.title,
key: data.key,
match: data.match,
score: data.score,
}))?.[0],
abstract: descResult
.filter((res) => res.item.publisher === data.publisher)
.map((data) => ({
key: data.key,
match: data.match,
score: data.score,
abstract: data.item?.abstract ?? '',
}))?.[0],
authors: authorsSearchScores
.filter((res) => res.publisher === data.publisher)
.map((data) => ({
score: data.score,
authors: data.match,
}))?.[0],
}))
.map((publication) => ({
...publication,
score:
((publication.title?.score ?? 0) + (publication.abstract?.score ?? 0) + (publication.authors?.score ?? 0)) /
3,
}))
.filter((entry) => entry.score >= 0.4);

logger.trace({ publications, uuid }, 'externalPublications');

if (publications.length > 0) return new SuccessResponse(publications).send(res);

// return new SuccessResponse({
// title: titleResult.map((data) => ({
// title: data.item.title,
// publisher: data.item.publisher,
// source_url: data.item?.resource?.primary?.URL || data.item.URL || '',
// doi: data.item.DOI,
// key: data.key,
// match: data.match,
// score: data.score,
// 'is-referenced-by-count': data.item['is-referenced-by-count'],
// })),
// abstract: descResult.map((data) => ({
// key: data.key,
// match: data.match,
// score: data.score,
// abstract: data.item?.abstract ?? '',
// publisher: data.item.publisher,
// source_url: data.item?.resource?.primary?.URL || data.item.URL || '',
// doi: data.item.DOI,
// })),
// authors: authorsSearchScores,
// }).send(res);
}

return new SuccessResponse(data).send(res);
return new SuccessResponse([]).send(res);
};

export const addExternalPublication = async (req: RequestWithNode, res: Response, _next: NextFunction) => {
const { uuid } = req.params as z.infer<typeof addExternalPublicationsSchema>['params'];

const node = await prisma.node.findFirst({ where: { uuid: ensureUuidEndsWithDot(uuid) } });
if (!node) throw new NotFoundError(`Node ${uuid} not found`);

const { doi, sourceUrl, publishYear, publisher, score } = req.body as z.infer<
typeof addExternalPublicationsSchema
>['body'];

const exists = await prisma.externalPublications.findFirst({ where: { AND: [{ uuid }, { publisher }] } });
if (exists) return new SuccessMessageResponse().send(res);

const entry = await prisma.externalPublications.create({
data: { doi, score, sourceUrl, publisher, publishYear, uuid: ensureUuidEndsWithDot(uuid) },
});

return new SuccessResponse(entry).send(res);
};
20 changes: 17 additions & 3 deletions desci-server/src/routes/v1/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import { verifyContribution } from '../../controllers/nodes/contributions/verify
import { createDpid } from '../../controllers/nodes/createDpid.js';
import { dispatchDocumentChange, getNodeDocument } from '../../controllers/nodes/documents.js';
import { explore } from '../../controllers/nodes/explore.js';
import { externalPublications, externalPublicationsSchema } from '../../controllers/nodes/externalPublications.js';
import {
addExternalPublication,
addExternalPublicationsSchema,
externalPublications,
externalPublicationsSchema,
} from '../../controllers/nodes/externalPublications.js';
import { feed } from '../../controllers/nodes/feed.js';
import { frontmatterPreview } from '../../controllers/nodes/frontmatterPreview.js';
import { getDraftNodeStats } from '../../controllers/nodes/getDraftNodeStats.js';
Expand Down Expand Up @@ -152,6 +157,17 @@ router.post(

router.delete('/:uuid', [ensureUser], deleteNode);

router.get(
'/:uuid/external-publications',
[validate(externalPublicationsSchema), attachUser],
asyncHandler(externalPublications),
);
router.post(
'/:uuid/external-publications',
[validate(addExternalPublicationsSchema), ensureUser, ensureNodeAccess],
asyncHandler(addExternalPublication),
);

router.get('/:uuid/comments', [validate(getCommentsSchema), attachUser], asyncHandler(getGeneralComments));

router.get('/:uuid/attestations', [validate(showNodeAttestationsSchema)], asyncHandler(showNodeAttestations));
Expand All @@ -162,8 +178,6 @@ router.get('/legacy/retrieveTitle', retrieveTitle);

router.post('/api/*', [], api);

router.get('/:uuid/external-publications', [validate(externalPublicationsSchema)], asyncHandler(externalPublications));

// must be last
router.get('/showPrivate/*', show);
router.get('/*', [ensureUser], show);
Expand Down
2 changes: 1 addition & 1 deletion desci-server/src/services/crossRef/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ class CrossRefClient {

if (crossRefResponse.ok) {
const apiRes = (await crossRefResponse.json()) as Items<Work>;
console.log('[api/publications/search.ts]', apiRes);
// console.log('[api/publications/search.ts]', apiRes);
const data = apiRes.message.items ?? []; // sort((a, b) => b['is-referenced-by-count'] - a['is-referenced-by-count'])?.[0];
return data;
} else {
Expand Down
Loading

0 comments on commit 9e6b451

Please sign in to comment.