@anthusai/papyrus 2.0.0-next.43 → 2.0.0-next.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,7 @@ from papyrus_content.story_reporting import save_story_version
27
27
  from papyrus_content.publication import ensure_publication_from_environment
28
28
  from papyrus_content.editorial_checks import GraphQLEditorialStore, ensure_default_policies
29
29
  from papyrus_content.reference_intake import IntakeError, intake_references
30
+ from papyrus_content.publish_alerts import PUBLISHED_TASK_KEY, announce_published
30
31
  from papyrus_content.publishing import (
31
32
  ItemFields,
32
33
  PublishError,
@@ -119,11 +120,17 @@ def projection_context(client):
119
120
  return context
120
121
 
121
122
 
122
- def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
123
+ def project_item_action(client, item_id: str, *, unpublished: bool, published_item_id: str | None = None) -> dict:
124
+ """Project one item. ``published_item_id`` is passed only for a real publish: the alert is announced once the page
125
+ is live, here when the projection succeeds, or by the scheduled retry (which carries the same marker) when it did not."""
123
126
  if not projection_configured():
127
+ if published_item_id:
128
+ announce_published(item_id, published_item_id)
124
129
  return {}
125
130
  liveness = {} if unpublished else {"live": False}
126
131
  task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
132
+ if published_item_id:
133
+ task[PUBLISHED_TASK_KEY] = {"publishedItemId": published_item_id}
127
134
  try:
128
135
  report = project(projection_context(client), item_id, unpublished=unpublished)
129
136
  except Exception as error: # noqa: BLE001 - a projection failure never fails the save
@@ -145,6 +152,8 @@ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
145
152
  schedule_unpurged_invalidation(report.unpurged)
146
153
  if report.follow_up_item_pages:
147
154
  schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
155
+ if report.ok and published_item_id:
156
+ announce_published(item_id, published_item_id)
148
157
  return response
149
158
 
150
159
 
@@ -201,6 +210,10 @@ def run_projection_task(task: dict) -> dict:
201
210
  schedule_unpurged_invalidation(report.unpurged)
202
211
  if not report.ok:
203
212
  raise ProjectionTaskFailed("; ".join(report.errors))
213
+ published = task.get(PUBLISHED_TASK_KEY)
214
+ if task.get("task") == "item" and not task.get("unpublished") and isinstance(published, dict):
215
+ # A real publish (the CLI marks it, as does the newsroom's retry): the page is live, so tell the newsletter.
216
+ announce_published(task["itemId"], published.get("publishedItemId"))
204
217
  return {"ok": True, "paths": [*report.written, *report.deleted]}
205
218
 
206
219
 
@@ -286,7 +299,9 @@ def publish_item_action(arguments: dict, actor: str) -> dict:
286
299
  }
287
300
  if result.changed:
288
301
  response.update(site_triggers_for(client, arguments["id"]))
289
- response.update(project_item_action(client, arguments["id"], unpublished=False))
302
+ response.update(
303
+ project_item_action(client, arguments["id"], unpublished=False, published_item_id=result.published_item_id if result.changed else None)
304
+ )
290
305
  return response
291
306
 
292
307
 
@@ -15,9 +15,11 @@ import { pythonLambdaCode } from "../../functions/shared/python-bundle.js";
15
15
  import { resolveNewsletterConfig } from "./config.js";
16
16
  import { newsletterOps } from "./ops/resource.js";
17
17
  import { NEWSLETTER_MUTATIONS, NEWSLETTER_QUERIES, newsletterSchemaDefinitions } from "./schema.js";
18
- import { newsletterFunctionName, wireSending } from "./sending.js";
18
+ import { functionArn, newsletterFunctionName, wireSending } from "./sending.js";
19
19
  export { resolveNewsletterConfig };
20
20
  export const NEWSLETTER_OUTPUT_KEY = "newsletter";
21
+ /** Set on the site's content-actions function: it invokes this function (`item-published`) after a real publish. Unset: no alerts. */
22
+ export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
21
23
  export const NEWSLETTER_HANDLER = "papyrus_content.newsletter.handler.handler";
22
24
  /** `boto3` is bundled because the runtime's copy may predate SESv2 `Simple.Headers` (List-Unsubscribe). */
23
25
  export const NEWSLETTER_REQUIREMENTS = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "boto3>=1.35.8,<2"];
