diff --git a/.gitignore b/.gitignore index ccc4f294ca..38a05eafeb 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ storybook-static ./playwright .local_data + +# mock-notify store +.notifications.json diff --git a/core/actions/_lib/interpolationContext.ts b/core/actions/_lib/interpolationContext.ts index 98631c4cf0..37d4cf3f06 100644 --- a/core/actions/_lib/interpolationContext.ts +++ b/core/actions/_lib/interpolationContext.ts @@ -12,7 +12,7 @@ import type { import type { FullAutomation, Json } from "db/types" import type { CommunityStage } from "~/lib/server/stages" -import { createPubProxy } from "./pubProxy" +import { createPubProxy, type IncomingRelations } from "./pubProxy" export type InterpolationContextBase = { community: InterpolationCommunity @@ -23,26 +23,12 @@ export type InterpolationContextBase = { env: InterpolationEnv } -export type InterpolationContextWithPub = InterpolationContextBase & { - pub: ReturnType - json?: Json -} - -export type InterpolationContextWithJson = InterpolationContextBase & { - json: Json +export type InterpolationContext = InterpolationContextBase & { pub?: ReturnType + json?: Json + site?: { base: string } } -export type InterpolationContextWithBoth = InterpolationContextBase & { - pub: ReturnType - json: Json -} - -export type InterpolationContext = - | InterpolationContextWithPub - | InterpolationContextWithJson - | InterpolationContextWithBoth - type InterpolationCommunity = Pick type InterpolationStage = Pick @@ -66,6 +52,7 @@ type BuildInterpolationContextArgsBase = { automation: FullAutomation automationRun: InterpolationAutomationRun user: InterpolationUser | null + incomingRelations?: IncomingRelations } type BuildInterpolationContextArgs = @@ -107,7 +94,7 @@ type BuildInterpolationContextArgs = export function buildInterpolationContext( args: BuildInterpolationContextArgs ): InterpolationContext { - const baseContext: Omit = { + const baseContext: Omit = { env: { PUBPUB_URL: args.env.PUBPUB_URL, }, @@ -158,7 +145,7 @@ export function buildInterpolationContext( // Both pub and json provided return { ...baseContext, - pub: createPubProxy(args.pub, args.community.slug), + pub: createPubProxy(args.pub, args.community.slug, args.incomingRelations), json: args.json, } } @@ -166,7 +153,7 @@ export function buildInterpolationContext( if (args.pub) { return { ...baseContext, - pub: createPubProxy(args.pub, args.community.slug), + pub: createPubProxy(args.pub, args.community.slug, args.incomingRelations), } } diff --git a/core/actions/_lib/pubProxy.ts b/core/actions/_lib/pubProxy.ts index 1d61d68194..f82beb598b 100644 --- a/core/actions/_lib/pubProxy.ts +++ b/core/actions/_lib/pubProxy.ts @@ -1,93 +1,3 @@ -// import type { ProcessedPub } from "contracts" - -// export const createPubProxy = (pub: ProcessedPub, communitySlug: string): any => { -// const valuesMap = new Map() - -// // these are just so that if you do `$.values`/`$.out`/`$.fields` you can see what fields are available -// const fields: Record = {} -// const relations: Record = {} - -// for (const value of pub.values) { -// fields[value.fieldSlug] = undefined -// fields[value.fieldSlug.replace(`${communitySlug}:`, "")] = undefined -// if (value.relatedPub) { -// relations[value.fieldSlug] = undefined -// relations[value.fieldSlug.replace(`${communitySlug}:`, "")] = undefined -// } -// valuesMap.set(value.fieldSlug, value) -// } - -// const pubWithAdditionalFields = { ...pub, fields: undefined, out: relations } - -// return new Proxy(pubWithAdditionalFields, { -// get(target, prop) { -// const propStr = String(prop) -// const _lowerProp = propStr.toLowerCase() - -// if (prop === "fields") { -// return new Proxy(fields, { -// get(_, fieldSlug: string) { -// return fields[fieldSlug] || fields[fieldSlug.toLowerCase()] -// }, -// }) -// } -// if (prop === "values") { -// return new Proxy(fields, { -// get(_, fieldSlug: string) { -// const lowerFieldSlug = fieldSlug.toLowerCase() -// const val = -// valuesMap.get(`${communitySlug}:${fieldSlug}`) ?? -// valuesMap.get(fieldSlug) ?? -// valuesMap.get(`${communitySlug}:${lowerFieldSlug}`) ?? -// valuesMap.get(lowerFieldSlug) -// return val?.value -// }, -// }) -// } - -// if (prop === "out") { -// return new Proxy(relations, { -// get(_, fieldSlug: string) { -// if (typeof fieldSlug !== "string") { -// return undefined -// } - -// if (fieldSlug === "out") { -// return relations -// } - -// const lowerFieldSlug = fieldSlug.toLowerCase() - -// const val = -// valuesMap.get(`${communitySlug}:${fieldSlug}`) ?? -// valuesMap.get(fieldSlug) ?? -// valuesMap.get(`${communitySlug}:${lowerFieldSlug}`) ?? -// valuesMap.get(lowerFieldSlug) -// if (val && "relatedPub" in val && val.relatedPub) { -// return createPubProxy(val.relatedPub, communitySlug) -// } -// return undefined -// }, -// }) -// } - -// if (prop === "in") { -// return new Proxy(relations, { -// get(_, fieldSlug: string) { -// const _lowerFieldSlug = fieldSlug.toLowerCase() -// // For "in", we look for pubs that point to this one via fieldSlug -// // This proxy doesn't currently support "in" metadata easily as it's not pre-loaded in valuesMap in the same way. -// // However, if we had it, we'd look it up here. -// return undefined -// }, -// }) -// } - -// return target[prop as keyof typeof target] -// }, -// }) -// } - import type { ProcessedPub } from "contracts" // properties that should never be forwarded through the proxy @@ -146,9 +56,12 @@ const createLookupProxy = ( }) } +export type IncomingRelations = Record + export const createPubProxy = ( pub: ProcessedPub, - communitySlug: string + communitySlug: string, + incomingRelations?: IncomingRelations ): Record => { // build plain objects for all lookups const fields: Record = {} @@ -166,9 +79,19 @@ export const createPubProxy = ( } } + // build incoming relations lookup: field slug -> array of pub proxies + const inObj: Record[]> = {} + if (incomingRelations) { + for (const [slug, pubs] of Object.entries(incomingRelations)) { + const shortSlug = slug.replace(`${communitySlug}:`, "") + inObj[shortSlug] = pubs.map((p) => createPubProxy(p, communitySlug)) + } + } + const fieldsProxy = createLookupProxy(fields, communitySlug) const valuesProxy = createLookupProxy(values, communitySlug) const outProxy = createLookupProxy(out, communitySlug) + const inProxy = createLookupProxy(inObj, communitySlug) // build the base object with all pub properties except values (which we override) const base: Record = {} @@ -180,7 +103,7 @@ export const createPubProxy = ( base.fields = fieldsProxy base.values = valuesProxy base.out = outProxy - base.in = {} // not implemented yet + base.in = inProxy return new Proxy(base, { get(target, prop) { diff --git a/core/actions/_lib/runAutomation.ts b/core/actions/_lib/runAutomation.ts index 815e65449c..fbc14be6f4 100644 --- a/core/actions/_lib/runAutomation.ts +++ b/core/actions/_lib/runAutomation.ts @@ -36,7 +36,7 @@ import { db } from "~/kysely/database" import { getAutomation } from "~/lib/db/queries" import { env } from "~/lib/env/env" import { createLastModifiedBy } from "~/lib/lastModifiedBy" -import { ApiError, getPubsWithRelatedValues } from "~/lib/server" +import { ApiError, getIncomingRelations, getPubsWithRelatedValues } from "~/lib/server" import { getActionConfigDefaults, getAutomationRunById } from "~/lib/server/actions" import { MAX_STACK_DEPTH } from "~/lib/server/automations" import { autoRevalidate } from "~/lib/server/cache/autoRevalidate" @@ -408,6 +408,10 @@ const runActionInstance = async (args: RunActionInstanceArgs): Promise { - const cssPath = path ? `${path}.css` : "css" - form.setValue(cssPath, DEFAULT_SITE_CSS) - } - return (
@@ -73,33 +68,6 @@ export default function BuildSiteActionForm() { label="Deployment Path" description="URL path for this build (e.g., 'v1' or '2024'). Defaults to a unique ID." /> - -
-
- Custom CSS - -
- ( - - )} - /> -
diff --git a/core/actions/buildSite/run.tsx b/core/actions/buildSite/run.tsx index 5176baa390..7b3853d4f0 100644 --- a/core/actions/buildSite/run.tsx +++ b/core/actions/buildSite/run.tsx @@ -1,6 +1,6 @@ "use server" -import type { JsonValue } from "contracts" +import type { JsonValue, ProcessedPub } from "contracts" import type { PubsId } from "db/public" import type { PubValues } from "~/lib/server" import type { action } from "./action" @@ -22,6 +22,7 @@ import { getCommunity } from "~/lib/server/community" import { applyJsonataFilter, compileJsonataQuery } from "~/lib/server/jsonata-query" import { updatePub } from "~/lib/server/pub" import { buildInterpolationContext } from "../_lib/interpolationContext" +import { createPubProxy, type IncomingRelations } from "../_lib/pubProxy" import { defineRun } from "../types" /** @@ -41,6 +42,27 @@ const extractValue = async (data: unknown, expression: string): Promise return interpolate(expression, data) } +/** + * Compute incoming relations from loaded pubs across all page groups. + * For each pub that has relational values, records the reverse mapping: + * targetPubId -> { fieldSlug -> [sourcePubs] } + */ +const computeIncomingRelations = (allPubs: ProcessedPub[]): Map => { + const map = new Map() + for (const pub of allPubs) { + for (const v of pub.values) { + if (v.relatedPubId) { + const existing = map.get(v.relatedPubId) ?? {} + const fieldPubs = existing[v.fieldSlug] ?? [] + fieldPubs.push(pub) + existing[v.fieldSlug] = fieldPubs + map.set(v.relatedPubId, existing) + } + } + } + return map +} + export const run = defineRun( async ({ communityId, pub, config, automationRunId, lastModifiedBy }) => { const community = await getCommunity(communityId) @@ -63,10 +85,13 @@ export const run = defineRun( }, }) - const pages = await Promise.all( + const NIL_UUID = "00000000-0000-0000-0000-000000000000" + + // Fetch pubs for all groups that have a filter + const groupsWithPubs = await Promise.all( config.pages.map(async (page) => { + if (!page.filter) return { page, pubs: [] as ProcessedPub[] } const query = compileJsonataQuery(page.filter) - const pubs = await getPubsWithRelatedValues( { communityId }, { @@ -77,7 +102,82 @@ export const run = defineRun( withPubType: true, } ) + return { page, pubs } + }) + ) + + // Compute incoming relations across all fetched pubs + const allPubs = groupsWithPubs.flatMap((g) => g.pubs) + const incomingRelationsMap = computeIncomingRelations(allPubs) + + const stringifyContent = (content: unknown): string => + typeof content === "object" && content !== null + ? JSON.stringify(content, null, 2) + : String(content ?? "") + const computeSiteBase = (slug: string) => { + const depth = slug.split("/").filter(Boolean).length + return depth === 0 ? "." : Array(depth).fill("..").join("/") + } + + // Process each page group according to its mode + const pageGroupData = await Promise.all( + groupsWithPubs.map(async ({ page, pubs }) => { + const extension = page.extension ?? "html" + + // Static: no filter, no pubs — evaluate transform once with empty context + if (!page.filter) { + const [slugErr, slug] = await tryCatch(interpolate(page.slug, {})) + const interpolatedSlug = (slugErr ? "static" : slug) as string + const [contentErr, content] = await tryCatch(interpolate(page.transform, {})) + if (contentErr) + logger.error({ msg: "Error interpolating static page", err: contentErr }) + return { + extension, + pubs: [ + { + id: NIL_UUID, + title: interpolatedSlug, + content: stringifyContent(content), + slug: interpolatedSlug, + }, + ], + } + } + + // Single: slug doesn't reference $.pub — one page with all matched pubs as $.pubs + const isPerPub = page.slug.includes("$.pub") + if (!isPerPub) { + const pubProxies = pubs.map((p) => + createPubProxy(p, communitySlug, incomingRelationsMap.get(p.id)) + ) + const context: Record = { + pubs: pubProxies, + community: { id: community.id, name: community.name, slug: community.slug }, + env: { PUBPUB_URL: env.PUBPUB_URL }, + } + const [slugErr, slug] = await tryCatch(interpolate(page.slug, context)) + const interpolatedSlug = (slugErr ? "index" : slug) as string + context.site = { base: computeSiteBase(interpolatedSlug) } + const [contentErr, content] = await tryCatch( + interpolate(page.transform, context) + ) + if (contentErr) + logger.error({ msg: "Error interpolating single page", err: contentErr }) + return { + extension, + pubs: [ + { + id: NIL_UUID, + title: interpolatedSlug, + content: stringifyContent(content), + slug: interpolatedSlug, + }, + ], + } + } + + // Per-pub: one page per matched pub with $.pub in context const interpolatedPubs = await Promise.all( pubs.map(async (pub) => { const pubContext = buildInterpolationContext({ @@ -85,35 +185,41 @@ export const run = defineRun( pub, env: { PUBPUB_URL: env.PUBPUB_URL }, useDummyValues: true, + incomingRelations: incomingRelationsMap.get(pub.id), }) - const [error, slug] = await tryCatch(interpolate(page.slug, pubContext)) - if (!slug) - logger.error({ - msg: "Error interpolating slug . Will continue with pub id.", - err: error, - }) - const slllug = error ? pub.id : slug + const [slugErr, slug] = await tryCatch(interpolate(page.slug, pubContext)) + if (slugErr) logger.error({ msg: "Error interpolating slug", err: slugErr }) + const interpolatedSlug = (slugErr ? pub.id : slug) as string + pubContext.site = { base: computeSiteBase(interpolatedSlug) } + const [contentErr, content] = await tryCatch( + interpolate(page.transform, pubContext) + ) + if (contentErr) + logger.error({ msg: "Error interpolating content", err: contentErr }) return { id: pub.id, - title: getPubTitle(pub), - content: page.transform, - slug: slllug, + title: getPubTitle(pub as unknown as Parameters[0]), + content: stringifyContent(content), + slug: interpolatedSlug, } }) ) - return { - pages: interpolatedPubs.map((pub) => ({ - id: pub.id, - title: pub.title, - content: page.transform, - slug: pub.slug as string, - })), - transform: page.transform, - extension: page.extension ?? "html", - } + return { extension, pubs: interpolatedPubs } }) ) + const pages: { + pages: { id: string; title: string; content: string; slug: string }[] + extension: string + }[] = pageGroupData.map((group) => ({ + pages: group.pubs.map((pub) => ({ + id: pub.id, + title: pub.title, + content: pub.content, + slug: pub.slug, + })), + extension: group.extension, + })) const [healthError, health] = await tryCatch(siteBuilderClient.health()) if (healthError) { @@ -140,7 +246,6 @@ export const run = defineRun( automationRunId: automationRunId, communitySlug, subpath: config.subpath, - css: config.css, pages, siteUrl: env.PUBPUB_URL, }, diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 55e591f173..d2571fe2ed 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -1317,6 +1317,57 @@ const COUNT_OPTIONS = { trx: db, } as const satisfies GetPubsWithRelatedValuesOptions +/** + * Fetch incoming relations for a pub: other pubs that reference this pub via relational fields. + * Returns a map of field slug -> array of source pubs (ProcessedPub[]). + */ +export async function getIncomingRelations( + pubId: PubsId, + communityId: CommunitiesId +): Promise> { + // Find all (source pub id, field slug) pairs where a value points to this pub + const refs = await db + .selectFrom("pub_values as pv") + .innerJoin("pub_fields as pf", "pf.id", "pv.fieldId") + .select(["pv.pubId", "pf.slug"]) + .where("pv.relatedPubId", "=", pubId) + .where("pf.communityId", "=", communityId) + .execute() + + if (refs.length === 0) return {} + + // Group source pub IDs by field slug + const pubIdsByField = new Map>() + for (const ref of refs) { + const set = pubIdsByField.get(ref.slug) ?? new Set() + set.add(ref.pubId) + pubIdsByField.set(ref.slug, set) + } + + // Fetch all unique source pubs + const allPubIds = [...new Set(refs.map((r) => r.pubId))] as PubsId[] + const sourcePubs = await getPubsWithRelatedValues( + { communityId }, + { + pubIds: allPubIds, + withValues: true, + withPubType: true, + withRelatedPubs: false, + } + ) + const pubsById = new Map(sourcePubs.map((p) => [p.id, p])) + + // Build the result map + const result: Record = {} + for (const [fieldSlug, pubIds] of pubIdsByField) { + result[fieldSlug] = [...pubIds] + .map((id) => pubsById.get(id as PubsId)) + .filter((p) => p !== undefined) + } + + return result +} + export async function getPubsWithRelatedValues( props: Extract, options?: Options diff --git a/core/playwright/coar-notify.spec.ts b/core/playwright/coar-notify.spec.ts index fd412dc5c1..6a4ac9d038 100644 --- a/core/playwright/coar-notify.spec.ts +++ b/core/playwright/coar-notify.spec.ts @@ -13,6 +13,7 @@ import { createSeed } from "~/prisma/seed/createSeed" import { seedCommunity } from "~/prisma/seed/seedCommunity" import { createAnnounceIngestPayload, + createAnnounceReviewPayload, createOfferReviewPayload, } from "./fixtures/coar-notify-payloads" import { LoginPage } from "./fixtures/login-page" @@ -20,28 +21,207 @@ import { StagesManagePage } from "./fixtures/stages-manage-page" import { expect, test } from "./test-fixtures" const WEBHOOK_PATH = "coar-inbox" -const COMMUNITY_SLUG = `coar-test-${crypto.randomUUID().slice(0, 8)}` -const STAGE_IDS = { +// --------------------------------------------------------------------------- +// User Story 1: Repository Author Requests Review +// --------------------------------------------------------------------------- + +const us1Slug = `coar-us1-${crypto.randomUUID().slice(0, 8)}` + +const us1StageIds = { + Submissions: crypto.randomUUID() as StagesId, + AwaitingResponse: crypto.randomUUID() as StagesId, + Completed: crypto.randomUUID() as StagesId, +} + +const us1Seed = createSeed({ + community: { + name: "US1: Arcadia Science", + slug: us1Slug, + }, + users: { + admin: { + firstName: "Admin", + lastName: "User", + email: `us1-admin-${crypto.randomUUID().slice(0, 8)}@example.com`, + password: "password", + role: MemberRole.admin, + }, + }, + pubFields: { + title: { schemaName: CoreSchemaType.String }, + content: { schemaName: CoreSchemaType.String }, + sourceurl: { schemaName: CoreSchemaType.String }, + relatedpub: { schemaName: CoreSchemaType.String, relation: true }, + }, + pubTypes: { + Submission: { + title: { isTitle: true }, + content: { isTitle: false }, + }, + Review: { + title: { isTitle: true }, + content: { isTitle: false }, + relatedpub: { isTitle: false }, + sourceurl: { isTitle: false }, + }, + }, + stages: { + Submissions: { + id: us1StageIds.Submissions, + automations: { + "Request Review": { + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Submission'", + }, + ], + }, + actions: [ + { + action: Action.move, + config: { stage: us1StageIds.AwaitingResponse }, + }, + ], + }, + }, + }, + AwaitingResponse: { + id: us1StageIds.AwaitingResponse, + automations: { + "Send Review Offer": { + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Submission'", + }, + ], + }, + actions: [ + { + action: Action.http, + config: { + url: "http://stubbed-remote-inbox/inbox", + method: "POST", + body: { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://coar-notify.net", + ], + type: ["Offer", "coar-notify:ReviewAction"], + id: "urn:uuid:{{ $.pub.id }}", + actor: { + id: "{{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}", + type: "Service", + name: "{{ $.community.name }}", + }, + object: { + id: "{{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", + type: ["Page", "sorg:AboutPage"], + }, + target: { + id: "http://stubbed-remote-inbox", + inbox: "http://stubbed-remote-inbox/inbox", + type: "Service", + }, + }, + }, + }, + ], + }, + "Receive Review Announcement": { + triggers: [ + { + event: AutomationEvent.webhook, + config: { path: WEBHOOK_PATH }, + }, + ], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: + "'Announce' in $.json.type and 'coar-notify:ReviewAction' in $.json.type", + }, + ], + }, + resolver: + '$.pub.id = {{ $replace($replace($.json.object.`as:inReplyTo`, $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pubs/", ""), $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pub/", "") }}', + actions: [ + { + action: Action.createPub, + config: { + stage: us1StageIds.Completed, + formSlug: "review-default-editor", + pubValues: { + title: "Review: {{ $.json.object.id }}", + sourceurl: "{{ $.json.object.id }}", + }, + relationConfig: { + fieldSlug: `${us1Slug}:relatedpub`, + relatedPubId: "{{ $.pub.id }}", + value: "Submission", + direction: "source", + }, + }, + }, + ], + }, + }, + }, + Completed: { + id: us1StageIds.Completed, + automations: {}, + }, + }, + pubs: [ + { + pubType: "Submission", + stage: "Submissions", + values: { title: "Sample Paper for Review" }, + }, + ], + stageConnections: { + Submissions: { to: ["AwaitingResponse"] }, + }, +}) + +// --------------------------------------------------------------------------- +// User Story 2: Review Group Receives Review Request +// --------------------------------------------------------------------------- + +const us2Slug = `coar-us2-${crypto.randomUUID().slice(0, 8)}` + +const us2StageIds = { Inbox: crypto.randomUUID() as StagesId, - ReviewRequested: crypto.randomUUID() as StagesId, Accepted: crypto.randomUUID() as StagesId, Rejected: crypto.randomUUID() as StagesId, - Published: crypto.randomUUID() as StagesId, ReviewInbox: crypto.randomUUID() as StagesId, Reviewing: crypto.randomUUID() as StagesId, + Published: crypto.randomUUID() as StagesId, } -const seed = createSeed({ +const us2Seed = createSeed({ community: { - name: "COAR Test Community", - slug: COMMUNITY_SLUG, + name: "US2: The Unjournal", + slug: us2Slug, }, users: { admin: { firstName: "Admin", lastName: "User", - email: `admin-${crypto.randomUUID().slice(0, 8)}@example.com`, + email: `us2-admin-${crypto.randomUUID().slice(0, 8)}@example.com`, password: "password", role: MemberRole.admin, }, @@ -54,10 +234,6 @@ const seed = createSeed({ relatedpub: { schemaName: CoreSchemaType.String, relation: true }, }, pubTypes: { - Submission: { - title: { isTitle: true }, - content: { isTitle: false }, - }, Notification: { title: { isTitle: true }, payload: { isTitle: false }, @@ -73,7 +249,7 @@ const seed = createSeed({ }, stages: { Inbox: { - id: STAGE_IDS.Inbox, + id: us2StageIds.Inbox, automations: { "Process COAR Notification": { triggers: [ @@ -82,11 +258,21 @@ const seed = createSeed({ config: { path: WEBHOOK_PATH }, }, ], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "'Offer' in $.json.type", + }, + ], + }, actions: [ { action: Action.createPub, config: { - stage: STAGE_IDS.Inbox, + stage: us2StageIds.Inbox, formSlug: "notification-default-editor", pubValues: { title: "URL: {{ $.json.object.id }} - Type: {{ $join($.json.type, ', ') }}", @@ -97,13 +283,22 @@ const seed = createSeed({ }, ], }, - "Create Review for Notification": { - triggers: [ - { - event: AutomationEvent.pubEnteredStage, - config: {}, - }, - ], + "Accept Request": { + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [{ action: Action.move, config: { stage: us2StageIds.Accepted } }], + }, + "Reject Request": { + triggers: [{ event: AutomationEvent.manual, config: {} }], condition: { type: AutomationConditionBlockType.AND, items: [ @@ -114,20 +309,42 @@ const seed = createSeed({ }, ], }, + actions: [{ action: Action.move, config: { stage: us2StageIds.Rejected } }], + }, + }, + }, + Accepted: { + id: us2StageIds.Accepted, + automations: { + "Create Review for Offer": { + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + { + kind: "condition", + type: "jsonata", + expression: "'Offer' in $eval($.pub.values.payload).type", + }, + ], + }, actions: [ { action: Action.createPub, config: { - stage: STAGE_IDS.ReviewInbox, + stage: us2StageIds.ReviewInbox, formSlug: "review-default-editor", pubValues: { title: "Review for: {{ $.pub.values.title }}", - // Copy sourceurl from Notification to Review for use in Announce - // TODO: Investigate why relationship traversal ($.pub.out.relatedpub.values.sourceurl) isn't working sourceurl: "{{ $.pub.values.sourceurl }}", }, relationConfig: { - fieldSlug: `${COMMUNITY_SLUG}:relatedpub`, + fieldSlug: `${us2Slug}:relatedpub`, relatedPubId: "{{ $.pub.id }}", value: "Notification", direction: "source", @@ -138,78 +355,139 @@ const seed = createSeed({ }, }, }, + Rejected: { + id: us2StageIds.Rejected, + automations: {}, + }, ReviewInbox: { - id: STAGE_IDS.ReviewInbox, + id: us2StageIds.ReviewInbox, automations: { "Start Review": { triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], - actions: [{ action: Action.move, config: { stage: STAGE_IDS.Reviewing } }], + actions: [{ action: Action.move, config: { stage: us2StageIds.Reviewing } }], }, }, }, Reviewing: { - id: STAGE_IDS.Reviewing, + id: us2StageIds.Reviewing, automations: { "Finish Review": { triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], - actions: [{ action: Action.move, config: { stage: STAGE_IDS.Published } }], + actions: [{ action: Action.move, config: { stage: us2StageIds.Published } }], }, }, }, - ReviewRequested: { - id: STAGE_IDS.ReviewRequested, + Published: { + id: us2StageIds.Published, automations: { - "Offer Review": { - triggers: [ - { - event: AutomationEvent.pubEnteredStage, - config: {}, - }, - ], + "Announce Review": { + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], actions: [ { action: Action.http, config: { url: "http://stubbed-remote-inbox/inbox", method: "POST", - body: { + body: `<<< { "@context": [ "https://www.w3.org/ns/activitystreams", - "https://coar-notify.net", + "https://coar-notify.net" ], - type: ["Offer", "coar-notify:ReviewAction"], - id: "urn:uuid:{{ $.pub.id }}", - actor: { - id: "{{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}", - type: "Service", - name: "{{ $.community.name }}", - }, - object: { - id: "{{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pubs/{{ $.pub.id }}", - type: ["Page", "sorg:AboutPage"], - }, - target: { - id: "http://stubbed-remote-inbox", - inbox: "http://stubbed-remote-inbox/inbox", - type: "Service", + "type": ["Announce", "coar-notify:ReviewAction"], + "id": "urn:uuid:" & $.pub.id, + "object": { + "id": $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pubs/" & $.pub.id, + "type": ["Page", "sorg:Review"], + "as:inReplyTo": $.pub.values.sourceurl }, - }, + "target": { + "id": "http://stubbed-remote-inbox", + "inbox": "http://stubbed-remote-inbox/inbox", + "type": "Service" + } + } >>>`, }, }, ], }, }, }, - Published: { - id: STAGE_IDS.Published, + }, + stageConnections: { + Inbox: { to: ["Accepted", "Rejected"] }, + Accepted: { to: ["ReviewInbox"] }, + ReviewInbox: { to: ["Reviewing"] }, + Reviewing: { to: ["Published"] }, + }, +}) + +// --------------------------------------------------------------------------- +// User Story 3: Review Group Requests Ingestion By Aggregator +// --------------------------------------------------------------------------- + +const us3Slug = `coar-us3-${crypto.randomUUID().slice(0, 8)}` + +const us3StageIds = { + Reviews: crypto.randomUUID() as StagesId, + Published: crypto.randomUUID() as StagesId, +} + +const us3Seed = createSeed({ + community: { + name: "US3: Review Group", + slug: us3Slug, + }, + users: { + admin: { + firstName: "Admin", + lastName: "User", + email: `us3-admin-${crypto.randomUUID().slice(0, 8)}@example.com`, + password: "password", + role: MemberRole.admin, + }, + }, + pubFields: { + title: { schemaName: CoreSchemaType.String }, + content: { schemaName: CoreSchemaType.String }, + sourceurl: { schemaName: CoreSchemaType.String }, + }, + pubTypes: { + Review: { + title: { isTitle: true }, + content: { isTitle: false }, + sourceurl: { isTitle: false }, + }, + }, + stages: { + Reviews: { + id: us3StageIds.Reviews, automations: { - "Announce Review": { - triggers: [ + "Publish Review": { + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Review'", + }, + ], + }, + actions: [ { - event: AutomationEvent.pubEnteredStage, - config: {}, + action: Action.move, + config: { stage: us3StageIds.Published }, }, ], + }, + }, + }, + Published: { + id: us3StageIds.Published, + automations: { + "Request Ingest": { + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], actions: [ { action: Action.http, @@ -243,46 +521,239 @@ const seed = createSeed({ }, pubs: [ { - pubType: "Submission", - stage: "Inbox", - values: { title: "Pre-existing Pub" }, + pubType: "Review", + stage: "Reviews", + values: { + title: "Sample Review of Research Output", + sourceurl: "https://www.biorxiv.org/content/10.1101/2024.01.01.123456", + }, }, ], stageConnections: { + Reviews: { to: ["Published"] }, + }, +}) + +// --------------------------------------------------------------------------- +// User Story 4: Review Group Aggregation Announcement to Repositories +// --------------------------------------------------------------------------- + +const us4Slug = `coar-us4-${crypto.randomUUID().slice(0, 8)}` + +const us4StageIds = { + Articles: crypto.randomUUID() as StagesId, + Inbox: crypto.randomUUID() as StagesId, + Accepted: crypto.randomUUID() as StagesId, + Rejected: crypto.randomUUID() as StagesId, + ReviewInbox: crypto.randomUUID() as StagesId, +} + +const us4Seed = createSeed({ + community: { + name: "US4: Arcadia Science", + slug: us4Slug, + }, + users: { + admin: { + firstName: "Admin", + lastName: "User", + email: `us4-admin-${crypto.randomUUID().slice(0, 8)}@example.com`, + password: "password", + role: MemberRole.admin, + }, + }, + pubFields: { + title: { schemaName: CoreSchemaType.String }, + content: { schemaName: CoreSchemaType.String }, + payload: { schemaName: CoreSchemaType.String }, + sourceurl: { schemaName: CoreSchemaType.String }, + relatedpub: { schemaName: CoreSchemaType.String, relation: true }, + }, + pubTypes: { + Submission: { + title: { isTitle: true }, + content: { isTitle: false }, + }, + Notification: { + title: { isTitle: true }, + payload: { isTitle: false }, + sourceurl: { isTitle: false }, + relatedpub: { isTitle: false }, + }, + Review: { + title: { isTitle: true }, + content: { isTitle: false }, + relatedpub: { isTitle: false }, + }, + }, + stages: { + Articles: { + id: us4StageIds.Articles, + automations: {}, + }, Inbox: { - to: ["ReviewRequested", "Published"], + id: us4StageIds.Inbox, + automations: { + "Process COAR Notification": { + triggers: [ + { + event: AutomationEvent.webhook, + config: { path: WEBHOOK_PATH }, + }, + ], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: + "'Announce' in $.json.type and 'coar-notify:IngestAction' in $.json.type", + }, + ], + }, + actions: [ + { + action: Action.createPub, + config: { + stage: us4StageIds.Inbox, + formSlug: "notification-default-editor", + pubValues: { + title: "URL: {{ $.json.object.id }} - Type: {{ $join($.json.type, ', ') }}", + payload: "{{ $string($.json) }}", + sourceurl: "{{ $.json.object.id }}", + }, + }, + }, + ], + }, + "Accept Request": { + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [{ action: Action.move, config: { stage: us4StageIds.Accepted } }], + }, + "Reject Request": { + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [{ action: Action.move, config: { stage: us4StageIds.Rejected } }], + }, + }, + }, + Accepted: { + id: us4StageIds.Accepted, + automations: { + "Create Review for Ingest": { + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + { + kind: "condition", + type: "jsonata", + expression: + "'Announce' in $eval($.pub.values.payload).type and 'coar-notify:IngestAction' in $eval($.pub.values.payload).type", + }, + ], + }, + actions: [ + { + action: Action.createPub, + config: { + stage: us4StageIds.ReviewInbox, + formSlug: "review-default-editor", + pubValues: { + title: "Review from aggregator: {{ $eval($.pub.values.payload).object.id }}", + }, + relationConfig: { + fieldSlug: `${us4Slug}:relatedpub`, + relatedPubId: "{{ $.pub.id }}", + value: "Notification", + direction: "source", + }, + }, + }, + ], + }, + }, + }, + Rejected: { + id: us4StageIds.Rejected, + automations: {}, }, ReviewInbox: { - to: ["Reviewing"], + id: us4StageIds.ReviewInbox, + automations: {}, }, - Reviewing: { - to: ["Published"], + }, + pubs: [ + { + pubType: "Submission", + stage: "Articles", + values: { title: "Research Paper on Gene Expression" }, }, + ], + stageConnections: { + Inbox: { to: ["Accepted", "Rejected"] }, + Accepted: { to: ["ReviewInbox"] }, }, }) -let community: CommunitySeedOutput +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +let us1Community: CommunitySeedOutput +let us2Community: CommunitySeedOutput +let us3Community: CommunitySeedOutput +let us4Community: CommunitySeedOutput test.beforeAll(async () => { - community = await seedCommunity(seed) + ;[us1Community, us2Community, us3Community, us4Community] = await Promise.all([ + seedCommunity(us1Seed), + seedCommunity(us2Seed), + seedCommunity(us3Seed), + seedCommunity(us4Seed), + ]) }) -test.describe("User Story 1 & 2: Review Request & Reception", () => { - test("Author requests review (US1) and Review Group receives it (US2)", async ({ +test.describe("User Story 1: Repository Author Requests Review", () => { + test("Author requests review and receives Announce Review back", async ({ page, mockPreprintRepo, }) => { const loginPage = new LoginPage(page) await loginPage.goto() - await loginPage.loginAndWaitForNavigation(community.users.admin.email, "password") + await loginPage.loginAndWaitForNavigation(us1Community.users.admin.email, "password") - const stagesManagePage = new StagesManagePage(page, community.community.slug) + const stagesManagePage = new StagesManagePage(page, us1Community.community.slug) - // --- Step 1: Author (Arcadia Science) requests review --- - // Update "Offer Review" automation to point to mock inbox + // Update "Send Review Offer" to point to mock inbox await stagesManagePage.goTo() - await stagesManagePage.openStagePanelTab("ReviewRequested", "Automations") - await page.getByText("Offer Review").click() + await stagesManagePage.openStagePanelTab("AwaitingResponse", "Automations") + await page.getByText("Send Review Offer").click() await page.getByTestId("action-config-card-http-collapse-trigger").click() const urlInput = page.getByLabel("Request URL") await urlInput.fill(`${mockPreprintRepo.url}/inbox`) @@ -291,26 +762,14 @@ test.describe("User Story 1 & 2: Review Request & Reception", () => { page.getByRole("button", { name: "Save automation", exact: true }) ).toHaveCount(0) - // Update "Announce Review" automation to point to mock inbox (for the end of the fake workflow) - await stagesManagePage.goTo() - await stagesManagePage.openStagePanelTab("Published", "Automations") - await page.getByText("Announce Review").click() - await page.getByTestId("action-config-card-http-collapse-trigger").click() - const announceUrlInput = page.getByLabel("Request URL") - await announceUrlInput.fill(`${mockPreprintRepo.url}/inbox`) - await page.getByRole("button", { name: "Save automation", exact: true }).click() - await expect( - page.getByRole("button", { name: "Save automation", exact: true }) - ).toHaveCount(0) - - // Move pub to ReviewRequested stage to trigger outgoing Offer + // Move Submission to AwaitingResponse to trigger outgoing Offer await stagesManagePage.goTo() - await stagesManagePage.openStagePanelTab("Inbox", "Pubs") - await page.getByRole("button", { name: "Inbox" }).first().click() - await page.getByText("Move to ReviewRequested").click() - - // Wait for the move to complete - await expect(page.getByText("Pre-existing Pub")).toHaveCount(0, { timeout: 15000 }) + await stagesManagePage.openStagePanelTab("Submissions", "Pubs") + await page.getByRole("button", { name: "Submissions" }).first().click() + await page.getByText("Move to AwaitingResponse").click() + await expect(page.getByText("Sample Paper for Review")).toHaveCount(0, { + timeout: 15000, + }) // Verify mock repo received the Offer await expect @@ -322,32 +781,85 @@ test.describe("User Story 1 & 2: Review Request & Reception", () => { .find((n) => (Array.isArray(n.type) ? n.type.includes("Offer") : n.type === "Offer")) expect(offer).toBeDefined() - // Clear received notifications to prepare for the next step - mockPreprintRepo.clearReceivedNotifications() + // Simulate PreReview sending an Announce Review back + const webhookUrl = `${process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"}/api/v0/c/${us1Community.community.slug}/site/webhook/${WEBHOOK_PATH}` + const submissionPub = us1Community.pubs[0] + const paperUrl = `${process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"}/c/${us1Community.community.slug}/pub/${submissionPub.id}` + + const announceReview = createAnnounceReviewPayload({ + preprintId: "mock-review-001", + reviewId: "review-from-prereview", + repositoryUrl: paperUrl.replace(`/pub/${submissionPub.id}`, ""), + serviceUrl: mockPreprintRepo.url, + serviceName: "PreReview", + }) + // Override as:inReplyTo to point to the actual paper URL + announceReview.object["as:inReplyTo"] = paperUrl + + await mockPreprintRepo.sendNotification(webhookUrl, announceReview) + + // Verify Review pub was created in Completed stage + await expect + .poll( + async () => { + await page.goto(`/c/${us1Community.community.slug}/stages`) + const reviewText = page.getByText("Review:", { exact: false }) + return (await reviewText.count()) > 0 + }, + { timeout: 15000 } + ) + .toBe(true) + }) +}) + +test.describe("User Story 2: Review Group Receives Review Request", () => { + test("Review group receives Offer, processes review, and sends Announce", async ({ + page, + mockPreprintRepo, + }) => { + const loginPage = new LoginPage(page) + await loginPage.goto() + await loginPage.loginAndWaitForNavigation(us2Community.users.admin.email, "password") + + const stagesManagePage = new StagesManagePage(page, us2Community.community.slug) + + // Update "Announce Review" automation to point to mock inbox + await stagesManagePage.goTo() + await stagesManagePage.openStagePanelTab("Published", "Automations") + await page.getByText("Announce Review").click() + await page.getByTestId("action-config-card-http-collapse-trigger").click() + const announceUrlInput = page.getByLabel("Request URL") + await announceUrlInput.fill(`${mockPreprintRepo.url}/inbox`) + await page.getByRole("button", { name: "Save automation", exact: true }).click() + await expect( + page.getByRole("button", { name: "Save automation", exact: true }) + ).toHaveCount(0) - // --- Step 2: Review Group (The Unjournal) receives the request and processes it through a fake workflow --- - const webhookUrl = `${process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"}/api/v0/c/${community.community.slug}/site/webhook/${WEBHOOK_PATH}` + // Send an Offer to the community's webhook (simulating external repository) + const webhookUrl = `${process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"}/api/v0/c/${us2Community.community.slug}/site/webhook/${WEBHOOK_PATH}` const incomingOffer = createOfferReviewPayload({ preprintId: "54321", repositoryUrl: mockPreprintRepo.url, - serviceUrl: `${process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"}/c/${community.community.slug}`, + serviceUrl: `${process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"}/c/${us2Community.community.slug}`, }) await mockPreprintRepo.sendNotification(webhookUrl, incomingOffer) - // Verify that a Notification pub was created - await page.goto(`/c/${community.community.slug}/activity/automations`) + // Verify Notification was created in Inbox + await page.goto(`/c/${us2Community.community.slug}/activity/automations`) const card = page.getByTestId(/automation-run-card-.*-Process COAR Notification/).first() await expect(card).toBeVisible({ timeout: 15000 }) - // The chain of automations should now run: - // 1. Process COAR Notification (Inbox) -> Creates Notification pub in Inbox - // 2. Create Review for Notification (Inbox) -> Creates Review pub in ReviewInbox - // 3. Start Review (ReviewInbox) -> Moves Review pub to Reviewing - // 4. Finish Review (Reviewing) -> Moves Review pub to Published - // 5. Announce Review (Published) -> Sends Announce to mock repository + // Manually accept the notification to trigger the automation chain + await stagesManagePage.goTo() + await stagesManagePage.openStagePanelTab("Inbox", "Pubs") + await page.getByRole("button", { name: "Inbox" }).first().click() + await page.getByText("Move to Accepted").click() + + // The automation chain runs: + // Accept → Create Review for Offer → Start Review → Finish Review → Announce Review - // Verify that the mock repository eventually receives the Announce notification + // Verify mock repo receives the Announce Review await expect .poll( () => @@ -371,29 +883,24 @@ test.describe("User Story 1 & 2: Review Request & Reception", () => { expect(finalAnnounce?.object?.["as:inReplyTo"]).toBe( `${mockPreprintRepo.url}/preprint/54321` ) - - // Check the stages to see if the Review pub reached Published - await page.goto(`/c/${community.community.slug}/stages`) - await expect( - page.getByText(`URL: ${mockPreprintRepo.url}/preprint/54321`, { exact: false }).first() - ).toBeVisible({ - timeout: 15000, - }) }) }) test.describe("User Story 3: Review Group Requests Ingestion By Aggregator", () => { - test("Review Group requests ingestion to Sciety", async ({ page, mockPreprintRepo }) => { + test("Review group publishes review and sends ingest request to aggregator", async ({ + page, + mockPreprintRepo, + }) => { const loginPage = new LoginPage(page) await loginPage.goto() - await loginPage.loginAndWaitForNavigation(community.users.admin.email, "password") + await loginPage.loginAndWaitForNavigation(us3Community.users.admin.email, "password") - const stagesManagePage = new StagesManagePage(page, community.community.slug) + const stagesManagePage = new StagesManagePage(page, us3Community.community.slug) - // Update "Announce Review" automation (acting as Ingest Request for this test) + // Update "Request Ingest" automation to point to mock inbox await stagesManagePage.goTo() await stagesManagePage.openStagePanelTab("Published", "Automations") - await page.getByText("Announce Review").click() + await page.getByText("Request Ingest").click() await page.getByTestId("action-config-card-http-collapse-trigger").click() const urlInput = page.getByLabel("Request URL") await urlInput.fill(`${mockPreprintRepo.url}/inbox`) @@ -402,16 +909,16 @@ test.describe("User Story 3: Review Group Requests Ingestion By Aggregator", () page.getByRole("button", { name: "Save automation", exact: true }) ).toHaveCount(0) - // Move pub to Published to trigger the announcement/ingest request + // Move Review to Published to trigger the ingest request await stagesManagePage.goTo() - await stagesManagePage.openStagePanelTab("Inbox", "Pubs") - await page.getByRole("button", { name: "Inbox" }).first().click() + await stagesManagePage.openStagePanelTab("Reviews", "Pubs") + await page.getByRole("button", { name: "Reviews" }).first().click() await page.getByText("Move to Published").click() + await expect(page.getByText("Sample Review of Research Output")).toHaveCount(0, { + timeout: 15000, + }) - // Wait for the move to complete - await expect(page.getByText("Pre-existing Pub")).toHaveCount(0, { timeout: 15000 }) - - // Verify mock repo (Sciety) received the Announce + // Verify mock aggregator (Sciety) received the Announce await expect .poll(() => mockPreprintRepo.getReceivedNotifications().length, { timeout: 15000 }) .toBeGreaterThan(0) @@ -425,37 +932,44 @@ test.describe("User Story 3: Review Group Requests Ingestion By Aggregator", () expect(announces.length).toBe(1) const announce = announces[0] - expect(announce).toBeDefined() expect(announce.type).toMatchObject(["Announce", "coar-notify:ReviewAction"]) expect(announce.object.id).toMatch( - `http://localhost:3000/c/${community.community.slug}/pubs/` + `http://localhost:3000/c/${us3Community.community.slug}/pubs/` ) expect(announce.object.type).toMatchObject(["Page", "sorg:Review"]) + expect(announce.object["as:inReplyTo"]).toBe( + "https://www.biorxiv.org/content/10.1101/2024.01.01.123456" + ) }) }) test.describe("User Story 4: Review Group Aggregation Announcement to Repositories", () => { - test("Arcadia Science receives ingestion announcement from Sciety", async ({ + test("Repository receives ingestion announcement and creates linked review", async ({ page, mockPreprintRepo, }) => { const loginPage = new LoginPage(page) await loginPage.goto() - await loginPage.loginAndWaitForNavigation(community.users.admin.email, "password") + await loginPage.loginAndWaitForNavigation(us4Community.users.admin.email, "password") + + const baseUrl = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000" + const webhookUrl = `${baseUrl}/api/v0/c/${us4Community.community.slug}/site/webhook/${WEBHOOK_PATH}` - const webhookUrl = `${process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000"}/api/v0/c/${community.community.slug}/site/webhook/${WEBHOOK_PATH}` + const submissionPub = us4Community.pubs[0] + const workUrl = `${baseUrl}/c/${us4Community.community.slug}/pub/${submissionPub.id}` const ingestionAnnouncement = createAnnounceIngestPayload({ reviewId: "review-123", serviceUrl: "https://review-group.org", aggregatorUrl: mockPreprintRepo.url, + workUrl, }) await mockPreprintRepo.sendNotification(webhookUrl, ingestionAnnouncement) // Verify Notification pub creation - await page.goto(`/c/${community.community.slug}/stages`) + await page.goto(`/c/${us4Community.community.slug}/stages`) await expect( page .getByText("URL: https://review-group.org/review/review-123", { exact: false }) @@ -463,5 +977,26 @@ test.describe("User Story 4: Review Group Aggregation Announcement to Repositori ).toBeVisible({ timeout: 15000, }) + + // Accept the notification to trigger Create Review for Ingest + const stagesManagePage = new StagesManagePage(page, us4Community.community.slug) + await stagesManagePage.goTo() + await stagesManagePage.openStagePanelTab("Inbox", "Pubs") + await page.getByRole("button", { name: "Inbox" }).first().click() + await page.getByText("Move to Accepted").click() + + // Verify Review was created and linked to the Submission + await expect + .poll( + async () => { + await page.goto(`/c/${us4Community.community.slug}/stages`) + const reviewList = page.getByText("Review from aggregator:", { + exact: false, + }) + return (await reviewList.count()) > 0 + }, + { timeout: 15000 } + ) + .toBe(true) }) }) diff --git a/core/playwright/fixtures/coar-notify-payloads.ts b/core/playwright/fixtures/coar-notify-payloads.ts index 361848c559..5d7ccf6951 100644 --- a/core/playwright/fixtures/coar-notify-payloads.ts +++ b/core/playwright/fixtures/coar-notify-payloads.ts @@ -231,10 +231,12 @@ export function createAnnounceIngestPayload({ reviewId, serviceUrl, aggregatorUrl, + workUrl, }: { reviewId: string serviceUrl: string aggregatorUrl: string + workUrl?: string }): CoarNotifyPayload { const reviewUrl = `${serviceUrl}/review/${reviewId}` return { @@ -249,6 +251,7 @@ export function createAnnounceIngestPayload({ object: { id: reviewUrl, type: ["Page", "sorg:Review"], + ...(workUrl && { "as:inReplyTo": workUrl }), }, target: { id: serviceUrl, diff --git a/core/prisma/consolidated-triggers.sql b/core/prisma/consolidated-triggers.sql index 5ec82d32bd..c091919964 100644 --- a/core/prisma/consolidated-triggers.sql +++ b/core/prisma/consolidated-triggers.sql @@ -1033,7 +1033,8 @@ BEGIN IF (NEW."pubId" IS NULL) THEN RETURN NEW; ELSE - correct_row = to_jsonb(NEW); + -- Strip large JSON columns to avoid exceeding pg_notify's 8000 byte payload limit + correct_row = to_jsonb(NEW) - 'config' - 'result' - 'json' - 'params'; END IF; @@ -1045,7 +1046,7 @@ BEGIN TG_TABLE_NAME, TG_OP ); - + RETURN NEW; END; $$ diff --git a/core/prisma/migrations/20260330000000_strip_large_json_from_action_runs_notify/migration.sql b/core/prisma/migrations/20260330000000_strip_large_json_from_action_runs_notify/migration.sql new file mode 100644 index 0000000000..03a6ecd574 --- /dev/null +++ b/core/prisma/migrations/20260330000000_strip_large_json_from_action_runs_notify/migration.sql @@ -0,0 +1,29 @@ +-- Strip large JSON columns (config, result, json, params) from the action_runs +-- notify payload to avoid exceeding pg_notify's 8000 byte limit. +CREATE OR REPLACE FUNCTION notify_change_action_runs() + RETURNS TRIGGER AS +$$ +DECLARE + correct_row jsonb; + community_id text; +BEGIN + + IF (NEW."pubId" IS NULL) THEN + RETURN NEW; + ELSE + correct_row = to_jsonb(NEW) - 'config' - 'result' - 'json' - 'params'; + END IF; + + select into community_id "communityId" from "pubs" where "id" = correct_row->>'pubId'::text; + + PERFORM notify_change( + correct_row, + community_id, + TG_TABLE_NAME, + TG_OP + ); + + RETURN NEW; +END; +$$ +LANGUAGE plpgsql; diff --git a/core/prisma/seed.ts b/core/prisma/seed.ts index d125f071c8..1c27e0aff6 100644 --- a/core/prisma/seed.ts +++ b/core/prisma/seed.ts @@ -7,14 +7,17 @@ import { logger } from "logger" import { isUniqueConstraintError } from "~/kysely/errors" import { env } from "~/lib/env/env" import { seedBlank } from "./seeds/blank" -import { seedCoarNotify } from "./seeds/coar-notify" +import { seedCoarUS1, seedCoarUS2, seedCoarUS3, seedCoarUS4 } from "./seeds/coar-notify" import { seedLegacy } from "./seeds/legacy" import { seedStarter } from "./seeds/starter" const legacyId = "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa" as CommunitiesId const starterId = "bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb" as CommunitiesId const blankId = "cccccccc-cccc-4ccc-cccc-cccccccccccc" as CommunitiesId -const coarNotifyId = "dddddddd-dddd-4ddd-dddd-dddddddddddd" as CommunitiesId +const coarUS1Id = "dd000001-dddd-4ddd-dddd-dddddddddddd" as CommunitiesId +const coarUS2Id = "dd000002-dddd-4ddd-dddd-dddddddddddd" as CommunitiesId +const coarUS3Id = "dd000003-dddd-4ddd-dddd-dddddddddddd" as CommunitiesId +const coarUS4Id = "dd000004-dddd-4ddd-dddd-dddddddddddd" as CommunitiesId async function main() { // do not seed arcadia if the minimal seed flag is set @@ -51,7 +54,10 @@ async function main() { await seedBlank(blankId) - await seedCoarNotify(coarNotifyId) + await seedCoarUS1(coarUS1Id) + await seedCoarUS2(coarUS2Id) + await seedCoarUS3(coarUS3Id) + await seedCoarUS4(coarUS4Id) } main() .then(async () => { diff --git a/core/prisma/seeds/coar-notify.ts b/core/prisma/seeds/coar-notify.ts index f4f5204633..a9e927a4e9 100644 --- a/core/prisma/seeds/coar-notify.ts +++ b/core/prisma/seeds/coar-notify.ts @@ -11,43 +11,92 @@ import { import { env } from "~/lib/env/env" import { seedCommunity } from "../seed/seedCommunity" -export async function seedCoarNotify(communityId?: CommunitiesId) { - const adminId = "dddddddd-dddd-4ddd-dddd-dddddddddd01" as UsersId +const WEBHOOK_PATH = "coar-inbox" +const REMOTE_INBOX_URL = "http://localhost:4001/api/inbox" - const STAGE_IDS = { - // Incoming notification processing stages - Inbox: "dddddddd-dddd-4ddd-dddd-dddddddddd10" as StagesId, - Accepted: "dddddddd-dddd-4ddd-dddd-dddddddddd12" as StagesId, - Rejected: "dddddddd-dddd-4ddd-dddd-dddddddddd13" as StagesId, - // Review workflow stages (for Reviews created from Notifications) - ReviewInbox: "dddddddd-dddd-4ddd-dddd-dddddddddd15" as StagesId, - Reviewing: "dddddddd-dddd-4ddd-dddd-dddddddddd16" as StagesId, - Published: "dddddddd-dddd-4ddd-dddd-dddddddddd14" as StagesId, - // Outbound review request stages (for our Submissions) - Submissions: "dddddddd-dddd-4ddd-dddd-dddddddddd17" as StagesId, - AwaitingResponse: "dddddddd-dddd-4ddd-dddd-dddddddddd18" as StagesId, - } +const adminId = "dddddddd-dddd-4ddd-dddd-dddddddddd01" as UsersId +const joeAuthorId = "dddddddd-dddd-4ddd-dddd-dddddddddd02" as UsersId + +const SEED_CSS = + "* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, sans-serif; background: #f0fdf4; color: #1e293b; line-height: 1.6; } .banner { background: #0d9488; color: #f0fdfa; padding: 0.5rem 1.5rem; font-size: 0.8rem; letter-spacing: 0.05em; text-transform: uppercase; } .site-content { max-width: 720px; margin: 2rem auto; padding: 0 1.5rem; } h1 { font-size: 1.6rem; color: #0f766e; border-bottom: 2px solid #14b8a6; padding-bottom: 0.5rem; margin-bottom: 1rem; } h2 { font-size: 1.1rem; color: #0f766e; margin: 1.25rem 0 0.4rem; } h3 { font-size: 1rem; margin: 0.75rem 0 0.25rem; } a { color: #0d9488; } .pub-field { margin-top: 1rem; } .pub-field-label { font-weight: 600; font-size: 0.85rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.5rem; } .pub-field-value { margin-bottom: 0.75rem; }" + +/** Static page group entry that produces styles.css */ +const CSS_PAGE_GROUP = { + slug: "'styles'", + transform: `'${SEED_CSS}'`, + extension: "css", +} + +/** Creates a single index page that lists all matched pubs as links */ +const indexPageGroup = (filter: string, bannerText: string) => ({ + filter, + slug: "''", + transform: [ + `'Index`, + `

Submissions

'`, + ].join(" "), + extension: "html", +}) - const WEBHOOK_PATH = "coar-inbox" +/** + * Wraps content template lines in a full HTML document with banner, CSS, + * and optional head extras (e.g. signposting tags). + */ +const withBannerAndHead = ({ + bannerText, + headExtra, + content, +}: { + bannerText: string + headExtra?: string // JSONata expression evaluating to HTML string for + content: string[] +}): string => { + const headBase = `' & $.pub.title & '` + const headSection = headExtra + ? `'${headBase}' & ${headExtra} & '` + : `'${headBase}` + return [ + `${headSection}
'`, + ...content, + "& '
'", + ].join(" ") +} - // Default remote inbox URL - can be changed in UI for testing - const REMOTE_INBOX_URL = "http://localhost:4001/api/inbox" +/** + * User Story 1: Repository Author Requests Review + * + * Arcadia Science is a PubPub repository community. An author can request + * a review from an external service like PreReview. When the review is + * fulfilled, a link to it appears alongside the research output. + * + * Flow: Submission → Request Review → Send Offer → Receive Announce Review → Display Review + */ +export async function seedCoarUS1(communityId?: CommunitiesId) { + const STAGE_IDS = { + Submissions: "dddddddd-0001-4ddd-dddd-dddddddddd10" as StagesId, + AwaitingResponse: "dddddddd-0001-4ddd-dddd-dddddddddd11" as StagesId, + ReviewCompleted: "dddddddd-0001-4ddd-dddd-dddddddddd12" as StagesId, + AwaitingReview: "dddddddd-0001-4ddd-dddd-dddddddddd13" as StagesId, + ReviewRejected: "dddddddd-0001-4ddd-dddd-dddddddddd14" as StagesId, + ExternalReviews: "dddddddd-0001-4ddd-dddd-dddddddddd15" as StagesId, + } return seedCommunity( { community: { id: communityId, - name: "COAR Notify", - slug: "coar-notify", + name: "US1: Arcadia Science", + slug: "coar-us1-arcadia", avatar: `${env.PUBPUB_URL}/demo/croc.png`, }, pubFields: { Title: { schemaName: CoreSchemaType.String }, Content: { schemaName: CoreSchemaType.String }, - Payload: { schemaName: CoreSchemaType.String }, + Author: { schemaName: CoreSchemaType.MemberId }, SourceURL: { schemaName: CoreSchemaType.String }, RelatedPub: { schemaName: CoreSchemaType.String, relation: true }, - Author: { schemaName: CoreSchemaType.MemberId }, }, pubTypes: { Submission: { @@ -55,16 +104,11 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { Content: { isTitle: false }, Author: { isTitle: false }, }, - Notification: { - Title: { isTitle: true }, - Payload: { isTitle: false }, - SourceURL: { isTitle: false }, - RelatedPub: { isTitle: false }, - }, Review: { Title: { isTitle: true }, Content: { isTitle: false }, RelatedPub: { isTitle: false }, + SourceURL: { isTitle: false }, }, }, users: { @@ -82,7 +126,7 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { role: MemberRole.admin, }, joeAuthor: { - id: "dddddddd-dddd-4ddd-dddd-dddddddddd02" as UsersId, + id: joeAuthorId, firstName: "Joe", lastName: "Author", email: "joe-author@pubpub.org", @@ -95,193 +139,87 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { pubType: "Submission", stage: "Submissions", values: { - Title: "Sample Submission for Review", - Author: "dddddddd-dddd-4ddd-dddd-dddddddddd02", + Title: "Sample Paper for Review", + Author: joeAuthorId, }, }, ], stages: { - Inbox: { - id: STAGE_IDS.Inbox, + Submissions: { + id: STAGE_IDS.Submissions, automations: { - "Process COAR Notification": { - icon: { - name: "mail", - color: "#3b82f6", - }, - triggers: [ - { - event: AutomationEvent.webhook, - config: { path: WEBHOOK_PATH }, - }, - ], - // Only process incoming Offer notifications (review requests from external services) - condition: { - type: AutomationConditionBlockType.AND, - items: [ - { - kind: "condition", - type: "jsonata", - expression: "'Offer' in $.json.type", - }, - ], - }, - actions: [ - { - action: Action.createPub, - config: { - stage: STAGE_IDS.Inbox, - formSlug: "notification-default-editor", - pubValues: { - Title: "URL: {{ $.json.object.id }} - Type: {{ $join($.json.type, ', ') }}", - Payload: "{{ $string($.json) }}", - SourceURL: "{{ $.json.object.id }}", - }, - }, - }, - ], - }, - // Manual action to accept an incoming review request - "Accept Request": { - icon: { - name: "check", - color: "#22c55e", - }, - triggers: [ - { - event: AutomationEvent.manual, - config: {}, - }, - ], - condition: { - type: AutomationConditionBlockType.AND, - items: [ - { - kind: "condition", - type: "jsonata", - expression: "$.pub.pubType.name = 'Notification'", - }, - ], - }, - actions: [ - { - action: Action.move, - config: { stage: STAGE_IDS.Accepted }, - }, - ], - }, - // Manual action to reject an incoming review request - "Reject Request": { - icon: { - name: "x", - color: "#ef4444", - }, - triggers: [ - { - event: AutomationEvent.manual, - config: {}, - }, - ], + "Request Review": { + icon: { name: "send", color: "#f59e0b" }, + triggers: [{ event: AutomationEvent.manual, config: {} }], condition: { type: AutomationConditionBlockType.AND, items: [ { kind: "condition", type: "jsonata", - expression: "$.pub.pubType.name = 'Notification'", + expression: "$.pub.pubType.name = 'Submission'", }, ], }, actions: [ { action: Action.move, - config: { stage: STAGE_IDS.Rejected }, + config: { stage: STAGE_IDS.AwaitingResponse }, }, ], }, - }, - }, - ReviewInbox: { - id: STAGE_IDS.ReviewInbox, - automations: { - "Start Review": { - icon: { - name: "play", - color: "#8b5cf6", - }, - triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], - actions: [ - { action: Action.move, config: { stage: STAGE_IDS.Reviewing } }, - ], - }, - }, - }, - Reviewing: { - id: STAGE_IDS.Reviewing, - automations: { - "Finish Review": { - icon: { - name: "check-circle", - color: "#22c55e", - }, - triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + "Publish Site": { + icon: { name: "globe", color: "#3b82f6" }, + triggers: [{ event: AutomationEvent.manual, config: {} }], actions: [ - { action: Action.move, config: { stage: STAGE_IDS.Published } }, - ], - }, - }, - }, - // Entry point for our own submissions that we want to request reviews for - Submissions: { - id: STAGE_IDS.Submissions, - automations: { - // Manual action to request a review from a remote repository - "Request Review": { - icon: { - name: "send", - color: "#f59e0b", - }, - triggers: [ { - event: AutomationEvent.manual, - config: {}, - }, - ], - condition: { - type: AutomationConditionBlockType.AND, - items: [ - { - kind: "condition", - type: "jsonata", - expression: "$.pub.pubType.name = 'Submission'", + action: Action.buildSite, + config: { + subpath: "site", + pages: [ + CSS_PAGE_GROUP, + indexPageGroup( + "$.pub.pubType.name = 'Submission'", + "Arcadia Science" + ), + { + filter: "$.pub.pubType.name = 'Submission'", + slug: "$.pub.id", + transform: withBannerAndHead({ + bannerText: "Arcadia Science", + content: [ + "& '
'", + "& '

' & $.pub.title & '

'", + "& '

This paper presents a novel approach to distributed systems consensus, combining elements of classical Byzantine fault tolerance with modern machine learning techniques. We demonstrate that our method achieves significant improvements in throughput while maintaining strong consistency guarantees.

'", + "& '

Abstract

'", + "& '

Consensus protocols form the backbone of reliable distributed systems, yet existing approaches struggle to balance performance with correctness under adversarial conditions. In this work, we introduce Adaptive Consensus (AC), a protocol that dynamically adjusts its communication patterns based on observed network behavior. Our evaluation across geo-distributed deployments shows a 3.2x improvement in commit latency compared to state-of-the-art protocols, with no loss in safety guarantees.

'", + "& '

Introduction

'", + "& '

The proliferation of globally distributed applications has created renewed interest in consensus protocols that can operate efficiently across wide-area networks. Traditional protocols such as Paxos and Raft were designed primarily for local-area deployments, and their performance degrades significantly when participants are separated by high-latency links.

'", + "& '

Recent work has explored various optimizations, including speculative execution, batching, and pipelining. However, these approaches typically assume relatively stable network conditions and do not adapt well to the dynamic environments characteristic of modern cloud deployments.

'", + "& '
'", + ], + }), + extension: "html", + }, + { + filter: "$.pub.pubType.name = 'Review'", + slug: "'_data/' & $.pub.id", + transform: "'{}'", + extension: "json", + }, + ], }, - ], - }, - actions: [ - { - action: Action.move, - config: { stage: STAGE_IDS.AwaitingResponse }, }, ], }, }, }, - // Waiting for response from external service after requesting a review AwaitingResponse: { id: STAGE_IDS.AwaitingResponse, automations: { - // Send the Offer when a submission enters this stage "Send Review Offer": { - icon: { - name: "send", - color: "#f59e0b", - }, - triggers: [ - { - event: AutomationEvent.pubEnteredStage, - config: {}, - }, - ], + icon: { name: "send", color: "#f59e0b" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], condition: { type: AutomationConditionBlockType.AND, items: [ @@ -298,130 +236,77 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { config: { url: REMOTE_INBOX_URL, method: "POST", - body: { + body: `<<< { "@context": [ "https://www.w3.org/ns/activitystreams", - "https://coar-notify.net", + "https://coar-notify.net" ], - type: ["Offer", "coar-notify:ReviewAction"], - id: "urn:uuid:{{ $.pub.id }}", - actor: { - id: "{{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}", - type: "Service", - name: "{{ $.community.name }}", - }, - object: { - id: "{{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", - type: ["Page", "sorg:AboutPage"], + "type": ["Offer", "coar-notify:ReviewAction"], + "id": "urn:uuid:" & $.pub.id, + "actor": { + "id": $.env.PUBPUB_URL & "/c/" & $.community.slug, + "type": "Service", + "name": $.community.name }, - target: { - id: REMOTE_INBOX_URL.replace("/inbox", ""), - inbox: REMOTE_INBOX_URL, - type: "Service", + "object": { + "id": $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pub/" & $.pub.id, + "type": ["Page", "sorg:AboutPage"] }, - origin: { - id: "{{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}", - inbox: `{{ $.env.PUBPUB_URL }}/api/v0/c/{{ $.community.slug }}/site/webhook/${WEBHOOK_PATH}`, - type: "Service", + "target": { + "id": "${REMOTE_INBOX_URL.replace("/inbox", "")}", + "inbox": "${REMOTE_INBOX_URL}", + "type": "Service" }, - }, + "origin": { + "id": $.env.PUBPUB_URL & "/c/" & $.community.slug, + "inbox": $.env.PUBPUB_URL & "/api/v0/c/" & $.community.slug & "/site/webhook/${WEBHOOK_PATH}", + "type": "Service" + } + } >>>`, }, }, ], }, - // Process incoming responses (Accept/Reject/Announce) from external services - "Process Response": { - icon: { - name: "mail", - color: "#3b82f6", - }, + "Offer Accepted": { + icon: { name: "check", color: "#22c55e" }, triggers: [ { event: AutomationEvent.webhook, config: { path: WEBHOOK_PATH }, }, ], - // Only process Accept, Reject, or Announce responses (not Offer) condition: { type: AutomationConditionBlockType.AND, items: [ { kind: "condition", type: "jsonata", - expression: - "'Accept' in $.json.type or 'Reject' in $.json.type or 'Announce' in $.json.type", + expression: "'Accept' in $.json.type", }, ], }, - // Resolver finds the Submission pub that matches the inReplyTo field - // The inReplyTo will be like "urn:uuid:" from our sent Offer - resolver: `{{ $replace($.json.inReplyTo, "http://localhost:3000/c/coar-notify/pub/", "") }} = $.pub.id`, + resolver: `$.pub.id = {{ $replace($.json.inReplyTo, "urn:uuid:", "") }}`, actions: [ { - action: Action.log, + action: Action.email, config: { - text: "Received response: {{ $.json.type }} for pub {{ $.pub.values.title }} ({{ $.pub.id }})", + recipientEmail: "all@pubpub.org", + subject: "Review offer accepted for: {{ $.pub.title }}", + body: "The review offer for **{{ $.pub.title }}** has been accepted.\n\nView the submission: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", }, }, - ], - }, - }, - }, - Published: { - id: STAGE_IDS.Published, - automations: { - "Announce Review": { - icon: { - name: "send", - color: "#ec4899", - }, - triggers: [ - { - event: AutomationEvent.pubEnteredStage, - config: {}, - }, - ], - actions: [ { - action: Action.http, - config: { - url: REMOTE_INBOX_URL, - method: "POST", - body: `<<< { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://coar-notify.net" - ], - "type": ["Announce", "coar-notify:ReviewAction"], - "id": "urn:uuid:" & $.pub.id, - "object": { - "id": "http://localhost:8080" & $.community.slug & "/reviews/" & $.pub.id, - "type": ["Page", "sorg:Review"], - "as:inReplyTo": $.pub.out.RelatedPub.values.SourceURL - }, - "target": { - "id": "${REMOTE_INBOX_URL.replace("/inbox", "")}", - "inbox": "${REMOTE_INBOX_URL}", - "type": "Service" - } - } >>>`, - }, + action: Action.move, + config: { stage: STAGE_IDS.AwaitingReview }, }, ], }, - "Build Site": { - icon: { - name: "globe", - color: "#6366f1", - }, + "Offer Rejected": { + icon: { name: "x", color: "#ef4444" }, triggers: [ { - event: AutomationEvent.pubEnteredStage, - config: {}, - }, - { - event: AutomationEvent.manual, - config: {}, + event: AutomationEvent.webhook, + config: { path: WEBHOOK_PATH }, }, ], condition: { @@ -430,127 +315,37 @@ export async function seedCoarNotify(communityId?: CommunitiesId) { { kind: "condition", type: "jsonata", - expression: "$.pub.pubType.name = 'Review'", + expression: "'Reject' in $.json.type", }, ], }, + resolver: `$.pub.id = {{ $replace($.json.inReplyTo, "urn:uuid:", "") }}`, actions: [ { - action: Action.buildSite, + action: Action.email, config: { - css: `:root { - --color-bg: #ffffff; - --color-text: #1a1a1a; - --color-muted: #6b7280; - --color-border: #e5e7eb; - --color-accent: #3b82f6; - --font-sans: system-ui, -apple-system, sans-serif; - --font-mono: ui-monospace, monospace; -} - -* { box-sizing: border-box; margin: 0; padding: 0; } - -body { - font-family: var(--font-sans); - line-height: 1.6; - color: var(--color-text); - background: var(--color-bg); - max-width: 800px; - margin: 0 auto; - padding: 2rem 1rem; -} - -h1, h2, h3 { line-height: 1.3; margin-bottom: 0.5em; } -h1 { font-size: 2rem; } -h2 { font-size: 1.5rem; color: var(--color-muted); } - -.pub-field { margin-bottom: 1.5rem; } -.pub-field-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-muted); - margin-bottom: 0.25rem; -} -.pub-field-value { font-size: 1rem; } -.pub-field-value:empty::after { content: "—"; color: var(--color-muted); } - -a { color: var(--color-accent); } -pre, code { font-family: var(--font-mono); font-size: 0.875rem; } -pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; } - -.review-list { list-style: none; } -.review-list li { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--color-border); } -.review-list li:last-child { border-bottom: none; }`, - subpath: "reviews", - pages: [ - { - // Individual review page - one per Review pub - slug: "$.pub.id", - filter: '$.pub.pubType.name = "Review"', - extension: "html", - transform: `'' & -'
' & - '

' & $.pub.title & '

' & - $join( - $map( - $filter($keys($.pub.values), function($v){ $not($contains($v, ":")) }), - function($v){ - '
' & - '
' & $v & '
' & - '
' & - $string($lookup($.pub.values, $v)) & '
' & - '
' - } - ), - '' - ) & - '

JSON

' & - '

← Back to all reviews

' & -'
'`, - }, - { - // JSON manifest - companion metadata for each review - slug: "$.pub.id", - filter: '$.pub.pubType.name = "Review"', - extension: "json", - transform: `$string({ - "title": $.pub.title, - "id": $.pub.id, - "type": "Review", - "pubType": $.pub.pubType.name -})`, - }, - { - // Index page - lists all published reviews - slug: '"/"', - filter: '$.pub.pubType.name = "Review"', - extension: "html", - transform: `'

Published Reviews

' & -''`, - }, - ], - outputMap: [], + recipientEmail: "all@pubpub.org", + subject: "Review offer rejected for: {{ $.pub.title }}", + body: "The review offer for **{{ $.pub.title }}** has been rejected.\n\nView the submission: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", }, }, + { + action: Action.move, + config: { stage: STAGE_IDS.ReviewRejected }, + }, ], }, }, }, - Accepted: { - id: STAGE_IDS.Accepted, + AwaitingReview: { + id: STAGE_IDS.AwaitingReview, automations: { - "Send Accept Acknowledgement": { - icon: { - name: "check", - color: "#22c55e", - }, + "Receive Review Announcement": { + icon: { name: "mail", color: "#3b82f6" }, triggers: [ { - event: AutomationEvent.pubEnteredStage, - config: {}, + event: AutomationEvent.webhook, + config: { path: WEBHOOK_PATH }, }, ], condition: { @@ -559,26 +354,304 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut { kind: "condition", type: "jsonata", - expression: "$.pub.pubType.name = 'Notification'", + expression: + "'Announce' in $.json.type and 'coar-notify:ReviewAction' in $.json.type", }, ], }, + resolver: + '$.pub.id = {{ $replace($replace($.json.object.`as:inReplyTo`, $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pubs/", ""), $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pub/", "") }}', actions: [ { - action: Action.http, + action: Action.createPub, config: { - url: REMOTE_INBOX_URL, - method: "POST", - body: `<<< ( - $payload := $eval($.pub.values.Payload); - { - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://coar-notify.net" - ], - "type": "Accept", - "id": "urn:uuid:" & $.pub.id & ":accept", - "actor": { + stage: STAGE_IDS.ExternalReviews, + formSlug: "review-default-editor", + pubValues: { + Title: "Review: {{ $.json.object.id }}", + SourceURL: "{{ $.json.object.id }}", + }, + relationConfig: { + fieldSlug: "coar-us1-arcadia:relatedpub", + relatedPubId: "{{ $.pub.id }}", + value: "Submission", + direction: "source", + }, + }, + }, + { + action: Action.move, + config: { stage: STAGE_IDS.ReviewCompleted }, + }, + ], + }, + }, + }, + ExternalReviews: { + id: STAGE_IDS.ExternalReviews, + automations: {}, + }, + ReviewRejected: { + id: STAGE_IDS.ReviewRejected, + automations: {}, + }, + ReviewCompleted: { + id: STAGE_IDS.ReviewCompleted, + automations: { + "Publish Site": { + icon: { name: "globe", color: "#3b82f6" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.buildSite, + config: { + subpath: "site", + pages: [ + CSS_PAGE_GROUP, + indexPageGroup( + "$.pub.pubType.name = 'Submission'", + "Arcadia Science" + ), + { + filter: "$.pub.pubType.name = 'Submission'", + slug: "$.pub.id", + transform: withBannerAndHead({ + bannerText: "Arcadia Science", + content: [ + "& '
'", + "& '

' & $.pub.title & '

'", + "& '

This paper presents a novel approach to distributed systems consensus, combining elements of classical Byzantine fault tolerance with modern machine learning techniques. We demonstrate that our method achieves significant improvements in throughput while maintaining strong consistency guarantees.

'", + "& '

Abstract

'", + "& '

Consensus protocols form the backbone of reliable distributed systems, yet existing approaches struggle to balance performance with correctness under adversarial conditions. In this work, we introduce Adaptive Consensus (AC), a protocol that dynamically adjusts its communication patterns based on observed network behavior. Our evaluation across geo-distributed deployments shows a 3.2x improvement in commit latency compared to state-of-the-art protocols, with no loss in safety guarantees.

'", + "& '

Introduction

'", + "& '

The proliferation of globally distributed applications has created renewed interest in consensus protocols that can operate efficiently across wide-area networks. Traditional protocols such as Paxos and Raft were designed primarily for local-area deployments, and their performance degrades significantly when participants are separated by high-latency links.

'", + "& '

Recent work has explored various optimizations, including speculative execution, batching, and pipelining. However, these approaches typically assume relatively stable network conditions and do not adapt well to the dynamic environments characteristic of modern cloud deployments.

'", + "& '
'", + ], + }), + extension: "html", + }, + // Review data group — not rendered as pages, used for cross-referencing + { + filter: "$.pub.pubType.name = 'Review'", + slug: "'_data/' & $.pub.id", + transform: "'{}'", + extension: "json", + }, + ], + }, + }, + ], + }, + "Notify: Site Published": { + icon: { name: "mail", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: + "Site published with new review for: {{ $.pub.title }}", + body: "The community site has been updated with a new review.\n\nReview: **{{ $.pub.title }}**\n\nView the pub: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}\n\nView the site: http://localhost:9000/assets.v7.pubpub.org/sites/coar-us1-arcadia/site/index.html", + }, + }, + ], + }, + }, + }, + }, + stageConnections: { + Submissions: { to: ["AwaitingResponse"] }, + AwaitingResponse: { to: ["AwaitingReview", "ReviewRejected"] }, + }, + }, + { randomSlug: false } + ) +} + +/** + * User Story 2: Review Group Receives Review Request + * + * The Unjournal is a PubPub review group community. It receives review + * requests from external repositories, processes them through a review + * workflow, and announces the completed review back to the repository. + * + * Flow: Receive Offer → Accept/Reject → Create Review → Review Workflow → Publish → Announce Review + */ +export async function seedCoarUS2(communityId?: CommunitiesId) { + const STAGE_IDS = { + Inbox: "dddddddd-0002-4ddd-dddd-dddddddddd10" as StagesId, + Accepted: "dddddddd-0002-4ddd-dddd-dddddddddd11" as StagesId, + Rejected: "dddddddd-0002-4ddd-dddd-dddddddddd12" as StagesId, + ReviewInbox: "dddddddd-0002-4ddd-dddd-dddddddddd13" as StagesId, + Reviewing: "dddddddd-0002-4ddd-dddd-dddddddddd14" as StagesId, + Published: "dddddddd-0002-4ddd-dddd-dddddddddd15" as StagesId, + } + + const SITE_BASE = "http://localhost:9000/assets.v7.pubpub.org/sites/coar-us2-unjournal/site" + + return seedCommunity( + { + community: { + id: communityId, + name: "US2: The Unjournal", + slug: "coar-us2-unjournal", + avatar: `${env.PUBPUB_URL}/demo/croc.png`, + }, + pubFields: { + Title: { schemaName: CoreSchemaType.String }, + Content: { schemaName: CoreSchemaType.String }, + Payload: { schemaName: CoreSchemaType.String }, + SourceURL: { schemaName: CoreSchemaType.String }, + RelatedPub: { schemaName: CoreSchemaType.String, relation: true }, + }, + pubTypes: { + Notification: { + Title: { isTitle: true }, + Payload: { isTitle: false }, + SourceURL: { isTitle: false }, + RelatedPub: { isTitle: false }, + }, + Review: { + Title: { isTitle: true }, + Content: { isTitle: false }, + RelatedPub: { isTitle: false }, + SourceURL: { isTitle: false }, + }, + }, + users: { + admin: { + id: adminId, + existing: true, + role: MemberRole.admin, + }, + jillAdmin: { + id: "0cd4b908-b4f6-41be-9463-28979fefb4cd" as UsersId, + existing: true, + role: MemberRole.admin, + }, + }, + stages: { + Inbox: { + id: STAGE_IDS.Inbox, + automations: { + "Process COAR Notification": { + icon: { name: "mail", color: "#3b82f6" }, + triggers: [ + { + event: AutomationEvent.webhook, + config: { path: WEBHOOK_PATH }, + }, + ], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "'Offer' in $.json.type", + }, + ], + }, + actions: [ + { + action: Action.createPub, + config: { + stage: STAGE_IDS.Inbox, + formSlug: "notification-default-editor", + pubValues: { + Title: "Request To Review: {{ $.json.object.id }}", + Payload: "{{ $string($.json) }}", + SourceURL: "{{ $.json.object.id }}", + }, + }, + }, + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: + "New review request received: {{ $.json.object.id }}", + body: "A new review request has been received.\n\nObject: {{ $.json.object.id }}\n\nView: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}", + }, + }, + ], + }, + "Accept Request": { + icon: { name: "check", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [ + { + action: Action.move, + config: { stage: STAGE_IDS.Accepted }, + }, + ], + }, + "Reject Request": { + icon: { name: "x", color: "#ef4444" }, + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [ + { + action: Action.move, + config: { stage: STAGE_IDS.Rejected }, + }, + ], + }, + }, + }, + Accepted: { + id: STAGE_IDS.Accepted, + automations: { + "Send Accept Acknowledgement": { + icon: { name: "check", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [ + { + action: Action.http, + config: { + url: REMOTE_INBOX_URL, + method: "POST", + body: `<<< ( + $payload := $eval($.pub.values.Payload); + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://coar-notify.net" + ], + "type": "Accept", + "id": "urn:uuid:" & $.pub.id & ":accept", + "actor": { "id": $.env.PUBPUB_URL & "/c/" & $.community.slug, "type": "Service", "name": $.community.name @@ -595,20 +668,19 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut ) >>>`, }, }, - ], - }, - // Create a Review pub after accepting the request - "Create Review for Notification": { - icon: { - name: "plus-circle", - color: "#10b981", - }, - triggers: [ { - event: AutomationEvent.pubEnteredStage, - config: {}, + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: "Review request accepted: {{ $.pub.title }}", + body: "The review request **{{ $.pub.title }}** has been accepted.\n\nView: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", + }, }, ], + }, + "Create Review for Offer": { + icon: { name: "plus-circle", color: "#10b981" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], condition: { type: AutomationConditionBlockType.AND, items: [ @@ -617,6 +689,11 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut type: "jsonata", expression: "$.pub.pubType.name = 'Notification'", }, + { + kind: "condition", + type: "jsonata", + expression: "'Offer' in $eval($.pub.values.Payload).type", + }, ], }, actions: [ @@ -627,9 +704,15 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut formSlug: "review-default-editor", pubValues: { Title: "Review for: {{ $.pub.values.title }}", + SourceURL: "{{ $.pub.values.SourceURL }}", + Content: + "

Summary: This paper presents a compelling approach to an important problem. The authors demonstrate a clear understanding of the existing literature and provide novel contributions that advance the field. We recommend acceptance with minor revisions.

" + + "

Strengths: The experimental design is rigorous and well-documented. The statistical analysis is appropriate, and the results are presented clearly. The discussion section effectively contextualizes the findings within the broader literature.

" + + "

Weaknesses: The sample size, while adequate, could be expanded in future work. Some of the assumptions underlying the theoretical model deserve further justification. The related work section would benefit from a more thorough comparison with recent approaches.

" + + "

Minor Issues: Figure 3 is difficult to read at the current resolution. Table 2 has a formatting inconsistency in the last column. A few typographical errors remain in Sections 4 and 5.

", }, relationConfig: { - fieldSlug: "coar-notify:relatedpub", + fieldSlug: "coar-us2-unjournal:relatedpub", relatedPubId: "{{ $.pub.id }}", value: "Notification", direction: "source", @@ -644,16 +727,8 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut id: STAGE_IDS.Rejected, automations: { "Send Reject Acknowledgement": { - icon: { - name: "x", - color: "#ef4444", - }, - triggers: [ - { - event: AutomationEvent.pubEnteredStage, - config: {}, - }, - ], + icon: { name: "x", color: "#ef4444" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], condition: { type: AutomationConditionBlockType.AND, items: [ @@ -697,31 +772,700 @@ pre { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: aut ) >>>`, }, }, + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: "Review request rejected: {{ $.pub.title }}", + body: "The review request **{{ $.pub.title }}** has been rejected.\n\nView: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", + }, + }, ], }, }, }, - }, - stageConnections: { - // Incoming notification flow: process and either accept, reject, or create review - Inbox: { - to: ["Accepted", "Rejected"], - }, - // Review workflow (for Reviews created from Notifications) ReviewInbox: { - to: ["Reviewing"], + id: STAGE_IDS.ReviewInbox, + automations: { + "Start Review": { + icon: { name: "play", color: "#8b5cf6" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.move, + config: { stage: STAGE_IDS.Reviewing }, + }, + ], + }, + }, }, Reviewing: { - to: ["Published"], - }, - // Outbound request flow: our submissions requesting external reviews - Submissions: { - to: ["AwaitingResponse"], + id: STAGE_IDS.Reviewing, + automations: { + "Finish Review": { + icon: { name: "check-circle", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.move, + config: { stage: STAGE_IDS.Published }, + }, + ], + }, + }, + }, + Published: { + id: STAGE_IDS.Published, + automations: { + "Announce Review": { + icon: { name: "send", color: "#ec4899" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.http, + config: { + url: REMOTE_INBOX_URL, + method: "POST", + body: `<<< { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://coar-notify.net" + ], + "type": ["Announce", "coar-notify:ReviewAction"], + "id": "urn:uuid:" & $.pub.id, + "object": { + "id": "${SITE_BASE}/" & $.pub.id & "/index.html", + "type": ["Page", "sorg:Review"], + "as:inReplyTo": $.pub.out.RelatedPub.values.SourceURL + }, + "target": { + "id": "${REMOTE_INBOX_URL.replace("/inbox", "")}", + "inbox": "${REMOTE_INBOX_URL}", + "type": "Service" + } + } >>>`, + }, + }, + ], + }, + "Publish Site": { + icon: { name: "globe", color: "#3b82f6" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.buildSite, + config: { + subpath: "site", + pages: [ + CSS_PAGE_GROUP, + indexPageGroup( + "$.pub.pubType.name = 'Review'", + "The Unjournal" + ), + // Review HTML pages with signposting to DocMap + { + filter: "$.pub.pubType.name = 'Review'", + slug: "$.pub.id", + transform: withBannerAndHead({ + bannerText: "The Unjournal", + headExtra: + '""', + content: [ + "& '
'", + "& '

' & $.pub.title & '

'", + "& '
'", + "& '
Source
'", + "& ''", + "& '
'", + "& '
'", + "& '
Review
'", + "& ($.pub.values.Content ? $.pub.values.Content : 'No content available')", + "& '
'", + "& '
'", + ], + }), + extension: "html", + }, + // Isolated review content page (plain HTML, no site chrome) + { + filter: "$.pub.pubType.name = 'Review'", + slug: "$.pub.id & '/content'", + transform: + "'' & ($.pub.values.Content ? $.pub.values.Content : 'No content available') & ''", + extension: "html", + }, + // DocMap JSON metadata for each review + { + filter: "$.pub.pubType.name = 'Review'", + slug: "$.pub.id & '.docmap'", + transform: [ + "'{' &", + '\'"@context": "https://w3id.org/docmaps/context.jsonld",\' &', + '\'"type": "docmap",\' &', + "'\"id\": \"http://localhost:9000/assets.v7.pubpub.org/sites/coar-us2-unjournal/site/' & $.pub.id & '.docmap.json\",' &", + '\'"publisher": {"name": "\' & $.community.name & \'"},\' &', + '\'"first-step": "_:b0",\' &', + '\'"steps": {"_:b0": {"actions": [{"outputs": [{\' &', + '\'"type": "review-article",\' &', + "'\"as:inReplyTo\": \"' & $.pub.values.SourceURL & '\",' &", + "'\"content\": [' &", + '\'{"type": "web-page", "url": "http://localhost:9000/assets.v7.pubpub.org/sites/coar-us2-unjournal/site/\' & $.pub.id & \'/index.html"},\' &', + '\'{"type": "web-content", "url": "http://localhost:9000/assets.v7.pubpub.org/sites/coar-us2-unjournal/site/\' & $.pub.id & \'/content/index.html"}\' &', + "']' &", + "'}]}]}}' &", + "'}'", + ].join(" "), + extension: "json", + }, + ], + }, + }, + ], + }, + "Notify: Site Published": { + icon: { name: "mail", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: "Review published: {{ $.pub.title }}", + body: `Review **{{ $.pub.title }}** has been published and announced.\n\nView the pub: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}\n\nView the site: ${SITE_BASE}/index.html`, + }, + }, + ], + }, + }, + }, + }, + stageConnections: { + Inbox: { to: ["Accepted", "Rejected"] }, + }, + }, + { randomSlug: false } + ) +} + +/** + * User Story 3: Review Group Requests Ingestion By Aggregator + * + * A review group (e.g. The Unjournal) has produced a review and wants to + * notify an aggregator like Sciety to ingest it. When a review is published, + * it sends a Request Ingest notification to the aggregator. + * + * Flow: Review → Publish → Build Site → Send Announce Review to Aggregator + */ +export async function seedCoarUS3(communityId?: CommunitiesId) { + const STAGE_IDS = { + Reviews: "dddddddd-0003-4ddd-dddd-dddddddddd10" as StagesId, + Published: "dddddddd-0003-4ddd-dddd-dddddddddd11" as StagesId, + } + + const SITE_BASE = "http://localhost:9000/assets.v7.pubpub.org/sites/coar-us3-review-group/site" + + return seedCommunity( + { + community: { + id: communityId, + name: "US3: Review Group", + slug: "coar-us3-review-group", + avatar: `${env.PUBPUB_URL}/demo/croc.png`, + }, + pubFields: { + Title: { schemaName: CoreSchemaType.String }, + Content: { schemaName: CoreSchemaType.String }, + SourceURL: { schemaName: CoreSchemaType.String }, + }, + pubTypes: { + Review: { + Title: { isTitle: true }, + Content: { isTitle: false }, + SourceURL: { isTitle: false }, + }, + }, + users: { + admin: { + id: adminId, + existing: true, + role: MemberRole.admin, + }, + jillAdmin: { + id: "0cd4b908-b4f6-41be-9463-28979fefb4cd" as UsersId, + existing: true, + role: MemberRole.admin, }, }, + pubs: [ + { + pubType: "Review", + stage: "Reviews", + values: { + Title: "Sample Review of Research Output", + SourceURL: "https://www.biorxiv.org/content/10.1101/2024.01.01.123456", + Content: + "

Overview: This research output presents a well-structured investigation into gene expression patterns across multiple tissue types. The methodology is sound and the findings contribute meaningfully to our understanding of transcriptional regulation.

" + + "

Strengths: The use of single-cell RNA sequencing combined with spatial transcriptomics provides a comprehensive view of expression dynamics. The computational pipeline is well-documented and reproducible. Statistical methods are appropriate for the scale of data analyzed.

" + + "

Areas for Improvement: The discussion could benefit from deeper engagement with conflicting results in the literature. Some of the supplementary figures are difficult to interpret without additional context. We suggest expanding the methods section to include parameter sensitivity analysis.

" + + "

Recommendation: Accept with minor revisions. The core findings are robust and the paper makes a valuable contribution to the field.

", + }, + }, + ], + stages: { + Reviews: { + id: STAGE_IDS.Reviews, + automations: { + "Publish Review": { + icon: { name: "check-circle", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Review'", + }, + ], + }, + actions: [ + { + action: Action.move, + config: { stage: STAGE_IDS.Published }, + }, + ], + }, + }, + }, + Published: { + id: STAGE_IDS.Published, + automations: { + "Request Ingest": { + icon: { name: "send", color: "#ec4899" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.http, + config: { + url: REMOTE_INBOX_URL, + method: "POST", + body: `<<< { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://coar-notify.net" + ], + "type": ["Announce", "coar-notify:ReviewAction"], + "id": "urn:uuid:" & $.pub.id, + "object": { + "id": "${SITE_BASE}/" & $.pub.id & "/index.html", + "type": ["Page", "sorg:Review"], + "as:inReplyTo": $.pub.values.SourceURL + }, + "target": { + "id": "${REMOTE_INBOX_URL.replace("/inbox", "")}", + "inbox": "${REMOTE_INBOX_URL}", + "type": "Service" + } + } >>>`, + }, + }, + ], + }, + "Publish Site": { + icon: { name: "globe", color: "#3b82f6" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.buildSite, + config: { + subpath: "site", + pages: [ + CSS_PAGE_GROUP, + indexPageGroup( + "$.pub.pubType.name = 'Review'", + "Review Group" + ), + // Review HTML pages with signposting to DocMap + { + filter: "$.pub.pubType.name = 'Review'", + slug: "$.pub.id", + transform: withBannerAndHead({ + bannerText: "Review Group", + headExtra: + '""', + content: [ + "& '
'", + "& '

' & $.pub.title & '

'", + "& '
'", + "& '
Source
'", + "& ''", + "& '
'", + "& '
'", + "& '
Review
'", + "& ($.pub.values.Content ? $.pub.values.Content : 'No content available')", + "& '
'", + "& '
'", + ], + }), + extension: "html", + }, + // Isolated review content page (plain HTML, no site chrome) + { + filter: "$.pub.pubType.name = 'Review'", + slug: "$.pub.id & '/content'", + transform: + "'' & ($.pub.values.Content ? $.pub.values.Content : 'No content available') & ''", + extension: "html", + }, + // DocMap JSON metadata for each review + { + filter: "$.pub.pubType.name = 'Review'", + slug: "$.pub.id & '.docmap'", + transform: [ + "'{' &", + '\'"@context": "https://w3id.org/docmaps/context.jsonld",\' &', + '\'"type": "docmap",\' &', + "'\"id\": \"http://localhost:9000/assets.v7.pubpub.org/sites/coar-us3-review-group/site/' & $.pub.id & '.docmap.json\",' &", + '\'"publisher": {"name": "\' & $.community.name & \'"},\' &', + '\'"first-step": "_:b0",\' &', + '\'"steps": {"_:b0": {"actions": [{"outputs": [{\' &', + '\'"type": "review-article",\' &', + "'\"as:inReplyTo\": \"' & $.pub.values.SourceURL & '\",' &", + "'\"content\": [' &", + '\'{"type": "web-page", "url": "http://localhost:9000/assets.v7.pubpub.org/sites/coar-us3-review-group/site/\' & $.pub.id & \'/index.html"},\' &', + '\'{"type": "web-content", "url": "http://localhost:9000/assets.v7.pubpub.org/sites/coar-us3-review-group/site/\' & $.pub.id & \'/content/index.html"}\' &', + "']' &", + "'}]}]}}' &", + "'}'", + ].join(" "), + extension: "json", + }, + ], + }, + }, + ], + }, + "Notify: Review Published": { + icon: { name: "mail", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: + "Review published and announced: {{ $.pub.title }}", + body: `Review **{{ $.pub.title }}** has been published and sent to the aggregator.\n\nView the pub: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}\n\nView the site: ${SITE_BASE}/index.html`, + }, + }, + ], + }, + }, + }, + }, + stageConnections: { + Reviews: { to: ["Published"] }, + }, }, + { randomSlug: false } + ) +} + +/** + * User Story 4: Review Group Aggregation Announcement to Repositories + * + * Arcadia Science (or any PubPub repository) subscribes to review notifications + * from an aggregator like Sciety. When an Announce Ingest arrives, the admin can + * accept it, which resolves the local article and creates a linked Review. + * + * Flow: Receive Announce Ingest → Accept/Reject → Resolve Local Article → Create Linked Review → Build Site + */ +export async function seedCoarUS4(communityId?: CommunitiesId) { + const STAGE_IDS = { + Articles: "dddddddd-0004-4ddd-dddd-dddddddddd10" as StagesId, + Inbox: "dddddddd-0004-4ddd-dddd-dddddddddd11" as StagesId, + Accepted: "dddddddd-0004-4ddd-dddd-dddddddddd12" as StagesId, + Rejected: "dddddddd-0004-4ddd-dddd-dddddddddd13" as StagesId, + ReviewCompleted: "dddddddd-0004-4ddd-dddd-dddddddddd14" as StagesId, + } + + const SITE_BASE = "http://localhost:9000/assets.v7.pubpub.org/sites/coar-us4-repository/site" + + return seedCommunity( { - randomSlug: false, - } + community: { + id: communityId, + name: "US4: Arcadia Science", + slug: "coar-us4-repository", + avatar: `${env.PUBPUB_URL}/demo/croc.png`, + }, + pubFields: { + Title: { schemaName: CoreSchemaType.String }, + Content: { schemaName: CoreSchemaType.String }, + Payload: { schemaName: CoreSchemaType.String }, + SourceURL: { schemaName: CoreSchemaType.String }, + RelatedPub: { schemaName: CoreSchemaType.String, relation: true }, + }, + pubTypes: { + Submission: { + Title: { isTitle: true }, + Content: { isTitle: false }, + }, + Notification: { + Title: { isTitle: true }, + Payload: { isTitle: false }, + SourceURL: { isTitle: false }, + RelatedPub: { isTitle: false }, + }, + Review: { + Title: { isTitle: true }, + Content: { isTitle: false }, + RelatedPub: { isTitle: false }, + SourceURL: { isTitle: false }, + }, + }, + users: { + admin: { + id: adminId, + existing: true, + role: MemberRole.admin, + }, + jillAdmin: { + id: "0cd4b908-b4f6-41be-9463-28979fefb4cd" as UsersId, + existing: true, + role: MemberRole.admin, + }, + }, + pubs: [ + { + pubType: "Submission", + stage: "Articles", + values: { + Title: "Research Paper on Gene Expression", + }, + }, + ], + stages: { + Articles: { + id: STAGE_IDS.Articles, + automations: {}, + }, + Inbox: { + id: STAGE_IDS.Inbox, + automations: { + "Process COAR Notification": { + icon: { name: "mail", color: "#3b82f6" }, + triggers: [ + { + event: AutomationEvent.webhook, + config: { path: WEBHOOK_PATH }, + }, + ], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: + "'Announce' in $.json.type and 'coar-notify:IngestAction' in $.json.type", + }, + ], + }, + actions: [ + { + action: Action.createPub, + config: { + stage: STAGE_IDS.Inbox, + formSlug: "notification-default-editor", + pubValues: { + Title: "Notification: {{ $.json.object.id }}", + Payload: "{{ $string($.json) }}", + SourceURL: "{{ $.json.object.id }}", + }, + }, + }, + ], + }, + "Accept Request": { + icon: { name: "check", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [ + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: "Ingest request accepted: {{ $.pub.title }}", + body: "The ingest request **{{ $.pub.title }}** has been accepted.\n\nView: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", + }, + }, + { + action: Action.move, + config: { stage: STAGE_IDS.Accepted }, + }, + ], + }, + "Reject Request": { + icon: { name: "x", color: "#ef4444" }, + triggers: [{ event: AutomationEvent.manual, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + ], + }, + actions: [ + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: "Ingest request rejected: {{ $.pub.title }}", + body: "The ingest request **{{ $.pub.title }}** has been rejected.\n\nView: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}", + }, + }, + { + action: Action.move, + config: { stage: STAGE_IDS.Rejected }, + }, + ], + }, + }, + }, + Accepted: { + id: STAGE_IDS.Accepted, + automations: { + "Create Review for Ingest": { + icon: { name: "plus-circle", color: "#10b981" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + condition: { + type: AutomationConditionBlockType.AND, + items: [ + { + kind: "condition", + type: "jsonata", + expression: "$.pub.pubType.name = 'Notification'", + }, + { + kind: "condition", + type: "jsonata", + expression: + "'Announce' in $eval($.pub.values.Payload).type and 'coar-notify:IngestAction' in $eval($.pub.values.Payload).type", + }, + ], + }, + resolver: + '$.pub.id = {{ $replace($replace($eval($.pub.values.Payload).object.`as:inReplyTo`, $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pubs/", ""), $.env.PUBPUB_URL & "/c/" & $.community.slug & "/pub/", "") }}', + actions: [ + { + action: Action.createPub, + config: { + stage: STAGE_IDS.ReviewCompleted, + formSlug: "review-default-editor", + pubValues: { + Title: "Review from aggregator: {{ $eval($.pub.values.Payload).object.id }}", + SourceURL: + "{{ $eval($.pub.values.Payload).object.id }}", + }, + relationConfig: { + fieldSlug: "coar-us4-repository:relatedpub", + relatedPubId: "{{ $.pub.id }}", + value: "Submission", + direction: "source", + }, + }, + }, + ], + }, + }, + }, + Rejected: { + id: STAGE_IDS.Rejected, + automations: {}, + }, + ReviewCompleted: { + id: STAGE_IDS.ReviewCompleted, + automations: { + "Publish Site": { + icon: { name: "globe", color: "#3b82f6" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.buildSite, + config: { + subpath: "site", + pages: [ + CSS_PAGE_GROUP, + indexPageGroup( + "$.pub.pubType.name = 'Submission'", + "External Repository" + ), + { + filter: "$.pub.pubType.name = 'Submission'", + slug: "$.pub.id", + transform: withBannerAndHead({ + bannerText: "External Repository", + content: [ + "& '
'", + "& '

' & $.pub.title & '

'", + "& '

This paper presents a novel approach to distributed systems consensus, combining elements of classical Byzantine fault tolerance with modern machine learning techniques. We demonstrate that our method achieves significant improvements in throughput while maintaining strong consistency guarantees.

'", + "& '

Abstract

'", + "& '

Consensus protocols form the backbone of reliable distributed systems, yet existing approaches struggle to balance performance with correctness under adversarial conditions. In this work, we introduce Adaptive Consensus (AC), a protocol that dynamically adjusts its communication patterns based on observed network behavior. Our evaluation across geo-distributed deployments shows a 3.2x improvement in commit latency compared to state-of-the-art protocols, with no loss in safety guarantees.

'", + "& '

Introduction

'", + "& '

The proliferation of globally distributed applications has created renewed interest in consensus protocols that can operate efficiently across wide-area networks. Traditional protocols such as Paxos and Raft were designed primarily for local-area deployments, and their performance degrades significantly when participants are separated by high-latency links.

'", + "& '

Recent work has explored various optimizations, including speculative execution, batching, and pipelining. However, these approaches typically assume relatively stable network conditions and do not adapt well to the dynamic environments characteristic of modern cloud deployments.

'", + "& '
'", + ], + }), + extension: "html", + }, + // Review data group — not rendered as pages, used for cross-referencing + { + filter: "$.pub.pubType.name = 'Review'", + slug: "'_data/' & $.pub.id", + transform: "'{}'", + extension: "json", + }, + ], + }, + }, + ], + }, + "Notify: Site Published": { + icon: { name: "mail", color: "#22c55e" }, + triggers: [{ event: AutomationEvent.pubEnteredStage, config: {} }], + actions: [ + { + action: Action.email, + config: { + recipientEmail: "all@pubpub.org", + subject: + "Site published with new review: {{ $.pub.title }}", + body: `The community site has been updated with a new review.\n\nReview: **{{ $.pub.title }}**\n\nView the pub: {{ $.env.PUBPUB_URL }}/c/{{ $.community.slug }}/pub/{{ $.pub.id }}\n\nView the site: ${SITE_BASE}/index.html`, + }, + }, + ], + }, + }, + }, + }, + stageConnections: { + Inbox: { to: ["Accepted", "Rejected"] }, + }, + }, + { randomSlug: false } ) } diff --git a/mock-notify/src/app/components/NotificationCard.tsx b/mock-notify/src/app/components/NotificationCard.tsx index 3ec8ce3fd3..2dff1e6979 100644 --- a/mock-notify/src/app/components/NotificationCard.tsx +++ b/mock-notify/src/app/components/NotificationCard.tsx @@ -4,12 +4,18 @@ import type { StoredNotification } from "~/lib/store" import { useState } from "react" -import { getAvailableResponses, type PayloadTemplateType } from "~/lib/payloads" +import { + getAvailableFollowUps, + getAvailableResponses, + type PayloadTemplateType, +} from "~/lib/payloads" interface NotificationCardProps { notification: StoredNotification + notifications: StoredNotification[] onDelete: () => void onRespond?: (responseType: PayloadTemplateType, prefill: ResponsePrefill) => void + isLatest?: boolean } export interface ResponsePrefill { @@ -21,7 +27,13 @@ export interface ResponsePrefill { targetServiceUrl: string } -export function NotificationCard({ notification, onDelete, onRespond }: NotificationCardProps) { +export function NotificationCard({ + notification, + notifications, + onDelete, + isLatest, + onRespond, +}: NotificationCardProps) { const [isExpanded, setIsExpanded] = useState(false) const types = Array.isArray(notification.payload.type) @@ -30,6 +42,10 @@ export function NotificationCard({ notification, onDelete, onRespond }: Notifica const availableResponses = notification.direction === "received" ? getAvailableResponses(notification.payload) : [] + const availableFollowUps = + notification.direction === "sent" && notification.status === "success" + ? getAvailableFollowUps(notification.payload) + : [] const getTypeColor = (type: string) => { if (type.includes("Offer")) return "bg-blue-100 text-blue-800" @@ -99,25 +115,56 @@ export function NotificationCard({ notification, onDelete, onRespond }: Notifica if (!onRespond) return const payload = notification.payload - const originInbox = payload.origin?.inbox ?? `${payload.origin?.id}/inbox/` - - const prefill: ResponsePrefill = { - targetUrl: originInbox, - templateType: responseType, - inReplyTo: payload.id, - inReplyToObjectUrl: payload.object?.id ?? "", - originUrl: "http://localhost:4001", - targetServiceUrl: payload.origin?.id ?? "", - } - onRespond(responseType, prefill) + if (notification.direction === "sent") { + // Follow-up on a sent notification (e.g., Announce after Accept) + // For Accept follow-ups, look up the original received Offer to get the paper URL + let inReplyToObjectUrl = payload.object?.id ?? "" + if (payload.inReplyTo) { + const originalNotification = notifications.find( + (n) => n.direction === "received" && n.payload.id === payload.inReplyTo + ) + if (originalNotification?.payload.object?.id) { + inReplyToObjectUrl = originalNotification.payload.object.id + } + } + + const prefill: ResponsePrefill = { + targetUrl: notification.targetUrl ?? "", + templateType: responseType, + inReplyTo: payload.inReplyTo ?? payload.id, + inReplyToObjectUrl, + originUrl: payload.origin?.id ?? "http://localhost:4001", + targetServiceUrl: payload.target?.id ?? "", + } + onRespond(responseType, prefill) + } else { + // Response to a received notification + const originInbox = payload.origin?.inbox ?? `${payload.origin?.id}/inbox/` + const prefill: ResponsePrefill = { + targetUrl: originInbox, + templateType: responseType, + inReplyTo: payload.id, + inReplyToObjectUrl: payload.object?.id ?? "", + originUrl: "http://localhost:4001", + targetServiceUrl: payload.origin?.id ?? "", + } + onRespond(responseType, prefill) + } } return ( -
+
+ {isLatest && ( + + Latest + + )} {getDirectionBadge()} {types.map((type) => ( Object:{" "} - {notification.payload.object.id} + + {notification.payload.object.id} +

)} {notification.direction === "sent" && notification.targetUrl && (

- To: {notification.targetUrl} + To:{" "} + + {notification.targetUrl} +

)} {notification.direction === "received" && notification.payload.origin && (

From:{" "} - {notification.payload.origin.id} + + {notification.payload.origin.id} +

)} @@ -177,6 +246,23 @@ export function NotificationCard({ notification, onDelete, onRespond }: Notifica ))}
)} + + {/* Follow-up buttons for sent notifications */} + {availableFollowUps.length > 0 && onRespond && ( +
+ Follow up: + {availableFollowUps.map((followUpType) => ( + + ))} +
+ )}
diff --git a/mock-notify/src/app/components/SendNotificationForm.tsx b/mock-notify/src/app/components/SendNotificationForm.tsx index 3435342f24..58639f0a7f 100644 --- a/mock-notify/src/app/components/SendNotificationForm.tsx +++ b/mock-notify/src/app/components/SendNotificationForm.tsx @@ -41,7 +41,8 @@ const TEMPLATE_OPTIONS: PayloadTemplateType[] = [ export function SendNotificationForm({ onSent, prefill }: SendNotificationFormProps) { const [mode, setMode] = useState("template") const [targetUrl, setTargetUrl] = useState( - prefill?.targetUrl ?? "http://localhost:3000/api/v0/c/coar-notify/site/webhook/coar-inbox" + prefill?.targetUrl ?? + "http://localhost:3000/api/v0/c/coar-us2-unjournal/site/webhook/coar-inbox" ) const [templateType, setTemplateType] = useState( prefill?.templateType ?? "Offer Review" @@ -57,7 +58,7 @@ export function SendNotificationForm({ onSent, prefill }: SendNotificationFormPr ) const [objectCiteAs, setObjectCiteAs] = useState("") const [objectItemUrl, setObjectItemUrl] = useState("") - const [reviewUrl, setReviewUrl] = useState("http://localhost:4001/review/review-001") + const [reviewUrl, setReviewUrl] = useState("http://localhost:4001/reviews/sample-review") const [originUrl, setOriginUrl] = useState(prefill?.originUrl ?? "http://localhost:4001") const [targetServiceUrl, setTargetServiceUrl] = useState( prefill?.targetServiceUrl ?? "http://localhost:3000" @@ -65,6 +66,49 @@ export function SendNotificationForm({ onSent, prefill }: SendNotificationFormPr const [serviceName, setServiceName] = useState("Mock Review Service") const [inReplyTo, setInReplyTo] = useState(prefill?.inReplyTo ?? "") const [inReplyToUrl, setInReplyToUrl] = useState(prefill?.inReplyToObjectUrl ?? "") + const [workUrl, setWorkUrl] = useState( + "http://localhost:3000/c/coar-us4-repository/pub/{pubId}" + ) + + // Default target URLs per template type for demo convenience + const TEMPLATE_DEFAULTS: Record< + PayloadTemplateType, + { targetUrl: string; targetServiceUrl: string } + > = { + "Offer Review": { + targetUrl: "http://localhost:3000/api/v0/c/coar-us2-unjournal/site/webhook/coar-inbox", + targetServiceUrl: "http://localhost:3000/c/coar-us2-unjournal", + }, + "Announce Review": { + targetUrl: "http://localhost:3000/api/v0/c/coar-us1-arcadia/site/webhook/coar-inbox", + targetServiceUrl: "http://localhost:3000/c/coar-us1-arcadia", + }, + "Offer Ingest": { + targetUrl: "http://localhost:3000/api/v0/c/coar-us4-repository/site/webhook/coar-inbox", + targetServiceUrl: "http://localhost:3000/c/coar-us4-repository", + }, + "Announce Ingest": { + targetUrl: "http://localhost:3000/api/v0/c/coar-us4-repository/site/webhook/coar-inbox", + targetServiceUrl: "http://localhost:3000/c/coar-us4-repository", + }, + Accept: { + targetUrl: "http://localhost:3000/api/v0/c/coar-us1-arcadia/site/webhook/coar-inbox", + targetServiceUrl: "http://localhost:3000/c/coar-us1-arcadia", + }, + Reject: { + targetUrl: "http://localhost:3000/api/v0/c/coar-us1-arcadia/site/webhook/coar-inbox", + targetServiceUrl: "http://localhost:3000/c/coar-us1-arcadia", + }, + } + + const handleTemplateChange = (newType: PayloadTemplateType) => { + setTemplateType(newType) + if (!prefill) { + const defaults = TEMPLATE_DEFAULTS[newType] + setTargetUrl(defaults.targetUrl) + setTargetServiceUrl(defaults.targetServiceUrl) + } + } const generatePayload = (): CoarNotifyPayload => { switch (templateType) { @@ -95,6 +139,7 @@ export function SendNotificationForm({ onSent, prefill }: SendNotificationFormPr reviewUrl, originUrl, targetUrl: targetServiceUrl, + workUrl: workUrl || undefined, }) case "Accept": return createAcceptPayload({ @@ -237,13 +282,6 @@ export function SendNotificationForm({ onSent, prefill }: SendNotificationFormPr className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" /> - setReviewUrl(e.target.value)} - placeholder="http://localhost:4001/review/..." - className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" - />