@actuate-media/cms-core 0.15.0 → 0.17.0

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.
Files changed (40) hide show
  1. package/dist/__tests__/api/collections-ai-create.test.d.ts +2 -0
  2. package/dist/__tests__/api/collections-ai-create.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/collections-ai-create.test.js +313 -0
  4. package/dist/__tests__/api/collections-ai-create.test.js.map +1 -0
  5. package/dist/__tests__/collections/presets.test.d.ts +2 -0
  6. package/dist/__tests__/collections/presets.test.d.ts.map +1 -0
  7. package/dist/__tests__/collections/presets.test.js +141 -0
  8. package/dist/__tests__/collections/presets.test.js.map +1 -0
  9. package/dist/__tests__/fields/presets.test.d.ts +2 -0
  10. package/dist/__tests__/fields/presets.test.d.ts.map +1 -0
  11. package/dist/__tests__/fields/presets.test.js +99 -0
  12. package/dist/__tests__/fields/presets.test.js.map +1 -0
  13. package/dist/api/handlers.d.ts.map +1 -1
  14. package/dist/api/handlers.js +174 -0
  15. package/dist/api/handlers.js.map +1 -1
  16. package/dist/collections/index.d.ts +2 -0
  17. package/dist/collections/index.d.ts.map +1 -1
  18. package/dist/collections/index.js +1 -0
  19. package/dist/collections/index.js.map +1 -1
  20. package/dist/collections/presets.d.ts +71 -0
  21. package/dist/collections/presets.d.ts.map +1 -0
  22. package/dist/collections/presets.js +504 -0
  23. package/dist/collections/presets.js.map +1 -0
  24. package/dist/fields/index.d.ts +1 -0
  25. package/dist/fields/index.d.ts.map +1 -1
  26. package/dist/fields/index.js +1 -0
  27. package/dist/fields/index.js.map +1 -1
  28. package/dist/fields/presets.d.ts +90 -0
  29. package/dist/fields/presets.d.ts.map +1 -0
  30. package/dist/fields/presets.js +253 -0
  31. package/dist/fields/presets.js.map +1 -0
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/page-builder/schema.d.ts +8 -8
  37. package/dist/page-builder/templates.d.ts.map +1 -1
  38. package/dist/page-builder/templates.js +312 -0
  39. package/dist/page-builder/templates.js.map +1 -1
  40. package/package.json +1 -1
@@ -4710,6 +4710,7 @@ export function registerCMSRoutes(router) {
4710
4710
  return modelNotAvailable('PageTemplate');
4711
4711
  const url = new URL(request.url, 'http://localhost');
4712
4712
  const category = url.searchParams.get('category');
4713
+ const collection = url.searchParams.get('collection');
4713
4714
  const builtInCount = await safeCount(d.pageTemplate, { builtIn: true });
4714
4715
  if (builtInCount === 0) {
4715
4716
  await seedBuiltInTemplates(d);
@@ -4721,6 +4722,37 @@ export function registerCMSRoutes(router) {
4721
4722
  where,
4722
4723
  orderBy: [{ builtIn: 'desc' }, { updatedAt: 'desc' }],
4723
4724
  });
4725
+ // When a `collection` filter is supplied, reorder the response so the
4726
+ // suggested templates for that collection bubble to the top. The admin
4727
+ // "Create new" picker uses this so blog posts default to `blog-post`,
4728
+ // case studies to `case-study`, etc., without forcing the user to scan
4729
+ // every template alphabetically.
4730
+ if (collection) {
4731
+ const { getTemplatesForCollection } = await import('../collections/presets.js');
4732
+ const { primary, alternates } = getTemplatesForCollection(collection);
4733
+ const suggestionOrder = new Map();
4734
+ if (primary)
4735
+ suggestionOrder.set(`builtin-${primary}`, 0);
4736
+ alternates.forEach((alt, i) => suggestionOrder.set(`builtin-${alt}`, i + 1));
4737
+ templates.sort((a, b) => {
4738
+ const ai = suggestionOrder.has(a.id) ? suggestionOrder.get(a.id) : 100;
4739
+ const bi = suggestionOrder.has(b.id) ? suggestionOrder.get(b.id) : 100;
4740
+ return ai - bi;
4741
+ });
4742
+ const annotated = templates.map((t) => ({
4743
+ ...t,
4744
+ suggested: suggestionOrder.has(t.id),
4745
+ suggestionRank: suggestionOrder.get(t.id) ?? null,
4746
+ }));
4747
+ return json({
4748
+ data: annotated,
4749
+ suggestion: {
4750
+ collection,
4751
+ primary: primary ? `builtin-${primary}` : null,
4752
+ alternates: alternates.map((a) => `builtin-${a}`),
4753
+ },
4754
+ });
4755
+ }
4724
4756
  return json({ data: templates });
4725
4757
  }
