Skip to content

Commit

Permalink
Merge pull request #738 from desci-labs/tay/external-pub
Browse files Browse the repository at this point in the history
Tay/external pub
  • Loading branch information
shadrach-tayo authored Dec 27, 2024
2 parents caff95d + 0ddc1ff commit 7deda97
Show file tree
Hide file tree
Showing 8 changed files with 372 additions and 44 deletions.
1 change: 1 addition & 0 deletions desci-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"crypto": "^1.0.1",
"ethers": "^5.6.9",
"express": "^4.17.1",
"fast-fuzzy": "^1.12.0",
"form-data": "^4.0.0",
"gaxios": "^6.3.0",
"googleapis": "^133.0.0",
Expand Down
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
193 changes: 193 additions & 0 deletions desci-server/src/controllers/nodes/externalPublications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { Response, NextFunction } from 'express';
import { Searcher } from 'fast-fuzzy';
import _ from 'lodash';
import z from 'zod';

import { prisma } from '../../client.js';
import { NotFoundError } from '../../core/ApiError.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 });

if (data.length > 0) {
const titleSearcher = new Searcher(data, { keySelector: (entry) => entry.title });
const titleResult = titleSearcher.search(manifest.title, { returnMatchData: true });
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 descResult = descSearcher.search(manifest.description ?? '', { returnMatchData: true });
logger.trace(
{
data: descResult.map((data) => ({
title: data.item.title,
key: data.key,
match: data.match,
score: data.score,
})),
},
'Abstract search result',
);

const authorsSearchScores = data.map((work) => {
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 {
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(
{
data: descResult.map((data) => ({
title: data.item.title,
key: data.key,
match: data.match,
score: data.score,
})),
},
'Authors search result',
);

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.8);

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

if (publications.length > 0) return new SuccessResponse(publications).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);
};
17 changes: 17 additions & 0 deletions desci-server/src/routes/v1/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +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 {
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 @@ -151,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 Down
22 changes: 22 additions & 0 deletions desci-server/src/services/crossRef/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,28 @@ class CrossRefClient {
return { success: false, failure: false };
}
}

async searchWorks({ queryTitle }: QueryWorkParams) {
const crossRefResponse = await fetch(
`https://api.crossref.org/works?filter=has-full-text:true&mailto=sina@desci.com&query.title=${encodeURIComponent(
queryTitle,
)}&rows=3`,
{
headers: {
Accept: '*/*',
},
},
);

if (crossRefResponse.ok) {
const apiRes = (await crossRefResponse.json()) as Items<Work>;
// 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 {
return [];
}
}
}

export default CrossRefClient;
Expand Down
Loading

0 comments on commit 7deda97

Please sign in to comment.