Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ These are hard rules. Do not work around them — flag and stop instead.
- **Guest Contributors:** still open. Ship as an additive optional `contributorType: 'staff' | 'guest'` field on the existing `authors` collection — do **not** create a separate collection.
- **Knowledge Hub:** `knowledgeBase` (versioned + drafts, slug-change-redirect hook, SEO field group) + `knowledgeCategories` (hierarchical with self-referencing `parent`).
- **Case studies:** listing **plus** a detail route at `/case-studies/[slug]` (reversed 2026-09-21; the collection was listing-only before). `summary` is the card blurb, `body` (Lexical) is the article. `outcomes` and `glance` are optional arrays the detail page reshapes around; `quote`/`quoteAuthor`/`quoteRole` are per-study and are **not** read from the homepage testimonial list; `featured` picks the listing spotlight, falling back to newest. Carries the full routed-collection machinery (slug-change redirect, SEO group, IndexNow, search sync) — adding a detail route without those breaks indexed URLs on rename.
- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`.
- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`. Advisories use the same Ladder and bottom bar through `getAdvisoryCtas` in `lib/blog-cta/resolve.ts`, and carry the same two editor picks (`ctaResource`, `ctaImage`, migration `20261102_120000_advisories_cta_picks`). On advisories the image pick is a searchable picker (`CatalogImagePicker`, fed by `GET /api/catalog-images`) instead of a typed name; the automatic-pick hint under both fields works for either collection (`/api/blog-cta-suggestion?collection=advisories`). Unset, the advisory's `technology` label names the catalog image first (`matchImageHint`), then the text decides. Click events are `advisory_ladder_rail_<stage>`; the blog's `blog_ladder_*` names are unchanged.
- **Videos in articles:** the rich-text editor has a `videoEmbed` block (`apps/cms/src/payload/blocks/VideoEmbedBlock.ts`, inserted from the slash and Add menus) that stores only a relationship to a published `videos` doc plus an optional caption. Pages call `resolveLexicalVideos` (`apps/web/src/lib/lexical-videos.ts`) after `highlightLexical`, because the blog body renders inside a client component and cannot fetch; it writes `fields._video` on each block and removes a block whose video is gone, private or unpublished. The renderer draws it as `ArticleVideo` (poster first, YouTube only after a click). Wired on blog, guide, advisory, news, resource and knowledge hub pages; any other `RenderLexical` page renders the block as nothing until it adds the pass. A video edit does not purge articles: they refresh on their normal revalidate window.
- **Integrations (Phase J1):** `config` field encrypted at rest via `lib/integrations/secrets.ts`. Per-row `routing` group (`events[]`, `collections[]`, `formSlugs[]`, `minLeadScore`). Admin endpoints: `/api/integrations/:id/test`, `/health`, `/audit` (file: `payload/endpoints/integrations-actions.ts`). Dead-letter retry reuses `WebhookDeadLetter`. Router: `lib/integrations/router.ts`.

