From efa8872f64014582c43d9f148454ed90a217b680 Mon Sep 17 00:00:00 2001 From: annarhughes Date: Mon, 9 Dec 2024 17:40:00 +0000 Subject: [PATCH] resource user routes --- src/entities/resource-feedback.entity.ts | 3 +- src/entities/resource-user.entity.ts | 4 +- src/entities/resource.entity.ts | 4 +- .../dtos/create-resource-user.dto.ts | 15 ---- src/resource-user/dtos/resource-user.dto.ts | 18 ++++ .../dtos/update-resource-user.dto.ts | 10 ++- src/resource-user/resource-user.controller.ts | 70 +++++++++------- src/resource-user/resource-user.service.ts | 83 +++++++++++++++++-- src/resource/resource.interface.ts | 14 ++++ src/resource/resource.service.ts | 3 + .../dtos/create-session-user.dto.ts | 10 --- src/user/dtos/get-user.dto.ts | 2 + src/user/user.service.ts | 2 + src/utils/serialize.ts | 18 ++++ 14 files changed, 188 insertions(+), 68 deletions(-) delete mode 100644 src/resource-user/dtos/create-resource-user.dto.ts create mode 100644 src/resource-user/dtos/resource-user.dto.ts create mode 100644 src/resource/resource.interface.ts delete mode 100644 src/session-user/dtos/create-session-user.dto.ts diff --git a/src/entities/resource-feedback.entity.ts b/src/entities/resource-feedback.entity.ts index 2479daca..7eb91034 100644 --- a/src/entities/resource-feedback.entity.ts +++ b/src/entities/resource-feedback.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinTable, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { FEEDBACK_TAGS_ENUM } from '../utils/constants'; import { BaseBloomEntity } from './base.entity'; import { ResourceEntity } from './resource.entity'; @@ -14,6 +14,7 @@ export class ResourceFeedbackEntity extends BaseBloomEntity { @ManyToOne(() => ResourceEntity, (resourceEntity) => resourceEntity.resourceFeedback, { onDelete: 'CASCADE', }) + @JoinTable({ name: 'resource', joinColumn: { name: 'resourceId' } }) resource: ResourceEntity; @Column() diff --git a/src/entities/resource-user.entity.ts b/src/entities/resource-user.entity.ts index cf3d284a..0c707a15 100644 --- a/src/entities/resource-user.entity.ts +++ b/src/entities/resource-user.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinTable, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { BaseBloomEntity } from './base.entity'; import { ResourceEntity } from './resource.entity'; import { UserEntity } from './user.entity'; @@ -16,8 +16,10 @@ export class ResourceUserEntity extends BaseBloomEntity { @ManyToOne(() => ResourceEntity, (resourceEntity) => resourceEntity.resourceUser, { onDelete: 'CASCADE', }) + @JoinTable({ name: 'resource', joinColumn: { name: 'resourceId' } }) resource: ResourceEntity; @ManyToOne(() => UserEntity, (userEntity) => userEntity.resourceUser, { onDelete: 'CASCADE' }) + @JoinTable({ name: 'user', joinColumn: { name: 'userId' } }) user: UserEntity; } diff --git a/src/entities/resource.entity.ts b/src/entities/resource.entity.ts index 3699c03c..e0876c6a 100644 --- a/src/entities/resource.entity.ts +++ b/src/entities/resource.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinTable, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { RESOURCE_CATEGORIES, STORYBLOK_STORY_STATUS_ENUM } from '../utils/constants'; import { BaseBloomEntity } from './base.entity'; import { ResourceFeedbackEntity } from './resource-feedback.entity'; @@ -40,6 +40,7 @@ export class ResourceEntity extends BaseBloomEntity { @OneToMany(() => ResourceUserEntity, (resourceUserEntity) => resourceUserEntity.resource, { cascade: true, }) + @JoinTable({ name: 'resourceUser', joinColumn: { name: 'resourceUserId' } }) resourceUser: ResourceUserEntity[]; @OneToMany( @@ -49,5 +50,6 @@ export class ResourceEntity extends BaseBloomEntity { cascade: true, }, ) + @JoinTable({ name: 'resourceFeedback', joinColumn: { name: 'resourceFeedbackId' } }) resourceFeedback: ResourceFeedbackEntity[]; } diff --git a/src/resource-user/dtos/create-resource-user.dto.ts b/src/resource-user/dtos/create-resource-user.dto.ts deleted file mode 100644 index 67316fc2..00000000 --- a/src/resource-user/dtos/create-resource-user.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsDate, IsNotEmpty, IsOptional, IsString } from 'class-validator'; - -export class CreateResourceUserDto { - @IsNotEmpty() - @IsString() - resourceId: string; - - @IsNotEmpty() - @IsString() - userId: string; - - @IsOptional() - @IsDate() - completedAt?: Date; -} diff --git a/src/resource-user/dtos/resource-user.dto.ts b/src/resource-user/dtos/resource-user.dto.ts new file mode 100644 index 00000000..b70f42e5 --- /dev/null +++ b/src/resource-user/dtos/resource-user.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; + +export class ResourceUserDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + resourceId: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: String }) + userId: string; + + @IsBoolean() + @ApiProperty({ type: Date }) + completedAt?: Date; +} diff --git a/src/resource-user/dtos/update-resource-user.dto.ts b/src/resource-user/dtos/update-resource-user.dto.ts index c1f8cf36..8e8c6b8e 100644 --- a/src/resource-user/dtos/update-resource-user.dto.ts +++ b/src/resource-user/dtos/update-resource-user.dto.ts @@ -1,7 +1,9 @@ -import { IsDate, IsOptional } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsNotEmpty } from 'class-validator'; export class UpdateResourceUserDto { - @IsOptional() - @IsDate() - completedAt?: Date; + @IsNotEmpty() + @IsDefined() + @ApiProperty({ type: Number }) + storyblokId: number; } diff --git a/src/resource-user/resource-user.controller.ts b/src/resource-user/resource-user.controller.ts index 0b61ef16..dbf9efc9 100644 --- a/src/resource-user/resource-user.controller.ts +++ b/src/resource-user/resource-user.controller.ts @@ -1,47 +1,61 @@ -import { - Body, - Controller, - HttpException, - Param, - Patch, - Post, - Req, - UseGuards, -} from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { FirebaseAuthGuard } from 'src/firebase/firebase-auth.guard'; -import { CreateResourceUserDto } from './dtos/create-resource-user.dto'; +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { ControllerDecorator } from 'src/utils/controller.decorator'; +import { UserEntity } from '../entities/user.entity'; +import { FirebaseAuthGuard } from '../firebase/firebase-auth.guard'; import { UpdateResourceUserDto } from './dtos/update-resource-user.dto'; import { ResourceUserService } from './resource-user.service'; -@Controller('v1/resource-user') +@ApiTags('Resource User') +@ControllerDecorator() +@Controller('/v1/resource-user') export class ResourceUserController { constructor(private readonly resourceUserService: ResourceUserService) {} @Post() @ApiBearerAuth('access-token') @ApiOperation({ - description: 'Updates resource_user table', + description: + 'Stores relationship between a `User` and `Resource` records, once a user has started a resource.', }) @UseGuards(FirebaseAuthGuard) - create(@Req() req: Request, @Body() createResourceUserDto: CreateResourceUserDto) { - if (req['userEntity'].id !== createResourceUserDto.userId) { - throw new HttpException('Unauthorized', 401); - } - return this.resourceUserService.create(createResourceUserDto); + async createResourceUser( + @Req() req: Request, + @Body() createResourceUserDto: UpdateResourceUserDto, + ) { + return await this.resourceUserService.createResourceUser( + req['userEntity'] as UserEntity, + createResourceUserDto, + ); } - @Patch(':id') + @Post('/complete') + @ApiOperation({ + description: 'Updates a users resources progress to completed', + }) @ApiBearerAuth('access-token') + @UseGuards(FirebaseAuthGuard) + async complete(@Req() req: Request, @Body() updateResourceUserDto: UpdateResourceUserDto) { + return await this.resourceUserService.setResourceUserCompleted( + req['userEntity'] as UserEntity, + updateResourceUserDto, + true, + ); + } + + @Post('/incomplete') @ApiOperation({ - description: 'Updates resource_user table', + description: + 'Updates a users resources progress to incomplete, undoing a previous complete action', }) + @ApiBearerAuth('access-token') @UseGuards(FirebaseAuthGuard) - update( - @Req() req: Request, - @Param('id') id: string, - @Body() updateResourceUserDto: UpdateResourceUserDto, - ) { - return this.resourceUserService.update(id, updateResourceUserDto); + async incomplete(@Req() req: Request, @Body() updateResourceUserDto: UpdateResourceUserDto) { + return await this.resourceUserService.setResourceUserCompleted( + req['userEntity'] as UserEntity, + updateResourceUserDto, + false, + ); } } diff --git a/src/resource-user/resource-user.service.ts b/src/resource-user/resource-user.service.ts index 33b8a55b..bc5c6de4 100644 --- a/src/resource-user/resource-user.service.ts +++ b/src/resource-user/resource-user.service.ts @@ -1,8 +1,11 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ResourceUserEntity } from 'src/entities/resource-user.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { ResourceService } from 'src/resource/resource.service'; +import { formatResourceUserObject } from 'src/utils/serialize'; import { Repository } from 'typeorm'; -import { CreateResourceUserDto } from './dtos/create-resource-user.dto'; +import { ResourceUserDto } from './dtos/resource-user.dto'; import { UpdateResourceUserDto } from './dtos/update-resource-user.dto'; @Injectable() @@ -10,21 +13,85 @@ export class ResourceUserService { constructor( @InjectRepository(ResourceUserEntity) private resourceUserRepository: Repository, + private resourceService: ResourceService, ) {} - create(createResourceUserDto: CreateResourceUserDto) { - return this.resourceUserRepository.save(createResourceUserDto); + private async getResourceUser({ + resourceId, + userId, + }: ResourceUserDto): Promise { + return await this.resourceUserRepository + .createQueryBuilder('resource_user') + .leftJoinAndSelect('resource_user.resource', 'resource') + .where('resource_user.userId = :userId', { userId }) + .andWhere('resource_user.resourceId = :resourceId', { resourceId }) + .getOne(); } - update(id: string, updateResourceUserDto: UpdateResourceUserDto) { - const resourceUser = this.resourceUserRepository.findOne({ where: { id } }); + async createResourceUserRecord({ + resourceId, + userId, + }: ResourceUserDto): Promise { + return await this.resourceUserRepository.save({ + resourceId, + userId, + completedAt: null, + }); + } + + public async createResourceUser(user: UserEntity, { storyblokId }: UpdateResourceUserDto) { + const resource = await this.resourceService.getResourceByStoryblokId(storyblokId); + + if (!resource) { + throw new HttpException('RESOURCE NOT FOUND', HttpStatus.NOT_FOUND); + } + + let resourceUser = await this.getResourceUser({ + resourceId: resource.id, + userId: user.id, + }); if (!resourceUser) { - throw new HttpException('RESOURCE USER NOT FOUND', HttpStatus.NOT_FOUND); + resourceUser = await this.createResourceUserRecord({ + resourceId: resource.id, + userId: user.id, + }); + } + + return formatResourceUserObject([{ ...resourceUser, resource }])[0]; + } + + public async setResourceUserCompleted( + user: UserEntity, + { storyblokId }: UpdateResourceUserDto, + completed: boolean, + ) { + const resource = await this.resourceService.getResourceByStoryblokId(storyblokId); + + if (!resource) { + throw new HttpException( + `Resource not found for storyblok id: ${storyblokId}`, + HttpStatus.NOT_FOUND, + ); } - const updatedResourceUser = { ...resourceUser, ...updateResourceUserDto }; + let resourceUser = await this.getResourceUser({ + resourceId: resource.id, + userId: user.id, + }); + + if (resourceUser) { + await this.resourceUserRepository.save({ + ...resourceUser, + completedAt: completed ? new Date() : null, + }); + } else { + resourceUser = await this.createResourceUserRecord({ + resourceId: resource.id, + userId: user.id, + }); + } - return this.resourceUserRepository.save(updatedResourceUser); + return formatResourceUserObject([{ ...resourceUser, resource }])[0]; } } diff --git a/src/resource/resource.interface.ts b/src/resource/resource.interface.ts new file mode 100644 index 00000000..169ee489 --- /dev/null +++ b/src/resource/resource.interface.ts @@ -0,0 +1,14 @@ +import { RESOURCE_CATEGORIES, STORYBLOK_STORY_STATUS_ENUM } from 'src/utils/constants'; + +export interface IResource { + id?: string; + createdAt?: Date | string; + updatedAt?: Date | string; + name?: string; + slug?: string; + status?: STORYBLOK_STORY_STATUS_ENUM; + storyblokId?: number; + storyblokUuid?: string; + category?: RESOURCE_CATEGORIES; + completedAt?: Date | string; +} diff --git a/src/resource/resource.service.ts b/src/resource/resource.service.ts index cd3eb71e..e881da38 100644 --- a/src/resource/resource.service.ts +++ b/src/resource/resource.service.ts @@ -20,4 +20,7 @@ export class ResourceService { async create(createResourceDto: CreateResourceDto): Promise { return this.resourceRepository.save(createResourceDto); } + async getResourceByStoryblokId(storyblokId: number): Promise { + return await this.resourceRepository.findOneBy({ storyblokId: storyblokId }); + } } diff --git a/src/session-user/dtos/create-session-user.dto.ts b/src/session-user/dtos/create-session-user.dto.ts deleted file mode 100644 index 60676928..00000000 --- a/src/session-user/dtos/create-session-user.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsNotEmpty, IsString } from 'class-validator'; - -export class CreateSessionUserDto { - @IsString() - @IsNotEmpty() - @IsDefined() - @ApiProperty({ type: String }) - sessionId: string; -} diff --git a/src/user/dtos/get-user.dto.ts b/src/user/dtos/get-user.dto.ts index 6aecbb85..f534942c 100644 --- a/src/user/dtos/get-user.dto.ts +++ b/src/user/dtos/get-user.dto.ts @@ -1,4 +1,5 @@ import { ICoursesWithSessions } from 'src/course/course.interface'; +import { IResource } from 'src/resource/resource.interface'; import { ITherapySession } from 'src/webhooks/webhooks.interface'; import { IPartnerAccessWithPartner } from '../../partner-access/partner-access.interface'; import { IPartnerAdminWithPartner } from '../../partner-admin/partner-admin.interface'; @@ -10,6 +11,7 @@ export class GetUserDto { partnerAccesses?: IPartnerAccessWithPartner[]; partnerAdmin?: IPartnerAdminWithPartner; courses?: ICoursesWithSessions[]; + resources?: IResource[]; therapySessions?: ITherapySession[]; subscriptions?: ISubscriptionUser[]; } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 95ba8f76..16758b6a 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -142,6 +142,8 @@ export class UserService { .leftJoinAndSelect('courseUser.course', 'course') .leftJoinAndSelect('courseUser.sessionUser', 'sessionUser') .leftJoinAndSelect('sessionUser.session', 'session') + .leftJoinAndSelect('user.resourceUser', 'resourceUser') + .leftJoinAndSelect('resourceUser.resource', 'resource') .leftJoinAndSelect('user.subscriptionUser', 'subscriptionUser') .leftJoinAndSelect('subscriptionUser.subscription', 'subscription') .where('user.id = :id', { id }) diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts index a97e4dc8..0d8f31b6 100644 --- a/src/utils/serialize.ts +++ b/src/utils/serialize.ts @@ -1,5 +1,6 @@ import { PartnerAdminEntity } from 'src/entities/partner-admin.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; +import { ResourceUserEntity } from 'src/entities/resource-user.entity'; import { IPartnerFeature } from 'src/partner-feature/partner-feature.interface'; import { IPartner } from 'src/partner/partner.interface'; import { GetSubscriptionUserDto } from 'src/subscription-user/dto/get-subscription-user.dto'; @@ -43,6 +44,22 @@ export const formatCourseUserObject = (courseUser: CourseUserEntity) => { }; }; +export const formatResourceUserObject = (resourceUsers: ResourceUserEntity[]) => { + return resourceUsers.map((resourceUser) => { + return { + id: resourceUser.resource.id, + createdAt: resourceUser.createdAt, + updatedAt: resourceUser.updatedAt, + name: resourceUser.resource.name, + slug: resourceUser.resource.slug, + status: resourceUser.resource.status, + storyblokId: resourceUser.resource.storyblokId, + storyblokUuid: resourceUser.resource.storyblokUuid, + completed: !!resourceUser.completedAt, // convert to boolean from data populated + }; + }); +}; + export const formatPartnerAdminObjects = (partnerAdminObject: PartnerAdminEntity) => { return { id: partnerAdminObject.id, @@ -113,6 +130,7 @@ export const formatUserObject = (userObject: UserEntity): GetUserDto => { ? formatPartnerAdminObjects(userObject.partnerAdmin) : null, courses: userObject.courseUser ? formatCourseUserObjects(userObject.courseUser) : [], + resources: userObject.resourceUser ? formatResourceUserObject(userObject.resourceUser) : [], subscriptions: userObject.subscriptionUser && userObject.subscriptionUser.length > 0 ? formatSubscriptionObjects(userObject.subscriptionUser)