From 87789a0de9f0c285904604926cedfe68af0b3c41 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 15 Jun 2026 04:21:32 +0500 Subject: [PATCH] feat(content): fix metric rollup trigger, add SEO fields, reconcile flow cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - publication_rollup fired on record-after-update with a previous.* condition, but metric snapshots are append-only (record_metric_snapshot inserts a new row each period), so the rollup never ran on the real recording path — publication/piece totals only matched in seed data. Switch the trigger to record-after-create and drop the update-only condition so the ROI and editorial-calendar dashboards stay correct once a real snapshot is recorded. - Add the SEO levers the growth/SEO persona actually tracks before publishing: content_piece.seo_title, meta_description, primary_keyword (en + zh-CN). - CHARTER: raise the flow cap 4→5 to match the five distinct event-driven flows that already ship (the package was over its own stated cap); note the lifecycle-notifications rename. Deferred (documented, not changed): hardening the in_review→approved state transition against manual bypass needs a role-aware field readonly predicate that no template/spec surface in this repo supports yet; forcing it via the state machine would break the approval flow's own set_approved step. typecheck + objectstack build + format:check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/content/CHARTER.md | 2 +- .../src/flows/publication_rollup.flow.ts | 17 ++++++++------- .../src/objects/content_piece.object.ts | 21 +++++++++++++++++++ packages/content/src/translations/en.ts | 3 +++ packages/content/src/translations/zh-CN.ts | 3 +++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/content/CHARTER.md b/packages/content/CHARTER.md index 3486143..7a7e6d8 100644 --- a/packages/content/CHARTER.md +++ b/packages/content/CHARTER.md @@ -65,7 +65,7 @@ These exist so the template stays a **template**, not a half-finished product: | Total `.ts` LOC under `src/` | ≤ 6,000 | Readable in a half-day sitting | | Locales | 2 (`en`, `zh-CN`) | Proves multi-locale without becoming a translation project | | Dashboards | 3 | Today / Calendar / ROI — each shows a distinct widget class | -| Flows | ≤ 4 | Event-driven only (no cron in spec v6); time-based work ships as record actions | +| Flows | ≤ 5 | Event-driven only (no cron in spec v6); time-based work ships as record actions. Cap raised 4→5 (2026-06): the package already ships five distinct event-driven flows — signal→topic promotion, CTA default, publish approval, publication rollup, and lifecycle notifications (the latter was renamed from `stamp_lifecycle_timestamps` once timestamp-stamping moved into the piece hook). | | State machines | 2 | piece + signal | | Approval processes | 1 | publish gate | | Sharing rules | 1 | topic-scoped | diff --git a/packages/content/src/flows/publication_rollup.flow.ts b/packages/content/src/flows/publication_rollup.flow.ts index 95cb1ad..6b2eaa8 100644 --- a/packages/content/src/flows/publication_rollup.flow.ts +++ b/packages/content/src/flows/publication_rollup.flow.ts @@ -9,9 +9,14 @@ type Flow = Automation.Flow; * the parent `content_piece` so its dashboard/widget filters don't have * to join. * - * Triggered on metric insert/update. The flow reads all metric rows for - * the snapshot's publication, sums them, then writes back denormalised - * totals on both the publication and (via a second step) the piece. + * Triggered when a metric snapshot is **created** — metric rows are + * append-only weekly snapshots (see `record_metric_snapshot.action.ts`, + * which inserts a new row each period), so the rollup must fire on insert. + * (The earlier `record-after-update` trigger never fired on the real + * recording path, so publication/piece totals only matched in seed data.) + * The flow reads all metric rows for the snapshot's publication, sums them, + * then writes back denormalised totals on both the publication and (via a + * second step) the piece. * * Why denormalised: the analytics service in spec 5.2 doesn't allow * filtered SUM(child.*) from a parent list view. Storing totals on the @@ -22,7 +27,7 @@ export const PublicationRollupFlow: Flow = { name: 'content_publication_rollup', label: 'Roll Up Metrics to Publication & Piece', description: - 'On metric snapshot insert/update, refresh the publication.total_* and piece.total_* denormalised fields.', + 'On metric snapshot create, refresh the publication.total_* and piece.total_* denormalised fields.', type: 'record_change', variables: [{ name: 'metricId', type: 'text', isInput: true, isOutput: false }], @@ -34,9 +39,7 @@ export const PublicationRollupFlow: Flow = { label: 'Start', config: { objectName: 'content_metric', - triggerType: 'record-after-update', - condition: - 'previous.views != record.views || previous.clicks != record.clicks || previous.signups != record.signups || previous.revenue != record.revenue', + triggerType: 'record-after-create', }, }, { diff --git a/packages/content/src/objects/content_piece.object.ts b/packages/content/src/objects/content_piece.object.ts index 1161357..d2ddf5b 100644 --- a/packages/content/src/objects/content_piece.object.ts +++ b/packages/content/src/objects/content_piece.object.ts @@ -133,6 +133,27 @@ export const Piece = ObjectSchema.create({ group: 'body', }), + // SEO — the pre-publish checklist these teams actually track. + seo_title: Field.text({ + label: 'SEO Title', + maxLength: 70, + group: 'body', + description: 'Title tag (≤ 60–70 chars). Falls back to the piece title if blank.', + }), + meta_description: Field.text({ + label: 'Meta Description', + maxLength: 160, + group: 'body', + description: 'Search-result snippet (≤ 160 chars).', + }), + primary_keyword: Field.text({ + label: 'Primary Keyword', + maxLength: 120, + searchable: true, + group: 'body', + description: 'The target query this piece is optimised to rank for.', + }), + // Lifecycle stamps submitted_at: Field.datetime({ label: 'Submitted At', readonly: true, group: 'planning' }), approved_at: Field.datetime({ label: 'Approved At', readonly: true, group: 'planning' }), diff --git a/packages/content/src/translations/en.ts b/packages/content/src/translations/en.ts index 25904cc..668aa11 100644 --- a/packages/content/src/translations/en.ts +++ b/packages/content/src/translations/en.ts @@ -229,6 +229,9 @@ export const en: TranslationData = { summary: { label: 'Summary' }, body_outline: { label: 'Outline' }, body_draft: { label: 'Draft' }, + seo_title: { label: 'SEO Title' }, + meta_description: { label: 'Meta Description' }, + primary_keyword: { label: 'Primary Keyword' }, submitted_at: { label: 'Submitted At' }, approved_at: { label: 'Approved At' }, published_at: { label: 'Published At' }, diff --git a/packages/content/src/translations/zh-CN.ts b/packages/content/src/translations/zh-CN.ts index 9db5637..00fec4e 100644 --- a/packages/content/src/translations/zh-CN.ts +++ b/packages/content/src/translations/zh-CN.ts @@ -215,6 +215,9 @@ export const zhCN: TranslationData = { summary: { label: '摘要' }, body_outline: { label: '大纲' }, body_draft: { label: '草稿' }, + seo_title: { label: 'SEO 标题' }, + meta_description: { label: 'Meta 描述' }, + primary_keyword: { label: '主关键词' }, submitted_at: { label: '提交时间' }, approved_at: { label: '通过时间' }, published_at: { label: '发布时间' },