---
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ These are hard rules. Do not work around them — flag and stop instead.
- **Guest Contributors:** still open. Ship as an additive optional `contributorType: 'staff' | 'guest'` field on the existing `authors` collection — do **not** create a separate collection.
- **Knowledge Hub:** `knowledgeBase` (versioned + drafts, slug-change-redirect hook, SEO field group) + `knowledgeCategories` (hierarchical with self-referencing `parent`).
- **Case studies:** listing **plus** a detail route at `/case-studies/[slug]` (reversed 2026-09-21; the collection was listing-only before). `summary` is the card blurb, `body` (Lexical) is the article. `outcomes` and `glance` are optional arrays the detail page reshapes around; `quote`/`quoteAuthor`/`quoteRole` are per-study and are **not** read from the homepage testimonial list; `featured` picks the listing spotlight, falling back to newest. Carries the full routed-collection machinery (slug-change redirect, SEO group, IndexNow, search sync) — adding a detail route without those breaks indexed URLs on rename.
- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`.
- **Blog sidebar CTAs:** `blogs` carries two optional editor picks, `ctaResource` (relationship to published `resources`, the Learn step) and `ctaImage` (a catalog image slug, the Explore step). Both are overrides: unset, `apps/web/src/lib/blog-cta/` matches from the post's title, abstract, category and headings. A pick that is unpublished or not in the catalog is ignored, never linked. The POC step always points at `/book-a-demo` with `source` params. The admin labels them "Sidebar resource (Learn step)" and "Images portal entry (Explore step)" (not "image", which reads as a picture) and shows the automatic pick under each (`BlogCtaAutoPick`, via the CMS endpoint `GET /api/blog-cta-suggestion?id=`, which asks the web route of the same name using `WEB_REVALIDATE_URL` and `WEB_REVALIDATE_SECRET`); the scoring lives in apps/web only, so do not copy it into the CMS. The other layouts that were reviewed, and how to revive the Edge Dock (kept under the tag `design/blog-cta-edge-dock`), are in `docs/web/BLOG-CTA-VARIANTS.md`. Advisories use the same Ladder and bottom bar through `getAdvisoryCtas` in `lib/blog-cta/resolve.ts`, and carry the same two editor picks (`ctaResource`, `ctaImage`, migration `20261102_120000_advisories_cta_picks`). On advisories the image pick is a searchable picker (`CatalogImagePicker`, fed by `GET /api/catalog-images`) instead of a typed name; the automatic-pick hint under both fields works for either collection (`/api/blog-cta-suggestion?collection=advisories`). Unset, the advisory's `technology` label names the catalog image first (`matchImageHint`), then the text decides. Click events are `advisory_ladder_rail_<stage>`; the blog's `blog_ladder_*` names are unchanged.
- **Videos in articles:** the rich-text editor has a `videoEmbed` block (`apps/cms/src/payload/blocks/VideoEmbedBlock.ts`, inserted from the slash and Add menus) that stores only a relationship to a published `videos` doc plus an optional caption. Pages call `resolveLexicalVideos` (`apps/web/src/lib/lexical-videos.ts`) after `highlightLexical`, because the blog body renders inside a client component and cannot fetch; it writes `fields._video` on each block and removes a block whose video is gone, private or unpublished. The renderer draws it as `ArticleVideo` (poster first, YouTube only after a click). Wired on blog, guide, advisory, news, resource and knowledge hub pages; any other `RenderLexical` page renders the block as nothing until it adds the pass. A video edit does not purge articles: they refresh on their normal revalidate window.
- **Integrations (Phase J1):** `config` field encrypted at rest via `lib/integrations/secrets.ts`. Per-row `routing` group (`events[]`, `collections[]`, `formSlugs[]`, `minLeadScore`). Admin endpoints: `/api/integrations/:id/test`, `/health`, `/audit` (file: `payload/endpoints/integrations-actions.ts`). Dead-letter retry reuses `WebhookDeadLetter`. Router: `lib/integrations/router.ts`.

---
Expand Down
2 changes: 2 additions & 0 deletions apps/cms/src/app/(payload)/admin/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions apps/cms/src/migrations/20261102_120000_advisories_cta_picks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-postgres'

/**
* Advisories gain the two optional editor picks blogs already have, for the
* sidebar CTAs on the advisory page: `cta_resource_id` (the resource offered
* in the Learn step) and `cta_image` (the catalog image offered in the Explore
* step). Both are overrides; the web app falls back to matching from the
* advisory when unset.
*
* Mirrored on `_advisories_v` because advisories are versioned with drafts.
* Names follow `20261005_090000_add_blog_cta_picks.ts`, which follows what
* Payload itself generated for the same shape on blogs.
*
* Hand-written rather than generated, for the reason spelled out in
* `20260921_100000_add_case_study_detail_fields.ts`: the newest `.json` schema
* snapshot is from June, so `payload migrate:create` would emit every schema
* change made since.
*
* Idempotent throughout: local databases run `push: true` and already have
* these columns.
*/

const UP = [
sql`ALTER TABLE "advisories" ADD COLUMN IF NOT EXISTS "cta_resource_id" integer;`,
sql`ALTER TABLE "advisories" ADD COLUMN IF NOT EXISTS "cta_image" varchar;`,
sql`ALTER TABLE "_advisories_v" ADD COLUMN IF NOT EXISTS "version_cta_resource_id" integer;`,
sql`ALTER TABLE "_advisories_v" ADD COLUMN IF NOT EXISTS "version_cta_image" varchar;`,

sql`DO $$ BEGIN ALTER TABLE "advisories" ADD CONSTRAINT "advisories_cta_resource_id_resources_id_fk" FOREIGN KEY ("cta_resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;`,
sql`DO $$ BEGIN ALTER TABLE "_advisories_v" ADD CONSTRAINT "_advisories_v_version_cta_resource_id_resources_id_fk" FOREIGN KEY ("version_cta_resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;`,

sql`CREATE INDEX IF NOT EXISTS "advisories_cta_resource_idx" ON "advisories" USING btree ("cta_resource_id");`,
sql`CREATE INDEX IF NOT EXISTS "_advisories_v_version_version_cta_resource_idx" ON "_advisories_v" USING btree ("version_cta_resource_id");`,
]

export async function up({ db }: MigrateUpArgs): Promise<void> {
for (const statement of UP) {
await db.execute(statement)
}
}

