Files
epess-web-backend/src/Service/service.schema.ts
Ly Tuan Kiet 092a15753b feat: enhance notification system and role-based access across schemas
- Updated MessageSchema to include context-aware filtering for notifications, allowing retrieval based on recipientId for NOTIFICATION and SYSTEM contexts.
- Enhanced RefundTicketSchema to notify moderators upon refund requests, improving communication and response times.
- Modified ResumeSchema to send notifications to mentors and center owners when a new resume is submitted, ensuring timely updates.
- Improved ServiceSchema to notify center owners and mentors about service approvals, enhancing user engagement and awareness.
- Implemented role-based access control checks across schemas to ensure only authorized users can perform specific actions, enhancing security and user experience.
2024-12-09 19:38:36 +07:00

486 lines
18 KiB
TypeScript

import { Inject, Injectable, Logger } from '@nestjs/common'
import { Message, MessageContextType, MessageType, Role, ServiceStatus } from '@prisma/client'
import { Pothos, PothosRef, PothosSchema, SchemaBuilderToken } from '@smatch-corp/nestjs-pothos'
import { Builder } from '../Graphql/graphql.builder'
import { MailService } from '../Mail/mail.service'
import { MinioService } from '../Minio/minio.service'
import { PrismaService } from '../Prisma/prisma.service'
import { PubSubEvent } from '../common/pubsub/pubsub-event'
import { DateTimeUtils } from '../common/utils/datetime.utils'
@Injectable()
export class ServiceSchema extends PothosSchema {
constructor(
@Inject(SchemaBuilderToken) private readonly builder: Builder,
private readonly prisma: PrismaService,
private readonly minioService: MinioService,
private readonly mailService: MailService,
) {
super()
}
@PothosRef()
service() {
return this.builder.prismaObject('Service', {
description: 'A service offered by a center.',
fields: (t) => ({
id: t.exposeID('id', {
description: 'The ID of the service.',
}),
name: t.exposeString('name', {
description: 'The name of the service.',
}),
description: t.exposeString('description', {
description: 'The description of the service.',
}),
content: t.exposeString('content', {
description: 'The content of the service.',
}),
centerId: t.exposeID('centerId', {
description: 'The ID of the center that offers the service.',
}),
userId: t.exposeID('userId', {
description: 'The ID of the user who requested the service.',
}),
adminNote: t.relation('adminNote', {
description: 'The admin note of the service.',
}),
price: t.exposeFloat('price', {
description: 'The price of the service.',
}),
commission: t.exposeFloat('commission', {
description: 'The commission of the service.',
}),
rating: t.expose('rating', {
type: 'Float',
nullable: true,
description: 'The rating of the service.',
}),
imageFile: t.relation('imageFile', {
description: 'The image file for the service.',
}),
imageFileId: t.exposeID('imageFileId', {
nullable: true,
description: 'The ID of the image file for the service.',
}),
imageFileUrl: t.string({
nullable: true,
description: 'The URL of the image file for the service.',
resolve: async (service) => {
// get file id from imageFileUrl
const imageFileId = service.imageFileUrl?.split('/').pop()?.split('?')[0]
return await this.minioService.updatePresignUrl(
imageFileId ?? '',
'files',
service.imageFileUrl ?? undefined,
)
},
}),
status: t.expose('status', {
type: ServiceStatus,
description: 'The status of the service.',
}),
isActive: t.exposeBoolean('isActive', {
description: 'Whether the service is active.',
}),
createdAt: t.expose('createdAt', {
type: 'DateTime',
description: 'The date and time the service was created.',
}),
updatedAt: t.expose('updatedAt', {
type: 'DateTime',
description: 'The date and time the service was updated.',
}),
feedbacks: t.relation('feedbacks', {
description: 'The feedbacks for the service.',
}),
order: t.relation('order', {
description: 'The order for the service.',
}),
center: t.relation('center', {
description: 'The center that offers the service.',
}),
workshop: t.relation('workshop', {
description: 'The workshop for the service.',
}),
serviceAndCategory: t.relation('serviceAndCategory', {
description: 'The service and category for the service.',
}),
workshopOrganization: t.relation('workshopOrganization', {
description: 'The workshop organization for the service.',
}),
user: t.relation('user', {
description: 'The user who requested the service.',
}),
managedService: t.relation('managedService', {
description: 'The managed service for the service.',
}),
feedbacked: t.boolean({
description: 'Whether the user has already provided feedback for the service.',
nullable: true,
resolve: async (service, _args, ctx) => {
if (ctx.isSubscription) {
return null
}
if (!ctx.http.me) {
return false
}
const serviceFeedbacks = await this.prisma.serviceFeedback.findMany({
where: {
serviceId: service.id,
userId: ctx.http.me.id,
},
})
return serviceFeedbacks.length > 0
},
}),
quiz: t.relation('quiz', {
description: 'The quiz of the service.',
}),
}),
})
}
@Pothos()
init() {
this.builder.queryFields((t) => ({
testServices: t.prismaConnection(
{
description: 'A test connection for services',
type: this.service(),
cursor: 'id',
args: this.builder.generator.findManyArgs('Service'),
resolve: async (query, _root, _args, _ctx, _info) => {
return await this.prisma.service.findMany({
...query,
})
},
totalCount: (query) => {
return this.prisma.service.count({
...query,
})
},
},
{},
),
services: t.prismaField({
description: 'Retrieve a list of services with optional filtering, ordering, and pagination.',
type: [this.service()],
args: this.builder.generator.findManyArgs('Service'),
resolve: async (query, _root, args, _ctx, _info) => {
return await this.prisma.service.findMany({
...query,
where: args.filter ?? undefined,
orderBy: args.orderBy ?? undefined,
skip: args.skip ?? undefined,
take: args.take ?? undefined,
cursor: args.cursor ?? undefined,
})
},
}),
servicesByCenter: t.prismaField({
description: 'Retrieve a list of services with optional filtering, ordering, and pagination.',
type: [this.service()],
args: this.builder.generator.findManyArgs('Service'),
resolve: async (query, _root, args, ctx, _info) => {
if (ctx.isSubscription) {
throw new Error('Not allowed')
}
// check role if user is mentor or center owner
const role = ctx.http.me?.role
if (role !== Role.CENTER_MENTOR && role !== Role.CENTER_OWNER) {
throw new Error('Not allowed')
}
if (role === Role.CENTER_MENTOR) {
// load only service belong to center of current user
const managedServices = await this.prisma.managedService.findMany({
where: { mentorId: ctx.http.me?.id ?? '' },
})
if (!managedServices) {
throw new Error('Managed services not found')
}
// query services that have serviceId in managedServices
args.filter = {
id: { in: managedServices.map((service) => service.serviceId) },
}
}
// if role is center owner, load all services belong to center of current user
if (role === Role.CENTER_OWNER) {
const center = await this.prisma.center.findUnique({
where: { centerOwnerId: ctx.http.me?.id ?? '' },
})
if (!center) {
throw new Error('Center not found')
}
args.filter = { centerId: { in: [center.id] } }
}
return await this.prisma.service.findMany({
...query,
where: args.filter ?? undefined,
orderBy: args.orderBy ?? undefined,
skip: args.skip ?? undefined,
take: args.take ?? undefined,
cursor: args.cursor ?? undefined,
})
},
}),
service: t.prismaField({
description: 'Retrieve a single service by its unique identifier.',
type: this.service(),
args: {
input: t.arg({
type: this.builder.generator.getWhereUnique('Service'),
required: true,
}),
},
resolve: async (query, _root, args, _ctx, _info) => {
return await this.prisma.service.findUnique({
...query,
where: args.input,
include: {
feedbacks: true,
},
})
},
}),
}))
// Mutation section
this.builder.mutationFields((t) => ({
createService: t.prismaField({
description: 'Create a new service.',
type: this.service(),
args: {
input: t.arg({
type: this.builder.generator.getCreateInput('Service', ['user']),
required: true,
}),
},
resolve: async (query, _root, args, ctx, _info) => {
if (ctx.isSubscription) {
throw new Error('Not allowed')
}
// replace userId with current user id
args.input.user = { connect: { id: ctx.http.me?.id ?? '' } }
const service = await this.prisma.service.create({
...query,
data: args.input,
})
// send notification to all mentor or center owner for the center
const center = await this.prisma.center.findUnique({
where: { id: service.centerId },
})
if (!center?.centerOwnerId) {
throw new Error('Center owner not found')
}
const centerOwner = await this.prisma.user.findUnique({
where: { id: center.centerOwnerId },
})
if (!centerOwner) {
throw new Error('Center owner not found')
}
const centerMentor = await this.prisma.centerMentor.findMany({
where: { centerId: service.centerId },
})
const mentorIds = centerMentor.map((mentor) => mentor.mentorId)
const mentorEmails = await this.prisma.user.findMany({
where: { id: { in: mentorIds } },
})
const emails = [centerOwner.email, ...mentorEmails.map((mentor) => mentor.email)]
await this.mailService.sendTemplateEmail(emails, 'Thông báo về trạng thái dịch vụ', 'ServiceApproved', {
SERVICE_NAME: service.name,
CENTER_NAME: center.name,
})
// send notification to all mentor or center owner for the center using context
const message = await this.prisma.message.create({
data: {
senderId: ctx.http.me?.id ?? '',
recipientId: centerOwner.id,
type: MessageType.TEXT,
content: `Dịch vụ ${service.name} của bạn đã được chấp thuận`,
sentAt: DateTimeUtils.nowAsJSDate(),
context: MessageContextType.NOTIFICATION,
},
})
ctx.http.pubSub.publish(`${PubSubEvent.NOTIFICATION}.${centerOwner.id}`, message)
return service
},
}),
updateService: t.prismaField({
description: 'Update an existing service.',
type: this.service(),
args: {
input: t.arg({
type: this.builder.generator.getUpdateInput('Service'),
required: true,
}),
where: t.arg({
type: this.builder.generator.getWhereUnique('Service'),
required: true,
}),
},
resolve: async (query, _root, args, _ctx, _info) => {
return await this.prisma.service.update({
...query,
where: args.where,
data: args.input,
})
},
}),
deleteService: t.prismaField({
description: 'Delete an existing service.',
type: this.service(),
args: {
where: t.arg({
type: this.builder.generator.getWhereUnique('Service'),
required: true,
}),
},
resolve: async (query, _root, args, _ctx, _info) => {
return await this.prisma.service.delete({
...query,
where: args.where,
})
},
}),
approveOrRejectService: t.prismaField({
description: 'Approve or reject a service. For moderator only.',
type: this.service(),
args: {
serviceId: t.arg({
type: 'String',
required: true,
}),
approve: t.arg({
type: 'Boolean',
required: true,
description: 'The approve status of the service.',
}),
adminNote: t.arg({
type: 'String',
required: false,
description: 'The admin note of the service.',
}),
commission: t.arg({
type: 'Float',
required: false,
description: 'The commission of the service. present as float 0 to 1 and required if approve is true',
}),
},
resolve: async (query, _root, args, ctx, _info) => {
if (ctx.isSubscription) {
throw new Error('Not allowed')
}
return await this.prisma.$transaction(async (prisma) => {
// check if service is already approved or rejected
const service = await prisma.service.findUnique({
where: { id: args.serviceId },
})
if (!service) {
throw new Error('Service not found')
}
if (service.status !== ServiceStatus.PENDING) {
throw new Error('Service is already approved or rejected')
}
let commission = args.commission
if (args.approve && !commission) {
commission = 0.05
}
// update service status
const updatedService = await prisma.service.update({
...query,
where: { id: args.serviceId },
data: {
status: args.approve ? ServiceStatus.APPROVED : ServiceStatus.REJECTED,
adminNote: {
create: {
content: args.adminNote ?? '',
notedByUserId: ctx.http.me?.id ?? '',
},
},
commission: commission ?? 0,
},
})
// mail to all mentor or center owner for the center
const center = await prisma.center.findUnique({
where: { id: service.centerId },
})
if (!center?.centerOwnerId) {
throw new Error('Center owner not found')
}
const centerOwner = await prisma.user.findUnique({
where: { id: center.centerOwnerId },
})
if (!centerOwner) {
throw new Error('Center owner not found')
}
const centerMentor = await prisma.centerMentor.findMany({
where: { centerId: service.centerId },
})
const mentorIds = centerMentor.map((mentor) => mentor.mentorId)
// get mentor emails
const mentorEmails = await prisma.user.findMany({
where: { id: { in: mentorIds } },
})
const emails = [centerOwner.email, ...mentorEmails.map((mentor) => mentor.email)]
if (args.approve) {
await this.mailService.sendTemplateEmail(emails, 'Thông báo về trạng thái dịch vụ', 'ServiceApproved', {
SERVICE_NAME: service.name,
CENTER_NAME: center.name,
})
// get user ids from mentorIds
const userIds = mentorIds.map((id) => id)
// send notification to user using context
userIds.forEach(async (id) => {
// add message to database
const message = await this.prisma.message.create({
data: {
senderId: ctx.http.me?.id ?? '',
recipientId: id,
type: MessageType.TEXT,
content: `Dịch vụ ${service.name} của bạn đã được chấp thuận`,
sentAt: DateTimeUtils.nowAsJSDate(),
context: MessageContextType.NOTIFICATION,
metadata: {
serviceId: service.id,
},
},
})
ctx.http.pubSub.publish(`${PubSubEvent.NOTIFICATION}.${id}`, message)
})
} else {
await this.mailService.sendTemplateEmail(emails, 'Thông báo về trạng thái dịch vụ', 'ServiceRejected', {
SERVICE_NAME: service.name,
CENTER_NAME: center.name,
ADMIN_NOTE: args.adminNote ?? 'Không có lý do',
})
// send notification to user using context
// get user ids from mentorIds
const userIds = mentorIds.map((id) => id)
userIds.forEach(async (id) => {
// add message to database
const message = await this.prisma.message.create({
data: {
senderId: ctx.http.me?.id ?? '',
recipientId: id,
type: MessageType.TEXT,
content: `Dịch vụ ${service.name} của bạn đã bị từ chối`,
sentAt: DateTimeUtils.nowAsJSDate(),
context: MessageContextType.NOTIFICATION,
metadata: {
serviceId: service.id,
},
},
})
ctx.http.pubSub.publish(`${PubSubEvent.NOTIFICATION}.${id}`, message)
})
}
return updatedService
})
},
}),
}))
}
}