From 88a5a003f4af9452bf3f5807baf1235830017c58 Mon Sep 17 00:00:00 2001 From: Sandeep Sarkar <152861071+sandy4242@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:03:00 +0530 Subject: [PATCH 1/4] Migrate GraphQL enums and relationships to TypeScript --- .../__tests__/unit/graphql-types.test.ts | 10 ++ quotevote-backend/app/data/types/Activity.ts | 34 +++++-- quotevote-backend/app/data/types/Comment.ts | 13 ++- quotevote-backend/app/data/types/Group.ts | 23 ++++- quotevote-backend/app/data/types/Message.ts | 28 +++++- .../app/data/types/MessageRoom.ts | 25 ++++- .../app/data/types/Notification.ts | 20 +++- quotevote-backend/app/data/types/Post.ts | 22 ++++- quotevote-backend/app/data/types/Presence.ts | 7 +- quotevote-backend/app/data/types/Quote.ts | 13 ++- quotevote-backend/app/data/types/Reaction.ts | 13 +++ quotevote-backend/app/data/types/Roster.ts | 17 +++- .../app/data/types/TypingIndicator.ts | 13 ++- quotevote-backend/app/data/types/User.ts | 44 ++++++++- .../app/data/types/UserInvite.ts | 3 +- .../app/data/types/UserReputation.ts | 18 +++- quotevote-backend/app/data/types/Vote.ts | 16 +++- quotevote-backend/app/data/types/enums.ts | 90 +++++++++++++++--- quotevote-backend/app/data/types/index.ts | 25 ++++- quotevote-backend/app/data/utils/constants.ts | 91 +++++++++++++++++++ quotevote-backend/app/types/common.ts | 2 +- 21 files changed, 480 insertions(+), 47 deletions(-) diff --git a/quotevote-backend/__tests__/unit/graphql-types.test.ts b/quotevote-backend/__tests__/unit/graphql-types.test.ts index f82cd312..2bfc4891 100644 --- a/quotevote-backend/__tests__/unit/graphql-types.test.ts +++ b/quotevote-backend/__tests__/unit/graphql-types.test.ts @@ -194,6 +194,16 @@ describe('GraphQL domain typedefs (7.28 migration)', () => { 'TypingResponse', 'PresenceStatus', 'RosterStatus', + 'AccountStatus', + 'VoteType', + 'MessageType', + 'NotificationType', + 'ActivityEventType', + 'GroupPrivacy', + 'InviteStatus', + 'ReportStatus', + 'ReportSeverity', + 'ReportReason', 'JSON', 'Date', ]) { diff --git a/quotevote-backend/app/data/types/Activity.ts b/quotevote-backend/app/data/types/Activity.ts index 53671725..9f888d14 100644 --- a/quotevote-backend/app/data/types/Activity.ts +++ b/quotevote-backend/app/data/types/Activity.ts @@ -7,6 +7,13 @@ import { PostType } from './Post'; import { VoteType } from './Vote'; import { QuoteType } from './Quote'; import { CommentType } from './Comment'; +import { ActivityEventTypeEnum } from './enums'; + +import User from '../models/User'; +import Post from '../models/Post'; +import Vote from '../models/Vote'; +import Quote from '../models/Quote'; +import Comment from '../models/Comment'; interface ActivityShape extends Common.Activity { post?: Common.Post; @@ -25,18 +32,33 @@ export const ActivityType: GraphQLObjectType = ne fields: (): GraphQLFieldConfigMap => ({ _id: { type: GraphQLString }, created: { type: DateScalar }, - activityType: { type: GraphQLString }, + activityType: { type: ActivityEventTypeEnum }, postId: { type: GraphQLString }, - post: { type: PostType }, + post: { + type: PostType, + resolve: (act) => act.post ?? (act.postId ? Post.findById(act.postId).lean() : null), + }, voteId: { type: GraphQLString }, - vote: { type: VoteType }, + vote: { + type: VoteType, + resolve: (act) => act.vote ?? (act.voteId ? Vote.findById(act.voteId).lean() : null), + }, quoteId: { type: GraphQLString }, - quote: { type: QuoteType }, + quote: { + type: QuoteType, + resolve: (act) => act.quote ?? (act.quoteId ? Quote.findById(act.quoteId).lean() : null), + }, commentId: { type: GraphQLString }, - comment: { type: CommentType }, + comment: { + type: CommentType, + resolve: (act) => act.comment ?? (act.commentId ? Comment.findById(act.commentId).lean() : null), + }, content: { type: GraphQLString }, userId: { type: GraphQLString }, - user: { type: UserType }, + user: { + type: UserType, + resolve: (act) => act.user ?? User.findById(act.userId).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/Comment.ts b/quotevote-backend/app/data/types/Comment.ts index 8c0605fb..e8206514 100644 --- a/quotevote-backend/app/data/types/Comment.ts +++ b/quotevote-backend/app/data/types/Comment.ts @@ -9,6 +9,10 @@ import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { DateScalar } from './scalars'; import { UserType } from './User'; +import { PostType } from './Post'; + +import User from '../models/User'; +import Post from '../models/Post'; export const CommentType: GraphQLObjectType = new GraphQLObjectType< Common.Comment, @@ -27,7 +31,14 @@ export const CommentType: GraphQLObjectType = ne url: { type: GraphQLString }, reaction: { type: GraphQLString }, deleted: { type: GraphQLBoolean }, - user: { type: UserType }, + user: { + type: UserType, + resolve: (comment) => (comment as any).user ?? User.findById(comment.userId).lean(), + }, + post: { + type: PostType, + resolve: (comment) => Post.findById(comment.postId).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/Group.ts b/quotevote-backend/app/data/types/Group.ts index b13b43fa..1b394434 100644 --- a/quotevote-backend/app/data/types/Group.ts +++ b/quotevote-backend/app/data/types/Group.ts @@ -8,6 +8,11 @@ import { import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { UserType } from './User'; +import { RosterType } from './Roster'; +import { GroupPrivacyEnum } from './enums'; + +import User from '../models/User'; +import Roster from '../models/Roster'; interface GroupShape extends Common.Group { pendingUsers?: Common.User[]; @@ -32,7 +37,7 @@ export const GroupType: GraphQLObjectType = new Grap resolve: (g) => g.description ?? '', }, url: { type: new GraphQLNonNull(GraphQLString), resolve: (g) => g.url ?? '' }, - privacy: { type: new GraphQLNonNull(GraphQLString) }, + privacy: { type: new GraphQLNonNull(GroupPrivacyEnum) }, allowedUserIds: { type: new GraphQLList(new GraphQLNonNull(GraphQLString)), resolve: (g) => g.allowedUserIds ?? [], @@ -45,6 +50,22 @@ export const GroupType: GraphQLObjectType = new Grap type: new GraphQLList(UserType), resolve: (g) => g.pendingUsers ?? [], }, + creator: { + type: UserType, + resolve: (group) => User.findById(group.creatorId).lean(), + }, + adminUsers: { + type: new GraphQLList(UserType), + resolve: (group) => User.find({ _id: { $in: group.adminIds ?? [] } }).lean(), + }, + allowedUsers: { + type: new GraphQLList(UserType), + resolve: (group) => User.find({ _id: { $in: group.allowedUserIds ?? [] } }).lean(), + }, + rosters: { + type: new GraphQLList(RosterType), + resolve: (group) => Roster.find({ userId: group.creatorId }).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/Message.ts b/quotevote-backend/app/data/types/Message.ts index 5f35b91d..ad959f28 100644 --- a/quotevote-backend/app/data/types/Message.ts +++ b/quotevote-backend/app/data/types/Message.ts @@ -11,6 +11,15 @@ import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { DateScalar } from './scalars'; import { UserType } from './User'; +import { MessageRoomType } from './MessageRoom'; +import { ReactionType } from './Reaction'; +import { PresenceType } from './Presence'; +import { MessageTypeEnum } from './enums'; + +import User from '../models/User'; +import MessageRoom from '../models/MessageRoom'; +import Reaction from '../models/Reaction'; +import Presence from '../models/Presence'; interface MessageShape extends Common.Message { userAvatar?: string; @@ -46,10 +55,25 @@ export const MessageType: GraphQLObjectType = new title: { type: GraphQLString }, text: { type: GraphQLString }, created: { type: DateScalar }, - type: { type: GraphQLString }, + type: { type: MessageTypeEnum }, mutation_type: { type: GraphQLString }, deleted: { type: GraphQLBoolean }, - user: { type: UserType }, + user: { + type: UserType, + resolve: (msg) => (msg as any).user ?? User.findById(msg.userId).lean(), + }, + messageRoom: { + type: MessageRoomType, + resolve: (msg) => MessageRoom.findById(msg.messageRoomId).lean(), + }, + reactions: { + type: new GraphQLList(ReactionType), + resolve: (msg) => Reaction.find({ messageId: msg._id }).lean(), + }, + presence: { + type: PresenceType, + resolve: (msg) => Presence.findOne({ userId: msg.userId }).lean(), + }, readBy: { type: new GraphQLList(GraphQLString), resolve: (m) => m.readBy ?? [] }, readByDetailed: { type: new GraphQLList(ReadByDetailedEntryType), diff --git a/quotevote-backend/app/data/types/MessageRoom.ts b/quotevote-backend/app/data/types/MessageRoom.ts index 193ef161..57783c6c 100644 --- a/quotevote-backend/app/data/types/MessageRoom.ts +++ b/quotevote-backend/app/data/types/MessageRoom.ts @@ -11,6 +11,15 @@ import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { DateScalar, JSONScalar } from './scalars'; import { MessageType } from './Message'; +import { UserType } from './User'; +import { TypingIndicatorType } from './TypingIndicator'; +import { PostType } from './Post'; +import { MessageTypeEnum } from './enums'; + +import User from '../models/User'; +import Message from '../models/Message'; +import Typing from '../models/Typing'; +import Post from '../models/Post'; interface PostDetailsShape { _id?: string; @@ -45,7 +54,7 @@ export const MessageRoomType: GraphQLObjectType => ({ _id: { type: new GraphQLNonNull(GraphQLID) }, users: { type: new GraphQLList(GraphQLString), resolve: (r) => r.users ?? [] }, - messageType: { type: GraphQLString }, + messageType: { type: MessageTypeEnum }, created: { type: DateScalar }, lastActivity: { type: DateScalar }, lastMessageTime: { type: DateScalar }, @@ -55,9 +64,21 @@ export const MessageRoomType: GraphQLObjectType r.messages ?? [], + resolve: (r) => r.messages ?? Message.find({ messageRoomId: r._id }).sort({ created: 1 }).lean(), }, postDetails: { type: PostDetailsType }, + usersData: { + type: new GraphQLList(UserType), + resolve: (r) => User.find({ _id: { $in: r.users ?? [] } }).lean(), + }, + typingIndicators: { + type: new GraphQLList(TypingIndicatorType), + resolve: (r) => Typing.find({ messageRoomId: r._id }).lean(), + }, + post: { + type: PostType, + resolve: (r) => Post.findById(r.postId).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/Notification.ts b/quotevote-backend/app/data/types/Notification.ts index bf350d4e..30befd11 100644 --- a/quotevote-backend/app/data/types/Notification.ts +++ b/quotevote-backend/app/data/types/Notification.ts @@ -4,6 +4,10 @@ import type * as Common from '~/types/common'; import { DateScalar } from './scalars'; import { UserType } from './User'; import { PostType } from './Post'; +import { NotificationTypeEnum } from './enums'; + +import User from '../models/User'; +import Post from '../models/Post'; interface NotificationShape extends Common.Notification { userBy?: Common.User; @@ -18,9 +22,19 @@ export const NotificationType: GraphQLObjectType notif.userBy ?? User.findById(notif.userIdBy).lean(), + }, + user: { + type: UserType, + resolve: (notif) => User.findById(notif.userId).lean(), + }, + post: { + type: PostType, + resolve: (notif) => notif.post ?? (notif.postId ? Post.findById(notif.postId).lean() : null), + }, + notificationType: { type: NotificationTypeEnum }, label: { type: GraphQLString }, status: { type: GraphQLString }, created: { type: DateScalar }, diff --git a/quotevote-backend/app/data/types/Post.ts b/quotevote-backend/app/data/types/Post.ts index 4a9df843..b684fbd2 100644 --- a/quotevote-backend/app/data/types/Post.ts +++ b/quotevote-backend/app/data/types/Post.ts @@ -15,6 +15,12 @@ import { VoteType } from './Vote'; import { QuoteType } from './Quote'; import { MessageRoomType } from './MessageRoom'; +import User from '../models/User'; +import Comment from '../models/Comment'; +import Vote from '../models/Vote'; +import Quote from '../models/Quote'; +import MessageRoom from '../models/MessageRoom'; + interface PostShape extends Common.Post { creator?: Common.User; comments?: Common.Comment[]; @@ -68,20 +74,26 @@ export const PostType: GraphQLObjectType = new GraphQ pointTimestamp: { type: GraphQLString }, featuredSlot: { type: GraphQLInt }, enable_voting: { type: GraphQLBoolean }, - creator: { type: UserType }, + creator: { + type: UserType, + resolve: (p) => p.creator ?? User.findById(p.userId).lean(), + }, comments: { type: new GraphQLList(CommentType), - resolve: (p) => p.comments ?? [], + resolve: (p) => p.comments ?? Comment.find({ postId: p._id }).lean(), }, votes: { type: new GraphQLList(VoteType), - resolve: (p) => p.votes ?? [], + resolve: (p) => p.votes ?? Vote.find({ postId: p._id }).lean(), }, quotes: { type: new GraphQLList(QuoteType), - resolve: (p) => p.quotes ?? [], + resolve: (p) => p.quotes ?? Quote.find({ postId: p._id }).lean(), + }, + messageRoom: { + type: MessageRoomType, + resolve: (p) => p.messageRoom ?? MessageRoom.findOne({ postId: p._id }).lean(), }, - messageRoom: { type: MessageRoomType }, }), }); diff --git a/quotevote-backend/app/data/types/Presence.ts b/quotevote-backend/app/data/types/Presence.ts index 4ab0ab27..6a860885 100644 --- a/quotevote-backend/app/data/types/Presence.ts +++ b/quotevote-backend/app/data/types/Presence.ts @@ -11,6 +11,8 @@ import { DateScalar } from './scalars'; import { PresenceStatusEnum } from './enums'; import { UserType } from './User'; +import User from '../models/User'; + interface PresenceShape extends Common.Presence { user?: Common.User; } @@ -28,7 +30,10 @@ export const PresenceType: GraphQLObjectType = ne statusMessage: { type: GraphQLString }, lastHeartbeat: { type: new GraphQLNonNull(DateScalar) }, lastSeen: { type: DateScalar }, - user: { type: UserType }, + user: { + type: UserType, + resolve: (presence) => presence.user ?? User.findById(presence.userId).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/Quote.ts b/quotevote-backend/app/data/types/Quote.ts index e6918d7f..121b4c93 100644 --- a/quotevote-backend/app/data/types/Quote.ts +++ b/quotevote-backend/app/data/types/Quote.ts @@ -10,6 +10,10 @@ import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { DateScalar } from './scalars'; import { UserType } from './User'; +import { PostType } from './Post'; + +import User from '../models/User'; +import Post from '../models/Post'; interface QuoteShape extends Common.Quote { quoted?: string; @@ -33,7 +37,14 @@ export const QuoteType: GraphQLObjectType = new Grap startWordIndex: { type: GraphQLInt }, endWordIndex: { type: GraphQLInt }, deleted: { type: GraphQLBoolean }, - user: { type: UserType }, + user: { + type: UserType, + resolve: (quote) => (quote as any).user ?? User.findById(quote.userId).lean(), + }, + post: { + type: PostType, + resolve: (quote) => Post.findById(quote.postId).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/Reaction.ts b/quotevote-backend/app/data/types/Reaction.ts index 8c5d5874..4ab3483a 100644 --- a/quotevote-backend/app/data/types/Reaction.ts +++ b/quotevote-backend/app/data/types/Reaction.ts @@ -1,6 +1,11 @@ import { GraphQLObjectType, GraphQLString, type GraphQLFieldConfigMap } from 'graphql'; import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; +import { UserType } from './User'; +import { MessageType } from './Message'; + +import User from '../models/User'; +import Message from '../models/Message'; export const ReactionType: GraphQLObjectType = new GraphQLObjectType({ @@ -13,6 +18,14 @@ export const ReactionType: GraphQLObjectType = messageId: { type: GraphQLString }, actionId: { type: GraphQLString }, emoji: { type: GraphQLString }, + user: { + type: UserType, + resolve: (rxn) => User.findById(rxn.userId).lean(), + }, + message: { + type: MessageType, + resolve: (rxn) => Message.findById(rxn.messageId).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/Roster.ts b/quotevote-backend/app/data/types/Roster.ts index 0ceaf329..30e2b716 100644 --- a/quotevote-backend/app/data/types/Roster.ts +++ b/quotevote-backend/app/data/types/Roster.ts @@ -12,6 +12,9 @@ import { RosterStatusEnum } from './enums'; import { UserType } from './User'; import { PresenceType } from './Presence'; +import User from '../models/User'; +import Presence from '../models/Presence'; + interface RosterShape extends Common.Roster { buddy?: Common.User; presence?: Common.Presence; @@ -32,8 +35,18 @@ export const RosterType: GraphQLObjectType = new Gr type: new GraphQLNonNull(GraphQLString), resolve: (r) => r.initiatedBy ?? r.userId, }, - buddy: { type: UserType }, - presence: { type: PresenceType }, + buddy: { + type: UserType, + resolve: (roster) => roster.buddy ?? User.findById(roster.buddyId).lean(), + }, + user: { + type: UserType, + resolve: (roster) => User.findById(roster.userId).lean(), + }, + presence: { + type: PresenceType, + resolve: (roster) => roster.presence ?? Presence.findOne({ userId: roster.buddyId }).lean(), + }, created: { type: new GraphQLNonNull(DateScalar) }, updated: { type: new GraphQLNonNull(DateScalar), diff --git a/quotevote-backend/app/data/types/TypingIndicator.ts b/quotevote-backend/app/data/types/TypingIndicator.ts index 1fbdcb05..54c3725c 100644 --- a/quotevote-backend/app/data/types/TypingIndicator.ts +++ b/quotevote-backend/app/data/types/TypingIndicator.ts @@ -9,6 +9,10 @@ import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { DateScalar } from './scalars'; import { UserType } from './User'; +import { MessageRoomType } from './MessageRoom'; + +import User from '../models/User'; +import MessageRoom from '../models/MessageRoom'; interface TypingIndicatorShape extends Common.Typing { user?: Common.User; @@ -21,7 +25,14 @@ export const TypingIndicatorType: GraphQLObjectType => ({ messageRoomId: { type: new GraphQLNonNull(GraphQLString) }, userId: { type: new GraphQLNonNull(GraphQLString) }, - user: { type: UserType }, + user: { + type: UserType, + resolve: (typing) => typing.user ?? User.findById(typing.userId).lean(), + }, + messageRoom: { + type: MessageRoomType, + resolve: (typing) => MessageRoom.findById(typing.messageRoomId).lean(), + }, isTyping: { type: new GraphQLNonNull(GraphQLBoolean) }, timestamp: { type: new GraphQLNonNull(DateScalar) }, }), diff --git a/quotevote-backend/app/data/types/User.ts b/quotevote-backend/app/data/types/User.ts index 9112dd72..d3a1d013 100644 --- a/quotevote-backend/app/data/types/User.ts +++ b/quotevote-backend/app/data/types/User.ts @@ -12,6 +12,20 @@ import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { JSONScalar } from './scalars'; import { UserReputationType } from './UserReputation'; +import { AccountStatusEnum } from './enums'; +import { PostType } from './Post'; +import { CommentType } from './Comment'; +import { VoteType } from './Vote'; +import { PresenceType } from './Presence'; +import { RosterType } from './Roster'; +import { GroupType } from './Group'; + +import Post from '../models/Post'; +import Comment from '../models/Comment'; +import Vote from '../models/Vote'; +import Presence from '../models/Presence'; +import Roster from '../models/Roster'; +import Group from '../models/Group'; export const UserType: GraphQLObjectType = new GraphQLObjectType< Common.User, @@ -54,7 +68,7 @@ export const UserType: GraphQLObjectType = new Grap contributorBadge: { type: GraphQLBoolean }, reputation: { type: UserReputationType }, botReports: { type: GraphQLInt }, - accountStatus: { type: GraphQLString }, + accountStatus: { type: AccountStatusEnum }, lastBotReportDate: { type: GraphQLString, resolve: (u) => @@ -66,6 +80,34 @@ export const UserType: GraphQLObjectType = new Grap type: GraphQLString, resolve: (u) => (u as Common.User & { themePreference?: string }).themePreference ?? null, }, + posts: { + type: new GraphQLList(PostType), + resolve: (user) => Post.find({ userId: user._id }).lean(), + }, + comments: { + type: new GraphQLList(CommentType), + resolve: (user) => Comment.find({ userId: user._id }).lean(), + }, + votes: { + type: new GraphQLList(VoteType), + resolve: (user) => Vote.find({ userId: user._id }).lean(), + }, + presence: { + type: PresenceType, + resolve: (user) => Presence.findOne({ userId: user._id }).lean(), + }, + rosters: { + type: new GraphQLList(RosterType), + resolve: (user) => Roster.find({ userId: user._id }).lean(), + }, + createdGroups: { + type: new GraphQLList(GroupType), + resolve: (user) => Group.find({ creatorId: user._id }).lean(), + }, + memberOfGroups: { + type: new GraphQLList(GroupType), + resolve: (user) => Group.find({ allowedUserIds: user._id }).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/UserInvite.ts b/quotevote-backend/app/data/types/UserInvite.ts index f9d2a5a7..65f7d769 100644 --- a/quotevote-backend/app/data/types/UserInvite.ts +++ b/quotevote-backend/app/data/types/UserInvite.ts @@ -7,6 +7,7 @@ import { import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { DateScalar } from './scalars'; +import { InviteStatusEnum } from './enums'; export const UserInviteType: GraphQLObjectType = new GraphQLObjectType({ @@ -15,7 +16,7 @@ export const UserInviteType: GraphQLObjectType => ({ _id: { type: new GraphQLNonNull(GraphQLString) }, email: { type: new GraphQLNonNull(GraphQLString) }, - status: { type: GraphQLString }, + status: { type: InviteStatusEnum }, _userId: { type: GraphQLString, resolve: (invite) => (invite as Common.UserInvite & { _userId?: string })._userId, diff --git a/quotevote-backend/app/data/types/UserReputation.ts b/quotevote-backend/app/data/types/UserReputation.ts index afa24e09..2b0c02bf 100644 --- a/quotevote-backend/app/data/types/UserReputation.ts +++ b/quotevote-backend/app/data/types/UserReputation.ts @@ -8,6 +8,10 @@ import { } from 'graphql'; import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; +import { UserType } from './User'; +import { ReportReasonEnum, ReportStatusEnum, ReportSeverityEnum } from './enums'; + +import User from '../models/User'; export const ReputationMetricsType: GraphQLObjectType = new GraphQLObjectType({ @@ -105,16 +109,24 @@ export const UserReportType: GraphQLObjectType r.reportedUserId, }, - reason: { type: new GraphQLNonNull(GraphQLString) }, + reason: { type: new GraphQLNonNull(ReportReasonEnum) }, description: { type: GraphQLString }, status: { - type: new GraphQLNonNull(GraphQLString), + type: new GraphQLNonNull(ReportStatusEnum), resolve: (r) => r.status ?? 'pending', }, severity: { - type: new GraphQLNonNull(GraphQLString), + type: new GraphQLNonNull(ReportSeverityEnum), resolve: (r) => r.severity ?? 'low', }, + reporter: { + type: UserType, + resolve: (r) => User.findById(r.reporterId).lean(), + }, + reportedUser: { + type: UserType, + resolve: (r) => User.findById(r.reportedUserId).lean(), + }, adminNotes: { type: GraphQLString }, createdAt: { type: new GraphQLNonNull(GraphQLString), diff --git a/quotevote-backend/app/data/types/Vote.ts b/quotevote-backend/app/data/types/Vote.ts index 2826a074..7ea2dd91 100644 --- a/quotevote-backend/app/data/types/Vote.ts +++ b/quotevote-backend/app/data/types/Vote.ts @@ -9,6 +9,11 @@ import type { GraphQLContext } from '~/types/graphql'; import type * as Common from '~/types/common'; import { DateScalar } from './scalars'; import { UserType } from './User'; +import { PostType } from './Post'; +import { VoteTypeEnum } from './enums'; + +import User from '../models/User'; +import Post from '../models/Post'; export const VoteType: GraphQLObjectType = new GraphQLObjectType< Common.Vote, @@ -21,7 +26,7 @@ export const VoteType: GraphQLObjectType = new Grap created: { type: DateScalar }, postId: { type: GraphQLString }, userId: { type: GraphQLString }, - type: { type: GraphQLString }, + type: { type: VoteTypeEnum }, tags: { type: GraphQLString, resolve: (v) => (Array.isArray(v.tags) ? v.tags.join(',') : (v.tags ?? null)), @@ -30,7 +35,14 @@ export const VoteType: GraphQLObjectType = new Grap endWordIndex: { type: GraphQLInt }, deleted: { type: GraphQLBoolean }, content: { type: GraphQLString }, - user: { type: UserType }, + user: { + type: UserType, + resolve: (vote) => (vote as any).user ?? User.findById(vote.userId).lean(), + }, + post: { + type: PostType, + resolve: (vote) => Post.findById(vote.postId).lean(), + }, }), }); diff --git a/quotevote-backend/app/data/types/enums.ts b/quotevote-backend/app/data/types/enums.ts index fb16f84f..abe2be44 100644 --- a/quotevote-backend/app/data/types/enums.ts +++ b/quotevote-backend/app/data/types/enums.ts @@ -1,26 +1,90 @@ /** * Shared GraphQL enum types used by the domain schema. - * Values mirror the `Common.PresenceStatus` / `Common.RosterStatus` string unions. + * Values mirror the constants in `app/data/utils/constants.ts` and Prisma. */ import { GraphQLEnumType } from 'graphql'; +import { + AccountStatusValues, + VoteTypeValues, + RosterStatusValues, + MessageTypeValues, + NotificationTypeValues, + ActivityEventTypeValues, + GroupPrivacyValues, + InviteStatusValues, + ReportStatusValues, + ReportSeverityValues, + ReportReasonValues, + PresenceStatusValues, +} from '../utils/constants'; + +// Helper to convert constant values to GraphQLEnumValueConfigMap +function toEnumValues>(values: T) { + const result: Record = {}; + for (const k of Object.keys(values)) { + const val = values[k]; + result[val] = { value: val }; + } + return result; +} export const PresenceStatusEnum = new GraphQLEnumType({ name: 'PresenceStatus', - values: { - online: { value: 'online' }, - away: { value: 'away' }, - dnd: { value: 'dnd' }, - invisible: { value: 'invisible' }, - offline: { value: 'offline' }, - }, + values: toEnumValues(PresenceStatusValues), }); export const RosterStatusEnum = new GraphQLEnumType({ name: 'RosterStatus', - values: { - pending: { value: 'pending' }, - accepted: { value: 'accepted' }, - blocked: { value: 'blocked' }, - }, + values: toEnumValues(RosterStatusValues), +}); + +export const AccountStatusEnum = new GraphQLEnumType({ + name: 'AccountStatus', + values: toEnumValues(AccountStatusValues), +}); + +export const VoteTypeEnum = new GraphQLEnumType({ + name: 'VoteType', + values: toEnumValues(VoteTypeValues), +}); + +export const MessageTypeEnum = new GraphQLEnumType({ + name: 'MessageType', + values: toEnumValues(MessageTypeValues), +}); + +export const NotificationTypeEnum = new GraphQLEnumType({ + name: 'NotificationType', + values: toEnumValues(NotificationTypeValues), +}); + +export const ActivityEventTypeEnum = new GraphQLEnumType({ + name: 'ActivityEventType', + values: toEnumValues(ActivityEventTypeValues), +}); + +export const GroupPrivacyEnum = new GraphQLEnumType({ + name: 'GroupPrivacy', + values: toEnumValues(GroupPrivacyValues), +}); + +export const InviteStatusEnum = new GraphQLEnumType({ + name: 'InviteStatus', + values: toEnumValues(InviteStatusValues), +}); + +export const ReportStatusEnum = new GraphQLEnumType({ + name: 'ReportStatus', + values: toEnumValues(ReportStatusValues), +}); + +export const ReportSeverityEnum = new GraphQLEnumType({ + name: 'ReportSeverity', + values: toEnumValues(ReportSeverityValues), +}); + +export const ReportReasonEnum = new GraphQLEnumType({ + name: 'ReportReason', + values: toEnumValues(ReportReasonValues), }); diff --git a/quotevote-backend/app/data/types/index.ts b/quotevote-backend/app/data/types/index.ts index 6fdf6a0e..da97d8c4 100644 --- a/quotevote-backend/app/data/types/index.ts +++ b/quotevote-backend/app/data/types/index.ts @@ -17,7 +17,20 @@ import { printType } from 'graphql'; // Scalars & enums import { DateScalar, JSONScalar } from './scalars'; -import { PresenceStatusEnum, RosterStatusEnum } from './enums'; +import { + PresenceStatusEnum, + RosterStatusEnum, + AccountStatusEnum, + VoteTypeEnum, + MessageTypeEnum, + NotificationTypeEnum, + ActivityEventTypeEnum, + GroupPrivacyEnum, + InviteStatusEnum, + ReportStatusEnum, + ReportSeverityEnum, + ReportReasonEnum, +} from './enums'; // Core domain object types — one per legacy typedef file import { ActivityType } from './Activity'; @@ -91,6 +104,16 @@ export const domainTypes: readonly GraphQLNamedType[] = [ // Enums PresenceStatusEnum, RosterStatusEnum, + AccountStatusEnum, + VoteTypeEnum, + MessageTypeEnum, + NotificationTypeEnum, + ActivityEventTypeEnum, + GroupPrivacyEnum, + InviteStatusEnum, + ReportStatusEnum, + ReportSeverityEnum, + ReportReasonEnum, // Pagination / envelopes PaginationType, PostsType, diff --git a/quotevote-backend/app/data/utils/constants.ts b/quotevote-backend/app/data/utils/constants.ts index c42a0a8b..43247420 100644 --- a/quotevote-backend/app/data/utils/constants.ts +++ b/quotevote-backend/app/data/utils/constants.ts @@ -16,3 +16,94 @@ export const USER_MESSAGE_CREATED = SubscriptionEvents.USER_MESSAGE_CREATED; export const USER_REACTION = SubscriptionEvents.USER_REACTION; export const COMMENT_CREATED = SubscriptionEvents.COMMENT_CREATED; export const NOTIFICATION_CREATED = SubscriptionEvents.NOTIFICATION_CREATED; + +export const AccountStatusValues = { + ACTIVE: 'active', + DISABLED: 'disabled', + SUSPENDED: 'suspended', + PENDING: 'pending', +} as const; + +export const VoteTypeValues = { + UP: 'up', + DOWN: 'down', +} as const; + +export const RosterStatusValues = { + PENDING: 'pending', + ACCEPTED: 'accepted', + DECLINED: 'declined', + BLOCKED: 'blocked', +} as const; + +export const MessageTypeValues = { + USER: 'USER', + POST: 'POST', + SYSTEM: 'SYSTEM', +} as const; + +export const NotificationTypeValues = { + FOLLOW: 'FOLLOW', + VOTE: 'VOTE', + COMMENT: 'COMMENT', + QUOTE: 'QUOTE', + MESSAGE: 'MESSAGE', + MENTION: 'MENTION', + SYSTEM: 'SYSTEM', + UPVOTED: 'UPVOTED', + DOWNVOTED: 'DOWNVOTED', +} as const; + +export const ActivityEventTypeValues = { + POSTED: 'POSTED', + VOTED: 'VOTED', + COMMENTED: 'COMMENTED', + QUOTED: 'QUOTED', + LIKED: 'LIKED', + UPVOTED: 'UPVOTED', + DOWNVOTED: 'DOWNVOTED', +} as const; + +export const GroupPrivacyValues = { + PUBLIC: 'public', + PRIVATE: 'private', + RESTRICTED: 'restricted', +} as const; + +export const InviteStatusValues = { + PENDING: 'pending', + ACCEPTED: 'accepted', + DECLINED: 'declined', + EXPIRED: 'expired', +} as const; + +export const ReportStatusValues = { + PENDING: 'pending', + REVIEWED: 'reviewed', + RESOLVED: 'resolved', + DISMISSED: 'dismissed', +} as const; + +export const ReportSeverityValues = { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + CRITICAL: 'critical', +} as const; + +export const ReportReasonValues = { + SPAM: 'spam', + HARASSMENT: 'harassment', + INAPPROPRIATE_CONTENT: 'inappropriate_content', + FAKE_ACCOUNT: 'fake_account', + OTHER: 'other', +} as const; + +export const PresenceStatusValues = { + ONLINE: 'online', + AWAY: 'away', + DND: 'dnd', + INVISIBLE: 'invisible', + OFFLINE: 'offline', +} as const; + diff --git a/quotevote-backend/app/types/common.ts b/quotevote-backend/app/types/common.ts index 46ed1bd8..82d221df 100644 --- a/quotevote-backend/app/types/common.ts +++ b/quotevote-backend/app/types/common.ts @@ -27,7 +27,7 @@ export type MessageType = 'USER' | 'POST'; export type RosterStatus = 'pending' | 'accepted' | 'declined' | 'blocked'; -export type AccountStatus = 'active' | 'disabled'; +export type AccountStatus = 'active' | 'disabled' | 'suspended' | 'pending'; export type VoteOption = '#true' | '#agree' | '#like' | '#false' | '#disagree' | '#dislike'; From 0fe90712e16cbb72df96dddb6ac35f40b5c04962 Mon Sep 17 00:00:00 2001 From: Sandeep Sarkar <152861071+sandy4242@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:51:20 +0530 Subject: [PATCH 2/4] Fix backend lint error and frontend test syntax error --- quotevote-backend/app/data/types/Comment.ts | 2 +- quotevote-backend/app/data/types/Message.ts | 2 +- quotevote-backend/app/data/types/Quote.ts | 2 +- quotevote-backend/app/data/types/Vote.ts | 2 +- .../__tests__/components/Profile/ProfileHeaderMessage.test.tsx | 1 - 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/quotevote-backend/app/data/types/Comment.ts b/quotevote-backend/app/data/types/Comment.ts index e8206514..fde3a928 100644 --- a/quotevote-backend/app/data/types/Comment.ts +++ b/quotevote-backend/app/data/types/Comment.ts @@ -33,7 +33,7 @@ export const CommentType: GraphQLObjectType = ne deleted: { type: GraphQLBoolean }, user: { type: UserType, - resolve: (comment) => (comment as any).user ?? User.findById(comment.userId).lean(), + resolve: (comment) => (comment as Common.Comment & { user?: Common.User }).user ?? User.findById(comment.userId).lean(), }, post: { type: PostType, diff --git a/quotevote-backend/app/data/types/Message.ts b/quotevote-backend/app/data/types/Message.ts index ad959f28..acf5953d 100644 --- a/quotevote-backend/app/data/types/Message.ts +++ b/quotevote-backend/app/data/types/Message.ts @@ -60,7 +60,7 @@ export const MessageType: GraphQLObjectType = new deleted: { type: GraphQLBoolean }, user: { type: UserType, - resolve: (msg) => (msg as any).user ?? User.findById(msg.userId).lean(), + resolve: (msg) => (msg as Common.Message & { user?: Common.User }).user ?? User.findById(msg.userId).lean(), }, messageRoom: { type: MessageRoomType, diff --git a/quotevote-backend/app/data/types/Quote.ts b/quotevote-backend/app/data/types/Quote.ts index 121b4c93..02d6bf87 100644 --- a/quotevote-backend/app/data/types/Quote.ts +++ b/quotevote-backend/app/data/types/Quote.ts @@ -39,7 +39,7 @@ export const QuoteType: GraphQLObjectType = new Grap deleted: { type: GraphQLBoolean }, user: { type: UserType, - resolve: (quote) => (quote as any).user ?? User.findById(quote.userId).lean(), + resolve: (quote) => (quote as Common.Quote & { user?: Common.User }).user ?? User.findById(quote.userId).lean(), }, post: { type: PostType, diff --git a/quotevote-backend/app/data/types/Vote.ts b/quotevote-backend/app/data/types/Vote.ts index 7ea2dd91..cad340f1 100644 --- a/quotevote-backend/app/data/types/Vote.ts +++ b/quotevote-backend/app/data/types/Vote.ts @@ -37,7 +37,7 @@ export const VoteType: GraphQLObjectType = new Grap content: { type: GraphQLString }, user: { type: UserType, - resolve: (vote) => (vote as any).user ?? User.findById(vote.userId).lean(), + resolve: (vote) => (vote as Common.Vote & { user?: Common.User }).user ?? User.findById(vote.userId).lean(), }, post: { type: PostType, diff --git a/quotevote-frontend/src/__tests__/components/Profile/ProfileHeaderMessage.test.tsx b/quotevote-frontend/src/__tests__/components/Profile/ProfileHeaderMessage.test.tsx index f1836d1d..bcabd50a 100644 --- a/quotevote-frontend/src/__tests__/components/Profile/ProfileHeaderMessage.test.tsx +++ b/quotevote-frontend/src/__tests__/components/Profile/ProfileHeaderMessage.test.tsx @@ -129,7 +129,6 @@ describe('ProfileHeader — Message button', () => { username: 'testuser', messageType: 'USER', users: ['currentuser', 'user1'], - username: 'testuser', }); expect(setChatOpen).toHaveBeenCalledWith(true); }); From 06b1b66029b3dace210f5a0835497a92d85e4cc7 Mon Sep 17 00:00:00 2001 From: Sandeep Sarkar <152861071+sandy4242@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:39:11 +0530 Subject: [PATCH 3/4] Fix reviewer concerns: update Group.rosters resolver, sync type unions, and add explicit unit tests --- .../__tests__/unit/graphql-types.test.ts | 74 +++++++++++++++++++ quotevote-backend/app/data/types/Group.ts | 13 +++- quotevote-backend/app/types/common.ts | 19 ++++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/quotevote-backend/__tests__/unit/graphql-types.test.ts b/quotevote-backend/__tests__/unit/graphql-types.test.ts index 2bfc4891..a0265421 100644 --- a/quotevote-backend/__tests__/unit/graphql-types.test.ts +++ b/quotevote-backend/__tests__/unit/graphql-types.test.ts @@ -54,6 +54,18 @@ import { VoteType, } from '~/data/types'; +jest.mock('~/data/models/Roster'); +jest.mock('~/data/models/User'); +jest.mock('~/data/models/Post'); +jest.mock('~/data/models/Comment'); +jest.mock('~/data/models/Vote'); +jest.mock('~/data/models/Quote'); +jest.mock('~/data/models/MessageRoom'); +jest.mock('~/data/models/Message'); +jest.mock('~/data/models/Typing'); +jest.mock('~/data/models/Reaction'); +jest.mock('~/data/models/Presence'); + const LEGACY_TYPE_NAMES = [ 'Activity', 'Activities', @@ -224,4 +236,66 @@ describe('GraphQL domain typedefs (7.28 migration)', () => { expect(printSchema(roundTripped)).toContain('type Post'); }); }); + + describe('runtime enum values and resolvers validation', () => { + it('verifies GraphQLEnumType value mappings match constants', () => { + const typeMap = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { _placeholder: { type: GraphQLString } }, + }), + types: [...domainTypes], + }).getTypeMap(); + + // Check PresenceStatusEnum + const presenceEnum = typeMap.PresenceStatus as any; + expect(presenceEnum).toBeDefined(); + expect(presenceEnum.getValue('online').value).toBe('online'); + expect(presenceEnum.getValue('away').value).toBe('away'); + + // Check AccountStatusEnum + const accountEnum = typeMap.AccountStatus as any; + expect(accountEnum).toBeDefined(); + expect(accountEnum.getValue('suspended').value).toBe('suspended'); + expect(accountEnum.getValue('pending').value).toBe('pending'); + + // Check MessageTypeEnum + const messageEnum = typeMap.MessageType as any; + expect(messageEnum).toBeDefined(); + expect(messageEnum.getValue('SYSTEM').value).toBe('SYSTEM'); + + // Check NotificationTypeEnum + const notificationEnum = typeMap.NotificationType as any; + expect(notificationEnum).toBeDefined(); + expect(notificationEnum.getValue('SYSTEM').value).toBe('SYSTEM'); + expect(notificationEnum.getValue('VOTE').value).toBe('VOTE'); + }); + + it('verifies relationship resolvers correctly fetch and filter database models', async () => { + const RosterMock = require('~/data/models/Roster').default; + const findSpy = jest.spyOn(RosterMock, 'find').mockImplementation(() => ({ + lean: () => Promise.resolve([{ _id: 'roster1', userId: 'user1', buddyId: 'user2' }]) + } as any)); + + const groupFields = GroupType.getFields(); + expect(groupFields.rosters).toBeDefined(); + expect(groupFields.rosters.resolve).toBeInstanceOf(Function); + + const fakeGroup = { + creatorId: 'creator123', + allowedUserIds: ['user123', 'user456'], + }; + + const result = await (groupFields.rosters.resolve as Function)(fakeGroup, {}, {}, {} as any); + expect(findSpy).toHaveBeenCalledWith({ + $or: [ + { userId: { $in: ['creator123', 'user123', 'user456'] } }, + { buddyId: { $in: ['creator123', 'user123', 'user456'] } }, + ] + }); + expect(result).toEqual([{ _id: 'roster1', userId: 'user1', buddyId: 'user2' }]); + + findSpy.mockRestore(); + }); + }); }); diff --git a/quotevote-backend/app/data/types/Group.ts b/quotevote-backend/app/data/types/Group.ts index 1b394434..1b15583b 100644 --- a/quotevote-backend/app/data/types/Group.ts +++ b/quotevote-backend/app/data/types/Group.ts @@ -64,7 +64,18 @@ export const GroupType: GraphQLObjectType = new Grap }, rosters: { type: new GraphQLList(RosterType), - resolve: (group) => Roster.find({ userId: group.creatorId }).lean(), + resolve: (group) => { + const memberIds = [ + group.creatorId, + ...(group.allowedUserIds ?? []), + ]; + return Roster.find({ + $or: [ + { userId: { $in: memberIds } }, + { buddyId: { $in: memberIds } }, + ], + }).lean(); + }, }, }), }); diff --git a/quotevote-backend/app/types/common.ts b/quotevote-backend/app/types/common.ts index 82d221df..f2b59a50 100644 --- a/quotevote-backend/app/types/common.ts +++ b/quotevote-backend/app/types/common.ts @@ -19,11 +19,20 @@ export type ActivityEventType = export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline' | 'invisible'; -export type NotificationType = 'FOLLOW' | 'UPVOTED' | 'DOWNVOTED' | 'COMMENTED' | 'QUOTED'; +export type NotificationType = + | 'FOLLOW' + | 'VOTE' + | 'COMMENT' + | 'QUOTE' + | 'MESSAGE' + | 'MENTION' + | 'SYSTEM' + | 'UPVOTED' + | 'DOWNVOTED'; export type VoteType = 'up' | 'down'; -export type MessageType = 'USER' | 'POST'; +export type MessageType = 'USER' | 'POST' | 'SYSTEM'; export type RosterStatus = 'pending' | 'accepted' | 'declined' | 'blocked'; @@ -216,7 +225,7 @@ export interface Message { userName?: string; title?: string; text?: string; - type?: string; + type?: MessageType; mutation_type?: string; deleted?: boolean; readBy?: string[]; @@ -349,12 +358,14 @@ export interface Reaction { // Invite & Report Types // ============================================================================ +export type InviteStatus = 'pending' | 'accepted' | 'declined' | 'expired'; + export interface UserInvite { _id: string; email: string; invitedBy?: string; code?: string; - status?: string; + status?: InviteStatus; created: Date | string; expiresAt?: Date | string; } From cec8ba32426b776a4559246b0e98340b6639899e Mon Sep 17 00:00:00 2001 From: Sandeep Sarkar <152861071+sandy4242@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:53:43 +0530 Subject: [PATCH 4/4] Fix typescript eslint warnings in graphql-types.test.ts --- .../__tests__/unit/graphql-types.test.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/quotevote-backend/__tests__/unit/graphql-types.test.ts b/quotevote-backend/__tests__/unit/graphql-types.test.ts index a0265421..3dcea5a6 100644 --- a/quotevote-backend/__tests__/unit/graphql-types.test.ts +++ b/quotevote-backend/__tests__/unit/graphql-types.test.ts @@ -20,7 +20,10 @@ import { GraphQLSchema, GraphQLString, printSchema, + GraphQLEnumType, } from 'graphql'; +import RosterMock from '~/data/models/Roster'; +import type { GraphQLContext } from '~/types/graphql'; import { DateScalar, domainTypeDefs, @@ -248,34 +251,33 @@ describe('GraphQL domain typedefs (7.28 migration)', () => { }).getTypeMap(); // Check PresenceStatusEnum - const presenceEnum = typeMap.PresenceStatus as any; + const presenceEnum = typeMap.PresenceStatus as GraphQLEnumType; expect(presenceEnum).toBeDefined(); expect(presenceEnum.getValue('online').value).toBe('online'); expect(presenceEnum.getValue('away').value).toBe('away'); // Check AccountStatusEnum - const accountEnum = typeMap.AccountStatus as any; + const accountEnum = typeMap.AccountStatus as GraphQLEnumType; expect(accountEnum).toBeDefined(); expect(accountEnum.getValue('suspended').value).toBe('suspended'); expect(accountEnum.getValue('pending').value).toBe('pending'); // Check MessageTypeEnum - const messageEnum = typeMap.MessageType as any; + const messageEnum = typeMap.MessageType as GraphQLEnumType; expect(messageEnum).toBeDefined(); expect(messageEnum.getValue('SYSTEM').value).toBe('SYSTEM'); // Check NotificationTypeEnum - const notificationEnum = typeMap.NotificationType as any; + const notificationEnum = typeMap.NotificationType as GraphQLEnumType; expect(notificationEnum).toBeDefined(); expect(notificationEnum.getValue('SYSTEM').value).toBe('SYSTEM'); expect(notificationEnum.getValue('VOTE').value).toBe('VOTE'); }); it('verifies relationship resolvers correctly fetch and filter database models', async () => { - const RosterMock = require('~/data/models/Roster').default; - const findSpy = jest.spyOn(RosterMock, 'find').mockImplementation(() => ({ + const findSpy = jest.spyOn(RosterMock, 'find').mockImplementation((() => ({ lean: () => Promise.resolve([{ _id: 'roster1', userId: 'user1', buddyId: 'user2' }]) - } as any)); + })) as unknown as typeof RosterMock.find); const groupFields = GroupType.getFields(); expect(groupFields.rosters).toBeDefined(); @@ -286,7 +288,14 @@ describe('GraphQL domain typedefs (7.28 migration)', () => { allowedUserIds: ['user123', 'user456'], }; - const result = await (groupFields.rosters.resolve as Function)(fakeGroup, {}, {}, {} as any); + const resolveFn = groupFields.rosters.resolve as ( + source: unknown, + args: Record, + context: GraphQLContext, + info: unknown + ) => unknown; + + const result = await resolveFn(fakeGroup, {}, {} as GraphQLContext, {}); expect(findSpy).toHaveBeenCalledWith({ $or: [ { userId: { $in: ['creator123', 'user123', 'user456'] } },