export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`ALTER TABLE "advisories" DROP COLUMN IF EXISTS "cta_resource_id";`)
await db.execute(sql`ALTER TABLE "advisories" DROP COLUMN IF EXISTS "cta_image";`)
await db.execute(sql`ALTER TABLE "_advisories_v" DROP COLUMN IF EXISTS "version_cta_resource_id";`)
await db.execute(sql`ALTER TABLE "_advisories_v" DROP COLUMN IF EXISTS "version_cta_image";`)
}
6 changes: 6 additions & 0 deletions apps/cms/src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import * as migration_20261027_120000_advisories_structured_fields from './20261
import * as migration_20261028_120000_advisories_cover_title from './20261028_120000_advisories_cover_title';
import * as migration_20261026_120000_add_missing_job_log_task_slugs from './20261026_120000_add_missing_job_log_task_slugs';
import * as migration_20261031_100000_add_videos from './20261031_100000_add_videos';
import * as migration_20261102_120000_advisories_cta_picks from './20261102_120000_advisories_cta_picks';

export const migrations = [
{
Expand Down Expand Up @@ -389,4 +390,9 @@ export const migrations = [
down: migration_20261031_100000_add_videos.down,
name: '20261031_100000_add_videos',
},
{
up: migration_20261102_120000_advisories_cta_picks.up,
down: migration_20261102_120000_advisories_cta_picks.down,
name: '20261102_120000_advisories_cta_picks',
},
];
7 changes: 7 additions & 0 deletions apps/cms/src/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,11 @@ export interface Advisory {
};
[k: string]: unknown;
} | null;
/**
* Optional. The resource offered in the "Learn" step of the advisory sidebar. If unset, the page picks the published resource most related to this advisory, which is a keyword match, so choose one here when none of the automatic picks fits.
*/
ctaResource?: (number | null) | Resource;
ctaImage?: string | null;
heroImage?: (number | null) | Media;
faqs?:
| {
Expand Down Expand Up @@ -10423,6 +10428,8 @@ export interface AdvisoriesSelect<T extends boolean = true> {
body?: T;
impact?: T;
resolution?: T;
ctaResource?: T;
ctaImage?: T;
heroImage?: T;
faqs?:
| T
Expand Down
2 changes: 2 additions & 0 deletions apps/cms/src/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { SearchLog } from './payload/collections/SearchLog';
import { Users } from './payload/collections/Users';
import { Webinars } from './payload/collections/Webinars';
import { blogCtaSuggestionEndpoint } from './payload/endpoints/blog-cta-suggestion';
import { catalogImagesEndpoint } from './payload/endpoints/catalog-images';
import { cachePurgeEndpoint } from './payload/endpoints/cache-purge';
import { cacheSearchEndpoint } from './payload/endpoints/cache-search';
import { canonicalCheckEndpoint } from './payload/endpoints/canonical-check';
Expand Down Expand Up @@ -488,6 +489,7 @@ export default buildConfig({
canonicalCheckEndpoint,
cachePurgeEndpoint,
blogCtaSuggestionEndpoint,
catalogImagesEndpoint,
cacheSearchEndpoint,
searchQueryEndpoint,
searchAnalyticsEndpoint,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ const ICONS = {
<path {...stroke} d="M5.5 6.5L3 8l2.5 1.5M10.5 6.5L13 8l-2.5 1.5M7.5 10l1-4" />
</svg>
),
video: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="3" width="14" height="10" rx="1.5" {...stroke} />
<path {...stroke} d="M6.5 6l3.5 2-3.5 2z" />
</svg>
),
code: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path {...stroke} d="M6 5L3 8l3 3M10 5l3 3-3 3" />
Expand Down Expand Up @@ -174,6 +180,18 @@ const MENU_ITEMS: ReadonlyArray<MenuItem> = [
icon: ICONS.image,
run: (editor) => editor.dispatchCommand(OPEN_INLINE_IMAGE_DIALOG_COMMAND, undefined),
},
{
id: 'video',
label: 'Video',
icon: ICONS.video,
run: (editor) =>
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
blockName: '',
blockType: 'videoEmbed',
video: null,
caption: '',
}),
},
{
id: 'embed',
label: 'Embed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const html = (el: ReturnType<typeof AutoPickNote>) => renderToStaticMarkup(el);
describe('AutoPickNote', () => {
it('asks to save a new post first', () => {
expect(html(<AutoPickNote kind="resource" state={{ phase: 'unsaved' }} overridden={false} />)).toContain(
'Save the post to see which one the page picks automatically.',
'Save it to see which one the page picks automatically.',
);
});

Expand Down
Loading
Loading