import { Inject, Injectable, Logger } from '@nestjs/common' import { Pothos, PothosRef, PothosSchema, SchemaBuilderToken, } from '@smatch-corp/nestjs-pothos' import { Builder, SchemaContext } from '../Graphql/graphql.builder' import { PrismaService } from '../Prisma/prisma.service' import { clerkClient } from '@clerk/express' import { UnauthorizedException } from '@nestjs/common' import { MailService } from '../Mail/mail.service' import { MessageSchema } from 'src/Message/message.schema' import { ChatRoom, Message, MessageContextType, MessageType, Role, } from '@prisma/client' import { PubSubEvent } from 'src/common/pubsub/pubsub-event' import { DateTimeUtils } from 'src/common/utils/datetime.utils' import { ChatroomSchema } from '../ChatRoom/chatroom.schema' @Injectable() export class UserSchema extends PothosSchema { constructor( @Inject(SchemaBuilderToken) private readonly builder: Builder, private readonly prisma: PrismaService, private readonly mailService: MailService, private readonly messageSchema: MessageSchema, private readonly chatRoomSchema: ChatroomSchema, ) { super() } // Types section @PothosRef() user() { return this.builder.prismaObject('User', { description: 'A user in the system.', fields: (t) => ({ id: t.exposeID('id', { description: 'The ID of the user.', }), name: t.exposeString('name', { description: 'The name of the user.', }), email: t.exposeString('email', { description: 'The email of the user.', }), phoneNumber: t.exposeString('phoneNumber', { description: 'The phone number of the user.', }), bankBin: t.exposeString('bankBin', { description: 'The bank bin of the user.', }), bankAccountNumber: t.exposeString('bankAccountNumber', { description: 'The bank account number of the user.', }), packageValue: t.exposeFloat('packageValue', { description: 'The package value of the user.', nullable: true, }), role: t.exposeString('role', { nullable: true, description: 'The role of the user.', }), avatarUrl: t.exposeString('avatarUrl', { nullable: true, description: 'The avatar URL of the user.', }), createdAt: t.expose('createdAt', { type: 'DateTime', nullable: true, description: 'The date and time the user was created.', }), updatedAt: t.expose('updatedAt', { type: 'DateTime', nullable: true, description: 'The date and time the user was updated.', }), orders: t.relation('orders', { description: 'The orders of the user.', }), serviceFeedbacks: t.relation('serviceFeedbacks', { description: 'The service feedbacks of the user.', }), files: t.relation('files', { description: 'The files of the user.', }), sentMessages: t.relation('sentMessages', { description: 'The sent messages of the user.', }), receivedMessages: t.relation('receivedMessages', { description: 'The received messages of the user.', }), resume: t.relation('resume', { description: 'The resume of the user.', }), service: t.relation('service', { description: 'The service of the user.', }), center: t.relation('center', { description: 'The center of the user.', }), customerChatRoom: t.relation('customerChatRoom', { description: 'The customer chat room of the user.', }), mentorChatRoom: t.relation('mentorChatRoom', { description: 'The mentor chat room of the user.', }), mentor: t.relation('mentor', { description: 'The mentor of the user.', }), workshopSubscription: t.relation('workshopSubscription', { description: 'The workshop subscription of the user.', }), adminNote: t.relation('adminNote', { description: 'The admin note of the user.', }), }), }) } @PothosRef() recentChatActivity() { return this.builder.simpleObject('RecentChatActivity', { fields: (t) => ({ chatRoom: t.field({ type: this.chatRoomSchema.chatRoom(), description: 'The chat room.', }), lastActivity: t.field({ type: 'DateTime', description: 'The last activity of the chat room.', }), sender: t.field({ type: this.user(), description: 'The sender of the message.', }), message: t.field({ type: this.messageSchema.message(), description: 'The last message of the chat room.', }), }), }) } // Query section @Pothos() init(): void { this.builder.queryFields((t) => ({ session: t.field({ type: 'Json', args: { sessionId: t.arg({ type: 'String', required: true }), }, resolve: async (_, { sessionId }) => { const session = await clerkClient.sessions.getSession(sessionId) return JSON.parse(JSON.stringify(session)) }, }), newSession: t.field({ type: 'String', args: { userId: t.arg({ type: 'String', required: true, }), }, resolve: async (_, { userId }) => { const session = await clerkClient.signInTokens.createSignInToken({ userId, expiresInSeconds: 60 * 60 * 24, }) return session.id }, }), me: t.prismaField({ description: 'Retrieve the current user in context.', type: this.user(), resolve: async (_query, _root, _args, ctx) => { if (ctx.isSubscription) { throw new Error('Not allowed') } return ctx.http.me }, }), recentChatActivity: t.field({ description: 'Retrieve the recent chat activity of the current user.', type: [this.recentChatActivity()], args: { take: t.arg({ type: 'Int', required: false }), }, resolve: async (_parent, args, ctx) => { if (ctx.isSubscription) throw new Error('Not allowed') const me = ctx.http.me if (!me) throw new Error('User not found') // get chat rooms that the user is a part of const chatRooms = await this.prisma.chatRoom.findMany({ where: { OR: [{ customerId: me.id }, { mentorId: me.id }], }, orderBy: { lastActivity: 'desc', }, take: args.take ?? 10, }) // get the last message for each chat room const lastMessages = await Promise.all( chatRooms.map(async (chatRoom) => { const lastMessage = await this.prisma.message.findFirst({ where: { chatRoomId: chatRoom.id, }, orderBy: { sentAt: 'desc', }, }) if (!lastMessage) return null const sender = lastMessage.senderId ? await this.prisma.user.findUnique({ where: { id: lastMessage.senderId }, }) : undefined return { chatRoom: chatRoom, lastActivity: lastMessage.sentAt, sender: sender, message: lastMessage, } }), ) return lastMessages.filter((msg) => msg !== null) }, }), users: t.prismaField({ description: 'Retrieve a list of users with optional filtering, ordering, and pagination.', type: [this.user()], args: this.builder.generator.findManyArgs('User'), resolve: async (query, _root, args) => { return await this.prisma.user.findMany({ ...query, take: args.take ?? undefined, skip: args.skip ?? undefined, orderBy: args.orderBy ?? undefined, where: args.filter ?? undefined, }) }, }), user: t.prismaField({ description: 'Retrieve a single user by their unique identifier.', type: this.user(), args: this.builder.generator.findUniqueArgs('User'), resolve: async (query, _root, args) => { return await this.prisma.user.findUniqueOrThrow({ ...query, where: args.where, }) }, }), userBySession: t.prismaField({ description: 'Retrieve a single user by their session ID.', type: this.user(), args: { sessionId: t.arg({ type: 'String', required: true }), }, resolve: async (query, _root, args) => { // check if the token is valid const session = await clerkClient.sessions.getSession(args.sessionId) Logger.log(session, 'Session') return await this.prisma.user.findFirstOrThrow({ ...query, where: { id: session.userId, }, }) }, }), })) // Mutation section this.builder.mutationFields((t) => ({ updateUser: t.prismaField({ description: 'Update an existing user.', type: this.user(), args: { input: t.arg({ type: this.builder.generator.getUpdateInput('User'), required: true, }), where: t.arg({ type: this.builder.generator.getWhereUnique('User'), required: true, }), }, resolve: async (query, _root, args) => { return await this.prisma.user.update({ ...query, where: args.where, data: args.input, }) }, }), updateMe: t.field({ type: this.user(), description: 'Update the current user in context.', args: { input: t.arg({ type: this.builder.generator.getUpdateInput('User', [ 'id', 'adminNote', 'center', 'customerChatRoom', 'avatarUrl', // 'bankAccountNumber', // 'bankBin', 'email', 'name', 'phoneNumber', 'role', 'createdAt', 'updatedAt', 'files', 'orders', 'sendingMessage', 'mentor', 'mentorChatRoom', 'resume', 'service', 'serviceFeedbacks', 'workshopSubscription', ]), required: false, }), imageBlob: t.arg({ type: 'Upload', required: false, }), firstName: t.arg({ type: 'String', required: false, }), lastName: t.arg({ type: 'String', required: false, }), }, resolve: async (_query, args, ctx, _info) => { if (ctx.isSubscription) { throw new Error('Not allowed') } const id = ctx.http.me?.id if (!id) { throw new Error('User not found') } if (args.imageBlob) { const { mimetype, createReadStream } = await args.imageBlob if (mimetype && createReadStream) { const stream = createReadStream() const chunks: Uint8Array[] = [] for await (const chunk of stream) { chunks.push(chunk) } const buffer = Buffer.concat(chunks) const { id: userId, imageUrl } = await clerkClient.users.updateUserProfileImage(id, { file: new Blob([buffer]), }) await this.prisma.user.update({ where: { id: userId }, data: { avatarUrl: imageUrl, }, }) } } // update info to clerk const clerkUser = await clerkClient.users.updateUser(id, { firstName: args.firstName as string, lastName: args.lastName as string, }) Logger.log(clerkUser, 'Clerk User') // update bank account number and bank bin to database if (args.input?.bankAccountNumber) { await this.prisma.user.update({ where: { id: clerkUser.id }, data: { bankAccountNumber: args.input.bankAccountNumber, }, }) } if (args.input?.bankBin) { await this.prisma.user.update({ where: { id: clerkUser.id }, data: { bankBin: args.input.bankBin, }, }) } if (args.firstName || args.lastName) { await this.prisma.user.update({ where: { id: clerkUser.id }, data: { name: `${args.firstName || ''} ${args.lastName || ''}`.trim(), }, }) } // invalidate cache await ctx.http.invalidateCache() return await this.prisma.user.findUniqueOrThrow({ where: { id: clerkUser.id }, }) }, }), inviteModerator: t.field({ type: 'String', args: { email: t.arg({ type: 'String', required: true }), }, resolve: async (_parent, args, ctx) => { // check context if (ctx.isSubscription) { throw new Error('Not allowed') } // check context is admin if (ctx.http.me?.role !== 'ADMIN') { throw new UnauthorizedException(`Only admin can invite moderator`) } return this.prisma.$transaction(async (tx) => { let user // perform update role try { user = await tx.user.update({ where: { email: args.email }, data: { role: 'MODERATOR' }, }) } catch (_error) { throw new Error(`User ${args.email} not found`) } // send email await this.mailService.sendTemplateEmail( [args.email], 'Thông báo chọn lựa quản trị viên cho người điều hành', 'ModeratorInvitation', { USER_NAME: user.name, }, ) return 'Invited' }) }, }), // send test notification sendTestNotification: t.field({ type: this.messageSchema.message(), args: { input: t.arg({ type: this.builder.generator.getCreateInput('Message'), required: true, }), }, resolve: async (_, args, ctx) => { if (ctx.isSubscription) { throw new Error('Not allowed') } const me = ctx.http.me if (!me) { throw new Error('User not found') } // create message const message = await this.prisma.message.create({ data: { type: args.input.type, content: args.input.content, senderId: me.id, recipientId: args.input.recipient?.connect?.id ?? null, chatRoomId: args.input.chatRoom?.connect?.id ?? null, sentAt: DateTimeUtils.nowAsJSDate(), context: args.input.context ?? undefined, metadata: args.input.metadata ?? undefined, }, }) // publish message await ctx.http.pubSub.publish( `${PubSubEvent.NEW_MESSAGE}.${message.recipientId}`, message, ) return message }, }), })) // Subscription section this.builder.subscriptionFields((t) => ({ userScopedMessage: t.field({ type: this.messageSchema.message(), subscribe: async (_, _args, ctx: SchemaContext) => { if (!ctx.isSubscription) throw new Error('Not allowed') const { websocket: { pubSub }, } = ctx return pubSub.asyncIterator([ `${PubSubEvent.NEW_MESSAGE}.${ctx.websocket.me?.id}`, ]) as unknown as AsyncIterable }, resolve: async (payload: Message) => payload, }), })) } }