diff --git a/desci-server/package.json b/desci-server/package.json index d8c0e8aa..45fa6d91 100755 --- a/desci-server/package.json +++ b/desci-server/package.json @@ -27,6 +27,7 @@ "script:seed-community-member": "debug=* node --no-warnings --enable-source-maps --loader ts-node/esm ./src/scripts/seed-community-members.ts", "script:backfill-annotations": "debug=* node --no-warnings --enable-source-maps --loader ts-node/esm ./src/scripts/backfill-annotations.ts", "script:prune-auth-tokens": "debug=* node --no-warnings --enable-source-maps --loader ts-node/esm ./src/scripts/prune-auth-tokens.ts", + "script:backfill-radar": "debug=* node --no-warnings --enable-source-maps --loader ts-node/esm ./src/scripts/backfill-radar.ts", "build": "rimraf dist && tsc && yarn copy-files; if [ \"$SENTRY_AUTH_TOKEN\" ]; then yarn sentry:sourcemaps; else echo 'SENTRY_AUTH_TOKEN not set, sourcemaps will not upload'; fi", "build:worker": "cd ../sync-server && ./scripts/build.sh test", "copy-files": "copyfiles -u 1 src/**/*.cjs dist/", diff --git a/desci-server/prisma/migrations/20250108131055_community_radar_entry/migration.sql b/desci-server/prisma/migrations/20250108131055_community_radar_entry/migration.sql new file mode 100644 index 00000000..c67377d3 --- /dev/null +++ b/desci-server/prisma/migrations/20250108131055_community_radar_entry/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "NodeAttestation" ADD COLUMN "communityRadarEntryId" INTEGER; + +-- CreateTable +CREATE TABLE "CommunityRadarEntry" ( + "id" SERIAL NOT NULL, + "desciCommunityId" INTEGER NOT NULL, + "nodeUuid" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CommunityRadarEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CommunityRadarEntry_nodeUuid_desciCommunityId_key" ON "CommunityRadarEntry"("nodeUuid", "desciCommunityId"); + +-- AddForeignKey +ALTER TABLE "NodeAttestation" ADD CONSTRAINT "NodeAttestation_communityRadarEntryId_fkey" FOREIGN KEY ("communityRadarEntryId") REFERENCES "CommunityRadarEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommunityRadarEntry" ADD CONSTRAINT "CommunityRadarEntry_desciCommunityId_fkey" FOREIGN KEY ("desciCommunityId") REFERENCES "DesciCommunity"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommunityRadarEntry" ADD CONSTRAINT "CommunityRadarEntry_nodeUuid_fkey" FOREIGN KEY ("nodeUuid") REFERENCES "Node"("uuid") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/desci-server/prisma/schema.prisma b/desci-server/prisma/schema.prisma index 7ef6f3e8..e9145dc5 100755 --- a/desci-server/prisma/schema.prisma +++ b/desci-server/prisma/schema.prisma @@ -801,8 +801,8 @@ model NodeAttestation { NodeAttestationVerification NodeAttestationVerification[] NodeAttestationReaction NodeAttestationReaction[] OrcidPutCodes OrcidPutCodes[] - CommunityRadarEntry CommunityRadarEntry? @relation(fields: [nodeUuid, desciCommunityId], references: [nodeUuid, desciCommunityId]) - // communityRadarEntryId Int? + CommunityRadarEntry CommunityRadarEntry? @relation(fields: [communityRadarEntryId], references: [id]) + communityRadarEntryId Int? @@unique([nodeUuid, nodeVersion, attestationId, attestationVersionId]) } diff --git a/desci-server/src/controllers/attestations/claims.ts b/desci-server/src/controllers/attestations/claims.ts index b248d85d..961164bd 100644 --- a/desci-server/src/controllers/attestations/claims.ts +++ b/desci-server/src/controllers/attestations/claims.ts @@ -9,6 +9,7 @@ import { logger } from '../../logger.js'; import { RequestWithUser } from '../../middleware/authorisation.js'; import { removeClaimSchema } from '../../routes/v1/attestations/schema.js'; import { attestationService } from '../../services/Attestation.js'; +import { communityService } from '../../services/Communities.js'; import { saveInteraction } from '../../services/interactionLog.js'; import { getIndexedResearchObjects } from '../../theGraph.js'; import { asyncMap, ensureUuidEndsWithDot } from '../../utils.js'; @@ -38,6 +39,8 @@ export const claimAttestation = async (req: RequestWithUser, res: Response, _nex if (claim && claim.revoked) { const reclaimed = await attestationService.reClaimAttestation(claim.id); await saveInteraction(req, ActionType.CLAIM_ATTESTATION, { ...body, claimId: reclaimed.id }); + // trigger update radar entry + await communityService.addToRadar(reclaimed.desciCommunityId, reclaimed.nodeUuid); new SuccessResponse(reclaimed).send(res); return; } @@ -48,6 +51,8 @@ export const claimAttestation = async (req: RequestWithUser, res: Response, _nex nodeUuid: uuid, attestationVersion: attestationVersion.id, }); + // trigger update radar entry + await communityService.addToRadar(nodeClaim.desciCommunityId, nodeClaim.nodeUuid); await saveInteraction(req, ActionType.CLAIM_ATTESTATION, { ...body, claimId: nodeClaim.id }); @@ -128,6 +133,9 @@ export const removeClaim = async (req: RequestWithUser, res: Response, _next: Ne ? await attestationService.revokeAttestation(claim.id) : await attestationService.unClaimAttestation(claim.id); + // trigger update radar entry + await communityService.addToRadar(claim.desciCommunityId, claim.nodeUuid); + await saveInteraction(req, ActionType.REVOKE_CLAIM, body); logger.info({ removeOrRevoke, totalSignal, claimSignal }, 'Claim Removed|Revoked'); diff --git a/desci-server/src/controllers/communities/radar.ts b/desci-server/src/controllers/communities/radar.ts index e744e805..743bb117 100644 --- a/desci-server/src/controllers/communities/radar.ts +++ b/desci-server/src/controllers/communities/radar.ts @@ -60,3 +60,32 @@ export const getCommunityRadar = async (req: Request, res: Response, next: NextF return new SuccessResponse(data).send(res); }; + +export const listCommunityRadar = async (req: Request, res: Response, next: NextFunction) => { + const communityRadar = await communityService.listCommunityRadar({ + communityId: parseInt(req.params.communityId as string), + offset: 0, + limit: 20, + }); + logger.info({ communityRadar }, 'Radar'); + // THIS is necessary because the engagement signal returned from getCommunityRadar + // accounts for only engagements on community selected attestations + const nodes = await asyncMap(communityRadar, async (entry) => { + const engagements = await attestationService.getNodeEngagementSignalsByUuid(entry.nodeUuid); + return { + ...entry, + engagements, + verifiedEngagements: { + reactions: entry.reactions, + annotations: entry.annotations, + verifications: entry.verifications, + }, + }; + }); + + // rank nodes by sum of sum of verified and non verified signals + + logger.info({ nodes }, 'CHECK Verification SignalS'); + + return new SuccessResponse(nodes).send(res); +}; diff --git a/desci-server/src/scripts/backfill-radar.ts b/desci-server/src/scripts/backfill-radar.ts index 2ab43e72..180b1728 100644 --- a/desci-server/src/scripts/backfill-radar.ts +++ b/desci-server/src/scripts/backfill-radar.ts @@ -1,6 +1,6 @@ import { prisma } from '../client.js'; import { logger } from '../logger.js'; -import { communityService } from '../services/Communities.js'; +// import { communityService } from '../services/Communities.js'; const main = async () => { const nodeAttestations = await prisma.nodeAttestation.findMany({ where: { revoked: false } }); @@ -21,12 +21,20 @@ const main = async () => { } // check if node has claimed all community entry attestations - const entryAttestations = await communityService.getEntryAttestations({ - desciCommunityId: nodeAttestation.desciCommunityId, + const entryAttestations = await prisma.communityEntryAttestation.findMany({ + orderBy: { createdAt: 'asc' }, + where: { desciCommunityId: nodeAttestation.desciCommunityId }, + include: { + attestation: { select: { protected: true, community: { select: { name: true } } } }, + // desciCommunity: { select: { name: true } }, + attestationVersion: { + select: { id: true, attestationId: true, name: true, image_url: true, description: true }, + }, + }, }); const claimedAttestations = await prisma.nodeAttestation.findMany({ - where: { desciCommunityId: nodeAttestation.desciCommunityId, nodeUuid: nodeAttestation.nodeUuid }, + where: { desciCommunityId: nodeAttestation.desciCommunityId, nodeUuid: nodeAttestation.nodeUuid, revoked: false }, }); const isEntriesClaimed = entryAttestations.every((entry) => @@ -49,13 +57,20 @@ const main = async () => { } // End check if node has claimed all community entry attestations - await prisma.communityRadarEntry.create({ + const radarEntry = await prisma.communityRadarEntry.create({ data: { desciCommunityId: nodeAttestation.desciCommunityId, nodeUuid: nodeAttestation.nodeUuid, }, }); radarCount++; + + const claims = await prisma.$transaction( + claimedAttestations.map((claim) => + prisma.nodeAttestation.update({ where: { id: claim.id }, data: { communityRadarEntryId: radarEntry.id } }), + ), + ); + logger.info({ rows: claims.length }, 'Claims Updated'); } logger.info({ radarCount }, 'Community radar fields: '); return radarCount; diff --git a/desci-server/src/services/Attestation.ts b/desci-server/src/services/Attestation.ts index a448771f..359519dd 100644 --- a/desci-server/src/services/Attestation.ts +++ b/desci-server/src/services/Attestation.ts @@ -1005,6 +1005,38 @@ export class AttestationService { ); return groupedEngagements; } + + /** + * Returns all engagement signals for a node across all claimed attestations + * This verification signal is the number returned for the verification field + * @param dpid + * @returns + */ + async getNodeEngagementSignalsByUuid(uuid: string) { + const claims = (await prisma.$queryRaw` + SELECT t1.*, + count(DISTINCT "Annotation".id)::int AS annotations, + count(DISTINCT "NodeAttestationReaction".id)::int AS reactions, + count(DISTINCT "NodeAttestationVerification".id)::int AS verifications + FROM "NodeAttestation" t1 + left outer JOIN "Annotation" ON t1."id" = "Annotation"."nodeAttestationId" + left outer JOIN "NodeAttestationReaction" ON t1."id" = "NodeAttestationReaction"."nodeAttestationId" + left outer JOIN "NodeAttestationVerification" ON t1."id" = "NodeAttestationVerification"."nodeAttestationId" + WHERE t1."nodeUuid" = ${uuid} AND t1."revoked" = false + GROUP BY + t1.id + `) as CommunityRadarNode[]; + + const groupedEngagements = claims.reduce( + (total, claim) => ({ + reactions: total.reactions + claim.reactions, + annotations: total.annotations + claim.annotations, + verifications: total.verifications + claim.verifications, + }), + { reactions: 0, annotations: 0, verifications: 0 }, + ); + return groupedEngagements; + } /** * Returns all engagement signals for a claimed attestation * @param claimId diff --git a/desci-server/src/services/Communities.ts b/desci-server/src/services/Communities.ts index ce73faa7..f95d1599 100644 --- a/desci-server/src/services/Communities.ts +++ b/desci-server/src/services/Communities.ts @@ -1,4 +1,11 @@ -import { Attestation, CommunityMembershipRole, NodeAttestation, NodeFeedItem, Prisma } from '@prisma/client'; +import { + Attestation, + CommunityMembershipRole, + CommunityRadarEntry, + NodeAttestation, + NodeFeedItem, + Prisma, +} from '@prisma/client'; import _, { includes } from 'lodash'; import { prisma } from '../client.js'; @@ -8,6 +15,7 @@ import { logger } from '../logger.js'; import { attestationService } from './Attestation.js'; export type CommunityRadarNode = NodeAttestation & { annotations: number; reactions: number; verifications: number }; +export type RadarEntry = CommunityRadarEntry & { annotations: number; reactions: number; verifications: number }; export class CommunityService { async createCommunity(data: Prisma.DesciCommunityCreateManyInput) { const exists = await prisma.desciCommunity.findFirst({ where: { name: data.name } }); @@ -176,6 +184,129 @@ export class CommunityService { return radar; } + async listCommunityRadar({ communityId, offset, limit }: { communityId: number; offset: number; limit: number }) { + const entryAttestations = await attestationService.getCommunityEntryAttestations(communityId); + const entries = await prisma.$queryRaw` + SELECT + cre.*, + count(DISTINCT "Annotation".id) :: int AS annotations, + count(DISTINCT "NodeAttestationReaction".id) :: int AS reactions, + count(DISTINCT "NodeAttestationVerification".id) :: int AS verifications, + COUNT(DISTINCT NaFiltered."id") :: int AS valid_attestations + FROM + "CommunityRadarEntry" cre + LEFT JOIN ( + SELECT + Na."id", + Na."communityRadarEntryId", + Na."attestationId", + Na."attestationVersionId" + FROM + "NodeAttestation" Na + WHERE + Na."revoked" = false + AND Na."nodeDpid10" IS NOT NULL + GROUP BY + Na."id", + Na."communityRadarEntryId" + ) NaFiltered ON cre."id" = NaFiltered."communityRadarEntryId" + LEFT OUTER JOIN "Annotation" ON NaFiltered."id" = "Annotation"."nodeAttestationId" + LEFT OUTER JOIN "NodeAttestationReaction" ON NaFiltered."id" = "NodeAttestationReaction"."nodeAttestationId" + LEFT OUTER JOIN "NodeAttestationVerification" ON NaFiltered."id" = "NodeAttestationVerification"."nodeAttestationId" + WHERE + EXISTS ( + SELECT + * + FROM + "CommunityEntryAttestation" Cea + WHERE + NaFiltered."attestationId" = Cea."attestationId" + AND NaFiltered."attestationVersionId" = cea."attestationVersionId" + AND Cea."desciCommunityId" = ${communityId} + AND Cea."required" = TRUE + ) + GROUP BY + cre.id + HAVING + COUNT(DISTINCT NaFiltered."id") = ${entryAttestations.length} + ORDER BY + verifications ASC, + cre."createdAt" DESC + LIMIT + ${limit}; + OFFSET ${offset}; + `; + + return entries as RadarEntry[]; + } + + async listCommunityCuratedFeed({ + communityId, + offset, + limit, + }: { + communityId: number; + offset: number; + limit: number; + }) { + const entryAttestations = await attestationService.getCommunityEntryAttestations(communityId); + + const entries = await prisma.$queryRaw` + SELECT + cre.*, + COUNT(DISTINCT "Annotation".id) :: int AS annotations, + COUNT(DISTINCT "NodeAttestationReaction".id) :: int AS reactions, + COUNT(DISTINCT "NodeAttestationVerification".id) :: int AS verifications, + COUNT(DISTINCT NaFiltered."id") :: int AS valid_attestations + FROM + "CommunityRadarEntry" cre + LEFT JOIN ( + SELECT + Na."id", + Na."communityRadarEntryId", + Na."attestationId", + Na."attestationVersionId" + FROM + "NodeAttestation" Na + LEFT JOIN "NodeAttestationVerification" Nav ON Na."id" = Nav."nodeAttestationId" + WHERE + Na."revoked" = false + AND Na."nodeDpid10" IS NOT NULL + GROUP BY + Na."id", + Na."communityRadarEntryId" + HAVING + COUNT(Nav."id") > 0 + ) NaFiltered ON cre."id" = NaFiltered."communityRadarEntryId" + LEFT JOIN "Annotation" ON NaFiltered."id" = "Annotation"."nodeAttestationId" + LEFT JOIN "NodeAttestationReaction" ON NaFiltered."id" = "NodeAttestationReaction"."nodeAttestationId" + LEFT JOIN "NodeAttestationVerification" ON NaFiltered."id" = "NodeAttestationVerification"."nodeAttestationId" + WHERE + EXISTS ( + SELECT + 1 + FROM + "CommunityEntryAttestation" Cea + WHERE + NaFiltered."attestationId" = Cea."attestationId" + AND NaFiltered."attestationVersionId" = Cea."attestationVersionId" + AND Cea."desciCommunityId" = ${communityId} + AND Cea."required" = TRUE + ) + GROUP BY + cre.id + HAVING + COUNT(DISTINCT NaFiltered."id") = ${entryAttestations.length} + ORDER BY + verifications DESC + OFFSET ${offset} + LIMIT + ${limit}; + `; + + return entries as RadarEntry[]; + } + /** * This methods takes the result of getCommunityRadar and * filter out entries(nodes) whose NodeAttestations don't have atleast on verification @@ -371,10 +502,11 @@ export class CommunityService { // check if node has claimed all community entry attestations const entryAttestations = await communityService.getEntryAttestations({ desciCommunityId, + required: true, }); const claimedAttestations = await prisma.nodeAttestation.findMany({ - where: { desciCommunityId, nodeUuid }, + where: { desciCommunityId, nodeUuid, revoked: false }, }); const isEntriesClaimed = entryAttestations.every((entry) => @@ -386,12 +518,59 @@ export class CommunityService { if (!isEntriesClaimed) return undefined; - return await prisma.communityRadarEntry.create({ + const radarEntry = await prisma.communityRadarEntry.create({ data: { desciCommunityId, nodeUuid, }, }); + + await prisma.$transaction( + claimedAttestations.map((claim) => + prisma.nodeAttestation.update({ where: { id: claim.id }, data: { communityRadarEntryId: radarEntry.id } }), + ), + ); + + return radarEntry; + } + + async removeFromRadar(desciCommunityId: number, nodeUuid: string) { + // check if node has claimed all community entry attestations + const entryAttestations = await communityService.getEntryAttestations({ + desciCommunityId, + required: true, + }); + + const claimedAttestations = await prisma.nodeAttestation.findMany({ + where: { desciCommunityId, nodeUuid, revoked: false }, + }); + + const isEntriesClaimed = entryAttestations.every((entry) => + claimedAttestations.find( + (claimed) => + claimed.attestationId === entry.attestationId && claimed.attestationVersionId === entry.attestationVersionId, + ), + ); + + if (isEntriesClaimed) return undefined; + const entry = await prisma.communityRadarEntry.findFirst({ + where: { + desciCommunityId, + nodeUuid, + }, + }); + + await prisma.$transaction( + claimedAttestations.map((claim) => + prisma.nodeAttestation.update({ where: { id: claim.id }, data: { communityRadarEntryId: null } }), + ), + ); + + return await prisma.communityRadarEntry.delete({ + where: { + id: entry.id, + }, + }); } }