@@ -70,13 +72,16 @@ export function newsletterEnvironment(config, tableName, keyParameter) {
70
72
  FROM_NAME: config.fromName,
71
73
  CONFIGURATION_SET: config.configurationSet,
72
74
  ITEM_URL_STYLE: config.itemUrlStyle,
75
+ INSTANT_ALERTS: config.instantAlerts ? "true" : "false",
73
76
  LISTS_JSON: JSON.stringify(config.lists.map((list) => ({
74
77
  id: list.id,
75
78
  name: list.name,
76
79
  description: list.description,
77
80
  kind: list.kind,
78
- // A roundup's issue type, archive section and time zone are what drafting needs; schedules and rules stay in the stack.
81
+ // A roundup's issue type, archive section and time zone are what drafting needs; its schedule stays in the stack.
79
82
  ...(list.kind === "roundup" ? { issueType: list.issueType, archivePath: list.archivePath, timezone: list.schedule.timezone } : {}),
83
+ // An alert's rule and approval decide which published items raise an alert and whether an admin approves it.
84
+ ...(list.kind === "alert" ? { rule: list.rule, approval: list.approval } : {}),
80
85
  }))),
81
86
  THEME_JSON: JSON.stringify(config.theme),
82
87
  ALARM_NAMES: Object.values(newsletterAlarmNames(config.siteId)).join(","),
@@ -229,6 +234,15 @@ export const newsletterPlugin = {
229
234
  });
230
235
  alarm.addAlarmAction(new SnsAction(alarmTopic));
231
236
  }