4726
4758
  catch (err) {
@@ -5301,6 +5333,148 @@ export function registerCMSRoutes(router) {
5301
5333
  return internalError(err, 'page-builder create');
5302
5334
  }
5303
5335
  });
5336
+ /**
5337
+ * Generic AI content authoring for ANY collection.
5338
+ *
5339
+ * `POST /collections/:slug/ai-create` is the non-page counterpart to
5340
+ * `/page-builder/create`. It takes a free-form prompt, looks up the
5341
+ * collection's field schema from the running config, asks the AI provider
5342
+ * to produce structured JSON matching that schema, validates / coerces the
5343
+ * output, and persists the result as a draft (or published) document.
5344
+ *
5345
+ * Used by:
5346
+ * - the admin "Generate from prompt" affordance on the collection list
5347
+ * - the MCP server's `create_in_collection` / `create_blog_post` / etc.
5348
+ * - AI agents calling the REST API directly
5349
+ */
5350
+ router.post('/collections/:slug/ai-create', async (request, params) => {
5351
+ try {
5352
+ const auth = await requireAuth(request);
5353
+ if (auth.error)
5354
+ return auth.error;
5355
+ const targetCollection = params.slug;
5356
+ const scopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
5357
+ if (scopeErr)
5358
+ return scopeErr;
5359
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
5360
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
5361
+ }
5362
+ const body = (await request.json());
5363
+ if (!body.prompt || typeof body.prompt !== 'string') {
5364
+ return errorResponse('prompt is required', 400);
5365
+ }
5366
+ if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
5367
+ return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
5368
+ }
5369
+ if (body.context && body.context.length > AI_CONTEXT_MAX_CHARS) {
5370
+ return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
5371
+ }
5372
+ const cfg = getActuateConfig();
5373
+ const collection = cfg?.collections?.[targetCollection];
5374
+ if (!collection) {
5375
+ return errorResponse(`Collection "${targetCollection}" not found`, 404);
5376
+ }
5377
+ // Refuse page-builder-driven collections — those have a layout tree
5378
+ // (not flat field data) and should use `/page-builder/create` instead.
5379
+ const hasLayoutField = Object.values(collection.fields).some((f) => f.type === 'blocks');
5380
+ if (hasLayoutField) {
5381
+ return errorResponse(`Collection "${targetCollection}" uses the page builder. Use POST /api/cms/page-builder/create instead.`, 400);
5382
+ }
5383
+ let generateDocumentContent = null;
5384
+ try {
5385
+ const aiModule = await importAIPlugin();
5386
+ generateDocumentContent = aiModule.generateDocumentContent;
5387
+ }
5388
+ catch {
5389
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use content authoring.', 501);
5390
+ }
5391
+ if (!generateDocumentContent) {
5392
+ return errorResponse('AI plugin missing `generateDocumentContent`. Upgrade @actuate-media/plugin-ai to >= 0.2.0.', 501);
5393
+ }
5394
+ const result = await generateDocumentContent({
5395
+ prompt: body.prompt,
5396
+ collection: {
5397
+ slug: collection.slug,
5398
+ labels: collection.labels,
5399
+ type: collection.type,
5400
+ fields: collection.fields,
5401
+ seo: collection.seo,
5402
+ },
5403
+ context: { existingContent: body.context, targetAudience: body.targetAudience },
5404
+ tone: body.tone,
5405
+ ...(typeof body.maxTokens === 'number' ? { maxTokens: body.maxTokens } : {}),
5406
+ ...(typeof body.temperature === 'number' ? { temperature: body.temperature } : {}),
5407
+ });
5408
+ const shouldPublish = body.status === 'PUBLISHED' || body.publish === true;
5409
+ const finalTitle = body.title ?? result.title;
5410
+ const finalSlug = body.slug ?? result.slug;
5411
+ // Spread AI data FIRST so explicit caller overrides (title, slug) win.
5412
+ // We don't use `pageSettings` here — that's a page-builder-specific
5413
+ // wire shape. For non-page collections SEO copy lives on regular
5414
+ // fields (metaTitle / metaDescription) declared via the seoFields
5415
+ // preset, and the AI generator already populates those inside
5416
+ // result.data when they exist on the schema.
5417
+ const docPayload = {
5418
+ ...result.data,
5419
+ title: finalTitle,
5420
+ slug: finalSlug,
5421
+ };
5422
+ const ctx = buildActionContext(auth.session, db());
5423
+ let doc = await createDocument(targetCollection, docPayload, ctx);
5424
+ // Optional publish: createDocument intentionally always returns DRAFT
5425
+ // (only EDITOR+ can publish). If the caller asked for an immediate
5426
+ // publish we do a follow-up updateDocument that runs through the
5427
+ // standard publish access check. Callers without `update` scope on the
5428
+ // collection will get a 403 here even though the create succeeded —
5429
+ // they end up with a draft, which is the safest fallback.
5430
+ if (shouldPublish && doc?.id) {
5431
+ try {
5432
+ doc = await updateDocument(targetCollection, doc.id, { status: 'PUBLISHED' }, ctx);
5433
+ }
5434
+ catch (publishErr) {
5435
+ // Surface a 207-style partial success: the draft was created but
5436
+ // publish was denied. We return 201 with a `warning` field so the
5437
+ // agent knows the doc landed but needs human publishing.
5438
+ return json({
5439
+ data: {
5440
+ document: doc,
5441
+ generation: {
5442
+ usage: result.usage,
5443
+ durationMs: result.durationMs,
5444
+ seo: result.seo,
5445
+ },
5446
+ },
5447
+ warning: `Document created as DRAFT — publish failed: ${publishErr instanceof Error ? publishErr.message : 'unknown'}`,
5448
+ }, 201);
5449
+ }
5450
+ }
5451
+ await logEvent({
5452
+ event: 'settings_changed',
5453
+ userId: auth.session.userId,
5454
+ details: {
5455
+ action: 'collection_ai_create',
5456
+ collection: targetCollection,
5457
+ documentId: doc?.id,
5458
+ prompt: redactSecrets(body.prompt).slice(0, 500),
5459
+ tokensUsed: result.usage,
5460
+ durationMs: result.durationMs,
5461
+ },
5462
+ });
5463
+ return json({
5464
+ data: {
5465
+ document: doc,
5466
+ generation: {
5467
+ usage: result.usage,
5468
+ durationMs: result.durationMs,
5469
+ seo: result.seo,
5470
+ },
5471
+ },
5472
+ }, 201);
5473
+ }
5474
+ catch (err) {
5475
+ return internalError(err, 'collections/:slug/ai-create');
5476
+ }
5477
+ });
5304
5478
  router.post('/page-builder/audit-a11y', async (request) => {
5305
5479
  try {
5306
5480
  const auth = await requireAuth(request);