diff --git a/docs/images/screenshots/dark/admin-admin-agent-instructions-dark.webp b/docs/images/screenshots/dark/admin-admin-agent-instructions-dark.webp index 1127aab6..4663597d 100644 Binary files a/docs/images/screenshots/dark/admin-admin-agent-instructions-dark.webp and b/docs/images/screenshots/dark/admin-admin-agent-instructions-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-api-catalogs-dark.webp b/docs/images/screenshots/dark/admin-admin-api-catalogs-dark.webp index 2e2d89d5..46d6f098 100644 Binary files a/docs/images/screenshots/dark/admin-admin-api-catalogs-dark.webp and b/docs/images/screenshots/dark/admin-admin-api-catalogs-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-asset-detail-dark.webp b/docs/images/screenshots/dark/admin-admin-asset-detail-dark.webp index 4b110be3..65a70656 100644 Binary files a/docs/images/screenshots/dark/admin-admin-asset-detail-dark.webp and b/docs/images/screenshots/dark/admin-admin-asset-detail-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-assets-dark.webp b/docs/images/screenshots/dark/admin-admin-assets-dark.webp index c0f5770c..fcbce678 100644 Binary files a/docs/images/screenshots/dark/admin-admin-assets-dark.webp and b/docs/images/screenshots/dark/admin-admin-assets-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-audit-apigateway-dark.webp b/docs/images/screenshots/dark/admin-admin-audit-apigateway-dark.webp index aac7de9e..ec21f78e 100644 Binary files a/docs/images/screenshots/dark/admin-admin-audit-apigateway-dark.webp and b/docs/images/screenshots/dark/admin-admin-audit-apigateway-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-audit-event-detail-dark.webp b/docs/images/screenshots/dark/admin-admin-audit-event-detail-dark.webp index bec47925..9b45d378 100644 Binary files a/docs/images/screenshots/dark/admin-admin-audit-event-detail-dark.webp and b/docs/images/screenshots/dark/admin-admin-audit-event-detail-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-audit-events-dark.webp b/docs/images/screenshots/dark/admin-admin-audit-events-dark.webp index 9e48a542..d2bb4308 100644 Binary files a/docs/images/screenshots/dark/admin-admin-audit-events-dark.webp and b/docs/images/screenshots/dark/admin-admin-audit-events-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-audit-health-dark.webp b/docs/images/screenshots/dark/admin-admin-audit-health-dark.webp index 5ae5e690..bb54f3e9 100644 Binary files a/docs/images/screenshots/dark/admin-admin-audit-health-dark.webp and b/docs/images/screenshots/dark/admin-admin-audit-health-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-audit-indexing-dark.webp b/docs/images/screenshots/dark/admin-admin-audit-indexing-dark.webp index 6c5ff21d..f0e4039c 100644 Binary files a/docs/images/screenshots/dark/admin-admin-audit-indexing-dark.webp and b/docs/images/screenshots/dark/admin-admin-audit-indexing-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-audit-mcp-dark.webp b/docs/images/screenshots/dark/admin-admin-audit-mcp-dark.webp index 8f648765..21629d1a 100644 Binary files a/docs/images/screenshots/dark/admin-admin-audit-mcp-dark.webp and b/docs/images/screenshots/dark/admin-admin-audit-mcp-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-catalog-create-dark.webp b/docs/images/screenshots/dark/admin-admin-catalog-create-dark.webp index e2892c87..f0fd33a6 100644 Binary files a/docs/images/screenshots/dark/admin-admin-catalog-create-dark.webp and b/docs/images/screenshots/dark/admin-admin-catalog-create-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-changelog-dark.webp b/docs/images/screenshots/dark/admin-admin-changelog-dark.webp index bb5c5b93..d3c44f11 100644 Binary files a/docs/images/screenshots/dark/admin-admin-changelog-dark.webp and b/docs/images/screenshots/dark/admin-admin-changelog-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-connection-create-dark.webp b/docs/images/screenshots/dark/admin-admin-connection-create-dark.webp index dbc9c620..1e94be61 100644 Binary files a/docs/images/screenshots/dark/admin-admin-connection-create-dark.webp and b/docs/images/screenshots/dark/admin-admin-connection-create-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-connection-edit-dark.webp b/docs/images/screenshots/dark/admin-admin-connection-edit-dark.webp index 6633474a..5d64bde7 100644 Binary files a/docs/images/screenshots/dark/admin-admin-connection-edit-dark.webp and b/docs/images/screenshots/dark/admin-admin-connection-edit-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-connections-dark.webp b/docs/images/screenshots/dark/admin-admin-connections-dark.webp index 20286464..0967ccfa 100644 Binary files a/docs/images/screenshots/dark/admin-admin-connections-dark.webp and b/docs/images/screenshots/dark/admin-admin-connections-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-dashboard-dark.webp b/docs/images/screenshots/dark/admin-admin-dashboard-dark.webp index 5cd1f784..d3065472 100644 Binary files a/docs/images/screenshots/dark/admin-admin-dashboard-dark.webp and b/docs/images/screenshots/dark/admin-admin-dashboard-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-description-dark.webp b/docs/images/screenshots/dark/admin-admin-description-dark.webp index 44be3957..b934c434 100644 Binary files a/docs/images/screenshots/dark/admin-admin-description-dark.webp and b/docs/images/screenshots/dark/admin-admin-description-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-key-create-dark.webp b/docs/images/screenshots/dark/admin-admin-key-create-dark.webp index dc80c242..1acd077c 100644 Binary files a/docs/images/screenshots/dark/admin-admin-key-create-dark.webp and b/docs/images/screenshots/dark/admin-admin-key-create-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-keys-dark.webp b/docs/images/screenshots/dark/admin-admin-keys-dark.webp index 83a48cf6..1c69e813 100644 Binary files a/docs/images/screenshots/dark/admin-admin-keys-dark.webp and b/docs/images/screenshots/dark/admin-admin-keys-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-persona-create-dark.webp b/docs/images/screenshots/dark/admin-admin-persona-create-dark.webp index 49fee8d2..3f319cfe 100644 Binary files a/docs/images/screenshots/dark/admin-admin-persona-create-dark.webp and b/docs/images/screenshots/dark/admin-admin-persona-create-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-personas-dark.webp b/docs/images/screenshots/dark/admin-admin-personas-dark.webp index 910117cc..8826a397 100644 Binary files a/docs/images/screenshots/dark/admin-admin-personas-dark.webp and b/docs/images/screenshots/dark/admin-admin-personas-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-prompt-create-dark.webp b/docs/images/screenshots/dark/admin-admin-prompt-create-dark.webp index e7b76205..c1198162 100644 Binary files a/docs/images/screenshots/dark/admin-admin-prompt-create-dark.webp and b/docs/images/screenshots/dark/admin-admin-prompt-create-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-prompts-dark.webp b/docs/images/screenshots/dark/admin-admin-prompts-dark.webp index 03234b7c..d7a44920 100644 Binary files a/docs/images/screenshots/dark/admin-admin-prompts-dark.webp and b/docs/images/screenshots/dark/admin-admin-prompts-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-resources-dark.webp b/docs/images/screenshots/dark/admin-admin-resources-dark.webp index 6f72808d..af06913b 100644 Binary files a/docs/images/screenshots/dark/admin-admin-resources-dark.webp and b/docs/images/screenshots/dark/admin-admin-resources-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-settings-dark.webp b/docs/images/screenshots/dark/admin-admin-settings-dark.webp new file mode 100644 index 00000000..b64b0977 Binary files /dev/null and b/docs/images/screenshots/dark/admin-admin-settings-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-tools-activity-dark.webp b/docs/images/screenshots/dark/admin-admin-tools-activity-dark.webp index a170cc98..558ae159 100644 Binary files a/docs/images/screenshots/dark/admin-admin-tools-activity-dark.webp and b/docs/images/screenshots/dark/admin-admin-tools-activity-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-tools-enrichment-dark.webp b/docs/images/screenshots/dark/admin-admin-tools-enrichment-dark.webp index 2ac3052d..c9b27803 100644 Binary files a/docs/images/screenshots/dark/admin-admin-tools-enrichment-dark.webp and b/docs/images/screenshots/dark/admin-admin-tools-enrichment-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-tools-overview-dark.webp b/docs/images/screenshots/dark/admin-admin-tools-overview-dark.webp index 3a868d9a..aa2a7ec6 100644 Binary files a/docs/images/screenshots/dark/admin-admin-tools-overview-dark.webp and b/docs/images/screenshots/dark/admin-admin-tools-overview-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-tools-tryit-dark.webp b/docs/images/screenshots/dark/admin-admin-tools-tryit-dark.webp index ad500bd7..409a573b 100644 Binary files a/docs/images/screenshots/dark/admin-admin-tools-tryit-dark.webp and b/docs/images/screenshots/dark/admin-admin-tools-tryit-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-tools-visibility-dark.webp b/docs/images/screenshots/dark/admin-admin-tools-visibility-dark.webp index 502c8842..069887e0 100644 Binary files a/docs/images/screenshots/dark/admin-admin-tools-visibility-dark.webp and b/docs/images/screenshots/dark/admin-admin-tools-visibility-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-admin-users-dark.webp b/docs/images/screenshots/dark/admin-admin-users-dark.webp index 838a6427..16cc345c 100644 Binary files a/docs/images/screenshots/dark/admin-admin-users-dark.webp and b/docs/images/screenshots/dark/admin-admin-users-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-catalog-spec-modal-dark.webp b/docs/images/screenshots/dark/admin-catalog-spec-modal-dark.webp index ee41bf94..43408c63 100644 Binary files a/docs/images/screenshots/dark/admin-catalog-spec-modal-dark.webp and b/docs/images/screenshots/dark/admin-catalog-spec-modal-dark.webp differ diff --git a/docs/images/screenshots/dark/admin-knowledge-insight-detail-dark.webp b/docs/images/screenshots/dark/admin-knowledge-insight-detail-dark.webp index f3b6f007..23a13c3f 100644 Binary files a/docs/images/screenshots/dark/admin-knowledge-insight-detail-dark.webp and b/docs/images/screenshots/dark/admin-knowledge-insight-detail-dark.webp differ diff --git a/docs/images/screenshots/dark/user-activity-dark.webp b/docs/images/screenshots/dark/user-activity-dark.webp index 7ec1ce27..8504249e 100644 Binary files a/docs/images/screenshots/dark/user-activity-dark.webp and b/docs/images/screenshots/dark/user-activity-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-csv-dark.webp b/docs/images/screenshots/dark/user-asset-csv-dark.webp index 28793594..7af96579 100644 Binary files a/docs/images/screenshots/dark/user-asset-csv-dark.webp and b/docs/images/screenshots/dark/user-asset-csv-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-feedback-dark.webp b/docs/images/screenshots/dark/user-asset-feedback-dark.webp index 2cdd44e1..8d0020f9 100644 Binary files a/docs/images/screenshots/dark/user-asset-feedback-dark.webp and b/docs/images/screenshots/dark/user-asset-feedback-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-feedback-detail-dark.webp b/docs/images/screenshots/dark/user-asset-feedback-detail-dark.webp index 85d19f80..8d0020f9 100644 Binary files a/docs/images/screenshots/dark/user-asset-feedback-detail-dark.webp and b/docs/images/screenshots/dark/user-asset-feedback-detail-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-html-dark.webp b/docs/images/screenshots/dark/user-asset-html-dark.webp index 6277a6ed..5dec3d16 100644 Binary files a/docs/images/screenshots/dark/user-asset-html-dark.webp and b/docs/images/screenshots/dark/user-asset-html-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-jsx-dark.webp b/docs/images/screenshots/dark/user-asset-jsx-dark.webp index bdb3fbc8..400e76a3 100644 Binary files a/docs/images/screenshots/dark/user-asset-jsx-dark.webp and b/docs/images/screenshots/dark/user-asset-jsx-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-markdown-dark.webp b/docs/images/screenshots/dark/user-asset-markdown-dark.webp index fb9b9dc0..f32d4d7f 100644 Binary files a/docs/images/screenshots/dark/user-asset-markdown-dark.webp and b/docs/images/screenshots/dark/user-asset-markdown-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-share-dark.webp b/docs/images/screenshots/dark/user-asset-share-dark.webp index 4f192994..4c3e3d10 100644 Binary files a/docs/images/screenshots/dark/user-asset-share-dark.webp and b/docs/images/screenshots/dark/user-asset-share-dark.webp differ diff --git a/docs/images/screenshots/dark/user-asset-svg-dark.webp b/docs/images/screenshots/dark/user-asset-svg-dark.webp index 13e51a15..0eab96d9 100644 Binary files a/docs/images/screenshots/dark/user-asset-svg-dark.webp and b/docs/images/screenshots/dark/user-asset-svg-dark.webp differ diff --git a/docs/images/screenshots/dark/user-assets-shared-dark.webp b/docs/images/screenshots/dark/user-assets-shared-dark.webp index 937b6e4f..dadc413b 100644 Binary files a/docs/images/screenshots/dark/user-assets-shared-dark.webp and b/docs/images/screenshots/dark/user-assets-shared-dark.webp differ diff --git a/docs/images/screenshots/dark/user-collection-asset-dark.webp b/docs/images/screenshots/dark/user-collection-asset-dark.webp index 082312cb..b480d569 100644 Binary files a/docs/images/screenshots/dark/user-collection-asset-dark.webp and b/docs/images/screenshots/dark/user-collection-asset-dark.webp differ diff --git a/docs/images/screenshots/dark/user-collection-edit-dark.webp b/docs/images/screenshots/dark/user-collection-edit-dark.webp index 51bab85d..148eadbb 100644 Binary files a/docs/images/screenshots/dark/user-collection-edit-dark.webp and b/docs/images/screenshots/dark/user-collection-edit-dark.webp differ diff --git a/docs/images/screenshots/dark/user-collection-view-dark.webp b/docs/images/screenshots/dark/user-collection-view-dark.webp index 33f3c4dc..295991b1 100644 Binary files a/docs/images/screenshots/dark/user-collection-view-dark.webp and b/docs/images/screenshots/dark/user-collection-view-dark.webp differ diff --git a/docs/images/screenshots/dark/user-collections-dark.webp b/docs/images/screenshots/dark/user-collections-dark.webp index 28494c3a..feb2b631 100644 Binary files a/docs/images/screenshots/dark/user-collections-dark.webp and b/docs/images/screenshots/dark/user-collections-dark.webp differ diff --git a/docs/images/screenshots/dark/user-feedback-dark.webp b/docs/images/screenshots/dark/user-feedback-dark.webp index d10c02f9..20b70761 100644 Binary files a/docs/images/screenshots/dark/user-feedback-dark.webp and b/docs/images/screenshots/dark/user-feedback-dark.webp differ diff --git a/docs/images/screenshots/dark/user-knowledge-insights-dark.webp b/docs/images/screenshots/dark/user-knowledge-insights-dark.webp index 84c6fcee..8c79a8a6 100644 Binary files a/docs/images/screenshots/dark/user-knowledge-insights-dark.webp and b/docs/images/screenshots/dark/user-knowledge-insights-dark.webp differ diff --git a/docs/images/screenshots/dark/user-knowledge-knowledge-dark.webp b/docs/images/screenshots/dark/user-knowledge-knowledge-dark.webp index 9ed5c03f..58fb8aac 100644 Binary files a/docs/images/screenshots/dark/user-knowledge-knowledge-dark.webp and b/docs/images/screenshots/dark/user-knowledge-knowledge-dark.webp differ diff --git a/docs/images/screenshots/dark/user-knowledge-memory-dark.webp b/docs/images/screenshots/dark/user-knowledge-memory-dark.webp index d89b89b1..a1980d4c 100644 Binary files a/docs/images/screenshots/dark/user-knowledge-memory-dark.webp and b/docs/images/screenshots/dark/user-knowledge-memory-dark.webp differ diff --git a/docs/images/screenshots/dark/user-my-assets-dark.webp b/docs/images/screenshots/dark/user-my-assets-dark.webp index 1ad4a7fc..2a8ec05c 100644 Binary files a/docs/images/screenshots/dark/user-my-assets-dark.webp and b/docs/images/screenshots/dark/user-my-assets-dark.webp differ diff --git a/docs/images/screenshots/dark/user-prompt-collections-dark.webp b/docs/images/screenshots/dark/user-prompt-collections-dark.webp new file mode 100644 index 00000000..34b88c6f Binary files /dev/null and b/docs/images/screenshots/dark/user-prompt-collections-dark.webp differ diff --git a/docs/images/screenshots/dark/user-prompt-create-dark.webp b/docs/images/screenshots/dark/user-prompt-create-dark.webp index a88169fc..ddb3e2a1 100644 Binary files a/docs/images/screenshots/dark/user-prompt-create-dark.webp and b/docs/images/screenshots/dark/user-prompt-create-dark.webp differ diff --git a/docs/images/screenshots/dark/user-prompt-version-diff-dark.webp b/docs/images/screenshots/dark/user-prompt-version-diff-dark.webp new file mode 100644 index 00000000..caa4ec8b Binary files /dev/null and b/docs/images/screenshots/dark/user-prompt-version-diff-dark.webp differ diff --git a/docs/images/screenshots/dark/user-prompt-view-dark.webp b/docs/images/screenshots/dark/user-prompt-view-dark.webp index 0401ccb6..53bd3ccd 100644 Binary files a/docs/images/screenshots/dark/user-prompt-view-dark.webp and b/docs/images/screenshots/dark/user-prompt-view-dark.webp differ diff --git a/docs/images/screenshots/dark/user-prompt-view-library-dark.webp b/docs/images/screenshots/dark/user-prompt-view-library-dark.webp new file mode 100644 index 00000000..68071096 Binary files /dev/null and b/docs/images/screenshots/dark/user-prompt-view-library-dark.webp differ diff --git a/docs/images/screenshots/dark/user-prompts-dark.webp b/docs/images/screenshots/dark/user-prompts-dark.webp index 3e2654b9..dc6ae30e 100644 Binary files a/docs/images/screenshots/dark/user-prompts-dark.webp and b/docs/images/screenshots/dark/user-prompts-dark.webp differ diff --git a/docs/images/screenshots/dark/user-prompts-library-dark.webp b/docs/images/screenshots/dark/user-prompts-library-dark.webp new file mode 100644 index 00000000..58e10be8 Binary files /dev/null and b/docs/images/screenshots/dark/user-prompts-library-dark.webp differ diff --git a/docs/images/screenshots/dark/user-resource-upload-dark.webp b/docs/images/screenshots/dark/user-resource-upload-dark.webp index 5bb88f51..15935665 100644 Binary files a/docs/images/screenshots/dark/user-resource-upload-dark.webp and b/docs/images/screenshots/dark/user-resource-upload-dark.webp differ diff --git a/docs/images/screenshots/dark/user-resources-dark.webp b/docs/images/screenshots/dark/user-resources-dark.webp index 765fdd65..b4080222 100644 Binary files a/docs/images/screenshots/dark/user-resources-dark.webp and b/docs/images/screenshots/dark/user-resources-dark.webp differ diff --git a/docs/images/screenshots/dark/user-settings-dark.webp b/docs/images/screenshots/dark/user-settings-dark.webp new file mode 100644 index 00000000..8aecfb31 Binary files /dev/null and b/docs/images/screenshots/dark/user-settings-dark.webp differ diff --git a/docs/images/screenshots/dark/user-shared-asset-dark.webp b/docs/images/screenshots/dark/user-shared-asset-dark.webp index 1508ba4e..a2790d07 100644 Binary files a/docs/images/screenshots/dark/user-shared-asset-dark.webp and b/docs/images/screenshots/dark/user-shared-asset-dark.webp differ diff --git a/docs/images/screenshots/light/admin-admin-agent-instructions-light.webp b/docs/images/screenshots/light/admin-admin-agent-instructions-light.webp index f004b629..9af891c6 100644 Binary files a/docs/images/screenshots/light/admin-admin-agent-instructions-light.webp and b/docs/images/screenshots/light/admin-admin-agent-instructions-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-api-catalogs-light.webp b/docs/images/screenshots/light/admin-admin-api-catalogs-light.webp index f288f7cb..d9e5deef 100644 Binary files a/docs/images/screenshots/light/admin-admin-api-catalogs-light.webp and b/docs/images/screenshots/light/admin-admin-api-catalogs-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-asset-detail-light.webp b/docs/images/screenshots/light/admin-admin-asset-detail-light.webp index 4b110be3..fc702a17 100644 Binary files a/docs/images/screenshots/light/admin-admin-asset-detail-light.webp and b/docs/images/screenshots/light/admin-admin-asset-detail-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-assets-light.webp b/docs/images/screenshots/light/admin-admin-assets-light.webp index eccc9cdb..10255499 100644 Binary files a/docs/images/screenshots/light/admin-admin-assets-light.webp and b/docs/images/screenshots/light/admin-admin-assets-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-audit-apigateway-light.webp b/docs/images/screenshots/light/admin-admin-audit-apigateway-light.webp index 97fee425..14a240ec 100644 Binary files a/docs/images/screenshots/light/admin-admin-audit-apigateway-light.webp and b/docs/images/screenshots/light/admin-admin-audit-apigateway-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-audit-event-detail-light.webp b/docs/images/screenshots/light/admin-admin-audit-event-detail-light.webp index c74dd7e8..71891a5e 100644 Binary files a/docs/images/screenshots/light/admin-admin-audit-event-detail-light.webp and b/docs/images/screenshots/light/admin-admin-audit-event-detail-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-audit-events-light.webp b/docs/images/screenshots/light/admin-admin-audit-events-light.webp index dcb46768..3482d3bf 100644 Binary files a/docs/images/screenshots/light/admin-admin-audit-events-light.webp and b/docs/images/screenshots/light/admin-admin-audit-events-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-audit-health-light.webp b/docs/images/screenshots/light/admin-admin-audit-health-light.webp index d739f388..14dada2d 100644 Binary files a/docs/images/screenshots/light/admin-admin-audit-health-light.webp and b/docs/images/screenshots/light/admin-admin-audit-health-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-audit-indexing-light.webp b/docs/images/screenshots/light/admin-admin-audit-indexing-light.webp index cb4b786e..130b987d 100644 Binary files a/docs/images/screenshots/light/admin-admin-audit-indexing-light.webp and b/docs/images/screenshots/light/admin-admin-audit-indexing-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-audit-mcp-light.webp b/docs/images/screenshots/light/admin-admin-audit-mcp-light.webp index 0092dc82..daf1ef9e 100644 Binary files a/docs/images/screenshots/light/admin-admin-audit-mcp-light.webp and b/docs/images/screenshots/light/admin-admin-audit-mcp-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-catalog-create-light.webp b/docs/images/screenshots/light/admin-admin-catalog-create-light.webp index dacc4749..c80600c4 100644 Binary files a/docs/images/screenshots/light/admin-admin-catalog-create-light.webp and b/docs/images/screenshots/light/admin-admin-catalog-create-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-changelog-light.webp b/docs/images/screenshots/light/admin-admin-changelog-light.webp index 2808ba67..f2444f0c 100644 Binary files a/docs/images/screenshots/light/admin-admin-changelog-light.webp and b/docs/images/screenshots/light/admin-admin-changelog-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-connection-create-light.webp b/docs/images/screenshots/light/admin-admin-connection-create-light.webp index 144afe77..9d9f0176 100644 Binary files a/docs/images/screenshots/light/admin-admin-connection-create-light.webp and b/docs/images/screenshots/light/admin-admin-connection-create-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-connection-edit-light.webp b/docs/images/screenshots/light/admin-admin-connection-edit-light.webp index 3db08544..edeb105c 100644 Binary files a/docs/images/screenshots/light/admin-admin-connection-edit-light.webp and b/docs/images/screenshots/light/admin-admin-connection-edit-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-connections-light.webp b/docs/images/screenshots/light/admin-admin-connections-light.webp index 7e26fbdb..4b5aaf76 100644 Binary files a/docs/images/screenshots/light/admin-admin-connections-light.webp and b/docs/images/screenshots/light/admin-admin-connections-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-dashboard-light.webp b/docs/images/screenshots/light/admin-admin-dashboard-light.webp index cc42f759..46307200 100644 Binary files a/docs/images/screenshots/light/admin-admin-dashboard-light.webp and b/docs/images/screenshots/light/admin-admin-dashboard-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-description-light.webp b/docs/images/screenshots/light/admin-admin-description-light.webp index 319c0082..fcfe6843 100644 Binary files a/docs/images/screenshots/light/admin-admin-description-light.webp and b/docs/images/screenshots/light/admin-admin-description-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-key-create-light.webp b/docs/images/screenshots/light/admin-admin-key-create-light.webp index 40b09827..9249b417 100644 Binary files a/docs/images/screenshots/light/admin-admin-key-create-light.webp and b/docs/images/screenshots/light/admin-admin-key-create-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-keys-light.webp b/docs/images/screenshots/light/admin-admin-keys-light.webp index f6ede45f..6e2eea98 100644 Binary files a/docs/images/screenshots/light/admin-admin-keys-light.webp and b/docs/images/screenshots/light/admin-admin-keys-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-persona-create-light.webp b/docs/images/screenshots/light/admin-admin-persona-create-light.webp index 68e074eb..4ffbb77b 100644 Binary files a/docs/images/screenshots/light/admin-admin-persona-create-light.webp and b/docs/images/screenshots/light/admin-admin-persona-create-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-personas-light.webp b/docs/images/screenshots/light/admin-admin-personas-light.webp index 62fd12ad..b85f0826 100644 Binary files a/docs/images/screenshots/light/admin-admin-personas-light.webp and b/docs/images/screenshots/light/admin-admin-personas-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-prompt-create-light.webp b/docs/images/screenshots/light/admin-admin-prompt-create-light.webp index 2c48c364..3f49cf1b 100644 Binary files a/docs/images/screenshots/light/admin-admin-prompt-create-light.webp and b/docs/images/screenshots/light/admin-admin-prompt-create-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-prompts-light.webp b/docs/images/screenshots/light/admin-admin-prompts-light.webp index 83efdc0d..ff404925 100644 Binary files a/docs/images/screenshots/light/admin-admin-prompts-light.webp and b/docs/images/screenshots/light/admin-admin-prompts-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-resources-light.webp b/docs/images/screenshots/light/admin-admin-resources-light.webp index 32fdb978..88aee1f1 100644 Binary files a/docs/images/screenshots/light/admin-admin-resources-light.webp and b/docs/images/screenshots/light/admin-admin-resources-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-settings-light.webp b/docs/images/screenshots/light/admin-admin-settings-light.webp new file mode 100644 index 00000000..e66f3c65 Binary files /dev/null and b/docs/images/screenshots/light/admin-admin-settings-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-tools-activity-light.webp b/docs/images/screenshots/light/admin-admin-tools-activity-light.webp index 333db47a..81a3b7ca 100644 Binary files a/docs/images/screenshots/light/admin-admin-tools-activity-light.webp and b/docs/images/screenshots/light/admin-admin-tools-activity-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-tools-enrichment-light.webp b/docs/images/screenshots/light/admin-admin-tools-enrichment-light.webp index 3216cab7..a4a1a067 100644 Binary files a/docs/images/screenshots/light/admin-admin-tools-enrichment-light.webp and b/docs/images/screenshots/light/admin-admin-tools-enrichment-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-tools-overview-light.webp b/docs/images/screenshots/light/admin-admin-tools-overview-light.webp index ac8357aa..af9ade47 100644 Binary files a/docs/images/screenshots/light/admin-admin-tools-overview-light.webp and b/docs/images/screenshots/light/admin-admin-tools-overview-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-tools-tryit-light.webp b/docs/images/screenshots/light/admin-admin-tools-tryit-light.webp index d4901f75..ce56c80b 100644 Binary files a/docs/images/screenshots/light/admin-admin-tools-tryit-light.webp and b/docs/images/screenshots/light/admin-admin-tools-tryit-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-tools-visibility-light.webp b/docs/images/screenshots/light/admin-admin-tools-visibility-light.webp index 10da35a4..bccb48d2 100644 Binary files a/docs/images/screenshots/light/admin-admin-tools-visibility-light.webp and b/docs/images/screenshots/light/admin-admin-tools-visibility-light.webp differ diff --git a/docs/images/screenshots/light/admin-admin-users-light.webp b/docs/images/screenshots/light/admin-admin-users-light.webp index 921de254..c75eb3ec 100644 Binary files a/docs/images/screenshots/light/admin-admin-users-light.webp and b/docs/images/screenshots/light/admin-admin-users-light.webp differ diff --git a/docs/images/screenshots/light/admin-catalog-spec-modal-light.webp b/docs/images/screenshots/light/admin-catalog-spec-modal-light.webp index 420b0675..c3e299d0 100644 Binary files a/docs/images/screenshots/light/admin-catalog-spec-modal-light.webp and b/docs/images/screenshots/light/admin-catalog-spec-modal-light.webp differ diff --git a/docs/images/screenshots/light/admin-knowledge-insight-detail-light.webp b/docs/images/screenshots/light/admin-knowledge-insight-detail-light.webp index a791312c..1b8b4f0a 100644 Binary files a/docs/images/screenshots/light/admin-knowledge-insight-detail-light.webp and b/docs/images/screenshots/light/admin-knowledge-insight-detail-light.webp differ diff --git a/docs/images/screenshots/light/user-activity-light.webp b/docs/images/screenshots/light/user-activity-light.webp index b3d762d1..f485f74a 100644 Binary files a/docs/images/screenshots/light/user-activity-light.webp and b/docs/images/screenshots/light/user-activity-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-csv-light.webp b/docs/images/screenshots/light/user-asset-csv-light.webp index e890a5aa..8306aea0 100644 Binary files a/docs/images/screenshots/light/user-asset-csv-light.webp and b/docs/images/screenshots/light/user-asset-csv-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-feedback-detail-light.webp b/docs/images/screenshots/light/user-asset-feedback-detail-light.webp index 0b3a2604..30706386 100644 Binary files a/docs/images/screenshots/light/user-asset-feedback-detail-light.webp and b/docs/images/screenshots/light/user-asset-feedback-detail-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-feedback-light.webp b/docs/images/screenshots/light/user-asset-feedback-light.webp index faa579f1..30706386 100644 Binary files a/docs/images/screenshots/light/user-asset-feedback-light.webp and b/docs/images/screenshots/light/user-asset-feedback-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-html-light.webp b/docs/images/screenshots/light/user-asset-html-light.webp index 573c968c..b29fc0db 100644 Binary files a/docs/images/screenshots/light/user-asset-html-light.webp and b/docs/images/screenshots/light/user-asset-html-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-jsx-light.webp b/docs/images/screenshots/light/user-asset-jsx-light.webp index 0dc8c56a..538b97c7 100644 Binary files a/docs/images/screenshots/light/user-asset-jsx-light.webp and b/docs/images/screenshots/light/user-asset-jsx-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-markdown-light.webp b/docs/images/screenshots/light/user-asset-markdown-light.webp index 166f1131..5e3cb49f 100644 Binary files a/docs/images/screenshots/light/user-asset-markdown-light.webp and b/docs/images/screenshots/light/user-asset-markdown-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-share-light.webp b/docs/images/screenshots/light/user-asset-share-light.webp index 969cba5f..786c13a5 100644 Binary files a/docs/images/screenshots/light/user-asset-share-light.webp and b/docs/images/screenshots/light/user-asset-share-light.webp differ diff --git a/docs/images/screenshots/light/user-asset-svg-light.webp b/docs/images/screenshots/light/user-asset-svg-light.webp index 42c58cc1..76fb0f21 100644 Binary files a/docs/images/screenshots/light/user-asset-svg-light.webp and b/docs/images/screenshots/light/user-asset-svg-light.webp differ diff --git a/docs/images/screenshots/light/user-assets-shared-light.webp b/docs/images/screenshots/light/user-assets-shared-light.webp index b0f0d6ce..5f38edf5 100644 Binary files a/docs/images/screenshots/light/user-assets-shared-light.webp and b/docs/images/screenshots/light/user-assets-shared-light.webp differ diff --git a/docs/images/screenshots/light/user-collection-asset-light.webp b/docs/images/screenshots/light/user-collection-asset-light.webp index 573c968c..e895b614 100644 Binary files a/docs/images/screenshots/light/user-collection-asset-light.webp and b/docs/images/screenshots/light/user-collection-asset-light.webp differ diff --git a/docs/images/screenshots/light/user-collection-edit-light.webp b/docs/images/screenshots/light/user-collection-edit-light.webp index a628e4f4..5c4e1e1b 100644 Binary files a/docs/images/screenshots/light/user-collection-edit-light.webp and b/docs/images/screenshots/light/user-collection-edit-light.webp differ diff --git a/docs/images/screenshots/light/user-collection-view-light.webp b/docs/images/screenshots/light/user-collection-view-light.webp index 38f64080..631792cf 100644 Binary files a/docs/images/screenshots/light/user-collection-view-light.webp and b/docs/images/screenshots/light/user-collection-view-light.webp differ diff --git a/docs/images/screenshots/light/user-collections-light.webp b/docs/images/screenshots/light/user-collections-light.webp index 598e353d..822a2f78 100644 Binary files a/docs/images/screenshots/light/user-collections-light.webp and b/docs/images/screenshots/light/user-collections-light.webp differ diff --git a/docs/images/screenshots/light/user-feedback-light.webp b/docs/images/screenshots/light/user-feedback-light.webp index bd83b525..c1fa6394 100644 Binary files a/docs/images/screenshots/light/user-feedback-light.webp and b/docs/images/screenshots/light/user-feedback-light.webp differ diff --git a/docs/images/screenshots/light/user-knowledge-insights-light.webp b/docs/images/screenshots/light/user-knowledge-insights-light.webp index 653e9307..ebaf6223 100644 Binary files a/docs/images/screenshots/light/user-knowledge-insights-light.webp and b/docs/images/screenshots/light/user-knowledge-insights-light.webp differ diff --git a/docs/images/screenshots/light/user-knowledge-knowledge-light.webp b/docs/images/screenshots/light/user-knowledge-knowledge-light.webp index 535b5a68..d7037d67 100644 Binary files a/docs/images/screenshots/light/user-knowledge-knowledge-light.webp and b/docs/images/screenshots/light/user-knowledge-knowledge-light.webp differ diff --git a/docs/images/screenshots/light/user-knowledge-memory-light.webp b/docs/images/screenshots/light/user-knowledge-memory-light.webp index 990d56b3..b8701cb8 100644 Binary files a/docs/images/screenshots/light/user-knowledge-memory-light.webp and b/docs/images/screenshots/light/user-knowledge-memory-light.webp differ diff --git a/docs/images/screenshots/light/user-my-assets-light.webp b/docs/images/screenshots/light/user-my-assets-light.webp index 9cde0e38..b13de465 100644 Binary files a/docs/images/screenshots/light/user-my-assets-light.webp and b/docs/images/screenshots/light/user-my-assets-light.webp differ diff --git a/docs/images/screenshots/light/user-prompt-collections-light.webp b/docs/images/screenshots/light/user-prompt-collections-light.webp new file mode 100644 index 00000000..88ff270b Binary files /dev/null and b/docs/images/screenshots/light/user-prompt-collections-light.webp differ diff --git a/docs/images/screenshots/light/user-prompt-create-light.webp b/docs/images/screenshots/light/user-prompt-create-light.webp index 42c3910f..520e98ad 100644 Binary files a/docs/images/screenshots/light/user-prompt-create-light.webp and b/docs/images/screenshots/light/user-prompt-create-light.webp differ diff --git a/docs/images/screenshots/light/user-prompt-version-diff-light.webp b/docs/images/screenshots/light/user-prompt-version-diff-light.webp new file mode 100644 index 00000000..d6677d30 Binary files /dev/null and b/docs/images/screenshots/light/user-prompt-version-diff-light.webp differ diff --git a/docs/images/screenshots/light/user-prompt-view-library-light.webp b/docs/images/screenshots/light/user-prompt-view-library-light.webp new file mode 100644 index 00000000..8263360c Binary files /dev/null and b/docs/images/screenshots/light/user-prompt-view-library-light.webp differ diff --git a/docs/images/screenshots/light/user-prompt-view-light.webp b/docs/images/screenshots/light/user-prompt-view-light.webp index 47457e7b..0b08ec84 100644 Binary files a/docs/images/screenshots/light/user-prompt-view-light.webp and b/docs/images/screenshots/light/user-prompt-view-light.webp differ diff --git a/docs/images/screenshots/light/user-prompts-library-light.webp b/docs/images/screenshots/light/user-prompts-library-light.webp new file mode 100644 index 00000000..bfd3e219 Binary files /dev/null and b/docs/images/screenshots/light/user-prompts-library-light.webp differ diff --git a/docs/images/screenshots/light/user-prompts-light.webp b/docs/images/screenshots/light/user-prompts-light.webp index f45b05ec..5e2ec52f 100644 Binary files a/docs/images/screenshots/light/user-prompts-light.webp and b/docs/images/screenshots/light/user-prompts-light.webp differ diff --git a/docs/images/screenshots/light/user-resource-upload-light.webp b/docs/images/screenshots/light/user-resource-upload-light.webp index 19481a6e..53284eab 100644 Binary files a/docs/images/screenshots/light/user-resource-upload-light.webp and b/docs/images/screenshots/light/user-resource-upload-light.webp differ diff --git a/docs/images/screenshots/light/user-resources-light.webp b/docs/images/screenshots/light/user-resources-light.webp index 1c56a06b..4447e2c2 100644 Binary files a/docs/images/screenshots/light/user-resources-light.webp and b/docs/images/screenshots/light/user-resources-light.webp differ diff --git a/docs/images/screenshots/light/user-settings-light.webp b/docs/images/screenshots/light/user-settings-light.webp new file mode 100644 index 00000000..3fbce4b2 Binary files /dev/null and b/docs/images/screenshots/light/user-settings-light.webp differ diff --git a/docs/images/screenshots/light/user-shared-asset-light.webp b/docs/images/screenshots/light/user-shared-asset-light.webp index 9638e862..afbc6882 100644 Binary files a/docs/images/screenshots/light/user-shared-asset-light.webp and b/docs/images/screenshots/light/user-shared-asset-light.webp differ diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 5ec6b7c0..12d6db7f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2173,7 +2173,7 @@ Structured feedback from reviewers (including non-agent subject-matter experts a The single home for the Memory to Insight to Knowledge lifecycle (formerly the separate Knowledge Pages, Knowledge & Memory, and admin Knowledge & Memory routes, which now redirect here). A header teaches the model: everything learned is a Memory; a memory others would benefit from becomes an Insight (a proposal awaiting review); whoever holds the `apply_knowledge` capability promotes good insights into Knowledge (business/domain facts become knowledge pages, technical/entity facts go to the DataHub catalog). Three tabs, with review/promote affordances gated on the `apply_knowledge` tool (a capability, not an admin role). Knowledge (default): unified search across every accessible source grouped by source with a coverage summary (the same federation as the `search` tool, over `GET /api/v1/portal/search`); with an empty query, browse of canonical knowledge pages (create/edit/remove for `apply_knowledge` holders); and for `apply_knowledge` holders the changesets (the record of insights promoted into knowledge, with rollback) since a changeset is created only at apply time and belongs with the promoted knowledge, not the unpromoted insights. Insights: the review pipeline only (insights are the one memory type that crosses between users) - your captured insights with status and relevance search, plus for `apply_knowledge` holders the full review queue (approve/reject); a pending-review count is badged on the sidebar Knowledge item and the Insights tab. Memory: personal, scoped to your own records (the cross-user unit is the insight), classified by lifecycle class (`sink_class`: Preference, Event, Business knowledge, Operational rule, Schema/entity). The Knowledge tab also has Catalog and Context Docs sub-tabs (#719/#720), first-class routes at `/knowledge/catalog` and `/knowledge/context-docs`, that surface the DataHub catalog and context documents in the portal. Catalog: pick a connection, browse/search datasets, open one to see description, tags, owners, glossary terms, domain, and columns, and edit each facet inline when the persona grants `datahub_update` and the connection is writable (no dataset create/delete since datasets originate in source systems). Context Docs: browse/search and full create/edit/delete of markdown context documents through a markdown editor, gated on `datahub_create`/`datahub_update`/`datahub_delete`; a document attaches only to Dataset/GlossaryTerm/GlossaryNode/Container. Both are backed by the portal DataHub REST API at `/api/v1/portal/datahub/{connection}/...` (`GET .../connections` lists connections with a writable flag): reads require DataHub access on the persona; a write requires the matching MCP tool grant AND a write-enabled connection (`read_only: false`), both enforced server-side and recorded in the audit log. Tag and glossary-term edits use batched add/remove sets (the clobber-safe write path, #721/#729). ## Prompts -Personal, available, and shared prompt templates. Three tabs: Personal (create/edit/delete own prompts), Available (global/persona prompts), and Shared (prompts other users shared with you). Search ranks approved prompts by relevance to a phrase (semantic vector similarity when an embedding provider is configured, keyword fallback otherwise) across the caller's full visibility, best-first; browse mode keeps sortable columns. The same ranking backs the MCP tool `manage_prompt list query=...`. Approved prompts are embedded off the request path by the shared index-jobs framework (source_kind `prompts`); editing a prompt's title, description, body, or tags clears its vector so it re-embeds against the new text. Visibility (own personal, global, matching-persona; all approved for admins) is applied before ranking, so a prompt you cannot read is never returned. Sortable columns, expandable rows with full content and copy button. Scope badges: Personal, Global, Persona, System. Lifecycle status badge (draft, approved, deprecated, superseded) and comma-separated tags on create/edit. Request Promotion on a personal prompt asks an admin to promote it to a chosen persona or to global; the prompt stays personal with a "Promotion requested" badge until an admin approves or rejects it. Share sends a personal prompt directly to another user by email (owner-initiated, no approval): the recipient gets a real runnable prompt, not a markdown snapshot, and it appears on the Prompts page's Shared tab. Personal prompt names are unique per owner; when served over MCP, names carry a scope prefix computed at serve time: `personal-`, `-` (one per persona), `global-`, or `shared-` for prompts shared with the caller, keeping the surface collision-free by construction; every descriptor carries a `title` from display_name. Users never need machine names: agents resolve any handle (stored name, display name, `mcp:prompt:`, or free text) to a ready-to-run prompt with the `manage_prompt` `use` command, which returns rendered content, argument specs, and provenance (including version, approver, and approval time), or ranked candidates when ambiguous. Every database prompt is versioned (#1009): each mutation of content, display name, description, arguments, or tags snapshots an immutable `prompt_versions` row with its author, and approval stamps bind to the specific version approved (approving v5 never alters v4's recorded approval). Editing the content or arguments of an approved global or persona prompt does not change what is served: the edit lands as a pending draft version (manage_prompt update returns status pending_approval; a gated content edit cannot be combined with scope/status changes in one call) and the approved snapshot keeps serving until an admin approves the draft via `POST /api/v1/admin/prompts/{id}/versions/{version}/approve` (reject with `.../reject`; full history with author and content via `GET /api/v1/admin/prompts/{id}/versions`, owner-readable per prompt via `GET /api/v1/portal/prompts/{id}/versions`). Metadata-only edits (tags, category, description, display name) apply directly; personal prompts version silently. Served prompts carry provenance: `prompts/get` stamps `prompt_version` / `prompt_approved_by` / `prompt_approved_at` / `prompt_reference` into `_meta`. Usage stats are aggregated from `prompt_serve` audit events (emitted on every database-prompt `prompts/get` and resolved `use`, within the audit retention window): `manage_prompt get` reports `run_count` and `last_run_at`, and `GET /api/v1/admin/prompts/usage` / `GET /api/v1/portal/prompts/usage` return the per-prompt rollup (portal scoped to the caller's visible prompts) for library curation and dead-prompt detection. +The organization's prompt library, presented as two buckets (#1010): My Prompts (the caller's personal prompts plus prompts shared with them, each attributed to its sharer) and Library (the approved shared prompts visible to the caller, grouped by collection). The scope taxonomy (global/persona/personal) appears only inside the promote and admin flows, never in the user-facing library. Collections are named groups organizing the library by team, domain, or workflow (`prompt_collections` table; a prompt belongs to at most one via `prompts.collection_id`, released to the default General group when its collection is deleted). Any user creates collections; renaming/deleting is creator-or-admin; assignment follows the prompt's own mutation rule (owner for personal, admin for shared) and is organizational metadata: it never versions or triggers review. REST: `GET/POST /api/v1/portal/prompt-collections`, `PUT/DELETE /api/v1/portal/prompt-collections/{id}`, `PUT /api/v1/portal/prompts/{id}/collection` (admin-prefixed equivalents exist). Facets narrow by collection, tag, status (My Prompts), owner (Library), and usage; rows show run count and last-run age with sorts by name, runs, and last run, and prompts never run or unrun for 60+ days carry an inactive badge for dead-prompt detection. Search ranks approved prompts by relevance to a phrase (semantic vector similarity when an embedding provider is configured, keyword fallback otherwise) across the caller's full visibility, best-first; browse mode keeps sortable columns. The same ranking backs the MCP tool `manage_prompt list query=...`. Approved prompts are embedded off the request path by the shared index-jobs framework (source_kind `prompts`); editing a prompt's title, description, body, or tags clears its vector so it re-embeds against the new text. Visibility (own personal, global, matching-persona; all approved for admins) is applied before ranking, so a prompt you cannot read is never returned. Sortable columns, expandable rows with full content and copy button. Scope badges: Personal, Global, Persona, System. Lifecycle status badge (draft, approved, deprecated, superseded) and comma-separated tags on create/edit. Request Promotion on a personal prompt asks an admin to promote it to a chosen persona or to global; the prompt stays personal with a "Promotion requested" badge until an admin approves or rejects it. Share sends a personal prompt directly to another user by email (owner-initiated, no approval): the recipient gets a real runnable prompt, not a markdown snapshot, and it appears in the Prompts page's My Prompts bucket with a shared-by attribution. Personal prompt names are unique per owner; when served over MCP, names carry a scope prefix computed at serve time: `personal-`, `-` (one per persona), `global-`, or `shared-` for prompts shared with the caller, keeping the surface collision-free by construction; every descriptor carries a `title` from display_name. Users never need machine names: agents resolve any handle (stored name, display name, `mcp:prompt:`, or free text) to a ready-to-run prompt with the `manage_prompt` `use` command, which returns rendered content, argument specs, and provenance (including version, approver, and approval time), or ranked candidates when ambiguous. Every database prompt is versioned (#1009): each mutation of content, display name, description, arguments, or tags snapshots an immutable `prompt_versions` row with its author, and approval stamps bind to the specific version approved (approving v5 never alters v4's recorded approval). Editing the content or arguments of an approved global or persona prompt does not change what is served: the edit lands as a pending draft version (manage_prompt update returns status pending_approval; a gated content edit cannot be combined with scope/status changes in one call) and the approved snapshot keeps serving until an admin approves the draft via `POST /api/v1/admin/prompts/{id}/versions/{version}/approve` (reject with `.../reject`; full history with author and content via `GET /api/v1/admin/prompts/{id}/versions`; `GET /api/v1/portal/prompts/{id}/versions` serves history to any caller who can view the prompt (own personal prompts and enabled shared prompts), since history is the library's verification surface; non-admin viewers of a shared prompt get the served history only: applied snapshots in full, the pending draft as a content-redacted stub, and rejected/superseded drafts omitted). The portal prompt page renders this history with per-version approval provenance, flags a pending draft (readers keep being served the approved version), and diffs any version against the current content as a line diff; it also shows point-of-use invocation help (a copyable natural-language invocation built from the stable name and required arguments, resolved by agents via `manage_prompt use`). Metadata-only edits (tags, category, description, display name) apply directly; personal prompts version silently. Served prompts carry provenance: `prompts/get` stamps `prompt_version` / `prompt_approved_by` / `prompt_approved_at` / `prompt_reference` into `_meta`. Usage stats are aggregated from `prompt_serve` audit events (emitted on every database-prompt `prompts/get` and resolved `use`, within the audit retention window): `manage_prompt get` reports `run_count` and `last_run_at`, and `GET /api/v1/admin/prompts/usage` / `GET /api/v1/portal/prompts/usage` return the per-prompt rollup (portal scoped to the caller's visible prompts, including prompts shared person-to-person with them) for library curation and dead-prompt detection. --- diff --git a/docs/llms.txt b/docs/llms.txt index c62c0d0a..76f3abeb 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -51,7 +51,7 @@ mcp-data-platform is the orchestration layer for the txn2 MCP ecosystem. DataHub ## Administration -- [User Portal](https://mcp-data-platform.txn2.com/server/portal-user/): User-facing portal pages: activity analytics, saved assets and collections (each with Mine / Shared / All ownership scopes and per-share access modes: restricted to a recipient, any signed-in user, or public; refused share links land on a branded page offering sign-in with return, and email-share recipients without an account can request single-use, 15-minute view links that open a view-only guest session scoped to that share), resources, feedback threads, knowledge and memory views, and a searchable prompt library +- [User Portal](https://mcp-data-platform.txn2.com/server/portal-user/): User-facing portal pages: activity analytics, saved assets and collections (each with Mine / Shared / All ownership scopes and per-share access modes: restricted to a recipient, any signed-in user, or public; refused share links land on a branded page offering sign-in with return, and email-share recipients without an account can request single-use, 15-minute view links that open a view-only guest session scoped to that share), resources, feedback threads, knowledge and memory views, and a searchable prompt library presented as two buckets (My Prompts with shared-by attribution, and a Library grouped into collections) with usage-based facets and sorting, dead-prompt identification, per-version approval provenance with diffs, and point-of-use invocation help - [Admin Portal](https://mcp-data-platform.txn2.com/server/admin-portal/): Web dashboard for operating the platform: activity dashboards, tool explorer, audit log, knowledge governance, indexing health, connections, personas, API keys, known users, and configuration entries - [Admin API](https://mcp-data-platform.txn2.com/server/admin-api/): REST endpoints backing the admin portal: system info, config, personas, keys, users, audit, knowledge, connections, and index-jobs health. Interactive Swagger UI at /api/v1/admin/docs/ - [Email Notifications](https://mcp-data-platform.txn2.com/server/notifications/): Branded email notifications for shares and feedback: admin-configured SMTP with encrypted password and send-test action, per-user preferences (off, immediate, daily digest), a durable database-backed queue with a retrying send worker, a no-login unsubscribe footer link plus RFC 8058 one-click List-Unsubscribe headers for recipients without an account, Message-ID stamped with the From-address domain, optional terms/privacy footer links, and direct transactional delivery of one-time guest view links diff --git a/docs/server/portal-user.md b/docs/server/portal-user.md index 633ba5b6..aa14dc48 100644 --- a/docs/server/portal-user.md +++ b/docs/server/portal-user.md @@ -157,7 +157,7 @@ Items that other users share with you appear in the corresponding pages, filtere - **Assets**: the Assets page has a Mine / Shared / All scope control. Shared assets show content type badges, tags, sharer email, permission level (Viewer/Editor), file size, and share date - **Collections**: the Collections page has the same Mine / Shared / All scope control, listing shared collections with sharer and access level -- **Prompts**: the Prompts page has a Shared tab. These are real runnable prompts: your agent can invoke a shared prompt over MCP as `shared-` +- **Prompts**: prompts shared with you appear in the Prompts page's **My Prompts** bucket with a "Shared by" attribution. These are real runnable prompts: your agent can invoke a shared prompt over MCP as `shared-`, or resolve it by name with `manage_prompt use` ![Shared Assets](../images/screenshots/light/user-assets-shared-light.webp#only-light)![Shared Assets](../images/screenshots/dark/user-assets-shared-dark.webp#only-dark) @@ -244,39 +244,55 @@ See [Knowledge Capture](../knowledge/overview.md) and [Memory Layer](../memory/o ## Prompts -Prompts are reusable templates that guide AI agent behavior. Users can create personal prompts and browse available global and persona-scoped prompts. +Prompts are reusable templates that guide AI agent behavior: the organization's SOP manual for agent-run procedures. The library presents two buckets: **My Prompts** (your personal prompts, plus prompts shared with you, each attributed to its sharer) and **Library** (the approved team prompts visible to you, grouped by collection). Scope and persona mechanics appear only inside the promote and admin flows. ![Prompts](../images/screenshots/light/user-prompts-light.webp#only-light)![Prompts](../images/screenshots/dark/user-prompts-dark.webp#only-dark) +The Library bucket groups prompts into **collections**: named groups organized by team, domain, or workflow. Uncollected prompts list under a default General group. + +![Prompt library](../images/screenshots/light/user-prompts-library-light.webp#only-light)![Prompt library](../images/screenshots/dark/user-prompts-library-dark.webp#only-dark) + +The **Collections** button opens the manager for creating, renaming, and deleting collections. + +![Manage collections](../images/screenshots/light/user-prompt-collections-light.webp#only-light)![Manage collections](../images/screenshots/dark/user-prompt-collections-dark.webp#only-dark) + Creating a prompt uses an inline markdown editor that auto-extracts `{argument}` placeholders into a typed arguments table. ![Create prompt](../images/screenshots/light/user-prompt-create-light.webp#only-light)![Create prompt](../images/screenshots/dark/user-prompt-create-dark.webp#only-dark) -Opening a prompt shows its rendered content, arguments, and actions (copy, save-as-asset, share, request promotion, edit, delete). +Opening a prompt shows its rendered content, arguments, actions (copy, save-as-asset, share, request promotion, edit, delete), point-of-use invocation help, and the version history described below. ![Prompt viewer](../images/screenshots/light/user-prompt-view-light.webp#only-light)![Prompt viewer](../images/screenshots/dark/user-prompt-view-dark.webp#only-dark) -Three tabs: - -- **Personal**: your own prompts with create, edit, and delete actions -- **Available**: global and persona-scoped prompts you can view and use -- **Shared**: prompts other users shared with you, with sharer and access level - Features: -- **Search** — Type a phrase to rank prompts by relevance to what you mean, not just literal substrings. Results span every prompt you can see (your personal, global, and persona prompts), are ranked best-first, and only include approved prompts. Ranking is semantic (vector similarity) when an embedding provider is configured, with a keyword fallback otherwise. -- **Sortable columns** — Name, scope, description, category (browse mode; search mode preserves relevance order) -- **Expandable rows** — Click the chevron to see the full prompt content, arguments, and copy-to-clipboard button -- **Scope badges** — Personal (gray), Global (blue), Persona (purple), System (amber) -- **Status badges** — Lifecycle state shown on the prompt viewer: draft (gray), approved (emerald), deprecated (amber), superseded (rose) -- **Tags** — Free-form, comma-separated labels for organizing prompts, set on create and edit and shown as chips +- **Search**: Type a phrase to rank prompts by relevance to what you mean, not just literal substrings. Results span your prompts and the Library, are ranked best-first, and only include approved shared prompts; prompts shared with you are matched by name and description. +- **Collections**: Group prompts by team, domain, or workflow. Any user can create collections (the **Collections** button opens the manager); renaming and deleting are limited to the collection's creator or an admin. A prompt belongs to at most one collection: owners assign their own prompts, admins assign shared prompts, from the picker on the prompt page. Deleting a collection releases its prompts to the General group. +- **Facets**: Narrow the list by collection, tag, status (My Prompts), owner (Library), and usage (recently used / never or long unused). +- **Usage columns and sorting**: Every row shows its run count and last-run age, aggregated from prompt-serve audit events. Sort by name, runs, or last run; usage sorts default to most-active-first. Prompts never run, or not run in over 60 days, carry an **inactive** badge so dead prompts are identifiable at a glance. +- **Status badges**: Lifecycle state on your own prompts: draft (gray), approved (emerald), deprecated (amber), superseded (rose) +- **Tags**: Free-form, comma-separated labels for organizing prompts, set on create and edit - **New Prompt** — Create prompts with name, display name, description, content (supports `{arg}` placeholders), category, and tags - **Request Promotion** — On your own personal prompt, ask an admin to promote it to a persona (you choose which) or to global scope. The prompt stays personal and shows a "Promotion requested" badge until an admin approves or rejects it in the admin review queue. - **Share** — Share your prompt directly with another user by email. The recipient gets a real, runnable prompt (with its arguments intact), not a markdown snapshot. "Save as Asset" remains a separate action for exporting the content as a markdown asset. +### Version history and diffs + +The prompt page renders the full version history with per-version approval provenance: each version's author, timestamp, status (applied, draft, superseded, rejected), and, bound to that specific version, who approved it and when. A pending draft on an approved shared prompt is flagged with a banner: readers keep being served the approved version until an admin approves the draft. Any version can be diffed against the current content as a line diff. + +![Library prompt with versions](../images/screenshots/light/user-prompt-view-library-light.webp#only-light)![Library prompt with versions](../images/screenshots/dark/user-prompt-view-library-dark.webp#only-dark) + +![Version diff](../images/screenshots/light/user-prompt-version-diff-light.webp#only-light)![Version diff](../images/screenshots/dark/user-prompt-version-diff-dark.webp#only-dark) + +Version history is visible to anyone who can view the prompt: your own prompts, and enabled Library prompts. Library readers see the served history: applied snapshots in full, and a pending draft as an author/date stub whose content stays private until an admin approves it; rejected and superseded drafts (never served) appear only to admins. A prompt shared with you person-to-person shows only its served content. + +### Run from chat + +The prompt page includes a copyable natural-language invocation ("Run the `` prompt with ..."), built from the prompt's stable name and required arguments. Paste it into any connected chat client; the agent resolves the name against the prompt library with `manage_prompt use`. + ### Sharing a prompt -Open your prompt and choose **Share**, then enter a recipient's email. The recipient sees it on the Prompts page's **Shared** tab and their agent can run it over MCP as `shared-` (auto-deduplicated if names collide). Sharing is owner-initiated and does not require admin approval; revoke a share any time from the Share dialog. Markdown export ("Save as Asset") is a distinct action for documentation or external sharing. +Open your prompt and choose **Share**, then enter a recipient's email. The recipient sees it in the Prompts page's **My Prompts** bucket, attributed to you, and their agent can run it over MCP as `shared-` (auto-deduplicated if names collide). Sharing is owner-initiated and does not require admin approval; revoke a share any time from the Share dialog. Markdown export ("Save as Asset") is a distinct action for documentation or external sharing. ### Requesting promotion diff --git a/internal/apidocs/docs.go b/internal/apidocs/docs.go index b39f7568..6a01cd12 100644 --- a/internal/apidocs/docs.go +++ b/internal/apidocs/docs.go @@ -5267,6 +5267,251 @@ const docTemplate = `{ } } }, + "/admin/prompt-collections": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns every prompt collection with its member prompt count, ordered by name.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt collections", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.collectionListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Creates a named collection organizing the prompt library. Names are unique case-insensitively.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Create prompt collection", + "parameters": [ + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompt-collections/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Renames or re-describes a collection.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Update prompt collection", + "parameters": [ + { + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Deletes a collection; member prompts are released to the default (uncollected) group.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Delete prompt collection", + "parameters": [ + { + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/admin/prompts": { "get": { "security": [ @@ -5647,8 +5892,8 @@ const docTemplate = `{ } } }, - "/admin/prompts/{id}/reject": { - "post": { + "/admin/prompts/{id}/collection": { + "put": { "security": [ { "ApiKeyAuth": [] @@ -5657,14 +5902,17 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Clears a personal prompt's pending promotion request, leaving it personal.", + "description": "Places a prompt in a collection, or clears the assignment with an empty collection_id. Placement is organizational metadata: no version is produced and no review is triggered.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "Prompts" ], - "summary": "Reject prompt promotion", + "summary": "Assign prompt to collection", "parameters": [ { "type": "string", @@ -5672,6 +5920,15 @@ const docTemplate = `{ "name": "id", "in": "path", "required": true + }, + { + "description": "Assignment", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.assignCollectionRequest" + } } ], "responses": { @@ -5681,14 +5938,87 @@ const docTemplate = `{ "$ref": "#/definitions/prompt.Prompt" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { - "$ref": "#/definitions/admin.problemDetail" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "500": { - "description": "Internal Server Error", + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/reject": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Clears a personal prompt's pending promotion request, leaving it personal.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Reject prompt promotion", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Prompt" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/admin.problemDetail" + } + }, + "500": { + "description": "Internal Server Error", "schema": { "$ref": "#/definitions/admin.problemDetail" } @@ -10111,48 +10441,331 @@ const docTemplate = `{ "$ref": "#/definitions/portal.memoryStatsResponse" } }, - "401": { - "description": "Unauthorized", + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/portal.problemDetail" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/portal.problemDetail" + } + } + } + } + }, + "/portal/notification-prefs": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns the calling user's email notification preferences. Users with no stored preferences get the defaults (immediate delivery, all categories on).", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get my notification preferences", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/notification.PrefsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Updates the calling user's email notification preferences. Omitted fields are left unchanged. Server-side self-scope: only the caller's own preferences are ever written.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Update my notification preferences", + "parameters": [ + { + "description": "Preference changes", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/notification.PrefsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/notification.PrefsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/portal/prompt-collections": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns every prompt collection with its member prompt count, ordered by name.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt collections (portal)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.collectionListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Creates a named collection organizing the prompt library. Names are unique case-insensitively.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Create prompt collection (portal)", + "parameters": [ + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/portal/prompt-collections/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Renames or re-describes a collection the caller created; admins may update any collection.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Update prompt collection (portal)", + "parameters": [ + { + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", "schema": { - "$ref": "#/definitions/portal.problemDetail" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "500": { - "description": "Internal Server Error", + "404": { + "description": "Not Found", "schema": { - "$ref": "#/definitions/portal.problemDetail" + "type": "object", + "additionalProperties": { + "type": "string" + } } - } - } - } - }, - "/portal/notification-prefs": { - "get": { - "security": [ - { - "ApiKeyAuth": [] }, - { - "BearerAuth": [] - } - ], - "description": "Returns the calling user's email notification preferences. Users with no stored preferences get the defaults (immediate delivery, all categories on).", - "produces": [ - "application/json" - ], - "tags": [ - "Notifications" - ], - "summary": "Get my notification preferences", - "responses": { - "200": { - "description": "OK", + "409": { + "description": "Conflict", "schema": { - "$ref": "#/definitions/notification.PrefsResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "401": { - "description": "Unauthorized", + "500": { + "description": "Internal Server Error", "schema": { "type": "object", "additionalProperties": { @@ -10162,7 +10775,7 @@ const docTemplate = `{ } } }, - "put": { + "delete": { "security": [ { "ApiKeyAuth": [] @@ -10171,37 +10784,35 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Updates the calling user's email notification preferences. Omitted fields are left unchanged. Server-side self-scope: only the caller's own preferences are ever written.", - "consumes": [ - "application/json" - ], + "description": "Deletes a collection the caller created; admins may delete any collection. Member prompts are released to the default (uncollected) group.", "produces": [ "application/json" ], "tags": [ - "Notifications" + "Prompts" ], - "summary": "Update my notification preferences", + "summary": "Delete prompt collection (portal)", "parameters": [ { - "description": "Preference changes", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/notification.PrefsRequest" - } + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/notification.PrefsResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "400": { - "description": "Bad Request", + "401": { + "description": "Unauthorized", "schema": { "type": "object", "additionalProperties": { @@ -10209,8 +10820,26 @@ const docTemplate = `{ } } }, - "401": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", "schema": { "type": "object", "additionalProperties": { @@ -10588,6 +11217,100 @@ const docTemplate = `{ } } }, + "/portal/prompts/{id}/collection": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Places a prompt in a collection, or clears the assignment with an empty collection_id. Owners organize their own prompts; admins organize shared prompts.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Assign prompt to collection (portal)", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Assignment", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.assignCollectionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Prompt" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/portal/prompts/{id}/shares": { "get": { "security": [ @@ -10727,7 +11450,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Returns the version history of a prompt the caller owns; admins may read any prompt's history.", + "description": "Returns the version history of a prompt visible to the caller: their own personal prompts, and enabled shared (global or persona-matching) prompts.", "produces": [ "application/json" ], @@ -17431,6 +18154,39 @@ const docTemplate = `{ } } }, + "prompt.Collection": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-07-01T14:30:00Z" + }, + "created_by": { + "type": "string", + "example": "jane@example.com" + }, + "description": { + "type": "string", + "example": "Weekly and daily sales SOPs" + }, + "id": { + "type": "string", + "example": "col_a1b2c3d4" + }, + "name": { + "type": "string", + "example": "Sales Reporting" + }, + "prompt_count": { + "type": "integer", + "example": 7 + }, + "updated_at": { + "type": "string", + "example": "2026-07-01T14:30:00Z" + } + } + }, "prompt.EditOutcome": { "type": "object", "properties": { @@ -17464,6 +18220,11 @@ const docTemplate = `{ "type": "string", "example": "analysis" }, + "collection_id": { + "description": "CollectionID places the prompt in at most one collection (#1010); empty\nmeans uncollected. Placement is organizational metadata, not reviewable\nsubstance: it is never versioned and changing it never triggers review.", + "type": "string", + "example": "col_a1b2c3d4" + }, "content": { "type": "string", "example": "Analyze sales data for {date} grouped by region." @@ -17720,6 +18481,43 @@ const docTemplate = `{ } } }, + "versionhttp.assignCollectionRequest": { + "type": "object", + "properties": { + "collection_id": { + "type": "string", + "example": "col_a1b2c3d4" + } + } + }, + "versionhttp.collectionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "total": { + "type": "integer", + "example": 3 + } + } + }, + "versionhttp.collectionRequest": { + "type": "object", + "properties": { + "description": { + "type": "string", + "example": "Weekly and daily sales SOPs" + }, + "name": { + "type": "string", + "example": "Sales Reporting" + } + } + }, "versionhttp.versionListResponse": { "type": "object", "properties": { diff --git a/internal/apidocs/swagger.json b/internal/apidocs/swagger.json index 95d0fd03..64a39c92 100644 --- a/internal/apidocs/swagger.json +++ b/internal/apidocs/swagger.json @@ -5261,6 +5261,251 @@ } } }, + "/admin/prompt-collections": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns every prompt collection with its member prompt count, ordered by name.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt collections", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.collectionListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Creates a named collection organizing the prompt library. Names are unique case-insensitively.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Create prompt collection", + "parameters": [ + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompt-collections/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Renames or re-describes a collection.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Update prompt collection", + "parameters": [ + { + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Deletes a collection; member prompts are released to the default (uncollected) group.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Delete prompt collection", + "parameters": [ + { + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/admin/prompts": { "get": { "security": [ @@ -5641,8 +5886,8 @@ } } }, - "/admin/prompts/{id}/reject": { - "post": { + "/admin/prompts/{id}/collection": { + "put": { "security": [ { "ApiKeyAuth": [] @@ -5651,14 +5896,17 @@ "BearerAuth": [] } ], - "description": "Clears a personal prompt's pending promotion request, leaving it personal.", + "description": "Places a prompt in a collection, or clears the assignment with an empty collection_id. Placement is organizational metadata: no version is produced and no review is triggered.", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ "Prompts" ], - "summary": "Reject prompt promotion", + "summary": "Assign prompt to collection", "parameters": [ { "type": "string", @@ -5666,6 +5914,15 @@ "name": "id", "in": "path", "required": true + }, + { + "description": "Assignment", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.assignCollectionRequest" + } } ], "responses": { @@ -5675,14 +5932,87 @@ "$ref": "#/definitions/prompt.Prompt" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { - "$ref": "#/definitions/admin.problemDetail" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "500": { - "description": "Internal Server Error", + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/reject": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Clears a personal prompt's pending promotion request, leaving it personal.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Reject prompt promotion", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Prompt" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/admin.problemDetail" + } + }, + "500": { + "description": "Internal Server Error", "schema": { "$ref": "#/definitions/admin.problemDetail" } @@ -10105,48 +10435,331 @@ "$ref": "#/definitions/portal.memoryStatsResponse" } }, - "401": { - "description": "Unauthorized", + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/portal.problemDetail" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/portal.problemDetail" + } + } + } + } + }, + "/portal/notification-prefs": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns the calling user's email notification preferences. Users with no stored preferences get the defaults (immediate delivery, all categories on).", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get my notification preferences", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/notification.PrefsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Updates the calling user's email notification preferences. Omitted fields are left unchanged. Server-side self-scope: only the caller's own preferences are ever written.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Update my notification preferences", + "parameters": [ + { + "description": "Preference changes", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/notification.PrefsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/notification.PrefsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/portal/prompt-collections": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns every prompt collection with its member prompt count, ordered by name.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt collections (portal)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.collectionListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Creates a named collection organizing the prompt library. Names are unique case-insensitively.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Create prompt collection (portal)", + "parameters": [ + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/portal/prompt-collections/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Renames or re-describes a collection the caller created; admins may update any collection.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Update prompt collection (portal)", + "parameters": [ + { + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Collection", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.collectionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", "schema": { - "$ref": "#/definitions/portal.problemDetail" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "500": { - "description": "Internal Server Error", + "404": { + "description": "Not Found", "schema": { - "$ref": "#/definitions/portal.problemDetail" + "type": "object", + "additionalProperties": { + "type": "string" + } } - } - } - } - }, - "/portal/notification-prefs": { - "get": { - "security": [ - { - "ApiKeyAuth": [] }, - { - "BearerAuth": [] - } - ], - "description": "Returns the calling user's email notification preferences. Users with no stored preferences get the defaults (immediate delivery, all categories on).", - "produces": [ - "application/json" - ], - "tags": [ - "Notifications" - ], - "summary": "Get my notification preferences", - "responses": { - "200": { - "description": "OK", + "409": { + "description": "Conflict", "schema": { - "$ref": "#/definitions/notification.PrefsResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "401": { - "description": "Unauthorized", + "500": { + "description": "Internal Server Error", "schema": { "type": "object", "additionalProperties": { @@ -10156,7 +10769,7 @@ } } }, - "put": { + "delete": { "security": [ { "ApiKeyAuth": [] @@ -10165,37 +10778,35 @@ "BearerAuth": [] } ], - "description": "Updates the calling user's email notification preferences. Omitted fields are left unchanged. Server-side self-scope: only the caller's own preferences are ever written.", - "consumes": [ - "application/json" - ], + "description": "Deletes a collection the caller created; admins may delete any collection. Member prompts are released to the default (uncollected) group.", "produces": [ "application/json" ], "tags": [ - "Notifications" + "Prompts" ], - "summary": "Update my notification preferences", + "summary": "Delete prompt collection (portal)", "parameters": [ { - "description": "Preference changes", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/notification.PrefsRequest" - } + "type": "string", + "description": "Collection ID", + "name": "id", + "in": "path", + "required": true } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/notification.PrefsResponse" + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "400": { - "description": "Bad Request", + "401": { + "description": "Unauthorized", "schema": { "type": "object", "additionalProperties": { @@ -10203,8 +10814,26 @@ } } }, - "401": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", "schema": { "type": "object", "additionalProperties": { @@ -10582,6 +11211,100 @@ } } }, + "/portal/prompts/{id}/collection": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Places a prompt in a collection, or clears the assignment with an empty collection_id. Owners organize their own prompts; admins organize shared prompts.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Assign prompt to collection (portal)", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Assignment", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/versionhttp.assignCollectionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Prompt" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/portal/prompts/{id}/shares": { "get": { "security": [ @@ -10721,7 +11444,7 @@ "BearerAuth": [] } ], - "description": "Returns the version history of a prompt the caller owns; admins may read any prompt's history.", + "description": "Returns the version history of a prompt visible to the caller: their own personal prompts, and enabled shared (global or persona-matching) prompts.", "produces": [ "application/json" ], @@ -17425,6 +18148,39 @@ } } }, + "prompt.Collection": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-07-01T14:30:00Z" + }, + "created_by": { + "type": "string", + "example": "jane@example.com" + }, + "description": { + "type": "string", + "example": "Weekly and daily sales SOPs" + }, + "id": { + "type": "string", + "example": "col_a1b2c3d4" + }, + "name": { + "type": "string", + "example": "Sales Reporting" + }, + "prompt_count": { + "type": "integer", + "example": 7 + }, + "updated_at": { + "type": "string", + "example": "2026-07-01T14:30:00Z" + } + } + }, "prompt.EditOutcome": { "type": "object", "properties": { @@ -17458,6 +18214,11 @@ "type": "string", "example": "analysis" }, + "collection_id": { + "description": "CollectionID places the prompt in at most one collection (#1010); empty\nmeans uncollected. Placement is organizational metadata, not reviewable\nsubstance: it is never versioned and changing it never triggers review.", + "type": "string", + "example": "col_a1b2c3d4" + }, "content": { "type": "string", "example": "Analyze sales data for {date} grouped by region." @@ -17714,6 +18475,43 @@ } } }, + "versionhttp.assignCollectionRequest": { + "type": "object", + "properties": { + "collection_id": { + "type": "string", + "example": "col_a1b2c3d4" + } + } + }, + "versionhttp.collectionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/prompt.Collection" + } + }, + "total": { + "type": "integer", + "example": 3 + } + } + }, + "versionhttp.collectionRequest": { + "type": "object", + "properties": { + "description": { + "type": "string", + "example": "Weekly and daily sales SOPs" + }, + "name": { + "type": "string", + "example": "Sales Reporting" + } + } + }, "versionhttp.versionListResponse": { "type": "object", "properties": { diff --git a/internal/apidocs/swagger.yaml b/internal/apidocs/swagger.yaml index 5f9fea97..34dd69d8 100644 --- a/internal/apidocs/swagger.yaml +++ b/internal/apidocs/swagger.yaml @@ -3656,6 +3656,30 @@ definitions: example: true type: boolean type: object + prompt.Collection: + properties: + created_at: + example: "2026-07-01T14:30:00Z" + type: string + created_by: + example: jane@example.com + type: string + description: + example: Weekly and daily sales SOPs + type: string + id: + example: col_a1b2c3d4 + type: string + name: + example: Sales Reporting + type: string + prompt_count: + example: 7 + type: integer + updated_at: + example: "2026-07-01T14:30:00Z" + type: string + type: object prompt.EditOutcome: properties: applied: @@ -3682,6 +3706,13 @@ definitions: category: example: analysis type: string + collection_id: + description: |- + CollectionID places the prompt in at most one collection (#1010); empty + means uncollected. Placement is organizational metadata, not reviewable + substance: it is never versioned and changing it never triggers review. + example: col_a1b2c3d4 + type: string content: example: Analyze sales data for {date} grouped by region. type: string @@ -3875,6 +3906,31 @@ definitions: reachable: type: boolean type: object + versionhttp.assignCollectionRequest: + properties: + collection_id: + example: col_a1b2c3d4 + type: string + type: object + versionhttp.collectionListResponse: + properties: + data: + items: + $ref: '#/definitions/prompt.Collection' + type: array + total: + example: 3 + type: integer + type: object + versionhttp.collectionRequest: + properties: + description: + example: Weekly and daily sales SOPs + type: string + name: + example: Sales Reporting + type: string + type: object versionhttp.versionListResponse: properties: data: @@ -7247,6 +7303,162 @@ paths: summary: Preview a persona's decision for a tool tags: - Personas + /admin/prompt-collections: + get: + description: Returns every prompt collection with its member prompt count, ordered + by name. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/versionhttp.collectionListResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: List prompt collections + tags: + - Prompts + post: + consumes: + - application/json + description: Creates a named collection organizing the prompt library. Names + are unique case-insensitively. + parameters: + - description: Collection + in: body + name: request + required: true + schema: + $ref: '#/definitions/versionhttp.collectionRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/prompt.Collection' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Create prompt collection + tags: + - Prompts + /admin/prompt-collections/{id}: + delete: + description: Deletes a collection; member prompts are released to the default + (uncollected) group. + parameters: + - description: Collection ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Delete prompt collection + tags: + - Prompts + put: + consumes: + - application/json + description: Renames or re-describes a collection. + parameters: + - description: Collection ID + in: path + name: id + required: true + type: string + - description: Collection + in: body + name: request + required: true + schema: + $ref: '#/definitions/versionhttp.collectionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/prompt.Collection' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Update prompt collection + tags: + - Prompts /admin/prompts: get: description: Returns all prompts across all scopes, including system-registered @@ -7464,6 +7676,62 @@ paths: summary: Approve prompt promotion tags: - Prompts + /admin/prompts/{id}/collection: + put: + consumes: + - application/json + description: 'Places a prompt in a collection, or clears the assignment with + an empty collection_id. Placement is organizational metadata: no version is + produced and no review is triggered.' + parameters: + - description: Prompt ID + in: path + name: id + required: true + type: string + - description: Assignment + in: body + name: request + required: true + schema: + $ref: '#/definitions/versionhttp.assignCollectionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/prompt.Prompt' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Assign prompt to collection + tags: + - Prompts /admin/prompts/{id}/reject: post: description: Clears a personal prompt's pending promotion request, leaving it @@ -10419,6 +10687,199 @@ paths: summary: Update my notification preferences tags: - Notifications + /portal/prompt-collections: + get: + description: Returns every prompt collection with its member prompt count, ordered + by name. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/versionhttp.collectionListResponse' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: List prompt collections (portal) + tags: + - Prompts + post: + consumes: + - application/json + description: Creates a named collection organizing the prompt library. Names + are unique case-insensitively. + parameters: + - description: Collection + in: body + name: request + required: true + schema: + $ref: '#/definitions/versionhttp.collectionRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/prompt.Collection' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Create prompt collection (portal) + tags: + - Prompts + /portal/prompt-collections/{id}: + delete: + description: Deletes a collection the caller created; admins may delete any + collection. Member prompts are released to the default (uncollected) group. + parameters: + - description: Collection ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Delete prompt collection (portal) + tags: + - Prompts + put: + consumes: + - application/json + description: Renames or re-describes a collection the caller created; admins + may update any collection. + parameters: + - description: Collection ID + in: path + name: id + required: true + type: string + - description: Collection + in: body + name: request + required: true + schema: + $ref: '#/definitions/versionhttp.collectionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/prompt.Collection' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Update prompt collection (portal) + tags: + - Prompts /portal/prompts: get: description: Returns the user's personal prompts plus available global, persona, @@ -10580,6 +11041,68 @@ paths: summary: Update personal prompt tags: - Prompts + /portal/prompts/{id}/collection: + put: + consumes: + - application/json + description: Places a prompt in a collection, or clears the assignment with + an empty collection_id. Owners organize their own prompts; admins organize + shared prompts. + parameters: + - description: Prompt ID + in: path + name: id + required: true + type: string + - description: Assignment + in: body + name: request + required: true + schema: + $ref: '#/definitions/versionhttp.assignCollectionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/prompt.Prompt' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Assign prompt to collection (portal) + tags: + - Prompts /portal/prompts/{id}/shares: get: description: Returns all shares for a prompt. Only the owner can view them. @@ -10664,8 +11187,9 @@ paths: - Prompts /portal/prompts/{id}/versions: get: - description: Returns the version history of a prompt the caller owns; admins - may read any prompt's history. + description: 'Returns the version history of a prompt visible to the caller: + their own personal prompts, and enabled shared (global or persona-matching) + prompts.' parameters: - description: Prompt ID in: path diff --git a/internal/httpserver/dbmounts.go b/internal/httpserver/dbmounts.go index 2ef3e396..7d588db9 100644 --- a/internal/httpserver/dbmounts.go +++ b/internal/httpserver/dbmounts.go @@ -15,6 +15,7 @@ package httpserver // mounts.go where their coverage is measured normally. import ( + "context" "errors" "log" "net/http" @@ -148,13 +149,29 @@ func promptVersionDeps(p *platform.Platform) (versionhttp.Deps, bool) { return versionhttp.Deps{}, false } deps := versionhttp.Deps{ - Store: store, - Versions: versions, - Registrar: p, + Store: store, + Versions: versions, + Registrar: p, + Collections: prompt.AsCollectionStore(store), } if s := p.AuditStore(); s != nil { deps.Usage = s } + // Prompts shared person-to-person are as visible as the caller's own, so + // their usage joins the portal rollup (#1010). + if ss := p.PortalShareStore(); ss != nil { + deps.SharedPromptIDs = func(ctx context.Context, userID, email string) ([]string, error) { + refs, err := ss.ListSharedPromptsWithUser(ctx, userID, email) + if err != nil { + return nil, err //nolint:wrapcheck // handler maps any failure to one HTTP error + } + ids := make([]string, 0, len(refs)) + for _, ref := range refs { + ids = append(ids, ref.PromptID) + } + return ids, nil + } + } return deps, true } diff --git a/internal/httpserver/promptversions.go b/internal/httpserver/promptversions.go index 5f9b845c..cce29c61 100644 --- a/internal/httpserver/promptversions.go +++ b/internal/httpserver/promptversions.go @@ -38,7 +38,7 @@ func portalIdentityResolver(adminRoles []string, resolver portal.PersonaResolver if user == nil { return nil } - id := &versionhttp.PortalIdentity{Email: user.Email, IsAdmin: rolesIntersect(user.Roles, adminRoles)} + id := &versionhttp.PortalIdentity{UserID: user.UserID, Email: user.Email, IsAdmin: rolesIntersect(user.Roles, adminRoles)} if resolver != nil { if pi := resolver(user.Roles); pi != nil { id.Persona = pi.Name diff --git a/internal/platform/promptlayer/listchanged.go b/internal/platform/promptlayer/listchanged.go index 4c4979f0..2b5a2449 100644 --- a/internal/platform/promptlayer/listchanged.go +++ b/internal/platform/promptlayer/listchanged.go @@ -51,7 +51,10 @@ func (h *Handle) notifyListChanged() { // beyond prompt.Store are Search (prompt.Searcher) and the versioning methods // (prompt.VersionStore, asserted by prompt.ApplyEdit and the composition // root). If a future extension interface is introduced, it must be forwarded -// here too, or the wrapper will silently drop it. +// here too, or the wrapper will silently drop it. Capabilities that need no +// write hook (prompt.CollectionStore) are instead exposed through the +// prompt.CollectionProvider accessor on notifyingStore, which every wrapper +// shape inherits, so they add no combinations here. func wrapStore(base prompt.Store, notify func()) prompt.Store { ns := ¬ifyingStore{Store: base, notify: notify} searcher, hasSearch := base.(prompt.Searcher) @@ -104,6 +107,15 @@ func (s *notifyingSearchStore) Search(ctx context.Context, q prompt.SearchQuery) return s.searcher.Search(ctx, q) //nolint:wrapcheck // transparent decorator: pass the searcher's error through unchanged } +// Collections exposes the wrapped store's collection capability (#1010) via +// prompt.CollectionProvider. Collection writes organize the portal library +// only — they never change the MCP prompt list, so they need no list_changed +// hook and pass through undecorated. Defined on the embedded base wrapper so +// every capability-combination shape built in wrapStore inherits it. +func (s *notifyingStore) Collections() prompt.CollectionStore { + return prompt.AsCollectionStore(s.Store) +} + // Create persists a new prompt and notifies on success. func (s *notifyingStore) Create(ctx context.Context, p *prompt.Prompt) error { if err := s.Store.Create(ctx, p); err != nil { @@ -193,12 +205,14 @@ type atomicNotifier = atomic.Pointer[ListChangedNotifier] // extension (and thus knowledge.PromptSearcher, which is Search + the embedded // GetByID) so the up-casts across the codebase continue to succeed. var ( - _ prompt.Store = (*notifyingStore)(nil) - _ prompt.Store = (*notifyingSearchStore)(nil) - _ prompt.Searcher = (*notifyingSearchStore)(nil) - _ prompt.Store = (*notifyingVersionOnlyStore)(nil) - _ prompt.VersionStore = (*notifyingVersionOnlyStore)(nil) - _ prompt.Store = (*notifyingSearchVersionStore)(nil) - _ prompt.Searcher = (*notifyingSearchVersionStore)(nil) - _ prompt.VersionStore = (*notifyingSearchVersionStore)(nil) + _ prompt.Store = (*notifyingStore)(nil) + _ prompt.CollectionProvider = (*notifyingStore)(nil) + _ prompt.CollectionProvider = (*notifyingSearchVersionStore)(nil) + _ prompt.Store = (*notifyingSearchStore)(nil) + _ prompt.Searcher = (*notifyingSearchStore)(nil) + _ prompt.Store = (*notifyingVersionOnlyStore)(nil) + _ prompt.VersionStore = (*notifyingVersionOnlyStore)(nil) + _ prompt.Store = (*notifyingSearchVersionStore)(nil) + _ prompt.Searcher = (*notifyingSearchVersionStore)(nil) + _ prompt.VersionStore = (*notifyingSearchVersionStore)(nil) ) diff --git a/internal/platform/promptlayer/listchanged_test.go b/internal/platform/promptlayer/listchanged_test.go index 28df7ffa..3173b611 100644 --- a/internal/platform/promptlayer/listchanged_test.go +++ b/internal/platform/promptlayer/listchanged_test.go @@ -172,6 +172,31 @@ func TestNotifyingStore_PreservesSearchCapability(t *testing.T) { }) } +// collectionCapableStore is a base store that also carries the collection +// capability (the production postgres store's shape for #1010). +type collectionCapableStore struct { + *mockPromptStore + prompt.CollectionStore +} + +// TestNotifyingStore_ExposesCollectionCapability proves the wrapper surfaces +// the base store's prompt.CollectionStore through the CollectionProvider +// accessor (collection writes need no notification hook, so the capability is +// exposed rather than decorated) and does not fabricate it for incapable +// bases. +func TestNotifyingStore_ExposesCollectionCapability(t *testing.T) { + capable := &collectionCapableStore{mockPromptStore: newMockPromptStore()} + h := New(Config{Store: capable}) + if got := prompt.AsCollectionStore(h.Store()); got == nil { + t.Error("wrapped collection-capable store must resolve via AsCollectionStore") + } + + h = New(Config{Store: newMockPromptStore()}) + if got := prompt.AsCollectionStore(h.Store()); got != nil { + t.Errorf("wrapping an incapable store must not fabricate a CollectionStore, got %T", got) + } +} + // TestSetListChangedNotifier_NilHandle proves the setter is safe on a nil Handle // (mirrors SetEmbedder / SetShareStore). notifyListChanged is only ever reached // through the notifying store built by New on a non-nil Handle, so it needs no diff --git a/pkg/database/migrate/migrate_realpg_test.go b/pkg/database/migrate/migrate_realpg_test.go index e0fcc8a4..8a879af7 100644 --- a/pkg/database/migrate/migrate_realpg_test.go +++ b/pkg/database/migrate/migrate_realpg_test.go @@ -14,7 +14,7 @@ import ( // expectedFinalVersion is the highest migration the embedded set defines. Bump // this when adding a migration so the gate asserts the full set applied. -const expectedFinalVersion = 85 +const expectedFinalVersion = 86 // TestMigrationsAgainstRealPostgres applies the embedded migrations to a real // PostgreSQL (pgvector) instance and exercises the full lifecycle: up, seed, diff --git a/pkg/database/migrate/migrate_unit_test.go b/pkg/database/migrate/migrate_unit_test.go index f921000d..f8bbcb5f 100644 --- a/pkg/database/migrate/migrate_unit_test.go +++ b/pkg/database/migrate/migrate_unit_test.go @@ -15,7 +15,7 @@ import ( ) const ( - migrateTestFileCount = 170 + migrateTestFileCount = 172 migrateTestSuccess = "success" migrateTestFactoryError = "factory error" ) diff --git a/pkg/database/migrate/migrations/000086_prompt_collections.down.sql b/pkg/database/migrate/migrations/000086_prompt_collections.down.sql new file mode 100644 index 00000000..5bb39d85 --- /dev/null +++ b/pkg/database/migrate/migrations/000086_prompt_collections.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_prompts_collection; +ALTER TABLE prompts DROP COLUMN IF EXISTS collection_id; +DROP INDEX IF EXISTS uq_prompt_collections_name; +DROP TABLE IF EXISTS prompt_collections; diff --git a/pkg/database/migrate/migrations/000086_prompt_collections.up.sql b/pkg/database/migrate/migrations/000086_prompt_collections.up.sql new file mode 100644 index 00000000..bbea5043 --- /dev/null +++ b/pkg/database/migrate/migrations/000086_prompt_collections.up.sql @@ -0,0 +1,28 @@ +-- Prompt collections (#1010): named groups organizing the prompt library by +-- team, domain, or workflow. Collections are org-visible entities; a prompt +-- belongs to at most one collection (prompts.collection_id), and uncollected +-- prompts list under a default group in the portal. Collections replace +-- free-text category as the primary organizing structure; category remains a +-- legacy filter until content migrates. + +CREATE TABLE IF NOT EXISTS prompt_collections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Collection names are an org-wide vocabulary; case-insensitive uniqueness +-- prevents "Sales" and "sales" coexisting as distinct groups. +CREATE UNIQUE INDEX IF NOT EXISTS uq_prompt_collections_name + ON prompt_collections (LOWER(name)); + +-- At-most-one collection per prompt. Deleting a collection releases its +-- prompts to the default (uncollected) group rather than deleting them. +ALTER TABLE prompts ADD COLUMN IF NOT EXISTS collection_id UUID + REFERENCES prompt_collections(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_prompts_collection + ON prompts (collection_id) WHERE collection_id IS NOT NULL; diff --git a/pkg/prompt/collection.go b/pkg/prompt/collection.go new file mode 100644 index 00000000..fba58d55 --- /dev/null +++ b/pkg/prompt/collection.go @@ -0,0 +1,113 @@ +package prompt + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +// maxCollectionNameLength and maxCollectionDescriptionLength bound a +// collection's display name and description. +const ( + maxCollectionNameLength = 128 + maxCollectionDescriptionLength = 2000 +) + +// ErrCollectionExists rejects a create or rename that collides with an +// existing collection name (names are an org-wide vocabulary, unique +// case-insensitively). REST handlers map it to 409. +var ErrCollectionExists = errors.New("a collection with that name already exists") + +// ErrCollectionNotFound rejects an assignment to a collection that does not +// exist (e.g. deleted between listing and assigning). REST handlers map it to +// 404. +var ErrCollectionNotFound = errors.New("collection not found") + +// Collection is a named group organizing the prompt library by team, domain, +// or workflow (#1010). Collections are visible to every portal user; a prompt +// belongs to at most one collection. +type Collection struct { + ID string `json:"id" example:"col_a1b2c3d4"` + Name string `json:"name" example:"Sales Reporting"` + Description string `json:"description" example:"Weekly and daily sales SOPs"` + CreatedBy string `json:"created_by" example:"jane@example.com"` + PromptCount int `json:"prompt_count" example:"7"` + CreatedAt time.Time `json:"created_at" example:"2026-07-01T14:30:00Z"` + UpdatedAt time.Time `json:"updated_at" example:"2026-07-01T14:30:00Z"` +} + +// ValidateCollectionName checks that a collection name is present and bounded. +// Collection names are free-text display names (unlike prompt names, which are +// invocation identifiers), so only presence and length are enforced. +func ValidateCollectionName(name string) error { + if strings.TrimSpace(name) == "" { + return errors.New("collection name is required") + } + if len(name) > maxCollectionNameLength { + return fmt.Errorf("collection name must be at most %d characters", maxCollectionNameLength) + } + return nil +} + +// ValidateCollectionDescription bounds a collection description. +func ValidateCollectionDescription(desc string) error { + if len(desc) > maxCollectionDescriptionLength { + return fmt.Errorf("collection description must be at most %d characters", maxCollectionDescriptionLength) + } + return nil +} + +// CollectionStore is the optional collection capability of a prompt store. +// The PostgreSQL store implements it; deployments without a database have no +// collections and the REST routes are not mounted. +type CollectionStore interface { + // CreateCollection persists a new collection, generating its ID. Returns + // ErrCollectionExists on a name collision. + CreateCollection(ctx context.Context, c *Collection) error + + // GetCollection retrieves a collection by ID. Returns nil, nil if not found. + GetCollection(ctx context.Context, id string) (*Collection, error) + + // ListCollections returns every collection with its prompt count, ordered + // by name. + ListCollections(ctx context.Context) ([]Collection, error) + + // UpdateCollection renames or re-describes a collection. Returns + // ErrCollectionExists on a name collision. + UpdateCollection(ctx context.Context, id, name, description string) error + + // DeleteCollection removes a collection, releasing its prompts to the + // default (uncollected) group. + DeleteCollection(ctx context.Context, id string) error + + // SetPromptCollection assigns a prompt to a collection, or clears the + // assignment when collectionID is empty. It touches only the assignment: + // no version snapshot is produced and the review gate is not involved + // (placement is not reviewable substance). + SetPromptCollection(ctx context.Context, promptID, collectionID string) error +} + +// CollectionProvider exposes the collection capability through store +// decorators that would otherwise hide it from a type assertion. The +// promptlayer notifying wrapper implements it by delegating to the wrapped +// store; the composition root resolves the capability with AsCollectionStore. +type CollectionProvider interface { + // Collections returns the underlying collection capability, or nil when + // the backing store does not support collections. + Collections() CollectionStore +} + +// AsCollectionStore resolves the collection capability from a prompt store, +// looking through any decorator that implements CollectionProvider. Returns +// nil when the store has no collection support. +func AsCollectionStore(store Store) CollectionStore { + if cs, ok := store.(CollectionStore); ok { + return cs + } + if cp, ok := store.(CollectionProvider); ok { + return cp.Collections() + } + return nil +} diff --git a/pkg/prompt/collection_test.go b/pkg/prompt/collection_test.go new file mode 100644 index 00000000..610b82c0 --- /dev/null +++ b/pkg/prompt/collection_test.go @@ -0,0 +1,67 @@ +package prompt + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateCollectionName(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {"valid", "Sales Reporting", ""}, + {"empty", "", "required"}, + {"whitespace only", " ", "required"}, + {"max length", strings.Repeat("a", 128), ""}, + {"too long", strings.Repeat("a", 129), "at most 128"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateCollectionName(tt.input) + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + +// capableStore implements Store plus CollectionStore. +type capableStore struct { + Store + CollectionStore +} + +// providerStore implements Store plus CollectionProvider, the decorator shape. +type providerStore struct { + Store + inner CollectionStore +} + +func (p providerStore) Collections() CollectionStore { return p.inner } + +// bareStore implements only Store. +type bareStore struct{ Store } + +// markerCollections is a distinguishable CollectionStore value. +type markerCollections struct{ CollectionStore } + +func TestAsCollectionStore(t *testing.T) { + marker := markerCollections{} + + direct := capableStore{CollectionStore: marker} + assert.NotNil(t, AsCollectionStore(direct), "direct implementation resolves") + + viaProvider := providerStore{inner: marker} + assert.Equal(t, CollectionStore(marker), AsCollectionStore(viaProvider), "provider delegates to the wrapped capability") + + emptyProvider := providerStore{inner: nil} + assert.Nil(t, AsCollectionStore(emptyProvider), "provider over an incapable base resolves to nil") + + assert.Nil(t, AsCollectionStore(bareStore{}), "no capability, no provider: nil") +} diff --git a/pkg/prompt/edit.go b/pkg/prompt/edit.go index c6efc597..e9e3199c 100644 --- a/pkg/prompt/edit.go +++ b/pkg/prompt/edit.go @@ -64,6 +64,7 @@ func unversionedFieldsChanged(before, after *Prompt) bool { func identityFieldsChanged(before, after *Prompt) bool { return before.Name != after.Name || before.Category != after.Category || + before.CollectionID != after.CollectionID || before.Scope != after.Scope || !slices.Equal(before.Personas, after.Personas) || before.OwnerEmail != after.OwnerEmail || diff --git a/pkg/prompt/edit_test.go b/pkg/prompt/edit_test.go index 2c7fcc50..9ecf3891 100644 --- a/pkg/prompt/edit_test.go +++ b/pkg/prompt/edit_test.go @@ -175,6 +175,7 @@ func TestApplyEdit_MixedGatedEditRejected(t *testing.T) { {"scope change", func(p *Prompt) { p.Scope = ScopePersona }}, {"status change", func(p *Prompt) { p.Status = StatusDeprecated }}, {"category change", func(p *Prompt) { p.Category = "other" }}, + {"collection change", func(p *Prompt) { p.CollectionID = "col-1" }}, {"rename", func(p *Prompt) { p.Name = "renamed" }}, {"personas change", func(p *Prompt) { p.Personas = []string{"analyst"} }}, {"enabled change", func(p *Prompt) { p.Enabled = false }}, diff --git a/pkg/prompt/postgres/collection.go b/pkg/prompt/postgres/collection.go new file mode 100644 index 00000000..6bf07b0f --- /dev/null +++ b/pkg/prompt/postgres/collection.go @@ -0,0 +1,173 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/lib/pq" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// Compile-time interface verification. +var _ prompt.CollectionStore = (*Store)(nil) + +// PostgreSQL error codes for constraint and input violations. +const ( + pqUniqueViolation = "23505" + pqForeignKeyViolation = "23503" + // pqInvalidTextRepresentation fires when a caller-supplied id fails to + // parse as a UUID; such an id cannot name any row, so lookups map it to + // not-found rather than surfacing a 500. + pqInvalidTextRepresentation = "22P02" +) + +// pqCode reports whether err is a pq error with the given SQLSTATE code. +func pqCode(err error, code string) bool { + var pqErr *pq.Error + return errors.As(err, &pqErr) && string(pqErr.Code) == code +} + +// isUniqueViolation reports whether err is a unique-constraint violation. +func isUniqueViolation(err error) bool { + return pqCode(err, pqUniqueViolation) +} + +// nullableID binds an optional UUID column: empty string becomes SQL NULL. +func nullableID(id string) any { + if id == "" { + return nil + } + return id +} + +// CreateCollection persists a new collection, generating its ID. +func (s *Store) CreateCollection(ctx context.Context, c *prompt.Collection) error { + err := s.db.QueryRowContext(ctx, ` + INSERT INTO prompt_collections (name, description, created_by) + VALUES ($1, $2, $3) + RETURNING id, created_at, updated_at`, + c.Name, c.Description, c.CreatedBy, + ).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt) + if isUniqueViolation(err) { + return prompt.ErrCollectionExists + } + if err != nil { + return fmt.Errorf("create prompt collection: %w", err) + } + return nil +} + +// GetCollection retrieves a collection by ID with its prompt count. Returns +// nil, nil if not found. +func (s *Store) GetCollection(ctx context.Context, id string) (*prompt.Collection, error) { + c := &prompt.Collection{} + err := s.db.QueryRowContext(ctx, collectionSelect+` + WHERE c.id = $1 + GROUP BY c.id`, id, + ).Scan(collectionScanDest(c)...) + if errors.Is(err, sql.ErrNoRows) || pqCode(err, pqInvalidTextRepresentation) { + return nil, nil //nolint:nilnil // CollectionStore contract: nil, nil means not found + } + if err != nil { + return nil, fmt.Errorf("get prompt collection: %w", err) + } + return c, nil +} + +// collectionSelect reads the collection columns plus the member prompt count. +const collectionSelect = ` + SELECT c.id, c.name, c.description, c.created_by, + COUNT(p.id), c.created_at, c.updated_at + FROM prompt_collections c + LEFT JOIN prompts p ON p.collection_id = c.id` + +// collectionScanDest returns the scan destinations in collectionSelect order. +func collectionScanDest(c *prompt.Collection) []any { + return []any{ + &c.ID, &c.Name, &c.Description, &c.CreatedBy, + &c.PromptCount, &c.CreatedAt, &c.UpdatedAt, + } +} + +// ListCollections returns every collection with its prompt count, ordered by +// name (case-insensitive, matching the uniqueness rule). +func (s *Store) ListCollections(ctx context.Context) ([]prompt.Collection, error) { + rows, err := s.db.QueryContext(ctx, collectionSelect+` + GROUP BY c.id + ORDER BY LOWER(c.name)`) + if err != nil { + return nil, fmt.Errorf("list prompt collections: %w", err) + } + defer func() { _ = rows.Close() }() + + result := []prompt.Collection{} + for rows.Next() { + var c prompt.Collection + if err := rows.Scan(collectionScanDest(&c)...); err != nil { + return nil, fmt.Errorf("scan prompt collection: %w", err) + } + result = append(result, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate prompt collections: %w", err) + } + return result, nil +} + +// UpdateCollection renames or re-describes a collection. +func (s *Store) UpdateCollection(ctx context.Context, id, name, description string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE prompt_collections + SET name = $2, description = $3, updated_at = NOW() + WHERE id = $1`, + id, name, description, + ) + if isUniqueViolation(err) { + return prompt.ErrCollectionExists + } + if err != nil { + return fmt.Errorf("update prompt collection: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("prompt collection %s not found", id) + } + return nil +} + +// DeleteCollection removes a collection. The collection_id FK is ON DELETE +// SET NULL, so member prompts are released to the uncollected group. +func (s *Store) DeleteCollection(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM prompt_collections WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete prompt collection: %w", err) + } + return nil +} + +// SetPromptCollection assigns a prompt to a collection (empty collectionID +// clears the assignment). Placement is not reviewable substance: no version +// snapshot, no review gate, and the search embedding is untouched. +func (s *Store) SetPromptCollection(ctx context.Context, promptID, collectionID string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE prompts SET collection_id = $2, updated_at = NOW() + WHERE id = $1`, + promptID, nullableID(collectionID), + ) + // A dangling FK and a malformed collection id both mean "no such + // collection". (A malformed prompt id cannot reach here: the handlers + // resolve the prompt row first, so the only unparsed UUID left is the + // collection id from the request body.) + if pqCode(err, pqForeignKeyViolation) || pqCode(err, pqInvalidTextRepresentation) { + return prompt.ErrCollectionNotFound + } + if err != nil { + return fmt.Errorf("set prompt collection: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("prompt %s not found", promptID) + } + return nil +} diff --git a/pkg/prompt/postgres/collection_realdb_integration_test.go b/pkg/prompt/postgres/collection_realdb_integration_test.go new file mode 100644 index 00000000..8e17e318 --- /dev/null +++ b/pkg/prompt/postgres/collection_realdb_integration_test.go @@ -0,0 +1,85 @@ +//go:build integration + +package postgres + +// Real-Postgres round-trip tests for prompt collections (#1010): the actual +// migration schema (case-insensitive unique name, collection_id FK with +// ON DELETE SET NULL) that sqlmock cannot exercise. + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/internal/testdb" + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +func TestCollections_RealDB_RoundTrip(t *testing.T) { + store := New(testdb.New(t)) + ctx := context.Background() + + col := &prompt.Collection{Name: "Sales Reporting", Description: "Sales SOPs", CreatedBy: "jane@example.com"} + require.NoError(t, store.CreateCollection(ctx, col)) + require.NotEmpty(t, col.ID) + + t.Run("name uniqueness is case-insensitive", func(t *testing.T) { + dup := &prompt.Collection{Name: "sales reporting"} + assert.ErrorIs(t, store.CreateCollection(ctx, dup), prompt.ErrCollectionExists) + }) + + p := &prompt.Prompt{ + Name: "col-rt", Content: "Body.", Scope: prompt.ScopePersonal, + OwnerEmail: "jane@example.com", Source: prompt.SourceOperator, Enabled: true, + } + require.NoError(t, store.Create(ctx, p)) + + t.Run("assignment round-trips through the prompt read path", func(t *testing.T) { + require.NoError(t, store.SetPromptCollection(ctx, p.ID, col.ID)) + got, err := store.GetByID(ctx, p.ID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, col.ID, got.CollectionID) + + cols, err := store.ListCollections(ctx) + require.NoError(t, err) + require.Len(t, cols, 1) + assert.Equal(t, 1, cols[0].PromptCount, "member count aggregates") + }) + + t.Run("assigning a dangling collection is the sentinel", func(t *testing.T) { + err := store.SetPromptCollection(ctx, p.ID, "00000000-0000-0000-0000-000000000000") + assert.ErrorIs(t, err, prompt.ErrCollectionNotFound) + }) + + t.Run("rename persists and collides case-insensitively", func(t *testing.T) { + other := &prompt.Collection{Name: "Marketing"} + require.NoError(t, store.CreateCollection(ctx, other)) + assert.ErrorIs(t, store.UpdateCollection(ctx, other.ID, "SALES REPORTING", ""), prompt.ErrCollectionExists) + require.NoError(t, store.UpdateCollection(ctx, other.ID, "Marketing Ops", "renamed")) + got, err := store.GetCollection(ctx, other.ID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "Marketing Ops", got.Name) + }) + + t.Run("delete releases members to the uncollected group", func(t *testing.T) { + require.NoError(t, store.DeleteCollection(ctx, col.ID)) + got, err := store.GetByID(ctx, p.ID) + require.NoError(t, err) + require.NotNil(t, got, "the prompt itself survives collection deletion") + assert.Empty(t, got.CollectionID, "ON DELETE SET NULL clears the assignment") + }) + + t.Run("clearing an assignment binds NULL", func(t *testing.T) { + again := &prompt.Collection{Name: "Regroup"} + require.NoError(t, store.CreateCollection(ctx, again)) + require.NoError(t, store.SetPromptCollection(ctx, p.ID, again.ID)) + require.NoError(t, store.SetPromptCollection(ctx, p.ID, "")) + got, err := store.GetByID(ctx, p.ID) + require.NoError(t, err) + assert.Empty(t, got.CollectionID) + }) +} diff --git a/pkg/prompt/postgres/collection_test.go b/pkg/prompt/postgres/collection_test.go new file mode 100644 index 00000000..d79ed5f6 --- /dev/null +++ b/pkg/prompt/postgres/collection_test.go @@ -0,0 +1,212 @@ +package postgres + +import ( + "context" + "database/sql/driver" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +var collectionColumns = []string{ + "id", "name", "description", "created_by", "count", "created_at", "updated_at", +} + +func collectionRow(id, name string) []driver.Value { + return []driver.Value{id, name, "desc", "jane@example.com", 2, testRowTime, testRowTime} +} + +func newCollectionMock(t *testing.T) (*Store, sqlmock.Sqlmock, func()) { + t.Helper() + db, mock, err := sqlmock.New() + require.NoError(t, err) + return New(db), mock, func() { _ = db.Close() } +} + +func TestCreateCollection(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectQuery("INSERT INTO prompt_collections"). + WithArgs("Sales", "Sales SOPs", "jane@example.com"). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow("col-1", testRowTime, testRowTime)) + + c := &prompt.Collection{Name: "Sales", Description: "Sales SOPs", CreatedBy: "jane@example.com"} + require.NoError(t, store.CreateCollection(context.Background(), c)) + assert.Equal(t, "col-1", c.ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreateCollection_NameCollision(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectQuery("INSERT INTO prompt_collections"). + WillReturnError(&pq.Error{Code: pqUniqueViolation}) + + err := store.CreateCollection(context.Background(), &prompt.Collection{Name: "Sales"}) + assert.ErrorIs(t, err, prompt.ErrCollectionExists) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetCollection(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectQuery("SELECT .+ FROM prompt_collections").WithArgs("col-1"). + WillReturnRows(sqlmock.NewRows(collectionColumns).AddRow(collectionRow("col-1", "Sales")...)) + + c, err := store.GetCollection(context.Background(), "col-1") + require.NoError(t, err) + require.NotNil(t, c) + assert.Equal(t, "Sales", c.Name) + assert.Equal(t, 2, c.PromptCount) + + // Not found and a malformed id both map to nil, nil. + mock.ExpectQuery("SELECT .+ FROM prompt_collections").WithArgs("missing"). + WillReturnRows(sqlmock.NewRows(collectionColumns)) + c, err = store.GetCollection(context.Background(), "missing") + assert.NoError(t, err) + assert.Nil(t, c) + + mock.ExpectQuery("SELECT .+ FROM prompt_collections").WithArgs("not-a-uuid"). + WillReturnError(&pq.Error{Code: pqInvalidTextRepresentation}) + c, err = store.GetCollection(context.Background(), "not-a-uuid") + assert.NoError(t, err) + assert.Nil(t, c) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestListCollections(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectQuery("SELECT .+ FROM prompt_collections"). + WillReturnRows(sqlmock.NewRows(collectionColumns). + AddRow(collectionRow("col-1", "Marketing")...). + AddRow(collectionRow("col-2", "Sales")...)) + + cols, err := store.ListCollections(context.Background()) + require.NoError(t, err) + require.Len(t, cols, 2) + assert.Equal(t, "Marketing", cols[0].Name) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestListCollections_QueryError(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectQuery("SELECT .+ FROM prompt_collections").WillReturnError(assert.AnError) + _, err := store.ListCollections(context.Background()) + assert.ErrorContains(t, err, "list prompt collections") + + // A malformed row surfaces as a scan error. + mock.ExpectQuery("SELECT .+ FROM prompt_collections"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("col-1")) + _, err = store.ListCollections(context.Background()) + assert.ErrorContains(t, err, "scan prompt collection") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDeleteCollection_ExecError(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectExec("DELETE FROM prompt_collections").WillReturnError(assert.AnError) + assert.ErrorContains(t, store.DeleteCollection(context.Background(), "col-1"), "delete prompt collection") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestListCollections_Empty(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectQuery("SELECT .+ FROM prompt_collections"). + WillReturnRows(sqlmock.NewRows(collectionColumns)) + + cols, err := store.ListCollections(context.Background()) + require.NoError(t, err) + assert.NotNil(t, cols, "empty list is non-nil for stable JSON") + assert.Empty(t, cols) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateCollection(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectExec("UPDATE prompt_collections"). + WithArgs("col-1", "Sales Ops", "renamed"). + WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, store.UpdateCollection(context.Background(), "col-1", "Sales Ops", "renamed")) + + // A rename onto an existing name is the sentinel; a missing row errors. + mock.ExpectExec("UPDATE prompt_collections"). + WillReturnError(&pq.Error{Code: pqUniqueViolation}) + assert.ErrorIs(t, store.UpdateCollection(context.Background(), "col-1", "Taken", ""), prompt.ErrCollectionExists) + + mock.ExpectExec("UPDATE prompt_collections"). + WillReturnResult(sqlmock.NewResult(0, 0)) + assert.ErrorContains(t, store.UpdateCollection(context.Background(), "missing", "X", ""), "not found") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDeleteCollection(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectExec("DELETE FROM prompt_collections").WithArgs("col-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + assert.NoError(t, store.DeleteCollection(context.Background(), "col-1")) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestSetPromptCollection(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectExec("UPDATE prompts SET collection_id"). + WithArgs("uuid-123", "col-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, store.SetPromptCollection(context.Background(), "uuid-123", "col-1")) + + // Clearing binds NULL. + mock.ExpectExec("UPDATE prompts SET collection_id"). + WithArgs("uuid-123", nil). + WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, store.SetPromptCollection(context.Background(), "uuid-123", "")) + + // A dangling collection FK and a malformed collection id both map to the + // not-found sentinel; a missing prompt errors. + mock.ExpectExec("UPDATE prompts SET collection_id"). + WillReturnError(&pq.Error{Code: pqForeignKeyViolation}) + assert.ErrorIs(t, store.SetPromptCollection(context.Background(), "uuid-123", "gone"), prompt.ErrCollectionNotFound) + + mock.ExpectExec("UPDATE prompts SET collection_id"). + WillReturnError(&pq.Error{Code: pqInvalidTextRepresentation}) + assert.ErrorIs(t, store.SetPromptCollection(context.Background(), "uuid-123", "not-a-uuid"), prompt.ErrCollectionNotFound) + + mock.ExpectExec("UPDATE prompts SET collection_id"). + WillReturnResult(sqlmock.NewResult(0, 0)) + assert.ErrorContains(t, store.SetPromptCollection(context.Background(), "missing", "col-1"), "not found") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestQueryOne_MalformedIDIsNotFound(t *testing.T) { + store, mock, done := newCollectionMock(t) + defer done() + + mock.ExpectQuery("SELECT .+ FROM prompts WHERE id").WithArgs("not-a-uuid"). + WillReturnError(&pq.Error{Code: pqInvalidTextRepresentation}) + p, err := store.GetByID(context.Background(), "not-a-uuid") + assert.NoError(t, err, "a malformed id names no row: not-found, not a 500") + assert.Nil(t, p) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/pkg/prompt/postgres/store.go b/pkg/prompt/postgres/store.go index 2506aaca..7a87ab3b 100644 --- a/pkg/prompt/postgres/store.go +++ b/pkg/prompt/postgres/store.go @@ -33,7 +33,8 @@ func New(db *sql.DB) *Store { const promptColumns = `id, name, display_name, description, content, arguments, category, scope, personas, owner_email, source, enabled, tags, status, approved_by, approved_at, deprecated_at, superseded_by, - review_requested, requested_scope, requested_personas, version, created_at, updated_at` + review_requested, requested_scope, requested_personas, version, + COALESCE(collection_id::text, ''), created_at, updated_at` // promptSelect is the base SELECT for the prompt columns. const promptSelect = "SELECT " + promptColumns + " FROM prompts" @@ -54,7 +55,7 @@ func promptScanDest(p *prompt.Prompt, argsJSON *[]byte) []any { &p.Source, &p.Enabled, pq.Array(&p.Tags), &p.Status, &p.ApprovedBy, &p.ApprovedAt, &p.DeprecatedAt, &p.SupersededBy, &p.ReviewRequested, &p.RequestedScope, pq.Array(&p.RequestedPersonas), - &p.Version, &p.CreatedAt, &p.UpdatedAt, + &p.Version, &p.CollectionID, &p.CreatedAt, &p.UpdatedAt, } } @@ -120,9 +121,10 @@ func (s *Store) Create(ctx context.Context, p *prompt.Prompt) error { INSERT INTO prompts (name, display_name, description, content, arguments, category, scope, personas, owner_email, source, enabled, tags, status, approved_by, approved_at, deprecated_at, - superseded_by, review_requested, requested_scope, requested_personas) + superseded_by, review_requested, requested_scope, requested_personas, + collection_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, $17, $18, $19, $20) + $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, version, created_at, updated_at` return s.withTx(ctx, "create prompt", func(tx *sql.Tx) error { @@ -131,6 +133,7 @@ func (s *Store) Create(ctx context.Context, p *prompt.Prompt) error { p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), + nullableID(p.CollectionID), ).Scan(&p.ID, &p.Version, &p.CreatedAt, &p.UpdatedAt) if err != nil { return fmt.Errorf("create prompt: %w", err) @@ -172,10 +175,12 @@ func (s *Store) GetByID(ctx context.Context, id string) (*prompt.Prompt, error) return s.queryOne(ctx, query, id) } -// queryOne runs a single-row query and maps not-found to (nil, nil). +// queryOne runs a single-row query and maps not-found to (nil, nil). A +// caller-supplied id that fails UUID parsing (22P02) names no row and maps to +// not-found the same way, so a malformed id is a 404, not a 500. func (s *Store) queryOne(ctx context.Context, query string, args ...any) (*prompt.Prompt, error) { p, err := scanPrompt(s.db.QueryRowContext(ctx, query, args...)) - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, sql.ErrNoRows) || pqCode(err, pqInvalidTextRepresentation) { return nil, nil //nolint:nilnil // Store interface contract: nil, nil means not found } if err != nil { @@ -236,7 +241,7 @@ func updateTx(ctx context.Context, tx *sql.Tx, p *prompt.Prompt) error { owner_email = $10, source = $11, enabled = $12, tags = $13, status = $14, approved_by = $15, approved_at = $16, deprecated_at = $17, superseded_by = $18, review_requested = $19, requested_scope = $20, - requested_personas = $21, + requested_personas = $21, collection_id = $24, version = CASE WHEN $23 > 0 THEN $23 ELSE version END, embedding = CASE WHEN embedding_text_hash IS DISTINCT FROM $22 THEN NULL ELSE embedding END, @@ -252,7 +257,7 @@ func updateTx(ctx context.Context, tx *sql.Tx, p *prompt.Prompt) error { p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), - newHash, p.Version, + newHash, p.Version, nullableID(p.CollectionID), ) if err != nil { return fmt.Errorf("update prompt: %w", err) diff --git a/pkg/prompt/postgres/store_test.go b/pkg/prompt/postgres/store_test.go index 02a2a290..38e2531b 100644 --- a/pkg/prompt/postgres/store_test.go +++ b/pkg/prompt/postgres/store_test.go @@ -23,7 +23,7 @@ var selectColumns = []string{ "category", "scope", "personas", "owner_email", "source", "enabled", "tags", "status", "approved_by", "approved_at", "deprecated_at", "superseded_by", "review_requested", "requested_scope", "requested_personas", - "version", "created_at", "updated_at", + "version", "collection_id", "created_at", "updated_at", } // testRowTime is the fixed created_at/updated_at value used by promptRow; the @@ -37,7 +37,7 @@ func promptRow(id, name, scope string, argsJSON []byte, owner string) []driver.V id, name, "Test Prompt", "A test prompt", "Do something with {topic}", argsJSON, "workflow", scope, pq.Array([]string{}), owner, "operator", true, pq.Array([]string{}), "approved", "", nil, nil, "", - false, "", pq.Array([]string{}), 1, + false, "", pq.Array([]string{}), 1, "", testRowTime, testRowTime, } } @@ -92,7 +92,7 @@ func TestCreate_Success(t *testing.T) { p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, pq.Array(p.Tags), prompt.StatusDraft, "", nil, nil, "", - false, "", pq.Array(p.RequestedPersonas), + false, "", pq.Array(p.RequestedPersonas), nil, ).WillReturnRows(sqlmock.NewRows([]string{"id", "version", "created_at", "updated_at"}). AddRow("uuid-123", 1, now, now)) mock.ExpectExec("INSERT INTO prompt_versions").WithArgs( @@ -230,7 +230,7 @@ func TestUpdate_Success(t *testing.T) { p.Source, p.Enabled, pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), - indexjobs.TextHash(prompt.IndexText(p)), p.Version, + indexjobs.TextHash(prompt.IndexText(p)), p.Version, nil, ).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() diff --git a/pkg/prompt/postgres/version_test.go b/pkg/prompt/postgres/version_test.go index eec78f2c..199ec215 100644 --- a/pkg/prompt/postgres/version_test.go +++ b/pkg/prompt/postgres/version_test.go @@ -38,7 +38,7 @@ func expectLockPrompt(mock sqlmock.Sqlmock, p *prompt.Prompt, argsJSON []byte) { p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), p.Version, - testRowTime, testRowTime, + p.CollectionID, testRowTime, testRowTime, )) } diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index cdd676af..a8e00501 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -261,6 +261,11 @@ type Prompt struct { DeprecatedAt *time.Time `json:"deprecated_at,omitempty"` SupersededBy string `json:"superseded_by,omitempty" example:"daily-sales-report-v2"` + // CollectionID places the prompt in at most one collection (#1010); empty + // means uncollected. Placement is organizational metadata, not reviewable + // substance: it is never versioned and changing it never triggers review. + CollectionID string `json:"collection_id,omitempty" example:"col_a1b2c3d4"` + // Version is the number of the snapshot the live row currently serves // (see VersionStore). Pending draft versions above this number exist in // the version history but are not served until approved. diff --git a/pkg/prompt/versionhttp/collections.go b/pkg/prompt/versionhttp/collections.go new file mode 100644 index 00000000..25e59582 --- /dev/null +++ b/pkg/prompt/versionhttp/collections.go @@ -0,0 +1,421 @@ +package versionhttp + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// This file serves the prompt collection routes (#1010): collection CRUD and +// per-prompt assignment. Collections are org-visible named groups; any portal +// user can create one and read them all, mutation of a collection is limited +// to its creator or an admin, and assignment follows the prompt's own +// mutation rule (owner for a personal prompt, admin for shared, system rows +// read-only). + +const errCollectionNot = "collection not found" + +// maxCollectionBodyBytes bounds the JSON bodies the collection routes decode, +// mirroring the admin surface's request cap. +const maxCollectionBodyBytes = 1 << 20 // 1 MiB + +// registerAdminCollections mounts the admin collection routes under prefix +// when the store has the collection capability. +func (h *Handler) registerAdminCollections(mux *http.ServeMux, prefix string, wrap func(http.Handler) http.Handler) { + if h.deps.Collections == nil { + return + } + mux.Handle("GET "+prefix+"/prompt-collections", wrap(http.HandlerFunc(h.listCollections))) + mux.Handle("POST "+prefix+"/prompt-collections", wrap(http.HandlerFunc(h.adminCreateCollection))) + mux.Handle("PUT "+prefix+"/prompt-collections/{id}", wrap(http.HandlerFunc(h.adminUpdateCollection))) + mux.Handle("DELETE "+prefix+"/prompt-collections/{id}", wrap(http.HandlerFunc(h.adminDeleteCollection))) + mux.Handle("PUT "+prefix+"/prompts/{id}/collection", wrap(http.HandlerFunc(h.adminAssignCollection))) +} + +// registerPortalCollections mounts the portal collection routes when the +// store has the collection capability. +func (h *Handler) registerPortalCollections(mux *http.ServeMux, wrap func(http.Handler) http.Handler) { + if h.deps.Collections == nil { + return + } + mux.Handle("GET /api/v1/portal/prompt-collections", wrap(h.portalHandler(h.portalListCollections))) + mux.Handle("POST /api/v1/portal/prompt-collections", wrap(h.portalHandler(h.portalCreateCollection))) + mux.Handle("PUT /api/v1/portal/prompt-collections/{id}", wrap(h.portalHandler(h.portalUpdateCollection))) + mux.Handle("DELETE /api/v1/portal/prompt-collections/{id}", wrap(h.portalHandler(h.portalDeleteCollection))) + mux.Handle("PUT /api/v1/portal/prompts/{id}/collection", wrap(h.portalHandler(h.portalAssignCollection))) +} + +// collectionListResponse is the collection listing payload. +type collectionListResponse struct { + Data []prompt.Collection `json:"data"` + Total int `json:"total" example:"3"` +} + +// collectionRequest is the create/update request body. +type collectionRequest struct { + Name string `json:"name" example:"Sales Reporting"` + Description string `json:"description" example:"Weekly and daily sales SOPs"` +} + +// assignCollectionRequest is the prompt assignment request body. An empty +// collection_id clears the assignment. +type assignCollectionRequest struct { + CollectionID string `json:"collection_id" example:"col_a1b2c3d4"` +} + +// listCollections returns every collection with its prompt count. Shared by +// the admin route directly and the portal route via portalListCollections: +// collections are org-visible, so both surfaces serve the same listing. +// +// @Summary List prompt collections +// @Description Returns every prompt collection with its member prompt count, ordered by name. +// @Tags Prompts +// @Produce json +// @Success 200 {object} collectionListResponse +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompt-collections [get] +func (h *Handler) listCollections(w http.ResponseWriter, r *http.Request) { + cols, err := h.deps.Collections.ListCollections(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list collections") + return + } + writeJSON(w, http.StatusOK, collectionListResponse{Data: cols, Total: len(cols)}) +} + +// portalListCollections returns the org-visible collection listing. +// +// @Summary List prompt collections (portal) +// @Description Returns every prompt collection with its member prompt count, ordered by name. +// @Tags Prompts +// @Produce json +// @Success 200 {object} collectionListResponse +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /portal/prompt-collections [get] +func (h *Handler) portalListCollections(w http.ResponseWriter, r *http.Request, _ *PortalIdentity) { + h.listCollections(w, r) +} + +// createCollection validates the request body and persists a new collection +// attributed to creator, writing the response. +func (h *Handler) createCollection(w http.ResponseWriter, r *http.Request, creator string) { + req, ok := decodeCollectionRequest(w, r) + if !ok { + return + } + col := &prompt.Collection{Name: req.Name, Description: req.Description, CreatedBy: creator} + if err := h.deps.Collections.CreateCollection(r.Context(), col); err != nil { + writeCollectionWriteError(w, err, "failed to create collection") + return + } + writeJSON(w, http.StatusCreated, col) +} + +// adminCreateCollection creates a collection attributed to the admin. +// +// @Summary Create prompt collection +// @Description Creates a named collection organizing the prompt library. Names are unique case-insensitively. +// @Tags Prompts +// @Accept json +// @Produce json +// @Param request body collectionRequest true "Collection" +// @Success 201 {object} prompt.Collection +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompt-collections [post] +func (h *Handler) adminCreateCollection(w http.ResponseWriter, r *http.Request) { + h.createCollection(w, r, h.deps.AdminEmail(r)) +} + +// portalCreateCollection creates a collection attributed to the caller. Any +// portal user can create a collection (prompt owners organize their own +// prompts); mutating an existing one is creator-or-admin. +// +// @Summary Create prompt collection (portal) +// @Description Creates a named collection organizing the prompt library. Names are unique case-insensitively. +// @Tags Prompts +// @Accept json +// @Produce json +// @Param request body collectionRequest true "Collection" +// @Success 201 {object} prompt.Collection +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /portal/prompt-collections [post] +func (h *Handler) portalCreateCollection(w http.ResponseWriter, r *http.Request, user *PortalIdentity) { + h.createCollection(w, r, user.Email) +} + +// updateCollection renames or re-describes an existing collection after the +// caller-specific permission check has passed. +func (h *Handler) updateCollection(w http.ResponseWriter, r *http.Request, col *prompt.Collection) { + req, ok := decodeCollectionRequest(w, r) + if !ok { + return + } + if err := h.deps.Collections.UpdateCollection(r.Context(), col.ID, req.Name, req.Description); err != nil { + writeCollectionWriteError(w, err, "failed to update collection") + return + } + col.Name = req.Name + col.Description = req.Description + writeJSON(w, http.StatusOK, col) +} + +// adminUpdateCollection renames or re-describes any collection. +// +// @Summary Update prompt collection +// @Description Renames or re-describes a collection. +// @Tags Prompts +// @Accept json +// @Produce json +// @Param id path string true "Collection ID" +// @Param request body collectionRequest true "Collection" +// @Success 200 {object} prompt.Collection +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompt-collections/{id} [put] +func (h *Handler) adminUpdateCollection(w http.ResponseWriter, r *http.Request) { + col, ok := h.loadCollection(w, r) + if !ok { + return + } + h.updateCollection(w, r, col) +} + +// portalUpdateCollection renames or re-describes a collection the caller +// created; admins may update any. +// +// @Summary Update prompt collection (portal) +// @Description Renames or re-describes a collection the caller created; admins may update any collection. +// @Tags Prompts +// @Accept json +// @Produce json +// @Param id path string true "Collection ID" +// @Param request body collectionRequest true "Collection" +// @Success 200 {object} prompt.Collection +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /portal/prompt-collections/{id} [put] +func (h *Handler) portalUpdateCollection(w http.ResponseWriter, r *http.Request, user *PortalIdentity) { + col, ok := h.loadOwnedCollection(w, r, user) + if !ok { + return + } + h.updateCollection(w, r, col) +} + +// adminDeleteCollection deletes any collection, releasing its prompts. +// +// @Summary Delete prompt collection +// @Description Deletes a collection; member prompts are released to the default (uncollected) group. +// @Tags Prompts +// @Produce json +// @Param id path string true "Collection ID" +// @Success 200 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompt-collections/{id} [delete] +func (h *Handler) adminDeleteCollection(w http.ResponseWriter, r *http.Request) { + col, ok := h.loadCollection(w, r) + if !ok { + return + } + h.deleteCollection(w, r, col) +} + +// portalDeleteCollection deletes a collection the caller created; admins may +// delete any. +// +// @Summary Delete prompt collection (portal) +// @Description Deletes a collection the caller created; admins may delete any collection. Member prompts are released to the default (uncollected) group. +// @Tags Prompts +// @Produce json +// @Param id path string true "Collection ID" +// @Success 200 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /portal/prompt-collections/{id} [delete] +func (h *Handler) portalDeleteCollection(w http.ResponseWriter, r *http.Request, user *PortalIdentity) { + col, ok := h.loadOwnedCollection(w, r, user) + if !ok { + return + } + h.deleteCollection(w, r, col) +} + +// deleteCollection removes the collection after permission checks. +func (h *Handler) deleteCollection(w http.ResponseWriter, r *http.Request, col *prompt.Collection) { + if err := h.deps.Collections.DeleteCollection(r.Context(), col.ID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete collection") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// adminAssignCollection assigns any non-system prompt to a collection. +// +// @Summary Assign prompt to collection +// @Description Places a prompt in a collection, or clears the assignment with an empty collection_id. Placement is organizational metadata: no version is produced and no review is triggered. +// @Tags Prompts +// @Accept json +// @Produce json +// @Param id path string true "Prompt ID" +// @Param request body assignCollectionRequest true "Assignment" +// @Success 200 {object} prompt.Prompt +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompts/{id}/collection [put] +func (h *Handler) adminAssignCollection(w http.ResponseWriter, r *http.Request) { + pr, ok := h.loadPrompt(w, r) + if !ok { + return + } + h.assignCollection(w, r, pr) +} + +// portalAssignCollection assigns a prompt the caller may organize: their own +// personal prompt, or any shared prompt when the caller is an admin. +// +// @Summary Assign prompt to collection (portal) +// @Description Places a prompt in a collection, or clears the assignment with an empty collection_id. Owners organize their own prompts; admins organize shared prompts. +// @Tags Prompts +// @Accept json +// @Produce json +// @Param id path string true "Prompt ID" +// @Param request body assignCollectionRequest true "Assignment" +// @Success 200 {object} prompt.Prompt +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /portal/prompts/{id}/collection [put] +func (h *Handler) portalAssignCollection(w http.ResponseWriter, r *http.Request, user *PortalIdentity) { + pr, ok := h.loadPrompt(w, r) + if !ok { + return + } + if !user.IsAdmin && (pr.Scope != prompt.ScopePersonal || pr.OwnerEmail != user.Email) { + writeError(w, http.StatusForbidden, "only the owner or an admin can organize this prompt") + return + } + h.assignCollection(w, r, pr) +} + +// assignCollection applies the assignment body to the prompt and returns the +// updated prompt. System rows are read-only config mirrors on every surface, +// so the guard lives here in the shared body. +func (h *Handler) assignCollection(w http.ResponseWriter, r *http.Request, pr *prompt.Prompt) { + if pr.Source == prompt.SourceSystem { + writeError(w, http.StatusForbidden, "system prompts are read-only") + return + } + var req assignCollectionRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCollectionBodyBytes)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if err := h.deps.Collections.SetPromptCollection(r.Context(), pr.ID, req.CollectionID); err != nil { + if errors.Is(err, prompt.ErrCollectionNotFound) { + writeError(w, http.StatusNotFound, errCollectionNot) + return + } + writeError(w, http.StatusInternalServerError, "failed to assign collection") + return + } + pr.CollectionID = req.CollectionID + writeJSON(w, http.StatusOK, pr) +} + +// decodeCollectionRequest parses and validates a create/update body. +func decodeCollectionRequest(w http.ResponseWriter, r *http.Request) (collectionRequest, bool) { + var req collectionRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxCollectionBodyBytes)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return req, false + } + if err := prompt.ValidateCollectionName(req.Name); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return req, false + } + if err := prompt.ValidateCollectionDescription(req.Description); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return req, false + } + return req, true +} + +// loadCollection resolves the {id} path param to a collection, writing the +// error response when it is missing. +func (h *Handler) loadCollection(w http.ResponseWriter, r *http.Request) (*prompt.Collection, bool) { + col, err := h.deps.Collections.GetCollection(r.Context(), r.PathValue(pathID)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get collection") + return nil, false + } + if col == nil { + writeError(w, http.StatusNotFound, errCollectionNot) + return nil, false + } + return col, true +} + +// loadOwnedCollection resolves the collection and enforces the portal +// mutation rule: the creator or an admin. +func (h *Handler) loadOwnedCollection(w http.ResponseWriter, r *http.Request, user *PortalIdentity) (*prompt.Collection, bool) { + col, ok := h.loadCollection(w, r) + if !ok { + return nil, false + } + if !user.IsAdmin && col.CreatedBy != user.Email { + writeError(w, http.StatusForbidden, "only the collection's creator or an admin can modify it") + return nil, false + } + return col, true +} + +// writeCollectionWriteError maps a collection write failure: a name collision +// surfaces as 409, anything else is a 500 with a generic message. +func writeCollectionWriteError(w http.ResponseWriter, err error, public string) { + if errors.Is(err, prompt.ErrCollectionExists) { + writeError(w, http.StatusConflict, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, public) +} diff --git a/pkg/prompt/versionhttp/collections_test.go b/pkg/prompt/versionhttp/collections_test.go new file mode 100644 index 00000000..a8870fbd --- /dev/null +++ b/pkg/prompt/versionhttp/collections_test.go @@ -0,0 +1,309 @@ +package versionhttp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// fakeCollections is an in-memory prompt.CollectionStore. +type fakeCollections struct { + cols map[string]*prompt.Collection // by id + store *fakeStore // assignment target + nextID int + createErr error + listErr error + assignErr error + getErr error + deleteErr error +} + +func (c *fakeCollections) CreateCollection(_ context.Context, col *prompt.Collection) error { + if c.createErr != nil { + return c.createErr + } + for _, existing := range c.cols { + if strings.EqualFold(existing.Name, col.Name) { + return prompt.ErrCollectionExists + } + } + c.nextID++ + col.ID = "col-" + strings.Repeat("x", c.nextID) + col.CreatedAt = time.Unix(1700000000, 0).UTC() + col.UpdatedAt = col.CreatedAt + c.cols[col.ID] = col + return nil +} + +func (c *fakeCollections) GetCollection(_ context.Context, id string) (*prompt.Collection, error) { + if c.getErr != nil { + return nil, c.getErr + } + return c.cols[id], nil //nolint:nilnil // interface contract +} + +func (c *fakeCollections) ListCollections(context.Context) ([]prompt.Collection, error) { + if c.listErr != nil { + return nil, c.listErr + } + out := []prompt.Collection{} + for _, col := range c.cols { + out = append(out, *col) + } + return out, nil +} + +func (c *fakeCollections) UpdateCollection(_ context.Context, id, name, description string) error { + col, ok := c.cols[id] + if !ok { + return prompt.ErrCollectionNotFound + } + for otherID, existing := range c.cols { + if otherID != id && strings.EqualFold(existing.Name, name) { + return prompt.ErrCollectionExists + } + } + col.Name, col.Description = name, description + return nil +} + +func (c *fakeCollections) DeleteCollection(_ context.Context, id string) error { + if c.deleteErr != nil { + return c.deleteErr + } + delete(c.cols, id) + return nil +} + +func (c *fakeCollections) SetPromptCollection(_ context.Context, promptID, collectionID string) error { + if c.assignErr != nil { + return c.assignErr + } + if collectionID != "" { + if _, ok := c.cols[collectionID]; !ok { + return prompt.ErrCollectionNotFound + } + } + if p, ok := c.store.prompts[promptID]; ok { + p.CollectionID = collectionID + } + return nil +} + +// collectionFixture seeds prompts plus one collection created by sarah. +func collectionFixture() (Deps, *fakeCollections) { + fx := seededDeps() + fx.store.prompts["p7"] = &prompt.Prompt{ + ID: "p7", Name: "sys", Scope: prompt.ScopeGlobal, Source: prompt.SourceSystem, Enabled: true, + } + cols := &fakeCollections{cols: map[string]*prompt.Collection{ + "col-1": {ID: "col-1", Name: "Sales", Description: "Sales SOPs", CreatedBy: "sarah@example.com"}, + }, store: fx.store} + fx.deps.Collections = cols + return fx.deps, cols +} + +// asUser sets the portal identity accessor on a copy of deps. +func asUser(deps Deps, email string, isAdmin bool) Deps { + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: email, IsAdmin: isAdmin} + } + return deps +} + +// doBodyReq performs a request with a JSON body. +func doBodyReq(t *testing.T, mux *http.ServeMux, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), method, path, strings.NewReader(body)) + mux.ServeHTTP(rec, req) + return rec +} + +func TestCollectionRoutes_NotRegisteredWithoutCapability(t *testing.T) { + deps := seededDeps().deps + deps.PortalUser = func(*http.Request) *PortalIdentity { return &PortalIdentity{Email: "x@example.com"} } + mux := newTestMux(t, deps) + + assert.Equal(t, http.StatusNotFound, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompt-collections").Code) + assert.Equal(t, http.StatusNotFound, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompt-collections").Code) +} + +func TestListCollections(t *testing.T) { + deps, _ := collectionFixture() + mux := newTestMux(t, asUser(deps, "bob@example.com", false)) + + for _, path := range []string{"/api/v1/portal/prompt-collections", "/api/v1/admin/prompt-collections"} { + rec := doReq(t, mux, http.MethodGet, path) + require.Equal(t, http.StatusOK, rec.Code, path) + var out collectionListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.Equal(t, 1, out.Total) + assert.Equal(t, "Sales", out.Data[0].Name) + } +} + +func TestPortalCollections_RequireIdentity(t *testing.T) { + deps, _ := collectionFixture() + deps.PortalUser = func(*http.Request) *PortalIdentity { return nil } + mux := newTestMux(t, deps) + + assert.Equal(t, http.StatusUnauthorized, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompt-collections").Code) + assert.Equal(t, http.StatusUnauthorized, doBodyReq(t, mux, http.MethodPost, "/api/v1/portal/prompt-collections", `{"name":"X"}`).Code) + assert.Equal(t, http.StatusUnauthorized, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p2/collection", `{"collection_id":"col-1"}`).Code) +} + +func TestCreateCollection(t *testing.T) { + deps, cols := collectionFixture() + mux := newTestMux(t, asUser(deps, "bob@example.com", false)) + + rec := doBodyReq(t, mux, http.MethodPost, "/api/v1/portal/prompt-collections", `{"name":"Marketing","description":"d"}`) + require.Equal(t, http.StatusCreated, rec.Code) + var created prompt.Collection + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + assert.Equal(t, "bob@example.com", created.CreatedBy, "portal create attributes the caller") + assert.NotEmpty(t, created.ID) + + // Name collisions are 409; invalid bodies, names, and oversized + // descriptions are 400. + assert.Equal(t, http.StatusConflict, doBodyReq(t, mux, http.MethodPost, "/api/v1/portal/prompt-collections", `{"name":"sales"}`).Code) + assert.Equal(t, http.StatusBadRequest, doBodyReq(t, mux, http.MethodPost, "/api/v1/portal/prompt-collections", `{"name":" "}`).Code) + assert.Equal(t, http.StatusBadRequest, doBodyReq(t, mux, http.MethodPost, "/api/v1/portal/prompt-collections", `not-json`).Code) + longDesc := `{"name":"Bounded","description":"` + strings.Repeat("a", 2001) + `"}` + assert.Equal(t, http.StatusBadRequest, doBodyReq(t, mux, http.MethodPost, "/api/v1/portal/prompt-collections", longDesc).Code) + + // The admin surface attributes the admin identity accessor. + rec = doBodyReq(t, mux, http.MethodPost, "/api/v1/admin/prompt-collections", `{"name":"Ops"}`) + require.Equal(t, http.StatusCreated, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + assert.Equal(t, "admin@example.com", created.CreatedBy) + assert.Len(t, cols.cols, 3) +} + +func TestUpdateCollection_Permissions(t *testing.T) { + deps, cols := collectionFixture() + + // A non-creator cannot rename it. + mux := newTestMux(t, asUser(deps, "bob@example.com", false)) + assert.Equal(t, http.StatusForbidden, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompt-collections/col-1", `{"name":"Hijacked"}`).Code) + + // The creator can. + mux = newTestMux(t, asUser(deps, "sarah@example.com", false)) + rec := doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompt-collections/col-1", `{"name":"Sales Ops","description":"renamed"}`) + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "Sales Ops", cols.cols["col-1"].Name) + + // An admin can via either surface; missing ids are 404. + mux = newTestMux(t, asUser(deps, "root@example.com", true)) + assert.Equal(t, http.StatusOK, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompt-collections/col-1", `{"name":"Sales"}`).Code) + assert.Equal(t, http.StatusOK, doBodyReq(t, mux, http.MethodPut, "/api/v1/admin/prompt-collections/col-1", `{"name":"Sales Team"}`).Code) + assert.Equal(t, http.StatusNotFound, doBodyReq(t, mux, http.MethodPut, "/api/v1/admin/prompt-collections/missing", `{"name":"X"}`).Code) + + // Renaming onto another collection's name is a 409. + rec = doBodyReq(t, mux, http.MethodPost, "/api/v1/admin/prompt-collections", `{"name":"Ops"}`) + require.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, http.StatusConflict, doBodyReq(t, mux, http.MethodPut, "/api/v1/admin/prompt-collections/col-1", `{"name":"ops"}`).Code) +} + +func TestDeleteCollection_Permissions(t *testing.T) { + deps, cols := collectionFixture() + + mux := newTestMux(t, asUser(deps, "bob@example.com", false)) + assert.Equal(t, http.StatusForbidden, doReq(t, mux, http.MethodDelete, "/api/v1/portal/prompt-collections/col-1").Code) + + // A delete-path store failure is a 500, and the collection survives. + cols.deleteErr = assert.AnError + mux = newTestMux(t, asUser(deps, "sarah@example.com", false)) + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodDelete, "/api/v1/portal/prompt-collections/col-1").Code) + cols.deleteErr = nil + + assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodDelete, "/api/v1/portal/prompt-collections/col-1").Code) + assert.Empty(t, cols.cols) + assert.Equal(t, http.StatusNotFound, doReq(t, mux, http.MethodDelete, "/api/v1/portal/prompt-collections/col-1").Code) +} + +func TestAdminDeleteCollection(t *testing.T) { + deps, cols := collectionFixture() + mux := newTestMux(t, deps) + + assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodDelete, "/api/v1/admin/prompt-collections/col-1").Code) + assert.Empty(t, cols.cols) + assert.Equal(t, http.StatusNotFound, doReq(t, mux, http.MethodDelete, "/api/v1/admin/prompt-collections/missing").Code) +} + +func TestLoadCollection_StoreFailure(t *testing.T) { + deps, cols := collectionFixture() + cols.getErr = assert.AnError + mux := newTestMux(t, asUser(deps, "sarah@example.com", false)) + + assert.Equal(t, http.StatusInternalServerError, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompt-collections/col-1", `{"name":"X"}`).Code) +} + +func TestAssignCollection_Portal(t *testing.T) { + deps, _ := collectionFixture() + + // The owner organizes their own personal prompt. + mux := newTestMux(t, asUser(deps, "sarah@example.com", false)) + rec := doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p2/collection", `{"collection_id":"col-1"}`) + require.Equal(t, http.StatusOK, rec.Code) + var updated prompt.Prompt + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + assert.Equal(t, "col-1", updated.CollectionID) + + // Clearing releases the prompt to the uncollected group. (Fresh var: + // collection_id is omitempty, so an empty value is absent from the JSON.) + rec = doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p2/collection", `{"collection_id":""}`) + require.Equal(t, http.StatusOK, rec.Code) + var cleared prompt.Prompt + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cleared)) + assert.Empty(t, cleared.CollectionID) + + // A non-admin cannot organize a shared prompt or someone else's personal + // prompt; an admin can organize shared prompts. + assert.Equal(t, http.StatusForbidden, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p1/collection", `{"collection_id":"col-1"}`).Code) + mux = newTestMux(t, asUser(deps, "bob@example.com", false)) + assert.Equal(t, http.StatusForbidden, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p2/collection", `{"collection_id":"col-1"}`).Code) + mux = newTestMux(t, asUser(deps, "root@example.com", true)) + assert.Equal(t, http.StatusOK, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p1/collection", `{"collection_id":"col-1"}`).Code) + + // System rows are read-only on every surface. + assert.Equal(t, http.StatusForbidden, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p7/collection", `{"collection_id":"col-1"}`).Code) + assert.Equal(t, http.StatusForbidden, doBodyReq(t, mux, http.MethodPut, "/api/v1/admin/prompts/p7/collection", `{"collection_id":"col-1"}`).Code) + + // Unknown targets are 404: the prompt, or the collection (deleted in a race). + assert.Equal(t, http.StatusNotFound, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/missing/collection", `{"collection_id":"col-1"}`).Code) + assert.Equal(t, http.StatusNotFound, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p2/collection", `{"collection_id":"missing"}`).Code) + assert.Equal(t, http.StatusBadRequest, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p2/collection", `not-json`).Code) +} + +func TestAssignCollection_Admin(t *testing.T) { + deps, _ := collectionFixture() + mux := newTestMux(t, asUser(deps, "root@example.com", true)) + + rec := doBodyReq(t, mux, http.MethodPut, "/api/v1/admin/prompts/p1/collection", `{"collection_id":"col-1"}`) + require.Equal(t, http.StatusOK, rec.Code) + var updated prompt.Prompt + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + assert.Equal(t, "col-1", updated.CollectionID) +} + +func TestCollections_StoreFailures(t *testing.T) { + deps, cols := collectionFixture() + cols.listErr = assert.AnError + cols.createErr = assert.AnError + cols.assignErr = assert.AnError + mux := newTestMux(t, asUser(deps, "sarah@example.com", false)) + + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompt-collections").Code) + assert.Equal(t, http.StatusInternalServerError, doBodyReq(t, mux, http.MethodPost, "/api/v1/portal/prompt-collections", `{"name":"X"}`).Code) + assert.Equal(t, http.StatusInternalServerError, doBodyReq(t, mux, http.MethodPut, "/api/v1/portal/prompts/p2/collection", `{"collection_id":"col-1"}`).Code) +} diff --git a/pkg/prompt/versionhttp/handler.go b/pkg/prompt/versionhttp/handler.go index 770b5c11..81415f38 100644 --- a/pkg/prompt/versionhttp/handler.go +++ b/pkg/prompt/versionhttp/handler.go @@ -1,13 +1,13 @@ // Package versionhttp exposes prompt version history, draft review actions, -// and audit-derived usage stats over REST (#1009). It serves both operator -// surfaces — the admin API (any prompt, approve/reject) and the portal API -// (own prompts, read-only history plus usage for the caller's visible set) — -// from one implementation. It lives beside pkg/prompt rather than inside -// pkg/admin or pkg/portal so those packages stay within the package-size -// budget; the composition root (internal/httpserver) mounts it under each -// surface's path prefix wrapped in that surface's own authentication -// middleware, and injects the identity accessors, so this package never -// imports either surface. +// audit-derived usage stats (#1009), and prompt collection management (#1010) +// over REST. It serves both operator surfaces — the admin API (any prompt, +// approve/reject) and the portal API (visible prompts, read-only history plus +// usage for the caller's visible set, collection CRUD and assignment) — from +// one implementation. It lives beside pkg/prompt rather than inside pkg/admin +// or pkg/portal so those packages stay within the package-size budget; the +// composition root (internal/httpserver) mounts it under each surface's path +// prefix wrapped in that surface's own authentication middleware, and injects +// the identity accessors, so this package never imports either surface. package versionhttp import ( @@ -29,6 +29,7 @@ type Registrar interface { // PortalIdentity is the portal caller resolved by the injected accessor. type PortalIdentity struct { + UserID string Email string Persona string IsAdmin bool @@ -36,20 +37,26 @@ type PortalIdentity struct { // Deps carries the collaborators the version handlers need. Store and // Versions are required; Usage is optional (audit disabled leaves usage -// empty); Registrar is optional. AdminEmail and PortalUser are the surface -// identity accessors injected by the composition root — each Register* call -// requires its accessor. +// empty); Registrar is optional; Collections is optional (nil skips the +// collection routes). AdminEmail and PortalUser are the surface identity +// accessors injected by the composition root — each Register* call requires +// its accessor. type Deps struct { - Store prompt.Store - Versions prompt.VersionStore - Usage prompt.UsageReader - Registrar Registrar + Store prompt.Store + Versions prompt.VersionStore + Usage prompt.UsageReader + Registrar Registrar + Collections prompt.CollectionStore // AdminEmail returns the authenticated admin's email for approval stamps. AdminEmail func(r *http.Request) string // PortalUser resolves the authenticated portal caller, or nil when the // request carries no user. PortalUser func(r *http.Request) *PortalIdentity + // SharedPromptIDs returns the ids of prompts shared person-to-person with + // the caller, so their usage is as visible as the prompts themselves. + // Optional: nil when the deployment has no portal share store. + SharedPromptIDs func(ctx context.Context, userID, email string) ([]string, error) } // Handler serves the prompt version and usage routes. @@ -82,13 +89,30 @@ func (h *Handler) RegisterAdmin(mux *http.ServeMux, prefix string, wrap func(htt mux.Handle("GET "+prefix+"/prompts/{id}/versions/{version}", wrap(http.HandlerFunc(h.adminGetVersion))) mux.Handle("POST "+prefix+"/prompts/{id}/versions/{version}/approve", wrap(http.HandlerFunc(h.approveVersion))) mux.Handle("POST "+prefix+"/prompts/{id}/versions/{version}/reject", wrap(http.HandlerFunc(h.rejectVersion))) + h.registerAdminCollections(mux, prefix, wrap) } // RegisterPortal mounts the portal version routes, wrapped in the portal auth -// middleware. +// middleware. Every portal handler goes through portalHandler, which resolves +// the caller identity and 401s unauthenticated requests in one place. func (h *Handler) RegisterPortal(mux *http.ServeMux, wrap func(http.Handler) http.Handler) { - mux.Handle("GET /api/v1/portal/prompts/usage", wrap(http.HandlerFunc(h.portalUsage))) - mux.Handle("GET /api/v1/portal/prompts/{id}/versions", wrap(http.HandlerFunc(h.portalListVersions))) + mux.Handle("GET /api/v1/portal/prompts/usage", wrap(h.portalHandler(h.portalUsage))) + mux.Handle("GET /api/v1/portal/prompts/{id}/versions", wrap(h.portalHandler(h.portalListVersions))) + h.registerPortalCollections(mux, wrap) +} + +// portalHandler adapts a portal handler by resolving the caller identity +// first, responding 401 when the request carries no user. Every portal route +// in this package registers through it. +func (h *Handler) portalHandler(fn func(w http.ResponseWriter, r *http.Request, user *PortalIdentity)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := h.deps.PortalUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + fn(w, r, user) + }) } // versionListResponse is the version history payload. diff --git a/pkg/prompt/versionhttp/handler_test.go b/pkg/prompt/versionhttp/handler_test.go index 7eb391ce..084fd5b9 100644 --- a/pkg/prompt/versionhttp/handler_test.go +++ b/pkg/prompt/versionhttp/handler_test.go @@ -330,16 +330,17 @@ func TestPortalListVersions_Authorization(t *testing.T) { mux := newTestMux(t, deps) assert.Equal(t, http.StatusUnauthorized, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p2/versions").Code) - // The owner reads their own history. + // The owner reads their own history, and — history being the library's + // verification surface (#1010) — any viewer reads an enabled global + // prompt's history. deps.PortalUser = func(*http.Request) *PortalIdentity { return &PortalIdentity{Email: "sarah@example.com"} } mux = newTestMux(t, deps) assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p2/versions").Code) - // But not a shared prompt's history (admin curation surface). - assert.Equal(t, http.StatusForbidden, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p1/versions").Code) + assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p1/versions").Code) - // A non-owner is denied; an admin is not. + // Another user's personal prompt stays hidden; an admin reads anything. deps.PortalUser = func(*http.Request) *PortalIdentity { return &PortalIdentity{Email: "bob@example.com"} } @@ -351,6 +352,90 @@ func TestPortalListVersions_Authorization(t *testing.T) { } mux = newTestMux(t, deps) assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p1/versions").Code) + assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p2/versions").Code) +} + +func TestPortalListVersions_SharedScopeVisibility(t *testing.T) { + fx := seededDeps() + deps, store := fx.deps, fx.store + store.prompts["p5"] = &prompt.Prompt{ + ID: "p5", Name: "team-sop", Scope: prompt.ScopePersona, + Personas: []string{"analyst"}, Enabled: true, Status: prompt.StatusApproved, Version: 1, + } + store.prompts["p6"] = &prompt.Prompt{ + ID: "p6", Name: "retired", Scope: prompt.ScopeGlobal, + Enabled: false, Status: prompt.StatusApproved, Version: 1, + } + + // A persona member reads their persona's history; an outsider does not. + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "sarah@example.com", Persona: "analyst"} + } + mux := newTestMux(t, deps) + assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p5/versions").Code) + + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "bob@example.com", Persona: "engineer"} + } + mux = newTestMux(t, deps) + assert.Equal(t, http.StatusForbidden, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p5/versions").Code) + + // A disabled shared prompt is as invisible as the prompt itself. + assert.Equal(t, http.StatusForbidden, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p6/versions").Code) +} + +func TestPortalListVersions_RedactsUnservedContentForViewers(t *testing.T) { + fx := seededDeps() + deps, versions := fx.deps, fx.versions + versions.versions["p1"] = append(versions.versions["p1"], + prompt.Version{ID: "v0r", PromptID: "p1", Version: 0, Content: "declined", Status: prompt.VersionStatusRejected, Author: "eve@example.com"}, + ) + + // A non-admin viewer of the shared prompt sees applied snapshots in full + // and the pending draft as a content-less stub; rejected rows are absent. + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "sarah@example.com"} + } + mux := newTestMux(t, deps) + rec := doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p1/versions") + require.Equal(t, http.StatusOK, rec.Code) + var out versionListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.Equal(t, 2, out.Total, "rejected rows are dropped") + assert.Equal(t, prompt.VersionStatusDraft, out.Data[0].Status) + assert.Empty(t, out.Data[0].Content, "pending draft content was never served") + assert.Equal(t, "jane@example.com", out.Data[0].Author, "the at-a-glance signal keeps its author") + assert.Equal(t, prompt.VersionStatusApplied, out.Data[1].Status) + assert.Equal(t, "live", out.Data[1].Content, "served snapshots stay complete") + + // An admin reads everything unredacted. + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "root@example.com", IsAdmin: true} + } + mux = newTestMux(t, deps) + rec = doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p1/versions") + require.Equal(t, http.StatusOK, rec.Code) + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + assert.Equal(t, 3, out.Total) + assert.Equal(t, "draft", out.Data[0].Content) +} + +func TestPortalListVersions_OwnerReadsPersonalHistoryInFull(t *testing.T) { + fx := seededDeps() + deps, versions := fx.deps, fx.versions + versions.versions["p2"] = []prompt.Version{ + {ID: "p2v1", PromptID: "p2", Version: 1, Content: "mine", Status: prompt.VersionStatusApplied, Author: "sarah@example.com"}, + } + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "sarah@example.com"} + } + mux := newTestMux(t, deps) + rec := doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p2/versions") + require.Equal(t, http.StatusOK, rec.Code) + var out versionListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.Equal(t, 1, out.Total) + assert.Equal(t, "mine", out.Data[0].Content, "personal history is the owner's, unredacted") } func TestPortalUsage_ScopesToVisiblePrompts(t *testing.T) { @@ -377,6 +462,26 @@ func TestPortalUsage_ScopesToVisiblePrompts(t *testing.T) { "own personal + global + persona prompts; never another user's personal prompt") } +func TestPortalUsage_IncludesSharedPrompts(t *testing.T) { + deps := seededDeps().deps + usage := &fakeUsage{usage: map[string]prompt.Usage{}} + deps.Usage = usage + deps.SharedPromptIDs = func(_ context.Context, userID, email string) ([]string, error) { + assert.Equal(t, "uid-sarah", userID) + assert.Equal(t, "sarah@example.com", email) + return []string{"p-shared"}, nil + } + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{UserID: "uid-sarah", Email: "sarah@example.com"} + } + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/usage") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, usage.gotIDs, "p-shared", + "prompts shared person-to-person are as visible as the caller's own") +} + func TestPortalUsage_AdminSeesAll(t *testing.T) { deps := seededDeps().deps usage := &fakeUsage{usage: map[string]prompt.Usage{}} diff --git a/pkg/prompt/versionhttp/usage.go b/pkg/prompt/versionhttp/usage.go index 8ede1f7b..dee74ced 100644 --- a/pkg/prompt/versionhttp/usage.go +++ b/pkg/prompt/versionhttp/usage.go @@ -3,6 +3,7 @@ package versionhttp import ( "context" "net/http" + "slices" "github.com/txn2/mcp-data-platform/pkg/prompt" ) @@ -42,12 +43,7 @@ func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) { // @Security ApiKeyAuth // @Security BearerAuth // @Router /portal/prompts/usage [get] -func (h *Handler) portalUsage(w http.ResponseWriter, r *http.Request) { - user := h.deps.PortalUser(r) - if user == nil { - writeError(w, http.StatusUnauthorized, "authentication required") - return - } +func (h *Handler) portalUsage(w http.ResponseWriter, r *http.Request, user *PortalIdentity) { ids, err := h.visiblePromptIDs(r.Context(), user) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list prompts") @@ -56,12 +52,16 @@ func (h *Handler) portalUsage(w http.ResponseWriter, r *http.Request) { h.writeUsage(r.Context(), w, ids) } -// portalListVersions returns a prompt's version history to its owner (or an -// admin). Shared-scope history is an admin curation surface; non-admin viewers -// see only the served version. +// portalListVersions returns a prompt's version history to any caller who can +// view the prompt itself (#1010): version history with per-version approval +// provenance is the library's verification surface, so a reader of an +// approved shared prompt may audit what they are running. Personal prompts +// remain owner-or-admin; a personal prompt shared person-to-person via the +// portal share system does not expose its history (recipients see only the +// served version, and the UI omits the section on 403). // // @Summary List prompt versions (portal) -// @Description Returns the version history of a prompt the caller owns; admins may read any prompt's history. +// @Description Returns the version history of a prompt visible to the caller: their own personal prompts, and enabled shared (global or persona-matching) prompts. // @Tags Prompts // @Produce json // @Param id path string true "Prompt ID" @@ -73,21 +73,71 @@ func (h *Handler) portalUsage(w http.ResponseWriter, r *http.Request) { // @Security ApiKeyAuth // @Security BearerAuth // @Router /portal/prompts/{id}/versions [get] -func (h *Handler) portalListVersions(w http.ResponseWriter, r *http.Request) { - user := h.deps.PortalUser(r) - if user == nil { - writeError(w, http.StatusUnauthorized, "authentication required") - return - } +func (h *Handler) portalListVersions(w http.ResponseWriter, r *http.Request, user *PortalIdentity) { pr, ok := h.loadPrompt(w, r) if !ok { return } - if !user.IsAdmin && (pr.Scope != prompt.ScopePersonal || pr.OwnerEmail != user.Email) { - writeError(w, http.StatusForbidden, "only the owner or an admin can view a prompt's version history") + if !canViewPrompt(user, pr) { + writeError(w, http.StatusForbidden, "you do not have access to this prompt's version history") return } - h.writeVersionList(r.Context(), w, pr.ID) + versions, err := h.deps.Versions.ListVersions(r.Context(), pr.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, errListVers) + return + } + // Admins curate everything; a personal prompt's history is its owner's + // (canViewPrompt already restricted personal scope to the owner). Every + // other viewer of a shared prompt gets the served history only. + if !user.IsAdmin && pr.Scope != prompt.ScopePersonal { + versions = redactVersionsForViewer(versions) + } + if versions == nil { + versions = []prompt.Version{} + } + writeJSON(w, http.StatusOK, versionListResponse{Data: versions, Total: len(versions)}) +} + +// redactVersionsForViewer reduces a shared prompt's history to what a +// non-privileged viewer may verify: applied snapshots in full (they were +// served), and the pending draft as a metadata stub — its existence is the +// at-a-glance re-review signal, but its content was never served and stays +// admin/author-only until approved. Rejected and superseded drafts were never +// served and are dropped entirely. +func redactVersionsForViewer(versions []prompt.Version) []prompt.Version { + out := make([]prompt.Version, 0, len(versions)) + for _, v := range versions { + switch v.Status { + case prompt.VersionStatusApplied: + out = append(out, v) + case prompt.VersionStatusDraft: + v.DisplayName, v.Description, v.Content = "", "", "" + v.Arguments, v.Tags = []prompt.Argument{}, []string{} + out = append(out, v) + } + } + return out +} + +// canViewPrompt mirrors the portal prompt list's visibility rule: admins see +// everything; owners see their own personal prompts; enabled global prompts +// are visible to all; enabled persona prompts are visible to members of a +// listed persona. +func canViewPrompt(user *PortalIdentity, pr *prompt.Prompt) bool { + if user.IsAdmin { + return true + } + switch pr.Scope { + case prompt.ScopePersonal: + return pr.OwnerEmail == user.Email + case prompt.ScopeGlobal: + return pr.Enabled + case prompt.ScopePersona: + return pr.Enabled && user.Persona != "" && slices.Contains(pr.Personas, user.Persona) + default: + return false + } } // writeUsage writes the usage map for the given prompt ids. Without a usage @@ -144,5 +194,12 @@ func (h *Handler) visiblePromptIDs(ctx context.Context, user *PortalIdentity) ([ } ids = append(ids, personas...) } + if h.deps.SharedPromptIDs != nil { + shared, err := h.deps.SharedPromptIDs(ctx, user.UserID, user.Email) + if err != nil { + return nil, err + } + ids = append(ids, shared...) + } return ids, nil } diff --git a/ui/e2e/interactive/prompts.spec.ts b/ui/e2e/interactive/prompts.spec.ts new file mode 100644 index 00000000..7d862118 --- /dev/null +++ b/ui/e2e/interactive/prompts.spec.ts @@ -0,0 +1,215 @@ +import { test, expect, type Page } from "@playwright/test"; +import { authenticate } from "../screenshots/helpers/auth"; + +// Interactive coverage for the prompt library reorganization (#1010). Runs +// against MSW with the seeded prompt/collection/usage/version fixtures. +// Exercises: the two-bucket model (My Prompts / Library), collection grouping, +// facets, usage sort, the collections manager, and the viewer's version +// history, diff, invocation help, and collection assignment. + +async function openPrompts(page: Page): Promise { + await authenticate(page); + await page.goto("/portal/prompts"); + await expect(page.getByRole("button", { name: /My Prompts/ })).toBeVisible(); +} + +async function openLibraryTab(page: Page): Promise { + await openPrompts(page); + await page.getByRole("button", { name: /^Library/ }).click(); + await expect(page.getByRole("heading", { name: "Sales Reporting" })).toBeVisible(); +} + +test.describe("Prompt library buckets", () => { + test("My Prompts merges personal and shared prompts with attribution", async ({ page }) => { + await openPrompts(page); + + // A personal prompt and a shared prompt appear in one bucket. + await expect(page.getByText("My Weekly Summary")).toBeVisible(); + await expect(page.getByText("Regional Deep Dive")).toBeVisible(); + await expect(page.getByText("Shared by carol@example.com")).toBeVisible(); + + // Usage columns are populated from the rollup. + await expect(page.getByRole("main")).toContainText("Runs"); + await expect(page.getByRole("main")).toContainText("Last run"); + }); + + test("the scope taxonomy is not shown in the library", async ({ page }) => { + await openPrompts(page); + const main = page.getByRole("main"); + await expect(main).not.toContainText(/\bScope\b/); + await expect(main).not.toContainText(/\bPersona\b/); + + await page.getByRole("button", { name: /^Library/ }).click(); + await expect(main).not.toContainText(/\bScope\b/); + await expect(main).not.toContainText(/\bPersona\b/); + }); + + test("Library groups prompts by collection with a trailing default group", async ({ page }) => { + await openLibraryTab(page); + + await expect(page.getByRole("heading", { name: "Sales Reporting" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Data Operations" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Executive Briefings" })).toBeVisible(); + // Uncollected prompts land in General. + await expect(page.getByRole("heading", { name: "General" })).toBeVisible(); + await expect(page.getByText("Inventory Health Check")).toBeVisible(); + }); + + test("dead prompts are visually identifiable", async ({ page }) => { + await openLibraryTab(page); + // prompt-008 (Stock Level Alert) has no usage entry: never run. + const row = page.getByRole("row", { name: /Stock Level Alert/ }); + await expect(row.getByText("inactive")).toBeVisible(); + await expect(row.getByText("Never")).toBeVisible(); + }); +}); + +test.describe("Prompt library facets and sorting", () => { + test("collection facet narrows the list and clears", async ({ page }) => { + await openLibraryTab(page); + + await page.getByLabel("Filter by collection").selectOption({ label: "Data Operations" }); + await expect(page.getByText("Data Quality Scan", { exact: true })).toBeVisible(); + await expect(page.getByText("Daily Sales Report", { exact: true })).not.toBeVisible(); + + await page.getByRole("button", { name: /Clear filters/ }).click(); + await expect(page.getByText("Daily Sales Report", { exact: true })).toBeVisible(); + }); + + test("usage facet isolates inactive prompts", async ({ page }) => { + await openLibraryTab(page); + + await page.getByLabel("Filter by usage").selectOption("inactive"); + await expect(page.getByText("Stock Level Alert", { exact: true })).toBeVisible(); + await expect(page.getByText("Daily Sales Report", { exact: true })).not.toBeVisible(); + }); + + test("an over-narrow facet combination shows the filtered empty state", async ({ page }) => { + await openLibraryTab(page); + + await page.getByLabel("Filter by collection").selectOption({ label: "Executive Briefings" }); + await page.getByLabel("Filter by usage").selectOption("inactive"); + await expect(page.getByText("No prompts match the current filters")).toBeVisible(); + }); + + test("run-count sort surfaces the most active prompts first", async ({ page }) => { + await openLibraryTab(page); + + // Sorting by Runs defaults to descending; within the Sales Reporting + // group the daily report (128 runs) must precede the revenue forecast (23). + await page.getByRole("columnheader", { name: "Runs" }).first().click(); + const salesTable = page.getByRole("table").filter({ hasText: "Revenue Forecast" }); + const first = salesTable.getByRole("row").nth(1); + await expect(first).toContainText("Daily Sales Report"); + await expect(first).toContainText("128"); + }); + + test("search ranks across buckets and includes shared matches", async ({ page }) => { + await openPrompts(page); + + await page.getByPlaceholder("Search prompts by meaning...").fill("regional"); + await expect(page.getByText(/Ranked by relevance/)).toBeVisible(); + await expect(page.getByText("Regional Deep Dive")).toBeVisible(); + await expect(page.getByText("Shared by carol@example.com")).toBeVisible(); + }); +}); + +test.describe("Collections manager", () => { + test("creates, renames, and deletes a collection", async ({ page }) => { + await openPrompts(page); + await page.getByRole("button", { name: "Collections", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Manage collections" })).toBeVisible(); + + // Create. + await page.getByPlaceholder("Collection name").fill("Churn Playbooks"); + const [createResp] = await Promise.all([ + page.waitForResponse((r) => r.url().includes("/prompt-collections") && r.request().method() === "POST"), + page.getByRole("button", { name: "Create" }).click(), + ]); + expect(createResp.status()).toBe(201); + const dialog = page.getByRole("dialog", { name: "Manage collections" }); + await expect(dialog.getByText("Churn Playbooks")).toBeVisible(); + + // Duplicate names are rejected with the server's message. + await page.getByPlaceholder("Collection name").fill("churn playbooks"); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByText(/already exists/)).toBeVisible(); + + // Rename. + await page.getByRole("button", { name: "Rename Churn Playbooks" }).click(); + await page.getByPlaceholder("Collection name").fill("Retention Playbooks"); + const [renameResp] = await Promise.all([ + page.waitForResponse((r) => r.url().includes("/prompt-collections/") && r.request().method() === "PUT"), + page.getByRole("button", { name: "Save", exact: true }).click(), + ]); + expect(renameResp.status()).toBe(200); + await expect(dialog.getByText("Retention Playbooks")).toBeVisible(); + + // Delete. + const [deleteResp] = await Promise.all([ + page.waitForResponse((r) => r.url().includes("/prompt-collections/") && r.request().method() === "DELETE"), + page.getByRole("button", { name: "Delete Retention Playbooks" }).click(), + ]); + expect(deleteResp.status()).toBe(200); + await expect(dialog.getByText("Retention Playbooks")).not.toBeVisible(); + }); +}); + +test.describe("Prompt viewer verification surface", () => { + test("shows invocation help with a copyable bare-name invocation", async ({ page }) => { + await authenticate(page); + await page.goto("/portal/prompts/prompt-003"); + + await expect(page.getByText("Run from chat")).toBeVisible(); + await expect(page.getByText(/Run the daily-sales-report prompt/)).toBeVisible(); + }); + + test("renders version history with approval provenance and a pending draft", async ({ page }) => { + await authenticate(page); + await page.goto("/portal/prompts/prompt-003"); + + await expect(page.getByText("Version history")).toBeVisible(); + await expect(page.getByTestId("pending-draft-banner")).toContainText( + "Draft v4 by bob@example.com is pending review", + ); + await expect(page.getByText("approved by alice@example.com").first()).toBeVisible(); + await expect(page.getByText("current", { exact: true })).toBeVisible(); + }); + + test("diffs an older version against the served content", async ({ page }) => { + await authenticate(page); + await page.goto("/portal/prompts/prompt-003"); + + // v1 is the shortest snapshot; diffing it against current shows additions. + await page.getByRole("button", { name: "Diff vs current" }).last().click(); + const diff = page.getByTestId("version-diff"); + await expect(diff).toBeVisible(); + await expect(diff).toContainText("v1 → v3 (current)"); + await expect(diff).toContainText("average order value"); + + await diff.getByRole("button", { name: "Close diff" }).click(); + await expect(page.getByTestId("version-diff")).not.toBeVisible(); + }); + + test("assigns the prompt to a collection from the viewer", async ({ page }) => { + await authenticate(page); + await page.goto("/portal/prompts/prompt-004"); + + const picker = page.getByLabel("Collection"); + await expect(picker).toBeVisible(); + const [resp] = await Promise.all([ + page.waitForResponse((r) => r.url().includes("/prompts/prompt-004/collection") && r.request().method() === "PUT"), + picker.selectOption({ label: "Data Operations" }), + ]); + expect(resp.status()).toBe(200); + }); + + test("back navigation returns from the viewer to the library", async ({ page }) => { + await openPrompts(page); + await page.getByText("My Weekly Summary").click(); + await expect(page.getByText("Run from chat")).toBeVisible(); + + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(page.getByRole("button", { name: /My Prompts/ })).toBeVisible(); + }); +}); diff --git a/ui/e2e/screenshots/route-manifest.ts b/ui/e2e/screenshots/route-manifest.ts index e60a4e19..ea077a51 100644 --- a/ui/e2e/screenshots/route-manifest.ts +++ b/ui/e2e/screenshots/route-manifest.ts @@ -162,12 +162,58 @@ export const routes: ScreenshotRoute[] = [ } }, }, + { + // Library bucket: approved shared prompts grouped by collection (#1010). + slug: "prompts-library", + path: "/portal/prompts", + category: "user", + beforeCapture: async (page) => { + const btn = page.locator("button:has-text('Library')").first(); + if (await btn.isVisible()) { + await btn.click(); + await page.waitForTimeout(600); + } + }, + }, + { + // Collections manager dialog (create/rename/delete groups) (#1010). + slug: "prompt-collections", + path: "/portal/prompts", + category: "user", + beforeCapture: async (page) => { + const btn = page.locator("button:has-text('Collections')").first(); + if (await btn.isVisible()) { + await btn.click(); + await page.waitForTimeout(600); + } + }, + }, { // User-facing prompt viewer (/prompts/:id). prompt-010 is a personal prompt. slug: "prompt-view", path: "/portal/prompts/prompt-010", category: "user", }, + { + // Library prompt viewer with version history, approval provenance, and a + // pending draft awaiting review (#1010). + slug: "prompt-view-library", + path: "/portal/prompts/prompt-003", + category: "user", + }, + { + // Version diff between an older snapshot and the served version (#1010). + slug: "prompt-version-diff", + path: "/portal/prompts/prompt-003", + category: "user", + beforeCapture: async (page) => { + const btn = page.locator("button:has-text('Diff vs current')").last(); + if (await btn.isVisible()) { + await btn.click(); + await page.waitForTimeout(600); + } + }, + }, { // Share dialog (create public link + share with users) on an asset. slug: "asset-share", diff --git a/ui/src/api/admin/types/config.ts b/ui/src/api/admin/types/config.ts index 001e780c..7f77a649 100644 --- a/ui/src/api/admin/types/config.ts +++ b/ui/src/api/admin/types/config.ts @@ -132,10 +132,49 @@ export interface Prompt { owner_email: string; source: string; enabled: boolean; + /** Collection the prompt belongs to (at most one); absent = uncollected. */ + collection_id?: string; + /** Number of the snapshot the live row currently serves (#1009). */ + version?: number; created_at: string; updated_at: string; } +/** One immutable snapshot of a prompt's versioned fields, with the author and + * the approval stamp bound to that specific version (#1009). */ +export interface PromptVersion { + id: string; + prompt_id: string; + version: number; + display_name: string; + description: string; + content: string; + arguments: PromptArgument[]; + tags: string[]; + author: string; + status: "draft" | "applied" | "superseded" | "rejected"; + approved_by?: string; + approved_at?: string; + created_at: string; +} + +/** Named group organizing the prompt library by team, domain, or workflow. */ +export interface PromptCollection { + id: string; + name: string; + description: string; + created_by: string; + prompt_count: number; + created_at: string; + updated_at: string; +} + +/** Audit-derived usage rollup for one prompt (#1009). */ +export interface PromptUsage { + run_count: number; + last_run_at?: string; +} + export interface PromptListResponse { data: Prompt[]; total: number; diff --git a/ui/src/api/portal/client.ts b/ui/src/api/portal/client.ts index da66d5ac..ee739d43 100644 --- a/ui/src/api/portal/client.ts +++ b/ui/src/api/portal/client.ts @@ -44,7 +44,10 @@ async function apiFetch(path: string, init?: RequestInit): Promise { useAuthStore.getState().expireSession(); } const body = await res.json().catch(() => ({ detail: res.statusText })); - throw new ApiError(res.status, body.detail || body.message || res.statusText, body); + // The portal surface speaks RFC 9457 (detail); the prompt version and + // collection routes (pkg/prompt/versionhttp) return {"error": msg} like + // the admin API, so accept both shapes. + throw new ApiError(res.status, body.detail || body.error || body.message || res.statusText, body); } return res.json() as Promise; diff --git a/ui/src/api/portal/hooks/prompts.ts b/ui/src/api/portal/hooks/prompts.ts index a783ab9e..2d5ee990 100644 --- a/ui/src/api/portal/hooks/prompts.ts +++ b/ui/src/api/portal/hooks/prompts.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "../client"; import type { PaginatedResponse } from "../types"; +import type { Prompt, PromptCollection, PromptUsage, PromptVersion } from "@/api/admin/types"; // --------------------------------------------------------------------------- // Prompts @@ -100,3 +101,109 @@ export function useDeleteMyPrompt() { }, }); } + +// --------------------------------------------------------------------------- +// Prompt usage, versions, and collections (#1009 / #1010) +// --------------------------------------------------------------------------- + +// usePromptUsage returns the audit-derived run count and last-run timestamp +// per prompt id for every prompt visible to the caller. Prompts never served +// are absent from the map. +export function usePromptUsage() { + return useQuery({ + queryKey: ["portal", "prompt-usage"], + queryFn: () => + apiFetch>("/prompts/usage"), + }); +} + +interface PromptVersionListResponse { + data: PromptVersion[]; + total: number; +} + +// usePromptVersions returns a prompt's version history, newest first. The +// server allows any caller who can view the prompt itself; a 403 (e.g. a +// prompt shared person-to-person) is surfaced as an error the UI treats as +// "history unavailable" rather than retried. +export function usePromptVersions(promptId: string | undefined) { + return useQuery({ + queryKey: ["portal", "prompt-versions", promptId], + enabled: !!promptId, + retry: false, + queryFn: () => + apiFetch(`/prompts/${promptId}/versions`), + }); +} + +export function usePromptCollections() { + return useQuery({ + queryKey: ["portal", "prompt-collections"], + queryFn: () => + apiFetch<{ data: PromptCollection[]; total: number }>( + "/prompt-collections", + ), + }); +} + +export function useCreatePromptCollection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: { name: string; description?: string }) => + apiFetch("/prompt-collections", { + method: "POST", + body: JSON.stringify(body), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["portal", "prompt-collections"] }); + }, + }); +} + +export function useUpdatePromptCollection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...body }: { id: string; name: string; description?: string }) => + apiFetch(`/prompt-collections/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["portal", "prompt-collections"] }); + }, + }); +} + +export function useDeletePromptCollection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + apiFetch(`/prompt-collections/${id}`, { method: "DELETE" }), + onSuccess: () => { + // Deleting a collection releases its member prompts, so both lists move. + queryClient.invalidateQueries({ queryKey: ["portal", "prompt-collections"] }); + queryClient.invalidateQueries({ queryKey: ["portal", "prompts"] }); + }, + }); +} + +// useAssignPromptCollection places a prompt in a collection (empty +// collection_id clears the assignment). Owners organize their own prompts; +// admins organize shared prompts. +export function useAssignPromptCollection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, collectionId }: { id: string; collectionId: string }) => + apiFetch(`/prompts/${id}/collection`, { + method: "PUT", + body: JSON.stringify({ collection_id: collectionId }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["portal", "prompts"] }); + queryClient.invalidateQueries({ queryKey: ["portal", "prompt-collections"] }); + // An admin can organize a prompt shared with them; that list caches the + // prompt too. + queryClient.invalidateQueries({ queryKey: ["shared-prompts"] }); + }, + }); +} diff --git a/ui/src/lib/textDiff.test.ts b/ui/src/lib/textDiff.test.ts new file mode 100644 index 00000000..830d718a --- /dev/null +++ b/ui/src/lib/textDiff.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { diffLines, diffStats } from "./textDiff"; + +describe("diffLines", () => { + it("reports identical texts as all-same", () => { + const d = diffLines("a\nb", "a\nb"); + expect(d).toEqual([ + { kind: "same", text: "a" }, + { kind: "same", text: "b" }, + ]); + expect(diffStats(d)).toEqual({ added: 0, removed: 0 }); + }); + + it("detects an in-place line change as remove + add", () => { + const d = diffLines("a\nb\nc", "a\nB\nc"); + expect(d).toEqual([ + { kind: "same", text: "a" }, + { kind: "removed", text: "b" }, + { kind: "added", text: "B" }, + { kind: "same", text: "c" }, + ]); + expect(diffStats(d)).toEqual({ added: 1, removed: 1 }); + }); + + it("detects pure additions and removals", () => { + expect(diffLines("a", "a\nb")).toEqual([ + { kind: "same", text: "a" }, + { kind: "added", text: "b" }, + ]); + expect(diffLines("a\nb", "b")).toEqual([ + { kind: "removed", text: "a" }, + { kind: "same", text: "b" }, + ]); + }); + + it("handles fully disjoint texts", () => { + const d = diffLines("x\ny", "p\nq"); + expect(diffStats(d)).toEqual({ added: 2, removed: 2 }); + }); + + it("handles empty inputs", () => { + expect(diffLines("", "")).toEqual([{ kind: "same", text: "" }]); + expect(diffLines("", "a")).toEqual([ + { kind: "removed", text: "" }, + { kind: "added", text: "a" }, + ]); + }); + + it("keeps surrounding context stable around a moved block", () => { + const from = "intro\nstep one\nstep two\noutro"; + const to = "intro\nstep two\nstep one\noutro"; + const d = diffLines(from, to); + expect(d[0]).toEqual({ kind: "same", text: "intro" }); + expect(d[d.length - 1]).toEqual({ kind: "same", text: "outro" }); + const stats = diffStats(d); + expect(stats.added).toBe(1); + expect(stats.removed).toBe(1); + }); +}); diff --git a/ui/src/lib/textDiff.ts b/ui/src/lib/textDiff.ts new file mode 100644 index 00000000..fbc28346 --- /dev/null +++ b/ui/src/lib/textDiff.ts @@ -0,0 +1,61 @@ +// Line-based text diff for the prompt version history view (#1010). A plain +// LCS over lines is sufficient at prompt scale (bodies are short documents), +// avoiding a diff library dependency for one view. + +export interface DiffLine { + kind: "same" | "added" | "removed"; + text: string; +} + +// diffLines returns the line-level edit script transforming `from` into `to`: +// unchanged lines once as "same", removals from `from` and additions in `to` +// in document order. +export function diffLines(from: string, to: string): DiffLine[] { + const a = from.split("\n"); + const b = to.split("\n"); + + // LCS length table (a.length+1 x b.length+1). + const m = a.length; + const n = b.length; + const table: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + table[i]![j] = + a[i] === b[j] + ? table[i + 1]![j + 1]! + 1 + : Math.max(table[i + 1]![j]!, table[i]![j + 1]!); + } + } + + // Walk the table to emit the edit script. + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < m && j < n) { + if (a[i] === b[j]) { + out.push({ kind: "same", text: a[i]! }); + i++; + j++; + } else if (table[i + 1]![j]! >= table[i]![j + 1]!) { + out.push({ kind: "removed", text: a[i]! }); + i++; + } else { + out.push({ kind: "added", text: b[j]! }); + j++; + } + } + for (; i < m; i++) out.push({ kind: "removed", text: a[i]! }); + for (; j < n; j++) out.push({ kind: "added", text: b[j]! }); + return out; +} + +// diffStats counts added and removed lines in an edit script. +export function diffStats(lines: DiffLine[]): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const l of lines) { + if (l.kind === "added") added++; + else if (l.kind === "removed") removed++; + } + return { added, removed }; +} diff --git a/ui/src/mocks/data/prompts.ts b/ui/src/mocks/data/prompts.ts index 1d458c45..90e3afcc 100644 --- a/ui/src/mocks/data/prompts.ts +++ b/ui/src/mocks/data/prompts.ts @@ -1,4 +1,4 @@ -import type { Prompt } from "@/api/admin/types"; +import type { Prompt, PromptCollection, PromptUsage, PromptVersion } from "@/api/admin/types"; const now = new Date(); function daysAgo(n: number): string { @@ -501,6 +501,182 @@ phrased as a question with options. If there are none, write "No asks this week. updated_at: daysAgo(2), }; +// --------------------------------------------------------------------------- +// Collections (#1010): named groups organizing the library. Assignments and +// served versions are stamped onto the prompt fixtures via withMeta so every +// list (portal, admin, search) serves consistent rows. +// --------------------------------------------------------------------------- + +export const mockPromptCollections: PromptCollection[] = [ + { + id: "pcol-001", + name: "Sales Reporting", + description: "Daily and weekly sales SOPs", + created_by: "alice@example.com", + prompt_count: 4, + created_at: daysAgo(40), + updated_at: daysAgo(6), + }, + { + id: "pcol-002", + name: "Data Operations", + description: "Pipeline and quality runbooks", + created_by: "dave@example.com", + prompt_count: 2, + created_at: daysAgo(35), + updated_at: daysAgo(10), + }, + { + id: "pcol-003", + name: "Executive Briefings", + description: "Leadership-facing summaries", + created_by: "alice@example.com", + prompt_count: 1, + created_at: daysAgo(20), + updated_at: daysAgo(20), + }, +]; + +const promptMeta: Record = { + "prompt-003": { collection_id: "pcol-001", version: 3 }, + "prompt-005": { collection_id: "pcol-002", version: 1 }, + "prompt-006": { collection_id: "pcol-003", version: 2 }, + "prompt-007": { collection_id: "pcol-002", version: 1 }, + "prompt-008": { collection_id: "pcol-001", version: 1 }, + "prompt-009": { collection_id: "pcol-001", version: 1 }, + "prompt-010": { collection_id: "pcol-001", version: 2 }, +}; + +function withMeta(p: Prompt): Prompt { + const m = promptMeta[p.id]; + return m ? { ...p, version: 1, ...m } : { ...p, version: 1 }; +} + +// --------------------------------------------------------------------------- +// Usage rollup (#1009): run count + last run per prompt id. Prompts absent +// from the map have never been served, and read as inactive in the library. +// --------------------------------------------------------------------------- + +export const mockPromptUsage: Record = { + "prompt-003": { run_count: 128, last_run_at: daysAgo(1) }, + "prompt-005": { run_count: 7, last_run_at: daysAgo(92) }, + "prompt-006": { run_count: 41, last_run_at: daysAgo(9) }, + "prompt-007": { run_count: 12, last_run_at: daysAgo(45) }, + "prompt-009": { run_count: 23, last_run_at: daysAgo(3) }, + "prompt-010": { run_count: 9, last_run_at: daysAgo(2) }, + "prompt-shared-001": { run_count: 4, last_run_at: daysAgo(12) }, +}; + +// --------------------------------------------------------------------------- +// Version history (#1009): per-version author + approval provenance. The +// daily sales report carries a realistic arc: applied history, the currently +// served approved version, and a pending draft awaiting review. +// --------------------------------------------------------------------------- + +export const mockPromptVersions: Record = { + // The v3 snapshot matches the served prompt-003 content exactly (the live + // row serves the applied version), so diffs against "current" are honest. + "prompt-003": [ + { + id: "pver-003-4", + prompt_id: "prompt-003", + version: 4, + display_name: "Daily Sales Report", + description: "Generate daily sales report by region", + content: + "Generate a daily sales summary for {{date}} broken down by region. Include total revenue, transaction count, and average order value. Flag any region with revenue below {{threshold}}.\nFinish with a short narrative summary for the sales channel.", + arguments: [ + { name: "date", description: "Report date in YYYY-MM-DD format", required: true }, + { name: "threshold", description: "Minimum expected revenue in dollars", required: false }, + ], + tags: [], + author: "bob@example.com", + status: "draft", + created_at: daysAgo(1), + }, + { + id: "pver-003-3", + prompt_id: "prompt-003", + version: 3, + display_name: "Daily Sales Report", + description: "Generate daily sales report by region", + content: + "Generate a daily sales summary for {{date}} broken down by region. Include total revenue, transaction count, and average order value. Flag any region with revenue below {{threshold}}.", + arguments: [ + { name: "date", description: "Report date in YYYY-MM-DD format", required: true }, + { name: "threshold", description: "Minimum expected revenue in dollars", required: false }, + ], + tags: [], + author: "carol@example.com", + status: "applied", + approved_by: "alice@example.com", + approved_at: daysAgo(6), + created_at: daysAgo(7), + }, + { + id: "pver-003-2", + prompt_id: "prompt-003", + version: 2, + display_name: "Daily Sales Report", + description: "Generate daily sales report by region", + content: + "Generate a daily sales summary for {{date}} broken down by region.\nInclude total revenue and transaction count.", + arguments: [{ name: "date", description: "Report date in YYYY-MM-DD format", required: true }], + tags: [], + author: "carol@example.com", + status: "superseded", + created_at: daysAgo(15), + }, + { + id: "pver-003-1", + prompt_id: "prompt-003", + version: 1, + display_name: "Daily Sales Report", + description: "Generate daily sales report", + content: "Generate a daily sales summary for {{date}}.", + arguments: [{ name: "date", description: "Report date in YYYY-MM-DD format", required: true }], + tags: [], + author: "alice@example.com", + status: "applied", + approved_by: "alice@example.com", + approved_at: daysAgo(30), + created_at: daysAgo(30), + }, + ], + // v2 matches the served prompt-010 content exactly (the live row serves the + // applied version), keeping the current-version diff empty as on the real + // server. + "prompt-010": [ + { + id: "pver-010-2", + prompt_id: "prompt-010", + version: 2, + display_name: "My Weekly Summary", + description: "Personal weekly data activity summary", + content: + "Summarize my data platform activity for the past week, including queries run, artifacts created, and top tables accessed. Highlight anything unusual.", + arguments: [], + tags: [], + author: "j.martinez@example.com", + status: "applied", + created_at: daysAgo(1), + }, + { + id: "pver-010-1", + prompt_id: "prompt-010", + version: 1, + display_name: "My Weekly Summary", + description: "Personal weekly data activity summary", + content: "Summarize my data platform activity for the past week.", + arguments: [], + tags: [], + author: "j.martinez@example.com", + status: "applied", + created_at: daysAgo(14), + }, + ], +}; + // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- @@ -539,7 +715,7 @@ export const mockAdminPrompts: Prompt[] = [ requested_scope: "persona", requested_personas: ["finance-executive"], }, -]; +].map(withMeta); const personalPrompts: Prompt[] = [ myWeeklySummary, @@ -547,7 +723,7 @@ const personalPrompts: Prompt[] = [ customSqlTemplate, incidentRetro, weeklyBusinessReview, -]; +].map(withMeta); const availablePrompts: Prompt[] = [ discoverDataDomains, @@ -559,7 +735,7 @@ const availablePrompts: Prompt[] = [ etlPipelineStatus, stockLevelAlert, revenueForecast, -]; +].map(withMeta); export const mockPortalPrompts: { personal: Prompt[]; available: Prompt[] } = { personal: personalPrompts, @@ -567,8 +743,9 @@ export const mockPortalPrompts: { personal: Prompt[]; available: Prompt[] } = { }; // --------------------------------------------------------------------------- -// Prompts shared directly with the current user (surfaced on the Prompts page -// "Shared" tab). These are runnable as `shared-`. +// Prompts shared directly with the current user (merged into the "My Prompts" +// bucket with a shared-by attribution). Presented over MCP as shared-; +// the agent also resolves them from the bare name via manage_prompt use. // --------------------------------------------------------------------------- export interface MockSharedPrompt { diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts index 20078a97..b6905a73 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -42,7 +42,14 @@ import { import { mockKnowledgePages } from "./data/knowledgePages"; import { mockContent } from "./data/content"; import { mockCollections, mockSharedCollections } from "./data/collections"; -import { mockAdminPrompts, mockPortalPrompts, mockSharedPrompts } from "./data/prompts"; +import { + mockAdminPrompts, + mockPortalPrompts, + mockSharedPrompts, + mockPromptCollections, + mockPromptUsage, + mockPromptVersions, +} from "./data/prompts"; import { mockResources } from "./data/resources"; import { mockThreads, mockThreadEvents, mockThreadChains } from "./data/feedback"; import { mockAPIKeys } from "./data/keys"; @@ -59,6 +66,10 @@ import { import { promInstantFor, promRangeFor } from "./data/observability"; import { mockIndexJobsSummary, mockIndexJobs, mockIndexJobsFailures } from "./data/indexjobs"; +// Mutable copy backing the stateful prompt-collection handlers (#1010); module +// state resets on page load, which is what the MSW-mode flows need. +const statefulPromptCollections = mockPromptCollections.map((c) => ({ ...c })); + const ADMIN_BASE = "/api/v1/admin"; const PORTAL_BASE = "/api/v1/portal"; const OBSERVABILITY_BASE = "/api/v1/observability"; @@ -2359,6 +2370,104 @@ export const handlers = [ http.get(`${PORTAL_BASE}/prompts/:id/shares`, () => HttpResponse.json([])), + // Usage rollup (#1009): run count + last run per visible prompt id. + http.get(`${PORTAL_BASE}/prompts/usage`, () => HttpResponse.json(mockPromptUsage)), + + // Version history (#1009/#1010): newest first, with approval provenance. + http.get(`${PORTAL_BASE}/prompts/:id/versions`, ({ params }) => { + const versions = mockPromptVersions[String(params.id)] ?? []; + return HttpResponse.json({ data: versions, total: versions.length }); + }), + + // Collections (#1010): stateful in the mock so the manage/create/assign + // flows are exercisable end-to-end in MSW mode. + http.get(`${PORTAL_BASE}/prompt-collections`, () => { + // Counts are computed per read like the real server's LEFT JOIN COUNT, so + // they stay honest after assignments. + const allPrompts = [ + ...mockPortalPrompts.personal, + ...mockPortalPrompts.available, + ...mockSharedPrompts.map((s) => s.prompt), + ]; + const data = statefulPromptCollections.map((c) => ({ + ...c, + prompt_count: allPrompts.filter((p) => p.collection_id === c.id).length, + })); + return HttpResponse.json({ data, total: data.length }); + }), + + http.post(`${PORTAL_BASE}/prompt-collections`, async ({ request }) => { + const body = (await request.json()) as { name?: string; description?: string }; + const name = (body.name ?? "").trim(); + if (!name) { + return HttpResponse.json({ error: "collection name is required" }, { status: 400 }); + } + if (statefulPromptCollections.some((c) => c.name.toLowerCase() === name.toLowerCase())) { + return HttpResponse.json({ error: "a collection with that name already exists" }, { status: 409 }); + } + const created = { + id: `pcol-${String(statefulPromptCollections.length + 1).padStart(3, "0")}`, + name, + description: body.description ?? "", + created_by: "admin@example.com", + prompt_count: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + statefulPromptCollections.push(created); + return HttpResponse.json(created, { status: 201 }); + }), + + http.put(`${PORTAL_BASE}/prompt-collections/:id`, async ({ params, request }) => { + const col = statefulPromptCollections.find((c) => c.id === params.id); + if (!col) { + return HttpResponse.json({ error: "collection not found" }, { status: 404 }); + } + const body = (await request.json()) as { name?: string; description?: string }; + const name = (body.name ?? "").trim(); + // Mirror the real server: empty names are 400, renaming onto another + // collection's name (case-insensitively) is 409. + if (!name) { + return HttpResponse.json({ error: "collection name is required" }, { status: 400 }); + } + if (statefulPromptCollections.some((c) => c.id !== params.id && c.name.toLowerCase() === name.toLowerCase())) { + return HttpResponse.json({ error: "a collection with that name already exists" }, { status: 409 }); + } + col.name = name; + col.description = body.description ?? col.description; + col.updated_at = new Date().toISOString(); + return HttpResponse.json(col); + }), + + http.delete(`${PORTAL_BASE}/prompt-collections/:id`, ({ params }) => { + const idx = statefulPromptCollections.findIndex((c) => c.id === params.id); + if (idx === -1) { + return HttpResponse.json({ error: "collection not found" }, { status: 404 }); + } + statefulPromptCollections.splice(idx, 1); + return HttpResponse.json({ status: "deleted" }); + }), + + // Assignment (#1010): stamp the prompt fixture so subsequent list reads + // reflect the move. + http.put(`${PORTAL_BASE}/prompts/:id/collection`, async ({ params, request }) => { + const body = (await request.json()) as { collection_id?: string }; + const all = [ + ...mockPortalPrompts.personal, + ...mockPortalPrompts.available, + ...mockSharedPrompts.map((s) => s.prompt), + ]; + const target = all.find((p) => p.id === params.id); + if (!target) { + return HttpResponse.json({ error: "prompt not found" }, { status: 404 }); + } + if (body.collection_id && !statefulPromptCollections.some((c) => c.id === body.collection_id)) { + return HttpResponse.json({ error: "collection not found" }, { status: 404 }); + } + target.collection_id = body.collection_id || undefined; + return HttpResponse.json(target); + }), + // ========================================================================= // Admin — Prompts // ========================================================================= diff --git a/ui/src/pages/prompts/CollectionsManagerDialog.tsx b/ui/src/pages/prompts/CollectionsManagerDialog.tsx new file mode 100644 index 00000000..e14d24f7 --- /dev/null +++ b/ui/src/pages/prompts/CollectionsManagerDialog.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { FolderOpen, Pencil, Plus, Trash2, X } from "lucide-react"; +import { useAuthStore } from "@/stores/auth"; +import { + usePromptCollections, + useCreatePromptCollection, + useUpdatePromptCollection, + useDeletePromptCollection, +} from "@/api/portal/hooks"; +import type { PromptCollection } from "@/api/admin/types"; + +// CollectionsManagerDialog creates, renames, and deletes prompt collections +// (#1010). Any user can create a collection; renaming and deleting are limited +// to the collection's creator or an admin (mirrored server-side). Deleting a +// collection releases its prompts to the default group, so it is safe. +export function CollectionsManagerDialog({ onClose }: { onClose: () => void }) { + const { data, isLoading } = usePromptCollections(); + const createMutation = useCreatePromptCollection(); + const updateMutation = useUpdatePromptCollection(); + const deleteMutation = useDeletePromptCollection(); + const myEmail = useAuthStore((s) => s.user?.email) ?? ""; + const isAdmin = useAuthStore((s) => s.isAdmin)(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [editingId, setEditingId] = useState(null); + const [error, setError] = useState(null); + + const collections = data?.data ?? []; + const canManage = (c: PromptCollection) => isAdmin || c.created_by === myEmail; + + function reportError(err: unknown) { + setError(err instanceof Error ? err.message : "Operation failed"); + } + + function resetForm() { + setName(""); + setDescription(""); + setEditingId(null); + setError(null); + } + + function handleSubmit() { + setError(null); + const body = { name, description }; + if (editingId) { + updateMutation.mutate({ id: editingId, ...body }, { onSuccess: resetForm, onError: reportError }); + } else { + createMutation.mutate(body, { onSuccess: resetForm, onError: reportError }); + } + } + + function startEdit(c: PromptCollection) { + setEditingId(c.id); + setName(c.name); + setDescription(c.description); + setError(null); + } + + function handleDelete(c: PromptCollection) { + setError(null); + // Deleting the collection being renamed would leave a stale edit form + // pointing at a dead id. + if (editingId === c.id) resetForm(); + deleteMutation.mutate(c.id, { onError: reportError }); + } + + return ( +
+
+
+

+ Manage collections +

+ +
+ + + + +
+
+ ); +} + +function CollectionEditorForm({ + editing, + name, + description, + error, + pending, + setName, + setDescription, + onCancel, + onSubmit, +}: { + editing: boolean; + name: string; + description: string; + error: string | null; + pending: boolean; + setName: (v: string) => void; + setDescription: (v: string) => void; + onCancel: () => void; + onSubmit: () => void; +}) { + return ( +
+
+ {editing ? "Rename collection" : "New collection"} +
+ setName(e.target.value)} + placeholder="Collection name" + className="w-full rounded-md border bg-background px-3 py-1.5 text-sm outline-none ring-ring focus:ring-2" + /> + setDescription(e.target.value)} + placeholder="Description (optional)" + className="w-full rounded-md border bg-background px-3 py-1.5 text-sm outline-none ring-ring focus:ring-2" + /> + {error && ( +
{error}
+ )} +
+ {editing && ( + + )} + +
+
+ ); +} + +function CollectionList({ + collections, + isLoading, + canManage, + deletePending, + onEdit, + onDelete, +}: { + collections: PromptCollection[]; + isLoading: boolean; + canManage: (c: PromptCollection) => boolean; + deletePending: boolean; + onEdit: (c: PromptCollection) => void; + onDelete: (c: PromptCollection) => void; +}) { + if (isLoading) { + return
Loading...
; + } + if (collections.length === 0) { + return ( +
+ No collections yet. Create one below to group prompts by team, domain, or workflow. +
+ ); + } + return ( +
    + {collections.map((c) => ( +
  • +
    +
    {c.name}
    + {c.description && ( +
    {c.description}
    + )} +
    + + {c.prompt_count} prompt{c.prompt_count === 1 ? "" : "s"} + + {canManage(c) && ( + <> + + + + )} +
  • + ))} +
+ ); +} diff --git a/ui/src/pages/prompts/MyPromptsPage.tsx b/ui/src/pages/prompts/MyPromptsPage.tsx index 7e4e352b..a82c5166 100644 --- a/ui/src/pages/prompts/MyPromptsPage.tsx +++ b/ui/src/pages/prompts/MyPromptsPage.tsx @@ -1,104 +1,54 @@ import { useState, useEffect, useCallback, useMemo } from "react"; +import { Search, Plus, MessageSquare, FolderOpen } from "lucide-react"; import { - Search, - Plus, - Globe, - Users, - User, - MessageSquare, - X, - Save, - ChevronDown, - ChevronUp, - ChevronsUpDown, -} from "lucide-react"; -import { useMyPrompts, useCreateMyPrompt, useSearchMyPrompts, useSharedPrompts } from "@/api/portal/hooks"; -import type { SharedPromptItem } from "@/api/portal/hooks"; -import type { Prompt } from "@/api/admin/types"; -import { SharePermissionBadge } from "@/components/SharePermissionBadge"; + useMyPrompts, + useSearchMyPrompts, + useSharedPrompts, + usePromptUsage, + usePromptCollections, +} from "@/api/portal/hooks"; +import type { Prompt, PromptCollection } from "@/api/admin/types"; import { cn } from "@/lib/utils"; -import { extractPromptArguments } from "./promptArguments"; -import { MarkdownEditor } from "@/components/MarkdownEditor"; -import { PromptNameField } from "./PromptNameField"; -import { PromptStatusBadge } from "./PromptStatusBadge"; -import { TagsField } from "./TagsField"; -import { validatePromptName, isPromptNameConflict } from "./promptName"; +import { CollectionsManagerDialog } from "./CollectionsManagerDialog"; +import { PromptCreateForm } from "./PromptCreateForm"; +import { PromptFacetsBar } from "./PromptFacetsBar"; +import { PromptListTable } from "./PromptListTable"; +import type { UsageFacet } from "./promptUsage"; +import { + allFacets, + facetsActive, + matchesFacets, + sortRows, + type Facets, + type Row, + type SortDir, + type SortKey, +} from "./promptList"; interface Props { onNavigate: (path: string) => void; } -type ScopeStyle = { label: string; icon: typeof Globe; color: string }; - -const scopeStyles: Record = { - global: { label: "Global", icon: Globe, color: "bg-blue-500/10 text-blue-400 border-blue-500/20" }, - persona: { label: "Persona", icon: Users, color: "bg-purple-500/10 text-purple-400 border-purple-500/20" }, - personal: { label: "Personal", icon: User, color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" }, - system: { label: "System", icon: MessageSquare, color: "bg-amber-500/10 text-amber-400 border-amber-500/20" }, -}; - -const defaultScopeStyle: ScopeStyle = scopeStyles["personal"]!; - -function getScopeStyle(scope: string): ScopeStyle { - const match = scopeStyles[scope]; - return match !== undefined ? match : defaultScopeStyle; -} - -function ScopeBadge({ scope }: { scope: string }) { - const cfg = getScopeStyle(scope); - const Icon = cfg.icon; - return ( - - - {cfg.label} - - ); -} - -type Tab = "personal" | "available" | "shared"; -type SortKey = "name" | "scope" | "description" | "category"; -type SortDir = "asc" | "desc"; +// The library's two buckets (#1010): My Prompts is the caller's personal +// prompts plus prompts shared with them (attributed); Library is the approved +// shared set visible to them, grouped by collection. Scope taxonomy stays out +// of this page. +type Tab = "mine" | "library"; -interface FormData { - name: string; - display_name: string; - description: string; - content: string; - category: string; - tags: string[]; - arguments: Prompt["arguments"]; -} - -const emptyForm: FormData = { - name: "", - display_name: "", - description: "", - content: "", - category: "", - tags: [], - arguments: [], -}; - -function sortValue(p: Prompt, key: SortKey): string { - switch (key) { - case "name": return (p.display_name || p.name || "").toLowerCase(); - case "scope": return p.scope || ""; - case "description": return (p.description || "").toLowerCase(); - case "category": return (p.category || "").toLowerCase(); - default: return ""; - } +interface LibraryGroup { + collection: PromptCollection | undefined; + rows: Row[]; } export function MyPromptsPage({ onNavigate }: Props) { - const [tab, setTab] = useState("personal"); + const [tab, setTab] = useState("mine"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [creating, setCreating] = useState(false); - const [form, setForm] = useState(emptyForm); - const [mutationError, setMutationError] = useState(null); - const [nameConflict, setNameConflict] = useState(null); + const [managingCollections, setManagingCollections] = useState(false); const [sortBy, setSortBy] = useState("name"); const [sortDir, setSortDir] = useState("asc"); + const [facets, setFacets] = useState(allFacets); useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(search), 300); @@ -107,135 +57,148 @@ export function MyPromptsPage({ onNavigate }: Props) { const { data, isLoading } = useMyPrompts(); const { data: sharedPrompts = [], isLoading: sharedLoading } = useSharedPrompts(); - const createMutation = useCreateMyPrompt(); + // usageReady distinguishes "usage unknown" (query pending or failed) from + // "absent from the map = never run": rows show a dash, never a false + // "inactive" claim, until the rollup actually arrives. + const { data: usageMap, isSuccess: usageReady } = usePromptUsage(); + const { data: collectionData } = usePromptCollections(); const searching = debouncedSearch.trim().length > 0; - // Semantic ranking covers personal/available scopes; on the Shared tab the - // search box filters the shared list client-side instead. - const searchResults = useSearchMyPrompts(tab === "shared" ? "" : debouncedSearch); + const searchResults = useSearchMyPrompts(debouncedSearch); - const personal = data?.personal ?? []; - const available = data?.available ?? []; - const items = tab === "personal" ? personal : available; - const isPersonalTab = tab === "personal"; - const isSharedTab = tab === "shared"; + const collections = useMemo(() => collectionData?.data ?? [], [collectionData]); + const collectionById = useMemo(() => { + const m = new Map(); + for (const c of collections) m.set(c.id, c); + return m; + }, [collections]); - const filteredSharedPrompts = sharedPrompts.filter((s) => { - const q = debouncedSearch.trim().toLowerCase(); - if (!q) return true; - return ( - (s.prompt.display_name || s.prompt.name || "").toLowerCase().includes(q) || - (s.prompt.description || "").toLowerCase().includes(q) - ); - }); + // My Prompts: personal plus shared-with-me, attributed. + const myRows = useMemo(() => { + const own = (data?.personal ?? []).map((p) => ({ prompt: p })); + const shared = sharedPrompts.map((s) => ({ prompt: s.prompt, sharedBy: s.shared_by })); + return [...own, ...shared]; + }, [data, sharedPrompts]); - const handleSort = useCallback((key: SortKey) => { - setSortBy((prev) => { - if (prev === key) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - return prev; - } - setSortDir("asc"); - return key; - }); - }, []); + // Library: the approved shared prompts visible to the caller. + const libraryRows = useMemo( + () => (data?.available ?? []).map((p) => ({ prompt: p })), + [data], + ); + + const rows = tab === "mine" ? myRows : libraryRows; + const isMineTab = tab === "mine"; - // Browse mode: the active tab's prompts sorted by the chosen column. - const sorted = useMemo(() => { - const list = [...items]; - list.sort((a, b) => { - const av = sortValue(a, sortBy); - const bv = sortValue(b, sortBy); - const cmp = av.localeCompare(bv); - return sortDir === "asc" ? cmp : -cmp; - }); - return list; - }, [items, sortBy, sortDir]); + // Facet vocabularies come from the active bucket's rows. + const tagOptions = useMemo(() => { + const tags = new Set(); + for (const r of rows) for (const t of r.prompt.tags ?? []) tags.add(t); + return [...tags].sort(); + }, [rows]); + const ownerOptions = useMemo(() => { + const owners = new Set(); + for (const r of rows) if (r.prompt.owner_email) owners.add(r.prompt.owner_email); + return [...owners].sort(); + }, [rows]); - // Search mode: approved prompts across the caller's visibility ranked by - // relevance. The server applies visibility before ranking and returns results - // already ordered, so the rank order is preserved (no client re-sort). - const ranked = useMemo( - () => (searchResults.data?.data ?? []).map((s) => s.prompt), - [searchResults.data], + // handleSort toggles direction on the active column, or activates a new + // column at its default direction (usage sorts default descending, most + // active first). State updates stay outside the updater functions so + // StrictMode's double-invocation cannot cancel the toggle. + const handleSort = useCallback( + (key: SortKey) => { + if (sortBy === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + return; + } + setSortBy(key); + setSortDir(key === "name" ? "asc" : "desc"); + }, + [sortBy], ); - const displayItems = searching ? ranked : sorted; - const listLoading = searching ? searchResults.isLoading : isLoading; + const visibleRows = useMemo(() => { + // Without usage data the activity facet must not misclassify everything + // as inactive, so it is suspended until the rollup arrives. + const effective = usageReady ? facets : { ...facets, usage: "all" as UsageFacet }; + return sortRows( + rows.filter((r) => matchesFacets(r, effective, usageMap?.[r.prompt.id])), + sortBy, + sortDir, + usageMap, + ); + }, [rows, facets, usageMap, usageReady, sortBy, sortDir]); - function openCreate() { - setForm(emptyForm); - setCreating(true); - } + // Library groups by collection; uncollected prompts land in the trailing + // default group. My Prompts stays flat (a collection column instead). + const groupedLibrary = useMemo(() => { + if (isMineTab) return []; + const groups = new Map(); + for (const r of visibleRows) { + const key = r.prompt.collection_id && collectionById.has(r.prompt.collection_id) + ? r.prompt.collection_id + : ""; + const list = groups.get(key) ?? []; + list.push(r); + groups.set(key, list); + } + const named = [...groups.entries()] + .filter(([id]) => id !== "") + .map(([id, list]) => ({ collection: collectionById.get(id), rows: list })) + .sort((a, b) => (a.collection?.name ?? "").localeCompare(b.collection?.name ?? "")); + const rest = groups.get(""); + if (rest) named.push({ collection: undefined, rows: rest }); + return named; + }, [isMineTab, visibleRows, collectionById]); - function handleContentChange(next: string) { - setForm((prev) => ({ - ...prev, - content: next, - arguments: extractPromptArguments(next, prev.arguments), - })); - } + // Search mode: server-ranked results across personal and Library, plus + // client-side matches from the shared-with-me list (which the server's + // ranked search does not cover), so both buckets are searched. + const searchRows = useMemo(() => { + const ranked = (searchResults.data?.data ?? []).map((s) => ({ prompt: s.prompt })); + const q = debouncedSearch.trim().toLowerCase(); + const seen = new Set(ranked.map((r) => r.prompt.id)); + const sharedMatches = sharedPrompts + .filter((s) => !seen.has(s.prompt.id)) + .filter( + (s) => + (s.prompt.display_name || s.prompt.name || "").toLowerCase().includes(q) || + (s.prompt.description || "").toLowerCase().includes(q), + ) + .map((s) => ({ prompt: s.prompt, sharedBy: s.shared_by })); + return [...ranked, ...sharedMatches]; + }, [searchResults.data, sharedPrompts, debouncedSearch]); - function updateArgField(name: string, patch: Partial) { - setForm((prev) => ({ - ...prev, - arguments: prev.arguments.map((a) => (a.name === name ? { ...a, ...patch } : a)), - })); - } + const listLoading = searching ? searchResults.isLoading : isLoading || (isMineTab && sharedLoading); + const filtersOn = facetsActive(facets); - function handleCreate() { - setMutationError(null); - setNameConflict(null); - createMutation.mutate(form, { - onSuccess: (p) => { - setCreating(false); - setMutationError(null); - if (p?.id) onNavigate(`/prompts/${p.id}`); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : "Operation failed"; - if (isPromptNameConflict(msg)) { - setNameConflict("That name is already taken."); - } else { - setMutationError(msg); - } - }, - }); + // switchTab resets the facets: their vocabularies (tags, owners) and the + // tab-specific selects (status vs owner) belong to one bucket, so a filter + // set on one tab must not silently narrow the other. + function switchTab(next: Tab) { + setTab(next); + setFacets(allFacets); } - function openPrompt(p: Prompt) { - onNavigate(`/prompts/${p.id}`); - } + const openPrompt = useCallback((p: Prompt) => onNavigate(`/prompts/${p.id}`), [onNavigate]); - const colDefs: { key: SortKey; label: string; width?: string; mdOnly?: boolean }[] = [ - { key: "name", label: "Name" }, - { key: "scope", label: "Scope", width: "w-[110px]" }, - { key: "description", label: "Description" }, - { key: "category", label: "Category", width: "w-[120px]", mdOnly: true }, - ]; + const tableProps = { + sortBy, + sortDir, + onSort: handleSort, + usageMap, + usageReady, + collectionById, + showStatus: isMineTab, + onOpen: openPrompt, + }; - function renderSortHeader(col: typeof colDefs[number]) { - const active = sortBy === col.key; - return ( - handleSort(col.key)} - className={cn( - "px-4 py-2 text-left font-medium text-muted-foreground cursor-pointer select-none hover:bg-muted/80", - col.width, - col.mdOnly && "hidden md:table-cell", - )} - > - - {col.label} - {active ? ( - sortDir === "asc" ? : - ) : ( - - )} - - - ); - } + const emptyMessage = searching + ? `No prompts match "${debouncedSearch.trim()}"` + : filtersOn + ? "No prompts match the current filters" + : isMineTab + ? "No personal prompts yet" + : "The library is empty"; return (
@@ -243,280 +206,111 @@ export function MyPromptsPage({ onNavigate }: Props) {
-
setSearch(e.target.value)} placeholder="Search prompts by meaning..." className="w-full rounded-md border bg-background pl-9 pr-3 py-2 text-sm outline-none ring-ring focus:ring-2" />
- {isPersonalTab && ( - + {isMineTab && ( + )}
- {/* Create form (inline) */} + {/* Facets (browse mode; search results keep their rank order) */} + {!searching && ( + + )} + {creating && ( -
-
-

Create Prompt

- -
-
- { setForm({ ...form, name: v }); setNameConflict(null); }} - serverError={nameConflict} - /> -
- - setForm({ ...form, display_name: e.target.value })} className="w-full rounded-md border bg-background px-3 py-1.5 text-sm outline-none" placeholder="My Prompt" /> -
-
- -