diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index e3ab5bf5d3..499c4cc9ea 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -15,6 +15,7 @@ const { makePromptProduction, } = require('~/models/Prompt'); const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); +const { getUserById, updateUser } = require('~/models'); const { logger } = require('~/config'); const router = express.Router(); @@ -173,6 +174,28 @@ router.patch('/:promptId/tags/production', checkPromptCreate, async (req, res) = } }); +/** + * Route to get user's prompt preferences (favorites and rankings) + * GET /preferences + */ +router.get('/preferences', async (req, res) => { + try { + const user = await getUserById(req.user.id); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + res.json({ + favorites: user.promptFavorites || [], + rankings: user.promptRanking || [], + }); + } catch (error) { + logger.error('Error getting user preferences', error); + res.status(500).json({ message: 'Error getting user preferences' }); + } +}); + router.get('/:promptId', async (req, res) => { const { promptId } = req.params; const author = req.user.id; @@ -243,4 +266,79 @@ const deletePromptGroupController = async (req, res) => { router.delete('/:promptId', checkPromptCreate, deletePromptController); router.delete('/groups/:groupId', checkPromptCreate, deletePromptGroupController); +/** + * Route to toggle favorite status for a prompt group + * POST /favorites/:groupId + */ +router.post('/favorites/:groupId', async (req, res) => { + try { + const { groupId } = req.params; + + const user = await getUserById(req.user.id); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const favorites = user.promptFavorites || []; + const isFavorite = favorites.some((id) => id.toString() === groupId.toString()); + + let updatedFavorites; + if (isFavorite) { + updatedFavorites = favorites.filter((id) => id.toString() !== groupId.toString()); + } else { + updatedFavorites = [...favorites, groupId]; + } + + await updateUser(req.user.id, { promptFavorites: updatedFavorites }); + + const response = { + promptGroupId: groupId, + isFavorite: !isFavorite, + }; + + res.json(response); + } catch (error) { + logger.error('Error toggling favorite status', error); + res.status(500).json({ message: 'Error updating favorite status' }); + } +}); + +/** + * Route to update prompt group rankings + * PUT /rankings + */ +router.put('/rankings', async (req, res) => { + try { + const { rankings } = req.body; + + if (!Array.isArray(rankings)) { + return res.status(400).json({ message: 'Rankings must be an array' }); + } + + const user = await getUserById(req.user.id); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const promptRanking = rankings + .filter(({ promptGroupId, order }) => promptGroupId && !isNaN(parseInt(order, 10))) + .map(({ promptGroupId, order }) => ({ + promptGroupId, + order: parseInt(order, 10), + })); + + const updatedUser = await updateUser(req.user.id, { promptRanking }); + + res.json({ + message: 'Rankings updated successfully', + rankings: updatedUser?.promptRanking || [], + }); + } catch (error) { + logger.error('Error updating rankings', error); + res.status(500).json({ message: 'Error updating rankings' }); + } +}); + module.exports = router; diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx index f8818b9f68..f6f06e8423 100644 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ b/client/src/components/Prompts/Groups/ChatGroupItem.tsx @@ -10,6 +10,7 @@ import { } from '~/components/ui'; import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } from '~/hooks'; import VariableDialog from '~/components/Prompts/Groups/VariableDialog'; +import FavoriteButton from '~/components/Prompts/Groups/FavoriteButton'; import PreviewPrompt from '~/components/Prompts/PreviewPrompt'; import ListCard from '~/components/Prompts/Groups/ListCard'; import { detectVariables } from '~/utils'; @@ -64,6 +65,7 @@ function ChatGroupItem({ {groupIsGlobal === true && ( )} + + ); +} + +export default memo(FavoriteButton); diff --git a/client/src/components/Prompts/Groups/List.tsx b/client/src/components/Prompts/Groups/List.tsx index 6a7feae40e..dc8806dafe 100644 --- a/client/src/components/Prompts/Groups/List.tsx +++ b/client/src/components/Prompts/Groups/List.tsx @@ -2,6 +2,7 @@ import { Plus } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider'; +import { RankablePromptList, RankingProvider } from '~/components/Prompts/Groups/RankingComponent'; import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem'; import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem'; import { useGetStartupConfig } from '~/data-provider'; @@ -12,10 +13,12 @@ export default function List({ groups = [], isChatRoute, isLoading, + enableRanking = true, }: { groups?: TPromptGroup[]; isChatRoute: boolean; isLoading: boolean; + enableRanking?: boolean; }) { const navigate = useNavigate(); const localize = useLocalize(); @@ -26,56 +29,59 @@ export default function List({ permission: Permissions.CREATE, }); + const renderGroupItem = (group: TPromptGroup) => { + if (isChatRoute) { + return ; + } + return ; + }; + return ( -
- {hasCreateAccess && ( -
- -
- )} -
-
- {isLoading && isChatRoute && ( - - )} - {isLoading && - !isChatRoute && - Array.from({ length: 10 }).map((_, index: number) => ( - - ))} - {!isLoading && groups.length === 0 && isChatRoute && ( -
- {localize('com_ui_nothing_found')} -
- )} - {!isLoading && groups.length === 0 && !isChatRoute && ( -
- {localize('com_ui_nothing_found')} -
- )} - {groups.map((group) => { - if (isChatRoute) { - return ( - +
+ {hasCreateAccess && ( +
+ +
+ )} +
+
+ {isLoading && isChatRoute && ( + + )} + {isLoading && + !isChatRoute && + Array.from({ length: 10 }).map((_, index: number) => ( + - ); - } - return ( - - ); - })} + ))} + {!isLoading && groups.length === 0 && isChatRoute && ( +
+ {localize('com_ui_nothing_found')} +
+ )} + {!isLoading && groups.length === 0 && !isChatRoute && ( +
+ {localize('com_ui_nothing_found')} +
+ )} + {!isLoading && groups.length > 0 && enableRanking ? ( + + ) : ( + !isLoading && groups.map((group) => renderGroupItem(group)) + )} +
-
+ ); } diff --git a/client/src/components/Prompts/Groups/RankingComponent.tsx b/client/src/components/Prompts/Groups/RankingComponent.tsx new file mode 100644 index 0000000000..abb2f99287 --- /dev/null +++ b/client/src/components/Prompts/Groups/RankingComponent.tsx @@ -0,0 +1,303 @@ +import React, { useCallback, useEffect, useState, useRef, ReactNode } from 'react'; +import { DndProvider, useDrag, useDrop, useDragLayer } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { GripVertical } from 'lucide-react'; +import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider'; +import type { TPromptGroup } from 'librechat-data-provider'; +import { cn } from '~/utils'; + +const ITEM_TYPE = 'PROMPT_GROUP'; + +interface DraggablePromptItemProps { + group: TPromptGroup; + index: number; + moveItem: (dragIndex: number, hoverIndex: number) => void; + isDragging: boolean; + children: ReactNode; +} + +interface DragItem { + index: number; + id: string; + type: string; +} + +function DraggablePromptItem({ + group, + index, + moveItem, + isDragging: isAnyDragging, + children, +}: DraggablePromptItemProps) { + const ref = useRef(null); + const [isOver, setIsOver] = useState(false); + + const [{ isDragging }, drag, preview] = useDrag({ + type: ITEM_TYPE, + item: () => ({ type: ITEM_TYPE, index, id: group._id }), + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [{ isOverCurrent }, drop] = useDrop({ + accept: ITEM_TYPE, + hover: (item, monitor) => { + if (!ref.current) return; + + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) return; + + const hoverBoundingRect = ref.current.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY * 0.8) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY * 1.2) return; + + moveItem(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + collect: (monitor) => ({ + isOverCurrent: monitor.isOver({ shallow: true }), + }), + }); + + useEffect(() => { + setIsOver(isOverCurrent); + }, [isOverCurrent]); + + drag(drop(ref)); + + useEffect(() => { + preview(new Image(), { captureDraggingState: false }); + }, [preview]); + + return ( +
+
+ +
+
{children}
+
+ ); +} + +function CustomDragLayer() { + const { item, itemType, currentOffset, isDragging } = useDragLayer((monitor) => ({ + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset(), + isDragging: monitor.isDragging(), + })); + + if (!isDragging || !currentOffset || itemType !== ITEM_TYPE) { + return null; + } + + const renderPreview = () => { + if (item && typeof item.id === 'string') { + return ( +
+ {`Moving: ${item.id}`} +
+ ); + } + return
Dragging...
; + }; + + return ( +
+
+ {renderPreview()} +
+
+ ); +} + +interface RankablePromptListProps { + groups: TPromptGroup[]; + renderItem: (group: TPromptGroup) => ReactNode; + onRankingChange?: (rankings: string[]) => void; +} + +function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePromptListProps) { + const { data: preferences } = useGetUserPromptPreferences(); + const updateRankings = useUpdatePromptRankings(); + + const [sortedGroups, setSortedGroups] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const saveTimeoutRef = useRef(null); + + useEffect(() => { + if (!groups?.length) { + setSortedGroups([]); + return; + } + const rankings = preferences?.rankings || []; + const favorites = preferences?.favorites || []; + const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order])); + + const sorted = [...groups].sort((a, b) => { + const aId = a._id ?? ''; + const bId = b._id ?? ''; + const aIsFavorite = favorites.includes(aId); + const bIsFavorite = favorites.includes(bId); + + if (aIsFavorite && !bIsFavorite) return -1; + if (!aIsFavorite && bIsFavorite) return 1; + + const aRank = rankingMap.get(aId); + const bRank = rankingMap.get(bId); + if (aRank !== undefined && bRank !== undefined) { + return aRank - bRank; + } + if (aRank !== undefined) return -1; + if (bRank !== undefined) return 1; + + return a.name.localeCompare(b.name); + }); + + setSortedGroups(sorted); + }, [groups, preferences]); + + const moveItem = useCallback( + (dragIndex: number, hoverIndex: number) => { + if (dragIndex === hoverIndex) return; + setSortedGroups((prevGroups) => { + const newGroups = [...prevGroups]; + const draggedItem = newGroups[dragIndex]; + newGroups.splice(dragIndex, 1); + newGroups.splice(hoverIndex, 0, draggedItem); + return newGroups; + }); + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => { + setSortedGroups((currentGroups) => { + const newRankings = currentGroups + .map((group, index) => + typeof group._id === 'string' ? { promptGroupId: group._id, order: index } : null, + ) + .filter( + (ranking): ranking is { promptGroupId: string; order: number } => ranking !== null, + ); + + if (newRankings.length > 0) { + updateRankings + .mutateAsync({ rankings: newRankings }) + .then(() => { + onRankingChange?.(newRankings.map((r) => r.promptGroupId)); + }) + .catch((error) => { + console.error('Failed to update rankings:', error); + }); + } + return currentGroups; + }); + }, 500); + }, + [updateRankings, onRankingChange], + ); + + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + const handleDragStart = () => setIsDragging(true); + const handleDragEnd = () => setIsDragging(false); + + document.addEventListener('dragstart', handleDragStart); + document.addEventListener('dragend', handleDragEnd); + + return () => { + document.removeEventListener('dragstart', handleDragStart); + document.removeEventListener('dragend', handleDragEnd); + }; + }, []); + + return ( +
+ {sortedGroups.map((group, index) => ( +
+ + {renderItem(group)} + +
+ ))} +
+ ); +} + +function RankingProvider({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} + +export { RankablePromptList, RankingProvider }; diff --git a/client/src/components/svg/StarIcon.tsx b/client/src/components/svg/StarIcon.tsx new file mode 100644 index 0000000000..44f9aab258 --- /dev/null +++ b/client/src/components/svg/StarIcon.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +interface StarIconProps { + className?: string; + size?: string | number; + filled?: boolean; +} + +export default function StarIcon({ className = '', size = '1em', filled = false }: StarIconProps) { + return filled ? ( + + + + ) : ( + + + + ); +} diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index 10068a1d47..d4eec4206c 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -60,4 +60,5 @@ export { default as CircleHelpIcon } from './CircleHelpIcon'; export { default as BedrockIcon } from './BedrockIcon'; export { default as ThumbUpIcon } from './ThumbUpIcon'; export { default as ThumbDownIcon } from './ThumbDownIcon'; +export { default as StarIcon } from './StarIcon'; export { default as XAIcon } from './XAIcon'; diff --git a/client/src/data-provider/prompts.ts b/client/src/data-provider/prompts.ts index 3f6e4bb89a..61e9749548 100644 --- a/client/src/data-provider/prompts.ts +++ b/client/src/data-provider/prompts.ts @@ -1,5 +1,5 @@ import { useRecoilValue } from 'recoil'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { dataService, QueryKeys } from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; @@ -327,3 +327,131 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions) }, }); }; + +/* Prompt Favorites and Rankings */ +export const useTogglePromptFavorite = ( + options?: t.UpdatePromptGroupOptions, +): UseMutationResult => { + const { onMutate, onError, onSuccess } = options || {}; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (variables: { groupId: string }) => + dataService.togglePromptFavorite(variables.groupId), + onMutate: async (variables: { groupId: string }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: [QueryKeys.promptGroups] }); + await queryClient.cancelQueries({ queryKey: [QueryKeys.userPromptPreferences] }); + + // Snapshot the previous values + const previousPreferences = queryClient.getQueryData([ + QueryKeys.userPromptPreferences, + ]); + + // Optimistically update the favorites + if (previousPreferences) { + const isFavorite = previousPreferences.favorites.includes(variables.groupId); + const newFavorites = isFavorite + ? previousPreferences.favorites.filter((id) => id !== variables.groupId) + : [...previousPreferences.favorites, variables.groupId]; + + queryClient.setQueryData( + [QueryKeys.userPromptPreferences], + { + ...previousPreferences, + favorites: newFavorites, + }, + ); + } + + if (onMutate) { + return onMutate(variables); + } + + return { previousPreferences }; + }, + onError: (err, variables, context) => { + // Revert optimistic update on error + if (context?.previousPreferences) { + queryClient.setQueryData([QueryKeys.userPromptPreferences], context.previousPreferences); + } + if (onError) { + onError(err, variables, context); + } + }, + onSuccess: (response, variables, context) => { + // Invalidate and refetch related queries + queryClient.invalidateQueries({ queryKey: [QueryKeys.userPromptPreferences] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.promptGroups] }); + + if (onSuccess) { + onSuccess(response, variables, context); + } + }, + }); +}; + +export const useUpdatePromptRankings = ( + options?: t.UpdatePromptGroupOptions, +): UseMutationResult => { + const { onMutate, onError, onSuccess } = options || {}; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (variables: t.TPromptRankingRequest) => dataService.updatePromptRankings(variables), + onMutate: async (variables: t.TPromptRankingRequest) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: [QueryKeys.userPromptPreferences] }); + + // Snapshot the previous values + const previousPreferences = queryClient.getQueryData([ + QueryKeys.userPromptPreferences, + ]); + + // Optimistically update the rankings + if (previousPreferences) { + queryClient.setQueryData( + [QueryKeys.userPromptPreferences], + { + ...previousPreferences, + rankings: variables.rankings, + }, + ); + } + + if (onMutate) { + return onMutate(variables); + } + + return { previousPreferences }; + }, + onError: (err, variables, context) => { + // Revert optimistic update on error + if (context?.previousPreferences) { + queryClient.setQueryData([QueryKeys.userPromptPreferences], context.previousPreferences); + } + if (onError) { + onError(err, variables, context); + } + }, + onSuccess: (response, variables, context) => { + // Don't automatically invalidate queries to prevent infinite loops + // The optimistic update in onMutate handles the UI update + // Manual invalidation can be done by components when needed + + if (onSuccess) { + onSuccess(response, variables, context); + } + }, + }); +}; + +export const useGetUserPromptPreferences = () => { + return useQuery({ + queryKey: [QueryKeys.userPromptPreferences], + queryFn: () => dataService.getUserPromptPreferences(), + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, // Prevent refetch on window focus + refetchOnMount: false, // Prevent refetch on component mount + }); +}; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b4c1bd5791..bb05c6ab70 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -968,5 +968,7 @@ "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", "com_user_message": "You", - "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint." -} \ No newline at end of file + "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.", + "com_ui_add_to_favorites": "Add to favorites", + "com_ui_remove_from_favorites": "Remove from favorites" +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 63a00acef1..dc010d4bfd 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -250,6 +250,13 @@ export const getCategories = () => '/api/categories'; export const getAllPromptGroups = () => `${prompts()}/all`; +/* Prompt Favorites and Rankings */ +export const togglePromptFavorite = (groupId: string) => `${prompts()}/favorites/${groupId}`; + +export const updatePromptRankings = () => `${prompts()}/rankings`; + +export const getUserPromptPreferences = () => `${prompts()}/preferences`; + /* Roles */ export const roles = () => '/api/roles'; export const getRole = (roleName: string) => `${roles()}/${roleName.toLowerCase()}`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 036d7928c7..94957a8af8 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -701,6 +701,21 @@ export function getRandomPrompts( return request.get(endpoints.getRandomPrompts(variables.limit, variables.skip)); } +/* Prompt Favorites and Rankings */ +export function togglePromptFavorite(groupId: string): Promise { + return request.post(endpoints.togglePromptFavorite(groupId)); +} + +export function updatePromptRankings( + variables: t.TPromptRankingRequest, +): Promise { + return request.put(endpoints.updatePromptRankings(), variables); +} + +export function getUserPromptPreferences(): Promise { + return request.get(endpoints.getUserPromptPreferences()); +} + /* Roles */ export function getRole(roleName: string): Promise { return request.get(endpoints.getRole(roleName)); diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index f9e51dc094..f3c18d6eb5 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -39,6 +39,7 @@ export enum QueryKeys { promptGroups = 'promptGroups', allPromptGroups = 'allPromptGroups', promptGroup = 'promptGroup', + userPromptPreferences = 'userPromptPreferences', categories = 'categories', randomPrompts = 'randomPrompts', roles = 'roles', diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 706d2a2e8b..121d6ac44f 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -537,6 +537,39 @@ export type TGetRandomPromptsRequest = { skip: number; }; +/** Prompt favorites and ranking types */ +export type TPromptFavoriteRequest = { + promptGroupId: string; +}; + +export type TPromptFavoriteResponse = { + promptGroupId: string; + isFavorite: boolean; +}; + +export type TPromptRankingRequest = { + rankings: Array<{ + promptGroupId: string; + order: number; + }>; +}; + +export type TPromptRankingResponse = { + message: string; + rankings: Array<{ + promptGroupId: string; + order: number; + }>; +}; + +export type TGetUserPromptPreferencesResponse = { + favorites: string[]; + rankings: Array<{ + promptGroupId: string; + order: number; + }>; +}; + export type TCustomConfigSpeechResponse = { [key: string]: string }; export type TUserTermsResponse = { @@ -557,7 +590,7 @@ export type TUpdateFeedbackResponse = { messageId: string; conversationId: string; feedback?: TMinimalFeedback; -} +}; export type TBalanceResponse = { tokenCredits: number; diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 6107488ee8..62e4ded4a1 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -129,6 +129,28 @@ const userSchema = new Schema( type: Boolean, default: false, }, + promptFavorites: { + type: [Schema.Types.ObjectId], + ref: 'PromptGroup', + default: [], + }, + promptRanking: { + type: [ + { + promptGroupId: { + type: Schema.Types.ObjectId, + ref: 'PromptGroup', + required: true, + }, + order: { + type: Number, + required: true, + }, + }, + ], + default: [], + _id: false, + }, }, { timestamps: true }, ); diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index 206d051819..e1b7ecf384 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -30,6 +30,11 @@ export interface IUser extends Document { }>; expiresAt?: Date; termsAccepted?: boolean; + promptFavorites?: Types.ObjectId[]; + promptRanking?: Array<{ + promptGroupId: Types.ObjectId; + order: number; + }>; createdAt?: Date; updatedAt?: Date; }