diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index ca96b88e7a6..bf3994fde1c 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -27,6 +27,7 @@ const { persistMCPAuthorizationFenceRetry, } = require('~/server/services/MCPAuthorizationFenceRetry'); const { processDeleteRequest } = require('~/server/services/Files/process'); +const { getPromptService } = require('~/server/services/Prompts'); const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore'); const { drainAgentTriggerDeliveriesForUser, @@ -517,7 +518,7 @@ const deleteUserController = async (req, res) => { await db.deleteAssistants({ user: user.id }); await db.deleteConversationTags({ user: user.id }); await db.deleteAllUserMemories(user.id); - await db.deleteUserPrompts(user.id); + await getPromptService().deleteUserPrompts(user.id); await db.deleteUserSkills(user.id); await deleteUserMcpServers(user.id); await db.deleteActions({ user: user.id }); diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index f625311d49d..91928c80eee 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -147,6 +147,12 @@ jest.mock('~/server/services/Files/process', () => ({ processDeleteRequest: (...args) => mockProcessDeleteRequest(...args), })); +jest.mock('~/server/services/Prompts', () => ({ + getPromptService: () => ({ + deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args), + }), +})); + jest.mock('~/server/services/Agents/triggers', () => ({ drainAgentTriggerDeliveriesForUser: (...args) => mockDrainAgentTriggerDeliveriesForUser(...args), prepareAgentTriggerUserPurge: (...args) => mockPrepareAgentTriggerUserPurge(...args), diff --git a/api/server/middleware/accessResources/canAccessPromptGroupResource.js b/api/server/middleware/accessResources/canAccessPromptGroupResource.js index 9da1994a772..6de084d2d4c 100644 --- a/api/server/middleware/accessResources/canAccessPromptGroupResource.js +++ b/api/server/middleware/accessResources/canAccessPromptGroupResource.js @@ -1,43 +1,17 @@ const { ResourceType } = require('librechat-data-provider'); +const { getPromptAccessResolvers } = require('~/server/services/Prompts'); const { canAccessResource } = require('./canAccessResource'); -const { getPromptGroup } = require('~/models'); -/** - * PromptGroup ID resolver function - * Resolves promptGroup ID to MongoDB ObjectId - * - * @param {string} groupId - PromptGroup ID from route parameter - * @returns {Promise} PromptGroup document with _id field, or null if not found - */ -const resolvePromptGroupId = async (groupId) => { - return await getPromptGroup({ _id: groupId }); -}; +const { resolvePromptGroup } = getPromptAccessResolvers(); /** * PromptGroup-specific middleware factory that creates middleware to check promptGroup access permissions. - * This middleware extends the generic canAccessResource to handle promptGroup ID resolution. + * On success, the loaded group is available as `req.resourceAccess.resourceInfo`. * * @param {Object} options - Configuration options * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share) * @param {string} [options.resourceIdParam='groupId'] - The name of the route parameter containing the promptGroup ID * @returns {Function} Express middleware function - * - * @example - * // Basic usage for viewing promptGroups - * router.get('/prompts/groups/:groupId', - * canAccessPromptGroupResource({ requiredPermission: 1 }), - * getPromptGroup - * ); - * - * @example - * // Custom resource ID parameter and edit permission - * router.patch('/prompts/groups/:id', - * canAccessPromptGroupResource({ - * requiredPermission: 2, - * resourceIdParam: 'id' - * }), - * updatePromptGroup - * ); */ const canAccessPromptGroupResource = (options) => { const { requiredPermission, resourceIdParam = 'groupId' } = options; @@ -52,7 +26,7 @@ const canAccessPromptGroupResource = (options) => { resourceType: ResourceType.PROMPTGROUP, requiredPermission, resourceIdParam, - idResolver: resolvePromptGroupId, + idResolver: resolvePromptGroup, }); }; diff --git a/api/server/middleware/accessResources/canAccessPromptViaGroup.js b/api/server/middleware/accessResources/canAccessPromptViaGroup.js index 534db3d6c69..5bcc59dba31 100644 --- a/api/server/middleware/accessResources/canAccessPromptViaGroup.js +++ b/api/server/middleware/accessResources/canAccessPromptViaGroup.js @@ -1,39 +1,19 @@ const { ResourceType } = require('librechat-data-provider'); +const { getPromptAccessResolvers } = require('~/server/services/Prompts'); const { canAccessResource } = require('./canAccessResource'); -const { getPrompt } = require('~/models'); -/** - * Prompt to PromptGroup ID resolver function - * Resolves prompt ID to its parent promptGroup ID - * - * @param {string} promptId - Prompt ID from route parameter - * @returns {Promise} Object with promptGroup's _id field, or null if not found - */ -const resolvePromptToGroupId = async (promptId) => { - const prompt = await getPrompt({ _id: promptId }); - if (!prompt || !prompt.groupId) { - return null; - } - // Return an object with _id that matches the promptGroup ID - return { _id: prompt.groupId }; -}; +const { resolvePromptViaGroup } = getPromptAccessResolvers(); /** * Middleware factory that checks promptGroup permissions when accessing individual prompts. * This allows permission management at the promptGroup level while still supporting - * individual prompt access patterns. + * individual prompt access patterns. On success, the loaded revision is available as + * `req.resourceAccess.resourceInfo.prompt`. * * @param {Object} options - Configuration options * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share) * @param {string} [options.resourceIdParam='promptId'] - The name of the route parameter containing the prompt ID * @returns {Function} Express middleware function - * - * @example - * // Check promptGroup permissions when viewing a prompt - * router.get('/prompts/:promptId', - * canAccessPromptViaGroup({ requiredPermission: 1 }), - * getPrompt - * ); */ const canAccessPromptViaGroup = (options) => { const { requiredPermission, resourceIdParam = 'promptId' } = options; @@ -46,7 +26,7 @@ const canAccessPromptViaGroup = (options) => { resourceType: ResourceType.PROMPTGROUP, requiredPermission, resourceIdParam, - idResolver: resolvePromptToGroupId, + idResolver: resolvePromptViaGroup, }); }; diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index f166a907e32..f75fed303cc 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -1,44 +1,6 @@ const express = require('express'); -const { ObjectId } = require('mongodb'); -const { logger, isValidObjectIdString } = require('@librechat/data-schemas'); -const { - generateCheckAccess, - markPublicPromptGroups, - buildPromptGroupFilter, - formatPromptGroupsResponse, - safeValidatePromptGroupUpdate, - createEmptyPromptGroupsResponse, - filterAccessibleIdsBySharedLogic, - inspectPromptContent, - projectStoredPrompts, - projectStoredPromptGroup, - projectStoredPromptGroups, - contentFilterBlockResponse, -} = require('@librechat/api'); -const { - Permissions, - ResourceType, - AccessRoleIds, - PrincipalType, - PermissionBits, - PermissionTypes, -} = require('librechat-data-provider'); -const { SystemCapabilities } = require('@librechat/data-schemas'); -const { - getListPromptGroupsByAccess, - getPromptGroupAccessContext, - incrementPromptGroupUsage, - makePromptProduction, - updatePromptGroup, - deletePromptGroup, - createPromptGroup, - getPromptGroup, - getRoleByName, - deletePrompt, - getPrompts, - savePrompt, - getPrompt, -} = require('~/models'); +const { generateCheckAccess } = require('@librechat/api'); +const { Permissions, PermissionBits, PermissionTypes } = require('librechat-data-provider'); const { canAccessPromptGroupResource, canAccessPromptViaGroup, @@ -46,19 +8,11 @@ const { requireJwtAuth, configMiddleware, } = require('~/server/middleware'); -const { getEffectivePermissions, grantPermission } = require('~/server/services/PermissionService'); -const { hasCapability } = require('~/server/middleware/roles/capabilities'); +const { getPromptHandlers } = require('~/server/services/Prompts'); +const { getRoleByName } = require('~/models'); const router = express.Router(); - -const blockFilteredPromptContent = (req, res, promptData) => { - const finding = inspectPromptContent(promptData, req.config?.filters); - if (finding == null) { - return false; - } - res.status(400).json(contentFilterBlockResponse(finding)); - return true; -}; +const handlers = getPromptHandlers(); const checkPromptAccess = generateCheckAccess({ permissionType: PermissionTypes.PROMPTS, @@ -74,502 +28,52 @@ const checkPromptCreate = generateCheckAccess({ router.use(requireJwtAuth); router.use(checkPromptAccess); -const checkGlobalPromptShare = generateCheckAccess({ - permissionType: PermissionTypes.PROMPTS, - permissions: [Permissions.USE, Permissions.CREATE], - getRoleByName, -}); - -/** - * Route to get single prompt group by its ID - * GET /groups/:groupId - */ router.get( '/groups/:groupId', - canAccessPromptGroupResource({ - requiredPermission: PermissionBits.VIEW, - }), + canAccessPromptGroupResource({ requiredPermission: PermissionBits.VIEW }), configMiddleware, - async (req, res) => { - const { groupId } = req.params; - - try { - const group = await getPromptGroup({ _id: groupId }); - - if (!group) { - return res.status(404).send({ message: 'Prompt group not found' }); - } - - if (blockFilteredPromptContent(req, res, { group })) { - return; - } - - res.status(200).send(projectStoredPromptGroup(group, req.config?.filters)); - } catch (error) { - logger.error('Error getting prompt group', error); - res.status(500).send({ message: 'Error getting prompt group' }); - } - }, + handlers.getPromptGroup, ); +router.get('/all', configMiddleware, handlers.listAllPromptGroups); +router.get('/groups', configMiddleware, handlers.listPromptGroups); -/** - * Route to fetch all prompt groups (ACL-aware) - * GET /all - */ -router.get('/all', configMiddleware, async (req, res) => { - try { - const userId = req.user.id; - const { name, category } = req.query; - const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ - name, - category, - }); - - const { accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds } = - await getPromptGroupAccessContext({ userId, role: req.user.role }); - - const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ - accessibleIds, - searchShared, - searchSharedOnly, - publicPromptGroupIds: publiclyAccessibleIds, - ownedPromptGroupIds, - }); - - const result = await getListPromptGroupsByAccess({ - accessibleIds: filteredAccessibleIds, - otherParams: filter, - }); - - if (!result) { - return res.status(200).send([]); - } - - const { data: promptGroups = [] } = result; - if (!promptGroups.length) { - return res.status(200).send([]); - } - - const readablePromptGroups = projectStoredPromptGroups(promptGroups, req.config?.filters, { - forReuse: true, - }); - const groupsWithPublicFlag = markPublicPromptGroups( - readablePromptGroups, - publiclyAccessibleIds, - ); - res.status(200).send(groupsWithPublicFlag); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error getting prompt groups' }); - } -}); - -/** - * Route to fetch paginated prompt groups with filters (ACL-aware) - * GET /groups - */ -router.get('/groups', configMiddleware, async (req, res) => { - try { - const userId = req.user.id; - const { pageSize, limit, cursor, name, category } = req.query; - - const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ - name, - category, - }); - - let actualLimit = limit; - let actualCursor = cursor; - - if (pageSize && !limit) { - actualLimit = parseInt(pageSize, 10); - } - - if ( - actualCursor && - (actualCursor === 'undefined' || actualCursor === 'null' || actualCursor.length === 0) - ) { - actualCursor = null; - } - - const { accessibleIds, publiclyAccessibleIds, ownedPromptGroupIds } = - await getPromptGroupAccessContext({ userId, role: req.user.role }); - - const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ - accessibleIds, - searchShared, - searchSharedOnly, - publicPromptGroupIds: publiclyAccessibleIds, - ownedPromptGroupIds, - }); - - // Cursor-based pagination only - const result = await getListPromptGroupsByAccess({ - accessibleIds: filteredAccessibleIds, - otherParams: filter, - limit: actualLimit, - after: actualCursor, - }); - - if (!result) { - const emptyResponse = createEmptyPromptGroupsResponse({ - pageNumber: '1', - pageSize: actualLimit, - actualLimit, - }); - return res.status(200).send(emptyResponse); - } - - const { data: promptGroups = [], has_more = false, after = null } = result; - const readablePromptGroups = projectStoredPromptGroups(promptGroups, req.config?.filters); - const groupsWithPublicFlag = markPublicPromptGroups( - readablePromptGroups, - publiclyAccessibleIds, - ); - - const response = formatPromptGroupsResponse({ - promptGroups: groupsWithPublicFlag, - pageNumber: '1', // Always 1 for cursor-based pagination - pageSize: actualLimit.toString(), - hasMore: has_more, - after, - }); - - res.status(200).send(response); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error getting prompt groups' }); - } -}); - -/** - * Creates a new prompt group with initial prompt - * @param {object} req - * @param {TCreatePrompt} req.body - * @param {Express.Response} res - */ -const createNewPromptGroup = async (req, res) => { - try { - const { prompt, group } = req.body; - - if (!prompt || !group || !group.name) { - return res.status(400).send({ error: 'Prompt and group name are required' }); - } - - if (blockFilteredPromptContent(req, res, { prompt, group })) { - return; - } - - const saveData = { - prompt, - group, - author: req.user.id, - authorName: req.user.name, - }; - - const result = await createPromptGroup(saveData); - - if (result.prompt && result.prompt._id && result.prompt.groupId) { - try { - await grantPermission({ - principalType: PrincipalType.USER, - principalId: req.user.id, - resourceType: ResourceType.PROMPTGROUP, - resourceId: result.prompt.groupId, - accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, - grantedBy: req.user.id, - }); - logger.debug( - `[createPromptGroup] Granted owner permissions to user ${req.user.id} for promptGroup ${result.prompt.groupId}`, - ); - } catch (permissionError) { - logger.error( - `[createPromptGroup] Failed to grant owner permissions for promptGroup ${result.prompt.groupId}:`, - permissionError, - ); - } - } - - res.status(200).send(result); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error creating prompt group' }); - } -}; - -/** - * Adds a new prompt to an existing prompt group - * @param {object} req - * @param {TCreatePrompt} req.body - * @param {Express.Response} res - */ -const addPromptToGroup = async (req, res) => { - try { - const { groupId } = req.params; - const { prompt } = req.body; - - if (!prompt) { - return res.status(400).send({ error: 'Prompt is required' }); - } - - if (typeof prompt.prompt !== 'string' || !prompt.prompt.trim()) { - return res - .status(400) - .send({ error: 'Prompt text is required and must be a non-empty string' }); - } - - if (prompt.type !== 'text' && prompt.type !== 'chat') { - return res.status(400).send({ error: 'Prompt type must be "text" or "chat"' }); - } - - if (blockFilteredPromptContent(req, res, { prompt })) { - return; - } - - // Ensure the prompt is associated with the correct group - prompt.groupId = groupId; - - const saveData = { - prompt, - author: req.user.id, - authorName: req.user.name, - }; - - const result = await savePrompt(saveData); - res.status(200).send(result); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error adding prompt to group' }); - } -}; - -// Create new prompt group (requires CREATE permission) -router.post('/', checkPromptCreate, configMiddleware, createNewPromptGroup); - -// Add prompt to existing group (requires EDIT permission on the group) +router.post('/', checkPromptCreate, configMiddleware, handlers.createPromptGroup); router.post( '/groups/:groupId/prompts', checkPromptAccess, - canAccessPromptGroupResource({ - requiredPermission: PermissionBits.EDIT, - }), + canAccessPromptGroupResource({ requiredPermission: PermissionBits.EDIT }), configMiddleware, - addPromptToGroup, + handlers.savePrompt, ); - -/** - * Records a prompt group usage (increments numberOfGenerations) - * POST /groups/:groupId/use - */ router.post( '/groups/:groupId/use', promptUsageLimiter, - canAccessPromptGroupResource({ - requiredPermission: PermissionBits.VIEW, - }), - async (req, res) => { - try { - const { groupId } = req.params; - if (!isValidObjectIdString(groupId)) { - return res.status(400).send({ error: 'Invalid groupId' }); - } - const result = await incrementPromptGroupUsage(groupId); - res.status(200).send(result); - } catch (error) { - logger.error('[recordPromptUsage]', error); - if (error.message === 'Invalid groupId') { - return res.status(400).send({ error: 'Invalid groupId' }); - } - if (error.message === 'Prompt group not found') { - return res.status(404).send({ error: 'Prompt group not found' }); - } - res.status(500).send({ error: 'Error recording prompt usage' }); - } - }, + canAccessPromptGroupResource({ requiredPermission: PermissionBits.VIEW }), + handlers.recordPromptUsage, ); -/** - * Updates a prompt group - * @param {object} req - * @param {object} req.params - The request parameters - * @param {string} req.params.groupId - The group ID - * @param {TUpdatePromptGroupPayload} req.body - The request body - * @param {Express.Response} res - */ -const patchPromptGroup = async (req, res) => { - try { - const { groupId } = req.params; - // Don't pass author - permissions are now checked by middleware - const filter = { _id: groupId }; - - const validationResult = safeValidatePromptGroupUpdate(req.body); - if (!validationResult.success) { - return res.status(400).send({ - error: 'Invalid request body', - details: validationResult.error.errors, - }); - } - - if (blockFilteredPromptContent(req, res, { group: validationResult.data })) { - return; - } - - const promptGroup = await updatePromptGroup(filter, validationResult.data); - res.status(200).send(promptGroup); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error updating prompt group' }); - } -}; - router.patch( '/groups/:groupId', - checkGlobalPromptShare, - canAccessPromptGroupResource({ - requiredPermission: PermissionBits.EDIT, - }), + checkPromptCreate, + canAccessPromptGroupResource({ requiredPermission: PermissionBits.EDIT }), configMiddleware, - patchPromptGroup, + handlers.updatePromptGroup, ); - router.patch( '/:promptId/tags/production', checkPromptCreate, - canAccessPromptViaGroup({ - requiredPermission: PermissionBits.EDIT, - resourceIdParam: 'promptId', - }), + canAccessPromptViaGroup({ requiredPermission: PermissionBits.EDIT, resourceIdParam: 'promptId' }), configMiddleware, - async (req, res) => { - try { - const { promptId } = req.params; - const prompt = await getPrompt({ _id: promptId }); - if (blockFilteredPromptContent(req, res, { prompt })) { - return; - } - const result = await makePromptProduction(promptId); - res.status(200).send(result); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error updating prompt production' }); - } - }, + handlers.makePromptProduction, ); router.get( '/:promptId', - canAccessPromptViaGroup({ - requiredPermission: PermissionBits.VIEW, - resourceIdParam: 'promptId', - }), + canAccessPromptViaGroup({ requiredPermission: PermissionBits.VIEW, resourceIdParam: 'promptId' }), configMiddleware, - async (req, res) => { - const { promptId } = req.params; - const prompt = await getPrompt({ _id: promptId }); - if (blockFilteredPromptContent(req, res, { prompt })) { - return; - } - res.status(200).send(prompt); - }, + handlers.getPrompt, ); - -router.get('/', configMiddleware, async (req, res) => { - try { - const author = req.user.id; - const { groupId } = req.query; - - // If requesting prompts for a specific group, check permissions - if (groupId) { - if (!isValidObjectIdString(groupId)) { - return res.status(400).send({ error: 'Invalid groupId' }); - } - - const permissions = await getEffectivePermissions({ - userId: req.user.id, - role: req.user.role, - resourceType: ResourceType.PROMPTGROUP, - resourceId: groupId, - }); - - if (!(permissions & PermissionBits.VIEW)) { - return res - .status(403) - .send({ error: 'Insufficient permissions to view prompts in this group' }); - } - - // If user has access, fetch all prompts in the group (not just their own) - const prompts = await getPrompts({ groupId: new ObjectId(groupId) }); - const readablePrompts = Array.isArray(prompts) - ? projectStoredPrompts(prompts, req.config?.filters) - : prompts; - return res.status(200).send(readablePrompts); - } - - // If no groupId, return user's own prompts - const query = { author }; - let canReadPrompts = false; - try { - canReadPrompts = await hasCapability(req.user, SystemCapabilities.READ_PROMPTS); - } catch (err) { - logger.warn(`[GET /prompts] capability check failed, denying bypass: ${err.message}`); - } - if (canReadPrompts) { - logger.debug(`[GET /prompts] READ_PROMPTS bypass for user ${req.user.id}`); - delete query.author; - } - const prompts = await getPrompts(query); - const readablePrompts = Array.isArray(prompts) - ? projectStoredPrompts(prompts, req.config?.filters) - : prompts; - res.status(200).send(readablePrompts); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error getting prompts' }); - } -}); - -/** - * Deletes a prompt - * - * @param {ServerRequest} req - The request object. - * @param {TDeletePromptVariables} req.params - The request parameters - * @param {import('mongoose').ObjectId} req.params.promptId - The prompt ID - * @param {Express.Response} res - The response object. - * @return {TDeletePromptResponse} A promise that resolves when the prompt is deleted. - */ -const deletePromptController = async (req, res) => { - try { - const { promptId } = req.params; - const { groupId } = req.query; - if (!groupId || !isValidObjectIdString(groupId)) { - return res.status(400).send({ error: 'Invalid or missing groupId' }); - } - const query = { promptId, groupId }; - const result = await deletePrompt(query); - res.status(200).send(result); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error deleting prompt' }); - } -}; - -/** - * Delete a prompt group - * @param {ServerRequest} req - * @param {ServerResponse} res - * @returns {Promise} - */ -const deletePromptGroupController = async (req, res) => { - try { - const { groupId: _id } = req.params; - // Don't pass author or role - permissions are checked by ACL middleware - const message = await deletePromptGroup({ _id }); - res.send(message); - } catch (error) { - logger.error('Error deleting prompt group', error); - res.status(500).send({ message: 'Error deleting prompt group' }); - } -}; +router.get('/', configMiddleware, handlers.getPrompts); router.delete( '/:promptId', @@ -578,15 +82,13 @@ router.delete( requiredPermission: PermissionBits.DELETE, resourceIdParam: 'promptId', }), - deletePromptController, + handlers.deletePrompt, ); router.delete( '/groups/:groupId', checkPromptCreate, - canAccessPromptGroupResource({ - requiredPermission: PermissionBits.DELETE, - }), - deletePromptGroupController, + canAccessPromptGroupResource({ requiredPermission: PermissionBits.DELETE }), + handlers.deletePromptGroup, ); module.exports = router; diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js index b8e06f8094e..1167c7728a5 100644 --- a/api/server/routes/prompts.test.js +++ b/api/server/routes/prompts.test.js @@ -1328,3 +1328,302 @@ describe('Prompt Routes - ACL Permissions', () => { }); }); }); + +async function responseOf(pending) { + const { status, body } = await pending; + return { status, body }; +} + +describe('Prompt Routes - response and failure compatibility', () => { + let consoleErrorSpy; + let group; + let revision; + + beforeEach(async () => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + ({ group, prompt: revision } = await createAccessiblePromptGroup({ + name: 'Compatibility Group', + prompt: 'Compatibility prompt', + })); + }); + + afterEach(async () => { + consoleErrorSpy.mockRestore(); + jest.restoreAllMocks(); + await Prompt.deleteMany({}); + await PromptGroup.deleteMany({}); + await AclEntry.deleteMany({}); + }); + + it('creates a group when the initial prompt has no type', async () => { + const response = await request(app) + .post('/api/prompts') + .send({ prompt: { prompt: 'Untyped prompt' }, group: { name: 'Untyped Group' } }) + .expect(200); + + expect(response.body.prompt.prompt).toBe('Untyped prompt'); + expect(response.body.group.productionPrompt).toEqual({ prompt: 'Untyped prompt' }); + }); + + it('adds a revision from the editor payload, which includes groupId', async () => { + const response = await request(app) + .post(`/api/prompts/groups/${group._id}/prompts`) + .send({ prompt: { prompt: 'Second revision', type: 'chat', groupId: group._id.toString() } }) + .expect(200); + + expect(response.body.prompt).toEqual( + expect.objectContaining({ + prompt: 'Second revision', + type: 'chat', + groupId: group._id.toString(), + author: testUsers.owner._id.toString(), + }), + ); + await expect(Prompt.countDocuments({ groupId: group._id })).resolves.toBe(2); + }); + + it('sends the legacy 200 message when saving a revision fails', async () => { + jest.spyOn(Prompt, 'create').mockRejectedValueOnce(new Error('database unavailable')); + + const response = await request(app) + .post(`/api/prompts/groups/${group._id}/prompts`) + .send({ prompt: { prompt: 'Lost revision', type: 'text' } }) + .expect(200); + + expect(response.body).toEqual({ message: 'Error saving prompt' }); + }); + + it('returns the group detail with its full Production revision', async () => { + const response = await request(app).get(`/api/prompts/groups/${group._id}`).expect(200); + + expect(response.body._id).toBe(group._id.toString()); + expect(response.body.productionPrompt).toEqual( + expect.objectContaining({ + _id: revision._id.toString(), + prompt: 'Compatibility prompt', + type: 'text', + groupId: group._id.toString(), + }), + ); + }); + + it('returns 404 from the handler when a capability bypass reads a missing group', async () => { + setTestUser(app, testUsers.admin); + + const response = await request(app).get(`/api/prompts/groups/${new ObjectId()}`).expect(404); + + expect(response.body).toEqual({ message: 'Prompt group not found' }); + }); + + it('returns an empty 200 when a capability bypass reads a missing revision', async () => { + setTestUser(app, testUsers.admin); + + const response = await request(app).get(`/api/prompts/${new ObjectId()}`).expect(200); + + expect(response.text).toBe(''); + }); + + it('returns revision history newest first with management fields', async () => { + const newer = await Prompt.create({ + prompt: 'Newer revision', + author: testUsers.owner._id, + type: 'text', + groupId: group._id, + createdAt: new Date(Date.now() + 1000), + }); + + const response = await request(app) + .get('/api/prompts') + .query({ groupId: group._id.toString() }) + .expect(200); + + expect(response.body.map((item) => item._id)).toEqual([ + newer._id.toString(), + revision._id.toString(), + ]); + expect(response.body[0]).toEqual( + expect.objectContaining({ author: testUsers.owner._id.toString(), type: 'text' }), + ); + }); + + it('keeps the malformed and denied revision-history responses', async () => { + expect( + await responseOf(request(app).get('/api/prompts').query({ groupId: 'not-an-id' })), + ).toEqual({ status: 400, body: { error: 'Invalid groupId' } }); + + setTestUser(app, testUsers.noAccess); + expect( + await responseOf(request(app).get('/api/prompts').query({ groupId: group._id.toString() })), + ).toEqual({ + status: 403, + body: { error: 'Insufficient permissions to view prompts in this group' }, + }); + }); + + it('sends the legacy 200 message when revision history fails', async () => { + jest.spyOn(Prompt, 'find').mockImplementationOnce(() => { + throw new Error('database unavailable'); + }); + + const response = await request(app) + .get('/api/prompts') + .query({ groupId: group._id.toString() }) + .expect(200); + + expect(response.body).toEqual({ message: 'Error getting prompts' }); + }); + + it('returns the updated group document fields from a metadata update', async () => { + const response = await request(app) + .patch(`/api/prompts/groups/${group._id}`) + .send({ name: 'Renamed Group', command: 'renamed' }) + .expect(200); + + expect(Object.keys(response.body).sort()).toEqual( + [ + '__v', + '_id', + 'author', + 'authorName', + 'category', + 'command', + 'createdAt', + 'name', + 'numberOfGenerations', + 'oneliner', + 'productionId', + 'updatedAt', + ].sort(), + ); + expect(response.body).toEqual( + expect.objectContaining({ name: 'Renamed Group', command: 'renamed' }), + ); + }); + + it('sends the legacy 200 message when a metadata update fails', async () => { + jest.spyOn(PromptGroup, 'findOneAndUpdate').mockImplementationOnce(() => { + throw new Error('database unavailable'); + }); + + const response = await request(app) + .patch(`/api/prompts/groups/${group._id}`) + .send({ name: 'Renamed Group' }) + .expect(200); + + expect(response.body).toEqual({ message: 'Error updating prompt group' }); + }); + + it('sends the legacy 200 message when a capability bypass promotes a missing revision', async () => { + setTestUser(app, testUsers.admin); + + const response = await request(app) + .patch(`/api/prompts/${new ObjectId()}/tags/production`) + .expect(200); + + expect(response.body).toEqual({ message: 'Error making prompt production' }); + }); + + it('does not promote when the preliminary read of a capability bypass fails', async () => { + const candidate = await Prompt.create({ + prompt: 'Candidate revision', + author: testUsers.owner._id, + type: 'text', + groupId: group._id, + }); + setTestUser(app, testUsers.admin); + jest.spyOn(Prompt, 'findOne').mockImplementationOnce(() => { + throw new Error('database unavailable'); + }); + const promote = jest.spyOn(PromptGroup, 'findByIdAndUpdate'); + + await request(app) + .patch(`/api/prompts/${candidate._id}/tags/production`) + .expect(500, { error: 'Error updating prompt production' }); + + expect(promote).not.toHaveBeenCalled(); + const stored = await PromptGroup.findById(group._id).lean(); + expect(stored.productionId.toString()).toBe(revision._id.toString()); + }); + + it('requires groupId for revision history, including READ_PROMPTS callers', async () => { + const missingGroupId = { status: 400, body: { error: 'Invalid or missing groupId' } }; + expect(await responseOf(request(app).get('/api/prompts'))).toEqual(missingGroupId); + + setTestUser(app, testUsers.admin); + expect(await responseOf(request(app).get('/api/prompts'))).toEqual(missingGroupId); + }); + + it('reuses the records that the access check loaded', async () => { + const readGroup = jest.spyOn(PromptGroup, 'aggregate'); + await request(app).get(`/api/prompts/groups/${group._id}`).expect(200); + expect(readGroup).toHaveBeenCalledTimes(1); + + const readRevision = jest.spyOn(Prompt, 'findOne'); + await request(app).get(`/api/prompts/${revision._id}`).expect(200); + expect(readRevision).toHaveBeenCalledTimes(1); + + readRevision.mockClear(); + await request(app).patch(`/api/prompts/${revision._id}/tags/production`).expect(200); + // The access check reads once; the mutation's own `findById` is the second call. + expect(readRevision).toHaveBeenCalledTimes(2); + }); + + it('returns 500 for GET /groups without limit or pageSize', async () => { + expect(await responseOf(request(app).get('/api/prompts/groups'))).toEqual({ + status: 500, + body: { error: 'Error getting prompt groups' }, + }); + }); + + it('returns the full catalog array from GET /all with the public flag', async () => { + const response = await request(app).get('/api/prompts/all').expect(200); + + expect(response.body).toEqual([ + expect.objectContaining({ + _id: group._id.toString(), + productionPrompt: { _id: revision._id.toString(), prompt: 'Compatibility prompt' }, + }), + ]); + expect(response.body[0].isPublic).toBeUndefined(); + }); + + it('keeps the Usage responses', async () => { + const recordUsage = () => request(app).post(`/api/prompts/groups/${group._id}/use`); + expect(await responseOf(recordUsage())).toEqual({ + status: 200, + body: { numberOfGenerations: 1 }, + }); + + jest.spyOn(PromptGroup, 'findByIdAndUpdate').mockImplementationOnce(() => { + throw new Error('database unavailable'); + }); + expect(await responseOf(recordUsage())).toEqual({ + status: 500, + body: { error: 'Error recording prompt usage' }, + }); + }); + + it('keeps the revision and group deletion responses', async () => { + await request(app) + .delete(`/api/prompts/${revision._id}`) + .expect(400, { error: 'Invalid or missing groupId' }); + + const response = await request(app) + .delete(`/api/prompts/${revision._id}`) + .query({ groupId: group._id.toString() }) + .expect(200); + + expect(response.body).toEqual({ + prompt: 'Prompt deleted successfully', + promptGroup: { message: 'Prompt group deleted successfully', id: group._id.toString() }, + }); + await expect(PromptGroup.countDocuments({ _id: group._id })).resolves.toBe(0); + }); + + it('keeps the group deletion response', async () => { + expect(await responseOf(request(app).delete(`/api/prompts/groups/${group._id}`))).toEqual({ + status: 200, + body: { message: 'Prompt group deleted successfully' }, + }); + }); +}); diff --git a/api/server/services/Prompts/index.js b/api/server/services/Prompts/index.js new file mode 100644 index 00000000000..76d25922e34 --- /dev/null +++ b/api/server/services/Prompts/index.js @@ -0,0 +1,28 @@ +const { + createPromptHandlers, + createNativePromptService, + createPromptAccessResolvers, +} = require('@librechat/api'); +const { getEffectivePermissions, grantPermission } = require('~/server/services/PermissionService'); +const db = require('~/models'); + +let promptService; + +function getPromptService() { + promptService ??= createNativePromptService({ db, grantPermission }); + return promptService; +} + +function getPromptHandlers() { + return createPromptHandlers({ + service: getPromptService(), + getPromptGroupAccessContext: db.getPromptGroupAccessContext, + getEffectivePermissions, + }); +} + +function getPromptAccessResolvers() { + return createPromptAccessResolvers(db); +} + +module.exports = { getPromptService, getPromptHandlers, getPromptAccessResolvers }; diff --git a/packages/api/src/prompts/access.ts b/packages/api/src/prompts/access.ts new file mode 100644 index 00000000000..6313d00b341 --- /dev/null +++ b/packages/api/src/prompts/access.ts @@ -0,0 +1,46 @@ +import { logger } from '@librechat/data-schemas'; +import type { PromptDatabase, PromptGroupRecord, PromptRecord } from './types'; +import { toPromptGroupRecord, toPromptRecord } from './records'; + +export interface PromptViaGroupResource { + /** The parent group ID, which the access check uses for the ACL lookup. */ + readonly _id: string; + readonly prompt: PromptRecord; +} + +/** + * Resolvers for the prompt access middleware. A read failure or a missing record returns + * null, so the access check sends its resource 404. On success the record becomes + * `req.resourceAccess.resourceInfo`, which the handlers reuse. + */ +export function createPromptAccessResolvers( + db: Pick, +): { + resolvePromptGroup(groupId: string): Promise; + resolvePromptViaGroup(promptId: string): Promise; +} { + return { + async resolvePromptGroup(groupId) { + try { + const group = await db.getPromptGroup({ _id: groupId }); + return group == null ? null : toPromptGroupRecord(group); + } catch (error) { + logger.error('[resolvePromptGroup] Error reading prompt group', error); + return null; + } + }, + async resolvePromptViaGroup(promptId) { + try { + const record = await db.getPrompt({ _id: promptId }); + if (record == null) { + return null; + } + const prompt = toPromptRecord(record); + return prompt.groupId ? { _id: prompt.groupId, prompt } : null; + } catch (error) { + logger.error('[resolvePromptViaGroup] Error reading prompt', error); + return null; + } + }, + }; +} diff --git a/packages/api/src/prompts/errors.ts b/packages/api/src/prompts/errors.ts new file mode 100644 index 00000000000..c6e9e07289f --- /dev/null +++ b/packages/api/src/prompts/errors.ts @@ -0,0 +1,33 @@ +export type PromptStoreStage = 'read' | 'write'; + +/** + * Wraps a prompt database failure with the operation stage where it occurred. The HTTP + * boundary uses the stage to keep each route's existing response for that failure. + */ +export class PromptStoreError extends Error { + readonly stage: PromptStoreStage; + readonly cause: unknown; + + constructor(stage: PromptStoreStage, cause: unknown) { + super(`Prompt ${stage} failed`); + this.name = 'PromptStoreError'; + this.stage = stage; + this.cause = cause; + } +} + +export function isPromptStoreError(error: unknown, stage: PromptStoreStage): boolean { + return error instanceof PromptStoreError && error.stage === stage; +} + +/** Runs a database call and tags its failure with the stage. */ +export async function withPromptStage( + stage: PromptStoreStage, + run: () => Promise, +): Promise { + try { + return await run(); + } catch (error) { + throw new PromptStoreError(stage, error); + } +} diff --git a/packages/api/src/prompts/format.ts b/packages/api/src/prompts/format.ts index 63c50247e98..652d1833b75 100644 --- a/packages/api/src/prompts/format.ts +++ b/packages/api/src/prompts/format.ts @@ -1,13 +1,11 @@ -import { escapeRegExp } from '@librechat/data-schemas'; import { SystemCategories } from 'librechat-data-provider'; -import type { IPromptGroupDocument as IPromptGroup } from '@librechat/data-schemas'; -import type { Types } from 'mongoose'; import type { PromptGroupsListResponse } from '~/types'; +import type { StoredId } from './types'; /** * Formats prompt groups for the paginated /groups endpoint response */ -export function formatPromptGroupsResponse({ +export function formatPromptGroupsResponse({ promptGroups = [], pageNumber, pageSize, @@ -15,13 +13,13 @@ export function formatPromptGroupsResponse({ hasMore = false, after = null, }: { - promptGroups: IPromptGroup[]; + promptGroups: T[]; pageNumber?: string; pageSize?: string; actualLimit?: string | number; hasMore?: boolean; after?: string | null; -}): PromptGroupsListResponse { +}): PromptGroupsListResponse { const currentPage = parseInt(pageNumber || '1'); // Calculate total pages based on whether there are more results @@ -39,74 +37,53 @@ export function formatPromptGroupsResponse({ }; } -/** - * Creates an empty response for the paginated /groups endpoint - */ -export function createEmptyPromptGroupsResponse({ - pageNumber, - pageSize, - actualLimit, -}: { - pageNumber?: string; - pageSize?: string; - actualLimit?: string | number; -}): PromptGroupsListResponse { - return { - promptGroups: [], - pageNumber: pageNumber || '1', - pageSize: pageSize || String(actualLimit) || '10', - pages: '0', - has_more: false, - after: null, - }; -} - /** * Marks prompt groups as public based on the publicly accessible IDs */ -export function markPublicPromptGroups( - promptGroups: IPromptGroup[], - publiclyAccessibleIds: Types.ObjectId[], -): IPromptGroup[] { +export function markPublicPromptGroups( + promptGroups: readonly T[], + publiclyAccessibleIds: readonly StoredId[], +): T[] { if (!promptGroups.length) { return []; } - return promptGroups.map((group) => { - const isPublic = publiclyAccessibleIds.some((id) => id.equals(group._id?.toString())); - return isPublic ? ({ ...group, isPublic: true } as IPromptGroup) : group; - }); + const publicIds = new Set(publiclyAccessibleIds.map(String)); + return promptGroups.map((group) => + group._id != null && publicIds.has(String(group._id)) ? { ...group, isPublic: true } : group, + ); } /** - * Builds filter object for prompt group queries + * Converts the listing name and category, including system categories, to plain + * listing inputs and shared-search flags. */ export function buildPromptGroupFilter({ name, category }: { name?: string; category?: string }): { - filter: Record; + name?: string; + category?: string; searchShared: boolean; searchSharedOnly: boolean; } { - const filter: Record = {}; let searchShared = true; let searchSharedOnly = false; + let categoryFilter: string | undefined; - // Handle name filter - convert to regex for case-insensitive search - if (name) { - filter.name = new RegExp(escapeRegExp(name), 'i'); - } - - // Handle category filters with special system categories if (category === SystemCategories.MY_PROMPTS) { searchShared = false; } else if (category === SystemCategories.NO_CATEGORY) { - filter.category = ''; + categoryFilter = ''; } else if (category === SystemCategories.SHARED_PROMPTS) { searchSharedOnly = true; } else if (category) { - filter.category = category; + categoryFilter = category; } - return { filter, searchShared, searchSharedOnly }; + return { + name: name || undefined, + category: categoryFilter, + searchShared, + searchSharedOnly, + }; } /** @@ -116,19 +93,19 @@ export function buildPromptGroupFilter({ name, category }: { name?: string; cate * Required for correct MY_PROMPTS and SHARED_PROMPTS filtering. When omitted the * function falls back to the legacy behaviour (public-only filtering). */ -export async function filterAccessibleIdsBySharedLogic({ +export async function filterAccessibleIdsBySharedLogic({ accessibleIds, searchShared, searchSharedOnly, publicPromptGroupIds, ownedPromptGroupIds, }: { - accessibleIds: Types.ObjectId[]; + accessibleIds: readonly T[]; searchShared: boolean; searchSharedOnly: boolean; - publicPromptGroupIds?: Types.ObjectId[]; - ownedPromptGroupIds?: Types.ObjectId[]; -}): Promise { + publicPromptGroupIds?: readonly T[]; + ownedPromptGroupIds?: readonly T[]; +}): Promise { const ownedIdStrings = new Set((ownedPromptGroupIds || []).map((id) => id.toString())); if (!searchShared) { diff --git a/packages/api/src/prompts/handlers.ts b/packages/api/src/prompts/handlers.ts new file mode 100644 index 00000000000..ce55198d48d --- /dev/null +++ b/packages/api/src/prompts/handlers.ts @@ -0,0 +1,372 @@ +import { PermissionBits, ResourceType } from 'librechat-data-provider'; +import { logger, isValidObjectIdString } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { PromptGroupRecord, PromptServiceError, StoredId } from './types'; +import type { PromptViaGroupResource } from './access'; +import type { PromptService } from './service'; +import type { ServerRequest } from '~/types'; +import { contentFilterBlockResponse } from '~/middleware/contentFilter'; +import { formatPromptGroupsResponse } from './format'; +import { isPromptStoreError } from './errors'; + +/** A request after the prompt access middleware stored the resolved record. */ +export type PromptRequest = ServerRequest & { + resourceAccess?: { resourceInfo?: unknown }; +}; + +type PromptHandler = (req: PromptRequest, res: Response) => Promise; + +export interface PromptHandlersDeps { + readonly service: PromptService; + getPromptGroupAccessContext(params: { userId: string; role?: string }): Promise<{ + accessibleIds: readonly StoredId[]; + publiclyAccessibleIds: readonly StoredId[]; + ownedPromptGroupIds: readonly StoredId[]; + }>; + getEffectivePermissions(params: { + userId: string; + role?: string; + resourceType: ResourceType; + resourceId: string; + }): Promise; +} + +export interface PromptHandlers { + getPromptGroup: PromptHandler; + listAllPromptGroups: PromptHandler; + listPromptGroups: PromptHandler; + createPromptGroup: PromptHandler; + savePrompt: PromptHandler; + recordPromptUsage: PromptHandler; + updatePromptGroup: PromptHandler; + makePromptProduction: PromptHandler; + getPrompt: PromptHandler; + getPrompts: PromptHandler; + deletePrompt: PromptHandler; + deletePromptGroup: PromptHandler; +} + +function param(req: PromptRequest, name: string): string { + return (req.params as Record)[name]; +} + +function query(req: PromptRequest, name: string): string | undefined { + const value = (req.query as Record)[name]; + return typeof value === 'string' ? value : undefined; +} + +function body(req: PromptRequest): Record { + return (req.body ?? {}) as Record; +} + +function loadedGroup(req: PromptRequest): PromptGroupRecord | undefined { + return req.resourceAccess?.resourceInfo as PromptGroupRecord | undefined; +} + +function loadedRevision(req: PromptRequest) { + return (req.resourceAccess?.resourceInfo as PromptViaGroupResource | undefined)?.prompt; +} + +function sendRejection(res: Response, error: PromptServiceError): Response { + if (error.type === 'blocked_content') { + return res.status(400).json(contentFilterBlockResponse(error.finding)); + } + if (error.type === 'invalid_input') { + return res + .status(400) + .send( + error.details === undefined + ? { error: error.message } + : { error: error.message, details: error.details }, + ); + } + return res.status(400).send({ error: 'Prompt operation is not available' }); +} + +async function loadAccessContext(deps: PromptHandlersDeps, req: PromptRequest) { + const context = await deps.getPromptGroupAccessContext({ + userId: req.user?.id ?? '', + role: req.user?.role, + }); + return { + accessibleIds: context.accessibleIds.map(String), + publiclyAccessibleIds: context.publiclyAccessibleIds.map(String), + ownedPromptGroupIds: context.ownedPromptGroupIds.map(String), + }; +} + +/** + * HTTP handlers for the prompt routes. The router enforces authentication, role gates + * and resource access before each handler. The handlers keep the existing status and + * body for each failure, including the legacy 200 message bodies. + */ +export function createPromptHandlers(deps: PromptHandlersDeps): PromptHandlers { + const { service } = deps; + + return { + async getPromptGroup(req, res) { + const groupId = param(req, 'groupId'); + try { + const result = await service.getPromptGroup({ + groupId, + loadedGroup: loadedGroup(req), + filters: req.config?.filters, + }); + if (result == null) { + return res.status(404).send({ message: 'Prompt group not found' }); + } + if (!result.ok) { + return sendRejection(res, result.error); + } + return res.status(200).send(result.value); + } catch (error) { + logger.error('Error getting prompt group', error); + if (isPromptStoreError(error, 'read')) { + return res.status(404).send({ message: 'Prompt group not found' }); + } + return res.status(500).send({ message: 'Error getting prompt group' }); + } + }, + + async listAllPromptGroups(req, res) { + try { + const result = await service.getListPromptGroupsByAccess({ + ...(await loadAccessContext(deps, req)), + name: query(req, 'name'), + category: query(req, 'category'), + limit: null, + after: null, + forReuse: true, + filters: req.config?.filters, + }); + return res.status(200).send(result.data); + } catch (error) { + logger.error(error); + return res.status(500).send({ error: 'Error getting prompt groups' }); + } + }, + + async listPromptGroups(req, res) { + try { + const pageSize = query(req, 'pageSize'); + const limit = query(req, 'limit'); + const actualLimit = pageSize && !limit ? parseInt(pageSize, 10) : limit; + if (actualLimit == null) { + logger.error('[GET /prompts/groups] limit or pageSize is required'); + return res.status(500).send({ error: 'Error getting prompt groups' }); + } + let cursor = query(req, 'cursor') ?? null; + if (cursor === 'undefined' || cursor === 'null' || cursor === '') { + cursor = null; + } + + const result = await service.getListPromptGroupsByAccess({ + ...(await loadAccessContext(deps, req)), + name: query(req, 'name'), + category: query(req, 'category'), + limit: actualLimit, + after: cursor, + forReuse: false, + filters: req.config?.filters, + }); + return res.status(200).send( + formatPromptGroupsResponse({ + promptGroups: [...result.data], + pageNumber: '1', + pageSize: actualLimit.toString(), + hasMore: result.has_more, + after: result.after, + }), + ); + } catch (error) { + logger.error(error); + return res.status(500).send({ error: 'Error getting prompt groups' }); + } + }, + + async createPromptGroup(req, res) { + try { + const { prompt, group } = body(req) as Pick< + Parameters[0], + 'prompt' | 'group' + >; + const result = await service.createPromptGroup({ + prompt, + group, + author: req.user?.id ?? '', + authorName: req.user?.name, + filters: req.config?.filters, + }); + if (!result.ok) { + return sendRejection(res, result.error); + } + return res.status(200).send(result.value); + } catch (error) { + logger.error(error); + return res.status(500).send({ error: 'Error creating prompt group' }); + } + }, + + async savePrompt(req, res) { + try { + const result = await service.savePrompt({ + groupId: param(req, 'groupId'), + prompt: body(req).prompt, + author: req.user?.id ?? '', + filters: req.config?.filters, + }); + if (!result.ok) { + return sendRejection(res, result.error); + } + return res.status(200).send(result.value); + } catch (error) { + logger.error(error); + if (isPromptStoreError(error, 'write')) { + return res.status(200).send({ message: 'Error saving prompt' }); + } + return res.status(500).send({ error: 'Error adding prompt to group' }); + } + }, + + async recordPromptUsage(req, res) { + try { + const groupId = param(req, 'groupId'); + if (!isValidObjectIdString(groupId)) { + return res.status(400).send({ error: 'Invalid groupId' }); + } + return res.status(200).send(await service.incrementPromptGroupUsage(groupId)); + } catch (error) { + logger.error('[recordPromptUsage]', error); + const message = error instanceof Error ? error.message : undefined; + if (message === 'Invalid groupId') { + return res.status(400).send({ error: 'Invalid groupId' }); + } + if (message === 'Prompt group not found') { + return res.status(404).send({ error: 'Prompt group not found' }); + } + return res.status(500).send({ error: 'Error recording prompt usage' }); + } + }, + + async updatePromptGroup(req, res) { + try { + const result = await service.updatePromptGroup({ + groupId: param(req, 'groupId'), + updates: req.body, + filters: req.config?.filters, + }); + if (!result.ok) { + return sendRejection(res, result.error); + } + return res.status(200).send(result.value); + } catch (error) { + logger.error(error); + if (isPromptStoreError(error, 'write')) { + return res.status(200).send({ message: 'Error updating prompt group' }); + } + return res.status(500).send({ error: 'Error updating prompt group' }); + } + }, + + async makePromptProduction(req, res) { + try { + const result = await service.makePromptProduction({ + promptId: param(req, 'promptId'), + loadedRevision: loadedRevision(req), + filters: req.config?.filters, + }); + if (!result.ok) { + return sendRejection(res, result.error); + } + return res.status(200).send(result.value); + } catch (error) { + logger.error(error); + if (isPromptStoreError(error, 'write')) { + return res.status(200).send({ message: 'Error making prompt production' }); + } + return res.status(500).send({ error: 'Error updating prompt production' }); + } + }, + + async getPrompt(req, res) { + try { + const result = await service.getPrompt({ + promptId: param(req, 'promptId'), + loadedRevision: loadedRevision(req), + filters: req.config?.filters, + }); + if (result == null) { + return res.status(200).send(null); + } + if (!result.ok) { + return sendRejection(res, result.error); + } + return res.status(200).send(result.value); + } catch (error) { + logger.error('Error getting prompt', error); + if (isPromptStoreError(error, 'read')) { + return res.status(200).send({ message: 'Error getting prompt' }); + } + return res.status(500).send({ message: 'Error getting prompt' }); + } + }, + + async getPrompts(req, res) { + try { + const groupId = query(req, 'groupId'); + if (!groupId) { + return res.status(400).send({ error: 'Invalid or missing groupId' }); + } + if (!isValidObjectIdString(groupId)) { + return res.status(400).send({ error: 'Invalid groupId' }); + } + const permissions = await deps.getEffectivePermissions({ + userId: req.user?.id ?? '', + role: req.user?.role, + resourceType: ResourceType.PROMPTGROUP, + resourceId: groupId, + }); + if (!(permissions & PermissionBits.VIEW)) { + return res + .status(403) + .send({ error: 'Insufficient permissions to view prompts in this group' }); + } + return res + .status(200) + .send(await service.getPrompts({ groupId, filters: req.config?.filters })); + } catch (error) { + logger.error(error); + if (isPromptStoreError(error, 'read')) { + return res.status(200).send({ message: 'Error getting prompts' }); + } + return res.status(500).send({ error: 'Error getting prompts' }); + } + }, + + async deletePrompt(req, res) { + try { + const groupId = query(req, 'groupId'); + if (!groupId || !isValidObjectIdString(groupId)) { + return res.status(400).send({ error: 'Invalid or missing groupId' }); + } + const result = await service.deletePrompt({ groupId, promptId: param(req, 'promptId') }); + if (!result.ok) { + return sendRejection(res, result.error); + } + return res.status(200).send(result.value); + } catch (error) { + logger.error(error); + return res.status(500).send({ error: 'Error deleting prompt' }); + } + }, + + async deletePromptGroup(req, res) { + try { + return res.send(await service.deletePromptGroup(param(req, 'groupId'))); + } catch (error) { + logger.error('Error deleting prompt group', error); + return res.status(500).send({ message: 'Error deleting prompt group' }); + } + }, + }; +} diff --git a/packages/api/src/prompts/index.ts b/packages/api/src/prompts/index.ts index 2da8a717813..60b622fcc0a 100644 --- a/packages/api/src/prompts/index.ts +++ b/packages/api/src/prompts/index.ts @@ -1,5 +1,12 @@ +export * from './access'; export * from './artifacts'; +export * from './errors'; export * from './format'; +export * from './handlers'; export * from './migration'; +export * from './native'; export * from './protection'; +export * from './records'; export * from './schemas'; +export * from './service'; +export * from './types'; diff --git a/packages/api/src/prompts/native.spec.ts b/packages/api/src/prompts/native.spec.ts new file mode 100644 index 00000000000..9514b6b23b9 --- /dev/null +++ b/packages/api/src/prompts/native.spec.ts @@ -0,0 +1,214 @@ +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createModels, createMethods } from '@librechat/data-schemas'; +import type { PromptDatabase, PromptGroupRecord, PromptRecord } from './types'; +import { createNativePromptAdapter } from './native'; + +let mongo: MongoMemoryServer; +let db: PromptDatabase; +let group: PromptGroupRecord; +let production: PromptRecord; +let draft: PromptRecord; +const author = new Types.ObjectId().toString(); + +beforeAll(async () => { + mongo = await MongoMemoryServer.create({ instance: { args: ['--nounixsocket'] } }); + await mongoose.connect(mongo.getUri()); + createModels(mongoose); + db = createMethods(mongoose); +}); + +beforeEach(async () => { + const adapter = createNativePromptAdapter(db); + const created = await adapter.createPromptGroup({ + prompt: { prompt: 'Production prompt', type: 'text' }, + group: { name: 'Native group' }, + author, + authorName: 'Author', + }); + production = created.prompt as PromptRecord; + ({ prompt: draft } = (await adapter.savePrompt?.({ + groupId: production.groupId, + prompt: { prompt: 'Draft prompt', type: 'chat' }, + author, + })) as { prompt: PromptRecord }); + group = (await adapter.getPromptGroup(production.groupId)) as PromptGroupRecord; +}); + +afterEach(async () => { + jest.restoreAllMocks(); + await mongoose.models.Prompt.deleteMany({}); + await mongoose.models.PromptGroup.deleteMany({}); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongo?.stop(); +}); + +describe('createNativePromptAdapter', () => { + it('returns records with string IDs', () => { + expect(group).toEqual( + expect.objectContaining({ + _id: production.groupId, + author, + productionId: production._id, + productionPrompt: expect.objectContaining({ + _id: production._id, + groupId: production.groupId, + }), + }), + ); + expect(typeof draft._id).toBe('string'); + expect(draft).toEqual(expect.objectContaining({ groupId: group._id, author, type: 'chat' })); + }); + + it('resolves Production from a loaded group without another read', async () => { + const adapter = createNativePromptAdapter(db); + const readGroup = jest.spyOn(db, 'getPromptGroup'); + const readPrompt = jest.spyOn(db, 'getPrompt'); + + await expect( + adapter.resolvePrompt({ + groupId: group._id, + selection: { type: 'production' }, + loadedGroup: group, + }), + ).resolves.toEqual({ + groupId: group._id, + promptId: production._id, + prompt: 'Production prompt', + type: 'text', + }); + expect(readGroup).not.toHaveBeenCalled(); + expect(readPrompt).not.toHaveBeenCalled(); + }); + + it('reads the group once to resolve Production when no group is loaded', async () => { + const adapter = createNativePromptAdapter(db); + const readGroup = jest.spyOn(db, 'getPromptGroup'); + const readPrompt = jest.spyOn(db, 'getPrompt'); + + await expect( + adapter.resolvePrompt({ groupId: group._id, selection: { type: 'production' } }), + ).resolves.toMatchObject({ promptId: production._id }); + expect(readGroup).toHaveBeenCalledTimes(1); + expect(readPrompt).not.toHaveBeenCalled(); + }); + + it('reads the group when the loaded group is a different group', async () => { + const adapter = createNativePromptAdapter(db); + const readGroup = jest.spyOn(db, 'getPromptGroup'); + + await expect( + adapter.resolvePrompt({ + groupId: group._id, + selection: { type: 'production' }, + loadedGroup: { ...group, _id: new Types.ObjectId().toString() }, + }), + ).resolves.toMatchObject({ promptId: production._id }); + expect(readGroup).toHaveBeenCalledTimes(1); + }); + + it('resolves an exact revision from a loaded revision without another read', async () => { + const adapter = createNativePromptAdapter(db); + const readPrompt = jest.spyOn(db, 'getPrompt'); + + await expect( + adapter.resolvePrompt({ + groupId: group._id, + selection: { type: 'exact', promptId: draft._id }, + loadedRevision: draft, + }), + ).resolves.toEqual({ + groupId: group._id, + promptId: draft._id, + prompt: 'Draft prompt', + type: 'chat', + }); + expect(readPrompt).not.toHaveBeenCalled(); + }); + + it('reads an exact revision when the loaded revision is a different revision', async () => { + const adapter = createNativePromptAdapter(db); + const readPrompt = jest.spyOn(db, 'getPrompt'); + + await expect( + adapter.resolvePrompt({ + groupId: group._id, + selection: { type: 'exact', promptId: draft._id }, + loadedRevision: production, + }), + ).resolves.toMatchObject({ promptId: draft._id }); + expect(readPrompt).toHaveBeenCalledTimes(1); + }); + + it('does not resolve a revision of another group or a missing selection', async () => { + const adapter = createNativePromptAdapter(db); + const otherGroupId = new Types.ObjectId().toString(); + + await expect( + adapter.resolvePrompt({ + groupId: otherGroupId, + selection: { type: 'exact', promptId: draft._id }, + }), + ).resolves.toBeNull(); + await expect( + adapter.resolvePrompt({ groupId: otherGroupId, selection: { type: 'production' } }), + ).resolves.toBeNull(); + await expect( + adapter.resolvePrompt({ + groupId: group._id, + selection: { type: 'exact', promptId: new Types.ObjectId().toString() }, + }), + ).resolves.toBeNull(); + }); + + it('returns null for absent records', async () => { + const adapter = createNativePromptAdapter(db); + const missing = new Types.ObjectId().toString(); + + await expect(adapter.getPromptGroup(missing)).resolves.toBeNull(); + await expect(adapter.getPrompt(missing)).resolves.toBeNull(); + }); + + it('returns the group revisions newest first', async () => { + const adapter = createNativePromptAdapter(db); + await mongoose.models.Prompt.updateOne( + { _id: draft._id }, + { createdAt: new Date(Date.now() + 1000) }, + { timestamps: false }, + ); + + const revisions = await adapter.getPrompts(group._id); + + expect(revisions.map((revision) => revision._id)).toEqual([draft._id, production._id]); + }); + + it('promotes a revision and throws for a missing revision', async () => { + const adapter = createNativePromptAdapter(db); + + await adapter.makePromptProduction?.(draft._id); + + await expect(adapter.getPromptGroup(group._id)).resolves.toMatchObject({ + productionId: draft._id, + }); + await expect( + adapter.makePromptProduction?.(new Types.ObjectId().toString()), + ).rejects.toMatchObject({ name: 'PromptNotFoundError' }); + }); + + it('returns a string group ID when deleting the last revision deletes the group', async () => { + const adapter = createNativePromptAdapter(db); + + await expect( + adapter.deletePrompt?.({ groupId: group._id, promptId: draft._id }), + ).resolves.toEqual({ prompt: 'Prompt deleted successfully' }); + await expect( + adapter.deletePrompt?.({ groupId: group._id, promptId: production._id }), + ).resolves.toEqual({ + prompt: 'Prompt deleted successfully', + promptGroup: { message: 'Prompt group deleted successfully', id: group._id }, + }); + }); +}); diff --git a/packages/api/src/prompts/native.ts b/packages/api/src/prompts/native.ts new file mode 100644 index 00000000000..dae1584e81d --- /dev/null +++ b/packages/api/src/prompts/native.ts @@ -0,0 +1,152 @@ +import type { + PromptRecord, + StoredId, + PromptDatabase, + ResolvedPrompt, + PromptProjection, + PromptSelection, + PromptGroupRecord, + PromptCatalogStore, + PromptSourceAdapter, +} from './types'; +import { toPromptGroupRecord, toPromptRecord } from './records'; + +type ResolvablePrompt = Pick; + +function isMatchingRevision( + revision: PromptRecord | PromptProjection | null | undefined, + groupId: string, + promptId?: string, +): revision is ResolvablePrompt { + return ( + revision != null && + revision._id != null && + revision.groupId === groupId && + revision.type != null && + (promptId == null || revision._id === promptId) + ); +} + +function resolveValue(revision: ResolvablePrompt): ResolvedPrompt { + return { + groupId: revision.groupId, + promptId: revision._id, + prompt: revision.prompt, + type: revision.type, + }; +} + +async function getPrompt(db: PromptDatabase, promptId: string): Promise { + const record = await db.getPrompt({ _id: promptId }); + return record == null ? null : toPromptRecord(record); +} + +async function getPromptGroup( + db: PromptDatabase, + groupId: string, +): Promise { + const record = await db.getPromptGroup({ _id: groupId }); + return record == null ? null : toPromptGroupRecord(record); +} + +async function resolveExact( + db: PromptDatabase, + groupId: string, + promptId: string, + loadedRevision?: PromptRecord | null, +): Promise { + let revision = loadedRevision; + if (!isMatchingRevision(revision, groupId, promptId)) { + revision = await getPrompt(db, promptId); + } + return isMatchingRevision(revision, groupId, promptId) ? resolveValue(revision) : null; +} + +async function resolveProduction( + db: PromptDatabase, + groupId: string, + loadedGroup?: PromptGroupRecord | null, +): Promise { + let group = loadedGroup; + if (group?._id !== groupId) { + group = await getPromptGroup(db, groupId); + } + if (group == null || group.productionId == null) { + return null; + } + if (isMatchingRevision(group.productionPrompt, groupId, group.productionId)) { + return resolveValue(group.productionPrompt); + } + const revision = await getPrompt(db, group.productionId); + return isMatchingRevision(revision, groupId, group.productionId) ? resolveValue(revision) : null; +} + +function toIdString(id: StoredId): string { + return typeof id === 'string' ? id : id.toString(); +} + +/** Native prompts stored in the LibreChat database. */ +export function createNativePromptAdapter(db: PromptDatabase): PromptSourceAdapter { + return { + resolvePrompt: ({ groupId, selection, loadedGroup, loadedRevision }) => { + if (selection.type === 'exact') { + return resolveExact(db, groupId, selection.promptId, loadedRevision); + } + return resolveProduction(db, groupId, loadedGroup); + }, + getPromptGroup: (groupId) => getPromptGroup(db, groupId), + getPrompt: (promptId) => getPrompt(db, promptId), + getPrompts: async (groupId) => (await db.getPrompts({ groupId })).map(toPromptRecord), + createPromptGroup: async ({ prompt, group = {}, author, authorName }) => { + const result = await db.createPromptGroup({ prompt, group, author, authorName }); + return { + prompt: result.prompt == null ? null : toPromptRecord(result.prompt), + group: toPromptGroupRecord(result.group), + }; + }, + savePrompt: async ({ groupId, prompt, author }) => { + const result = await db.savePrompt({ prompt: { ...prompt, groupId }, author }); + return { prompt: toPromptRecord(result.prompt) }; + }, + makePromptProduction: (promptId) => db.makePromptProduction(promptId), + deletePrompt: async ({ groupId, promptId }) => { + const result = await db.deletePrompt({ groupId, promptId }); + if (result.promptGroup == null) { + return { prompt: result.prompt }; + } + return { + prompt: result.prompt, + promptGroup: { message: result.promptGroup.message, id: toIdString(result.promptGroup.id) }, + }; + }, + }; +} + +/** Local catalog operations over the LibreChat prompt group collection. */ +export function createPromptCatalogStore(db: PromptDatabase): PromptCatalogStore { + return { + getListPromptGroupsByAccess: async ({ accessibleIds, name, category, limit, after }) => { + const result = await db.getListPromptGroupsByAccess({ + accessibleIds: [...accessibleIds], + name, + category, + limit, + after, + }); + return { + data: result.data.map(toPromptGroupRecord), + has_more: result.has_more, + after: result.after, + }; + }, + updatePromptGroup: async (groupId, updates) => + toPromptGroupRecord(await db.updatePromptGroup({ _id: groupId }, updates)), + incrementPromptGroupUsage: (groupId) => db.incrementPromptGroupUsage(groupId), + deletePromptGroup: (groupId) => db.deletePromptGroup({ _id: groupId }), + deleteUserPrompts: (userId) => db.deleteUserPrompts(userId), + }; +} + +export function selectionUnavailableReason(selection: PromptSelection): 'production' | 'revision' { + return selection.type === 'production' ? 'production' : 'revision'; +} diff --git a/packages/api/src/prompts/records.ts b/packages/api/src/prompts/records.ts new file mode 100644 index 00000000000..71f4ddbba76 --- /dev/null +++ b/packages/api/src/prompts/records.ts @@ -0,0 +1,32 @@ +import type { PromptGroupRecord, PromptRecord } from './types'; + +const ID_FIELDS = ['_id', 'groupId', 'author', 'productionId'] as const; + +function withStringIds(record: object): Record { + const plain: Record = { ...record }; + for (const field of ID_FIELDS) { + const value = plain[field]; + if (value != null && typeof value !== 'string') { + plain[field] = String(value); + } + } + return plain; +} + +/** + * Converts a stored revision to plain data with string IDs. Other fields, such as dates, + * `__v` and `tenantId`, stay as stored, so the JSON response does not change. + */ +export function toPromptRecord(record: object): PromptRecord { + return withStringIds(record) as unknown as PromptRecord; +} + +/** Converts a stored group, and its Production revision when present, to string IDs. */ +export function toPromptGroupRecord(record: object): PromptGroupRecord { + const plain = withStringIds(record); + const production = plain.productionPrompt; + if (production != null && typeof production === 'object') { + plain.productionPrompt = withStringIds(production); + } + return plain as unknown as PromptGroupRecord; +} diff --git a/packages/api/src/prompts/schemas.spec.ts b/packages/api/src/prompts/schemas.spec.ts index 2ba34e17f28..f4de5af1118 100644 --- a/packages/api/src/prompts/schemas.spec.ts +++ b/packages/api/src/prompts/schemas.spec.ts @@ -1,9 +1,37 @@ import { updatePromptGroupSchema, validatePromptGroupUpdate, + safeValidatePromptPayload, safeValidatePromptGroupUpdate, } from './schemas'; +describe('safeValidatePromptPayload', () => { + it('keeps the prompt fields and removes other fields', () => { + const result = safeValidatePromptPayload({ + prompt: 'Text', + type: 'chat', + groupId: 'group-1', + author: 'user-1', + }); + + expect(result.success && result.data).toEqual({ prompt: 'Text', type: 'chat' }); + }); + + it.each([ + [null, 'Prompt text is required and must be a non-empty string'], + [{ type: 'text' }, 'Prompt text is required and must be a non-empty string'], + [{ prompt: 42, type: 'text' }, 'Prompt text is required and must be a non-empty string'], + [{ prompt: ' ', type: 'text' }, 'Prompt text is required and must be a non-empty string'], + [{ prompt: 'Text' }, 'Prompt type must be "text" or "chat"'], + [{ prompt: 'Text', type: 'image' }, 'Prompt type must be "text" or "chat"'], + ])('rejects %j with the route message', (payload, message) => { + const result = safeValidatePromptPayload(payload); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0]?.message).toBe(message); + }); +}); + describe('updatePromptGroupSchema', () => { describe('allowed fields', () => { it('should accept valid name field', () => { diff --git a/packages/api/src/prompts/schemas.ts b/packages/api/src/prompts/schemas.ts index c9df44bfe7a..508a4d2dd3d 100644 --- a/packages/api/src/prompts/schemas.ts +++ b/packages/api/src/prompts/schemas.ts @@ -1,6 +1,31 @@ import { z } from 'zod'; import { Constants } from 'librechat-data-provider'; +export type TPromptPayloadSchema = { + prompt: string; + type: 'text' | 'chat'; +}; + +const PROMPT_TEXT_MESSAGE = 'Prompt text is required and must be a non-empty string'; +const PROMPT_TYPE_MESSAGE = 'Prompt type must be "text" or "chat"'; + +/** Validates a new revision. Unknown keys, such as the editor's `groupId`, are removed. */ +const promptPayloadSchema: z.ZodType = z.object( + { + prompt: z + .string({ required_error: PROMPT_TEXT_MESSAGE, invalid_type_error: PROMPT_TEXT_MESSAGE }) + .refine((value) => value.trim().length > 0, { message: PROMPT_TEXT_MESSAGE }), + type: z.enum(['text', 'chat'], { errorMap: () => ({ message: PROMPT_TYPE_MESSAGE }) }), + }, + { invalid_type_error: PROMPT_TEXT_MESSAGE }, +); + +export function safeValidatePromptPayload( + data: unknown, +): z.SafeParseReturnType { + return promptPayloadSchema.safeParse(data); +} + /** * Schema for validating prompt group update payloads. * Only allows fields that users should be able to modify. diff --git a/packages/api/src/prompts/service.spec.ts b/packages/api/src/prompts/service.spec.ts new file mode 100644 index 00000000000..412c9f44c7f --- /dev/null +++ b/packages/api/src/prompts/service.spec.ts @@ -0,0 +1,518 @@ +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { logger, createModels, createMethods } from '@librechat/data-schemas'; +import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provider'; +import type { FiltersConfig } from 'librechat-data-provider'; +import type { NativePromptServiceDependencies, PromptService } from './service'; +import type { PromptDatabase, PromptGroupRecord, PromptRecord } from './types'; +import { createPromptCatalogStore, createNativePromptAdapter } from './native'; +import { createNativePromptService, createPromptService } from './service'; +import { PromptStoreError } from './errors'; + +const filters: FiltersConfig = { + prompts: { + pii: { + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-[A-Z]+' }], + }, + }, +}; + +let mongo: MongoMemoryServer; +let db: PromptDatabase; +let grantPermission: jest.MockedFunction; +let service: PromptService; +let group: PromptGroupRecord; +let production: PromptRecord; +const author = new Types.ObjectId().toString(); +const missingId = () => new Types.ObjectId().toString(); + +beforeAll(async () => { + mongo = await MongoMemoryServer.create({ instance: { args: ['--nounixsocket'] } }); + await mongoose.connect(mongo.getUri()); + createModels(mongoose); + db = createMethods(mongoose); +}); + +beforeEach(async () => { + grantPermission = jest.fn().mockResolvedValue(undefined); + service = createNativePromptService({ db, grantPermission }); + const created = await service.createPromptGroup({ + prompt: { prompt: 'Production prompt', type: 'text' }, + group: { name: 'Service group' }, + author, + authorName: 'Author', + }); + if (!created.ok || created.value.prompt == null) { + throw new Error('Test group was not created'); + } + production = created.value.prompt; + group = (await createNativePromptAdapter(db).getPromptGroup( + production.groupId, + )) as PromptGroupRecord; + grantPermission.mockClear(); +}); + +afterEach(async () => { + jest.restoreAllMocks(); + await mongoose.models.Prompt.deleteMany({}); + await mongoose.models.PromptGroup.deleteMany({}); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongo?.stop(); +}); + +async function addRevision(prompt: string): Promise { + const result = await service.savePrompt({ + groupId: group._id, + prompt: { prompt, type: 'text' }, + author, + }); + if (!result.ok) { + throw new Error('Test revision was not saved'); + } + return result.value.prompt; +} + +describe('createPromptService', () => { + describe('resolvePrompt', () => { + it('returns the Production revision', async () => { + await expect( + service.resolvePrompt({ groupId: group._id, selection: { type: 'production' }, filters }), + ).resolves.toEqual({ + ok: true, + value: { + groupId: group._id, + promptId: production._id, + prompt: 'Production prompt', + type: 'text', + }, + }); + }); + + it('rejects blocked selected content', async () => { + const blocked = await addRevision('Contains PRIVATE-VALUE'); + + await expect( + service.resolvePrompt({ + groupId: group._id, + selection: { type: 'exact', promptId: blocked._id }, + filters, + }), + ).resolves.toMatchObject({ ok: false, error: { type: 'blocked_content' } }); + }); + + it('returns an unavailable result for a missing selection', async () => { + await expect( + service.resolvePrompt({ + groupId: group._id, + selection: { type: 'exact', promptId: missingId() }, + }), + ).resolves.toEqual({ + ok: false, + error: { type: 'unavailable_selection', reason: 'revision' }, + }); + await expect( + service.resolvePrompt({ groupId: missingId(), selection: { type: 'production' } }), + ).resolves.toEqual({ + ok: false, + error: { type: 'unavailable_selection', reason: 'production' }, + }); + }); + }); + + describe('getListPromptGroupsByAccess', () => { + it('projects catalog content and marks public groups', async () => { + const stored = await service.createPromptGroup({ + prompt: { prompt: 'PRIVATE-PROMPT', type: 'text' }, + group: { name: 'Stored before the filter' }, + author, + }); + const blockedId = stored.ok ? stored.value.prompt?.groupId : undefined; + expect(blockedId).toEqual(expect.any(String)); + + const result = await service.getListPromptGroupsByAccess({ + accessibleIds: [group._id, blockedId as string], + publiclyAccessibleIds: [group._id], + ownedPromptGroupIds: [group._id], + limit: 10, + after: null, + forReuse: true, + filters, + }); + + expect(result).toEqual({ + data: [expect.objectContaining({ _id: group._id, isPublic: true })], + has_more: false, + after: null, + }); + }); + + it('filters by name without regular expression input', async () => { + const input = { + accessibleIds: [group._id], + publiclyAccessibleIds: [], + ownedPromptGroupIds: [group._id], + limit: null, + after: null, + forReuse: false, + }; + + await expect( + service.getListPromptGroupsByAccess({ ...input, name: 'service' }), + ).resolves.toMatchObject({ data: [{ _id: group._id }] }); + await expect( + service.getListPromptGroupsByAccess({ ...input, name: '.*' }), + ).resolves.toMatchObject({ data: [] }); + }); + }); + + describe('getPrompts', () => { + it('redacts blocked revision history and keeps structural fields', async () => { + const blocked = await addRevision('Contains PRIVATE-VALUE'); + + const history = await service.getPrompts({ groupId: group._id, filters }); + + expect(history).toHaveLength(2); + expect(history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ _id: blocked._id, prompt: '', contentFilterBlocked: true }), + expect.objectContaining({ _id: production._id, prompt: 'Production prompt' }), + ]), + ); + }); + + it('tags a read failure with the read stage', async () => { + jest.spyOn(db, 'getPrompts').mockRejectedValueOnce(new Error('database unavailable')); + + await expect(service.getPrompts({ groupId: group._id })).rejects.toMatchObject({ + name: 'PromptStoreError', + stage: 'read', + }); + }); + }); + + describe('createPromptGroup', () => { + it('rejects a missing prompt or group name before writing', async () => { + const create = jest.spyOn(db, 'createPromptGroup'); + + await expect( + service.createPromptGroup({ + prompt: { prompt: 'Safe', type: 'text' }, + group: { name: '' }, + author, + }), + ).resolves.toEqual({ + ok: false, + error: { type: 'invalid_input', message: 'Prompt and group name are required' }, + }); + expect(create).not.toHaveBeenCalled(); + }); + + it('rejects protected content before writing', async () => { + const create = jest.spyOn(db, 'createPromptGroup'); + + await expect( + service.createPromptGroup({ + prompt: { prompt: 'Safe', type: 'text' }, + group: { name: 'PRIVATE-NAME' }, + author, + filters, + }), + ).resolves.toMatchObject({ ok: false, error: { type: 'blocked_content' } }); + expect(create).not.toHaveBeenCalled(); + }); + + it('grants the creator ownership of the new group', async () => { + const result = await service.createPromptGroup({ + prompt: { prompt: 'New prompt', type: 'text' }, + group: { name: 'New group' }, + author, + }); + + expect(result.ok).toBe(true); + const groupId = result.ok ? result.value.prompt?.groupId : undefined; + expect(grantPermission).toHaveBeenCalledWith({ + principalType: PrincipalType.USER, + principalId: author, + resourceType: ResourceType.PROMPTGROUP, + resourceId: groupId, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: author, + }); + }); + + it('logs a failed ownership grant and still returns the new group', async () => { + grantPermission.mockRejectedValueOnce(new Error('acl unavailable')); + const logError = jest.spyOn(logger, 'error').mockImplementation(() => logger); + + await expect( + service.createPromptGroup({ + prompt: { prompt: 'New prompt', type: 'text' }, + group: { name: 'New group' }, + author, + }), + ).resolves.toMatchObject({ ok: true }); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining('[createPromptGroup] Failed to grant owner permissions'), + expect.any(Error), + ); + }); + }); + + describe('savePrompt', () => { + it.each([ + [undefined, 'Prompt is required'], + [{ prompt: ' ', type: 'text' }, 'Prompt text is required and must be a non-empty string'], + [{ type: 'text' }, 'Prompt text is required and must be a non-empty string'], + [{ prompt: 'Text', type: 'image' }, 'Prompt type must be "text" or "chat"'], + ])('rejects the payload %j', async (prompt, message) => { + const save = jest.spyOn(db, 'savePrompt'); + + await expect(service.savePrompt({ groupId: group._id, prompt, author })).resolves.toEqual({ + ok: false, + error: { type: 'invalid_input', message }, + }); + expect(save).not.toHaveBeenCalled(); + }); + + it('saves only the prompt fields into the route group without promoting', async () => { + const otherGroupId = missingId(); + const result = await service.savePrompt({ + groupId: group._id, + prompt: { prompt: 'Draft', type: 'chat', groupId: otherGroupId, author: missingId() }, + author, + }); + + expect(result).toMatchObject({ + ok: true, + value: { prompt: { groupId: group._id, author, prompt: 'Draft', type: 'chat' } }, + }); + await expect(db.getPromptGroup({ _id: group._id })).resolves.toMatchObject({ + productionId: new Types.ObjectId(production._id), + }); + }); + + it('rejects protected content before writing', async () => { + const save = jest.spyOn(db, 'savePrompt'); + + await expect( + service.savePrompt({ + groupId: group._id, + prompt: { prompt: 'PRIVATE-VALUE', type: 'text' }, + author, + filters, + }), + ).resolves.toMatchObject({ ok: false, error: { type: 'blocked_content' } }); + expect(save).not.toHaveBeenCalled(); + }); + + it('tags a write failure with the write stage', async () => { + jest.spyOn(db, 'savePrompt').mockRejectedValueOnce(new Error('database unavailable')); + + const error = await service + .savePrompt({ groupId: group._id, prompt: { prompt: 'Text', type: 'text' }, author }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(PromptStoreError); + expect(error).toMatchObject({ stage: 'write', cause: expect.any(Error) }); + }); + }); + + describe('getPromptGroup', () => { + it('reuses a matching loaded group and redacts a blocked Production revision', async () => { + const read = jest.spyOn(db, 'getPromptGroup'); + + const result = await service.getPromptGroup({ + groupId: group._id, + loadedGroup: { + ...group, + productionPrompt: { ...production, prompt: 'PRIVATE-PROMPT' }, + }, + filters, + }); + + expect(read).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: true, + value: { productionPrompt: { prompt: '', contentFilterBlocked: true } }, + }); + }); + + it('rejects blocked group metadata', async () => { + await expect( + service.getPromptGroup({ + groupId: group._id, + loadedGroup: { ...group, name: 'PRIVATE-GROUP' }, + filters, + }), + ).resolves.toMatchObject({ ok: false, error: { type: 'blocked_content' } }); + }); + + it('returns null for an absent group and reads when the loaded group differs', async () => { + const read = jest.spyOn(db, 'getPromptGroup'); + + await expect( + service.getPromptGroup({ groupId: missingId(), loadedGroup: group }), + ).resolves.toBeNull(); + expect(read).toHaveBeenCalledTimes(1); + }); + + it('tags a read failure with the read stage', async () => { + jest.spyOn(db, 'getPromptGroup').mockRejectedValueOnce(new Error('database unavailable')); + + await expect(service.getPromptGroup({ groupId: group._id })).rejects.toMatchObject({ + stage: 'read', + }); + }); + }); + + describe('getPrompt', () => { + it('reuses a matching loaded revision and rejects blocked content', async () => { + const read = jest.spyOn(db, 'getPrompt'); + + await expect( + service.getPrompt({ + promptId: production._id, + loadedRevision: { ...production, prompt: 'PRIVATE-PROMPT' }, + filters, + }), + ).resolves.toMatchObject({ ok: false, error: { type: 'blocked_content' } }); + expect(read).not.toHaveBeenCalled(); + }); + + it('returns the stored revision and null for an absent revision', async () => { + await expect(service.getPrompt({ promptId: production._id })).resolves.toMatchObject({ + ok: true, + value: production, + }); + await expect(service.getPrompt({ promptId: missingId() })).resolves.toBeNull(); + }); + }); + + describe('updatePromptGroup', () => { + it('returns the schema details for an invalid update', async () => { + const result = await service.updatePromptGroup({ + groupId: group._id, + updates: { author: missingId() }, + }); + + expect(result).toMatchObject({ + ok: false, + error: { + type: 'invalid_input', + message: 'Invalid request body', + details: [expect.any(Object)], + }, + }); + }); + + it('rejects protected metadata before writing', async () => { + const update = jest.spyOn(db, 'updatePromptGroup'); + + await expect( + service.updatePromptGroup({ + groupId: group._id, + updates: { name: 'PRIVATE-NAME' }, + filters, + }), + ).resolves.toMatchObject({ ok: false, error: { type: 'blocked_content' } }); + expect(update).not.toHaveBeenCalled(); + }); + + it('updates the group and tags a missing group as a write failure', async () => { + await expect( + service.updatePromptGroup({ groupId: group._id, updates: { name: 'Renamed' } }), + ).resolves.toMatchObject({ ok: true, value: { _id: group._id, name: 'Renamed' } }); + await expect( + service.updatePromptGroup({ groupId: missingId(), updates: { name: 'Renamed' } }), + ).rejects.toMatchObject({ stage: 'write' }); + }); + }); + + describe('makePromptProduction', () => { + it('promotes a loaded revision without another read', async () => { + const draft = await addRevision('Draft'); + const read = jest.spyOn(db, 'getPrompt'); + + await expect( + service.makePromptProduction({ promptId: draft._id, loadedRevision: draft }), + ).resolves.toEqual({ ok: true, value: { message: 'Prompt production made successfully' } }); + expect(read).not.toHaveBeenCalled(); + }); + + it('does not promote blocked content', async () => { + const blocked = await addRevision('Contains PRIVATE-VALUE'); + const promote = jest.spyOn(db, 'makePromptProduction'); + + await expect( + service.makePromptProduction({ promptId: blocked._id, filters }), + ).resolves.toMatchObject({ ok: false, error: { type: 'blocked_content' } }); + expect(promote).not.toHaveBeenCalled(); + }); + + it('does not promote when the preliminary read fails', async () => { + const draft = await addRevision('Draft'); + jest.spyOn(db, 'getPrompt').mockRejectedValueOnce(new Error('database unavailable')); + const promote = jest.spyOn(db, 'makePromptProduction'); + + await expect(service.makePromptProduction({ promptId: draft._id })).rejects.toMatchObject({ + stage: 'read', + }); + expect(promote).not.toHaveBeenCalled(); + }); + + it('tags a missing revision as a write failure', async () => { + await expect(service.makePromptProduction({ promptId: missingId() })).rejects.toMatchObject({ + stage: 'write', + cause: { name: 'PromptNotFoundError' }, + }); + }); + }); + + describe('usage and deletion', () => { + it('records usage and deletes groups and user prompts', async () => { + await expect(service.incrementPromptGroupUsage(group._id)).resolves.toEqual({ + numberOfGenerations: 1, + }); + await expect(service.deletePromptGroup(group._id)).resolves.toEqual({ + message: 'Prompt group deleted successfully', + }); + + const deleteUserPrompts = jest.spyOn(db, 'deleteUserPrompts'); + await service.deleteUserPrompts(author); + expect(deleteUserPrompts).toHaveBeenCalledWith(author); + }); + }); + + describe('a source without mutations', () => { + it('returns unsupported for each mutation before validating or writing', async () => { + const { + savePrompt: _save, + makePromptProduction: _promote, + deletePrompt: _delete, + ...source + } = createNativePromptAdapter(db); + const readOnly = createPromptService({ + source, + catalog: createPromptCatalogStore(db), + grantCreatorOwnership: jest.fn(), + logger: { error: jest.fn() }, + }); + + await expect( + readOnly.savePrompt({ groupId: group._id, prompt: undefined, author }), + ).resolves.toEqual({ ok: false, error: { type: 'unsupported', operation: 'savePrompt' } }); + await expect(readOnly.makePromptProduction({ promptId: production._id })).resolves.toEqual({ + ok: false, + error: { type: 'unsupported', operation: 'makePromptProduction' }, + }); + await expect( + readOnly.deletePrompt({ groupId: group._id, promptId: production._id }), + ).resolves.toEqual({ ok: false, error: { type: 'unsupported', operation: 'deletePrompt' } }); + await expect(readOnly.getPrompts({ groupId: group._id })).resolves.toHaveLength(1); + }); + }); +}); diff --git a/packages/api/src/prompts/service.ts b/packages/api/src/prompts/service.ts new file mode 100644 index 00000000000..33b2ec5d6d7 --- /dev/null +++ b/packages/api/src/prompts/service.ts @@ -0,0 +1,331 @@ +import { logger } from '@librechat/data-schemas'; +import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provider'; +import type { + FiltersConfig, + TDeletePromptResponse, + TMakePromptProductionResponse, +} from 'librechat-data-provider'; +import type { + PromptRecord, + PromptDatabase, + ResolvedPrompt, + PromptListInput, + PromptOperation, + PromptGroupRecord, + ResolvePromptInput, + PromptServiceResult, + PromptCreationResult, + CreatePromptGroupInput, + PromptServiceDependencies, +} from './types'; +import type { ProjectedStoredPrompt, ProjectedStoredPromptGroup } from './protection'; +import { + inspectPromptContent, + projectStoredPrompts, + projectStoredPromptGroup, + projectStoredPromptGroups, +} from './protection'; +import { + markPublicPromptGroups, + buildPromptGroupFilter, + filterAccessibleIdsBySharedLogic, +} from './format'; +import { + createPromptCatalogStore, + createNativePromptAdapter, + selectionUnavailableReason, +} from './native'; +import { safeValidatePromptGroupUpdate, safeValidatePromptPayload } from './schemas'; +import { withPromptStage } from './errors'; + +type WithPromptFilters = T & { readonly filters?: FiltersConfig }; +type ProjectedGroup = ProjectedStoredPromptGroup; + +export interface PromptServiceListResult { + readonly data: readonly ProjectedGroup[]; + readonly has_more: boolean; + readonly after: string | null; +} + +/** + * Prompt operations for authorized callers. The caller enforces access before each call. + * Database failures throw `PromptStoreError` with the stage (`read` or `write`) where + * they occurred, so the HTTP boundary can keep each route's existing response. + */ +export interface PromptService { + resolvePrompt(input: ResolvePromptInput): Promise>; + getListPromptGroupsByAccess(input: PromptListInput): Promise; + /** Returns the group's revisions, newest first. */ + getPrompts( + input: WithPromptFilters<{ readonly groupId: string }>, + ): Promise[]>; + createPromptGroup( + input: WithPromptFilters, + ): Promise>; + savePrompt( + input: WithPromptFilters<{ + readonly groupId: string; + readonly prompt: unknown; + readonly author: string; + }>, + ): Promise>; + /** + * Returns null when the group does not exist. A successful value of null means that + * projection removed the group because its metadata is blocked. + */ + getPromptGroup( + input: WithPromptFilters<{ + readonly groupId: string; + readonly loadedGroup?: PromptGroupRecord | null; + }>, + ): Promise | null>; + /** Returns null when the revision does not exist. */ + getPrompt( + input: WithPromptFilters<{ + readonly promptId: string; + readonly loadedRevision?: PromptRecord | null; + }>, + ): Promise | null>; + incrementPromptGroupUsage(groupId: string): Promise<{ readonly numberOfGenerations: number }>; + updatePromptGroup( + input: WithPromptFilters<{ readonly groupId: string; readonly updates: unknown }>, + ): Promise>; + makePromptProduction( + input: WithPromptFilters<{ + readonly promptId: string; + readonly loadedRevision?: PromptRecord | null; + }>, + ): Promise>; + deletePrompt(input: { + readonly groupId: string; + readonly promptId: string; + }): Promise>; + deletePromptGroup(groupId: string): Promise<{ readonly message: string }>; + /** Best-effort cleanup of the groups the user owns alone. */ + deleteUserPrompts(userId: string): Promise; +} + +function invalidInput(message: string, details?: unknown): PromptServiceResult { + return { ok: false, error: { type: 'invalid_input', message, details } }; +} + +function unsupported(operation: PromptOperation): PromptServiceResult { + return { ok: false, error: { type: 'unsupported', operation } }; +} + +function inspect( + input: Parameters[0], + filters: FiltersConfig | undefined, +): PromptServiceResult | null { + const finding = inspectPromptContent(input, filters); + return finding == null ? null : { ok: false, error: { type: 'blocked_content', finding } }; +} + +export function createPromptService(dependencies: PromptServiceDependencies): PromptService { + const { source, catalog, grantCreatorOwnership, logger } = dependencies; + + const readPrompt = (promptId: string, loaded?: PromptRecord | null) => + loaded?._id === promptId + ? Promise.resolve(loaded) + : withPromptStage('read', () => source.getPrompt(promptId)); + + return { + async resolvePrompt({ filters, ...input }) { + const resolved = await source.resolvePrompt(input); + if (resolved == null) { + return { + ok: false, + error: { + type: 'unavailable_selection', + reason: selectionUnavailableReason(input.selection), + }, + }; + } + return inspect({ prompt: resolved.prompt }, filters) ?? { ok: true, value: resolved }; + }, + + async getListPromptGroupsByAccess(input) { + const { name, category, searchShared, searchSharedOnly } = buildPromptGroupFilter(input); + const accessibleIds = await filterAccessibleIdsBySharedLogic({ + accessibleIds: input.accessibleIds, + searchShared, + searchSharedOnly, + publicPromptGroupIds: input.publiclyAccessibleIds, + ownedPromptGroupIds: input.ownedPromptGroupIds, + }); + const result = await catalog.getListPromptGroupsByAccess({ + accessibleIds, + name, + category, + limit: input.limit, + after: input.after, + }); + const projected = projectStoredPromptGroups(result.data, input.filters, { + forReuse: input.forReuse, + }); + return { + data: markPublicPromptGroups(projected, input.publiclyAccessibleIds), + has_more: result.has_more, + after: result.after, + }; + }, + + async getPrompts({ groupId, filters }) { + const prompts = await withPromptStage('read', () => source.getPrompts(groupId)); + return projectStoredPrompts(prompts, filters); + }, + + async createPromptGroup({ filters, ...input }) { + if (!input.prompt || !input.group || !input.group.name) { + return invalidInput('Prompt and group name are required'); + } + const rejection = inspect( + { prompt: input.prompt, group: input.group }, + filters, + ); + if (rejection != null) { + return rejection; + } + const value = await source.createPromptGroup(input); + const groupId = value.prompt?.groupId; + if (value.prompt?._id && groupId) { + try { + await grantCreatorOwnership({ userId: input.author, groupId }); + } catch (error) { + logger.error( + `[createPromptGroup] Failed to grant owner permissions for promptGroup ${groupId}:`, + error, + ); + } + } + return { ok: true, value }; + }, + + async savePrompt({ groupId, prompt, author, filters }) { + if (!source.savePrompt) { + return unsupported('savePrompt'); + } + if (!prompt) { + return invalidInput('Prompt is required'); + } + const validation = safeValidatePromptPayload(prompt); + if (!validation.success) { + return invalidInput(validation.error.issues[0]?.message ?? 'Invalid prompt'); + } + const rejection = inspect<{ prompt: PromptRecord }>({ prompt: validation.data }, filters); + if (rejection != null) { + return rejection; + } + const save = source.savePrompt; + const value = await withPromptStage('write', () => + save({ groupId, prompt: validation.data, author }), + ); + return { ok: true, value }; + }, + + async getPromptGroup({ groupId, loadedGroup, filters }) { + const group = + loadedGroup?._id === groupId + ? loadedGroup + : await withPromptStage('read', () => source.getPromptGroup(groupId)); + if (group == null) { + return null; + } + return ( + inspect({ group }, filters) ?? { + ok: true, + value: projectStoredPromptGroup(group, filters), + } + ); + }, + + async getPrompt({ promptId, loadedRevision, filters }) { + const revision = await readPrompt(promptId, loadedRevision); + if (revision == null) { + return null; + } + return inspect({ prompt: revision }, filters) ?? { ok: true, value: revision }; + }, + + incrementPromptGroupUsage: (groupId) => catalog.incrementPromptGroupUsage(groupId), + + async updatePromptGroup({ groupId, updates, filters }) { + const validation = safeValidatePromptGroupUpdate(updates); + if (!validation.success) { + return invalidInput('Invalid request body', validation.error.errors); + } + const rejection = inspect({ group: validation.data }, filters); + if (rejection != null) { + return rejection; + } + const value = await withPromptStage('write', () => + catalog.updatePromptGroup(groupId, validation.data), + ); + return { ok: true, value }; + }, + + async makePromptProduction({ promptId, loadedRevision, filters }) { + if (!source.makePromptProduction) { + return unsupported('makePromptProduction'); + } + const revision = await readPrompt(promptId, loadedRevision); + const rejection = inspect( + { prompt: revision ?? undefined }, + filters, + ); + if (rejection != null) { + return rejection; + } + const promote = source.makePromptProduction; + const value = await withPromptStage('write', () => promote(promptId)); + return { ok: true, value }; + }, + + async deletePrompt(input) { + if (!source.deletePrompt) { + return unsupported('deletePrompt'); + } + return { ok: true, value: await source.deletePrompt(input) }; + }, + + deletePromptGroup: (groupId) => catalog.deletePromptGroup(groupId), + deleteUserPrompts: (userId) => catalog.deleteUserPrompts(userId), + }; +} + +export interface NativePromptServiceDependencies { + readonly db: PromptDatabase; + readonly grantPermission: (input: { + principalType: PrincipalType; + principalId: string; + resourceType: ResourceType; + resourceId: string; + accessRoleId: AccessRoleIds; + grantedBy: string; + }) => Promise; +} + +/** The prompt service over native LibreChat prompts. */ +export function createNativePromptService({ + db, + grantPermission, +}: NativePromptServiceDependencies): PromptService { + return createPromptService({ + source: createNativePromptAdapter(db), + catalog: createPromptCatalogStore(db), + grantCreatorOwnership: async ({ userId, groupId }) => { + await grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.PROMPTGROUP, + resourceId: groupId, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: userId, + }); + logger.debug( + `[createPromptGroup] Granted owner permissions to user ${userId} for promptGroup ${groupId}`, + ); + }, + logger, + }); +} diff --git a/packages/api/src/prompts/types.ts b/packages/api/src/prompts/types.ts new file mode 100644 index 00000000000..f3e305a5431 --- /dev/null +++ b/packages/api/src/prompts/types.ts @@ -0,0 +1,200 @@ +import type { + FiltersConfig, + TCreatePromptRecord, + TDeletePromptResponse, + TMakePromptProductionResponse, +} from 'librechat-data-provider'; +import type { ProtectionFinding } from '../protection/types'; + +export type PromptKind = 'text' | 'chat'; +export type PromptTimestamp = string | Date; + +/** A stored ID as the database returns it: a string or an ObjectId-like value. */ +export interface StoredId { + toString(): string; +} + +export interface PromptRecord { + readonly _id: string; + readonly groupId: string; + readonly author: string; + readonly prompt: string; + readonly type: PromptKind; + readonly createdAt?: PromptTimestamp; + readonly updatedAt?: PromptTimestamp; + readonly tenantId?: string; +} + +export interface PromptProjection { + readonly _id?: string; + readonly prompt: string; + readonly groupId?: string; + readonly author?: string; + readonly type?: PromptKind; +} + +export interface PromptGroupRecord { + readonly _id: string; + readonly name: string; + readonly author: string; + readonly authorName: string; + readonly numberOfGenerations?: number; + readonly command?: string | null; + readonly oneliner?: string; + readonly category?: string; + readonly productionId?: string | null; + readonly productionPrompt?: PromptRecord | PromptProjection | null; + readonly isPublic?: boolean; + readonly createdAt?: PromptTimestamp; + readonly updatedAt?: PromptTimestamp; + readonly tenantId?: string; +} + +export type PromptSelection = + | { readonly type: 'production' } + | { readonly type: 'exact'; readonly promptId: string }; + +export interface ResolvedPrompt { + readonly groupId: string; + readonly promptId: string; + readonly prompt: string; + readonly type: PromptKind; +} + +/** The raw creation body plus the server-supplied creator. */ +export type CreatePromptGroupInput = Omit & { + readonly authorName?: string; +}; + +export interface AddPromptRevisionInput { + readonly groupId: string; + readonly prompt: { readonly prompt: string; readonly type: PromptKind }; + readonly author: string; +} + +export interface PromptCreationResult { + readonly prompt: PromptRecord | null; + readonly group: PromptGroupRecord; +} + +export interface PromptListResult { + readonly data: readonly PromptGroupRecord[]; + readonly has_more: boolean; + readonly after: string | null; +} + +export interface PromptListInput { + readonly accessibleIds: readonly string[]; + readonly publiclyAccessibleIds: readonly string[]; + readonly ownedPromptGroupIds: readonly string[]; + readonly name?: string; + readonly category?: string; + /** Page size; null requests the full catalog. */ + readonly limit: number | string | null; + readonly after: string | null; + readonly forReuse: boolean; + readonly filters?: FiltersConfig; +} + +export type PromptOperation = 'savePrompt' | 'makePromptProduction' | 'deletePrompt'; + +export type PromptServiceError = + | { readonly type: 'invalid_input'; readonly message: string; readonly details?: unknown } + | { readonly type: 'blocked_content'; readonly finding: ProtectionFinding } + | { + readonly type: 'unavailable_selection'; + readonly reason: 'production' | 'revision'; + } + | { readonly type: 'unsupported'; readonly operation: PromptOperation }; + +export type PromptServiceResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: PromptServiceError }; + +export interface ResolvePromptInput { + readonly groupId: string; + readonly selection: PromptSelection; + readonly loadedGroup?: PromptGroupRecord | null; + readonly loadedRevision?: PromptRecord | null; + readonly filters?: FiltersConfig; +} + +/** + * Operations that need source content. Mutations are optional: a source that cannot + * perform one leaves it out, and the service returns an `unsupported` result. + */ +export interface PromptSourceAdapter { + /** Returns null when the selection is not available in the group. */ + resolvePrompt(input: Omit): Promise; + getPromptGroup(groupId: string): Promise; + getPrompt(promptId: string): Promise; + getPrompts(groupId: string): Promise; + createPromptGroup(input: CreatePromptGroupInput): Promise; + savePrompt?(input: AddPromptRevisionInput): Promise<{ readonly prompt: PromptRecord }>; + /** Throws `PromptNotFoundError` from data-schemas when the revision does not exist. */ + makePromptProduction?(promptId: string): Promise; + deletePrompt?(input: { + readonly groupId: string; + readonly promptId: string; + }): Promise; +} + +/** Local catalog operations that do not depend on the content source. */ +export interface PromptCatalogStore { + getListPromptGroupsByAccess(input: { + readonly accessibleIds: readonly string[]; + readonly name?: string; + readonly category?: string; + readonly limit: number | string | null; + readonly after: string | null; + }): Promise; + updatePromptGroup(groupId: string, updates: Record): Promise; + incrementPromptGroupUsage(groupId: string): Promise<{ readonly numberOfGenerations: number }>; + deletePromptGroup(groupId: string): Promise<{ readonly message: string }>; + deleteUserPrompts(userId: string): Promise; +} + +/** The data-schemas prompt methods the native adapter and catalog store use. */ +export interface PromptDatabase { + getPromptGroup(filter: { _id: string }): Promise; + getPrompt(filter: { _id: string }): Promise; + getPrompts(filter: { groupId: string }): Promise; + createPromptGroup(input: { + prompt: Record; + group: Record; + author: string; + authorName?: string; + }): Promise<{ prompt: object | null; group: object }>; + savePrompt(input: { + prompt: Record; + author: string; + }): Promise<{ prompt: object }>; + makePromptProduction(promptId: string): Promise<{ message: string }>; + deletePrompt(input: { + promptId: string; + groupId: string; + }): Promise<{ prompt: string; promptGroup?: { message: string; id: StoredId } }>; + getListPromptGroupsByAccess(input: { + accessibleIds: string[]; + name?: string; + category?: string; + limit: number | string | null; + after: string | null; + }): Promise<{ data: readonly object[]; has_more: boolean; after: string | null }>; + updatePromptGroup(filter: { _id: string }, data: Record): Promise; + incrementPromptGroupUsage(groupId: string): Promise<{ numberOfGenerations: number }>; + deletePromptGroup(filter: { _id: string }): Promise<{ message: string }>; + deleteUserPrompts(userId: string): Promise; +} + +export interface PromptServiceDependencies { + readonly source: PromptSourceAdapter; + readonly catalog: PromptCatalogStore; + readonly grantCreatorOwnership: (input: { + readonly userId: string; + readonly groupId: string; + }) => Promise; + readonly logger: { + error(message: string, error: unknown): void; + }; +} diff --git a/packages/api/src/protection/adapters/submissions.ts b/packages/api/src/protection/adapters/submissions.ts index e57f4638b8a..86f68e6131c 100644 --- a/packages/api/src/protection/adapters/submissions.ts +++ b/packages/api/src/protection/adapters/submissions.ts @@ -99,7 +99,7 @@ export interface PromptRecordInput { readonly description?: string; readonly oneliner?: string; readonly category?: string; - readonly command?: string; + readonly command?: string | null; } interface PromptRecordContentInput extends Omit { diff --git a/packages/api/src/types/prompts.ts b/packages/api/src/types/prompts.ts index f503f688df2..17224398da8 100644 --- a/packages/api/src/types/prompts.ts +++ b/packages/api/src/types/prompts.ts @@ -1,8 +1,8 @@ import type { IPromptGroup as IPromptGroup } from '@librechat/data-schemas'; import type { Types } from 'mongoose'; -export interface PromptGroupsListResponse { - promptGroups: IPromptGroup[]; +export interface PromptGroupsListResponse { + promptGroups: T[]; pageNumber: string; pageSize: string; pages: string; diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 42d69b35736..ec3d0b3e69f 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -36,6 +36,7 @@ export { MAX_AUDIT_VERIFY_ROWS, MAX_TOOL_FAVORITES, AgentTriggerDeliveryConflictError, + PromptNotFoundError, AgentQueuedTurnCapacityError, AgentQueuedTurnConflictError, AgentQueuedTurnLaneRetiredError, diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index bce00d7fa22..4c1d0e18014 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -110,7 +110,13 @@ import { } from './tx'; import { createTransactionMethods, type TransactionMethods } from './transaction'; import { createSpendTokensMethods, type SpendTokensMethods } from './spendTokens'; -import { createPromptMethods, type PromptMethods, type PromptDeps } from './prompt'; +import { + createPromptMethods, + PromptNotFoundError, + type PromptMethods, + type PromptDeps, + type PromptGroupListParams, +} from './prompt'; import { createSkillMethods, partitionIssues, @@ -219,6 +225,8 @@ export { export { AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, MAX_AUDIT_VERIFY_ROWS }; export { MAX_TOOL_FAVORITES }; export { AgentTriggerDeliveryConflictError }; +export { PromptNotFoundError }; +export type { PromptGroupListParams }; export { AgentQueuedTurnCapacityError, AgentQueuedTurnConflictError, diff --git a/packages/data-schemas/src/methods/prompt.spec.ts b/packages/data-schemas/src/methods/prompt.spec.ts index 6a02b8bc3bd..5d0c02b394a 100644 --- a/packages/data-schemas/src/methods/prompt.spec.ts +++ b/packages/data-schemas/src/methods/prompt.spec.ts @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { ObjectId } from 'mongodb'; +import { logger, createModels } from '..'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { SystemRoles, @@ -9,9 +10,8 @@ import { PermissionBits, } from 'librechat-data-provider'; import type { IPromptGroup, AccessRole as TAccessRole, AclEntry as TAclEntry } from '..'; +import { createMethods, PromptNotFoundError } from './index'; import { createAclEntryMethods } from './aclEntry'; -import { logger, createModels } from '..'; -import { createMethods } from './index'; // Disable console for tests logger.silent = true; @@ -623,3 +623,147 @@ describe('Prompt ACL Permissions', () => { }); }); }); + +describe('Prompt method failure contracts', () => { + const dbError = new Error('database unavailable'); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + async function createGroupWithPrompt(name: string, category = '') { + const { prompt, group } = await methods.createPromptGroup({ + prompt: { prompt: `${name} text`, type: 'text' }, + group: { name, category }, + author: String(testUsers.owner._id), + authorName: testUsers.owner.name ?? '', + }); + return { + groupId: String((group as { _id: unknown })._id), + promptId: String((prompt as { _id: unknown })._id), + }; + } + + it('returns null for an absent group or revision', async () => { + await expect(methods.getPromptGroup({ _id: new ObjectId().toString() })).resolves.toBeNull(); + await expect(methods.getPrompt({ _id: new ObjectId() })).resolves.toBeNull(); + }); + + it('throws database failures from reads instead of returning data', async () => { + jest.spyOn(PromptGroup, 'aggregate').mockImplementationOnce(() => { + throw dbError; + }); + await expect(methods.getPromptGroup({ _id: new ObjectId().toString() })).rejects.toBe(dbError); + + jest.spyOn(Prompt, 'findOne').mockImplementationOnce(() => { + throw dbError; + }); + await expect(methods.getPrompt({ _id: new ObjectId() })).rejects.toBe(dbError); + + jest.spyOn(Prompt, 'find').mockImplementationOnce(() => { + throw dbError; + }); + await expect(methods.getPrompts({ groupId: new ObjectId() })).rejects.toBe(dbError); + }); + + it('saves a revision as plain data and throws save failures', async () => { + const { groupId } = await createGroupWithPrompt('Save contract'); + const { prompt } = await methods.savePrompt({ + prompt: { prompt: 'second', type: 'chat', groupId }, + author: String(testUsers.owner._id), + }); + expect(prompt).not.toBeInstanceOf(mongoose.Document); + expect(String(prompt.groupId)).toBe(groupId); + + jest.spyOn(Prompt, 'create').mockImplementationOnce(() => { + throw dbError; + }); + await expect( + methods.savePrompt({ + prompt: { prompt: 'third', type: 'text', groupId }, + author: String(testUsers.owner._id), + }), + ).rejects.toBe(dbError); + }); + + it('keeps the index repair retry when saving a revision', async () => { + const { groupId } = await createGroupWithPrompt('Index repair'); + const dropIndex = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(Prompt.db, 'collection') + .mockReturnValueOnce({ dropIndex } as unknown as ReturnType); + const create = jest.spyOn(Prompt, 'create'); + create.mockImplementationOnce(() => { + throw new Error('E11000 duplicate key error index: groupId_1_version_1'); + }); + + const { prompt } = await methods.savePrompt({ + prompt: { prompt: 'retried', type: 'text', groupId }, + author: String(testUsers.owner._id), + }); + + expect(dropIndex).toHaveBeenCalledWith('groupId_1_version_1'); + expect(create).toHaveBeenCalledTimes(2); + expect(prompt.prompt).toBe('retried'); + }); + + it('returns the updated group as plain data and throws update failures', async () => { + const { groupId } = await createGroupWithPrompt('Update contract'); + const updated = await methods.updatePromptGroup({ _id: groupId }, { name: 'Renamed' }); + expect(updated).not.toBeInstanceOf(mongoose.Document); + expect(updated.name).toBe('Renamed'); + + await expect( + methods.updatePromptGroup({ _id: new ObjectId().toString() }, { name: 'Missing' }), + ).rejects.toThrow('Prompt group not found'); + + jest.spyOn(PromptGroup, 'findOneAndUpdate').mockImplementationOnce(() => { + throw dbError; + }); + await expect(methods.updatePromptGroup({ _id: groupId }, { name: 'x' })).rejects.toBe(dbError); + }); + + it('distinguishes a missing promotion revision from a database failure', async () => { + const { groupId, promptId } = await createGroupWithPrompt('Promotion contract'); + const { prompt } = await methods.savePrompt({ + prompt: { prompt: 'next', type: 'text', groupId }, + author: String(testUsers.owner._id), + }); + + await expect(methods.makePromptProduction(String(prompt._id))).resolves.toEqual({ + message: 'Prompt production made successfully', + }); + const group = (await methods.getPromptGroup({ _id: groupId })) as { productionId: unknown }; + expect(String(group.productionId)).toBe(String(prompt._id)); + + await expect(methods.makePromptProduction(new ObjectId().toString())).rejects.toBeInstanceOf( + PromptNotFoundError, + ); + + jest.spyOn(Prompt, 'findById').mockImplementationOnce(() => { + throw dbError; + }); + await expect(methods.makePromptProduction(promptId)).rejects.toBe(dbError); + }); + + it('filters the access listing by plain name and category inputs', async () => { + const first = await createGroupWithPrompt('Listing Alpha (v1)', 'writing'); + const second = await createGroupWithPrompt('Listing Beta', ''); + const accessibleIds = [first.groupId, second.groupId]; + + const byName = await methods.getListPromptGroupsByAccess({ + accessibleIds, + name: 'alpha (v1', + }); + expect(byName.data.map((group) => String(group._id))).toEqual([first.groupId]); + + const uncategorized = await methods.getListPromptGroupsByAccess({ + accessibleIds, + category: '', + }); + expect(uncategorized.data.map((group) => String(group._id))).toEqual([second.groupId]); + + const all = await methods.getListPromptGroupsByAccess({ accessibleIds }); + expect(all.data).toHaveLength(2); + }); +}); diff --git a/packages/data-schemas/src/methods/prompt.ts b/packages/data-schemas/src/methods/prompt.ts index 40cad9c6b7c..459c1245d39 100644 --- a/packages/data-schemas/src/methods/prompt.ts +++ b/packages/data-schemas/src/methods/prompt.ts @@ -7,7 +7,14 @@ import { Time, } from 'librechat-data-provider'; import type { Model, Types } from 'mongoose'; -import type { IAclEntry, CacheStore, IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types'; +import type { + IAclEntry, + CacheStore, + IPrompt, + IPromptGroup, + IPromptRecord, + IPromptGroupDocument, +} from '~/types'; import { getTenantId, scopedCacheKey, SYSTEM_TENANT_ID } from '~/config/tenantContext'; import { isValidObjectIdString } from '~/utils/objectId'; import { escapeRegExp } from '~/utils/string'; @@ -91,6 +98,26 @@ async function readAccessGeneration(cache: CacheStore): Promise; + name?: string; + category?: string; + limit?: number | string | null; + after?: string | null; +} + +/** Thrown when a revision selected for promotion does not exist. */ +export class PromptNotFoundError extends Error { + constructor() { + super('Prompt not found'); + this.name = 'PromptNotFoundError'; + } +} + export interface PromptMethods { getPromptGroups(filter: Record): Promise< | { @@ -105,12 +132,7 @@ export interface PromptMethods { getAllPromptGroups( filter: Record, ): Promise[] | { message: string }>; - getListPromptGroupsByAccess(params: { - accessibleIds?: Types.ObjectId[]; - otherParams?: Record; - limit?: number | null; - after?: string | null; - }): Promise<{ + getListPromptGroupsByAccess(params: PromptGroupListParams): Promise<{ object: 'list'; data: Record[]; first_id: string | null; @@ -128,13 +150,9 @@ export interface PromptMethods { savePrompt(saveData: { prompt: Record; author: string | Types.ObjectId; - }): Promise<{ prompt: IPrompt } | { message: string }>; - getPrompts( - filter: Record, - ): Promise[] | { message: string }>; - getPrompt( - filter: Record, - ): Promise | null | { message: string }>; + }): Promise<{ prompt: IPromptRecord }>; + getPrompts(filter: Record): Promise; + getPrompt(filter: Record): Promise; getRandomPromptGroups(filter: { skip: number | string; limit: number | string; @@ -158,7 +176,7 @@ export interface PromptMethods { updatePromptGroup( filter: Record, data: Record, - ): Promise; + ): Promise; makePromptProduction(promptId: string): Promise<{ message: string }>; updatePromptLabels(_id: string, labels: unknown): Promise<{ message: string }>; } @@ -365,15 +383,11 @@ export function createPromptMethods( */ async function getListPromptGroupsByAccess({ accessibleIds = [], - otherParams = {}, + name, + category, limit = null, after = null, - }: { - accessibleIds?: Types.ObjectId[]; - otherParams?: Record; - limit?: number | null; - after?: string | null; - }): Promise<{ + }: PromptGroupListParams): Promise<{ object: 'list'; data: Record[]; first_id: string | null; @@ -388,9 +402,14 @@ export function createPromptMethods( : null; const baseQuery: Record = { - ...otherParams, _id: { $in: accessibleIds }, }; + if (name) { + baseQuery.name = new RegExp(escapeRegExp(name), 'i'); + } + if (category != null) { + baseQuery.category = category; + } let matchQuery: Record = baseQuery; @@ -569,58 +588,42 @@ export function createPromptMethods( async function savePrompt(saveData: { prompt: Record; author: string | Types.ObjectId; - }) { - try { - const Prompt = mongoose.models.Prompt as Model; - const { prompt, author } = saveData; - const newPromptData = { ...prompt, author }; + }): Promise<{ prompt: IPromptRecord }> { + const Prompt = mongoose.models.Prompt as Model; + const { prompt, author } = saveData; + const newPromptData = { ...prompt, author }; - let newPrompt; - try { - newPrompt = await Prompt.create(newPromptData); - } catch (error: unknown) { - if ((error as Error)?.message?.includes('groupId_1_version_1')) { - await Prompt.db.collection('prompts').dropIndex('groupId_1_version_1'); - } else { - throw error; - } - newPrompt = await Prompt.create(newPromptData); + let newPrompt; + try { + newPrompt = await Prompt.create(newPromptData); + } catch (error: unknown) { + if (!(error as Error)?.message?.includes('groupId_1_version_1')) { + throw error; } - - return { prompt: newPrompt }; - } catch (error) { - logger.error('Error saving prompt', error); - return { message: 'Error saving prompt' }; + await Prompt.db.collection('prompts').dropIndex('groupId_1_version_1'); + newPrompt = await Prompt.create(newPromptData); } + + return { prompt: newPrompt.toObject() }; } /** * Get prompts by filter. */ - async function getPrompts(filter: Record) { - try { - const Prompt = mongoose.models.Prompt as Model; - return await Prompt.find(filter).sort({ createdAt: -1 }).lean(); - } catch (error) { - logger.error('Error getting prompts', error); - return { message: 'Error getting prompts' }; - } + async function getPrompts(filter: Record): Promise { + const Prompt = mongoose.models.Prompt as Model; + return await Prompt.find(filter).sort({ createdAt: -1 }).lean(); } /** * Get a single prompt by filter. */ - async function getPrompt(filter: Record) { - try { - const Prompt = mongoose.models.Prompt as Model; - if (filter.groupId) { - filter.groupId = new ObjectId(filter.groupId as string); - } - return await Prompt.findOne(filter).lean(); - } catch (error) { - logger.error('Error getting prompt', error); - return { message: 'Error getting prompt' }; + async function getPrompt(filter: Record): Promise { + const Prompt = mongoose.models.Prompt as Model; + if (filter.groupId) { + filter.groupId = new ObjectId(filter.groupId as string); } + return await Prompt.findOne(filter).lean(); } /** @@ -687,44 +690,39 @@ export function createPromptMethods( * Get a single prompt group by filter, with productionPrompt populated via $lookup. */ async function getPromptGroup(filter: Record) { - try { - const PromptGroup = mongoose.models.PromptGroup as Model; - // Cast string _id to ObjectId for aggregation (findOne auto-casts, aggregate does not) - const matchFilter = { ...filter }; - if (typeof matchFilter._id === 'string') { - matchFilter._id = new ObjectId(matchFilter._id); - } - const tenantId = getTenantId(); - const useTenantFilter = tenantId && tenantId !== SYSTEM_TENANT_ID; - - const result = await PromptGroup.aggregate([ - { $match: matchFilter }, - { - $lookup: { - from: 'prompts', - localField: 'productionId', - foreignField: '_id', - as: 'productionPrompt', - }, + const PromptGroup = mongoose.models.PromptGroup as Model; + // Cast string _id to ObjectId for aggregation (findOne auto-casts, aggregate does not) + const matchFilter = { ...filter }; + if (typeof matchFilter._id === 'string') { + matchFilter._id = new ObjectId(matchFilter._id); + } + const tenantId = getTenantId(); + const useTenantFilter = tenantId && tenantId !== SYSTEM_TENANT_ID; + + const result = await PromptGroup.aggregate([ + { $match: matchFilter }, + { + $lookup: { + from: 'prompts', + localField: 'productionId', + foreignField: '_id', + as: 'productionPrompt', }, - { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } }, - ]); - const group = result[0] || null; - if ( - group?.productionPrompt && - useTenantFilter && - group.productionPrompt.tenantId !== tenantId - ) { - group.productionPrompt = null; - } - if (group?.author) { - group.author = group.author.toString(); - } - return group; - } catch (error) { - logger.error('Error getting prompt group', error); - return null; + }, + { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } }, + ]); + const group = result[0] || null; + if ( + group?.productionPrompt && + useTenantFilter && + group.productionPrompt.tenantId !== tenantId + ) { + group.productionPrompt = null; + } + if (group?.author) { + group.author = group.author.toString(); } + return group; } /** @@ -1077,54 +1075,41 @@ export function createPromptMethods( /** * Update a prompt group. */ - async function updatePromptGroup(filter: Record, data: Record) { - try { - const PromptGroup = mongoose.models.PromptGroup as Model; - const updateOps = {}; - const updateData = { ...data, ...updateOps }; - const updatedDoc = await PromptGroup.findOneAndUpdate(filter, updateData, { - new: true, - upsert: false, - }); - - if (!updatedDoc) { - throw new Error('Prompt group not found'); - } + async function updatePromptGroup( + filter: Record, + data: Record, + ): Promise { + const PromptGroup = mongoose.models.PromptGroup as Model; + const updatedDoc = await PromptGroup.findOneAndUpdate(filter, data, { + new: true, + upsert: false, + }).lean(); - return updatedDoc; - } catch (error) { - logger.error('Error updating prompt group', error); - return { message: 'Error updating prompt group' }; + if (!updatedDoc) { + throw new Error('Prompt group not found'); } + + return updatedDoc as unknown as IPromptGroup; } /** * Make a prompt the production prompt for its group. */ - async function makePromptProduction(promptId: string) { - try { - const Prompt = mongoose.models.Prompt as Model; - const PromptGroup = mongoose.models.PromptGroup as Model; + async function makePromptProduction(promptId: string): Promise<{ message: string }> { + const Prompt = mongoose.models.Prompt as Model; + const PromptGroup = mongoose.models.PromptGroup as Model; - const prompt = await Prompt.findById(promptId).lean(); + const prompt = await Prompt.findById(promptId).lean(); - if (!prompt) { - throw new Error('Prompt not found'); - } + if (!prompt) { + throw new PromptNotFoundError(); + } - await PromptGroup.findByIdAndUpdate( - prompt.groupId, - { productionId: prompt._id }, - { new: true }, - ) - .lean() - .exec(); + await PromptGroup.findByIdAndUpdate(prompt.groupId, { productionId: prompt._id }, { new: true }) + .lean() + .exec(); - return { message: 'Prompt production made successfully' }; - } catch (error) { - logger.error('Error making prompt production', error); - return { message: 'Error making prompt production' }; - } + return { message: 'Prompt production made successfully' }; } /** diff --git a/packages/data-schemas/src/types/prompts.ts b/packages/data-schemas/src/types/prompts.ts index 02db35a1be8..24150ba27fd 100644 --- a/packages/data-schemas/src/types/prompts.ts +++ b/packages/data-schemas/src/types/prompts.ts @@ -10,6 +10,12 @@ export interface IPrompt extends Document { tenantId?: string; } +/** A stored prompt revision as plain data, without Mongoose document methods. */ +export type IPromptRecord = Pick< + IPrompt, + 'groupId' | 'author' | 'prompt' | 'type' | 'createdAt' | 'updatedAt' | 'tenantId' +> & { _id: Types.ObjectId; __v?: number }; + export interface IPromptGroup { name: string; numberOfGenerations: number;