237
+ // The publish path tells this function about a real publish. The ARN is spelled out (not referenced) so the content-actions
238
+ // stack does not depend on this one, and the grant is for this one function only.
239
+ const contentActions = context.contentActionsLambda;
240
+ contentActions.addEnvironment(NEWSLETTER_FUNCTION_ENV, newsletterFunctionName(config.siteId));
241
+ contentActions.addToRolePolicy(new PolicyStatement({
242
+ effect: Effect.ALLOW,
243
+ actions: ["lambda:InvokeFunction"],
244
+ resources: [functionArn(Stack.of(contentActions), newsletterFunctionName(config.siteId))],
245
+ }));
232
246
  lambda.addPermission("InvokeFromCmsCompute", {
233
247
  principal: new ArnPrincipal(config.computeRoleArn),
234
248
  action: "lambda:InvokeFunction",
@@ -16,12 +16,14 @@ import type { PapyrusPlugin, PluginManifest, PluginWiringContext } from "../type
16
16
  import { resolveNewsletterConfig, type NewsletterConfig } from "./config";
17
17
  import { newsletterOps } from "./ops/resource";
18
18
  import { NEWSLETTER_MUTATIONS, NEWSLETTER_QUERIES, newsletterSchemaDefinitions } from "./schema";
19
- import { newsletterFunctionName, wireSending } from "./sending";
19
+ import { functionArn, newsletterFunctionName, wireSending } from "./sending";
20
20
 
21
21
  export { resolveNewsletterConfig };
22
22
  export type { NewsletterConfig };
23
23
 
24
24
  export const NEWSLETTER_OUTPUT_KEY = "newsletter";
25
+ /** Set on the site's content-actions function: it invokes this function (`item-published`) after a real publish. Unset: no alerts. */
26
+ export const NEWSLETTER_FUNCTION_ENV = "PAPYRUS_NEWSLETTER_FUNCTION";
25
27
  export const NEWSLETTER_HANDLER = "papyrus_content.newsletter.handler.handler";
26
28
  /** `boto3` is bundled because the runtime's copy may predate SESv2 `Simple.Headers` (List-Unsubscribe). */
27
29
  export const NEWSLETTER_REQUIREMENTS = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.7.0", "Pillow>=11.3,<12", "boto3>=1.35.8,<2"];
@@ -83,14 +85,17 @@ export function newsletterEnvironment(config: NewsletterConfig, tableName: strin
83
85
  FROM_NAME: config.fromName,
84
86
  CONFIGURATION_SET: config.configurationSet,
85
87
  ITEM_URL_STYLE: config.itemUrlStyle,
88
+ INSTANT_ALERTS: config.instantAlerts ? "true" : "false",
86
89
  LISTS_JSON: JSON.stringify(
87
90
  config.lists.map((list) => ({
88
91
  id: list.id,
89
92
  name: list.name,
90
93
  description: list.description,
91
94
  kind: list.kind,
92
- // A roundup's issue type, archive section and time zone are what drafting needs; schedules and rules stay in the stack.
95
+ // A roundup's issue type, archive section and time zone are what drafting needs; its schedule stays in the stack.
93
96
  ...(list.kind === "roundup" ? { issueType: list.issueType, archivePath: list.archivePath, timezone: list.schedule.timezone } : {}),
97
+ // An alert's rule and approval decide which published items raise an alert and whether an admin approves it.
98
+ ...(list.kind === "alert" ? { rule: list.rule, approval: list.approval } : {}),
94
99
  })),
95
100
  ),
96
101
  THEME_JSON: JSON.stringify(config.theme),
@@ -268,6 +273,18 @@ export const newsletterPlugin: PapyrusPlugin<NewsletterConfig, { newsletterOps:
268
273
  alarm.addAlarmAction(new SnsAction(alarmTopic));
269
274
  }
270
275
 
276
+ // The publish path tells this function about a real publish. The ARN is spelled out (not referenced) so the content-actions
277
+ // stack does not depend on this one, and the grant is for this one function only.
278
+ const contentActions = context.contentActionsLambda;
279
+ contentActions.addEnvironment(NEWSLETTER_FUNCTION_ENV, newsletterFunctionName(config.siteId));
280
+ contentActions.addToRolePolicy(
281
+ new PolicyStatement({
282
+ effect: Effect.ALLOW,
283
+ actions: ["lambda:InvokeFunction"],
284
+ resources: [functionArn(Stack.of(contentActions), newsletterFunctionName(config.siteId))],
285
+ }),
286
+ );
287
+
271
288
  lambda.addPermission("InvokeFromCmsCompute", {
272
289
  principal: new ArnPrincipal(config.computeRoleArn),
273
290
  action: "lambda:InvokeFunction",
@@ -13,9 +13,10 @@ export const SCHEDULE_NAME_PREFIX = "papyrus-nl-";
13
13
  export const newsletterFunctionName = (siteId) => `papyrus-newsletter-${siteId}`;
14
14
  export const newsletterWorkerName = (siteId) => `papyrus-newsletter-send-${siteId}`;
15
15
  export const newsletterSendQueueName = (siteId) => `papyrus-newsletter-send-${siteId}`;
16
- /** The AppSync fields the `newsletter` function reads and writes through the content API (the roundup draft and the issue's Item). */
16
+ /** The AppSync fields the `newsletter` function reads and writes through the content API (the roundup draft, the issue's Item, and the published item an alert is about). */
17
17
  export const NEWSLETTER_APPSYNC_FIELDS = [
18
18
  "Query/fields/getItem",
19
+ "Query/fields/getPublishedItem",
19
20
  "Query/fields/listPublishedItems",
20
21
  "Mutation/fields/createItem",
21
22
  "Mutation/fields/updateItem",
@@ -33,7 +34,7 @@ export function functionArn(stack, name) {
33
34
  /**
34
35
  * Everything that sends: the queue and its dead letter queue, the send worker, the scheduler role and the per-list
35
36
  * draft schedules, and the IAM they need. Nothing here lets the function reach anything but its own queue, its own
36
- * schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and four content API fields.
37
+ * schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and five content API fields.
37
38
  */
38
39
  export function wireSending(inputs) {
39
40
  const { stack, config, context, backend, table, lambda, environment, keyParameterArn, sesResources, code } = inputs;
@@ -19,9 +19,10 @@ export const newsletterFunctionName = (siteId: string) => `papyrus-newsletter-${
19
19
  export const newsletterWorkerName = (siteId: string) => `papyrus-newsletter-send-${siteId}`;
20
20
  export const newsletterSendQueueName = (siteId: string) => `papyrus-newsletter-send-${siteId}`;
21
21
 
22
- /** The AppSync fields the `newsletter` function reads and writes through the content API (the roundup draft and the issue's Item). */
22
+ /** The AppSync fields the `newsletter` function reads and writes through the content API (the roundup draft, the issue's Item, and the published item an alert is about). */
23
23
  export const NEWSLETTER_APPSYNC_FIELDS = [
24
24
  "Query/fields/getItem",
25
+ "Query/fields/getPublishedItem",
25
26
  "Query/fields/listPublishedItems",
26
27
  "Mutation/fields/createItem",
27
28
  "Mutation/fields/updateItem",
@@ -58,7 +59,7 @@ type SendingInputs = {
58
59
  /**
59
60
  * Everything that sends: the queue and its dead letter queue, the send worker, the scheduler role and the per-list
60
61
  * draft schedules, and the IAM they need. Nothing here lets the function reach anything but its own queue, its own
61
- * schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and four content API fields.
62
+ * schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and five content API fields.
62
63
  */
63
64
  export function wireSending(inputs: SendingInputs): { queue: Queue; deadLetterQueue: Queue; worker: LambdaFunction; schedulerRole: Role } {
64
65
  const { stack, config, context, backend, table, lambda, environment, keyParameterArn, sesResources, code } = inputs;
package/package.json CHANGED
@@ -428,7 +428,7 @@
428
428
  "optional": true
429
429
  }
430
430
  },
431
- "version": "2.0.0-next.43",
431
+ "version": "2.0.0-next.44",
432
432
  "dependencies": {
433
433
  "@aws-amplify/adapter-nextjs": "^1.7.3",
434
434
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0-next.43",
2
+ "version": "2.0.0-next.44",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",