diff --git a/services/api/src/application/controllers/study/quizzes.ts b/services/api/src/application/controllers/study/quizzes.ts index 260436b0..f61742d8 100644 --- a/services/api/src/application/controllers/study/quizzes.ts +++ b/services/api/src/application/controllers/study/quizzes.ts @@ -127,4 +127,23 @@ export class QuizController { if (updatedQuiz) return updatedQuiz throw new NotAuthorizedError() } + + static async requestAccess (req: Request) { + const { add } = validate({ add: Schema.boolean() }, req.body) + + const updatedQuiz = await QuizzesUseCases.requestAccess({ id: req.params.id, userId: req.authUser!.id, add }) + if (updatedQuiz) return updatedQuiz + throw new NotAuthorizedError() + } + + static async grantAccess (req: Request) { + const { userId, grant } = validate({ + userId: Schema.string().min(1), + grant: Schema.boolean() + }, req.body) + + const updatedQuiz = await QuizzesUseCases.grantAccess({ id: req.params.id, ownerId: req.authUser!.id, userId, grant }) + if (updatedQuiz) return updatedQuiz + throw new NotAuthorizedError() + } } \ No newline at end of file diff --git a/services/api/src/application/routes/study/quizzes.ts b/services/api/src/application/routes/study/quizzes.ts index 8785cd7e..3c20c5b4 100644 --- a/services/api/src/application/routes/study/quizzes.ts +++ b/services/api/src/application/routes/study/quizzes.ts @@ -108,5 +108,29 @@ export const quizzesRoutes = groupRoutes('/quizzes', [ } }) ] + }, { + path: '/:id/access/request', + method: 'post', + controllers: [ + isAuthenticated, + makeController(async (req) => { + return { + status: StatusCodes.Ok, + result: await QuizController.requestAccess(req) + } + }) + ] + }, { + path: '/:id/access/grant', + method: 'post', + controllers: [ + isAuthenticated, + makeController(async (req) => { + return { + status: StatusCodes.Ok, + result: await QuizController.grantAccess(req) + } + }) + ] } ]) \ No newline at end of file diff --git a/services/api/src/modules/notifications/domain/types/notifications.ts b/services/api/src/modules/notifications/domain/types/notifications.ts index f5887f34..75778f80 100644 --- a/services/api/src/modules/notifications/domain/types/notifications.ts +++ b/services/api/src/modules/notifications/domain/types/notifications.ts @@ -14,6 +14,9 @@ export enum NotificationType { WalletFundSuccessful = 'WalletFundSuccessful', SubscriptionSuccessful = 'SubscriptionSuccessful', SubscriptionFailed = 'SubscriptionFailed', + NewQuizAccessRequest = 'NewQuizAccessRequest', + QuizAccessRequestGranted = 'QuizAccessRequestGranted', + QuizAccessRequestRejected = 'QuizAccessRequestRejected', } export type NotificationData = @@ -29,4 +32,7 @@ export type NotificationData = | { type: NotificationType.WithdrawalFailed, withdrawalId: string, amount: number, currency: string } | { type: NotificationType.WalletFundSuccessful, amount: number, currency: string } | { type: NotificationType.SubscriptionSuccessful, planId: string } - | { type: NotificationType.SubscriptionFailed, planId: string } \ No newline at end of file + | { type: NotificationType.SubscriptionFailed, planId: string } + | { type: NotificationType.NewQuizAccessRequest, userIds: string[] } + | { type: NotificationType.QuizAccessRequestGranted, quizId: string } + | { type: NotificationType.QuizAccessRequestRejected, quizId: string } \ No newline at end of file diff --git a/services/api/src/modules/study/data/mappers/quizzes.ts b/services/api/src/modules/study/data/mappers/quizzes.ts index d3aa5838..3433ade3 100644 --- a/services/api/src/modules/study/data/mappers/quizzes.ts +++ b/services/api/src/modules/study/data/mappers/quizzes.ts @@ -5,10 +5,13 @@ import { QuizFromModel, QuizToModel } from '../models/quizzes' export class QuizMapper extends BaseMapper { mapFrom (model: QuizFromModel | null) { if (!model) return null - const { _id, title, description, photo, questions, courseId, user, topicId, tagIds, ratings, status, meta, isForTutors, createdAt, updatedAt } = model + const { + _id, title, description, photo, questions, courseId, user, topicId, tagIds, + access, ratings, status, meta, isForTutors, createdAt, updatedAt + } = model return new QuizEntity({ id: _id.toString(), title, description, photo, questions, courseId, ratings, - user, topicId, tagIds, status, meta, isForTutors, createdAt, updatedAt + access, user, topicId, tagIds, status, meta, isForTutors, createdAt, updatedAt }) } diff --git a/services/api/src/modules/study/data/models/quizzes.ts b/services/api/src/modules/study/data/models/quizzes.ts index 24c817f7..f6e882d0 100644 --- a/services/api/src/modules/study/data/models/quizzes.ts +++ b/services/api/src/modules/study/data/models/quizzes.ts @@ -1,8 +1,9 @@ -import { CoursableData, QuizMeta } from '../../domain/types' +import { CoursableData, QuizAccess, QuizMeta } from '../../domain/types' export interface QuizFromModel extends QuizToModel { _id: string questions: string[] + access: QuizAccess ratings: CoursableData['ratings'] meta: Record createdAt: number diff --git a/services/api/src/modules/study/data/mongooseModels/quizzes.ts b/services/api/src/modules/study/data/mongooseModels/quizzes.ts index fcabe790..e3b9d234 100644 --- a/services/api/src/modules/study/data/mongooseModels/quizzes.ts +++ b/services/api/src/modules/study/data/mongooseModels/quizzes.ts @@ -11,6 +11,18 @@ const Schema = new appInstance.dbs.mongo.Schema({ type: [String], required: true }, + access: { + members: { + type: [String], + required: false, + default: () => [] + }, + requests: { + type: [String], + required: false, + default: () => [] + } + }, meta: Object.fromEntries( Object.values(QuizMeta).map((meta) => [meta, { type: Number, diff --git a/services/api/src/modules/study/data/repositories/quizzes.ts b/services/api/src/modules/study/data/repositories/quizzes.ts index e99bb521..d52144f5 100644 --- a/services/api/src/modules/study/data/repositories/quizzes.ts +++ b/services/api/src/modules/study/data/repositories/quizzes.ts @@ -106,4 +106,23 @@ export class QuizRepository implements IQuizRepository { }) return res } + + async requestAccess(id: string, userId: string, add: boolean) { + const quiz = await Quiz.findOneAndUpdate({ + _id: id, 'access.requests': { [add ? '$nin' : '$in']: userId }, 'user.id': { $ne: userId } + }, { + [add ? '$addToSet' : '$pull']: { 'access.requests': userId } + }) + return !!quiz + } + + async grantAccess(id: string, ownerId: string, userId: string, grant: boolean) { + const quiz = await Quiz.findByIdAndUpdate({ + _id: id, 'user.id': ownerId, 'access.requests': { $in: userId } + }, { + $pull: { 'access.requests': userId }, + ...(grant ? { $addToSet: { 'access.members': userId } } : {}) + }) + return !!quiz + } } \ No newline at end of file diff --git a/services/api/src/modules/study/domain/entities/coursables.ts b/services/api/src/modules/study/domain/entities/coursables.ts new file mode 100644 index 00000000..717eb4c7 --- /dev/null +++ b/services/api/src/modules/study/domain/entities/coursables.ts @@ -0,0 +1,47 @@ +import { generateDefaultUser } from '@modules/users' +import { BaseEntity } from 'equipped' +import { CoursableData, Publishable } from '../types' + +export class PublishableEntity extends BaseEntity implements Publishable { + public readonly id: string + public readonly title: Publishable['title'] + public readonly description: Publishable['description'] + public readonly photo: Publishable['photo'] + public readonly user: Publishable['user'] + public readonly topicId: Publishable['topicId'] + public readonly tagIds: Publishable['tagIds'] + public readonly status: Publishable['status'] + public readonly ratings: Publishable['ratings'] + public readonly createdAt: number + public readonly updatedAt: number + + constructor ({ id, title, description, photo, user, topicId, tagIds, ratings, status, createdAt, updatedAt }: PublishableConstructorArgs) { + super() + this.id = id + this.title = title + this.description = description + this.photo = photo + this.user = generateDefaultUser(user) + this.topicId = topicId + this.tagIds = tagIds + this.ratings = ratings + this.status = status + this.createdAt = createdAt + this.updatedAt = updatedAt + } +} + +type PublishableConstructorArgs = Publishable & { + id: string + createdAt: number + updatedAt: number +} + +export class CoursableEntity extends PublishableEntity implements CoursableData { + public readonly courseId: string | null + + constructor (data: PublishableConstructorArgs & { courseId: string | null }) { + super(data) + this.courseId = data.courseId + } +} \ No newline at end of file diff --git a/services/api/src/modules/study/domain/entities/courses.ts b/services/api/src/modules/study/domain/entities/courses.ts index 985e2578..e175053f 100644 --- a/services/api/src/modules/study/domain/entities/courses.ts +++ b/services/api/src/modules/study/domain/entities/courses.ts @@ -1,43 +1,20 @@ -import { generateDefaultUser } from '@modules/users' -import { BaseEntity } from 'equipped' import { Coursable, CourseMeta, CourseSections, Publishable, Saleable } from '../types' +import { PublishableEntity } from './coursables' -export class CourseEntity extends BaseEntity implements Publishable, Saleable { - public readonly id: string +export class CourseEntity extends PublishableEntity implements Publishable, Saleable { public readonly coursables: { id: string, type: Coursable }[] public readonly sections: CourseSections - public readonly title: Publishable['title'] - public readonly description: Publishable['description'] - public readonly photo: Publishable['photo'] - public readonly user: Publishable['user'] - public readonly topicId: Publishable['topicId'] - public readonly tagIds: Publishable['tagIds'] - public readonly ratings: Publishable['ratings'] - public readonly status: Publishable['status'] public readonly frozen: Saleable['frozen'] public readonly price: Saleable['price'] public readonly meta: Record - public readonly createdAt: number - public readonly updatedAt: number - constructor ({ id, coursables, sections, title, description, photo, user, topicId, tagIds, ratings, status, frozen, price, meta, createdAt, updatedAt }: CourseConstructorArgs) { - super() - this.id = id - this.coursables = coursables - this.sections = sections - this.title = title - this.description = description - this.photo = photo - this.user = generateDefaultUser(user) - this.topicId = topicId - this.tagIds = tagIds - this.ratings = ratings - this.status = status - this.frozen = frozen - this.price = price - this.meta = meta - this.createdAt = createdAt - this.updatedAt = updatedAt + constructor (data: CourseConstructorArgs) { + super(data) + this.coursables = data.coursables + this.sections = data.sections + this.frozen = data.frozen + this.price = data.price + this.meta = data.meta } getCoursables () { diff --git a/services/api/src/modules/study/domain/entities/files.ts b/services/api/src/modules/study/domain/entities/files.ts index 25696e05..5d64d4be 100644 --- a/services/api/src/modules/study/domain/entities/files.ts +++ b/services/api/src/modules/study/domain/entities/files.ts @@ -1,40 +1,15 @@ -import { generateDefaultUser } from '@modules/users' -import { BaseEntity } from 'equipped' import { CoursableData, FileType, Media } from '../types' +import { CoursableEntity } from './coursables' -export class FileEntity extends BaseEntity implements CoursableData { - public readonly id: string +export class FileEntity extends CoursableEntity implements CoursableData { public readonly type: FileType public readonly media: Media - public readonly title: CoursableData['title'] - public readonly description: CoursableData['description'] - public readonly photo: CoursableData['photo'] - public readonly courseId: CoursableData['courseId'] - public readonly user: CoursableData['user'] - public readonly topicId: CoursableData['topicId'] - public readonly tagIds: CoursableData['tagIds'] - public readonly ratings: CoursableData['ratings'] - public readonly status: CoursableData['status'] - public readonly createdAt: number - public readonly updatedAt: number ignoreInJSON = ['media'] - constructor ({ id, title, description, media, photo, type, courseId, user, topicId, tagIds, ratings, status, createdAt, updatedAt }: FileConstructorArgs) { - super() - this.id = id - this.title = title - this.description = description - this.photo = photo - this.type = type - this.media = media - this.courseId = courseId - this.user = generateDefaultUser(user) - this.topicId = topicId - this.tagIds = tagIds - this.ratings = ratings - this.status = status - this.createdAt = createdAt - this.updatedAt = updatedAt + constructor (data: FileConstructorArgs) { + super(data) + this.type = data.type + this.media = data.media } } diff --git a/services/api/src/modules/study/domain/entities/quizzes.ts b/services/api/src/modules/study/domain/entities/quizzes.ts index fa67594a..a7531a35 100644 --- a/services/api/src/modules/study/domain/entities/quizzes.ts +++ b/services/api/src/modules/study/domain/entities/quizzes.ts @@ -1,41 +1,18 @@ -import { generateDefaultUser } from '@modules/users' -import { BaseEntity } from 'equipped' -import { CoursableData, QuizMeta } from '../types' +import { CoursableData, QuizAccess, QuizMeta } from '../types' +import { CoursableEntity } from './coursables' -export class QuizEntity extends BaseEntity implements CoursableData { - public readonly id: string +export class QuizEntity extends CoursableEntity implements CoursableData { public readonly questions: string[] - public readonly title: CoursableData['title'] - public readonly description: CoursableData['description'] - public readonly photo: CoursableData['photo'] - public readonly courseId: CoursableData['courseId'] - public readonly user: CoursableData['user'] - public readonly topicId: CoursableData['topicId'] - public readonly tagIds: CoursableData['tagIds'] - public readonly status: CoursableData['status'] - public readonly ratings: CoursableData['ratings'] public readonly meta: Record + public readonly access: QuizAccess public readonly isForTutors: boolean - public readonly createdAt: number - public readonly updatedAt: number - constructor ({ id, title, description, photo, questions, courseId, user, topicId, tagIds, ratings, status, meta, isForTutors, createdAt, updatedAt }: QuizConstructorArgs) { - super() - this.id = id - this.title = title - this.description = description - this.photo = photo - this.questions = questions - this.courseId = courseId - this.user = generateDefaultUser(user) - this.topicId = topicId - this.tagIds = tagIds - this.ratings = ratings - this.status = status - this.meta = meta - this.isForTutors = isForTutors - this.createdAt = createdAt - this.updatedAt = updatedAt + constructor (data: QuizConstructorArgs) { + super(data) + this.questions = data.questions + this.meta = data.meta + this.access = data.access + this.isForTutors = data.isForTutors } } @@ -43,6 +20,7 @@ type QuizConstructorArgs = CoursableData & { id: string questions: string[] meta: Record + access: QuizAccess isForTutors: boolean createdAt: number updatedAt: number diff --git a/services/api/src/modules/study/domain/irepositories/quizzes.ts b/services/api/src/modules/study/domain/irepositories/quizzes.ts index 7e3fb85b..df8e908a 100644 --- a/services/api/src/modules/study/domain/irepositories/quizzes.ts +++ b/services/api/src/modules/study/domain/irepositories/quizzes.ts @@ -15,4 +15,6 @@ export interface IQuizRepository { reorder: (id: string, userId: string, questionIds: string[]) => Promise updateMeta: (id: string, property: QuizMeta, value: 1 | -1) => Promise updateRatings (id: string, ratings: number, add: boolean): Promise + requestAccess (id: string, userId: string, add: boolean): Promise + grantAccess (id: string, ownerId: string, userId: string, grant: boolean): Promise } \ No newline at end of file diff --git a/services/api/src/modules/study/domain/types/index.ts b/services/api/src/modules/study/domain/types/index.ts index f3fbcfda..28e1ea7b 100644 --- a/services/api/src/modules/study/domain/types/index.ts +++ b/services/api/src/modules/study/domain/types/index.ts @@ -56,4 +56,9 @@ export enum QuizMeta { export enum CourseMeta { purchases = 'purchases', total = 'total' +} + +export type QuizAccess = { + requests: string[] + members: string[] } \ No newline at end of file diff --git a/services/api/src/modules/study/domain/useCases/quizzes.ts b/services/api/src/modules/study/domain/useCases/quizzes.ts index 597eeab1..b10af4ea 100644 --- a/services/api/src/modules/study/domain/useCases/quizzes.ts +++ b/services/api/src/modules/study/domain/useCases/quizzes.ts @@ -53,4 +53,12 @@ export class QuizzesUseCase { async updateRatings (input: { id: string, ratings: number, add: boolean }) { return await this.repository.updateRatings(input.id, input.ratings, input.add) } + + async requestAccess (data: { id: string, userId: string, add: boolean }) { + return await this.repository.requestAccess(data.id, data.userId, data.add) + } + + async grantAccess (data: { id: string, ownerId: string, userId: string, grant: boolean }) { + return await this.repository.grantAccess(data.id, data.ownerId, data.userId, data.grant) + } } \ No newline at end of file diff --git a/services/api/src/modules/study/utils/changes/quizzes.ts b/services/api/src/modules/study/utils/changes/quizzes.ts index fc1b99ec..3b1ebd5e 100644 --- a/services/api/src/modules/study/utils/changes/quizzes.ts +++ b/services/api/src/modules/study/utils/changes/quizzes.ts @@ -7,6 +7,7 @@ import { CoursesUseCases, FoldersUseCases, QuestionsUseCases } from '../..' import { QuizFromModel } from '../../data/models/quizzes' import { QuizEntity } from '../../domain/entities/quizzes' import { Coursable, DraftStatus, FolderSaved } from '../../domain/types' +import { NotificationType, sendNotification } from '@modules/notifications' export const QuizDbChangeCallbacks: DbChangeCallbacks = { created: async ({ after }) => { @@ -41,6 +42,31 @@ export const QuizDbChangeCallbacks: DbChangeCallbacks value: after.status === DraftStatus.published ? 1 : -1, property: UserMeta.publishedQuizzes }) + + if (changes.access?.requests) { + const newRequests = after.access.requests.filter((r) => !before.access.requests.includes(r)) + const oldRequests = before.access.requests.filter((r) => !after.access.requests.includes(r)) + const accepted = oldRequests.filter((r) => after.access.members.includes(r)) + const rejected = oldRequests.filter((r) => !after.access.members.includes(r)) + if (newRequests.length) await sendNotification([after.user.id], { + title: 'New Quiz Edit Request', + body: `Someone just requested access to edit your quiz: ${after.title}`, + sendEmail: true, + data: { type: NotificationType.NewQuizAccessRequest, userIds: newRequests } + }) + if (accepted.length) await sendNotification(accepted, { + title: 'Quiz Edit Request Granted', + body: `Your request to edit ${after.title} has been accepted`, + sendEmail: true, + data: { type: NotificationType.QuizAccessRequestGranted, quizId: after.id } + }) + if (rejected.length) await sendNotification(rejected, { + title: 'Quiz Edit Request Rejected', + body: `Your request to edit ${after.title} has been rejected`, + sendEmail: true, + data: { type: NotificationType.QuizAccessRequestRejected, quizId: after.id } + }) + } }, deleted: async ({ before }) => { const paths = before.isForTutors ? ['study/quizzes/tutors', `study/quizzes/tutors/${before.id}`] : ['study/quizzes', `study/quizzes/${before.id}`] diff --git a/services/api/src/modules/study/utils/courses.ts b/services/api/src/modules/study/utils/courses.ts index 03515c65..43def549 100644 --- a/services/api/src/modules/study/utils/courses.ts +++ b/services/api/src/modules/study/utils/courses.ts @@ -14,10 +14,12 @@ type Type = Awaited> export const canAccessCoursable = async (type: T, coursableId: string, user: AuthUser): Promise | null> => { const coursable = await finders[type]?.find(coursableId) ?? null if (!coursable) return null - // current user ass an admin can access tutor quizzes + // current user as an admin can access tutor quizzes if (coursable instanceof QuizEntity && coursable.isForTutors && user.roles[AuthRole.isAdmin]) return coursable as Type // current user owns the item if (coursable.user.id === user.id) return coursable as Type + // current user has been granted access + if (coursable instanceof QuizEntity && coursable.access.members.includes(user.id)) return coursable as Type // owner of the item has not published yet if (coursable.status === DraftStatus.draft) return null // item is not in a course, so it is free