- Updated biome.json to include "graphql.d.ts" in the ignored files list. - Updated subproject commit reference in epess-database to the latest version. - Removed unused script from package.json and streamlined module file extensions in tsconfig.json. - Consolidated exclude patterns in tsconfig.build.json for clarity. - Refactored imports across multiple schema files for consistency and improved readability. - Enhanced various schema files by ensuring proper import order and removing redundant code. - Improved error handling and data integrity checks in several service and schema files.
557 lines
18 KiB
TypeScript
557 lines
18 KiB
TypeScript
import { clerkClient } from '@clerk/express'
|
|
import { Inject, Injectable, Logger } from '@nestjs/common'
|
|
import { ChatRoom, Message, MessageContextType, MessageType, Role } from '@prisma/client'
|
|
import { Pothos, PothosRef, PothosSchema, SchemaBuilderToken } from '@smatch-corp/nestjs-pothos'
|
|
import { ChatroomSchema } from '../ChatRoom/chatroom.schema'
|
|
import { Builder, SchemaContext } from '../Graphql/graphql.builder'
|
|
import { MailService } from '../Mail/mail.service'
|
|
import { MessageSchema } from '../Message/message.schema'
|
|
import { PrismaService } from '../Prisma/prisma.service'
|
|
import { PubSubEvent } from '../common/pubsub/pubsub-event'
|
|
import { DateTimeUtils } from '../common/utils/datetime.utils'
|
|
@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.',
|
|
}),
|
|
banned: t.exposeBoolean('banned', {
|
|
description: 'The banned status 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) => {
|
|
const user = await this.prisma.user.findUnique({
|
|
...query,
|
|
where: args.where,
|
|
})
|
|
if (!user) throw new Error('User not found')
|
|
return user
|
|
},
|
|
}),
|
|
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,
|
|
})
|
|
// 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 !== Role.ADMIN) {
|
|
throw new Error(`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
|
|
},
|
|
}),
|
|
banUser: t.field({
|
|
type: 'String',
|
|
args: {
|
|
userId: t.arg({ type: 'String', required: true }),
|
|
},
|
|
resolve: async (_parent, args, ctx) => {
|
|
if (ctx.isSubscription) {
|
|
throw new Error('Not allowed')
|
|
}
|
|
if (ctx.http.me?.role !== Role.ADMIN && ctx.http.me?.role !== Role.MODERATOR) {
|
|
throw new Error(`Only admin or moderator can ban user`)
|
|
}
|
|
if (args.userId === ctx.http.me?.id) {
|
|
throw new Error(`Cannot ban yourself`)
|
|
}
|
|
// get banning user info
|
|
const banningUser = await this.prisma.user.findUnique({
|
|
where: { id: args.userId },
|
|
})
|
|
if (!banningUser) {
|
|
throw new Error(`User ${args.userId} not found`)
|
|
}
|
|
// if banning user is moderator or admin, throw error
|
|
if (banningUser.role === Role.MODERATOR || banningUser.role === Role.ADMIN) {
|
|
throw new Error(`Cannot ban moderator or admin`)
|
|
}
|
|
// ban user from clerk
|
|
await clerkClient.users.banUser(args.userId)
|
|
// invalidate cache
|
|
await ctx.http.invalidateCache()
|
|
// update user banned status
|
|
await this.prisma.user.update({
|
|
where: { id: args.userId },
|
|
data: { banned: true },
|
|
})
|
|
return 'Banned'
|
|
},
|
|
}),
|
|
}))
|
|
|
|
// 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.asyncIterableIterator([
|
|
`${PubSubEvent.NEW_MESSAGE}.${ctx.websocket.me?.id}`,
|
|
`${PubSubEvent.NOTIFICATION}.${ctx.websocket.me?.id}`,
|
|
])
|
|
},
|
|
resolve: async (payload: Message) => payload,
|
|
}),
|
|
}))
|
|
}
|
|
}
|