From 55aac1175272a8806bcbd533333d54fcda4e3767 Mon Sep 17 00:00:00 2001 From: Max Weng <73797155+maxwn04@users.noreply.github.com> Date: Sat, 17 Feb 2024 20:42:38 -0800 Subject: [PATCH 1/8] Store multiple collection images (#360) * migration * Schema and response classes and interfaces * validators and naming * Merch Collection Photo Repository * repository and models stuff * api request bug * renaming and bugfix * renaming and stuff * types * add/delete collection photo, edit collection fix service * naming and documentation * migration name fix * delete controller * renaming and shifting types for consistency * factory and start seed * bugfixes for postman test * tests * weird tests stuff * deletion logic change, cascade delete * sort fix * ling * naming and some position logic * merge stuff * seed data change * test change * remove automatically end of the list * lint stuff * build and lint fix * lint * more lint * fix migration * update version and database connection * remove unused collection in test * lint and migration rename --- api/controllers/MerchStoreController.ts | 37 ++++ api/validators/MerchStoreRequests.ts | 32 ++++ config/index.ts | 1 + migrations/0039-add-collection-image-table.ts | 58 ++++++ ...Event-column-to-orderPickupEvent-table.ts} | 0 models/MerchCollectionPhotoModel.ts | 33 ++++ models/MerchandiseCollectionModel.ts | 5 + models/index.ts | 2 + package.json | 2 +- repositories/MerchStoreRepository.ts | 32 +++- repositories/index.ts | 5 + services/MerchStoreService.ts | 111 +++++++++++- tests/Seeds.ts | 30 ++++ tests/data/DatabaseConnection.ts | 1 + tests/data/MerchFactory.ts | 27 ++- tests/data/PortalState.ts | 1 + tests/merchStore.test.ts | 167 ++++++++++++++++++ types/ApiRequests.ts | 23 ++- types/ApiResponses.ts | 14 ++ 19 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 migrations/0039-add-collection-image-table.ts rename migrations/{0039-add-linkedEvent-column-to-orderPickupEvent-table.ts => 0040-add-linkedEvent-column-to-orderPickupEvent-table.ts} (100%) create mode 100644 models/MerchCollectionPhotoModel.ts diff --git a/api/controllers/MerchStoreController.ts b/api/controllers/MerchStoreController.ts index bfaca0b92..4bcf915ed 100644 --- a/api/controllers/MerchStoreController.ts +++ b/api/controllers/MerchStoreController.ts @@ -20,6 +20,8 @@ import { GetAllMerchCollectionsResponse, CreateMerchCollectionResponse, EditMerchCollectionResponse, + CreateCollectionPhotoResponse, + DeleteCollectionPhotoResponse, GetOneMerchItemResponse, DeleteMerchCollectionResponse, CreateMerchItemResponse, @@ -55,6 +57,7 @@ import MerchStoreService from '../../services/MerchStoreService'; import { CreateMerchCollectionRequest, EditMerchCollectionRequest, + CreateCollectionPhotoRequest, CreateMerchItemRequest, EditMerchItemRequest, PlaceMerchOrderRequest, @@ -124,6 +127,40 @@ export class MerchStoreController { return { error: null }; } + @UseBefore(UserAuthentication) + @Post('/collection/picture/:uuid') + async createMerchCollectionPhoto(@UploadedFile('image', + { options: StorageService.getFileOptions(MediaType.MERCH_PHOTO) }) file: File, + @Params() params: UuidParam, + @Body() createCollectionRequest: CreateCollectionPhotoRequest, + @AuthenticatedUser() user: UserModel): Promise { + if (!PermissionsService.canEditMerchStore(user)) throw new ForbiddenError(); + + // generate a random string for the uploaded photo url + const position = parseInt(createCollectionRequest.position, 10); + if (Number.isNaN(position)) throw new BadRequestError('Position must be a number'); + const uniqueFileName = uuid(); + const uploadedPhoto = await this.storageService.uploadToFolder( + file, MediaType.MERCH_PHOTO, uniqueFileName, params.uuid, + ); + const collectionPhoto = await this.merchStoreService.createCollectionPhoto( + params.uuid, { uploadedPhoto, position }, + ); + + return { error: null, collectionPhoto }; + } + + @UseBefore(UserAuthentication) + @Delete('/collection/picture/:uuid') + async deleteMerchCollectionPhoto(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel): + Promise { + if (!PermissionsService.canEditMerchStore(user)) throw new ForbiddenError(); + const photoToDelete = await this.merchStoreService.getCollectionPhotoForDeletion(params.uuid); + await this.storageService.deleteAtUrl(photoToDelete.uploadedPhoto); + await this.merchStoreService.deleteCollectionPhoto(photoToDelete); + return { error: null }; + } + @Get('/item/:uuid') async getOneMerchItem(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel): Promise { diff --git a/api/validators/MerchStoreRequests.ts b/api/validators/MerchStoreRequests.ts index 379b740b1..5159025ee 100644 --- a/api/validators/MerchStoreRequests.ts +++ b/api/validators/MerchStoreRequests.ts @@ -17,6 +17,7 @@ import { Type } from 'class-transformer'; import { CreateMerchCollectionRequest as ICreateMerchCollectionRequest, EditMerchCollectionRequest as IEditMerchCollectionRequest, + CreateCollectionPhotoRequest as ICreateCollectionPhotoRequest, CreateMerchItemRequest as ICreateMerchItemRequest, EditMerchItemRequest as IEditMerchItemRequest, CreateMerchItemOptionRequest as ICreateMerchItemOptionRequest, @@ -32,6 +33,8 @@ import { OrderItemFulfillmentUpdate as IOrderItemFulfillmentUpdate, MerchCollection as IMerchCollection, MerchCollectionEdit as IMerchCollectionEdit, + MerchCollectionPhoto as IMerchCollectionPhoto, + MerchCollectionPhotoEdit as IMerchCollectionPhotoEdit, MerchItem as IMerchItem, MerchItemEdit as IMerchItemEdit, MerchItemOption as IMerchItemOption, @@ -60,6 +63,9 @@ export class MerchCollection implements IMerchCollection { @Allow() archived?: boolean; + + @Allow() + collectionPhotos: MerchCollectionPhoto[]; } export class MerchCollectionEdit implements IMerchCollectionEdit { @@ -78,6 +84,26 @@ export class MerchCollectionEdit implements IMerchCollectionEdit { @Min(0) @Max(100) discountPercentage?: number; + + @Allow() + collectionPhotos?: MerchCollectionPhotoEdit[]; +} + +export class MerchCollectionPhoto implements IMerchCollectionPhoto { + @Allow() + uploadedPhoto: string; + + @Allow() + position: number; +} + +export class MerchCollectionPhotoEdit implements IMerchCollectionPhotoEdit { + @IsDefined() + @IsUUID() + uuid: string; + + @Allow() + position: number; } export class MerchItemOptionMetadata implements IMerchItemOptionMetadata { @@ -305,6 +331,12 @@ export class EditMerchCollectionRequest implements IEditMerchCollectionRequest { collection: MerchCollectionEdit; } +export class CreateCollectionPhotoRequest implements ICreateCollectionPhotoRequest { + @IsDefined() + @IsNumberString() + position: string; +} + export class CreateMerchItemRequest implements ICreateMerchItemRequest { @Type(() => MerchItem) @ValidateNested() diff --git a/config/index.ts b/config/index.ts index 58c321b7a..b2a13d264 100644 --- a/config/index.ts +++ b/config/index.ts @@ -60,6 +60,7 @@ export const Config = { MAX_EVENT_COVER_FILE_SIZE: Number(process.env.MAX_EVENT_COVER_FILE_SIZE) * BYTES_PER_KILOBYTE, MAX_BANNER_FILE_SIZE: Number(process.env.MAX_BANNER_FILE_SIZE) * BYTES_PER_KILOBYTE, MAX_MERCH_PHOTO_FILE_SIZE: Number(process.env.MAX_MERCH_PHOTO_FILE_SIZE) * BYTES_PER_KILOBYTE, + MAX_COLLECTION_PHOTO_FILE_SIZE: Number(process.env.MAX_MERCH_PHOTO_FILE_SIZE) * BYTES_PER_KILOBYTE, MAX_RESUME_FILE_SIZE: Number(process.env.MAX_RESUME_FILE_SIZE) * BYTES_PER_KILOBYTE, PROFILE_PICTURE_UPLOAD_PATH: process.env.BASE_UPLOAD_PATH + process.env.PROFILE_PICTURE_UPLOAD_PATH, EVENT_COVER_UPLOAD_PATH: process.env.BASE_UPLOAD_PATH + process.env.EVENT_COVER_UPLOAD_PATH, diff --git a/migrations/0039-add-collection-image-table.ts b/migrations/0039-add-collection-image-table.ts new file mode 100644 index 000000000..eef968fd6 --- /dev/null +++ b/migrations/0039-add-collection-image-table.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +const TABLE_NAME = 'MerchCollectionPhotos'; +const COLLECTION_TABLE_NAME = 'MerchandiseCollections'; + +export class AddCollectionImageTable1696990832868 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable(new Table({ + name: TABLE_NAME, + columns: [ + { + name: 'uuid', + type: 'uuid', + isGenerated: true, + isPrimary: true, + generationStrategy: 'uuid', + }, + { + name: 'merchCollection', + type: 'uuid', + }, + { + name: 'uploadedPhoto', + type: 'varchar(255)', + }, + { + name: 'uploadedAt', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP(6)', + }, + { + name: 'position', + type: 'integer', + }, + ], + // cascade delete + foreignKeys: [ + { + columnNames: ['merchCollection'], + referencedTableName: COLLECTION_TABLE_NAME, + referencedColumnNames: ['uuid'], + onDelete: 'CASCADE', + }, + ], + })); + + await queryRunner.createIndices(TABLE_NAME, [ + new TableIndex({ + name: 'images_by_collection_index', + columnNames: ['merchCollection'], + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable(TABLE_NAME); + } +} diff --git a/migrations/0039-add-linkedEvent-column-to-orderPickupEvent-table.ts b/migrations/0040-add-linkedEvent-column-to-orderPickupEvent-table.ts similarity index 100% rename from migrations/0039-add-linkedEvent-column-to-orderPickupEvent-table.ts rename to migrations/0040-add-linkedEvent-column-to-orderPickupEvent-table.ts diff --git a/models/MerchCollectionPhotoModel.ts b/models/MerchCollectionPhotoModel.ts new file mode 100644 index 000000000..e3e3bdbea --- /dev/null +++ b/models/MerchCollectionPhotoModel.ts @@ -0,0 +1,33 @@ +import { BaseEntity, Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { PublicMerchCollectionPhoto, Uuid } from '../types'; +import { MerchandiseCollectionModel } from './MerchandiseCollectionModel'; + +@Entity('MerchCollectionPhotos') +export class MerchCollectionPhotoModel extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + uuid: Uuid; + + @ManyToOne((type) => MerchandiseCollectionModel, + (merchCollection) => merchCollection.collectionPhotos, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'merchCollection' }) + @Index('images_by_collection_index') + merchCollection: MerchandiseCollectionModel; + + @Column('varchar', { length: 255, nullable: false }) + uploadedPhoto: string; + + @Column('timestamptz', { default: () => 'CURRENT_TIMESTAMP(6)', nullable: false }) + uploadedAt: Date; + + @Column('integer') + position: number; + + public getPublicMerchCollectionPhoto(): PublicMerchCollectionPhoto { + return { + uuid: this.uuid, + uploadedPhoto: this.uploadedPhoto, + uploadedAt: this.uploadedAt, + position: this.position, + }; + } +} diff --git a/models/MerchandiseCollectionModel.ts b/models/MerchandiseCollectionModel.ts index 94b30bcfa..6c7d5da2a 100644 --- a/models/MerchandiseCollectionModel.ts +++ b/models/MerchandiseCollectionModel.ts @@ -1,6 +1,7 @@ import { Entity, BaseEntity, Column, PrimaryGeneratedColumn, OneToMany, CreateDateColumn } from 'typeorm'; import { Uuid, PublicMerchCollection } from '../types'; import { MerchandiseItemModel } from './MerchandiseItemModel'; +import { MerchCollectionPhotoModel } from './MerchCollectionPhotoModel'; @Entity('MerchandiseCollections') export class MerchandiseCollectionModel extends BaseEntity { @@ -25,10 +26,14 @@ export class MerchandiseCollectionModel extends BaseEntity { @OneToMany((type) => MerchandiseItemModel, (item) => item.collection, { cascade: true }) items: MerchandiseItemModel[]; + @OneToMany((type) => MerchCollectionPhotoModel, (photo) => photo.merchCollection, { cascade: true }) + collectionPhotos: MerchCollectionPhotoModel[]; + public getPublicMerchCollection(canSeeHiddenItems = false): PublicMerchCollection { const baseMerchCollection: any = { uuid: this.uuid, title: this.title, + collectionPhotos: this.collectionPhotos, themeColorHex: this.themeColorHex, description: this.description, createdAt: this.createdAt, diff --git a/models/index.ts b/models/index.ts index e571f4e2f..a077f5dc6 100644 --- a/models/index.ts +++ b/models/index.ts @@ -3,6 +3,7 @@ import { ActivityModel } from './ActivityModel'; import { EventModel } from './EventModel'; import { AttendanceModel } from './AttendanceModel'; import { MerchandiseCollectionModel } from './MerchandiseCollectionModel'; +import { MerchCollectionPhotoModel } from './MerchCollectionPhotoModel'; import { MerchandiseItemModel } from './MerchandiseItemModel'; import { MerchandiseItemPhotoModel } from './MerchandiseItemPhotoModel'; import { OrderModel } from './OrderModel'; @@ -20,6 +21,7 @@ export const models = [ EventModel, AttendanceModel, MerchandiseCollectionModel, + MerchCollectionPhotoModel, MerchandiseItemModel, MerchandiseItemPhotoModel, MerchandiseItemOptionModel, diff --git a/package.json b/package.json index 39b410952..83243d054 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.1.0", + "version": "3.2.0", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/repositories/MerchStoreRepository.ts b/repositories/MerchStoreRepository.ts index 7f5f7e478..0eedb4b7c 100644 --- a/repositories/MerchStoreRepository.ts +++ b/repositories/MerchStoreRepository.ts @@ -2,6 +2,7 @@ import { EntityRepository, SelectQueryBuilder } from 'typeorm'; import { MerchandiseItemOptionModel } from '../models/MerchandiseItemOptionModel'; import { MerchandiseItemPhotoModel } from '../models/MerchandiseItemPhotoModel'; import { MerchandiseCollectionModel } from '../models/MerchandiseCollectionModel'; +import { MerchCollectionPhotoModel } from '../models/MerchCollectionPhotoModel'; import { MerchandiseItemModel } from '../models/MerchandiseItemModel'; import { Uuid } from '../types'; import { BaseRepository } from './BaseRepository'; @@ -40,15 +41,44 @@ export class MerchCollectionRepository extends BaseRepository { return this.repository.createQueryBuilder('collection') .leftJoinAndSelect('collection.items', 'items') + .leftJoinAndSelect('collection.collectionPhotos', 'collectionPhotos') .leftJoinAndSelect('items.options', 'options') .leftJoinAndSelect('items.merchPhotos', 'merchPhotos'); } } +@EntityRepository(MerchCollectionPhotoModel) +export class MerchCollectionPhotoRepository extends BaseRepository { + public async findByUuid(uuid: Uuid): Promise { + return this.repository.findOne(uuid, { relations: ['merchCollection'] }); + } + + // for querying a group of pictures together + public async batchFindByUuid(uuids: Uuid[]): Promise> { + const photos = await this.repository.findByIds(uuids, { relations: ['merchCollection'] }); + return new Map(photos.map((o) => [o.uuid, o])); + } + + public async upsertCollectionPhoto(photo: MerchCollectionPhotoModel, + changes?: Partial): Promise { + if (changes) photo = MerchCollectionPhotoModel.merge(photo, changes); + return this.repository.save(photo); + } + + public async deleteCollectionPhoto(photo: MerchCollectionPhotoModel): Promise { + await this.repository.remove(photo); + } +} + @EntityRepository(MerchandiseItemModel) export class MerchItemRepository extends BaseRepository { public async findByUuid(uuid: Uuid): Promise { - return this.repository.findOne(uuid, { relations: ['collection', 'options', 'merchPhotos'] }); + return this.repository.findOne(uuid, { relations: [ + 'collection', + 'options', + 'merchPhotos', + 'collection.collectionPhotos', + ] }); } public async upsertMerchItem(item: MerchandiseItemModel, changes?: Partial): diff --git a/repositories/index.ts b/repositories/index.ts index 02a50c929..540ab69d7 100644 --- a/repositories/index.ts +++ b/repositories/index.ts @@ -8,6 +8,7 @@ import { MerchCollectionRepository, MerchItemRepository, MerchItemOptionRepository, + MerchCollectionPhotoRepository, MerchItemPhotoRepository, } from './MerchStoreRepository'; import { ActivityRepository } from './ActivityRepository'; @@ -48,6 +49,10 @@ export default class Repositories { return transactionalEntityManager.getCustomRepository(MerchCollectionRepository); } + public static merchStoreCollectionPhoto(transactionalEntityManager: EntityManager): MerchCollectionPhotoRepository { + return transactionalEntityManager.getCustomRepository(MerchCollectionPhotoRepository); + } + public static merchStoreItem(transactionalEntityManager: EntityManager): MerchItemRepository { return transactionalEntityManager.getCustomRepository(MerchItemRepository); } diff --git a/services/MerchStoreService.ts b/services/MerchStoreService.ts index 9bdac82bb..016186947 100644 --- a/services/MerchStoreService.ts +++ b/services/MerchStoreService.ts @@ -25,6 +25,8 @@ import { OrderPickupEventStatus, PublicMerchItemPhoto, MerchItemPhoto, + PublicMerchCollectionPhoto, + MerchCollectionPhoto, } from '../types'; import { MerchandiseItemModel } from '../models/MerchandiseItemModel'; import { OrderModel } from '../models/OrderModel'; @@ -32,6 +34,7 @@ import { UserModel } from '../models/UserModel'; import { EventModel } from '../models/EventModel'; import Repositories, { TransactionsManager } from '../repositories'; import { MerchandiseCollectionModel } from '../models/MerchandiseCollectionModel'; +import { MerchCollectionPhotoModel } from '../models/MerchCollectionPhotoModel'; import EmailService, { OrderInfo, OrderPickupEventInfo } from './EmailService'; import { UserError } from '../utils/Errors'; import { OrderItemModel } from '../models/OrderItemModel'; @@ -42,6 +45,8 @@ import { MerchandiseItemPhotoModel } from '../models/MerchandiseItemPhotoModel'; export default class MerchStoreService { private static readonly MAX_MERCH_PHOTO_COUNT = 5; + private static readonly MAX_COLLECTION_PHOTO_COUNT = 5; + private emailService: EmailService; private transactions: TransactionsManager; @@ -57,6 +62,7 @@ export default class MerchStoreService { .findByUuid(uuid)); if (!collection) throw new NotFoundError('Merch collection not found'); if (collection.archived && !canSeeInactiveCollections) throw new ForbiddenError(); + collection.collectionPhotos = collection.collectionPhotos.sort((a, b) => a.position - b.position); return canSeeInactiveCollections ? collection : collection.getPublicMerchCollection(); } @@ -78,14 +84,15 @@ export default class MerchStoreService { .upsertMerchCollection(MerchandiseCollectionModel.create(collection))); } - public async editCollection(uuid: Uuid, changes: MerchCollectionEdit): Promise { + public async editCollection(uuid: Uuid, collectionEdit: MerchCollectionEdit): Promise { return this.transactions.readWrite(async (txn) => { const merchCollectionRepository = Repositories.merchStoreCollection(txn); const currentCollection = await merchCollectionRepository.findByUuid(uuid); if (!currentCollection) throw new NotFoundError('Merch collection not found'); - let updatedCollection = await merchCollectionRepository.upsertMerchCollection(currentCollection, changes); - if (changes.discountPercentage !== undefined) { - const { discountPercentage } = changes; + + const { discountPercentage, collectionPhotos, ...changes } = collectionEdit; + + if (discountPercentage !== undefined) { await Repositories .merchStoreItemOption(txn) .updateMerchItemOptionsInCollection(uuid, discountPercentage); @@ -95,9 +102,33 @@ export default class MerchStoreService { .merchStoreItem(txn) .updateMerchItemsInCollection(uuid, { hidden: changes.archived }); } - if (changes.discountPercentage !== undefined || changes.archived !== undefined) { + + // this part only handles updating the positions of the pictures + if (collectionPhotos) { + // error on duplicate photo uuids + const dupSet = new Set(); + collectionPhotos.forEach((merchPhoto) => { + if (dupSet.has(merchPhoto.uuid)) { + throw new UserError(`Multiple edits is made to photo: ${merchPhoto.uuid}`); + } + dupSet.add(merchPhoto.uuid); + }); + + const photoUpdatesByUuid = new Map(collectionPhotos.map((merchPhoto) => [merchPhoto.uuid, merchPhoto])); + + currentCollection.collectionPhotos.map((currentPhoto) => { + if (!photoUpdatesByUuid.has(currentPhoto.uuid)) return; + const photoUpdate = photoUpdatesByUuid.get(currentPhoto.uuid); + return MerchCollectionPhotoModel.merge(currentPhoto, photoUpdate); + }); + } + + let updatedCollection = await merchCollectionRepository.upsertMerchCollection(currentCollection, changes); + + if (discountPercentage !== undefined || changes.archived !== undefined) { updatedCollection = await merchCollectionRepository.findByUuid(uuid); } + return updatedCollection; }); } @@ -115,6 +146,76 @@ export default class MerchStoreService { }); } + /** + * Verify that collections have valid photots. + */ + private static verifyCollectionHasValidPhotos(collection: MerchCollection | MerchandiseCollectionModel) { + if (collection.collectionPhotos.length > this.MAX_COLLECTION_PHOTO_COUNT) { + throw new UserError('Collections cannot have more than 5 pictures'); + } + } + + /** + * Creates a collection photo and assign it the corresponding picture url + * and append the photo to the photos list from merchItem + * @param collection merch collection uuid + * @param properties merch collection photo picture url and position + * @returns created collection photo + */ + public async createCollectionPhoto(collection: Uuid, properties: MerchCollectionPhoto): + Promise { + return this.transactions.readWrite(async (txn) => { + const merchCollection = await Repositories.merchStoreCollection(txn).findByUuid(collection); + if (!merchCollection) throw new NotFoundError('Collection not found'); + + const createdPhoto = MerchCollectionPhotoModel.create({ ...properties, merchCollection }); + const merchStoreCollectionPhotoRepository = Repositories.merchStoreCollectionPhoto(txn); + + // verify the result photos array + merchCollection.collectionPhotos.push(createdPhoto); + MerchStoreService.verifyCollectionHasValidPhotos(merchCollection); + + const upsertedPhoto = await merchStoreCollectionPhotoRepository.upsertCollectionPhoto(createdPhoto); + return upsertedPhoto.getPublicMerchCollectionPhoto(); + }); + } + + /** + * Check if the photo is ready to be deleted. Fail if the merch item is visible + * and it was the only photo of the item. + * + * @param uuid the uuid of photo to be deleted + * @returns the photo object to be removed from database + */ + public async getCollectionPhotoForDeletion(uuid: Uuid): Promise { + return this.transactions.readWrite(async (txn) => { + const merchCollectionPhotoRepository = Repositories.merchStoreCollectionPhoto(txn); + const collectionPhoto = await merchCollectionPhotoRepository.findByUuid(uuid); + if (!collectionPhoto) throw new NotFoundError('Merch collection photo not found'); + + const collection = await Repositories.merchStoreCollection(txn).findByUuid(collectionPhoto.merchCollection.uuid); + if (collection.collectionPhotos.length === 1) { + throw new UserError('Cannot delete the only photo for a collection'); + } + + return collectionPhoto; + }); + } + + /** + * Deletes the given item photo. + * + * @param merchPhoto the photo object to be removed + * @returns the photo object removed from database + */ + public async deleteCollectionPhoto(collectionPhoto: MerchCollectionPhotoModel): Promise { + return this.transactions.readWrite(async (txn) => { + const merchStoreItemPhotoRepository = Repositories.merchStoreCollectionPhoto(txn); + await merchStoreItemPhotoRepository.deleteCollectionPhoto(collectionPhoto); + return collectionPhoto; + }); + } + public async findItemByUuid(uuid: Uuid, user: UserModel): Promise { return this.transactions.readOnly(async (txn) => { const item = await Repositories.merchStoreItem(txn).findByUuid(uuid); diff --git a/tests/Seeds.ts b/tests/Seeds.ts index 31c57a729..7954f1973 100644 --- a/tests/Seeds.ts +++ b/tests/Seeds.ts @@ -378,6 +378,28 @@ async function seed(): Promise { description: 'Do you like to code? Tell the world with this Hack School inspired collection.', themeColorHex: '#EB8C34', }); + + const MERCH_COLLECTION_1_PHOTO_1 = MerchFactory.fakeCollectionPhoto({ + merchCollection: MERCH_COLLECTION_1, + position: 0, + }); + const MERCH_COLLECTION_1_PHOTO_2 = MerchFactory.fakeCollectionPhoto({ + merchCollection: MERCH_COLLECTION_1, + uploadedPhoto: 'https://www.fakepicture.com/', + position: 1, + }); + const MERCH_COLLECTION_1_PHOTO_3 = MerchFactory.fakeCollectionPhoto({ + merchCollection: MERCH_COLLECTION_1, + uploadedPhoto: 'https://i.imgur.com/pSZ921P.png', + position: 2, + }); + + MERCH_COLLECTION_1.collectionPhotos = [ + MERCH_COLLECTION_1_PHOTO_1, + MERCH_COLLECTION_1_PHOTO_2, + MERCH_COLLECTION_1_PHOTO_3, + ]; + const MERCH_ITEM_1 = MerchFactory.fakeItem({ collection: MERCH_COLLECTION_1, itemName: 'Unisex Hack School Anorak', @@ -538,6 +560,14 @@ async function seed(): Promise { title: 'Fall 2001', description: 'Celebrate the opening of Sixth College in style, featuring raccoon print jackets.', }); + + const MERCH_COLLECTION_2_PHOTO_1 = MerchFactory.fakeCollectionPhoto({ + merchCollection: MERCH_COLLECTION_2, + uploadedPhoto: 'https://www.fakepicture.com/', + position: 1, + }); + MERCH_COLLECTION_2.collectionPhotos = [MERCH_COLLECTION_2_PHOTO_1]; + const MERCH_ITEM_3 = MerchFactory.fakeItem({ collection: MERCH_COLLECTION_2, itemName: 'Camp Snoopy Snapback', diff --git a/tests/data/DatabaseConnection.ts b/tests/data/DatabaseConnection.ts index d3913b391..bbe166bb1 100644 --- a/tests/data/DatabaseConnection.ts +++ b/tests/data/DatabaseConnection.ts @@ -44,6 +44,7 @@ export class DatabaseConnection { 'Feedback', 'Resumes', 'UserSocialMedia', + 'MerchCollectionPhotos', ]; await Promise.all(tableNames.map((t) => txn.query(`DELETE FROM "${t}"`))); }); diff --git a/tests/data/MerchFactory.ts b/tests/data/MerchFactory.ts index a97ace121..b28349329 100644 --- a/tests/data/MerchFactory.ts +++ b/tests/data/MerchFactory.ts @@ -1,13 +1,14 @@ import * as faker from 'faker'; import * as moment from 'moment'; import { v4 as uuid } from 'uuid'; -import FactoryUtils from './FactoryUtils'; import { MerchItemOptionMetadata, OrderPickupEventStatus } from '../../types'; import { OrderPickupEventModel } from '../../models/OrderPickupEventModel'; import { MerchandiseCollectionModel } from '../../models/MerchandiseCollectionModel'; +import { MerchCollectionPhotoModel } from '../../models/MerchCollectionPhotoModel'; import { MerchandiseItemModel } from '../../models/MerchandiseItemModel'; import { MerchandiseItemOptionModel } from '../../models/MerchandiseItemOptionModel'; import { MerchandiseItemPhotoModel } from '../../models/MerchandiseItemPhotoModel'; +import FactoryUtils from './FactoryUtils'; export class MerchFactory { public static fakeCollection(substitute?: Partial): MerchandiseCollectionModel { @@ -28,6 +29,14 @@ export class MerchFactory { hidden: substitute?.archived, })); } + + if (!substitute?.collectionPhotos) { + const numPhotos = FactoryUtils.getRandomNumber(1, 5); + fake.collectionPhotos = MerchFactory + .createCollectionPhotos(numPhotos) + .map((collectionPhoto) => MerchCollectionPhotoModel.merge(collectionPhoto, { merchCollection: fake })); + } + return MerchandiseCollectionModel.merge(fake, substitute); } @@ -69,6 +78,22 @@ export class MerchFactory { return MerchandiseItemPhotoModel.merge(fake, substitute); } + public static fakeCollectionPhoto(substitute?: Partial): MerchCollectionPhotoModel { + const fake = MerchCollectionPhotoModel.create({ + uuid: uuid(), + position: 0, + uploadedPhoto: 'https://www.fakepicture.com/', + uploadedAt: faker.date.recent(), + }); + return MerchCollectionPhotoModel.merge(fake, substitute); + } + + private static createCollectionPhotos(n: number): MerchCollectionPhotoModel[] { + return FactoryUtils + .create(n, () => MerchFactory.fakeCollectionPhoto()) + .map((collectionPhoto, i) => MerchCollectionPhotoModel.merge(collectionPhoto, { position: i })); + } + public static fakeOption(substitute?: Partial): MerchandiseItemOptionModel { const fake = MerchandiseItemOptionModel.create({ uuid: uuid(), diff --git a/tests/data/PortalState.ts b/tests/data/PortalState.ts index 2838b85af..bb2a5f6ff 100644 --- a/tests/data/PortalState.ts +++ b/tests/data/PortalState.ts @@ -100,6 +100,7 @@ export class PortalState { public createMerchItem(item: MerchandiseItemModel): PortalState { const collectionWithItem = MerchFactory.fakeCollection({ items: [item] }); + // console.log(collectionWithItem) return this.createMerchCollections(collectionWithItem); } diff --git a/tests/merchStore.test.ts b/tests/merchStore.test.ts index c04a8e7cd..cbaa4bce6 100644 --- a/tests/merchStore.test.ts +++ b/tests/merchStore.test.ts @@ -161,6 +161,7 @@ describe('creating merch collections', () => { .toEqual(expectedCollectionOrder); }); }); + describe('editing merch collections', () => { test('only admins can edit merch collections', async () => { const conn = await DatabaseConnection.get(); @@ -187,6 +188,172 @@ describe('editing merch collections', () => { }); }); +describe('merch collection photos', () => { + const folderLocation = 'https://s3.amazonaws.com/upload-photo/'; + + test('can create a collection with up to 5 pictures', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const photo1 = MerchFactory.fakeCollectionPhoto(); + const collection = MerchFactory.fakeCollection({ collectionPhotos: [photo1] }); + + await new PortalState() + .createUsers(admin) + .createMerchCollections(collection) + .write(); + + const image2 = FileFactory.image(Config.file.MAX_MERCH_PHOTO_FILE_SIZE / 2); + const image3 = FileFactory.image(Config.file.MAX_MERCH_PHOTO_FILE_SIZE / 2); + const image4 = FileFactory.image(Config.file.MAX_MERCH_PHOTO_FILE_SIZE / 2); + const image5 = FileFactory.image(Config.file.MAX_MERCH_PHOTO_FILE_SIZE / 2); + const imageExtra = FileFactory.image(Config.file.MAX_MERCH_PHOTO_FILE_SIZE / 2); + const storageService = Mocks.storage(folderLocation); + + const merchStoreController = ControllerFactory.merchStore( + conn, + undefined, + instance(storageService), + ); + + const params = { uuid: collection.uuid }; + + const response2 = await merchStoreController.createMerchCollectionPhoto(image2, params, { position: '1' }, admin); + const response3 = await merchStoreController.createMerchCollectionPhoto(image3, params, { position: '2' }, admin); + const response4 = await merchStoreController.createMerchCollectionPhoto(image4, params, { position: '3' }, admin); + const response5 = await merchStoreController.createMerchCollectionPhoto(image5, params, { position: '4' }, admin); + + // checking no error is thrown and storage is correctly modified + // enough to check first and last response + expect(response2.error).toBe(null); + expect(response5.error).toBe(null); + verify( + storageService.uploadToFolder( + image2, + MediaType.MERCH_PHOTO, + anything(), + anything(), + ), + ).called(); + verify( + storageService.uploadToFolder( + image5, + MediaType.MERCH_PHOTO, + anything(), + anything(), + ), + ).called(); + + const photo2 = response2.collectionPhoto; + const photo3 = response3.collectionPhoto; + const photo4 = response4.collectionPhoto; + const photo5 = response5.collectionPhoto; + + // 0 index + expect(photo2.position).toBe(1); + expect(photo3.position).toBe(2); + expect(photo4.position).toBe(3); + expect(photo5.position).toBe(4); + + const photos = [photo1, photo2, photo3, photo4, photo5]; + expect((await merchStoreController.getOneMerchCollection(params, admin)).collection.collectionPhotos) + .toEqual(photos); + + expect(merchStoreController.createMerchCollectionPhoto(imageExtra, params, { position: '5' }, admin)) + .rejects.toThrow('Collections cannot have more than 5 pictures'); + }); + + test('can remap the picture of a collection to different orders', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const photo1 = MerchFactory.fakeCollectionPhoto({ position: 0 }); + const photo2 = MerchFactory.fakeCollectionPhoto({ position: 1 }); + const photo3 = MerchFactory.fakeCollectionPhoto({ position: 2 }); + const photo4 = MerchFactory.fakeCollectionPhoto({ position: 3 }); + const photo5 = MerchFactory.fakeCollectionPhoto({ position: 4 }); + const collectionPhotos = [photo1, photo2, photo3, photo4, photo5]; + const collection = MerchFactory.fakeCollection({ collectionPhotos }); + + await new PortalState() + .createUsers(admin) + .createMerchCollections(collection) + .write(); + + const merchStoreController = ControllerFactory.merchStore(conn); + const params = { uuid: collection.uuid }; + + // check before remap whether photos are correctly positioned + expect((await merchStoreController.getOneMerchCollection(params, admin)) + .collection.collectionPhotos).toEqual(collectionPhotos); + + // reversing the order of the photos + const editMerchCollectionRequest = { collection: { + collectionPhotos: [ + { uuid: photo5.uuid, position: 0 }, + { uuid: photo4.uuid, position: 1 }, + { uuid: photo3.uuid, position: 2 }, + { uuid: photo2.uuid, position: 3 }, + { uuid: photo1.uuid, position: 4 }, + ], + } }; + + await merchStoreController.editMerchCollection(params, editMerchCollectionRequest, admin); + + const newPhotos = (await merchStoreController.getOneMerchCollection(params, admin)).collection.collectionPhotos; + const newPhotosUuids = newPhotos.map((photo) => photo.uuid); + const expectedPhotosUuids = [photo5.uuid, photo4.uuid, photo3.uuid, photo2.uuid, photo1.uuid]; + expect(newPhotosUuids).toStrictEqual(expectedPhotosUuids); + }); + + test('can delete photo until 1 photo left except merch collection is deleted', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const photo1 = MerchFactory.fakeCollectionPhoto({ position: 0 }); + const photo2 = MerchFactory.fakeCollectionPhoto({ position: 1 }); + const collectionPhotos = [photo1, photo2]; + const collection = MerchFactory.fakeCollection({ collectionPhotos }); + + await new PortalState() + .createUsers(admin) + .createMerchCollections(collection) + .write(); + + const storageService = Mocks.storage(); + const merchStoreController = ControllerFactory.merchStore( + conn, + undefined, + instance(storageService), + ); + const params = { uuid: collection.uuid }; + + // verify before deleting, the photos all exist + const collectionInDatabase = (await merchStoreController.getOneMerchCollection(params, admin)).collection; + expect(collectionInDatabase.collectionPhotos).toEqual(collectionPhotos); + + const deleteMerchCollectionPhotoParam1 = { uuid: photo1.uuid }; + const deleteMerchCollectionPhotoParam2 = { uuid: photo2.uuid }; + + // verify deletion delete correctly + await merchStoreController.deleteMerchCollectionPhoto(deleteMerchCollectionPhotoParam1, admin); + const expectedUrl = collectionInDatabase.collectionPhotos[0].uploadedPhoto; + verify(storageService.deleteAtUrl(expectedUrl)).called(); + + const newPhotos = (await merchStoreController.getOneMerchCollection(params, admin)).collection.collectionPhotos; + + expect(newPhotos).toHaveLength(1); + expect(newPhotos[0].uuid).toEqual(photo2.uuid); + expect(newPhotos[0].position).toEqual(1); + + // verify visible item photo limitation + expect(merchStoreController.deleteMerchCollectionPhoto(deleteMerchCollectionPhotoParam2, admin)) + .rejects.toThrow('Cannot delete the only photo for a collection'); + + // check cascade + await merchStoreController.deleteMerchCollection(params, admin); + expect(merchStoreController.deleteMerchCollectionPhoto(deleteMerchCollectionPhotoParam2, admin)) + .rejects.toThrow(NotFoundError); + }); +}); + describe('archived merch collections', () => { test('only admins can view archived collections', async () => { const conn = await DatabaseConnection.get(); diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index d4807c6b7..0a7332726 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -193,6 +193,10 @@ export interface EditMerchCollectionRequest { collection: MerchCollectionEdit; } +export interface CreateCollectionPhotoRequest { + position: string; +} + export interface CreateMerchItemRequest { merchandise: MerchItem; } @@ -226,15 +230,30 @@ export interface RescheduleOrderPickupRequest { pickupEvent: Uuid; } -export interface MerchCollection { +export interface CommonCollectionProperties { title: string; themeColorHex?: string; description: string; archived?: boolean; } -export interface MerchCollectionEdit extends Partial { +export interface MerchCollectionPhoto { + uploadedPhoto: string; + position: number; +} + +export interface MerchCollectionPhotoEdit { + uuid: string; + position?: number; +} + +export interface MerchCollection extends Partial { + collectionPhotos: MerchCollectionPhoto[] +} + +export interface MerchCollectionEdit extends Partial { discountPercentage?: number; + collectionPhotos?: MerchCollectionPhotoEdit[] } export interface CommonMerchItemProperties { diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index cb93f0469..1884cdd41 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -157,6 +157,7 @@ export interface PublicMerchCollection { themeColorHex?: string; description: string; items: PublicMerchItem[]; + collectionPhotos: PublicMerchCollectionPhoto[] createdAt: Date; } @@ -200,6 +201,13 @@ export interface PublicMerchItemPhoto { uploadedAt: Date; } +export interface PublicMerchCollectionPhoto { + uuid: Uuid; + uploadedPhoto: string; + position: number; + uploadedAt: Date +} + export interface PublicOrderMerchItemOption { uuid: Uuid; price: number; @@ -249,6 +257,12 @@ export interface EditMerchCollectionResponse extends ApiResponse { export interface DeleteMerchCollectionResponse extends ApiResponse {} +export interface CreateCollectionPhotoResponse extends ApiResponse { + collectionPhoto: PublicMerchCollectionPhoto; +} + +export interface DeleteCollectionPhotoResponse extends ApiResponse {} + export interface GetOneMerchItemResponse extends ApiResponse { item: PublicMerchItemWithPurchaseLimits; } From 635f3d5d6b60e95fe56c18b2a164303c7f45b291 Mon Sep 17 00:00:00 2001 From: Shravan Hariharan <33337209+shravanhariharan2@users.noreply.github.com> Date: Sat, 24 Feb 2024 13:45:04 -0800 Subject: [PATCH 2/8] Implement Express Check-in (#344) * progress * Implement POST /attendance/expressCheckin * touchups * fix build error * finish tests for POST /attendance/expressCheckin * fix * finish registration logic and tests * remove redundant nullable * update email template * updated branch and renamed migration file --------- Co-authored-by: Nikhil Dange --- api/controllers/AttendanceController.ts | 21 ++- .../AttendanceControllerRequests.ts | 17 +- migrations/0041-add-expressCheckins-table.ts | 38 +++++ models/EventModel.ts | 4 + models/ExpressCheckinModel.ts | 28 ++++ models/index.ts | 2 + repositories/AttendanceRepository.ts | 12 ++ repositories/UserRepository.ts | 5 + repositories/index.ts | 6 +- services/AttendanceService.ts | 56 ++++++- services/EmailService.ts | 22 +++ services/UserAuthService.ts | 35 +++- templates/expressCheckinConfirmation.ejs | 155 ++++++++++++++++++ tests/auth.test.ts | 66 +++++++- tests/controllers/ControllerFactory.ts | 4 +- tests/data/DatabaseConnection.ts | 1 + tests/data/PortalState.ts | 14 ++ tests/expressCheckin.test.ts | 130 +++++++++++++++ types/ApiRequests.ts | 5 + types/ApiResponses.ts | 6 + types/internal/index.ts | 2 +- 21 files changed, 606 insertions(+), 23 deletions(-) create mode 100644 migrations/0041-add-expressCheckins-table.ts create mode 100644 models/ExpressCheckinModel.ts create mode 100644 templates/expressCheckinConfirmation.ejs create mode 100644 tests/expressCheckin.test.ts diff --git a/api/controllers/AttendanceController.ts b/api/controllers/AttendanceController.ts index 5487c98a3..e47bd1213 100644 --- a/api/controllers/AttendanceController.ts +++ b/api/controllers/AttendanceController.ts @@ -1,22 +1,26 @@ import { JsonController, Get, Post, UseBefore, Params, ForbiddenError, Body } from 'routing-controllers'; +import EmailService from '../../services/EmailService'; import { UserAuthentication } from '../middleware/UserAuthentication'; import { AuthenticatedUser } from '../decorators/AuthenticatedUser'; -import { AttendEventRequest } from '../validators/AttendanceControllerRequests'; +import { AttendEventRequest, AttendViaExpressCheckinRequest } from '../validators/AttendanceControllerRequests'; import { UserModel } from '../../models/UserModel'; import AttendanceService from '../../services/AttendanceService'; import PermissionsService from '../../services/PermissionsService'; import { GetAttendancesForEventResponse, GetAttendancesForUserResponse, AttendEventResponse } from '../../types'; import { UuidParam } from '../validators/GenericRequests'; -@UseBefore(UserAuthentication) @JsonController('/attendance') export class AttendanceController { private attendanceService: AttendanceService; - constructor(attendanceService: AttendanceService) { + private emailService: EmailService; + + constructor(attendanceService: AttendanceService, emailService: EmailService) { this.attendanceService = attendanceService; + this.emailService = emailService; } + @UseBefore(UserAuthentication) @Get('/:uuid') async getAttendancesForEvent(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel): Promise { @@ -25,6 +29,7 @@ export class AttendanceController { return { error: null, attendances }; } + @UseBefore(UserAuthentication) @Get() async getAttendancesForCurrentUser(@AuthenticatedUser() user: UserModel): Promise { const attendances = await this.attendanceService.getAttendancesForCurrentUser(user); @@ -41,10 +46,20 @@ export class AttendanceController { return { error: null, attendances }; } + @UseBefore(UserAuthentication) @Post() async attendEvent(@Body() body: AttendEventRequest, @AuthenticatedUser() user: UserModel): Promise { const { event } = await this.attendanceService.attendEvent(user, body.attendanceCode, body.asStaff); return { error: null, event }; } + + @Post('/expressCheckin') + async attendViaExpressCheckin(@Body() body: AttendViaExpressCheckinRequest): Promise { + body.email = body.email.toLowerCase(); + const { email, attendanceCode } = body; + const { event } = await this.attendanceService.attendViaExpressCheckin(attendanceCode, email); + await this.emailService.sendExpressCheckinConfirmation(email, event.title, event.pointValue); + return { error: null, event }; + } } diff --git a/api/validators/AttendanceControllerRequests.ts b/api/validators/AttendanceControllerRequests.ts index fdd562b37..bc90ea658 100644 --- a/api/validators/AttendanceControllerRequests.ts +++ b/api/validators/AttendanceControllerRequests.ts @@ -1,5 +1,8 @@ -import { IsDefined, IsNotEmpty, Allow } from 'class-validator'; -import { AttendEventRequest as IAttendEventRequest } from '../../types'; +import { IsDefined, IsNotEmpty, Allow, IsEmail } from 'class-validator'; +import { + AttendEventRequest as IAttendEventRequest, + AttendViaExpressCheckinRequest as IAttendViaExpressCheckinRequest, +} from '../../types'; export class AttendEventRequest implements IAttendEventRequest { @IsDefined() @@ -9,3 +12,13 @@ export class AttendEventRequest implements IAttendEventRequest { @Allow() asStaff?: boolean; } + +export class AttendViaExpressCheckinRequest implements IAttendViaExpressCheckinRequest { + @IsDefined() + @IsNotEmpty() + attendanceCode: string; + + @IsDefined() + @IsEmail() + email: string; +} diff --git a/migrations/0041-add-expressCheckins-table.ts b/migrations/0041-add-expressCheckins-table.ts new file mode 100644 index 000000000..e137f554a --- /dev/null +++ b/migrations/0041-add-expressCheckins-table.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +const TABLE_NAME = 'ExpressCheckins'; + +export class AddExpressCheckinsTable1708807643314 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable(new Table({ + name: TABLE_NAME, + columns: [ + { + name: 'uuid', + type: 'uuid', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { + name: 'email', + type: 'varchar(255)', + isUnique: true, + }, + { + name: 'event', + type: 'uuid', + }, + { + name: 'timestamp', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP(6)', + }, + ], + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable(TABLE_NAME); + } +} diff --git a/models/EventModel.ts b/models/EventModel.ts index ca17ec71c..99f2d20ef 100644 --- a/models/EventModel.ts +++ b/models/EventModel.ts @@ -2,6 +2,7 @@ import * as moment from 'moment'; import { BaseEntity, Column, Entity, Index, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; import { PublicEvent, Uuid } from '../types'; import { AttendanceModel } from './AttendanceModel'; +import { ExpressCheckinModel } from './ExpressCheckinModel'; @Entity('Events') @Index('event_start_end_index', ['start', 'end']) @@ -59,6 +60,9 @@ export class EventModel extends BaseEntity { @OneToMany((type) => AttendanceModel, (attendance) => attendance.event, { cascade: true }) attendances: AttendanceModel[]; + @OneToMany((type) => ExpressCheckinModel, (expressCheckin) => expressCheckin.event, { cascade: true }) + expressCheckins: ExpressCheckinModel[]; + public getPublicEvent(canSeeAttendanceCode = false): PublicEvent { const publicEvent: PublicEvent = { uuid: this.uuid, diff --git a/models/ExpressCheckinModel.ts b/models/ExpressCheckinModel.ts new file mode 100644 index 000000000..d9e39d841 --- /dev/null +++ b/models/ExpressCheckinModel.ts @@ -0,0 +1,28 @@ +import { BaseEntity, Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { PublicExpressCheckin, Uuid } from '../types'; +import { EventModel } from './EventModel'; + +@Entity('ExpressCheckins') +export class ExpressCheckinModel extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + uuid: Uuid; + + @Column() + @Index({ unique: true }) + email: string; + + @ManyToOne((type) => EventModel, (event) => event.expressCheckins, { nullable: false }) + @JoinColumn({ name: 'event' }) + event: EventModel; + + @Column('timestamptz', { default: () => 'CURRENT_TIMESTAMP(6)' }) + timestamp: Date; + + public getPublicExpressCheckin(): PublicExpressCheckin { + return { + email: this.email, + event: this.event.getPublicEvent(), + timestamp: this.timestamp, + }; + } +} diff --git a/models/index.ts b/models/index.ts index a077f5dc6..0838f0588 100644 --- a/models/index.ts +++ b/models/index.ts @@ -13,6 +13,7 @@ import { FeedbackModel } from './FeedbackModel'; import { OrderPickupEventModel } from './OrderPickupEventModel'; import { ResumeModel } from './ResumeModel'; import { UserSocialMediaModel } from './UserSocialMediaModel'; +import { ExpressCheckinModel } from './ExpressCheckinModel'; export const models = [ UserModel, @@ -30,4 +31,5 @@ export const models = [ OrderPickupEventModel, ResumeModel, UserSocialMediaModel, + ExpressCheckinModel, ]; diff --git a/repositories/AttendanceRepository.ts b/repositories/AttendanceRepository.ts index ed6572c9e..aa16270e2 100644 --- a/repositories/AttendanceRepository.ts +++ b/repositories/AttendanceRepository.ts @@ -1,4 +1,5 @@ import { EntityRepository } from 'typeorm'; +import { ExpressCheckinModel } from '../models/ExpressCheckinModel'; import { Uuid } from '../types'; import { AttendanceModel } from '../models/AttendanceModel'; import { UserModel } from '../models/UserModel'; @@ -48,3 +49,14 @@ export class AttendanceRepository extends BaseRepository { return this.repository.save(attendance); } } + +@EntityRepository(ExpressCheckinModel) +export class ExpressCheckinRepository extends BaseRepository { + public async getPastExpressCheckin(email: string): Promise { + return this.repository.findOne({ where: { email }, relations: ['event'] }); + } + + public async createExpressCheckin(email: string, event: EventModel): Promise { + return this.repository.save(ExpressCheckinModel.create({ email, event })); + } +} diff --git a/repositories/UserRepository.ts b/repositories/UserRepository.ts index 17f32ebae..95eb5934a 100644 --- a/repositories/UserRepository.ts +++ b/repositories/UserRepository.ts @@ -41,6 +41,11 @@ export class UserRepository extends BaseRepository { }); } + public async isEmailInUse(email: string): Promise { + const user = await this.findByEmail(email); + return user !== undefined; + } + public async findByAccessCode(accessCode: string): Promise { return this.repository.findOne({ accessCode }); } diff --git a/repositories/index.ts b/repositories/index.ts index 540ab69d7..e08de61e8 100644 --- a/repositories/index.ts +++ b/repositories/index.ts @@ -1,7 +1,7 @@ import { EntityManager } from 'typeorm'; import { UserRepository } from './UserRepository'; import { FeedbackRepository } from './FeedbackRepository'; -import { AttendanceRepository } from './AttendanceRepository'; +import { AttendanceRepository, ExpressCheckinRepository } from './AttendanceRepository'; import { EventRepository } from './EventRepository'; import { MerchOrderRepository, OrderItemRepository, OrderPickupEventRepository } from './MerchOrderRepository'; import { @@ -80,6 +80,10 @@ export default class Repositories { public static userSocialMedia(transactionalEntityManager: EntityManager): UserSocialMediaRepository { return transactionalEntityManager.getCustomRepository(UserSocialMediaRepository); } + + public static expressCheckin(transactionalEntityManager: EntityManager): ExpressCheckinRepository { + return transactionalEntityManager.getCustomRepository(ExpressCheckinRepository); + } } export class TransactionsManager { diff --git a/services/AttendanceService.ts b/services/AttendanceService.ts index 332d10696..166ef16f8 100644 --- a/services/AttendanceService.ts +++ b/services/AttendanceService.ts @@ -3,7 +3,7 @@ import { InjectManager } from 'typeorm-typedi-extensions'; import { BadRequestError, ForbiddenError, NotFoundError } from 'routing-controllers'; import { EntityManager } from 'typeorm'; import * as moment from 'moment'; -import { ActivityType, PublicAttendance, Uuid } from '../types'; +import { ActivityType, PublicAttendance, PublicExpressCheckin, Uuid } from '../types'; import { Config } from '../config'; import { UserModel } from '../models/UserModel'; import { EventModel } from '../models/EventModel'; @@ -47,13 +47,8 @@ export default class AttendanceService { public async attendEvent(user: UserModel, attendanceCode: string, asStaff = false): Promise { return this.transactions.readWrite(async (txn) => { const event = await Repositories.event(txn).findByAttendanceCode(attendanceCode); - if (!event) throw new NotFoundError('Oh no! That code didn\'t work.'); - if (event.isTooEarlyToAttendEvent()) { - throw new UserError('This event hasn\'t started yet, please wait to check in.'); - } - if (event.isTooLateToAttendEvent()) { - throw new UserError('This event has ended and is no longer accepting attendances'); - } + + this.validateEventToAttend(event); const hasAlreadyAttended = await Repositories.attendance(txn).hasUserAttendedEvent(user, event); if (hasAlreadyAttended) throw new UserError('You have already attended this event'); @@ -63,6 +58,16 @@ export default class AttendanceService { }); } + private validateEventToAttend(event: EventModel) { + if (!event) throw new NotFoundError('Oh no! That code didn\'t work.'); + if (event.isTooEarlyToAttendEvent()) { + throw new UserError('This event hasn\'t started yet, please wait to check in.'); + } + if (event.isTooLateToAttendEvent()) { + throw new UserError('This event has ended and is no longer accepting attendances'); + } + } + private async writeEventAttendance(user: UserModel, event: EventModel, asStaff: boolean, txn: EntityManager): Promise { const attendedAsStaff = asStaff && user.isStaff() && event.requiresStaff; @@ -81,6 +86,41 @@ export default class AttendanceService { }); } + public async attendViaExpressCheckin(attendanceCode: string, email: string): Promise { + return this.transactions.readWrite(async (txn) => { + const event = await Repositories.event(txn).findByAttendanceCode(attendanceCode); + + this.validateEventToAttend(event); + + const expressCheckinRepository = Repositories.expressCheckin(txn); + + const isEmailInUse = await Repositories.user(txn).isEmailInUse(email); + if (isEmailInUse) { + throw new UserError( + 'This email already has an account registered to it. ' + + 'Please login to your account to check-in to this event!', + ); + } + + const pastExpressCheckin = await expressCheckinRepository.getPastExpressCheckin(email); + if (pastExpressCheckin) { + if (pastExpressCheckin.event.uuid === event.uuid) { + throw new UserError( + 'You have already successfully checked into this event!', + ); + } else { + throw new UserError( + 'You have already done an express check-in before for a previous event. ' + + 'Please complete your account registration to attend this event!', + ); + } + } + + const expressCheckin = await expressCheckinRepository.createExpressCheckin(email, event); + return expressCheckin.getPublicExpressCheckin(); + }); + } + public async submitAttendanceForUsers(emails: string[], eventUuid: Uuid, asStaff = false, proxyUser: UserModel): Promise { return this.transactions.readWrite(async (txn) => { diff --git a/services/EmailService.ts b/services/EmailService.ts index f36848c3c..ac99929cd 100644 --- a/services/EmailService.ts +++ b/services/EmailService.ts @@ -34,6 +34,9 @@ export default class EmailService { private static readonly orderPartiallyFulfilledTemplate = EmailService.readTemplate('orderPartiallyFulfilled.ejs'); + private static readonly expressCheckinConfirmationTemplate = EmailService + .readTemplate('expressCheckinConfirmation.ejs'); + constructor() { this.mailer.setApiKey(Config.email.apiKey); } @@ -225,6 +228,25 @@ export default class EmailService { } } + public async sendExpressCheckinConfirmation(email: string, eventName, pointValue) { + try { + const data = { + to: email, + from: Config.email.user, + subject: 'ACM UCSD Express Checkin - Complete Your Account Registration', + html: ejs.render(EmailService.expressCheckinConfirmationTemplate, { + eventName, + pointValue, + registerLink: `${Config.client}/register`, + storeLink: `${Config.client}/store`, + }), + }; + await this.sendEmail(data); + } catch (error) { + log.warn(`Failed to send express checkin confirmation to ${email}`, { error }); + } + } + private static readTemplate(filename: string): string { return fs.readFileSync(path.join(__dirname, `../templates/${filename}`), 'utf-8'); } diff --git a/services/UserAuthService.ts b/services/UserAuthService.ts index e7e0cdece..0565fa73b 100644 --- a/services/UserAuthService.ts +++ b/services/UserAuthService.ts @@ -4,6 +4,7 @@ import * as crypto from 'crypto'; import * as jwt from 'jsonwebtoken'; import { InjectManager } from 'typeorm-typedi-extensions'; import { EntityManager } from 'typeorm'; +import { ExpressCheckinModel } from 'models/ExpressCheckinModel'; import { UserRepository } from '../repositories/UserRepository'; import { Uuid, ActivityType, UserState, UserRegistration } from '../types'; import { Config } from '../config'; @@ -27,29 +28,61 @@ export default class UserAuthService { public async registerUser(registration: UserRegistration): Promise { return this.transactions.readWrite(async (txn) => { const userRepository = Repositories.user(txn); - const emailAlreadyUsed = !!(await userRepository.findByEmail(registration.email)); + + const { email } = registration; + const emailAlreadyUsed = await userRepository.isEmailInUse(email); if (emailAlreadyUsed) throw new BadRequestError('Email already in use'); + if (registration.handle) { const userHandleTaken = await userRepository.isHandleTaken(registration.handle); if (userHandleTaken) throw new BadRequestError('This handle is already in use.'); } const userHandle = registration.handle ?? UserAccountService.generateDefaultHandle(registration.firstName, registration.lastName); + const user = await userRepository.upsertUser(UserModel.create({ ...registration, hash: await UserRepository.generateHash(registration.password), accessCode: UserAuthService.generateAccessCode(), handle: userHandle, })); + const activityRepository = Repositories.activity(txn); await activityRepository.logActivity({ user, type: ActivityType.ACCOUNT_CREATE, }); + + const expressCheckin = await Repositories.expressCheckin(txn).getPastExpressCheckin(email); + if (expressCheckin) { + await this.processExpressCheckin(user, expressCheckin, txn); + } + return user; }); } + /** + * Creates the attendance and activity records and awards user points + * given the express checkin details. + */ + private async processExpressCheckin(user: UserModel, + expressCheckin: ExpressCheckinModel, + txn: EntityManager): Promise { + const { event } = expressCheckin; + + await Repositories.attendance(txn).writeAttendance({ + user, + event, + asStaff: false, + }); + await Repositories.activity(txn).logActivity({ + user, + type: ActivityType.ATTEND_EVENT, + }); + await Repositories.user(txn).addPoints(user, event.pointValue); + } + public async modifyEmail(user: UserModel, proposedEmail: string): Promise { const updatedUser = await this.transactions.readWrite(async (txn) => { const userRepository = Repositories.user(txn); diff --git a/templates/expressCheckinConfirmation.ejs b/templates/expressCheckinConfirmation.ejs new file mode 100644 index 000000000..16b1063b3 --- /dev/null +++ b/templates/expressCheckinConfirmation.ejs @@ -0,0 +1,155 @@ + + + + + + Complete User Registration - Express Checkin + + + + + + + + + +
  +
+ + + + + + + + + + + +
+ + + + +
+

Hello,

+

Thanks for attending your first ACM event, <%= eventName %>! You've just earned your very first <%= pointValue %> membership points. Earn more points to climb the leaderboard, make amazing friends, and create memories here with our community! You can spend the points you earned and collect at future events at <%= storeLink %> on shirts, sweaters, and more swag.

+

In order to finish becoming a full member and access these benefits, please register for an account on our Membership Portal. You will not be able to check in for future events until registering and verifying your email.

+ + + + + + +
+ + + + + + +
Complete Registration
+
+

Visit <%= registerLink %> if the link above doesn't work.

+

Regards,
ACM UCSD

+
+
+ + + + + + +
+
 
+ + diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 79476dc6d..9a9fb06c2 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -7,11 +7,13 @@ import { Config } from '../config'; import { UserModel } from '../models/UserModel'; import EmailService from '../services/EmailService'; import UserAuthService from '../services/UserAuthService'; -import { UserAccessType, UserState } from '../types'; +import { ActivityType, UserAccessType, UserState } from '../types'; import { ControllerFactory } from './controllers'; -import { DatabaseConnection, PortalState, UserFactory } from './data'; +import { DatabaseConnection, EventFactory, PortalState, UserFactory } from './data'; import FactoryUtils from './data/FactoryUtils'; import { UserRegistrationFactory } from './data/UserRegistrationFactory'; +import { AttendanceModel } from '../models/AttendanceModel'; +import { ActivityModel } from '../models/ActivityModel'; beforeAll(async () => { await DatabaseConnection.connect(); @@ -62,6 +64,13 @@ describe('account registration', () => { isAttendancePublic: true, }); + // check that no express checkin was processed + const attendances = await conn.manager.find(AttendanceModel); + expect(attendances).toHaveLength(0); + + const attendanceActivities = await conn.manager.find(ActivityModel, { where: { type: ActivityType.ATTEND_EVENT } }); + expect(attendanceActivities).toHaveLength(0); + // check that email verification is sent verify(emailService.sendEmailVerification(user.email, user.firstName, anyString())) .called(); @@ -91,7 +100,7 @@ describe('account registration', () => { .never(); }); - test('User cannot register with a handle that is already in use', async () => { + test('user cannot register with a handle that is already in use', async () => { const conn = await DatabaseConnection.get(); const existingMember = UserFactory.fake(); @@ -115,7 +124,7 @@ describe('account registration', () => { .never(); }); - test('User registration with a full name longer than 32 characters truncates handle to exactly 32 characters', + test('user registration with a full name longer than 32 characters truncates handle to exactly 32 characters', async () => { const conn = await DatabaseConnection.get(); const existingMember = UserFactory.fake(); @@ -140,7 +149,7 @@ describe('account registration', () => { expect(registerResponse.user.handle.length).toBe(32); }); - test('User can register with an optional handle to be set', async () => { + test('user can register with an optional handle to be set', async () => { const conn = await DatabaseConnection.get(); const existingMember = UserFactory.fake(); @@ -169,6 +178,53 @@ describe('account registration', () => { verify(emailService.sendEmailVerification(user.email, user.firstName, anyString())) .called(); }); + + test('user who registers after an express checkin has their attendance properly logged', async () => { + const conn = await DatabaseConnection.get(); + // email can have uppercases in it, so we should test that lowercase emails + // are used by SendGrid / written to the database when an uppercase email is sent + // in the request + const newUserEmail = faker.internet.email(); + const lowercasedEmail = newUserEmail.toLowerCase(); + const pastEvent = EventFactory.fake(EventFactory.daysBefore(5)); + + await new PortalState() + .createEvents(pastEvent) + .createExpressCheckin(lowercasedEmail, pastEvent) + .write(); + + const userToRegister = UserRegistrationFactory.fake({ + email: newUserEmail, + }); + + // register user + const emailService = mock(EmailService); + when(emailService.sendEmailVerification(lowercasedEmail, userToRegister.firstName, anyString())) + .thenResolve(); + const authController = ControllerFactory.auth(conn, instance(emailService)); + const registerRequest = { user: userToRegister }; + const registerResponse = await authController.register(registerRequest, FactoryUtils.randomHexString()); + const { user } = registerResponse; + + // check that points for event were logged and that attendance and activity objects were persisted + expect(user.points).toEqual(pastEvent.pointValue); + + const attendances = await conn.manager.find(AttendanceModel, { relations: ['event', 'user'] }); + expect(attendances).toHaveLength(1); + expect(attendances[0].event.uuid).toEqual(pastEvent.uuid); + expect(attendances[0].user.uuid).toEqual(user.uuid); + + const attendanceActivities = await conn.manager.find( + ActivityModel, + { where: { type: ActivityType.ATTEND_EVENT }, relations: ['user'] }, + ); + expect(attendanceActivities).toHaveLength(1); + expect(attendanceActivities[0].user.uuid).toEqual(user.uuid); + + // verify email was sent + verify(emailService.sendEmailVerification(lowercasedEmail, user.firstName, anyString())) + .called(); + }); }); describe('account login', () => { diff --git a/tests/controllers/ControllerFactory.ts b/tests/controllers/ControllerFactory.ts index 41b42b49a..8d9932842 100644 --- a/tests/controllers/ControllerFactory.ts +++ b/tests/controllers/ControllerFactory.ts @@ -43,9 +43,9 @@ export class ControllerFactory { return new AdminController(storageService, userAccountService, attendanceService); } - public static attendance(conn: Connection): AttendanceController { + public static attendance(conn: Connection, emailService = new EmailService()): AttendanceController { const attendanceService = new AttendanceService(conn.manager); - return new AttendanceController(attendanceService); + return new AttendanceController(attendanceService, emailService); } public static auth(conn: Connection, emailService: EmailService): AuthController { diff --git a/tests/data/DatabaseConnection.ts b/tests/data/DatabaseConnection.ts index bbe166bb1..d8b7c6e31 100644 --- a/tests/data/DatabaseConnection.ts +++ b/tests/data/DatabaseConnection.ts @@ -44,6 +44,7 @@ export class DatabaseConnection { 'Feedback', 'Resumes', 'UserSocialMedia', + 'ExpressCheckins', 'MerchCollectionPhotos', ]; await Promise.all(tableNames.map((t) => txn.query(`DELETE FROM "${t}"`))); diff --git a/tests/data/PortalState.ts b/tests/data/PortalState.ts index bb2a5f6ff..1bd1426fb 100644 --- a/tests/data/PortalState.ts +++ b/tests/data/PortalState.ts @@ -17,6 +17,7 @@ import { UserSocialMediaModel } from '../../models/UserSocialMediaModel'; import { DatabaseConnection } from './DatabaseConnection'; import { MerchFactory } from '.'; import { ResumeModel } from '../../models/ResumeModel'; +import { ExpressCheckinModel } from '../../models/ExpressCheckinModel'; export class PortalState { users: UserModel[] = []; @@ -39,6 +40,8 @@ export class PortalState { userSocialMedia: UserSocialMediaModel[] = []; + expressCheckins: ExpressCheckinModel[] = []; + public from(state: PortalState): PortalState { // deep clones all around for immutable PortalStates this.users = rfdc()(state.users); @@ -51,6 +54,7 @@ export class PortalState { this.feedback = rfdc()(state.feedback); this.resumes = rfdc()(state.resumes); this.userSocialMedia = rfdc()(state.userSocialMedia); + this.expressCheckins = rfdc()(state.expressCheckins); return this; } @@ -67,6 +71,7 @@ export class PortalState { this.feedback = await txn.save(this.feedback); this.resumes = await txn.save(this.resumes); this.userSocialMedia = await txn.save(this.userSocialMedia); + this.expressCheckins = await txn.save(this.expressCheckins); }); } @@ -231,6 +236,15 @@ export class PortalState { } return this; } + + public createExpressCheckin(email: string, event: EventModel): PortalState { + this.expressCheckins.push(ExpressCheckinModel.create({ + email: email.toLowerCase(), + event, + })); + + return this; + } } export interface MerchItemOptionAndQuantity { diff --git a/tests/expressCheckin.test.ts b/tests/expressCheckin.test.ts new file mode 100644 index 000000000..5b33d18de --- /dev/null +++ b/tests/expressCheckin.test.ts @@ -0,0 +1,130 @@ +import * as faker from 'faker'; +import { } from '../types'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { ControllerFactory } from './controllers'; +import { DatabaseConnection, EventFactory, PortalState, UserFactory } from './data'; +import EmailService from '../services/EmailService'; +import { ExpressCheckinModel } from '../models/ExpressCheckinModel'; + +beforeAll(async () => { + await DatabaseConnection.connect(); +}); + +beforeEach(async () => { + await DatabaseConnection.clear(); +}); + +afterAll(async () => { + await DatabaseConnection.clear(); + await DatabaseConnection.close(); +}); + +describe('express check-in', () => { + test('succeeds for new accounts that have never used express check-in before', async () => { + const conn = await DatabaseConnection.get(); + const event = EventFactory.fake(EventFactory.ongoing()); + await new PortalState().createEvents(event).write(); + + // email can have uppercases in it, so we should test that lowercase emails + // are used by SendGrid / written to the database + const newUserEmail = faker.internet.email(); + const lowercasedEmail = newUserEmail.toLowerCase(); + + const emailService = mock(EmailService); + when(emailService.sendExpressCheckinConfirmation(lowercasedEmail, event.title, event.pointValue)).thenResolve(); + const attendanceController = ControllerFactory.attendance(conn, instance(emailService)); + + const request = { + email: newUserEmail, + attendanceCode: event.attendanceCode, + }; + const response = await attendanceController.attendViaExpressCheckin(request); + + expect(response.error).toBeNull(); + expect(response.event).toStrictEqual(event.getPublicEvent()); + + const expressCheckins = await conn.manager.find(ExpressCheckinModel, { relations: ['event'] }); + expect(expressCheckins).toHaveLength(1); + expect(expressCheckins[0].email).toEqual(newUserEmail.toLowerCase()); + expect(expressCheckins[0].event).toEqual(event); + + verify(emailService.sendExpressCheckinConfirmation(lowercasedEmail, event.title, event.pointValue)).called(); + }); + + test('throws proper error when user already has a registered account', async () => { + const conn = await DatabaseConnection.get(); + const registeredMember = UserFactory.fake(); + const event = EventFactory.fake(EventFactory.ongoing()); + await new PortalState() + .createUsers(registeredMember) + .createEvents(event) + .write(); + + const newUserEmail = registeredMember.email; + + const emailService = mock(EmailService); + const attendanceController = ControllerFactory.attendance(conn, instance(emailService)); + + const request = { + email: newUserEmail, + attendanceCode: event.attendanceCode, + }; + + await (expect(attendanceController.attendViaExpressCheckin(request)) + .rejects + .toThrow('This email already has an account registered to it. ' + + 'Please login to your account to check-in to this event!')); + + verify(emailService.sendExpressCheckinConfirmation(anything(), anything(), anything())).never(); + }); + + test('throws proper error when user already used express checkin for the same event', async () => { + const conn = await DatabaseConnection.get(); + const newUserEmail = faker.internet.email(); + const event = EventFactory.fake(EventFactory.ongoing()); + await new PortalState() + .createEvents(event) + .createExpressCheckin(newUserEmail, event) + .write(); + + const emailService = mock(EmailService); + const attendanceController = ControllerFactory.attendance(conn, instance(emailService)); + + const request = { + email: newUserEmail, + attendanceCode: event.attendanceCode, + }; + + await (expect(attendanceController.attendViaExpressCheckin(request)) + .rejects + .toThrow('You have already successfully checked into this event!')); + + verify(emailService.sendExpressCheckinConfirmation(anything(), anything(), anything())).never(); + }); + + test('throws proper error when user already used express checkin for any previous event', async () => { + const conn = await DatabaseConnection.get(); + const newUserEmail = faker.internet.email(); + const previousEvent = EventFactory.fake(EventFactory.daysBefore(5)); + const ongoingEvent = EventFactory.fake(EventFactory.ongoing()); + await new PortalState() + .createEvents(previousEvent, ongoingEvent) + .createExpressCheckin(newUserEmail, previousEvent) + .write(); + + const emailService = mock(EmailService); + const attendanceController = ControllerFactory.attendance(conn, instance(emailService)); + + const request = { + email: newUserEmail, + attendanceCode: ongoingEvent.attendanceCode, + }; + + await (expect(attendanceController.attendViaExpressCheckin(request)) + .rejects + .toThrow('You have already done an express check-in before for a previous event. ' + + 'Please complete your account registration to attend this event!')); + + verify(emailService.sendExpressCheckinConfirmation(anything(), anything(), anything())).never(); + }); +}); diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index 0a7332726..44447fb9e 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -172,6 +172,11 @@ export interface AttendEventRequest { asStaff?: boolean; } +export interface AttendViaExpressCheckinRequest { + attendanceCode: string; + email: string; +} + export interface SubmitEventFeedbackRequest { feedback: string[]; } diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index 1884cdd41..f449b2d5b 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -69,6 +69,12 @@ export interface AttendEventResponse extends ApiResponse { event: PublicEvent; } +export interface PublicExpressCheckin { + email: string; + event: PublicEvent; + timestamp: Date; +} + // AUTH export interface RegistrationResponse extends ApiResponse { diff --git a/types/internal/index.ts b/types/internal/index.ts index 2ecaea250..a068e0131 100644 --- a/types/internal/index.ts +++ b/types/internal/index.ts @@ -1,4 +1,4 @@ -import { MerchandiseItemModel } from 'models/MerchandiseItemModel'; +import { MerchandiseItemModel } from '../../models/MerchandiseItemModel'; import { EventModel } from '../../models/EventModel'; import { UserModel } from '../../models/UserModel'; import { ActivityScope, ActivityType } from '../Enums'; From 5cf2ad5b868f7f39a13d6303c6e671d13c236fbf Mon Sep 17 00:00:00 2001 From: Nikhil Dange Date: Mon, 26 Feb 2024 20:49:15 -0800 Subject: [PATCH 3/8] added auth decorator to GET attendance/user/uuid (#409) --- api/controllers/AttendanceController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/controllers/AttendanceController.ts b/api/controllers/AttendanceController.ts index e47bd1213..ffc3c6c69 100644 --- a/api/controllers/AttendanceController.ts +++ b/api/controllers/AttendanceController.ts @@ -36,6 +36,7 @@ export class AttendanceController { return { error: null, attendances }; } + @UseBefore(UserAuthentication) @Get('/user/:uuid') async getAttendancesForUser(@Params() params: UuidParam, @AuthenticatedUser() currentUser: UserModel): Promise { From fa7532439e62b6e3fce9d911b19fa94b98963e50 Mon Sep 17 00:00:00 2001 From: Max Weng <73797155+maxwn04@users.noreply.github.com> Date: Wed, 28 Feb 2024 16:34:54 -0800 Subject: [PATCH 4/8] Bad words filter (#395) * basic bad words filter * bad words filter * lint * change error * remove unused package * bump version number --- package.json | 3 ++- services/UserAccountService.ts | 14 ++++++++++++++ yarn.lock | 5 +++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 83243d054..5c84392fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.2.0", + "version": "3.2.1", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ @@ -55,6 +55,7 @@ "moment-timezone": "^0.5.34", "morgan": "^1.10.0", "multer": "^1.4.2", + "obscenity": "^0.2.0", "pg": "^8.6.0", "reflect-metadata": "^0.1.13", "routing-controllers": "^0.9.0", diff --git a/services/UserAccountService.ts b/services/UserAccountService.ts index e60bf78be..d59e2d964 100644 --- a/services/UserAccountService.ts +++ b/services/UserAccountService.ts @@ -5,6 +5,11 @@ import { EntityManager } from 'typeorm'; import * as moment from 'moment'; import * as faker from 'faker'; import { UserAccessUpdates } from 'api/validators/AdminControllerRequests'; +import { + RegExpMatcher, + englishDataset, + englishRecommendedTransformers, +} from 'obscenity'; import Repositories, { TransactionsManager } from '../repositories'; import { Uuid, @@ -23,8 +28,14 @@ import { UserModel } from '../models/UserModel'; export default class UserAccountService { private transactions: TransactionsManager; + private matcher: RegExpMatcher; + constructor(@InjectManager() entityManager: EntityManager) { this.transactions = new TransactionsManager(entityManager); + this.matcher = new RegExpMatcher({ + ...englishDataset.build(), + ...englishRecommendedTransformers, + }); } public async findByUuid(uuid: Uuid): Promise { @@ -104,6 +115,9 @@ export default class UserAccountService { } changes.hash = await UserRepository.generateHash(newPassword); } + if (this.matcher.hasMatch(userPatches.handle)) { + throw new ForbiddenError('Please remove profanity from handle.'); + } return this.transactions.readWrite(async (txn) => { if (userPatches.handle) { const userRepository = Repositories.user(txn); diff --git a/yarn.lock b/yarn.lock index 400edfbfe..f53205a52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4342,6 +4342,11 @@ object.values@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" +obscenity@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/obscenity/-/obscenity-0.2.0.tgz#5a234c7af0aaebcd58fc762b29d2852104350fa2" + integrity sha512-eYe8r9hqJk5dEMZkLtWlGlwGxKYO6xA/yEOzd8MGSG3vzn8hAgo6MUZ9dirA3kPAfy/1d1jEOkCUpIU1nGI1EQ== + on-finished@^2.3.0: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" From 0a6da54aadd4308e2486ed4901236d3b4952866c Mon Sep 17 00:00:00 2001 From: "Caogang (Marcelo) Shen" Date: Wed, 28 Feb 2024 16:49:52 -0800 Subject: [PATCH 5/8] Added the merch photo table name to the list of tables for testing (#406) * added the thing * bump version number for express checkin --- package.json | 2 +- tests/data/DatabaseConnection.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5c84392fd..a92c3ce52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.2.1", + "version": "3.3.0", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/tests/data/DatabaseConnection.ts b/tests/data/DatabaseConnection.ts index d8b7c6e31..857f32f39 100644 --- a/tests/data/DatabaseConnection.ts +++ b/tests/data/DatabaseConnection.ts @@ -36,6 +36,7 @@ export class DatabaseConnection { 'Orders', 'OrderPickupEvents', 'MerchandiseItemOptions', + 'MerchandiseItemPhotos', 'MerchandiseItems', 'MerchandiseCollections', 'Attendances', From 60cba34680fede5fc94864c6ceb56a881220d4ce Mon Sep 17 00:00:00 2001 From: "Caogang (Marcelo) Shen" Date: Wed, 28 Feb 2024 17:00:00 -0800 Subject: [PATCH 6/8] Bug Fix for Transaction Read/Write Error (#401) * added packages * sample implementation test * lint fix * testing base functionalities * lint fix * more testing * omfg i got it * clean up testing related variables and functions * lint fix * added comments * added concurrent tests * bumped version number --- package.json | 4 +- repositories/index.ts | 31 +++++- tests/Seeds.ts | 5 - tests/data/EventFactory.ts | 4 +- tests/data/FactoryUtils.ts | 4 + tests/data/MerchFactory.ts | 2 +- tests/userSocialMedia.test.ts | 189 +++++++++++++++++++++++++++++++++- yarn.lock | 24 +++++ 8 files changed, 248 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index a92c3ce52..9d4a142a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.3.0", + "version": "3.4.0", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ @@ -39,6 +39,7 @@ "dependencies": { "@sendgrid/mail": "^7.4.4", "amazon-s3-uri": "^0.1.1", + "async-retry": "^1.3.3", "aws-sdk": "^2.906.0", "bcrypt": "^5.0.0", "body-parser": "^1.19.0", @@ -71,6 +72,7 @@ "winston-daily-rotate-file": "^4.5.0" }, "devDependencies": { + "@types/async-retry": "^1.4.8", "@types/bcrypt": "^3.0.0", "@types/body-parser": "^1.19.0", "@types/datadog-metrics": "^0.6.1", diff --git a/repositories/index.ts b/repositories/index.ts index e08de61e8..9ede2b5c5 100644 --- a/repositories/index.ts +++ b/repositories/index.ts @@ -1,4 +1,5 @@ import { EntityManager } from 'typeorm'; +import AsyncRetry = require('async-retry'); import { UserRepository } from './UserRepository'; import { FeedbackRepository } from './FeedbackRepository'; import { AttendanceRepository, ExpressCheckinRepository } from './AttendanceRepository'; @@ -16,6 +17,9 @@ import { LeaderboardRepository } from './LeaderboardRepository'; import { ResumeRepository } from './ResumeRepository'; import { UserSocialMediaRepository } from './UserSocialMediaRepository'; +const HINT_FOR_RETRIABLE_TRANSACTIONS : string = 'The transaction might succeed if retried.'; +const AMOUNT_OF_RETRIES : number = 10; + export default class Repositories { public static activity(transactionalEntityManager: EntityManager): ActivityRepository { return transactionalEntityManager.getCustomRepository(ActivityRepository); @@ -93,11 +97,34 @@ export class TransactionsManager { this.transactionalEntityManager = transactionalEntityManager; } + // used async-retry library to handle automatic retries under transaction failure due to concurrent transactions public readOnly(fn: (transactionalEntityManager: EntityManager) => Promise): Promise { - return this.transactionalEntityManager.transaction('REPEATABLE READ', fn); + return AsyncRetry(async (bail, attemptNum) => { + try { + const res = await this.transactionalEntityManager.transaction('REPEATABLE READ', fn); + return res; + } catch (e) { + if (e.hint !== HINT_FOR_RETRIABLE_TRANSACTIONS) bail(e); + else throw e; + } + }, + { + retries: AMOUNT_OF_RETRIES, + }); } public readWrite(fn: (transactionalEntityManager: EntityManager) => Promise): Promise { - return this.transactionalEntityManager.transaction('SERIALIZABLE', fn); + return AsyncRetry(async (bail, attemptNum) => { + try { + const res = await this.transactionalEntityManager.transaction('SERIALIZABLE', fn); + return res; + } catch (e) { + if (e.hint !== HINT_FOR_RETRIABLE_TRANSACTIONS) bail(e); + else throw e; + } + }, + { + retries: AMOUNT_OF_RETRIES, + }); } } diff --git a/tests/Seeds.ts b/tests/Seeds.ts index 7954f1973..a3626228a 100644 --- a/tests/Seeds.ts +++ b/tests/Seeds.ts @@ -479,7 +479,6 @@ async function seed(): Promise { }); const MERCH_ITEM_1_PHOTO_1 = MerchFactory.fakePhoto({ merchItem: MERCH_ITEM_1, - uploadedPhoto: 'https://www.fakepicture.com/', position: 1, }); const MERCH_ITEM_1_PHOTO_2 = MerchFactory.fakePhoto({ @@ -542,7 +541,6 @@ async function seed(): Promise { ]; const MERCH_ITEM_2_PHOTO_0 = MerchFactory.fakePhoto({ merchItem: MERCH_ITEM_2, - uploadedPhoto: 'https://www.fakepicture.com/', position: 0, }); const MERCH_ITEM_2_PHOTO_1 = MerchFactory.fakePhoto({ @@ -591,7 +589,6 @@ async function seed(): Promise { }); const MERCH_ITEM_3_PHOTO_1 = MerchFactory.fakePhoto({ merchItem: MERCH_ITEM_3, - uploadedPhoto: 'https://www.fakepicture.com/', position: 1, }); const MERCH_ITEM_3_PHOTO_2 = MerchFactory.fakePhoto({ @@ -601,12 +598,10 @@ async function seed(): Promise { }); const MERCH_ITEM_3_PHOTO_3 = MerchFactory.fakePhoto({ merchItem: MERCH_ITEM_3, - uploadedPhoto: 'https://www.fakepicture.com/', position: 3, }); const MERCH_ITEM_3_PHOTO_4 = MerchFactory.fakePhoto({ merchItem: MERCH_ITEM_3, - uploadedPhoto: 'https://www.fakepicture.com/', position: 4, }); MERCH_ITEM_3.merchPhotos = [ diff --git a/tests/data/EventFactory.ts b/tests/data/EventFactory.ts index c6dccd2ed..d07f523b3 100644 --- a/tests/data/EventFactory.ts +++ b/tests/data/EventFactory.ts @@ -37,10 +37,10 @@ export class EventFactory { requiresStaff: FactoryUtils.getRandomBoolean(), staffPointBonus: EventFactory.randomPointValue(), committee: 'ACM', - cover: faker.internet.url(), + cover: FactoryUtils.getRandomImageUrl(), deleted: false, eventLink: faker.internet.url(), - thumbnail: faker.internet.url(), + thumbnail: FactoryUtils.getRandomImageUrl(), }); return EventModel.merge(fake, substitute); } diff --git a/tests/data/FactoryUtils.ts b/tests/data/FactoryUtils.ts index 9bd9f9cdd..567990cff 100644 --- a/tests/data/FactoryUtils.ts +++ b/tests/data/FactoryUtils.ts @@ -40,4 +40,8 @@ export default class FactoryUtils { public static randomHexString(): string { return faker.datatype.hexaDecimal(10); } + + public static getRandomImageUrl(): string { + return `http://i.imgur.com/${faker.random.alphaNumeric(10)}.jpeg`; + } } diff --git a/tests/data/MerchFactory.ts b/tests/data/MerchFactory.ts index b28349329..5885fde04 100644 --- a/tests/data/MerchFactory.ts +++ b/tests/data/MerchFactory.ts @@ -72,7 +72,7 @@ export class MerchFactory { const fake = MerchandiseItemPhotoModel.create({ uuid: uuid(), position: 0, - uploadedPhoto: 'https://www.fakepicture.com/', + uploadedPhoto: FactoryUtils.getRandomImageUrl(), uploadedAt: faker.date.recent(), }); return MerchandiseItemPhotoModel.merge(fake, substitute); diff --git a/tests/userSocialMedia.test.ts b/tests/userSocialMedia.test.ts index 98938edc6..f2b39778b 100644 --- a/tests/userSocialMedia.test.ts +++ b/tests/userSocialMedia.test.ts @@ -1,24 +1,26 @@ import faker = require('faker'); +import { Connection } from 'typeorm'; import { UserModel } from '../models/UserModel'; import { SocialMediaType } from '../types'; import { ControllerFactory } from './controllers'; import { DatabaseConnection, PortalState, UserFactory } from './data'; import { UserSocialMediaFactory } from './data/UserSocialMediaFactory'; +import { UserController } from '../api/controllers/UserController'; beforeAll(async () => { await DatabaseConnection.connect(); }); -beforeEach(async () => { - await DatabaseConnection.clear(); -}); - afterAll(async () => { await DatabaseConnection.clear(); await DatabaseConnection.close(); }); describe('social media URL submission', () => { + beforeEach(async () => { + await DatabaseConnection.clear(); + }); + test('properly persists on successful submission', async () => { const conn = await DatabaseConnection.get(); let member = UserFactory.fake(); @@ -60,6 +62,10 @@ describe('social media URL submission', () => { }); describe('social media URL update', () => { + beforeEach(async () => { + await DatabaseConnection.clear(); + }); + test('is invalidated when updating social media URL of another user', async () => { const conn = await DatabaseConnection.get(); let member = UserFactory.fake(); @@ -100,6 +106,10 @@ describe('social media URL update', () => { }); describe('social media URL delete', () => { + beforeEach(async () => { + await DatabaseConnection.clear(); + }); + test('is invalidated when deleting social media URL of another user', async () => { const conn = await DatabaseConnection.get(); let member = UserFactory.fake(); @@ -119,3 +129,174 @@ describe('social media URL delete', () => { await expect(userController.deleteSocialMediaForUser(uuidParams, unauthorizedMember)).rejects.toThrow(errorMessage); }); }); + +describe('social media URL update', () => { + let conn : Connection; + let member : UserModel; + let userController : UserController; + + let flag : boolean = false; + function waitForFlag(interval = 500, timeout = 5000) { + let acc = 0; // time accumulation + return new Promise((resolve, reject) => { + const i = setInterval(() => { + acc += interval; + if (flag) { + clearInterval(i); + resolve(flag); + } + if (acc > timeout) { + clearInterval(i); + reject(); + } + }, interval); + }); + } + + // beforeAll does not behave properly when concurrent is being used: + // https://stackoverflow.com/questions/42633635/how-to-run-concurrent-tests-in-jest-with-multiple-tests-per-request + beforeAll(async () => { + await DatabaseConnection.clear(); + conn = await DatabaseConnection.get(); + member = UserFactory.fake(); + + const userSocialMedia0 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.FACEBOOK }); + const userSocialMedia1 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.GITHUB }); + const userSocialMedia2 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.DEVPOST }); + const userSocialMedia3 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.EMAIL }); + const userSocialMedia4 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.INSTAGRAM }); + + await new PortalState() + .createUsers(member) + .createUserSocialMedia( + member, userSocialMedia0, userSocialMedia1, userSocialMedia2, userSocialMedia3, userSocialMedia4, + ).write(); + + userController = ControllerFactory.user(conn); + + // refreshes member to have the userSocialMedia field + member = await conn.manager.findOne(UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }); + + flag = true; + }); + + test.concurrent('concurrent updates properly persist on successful submission 0', async () => { + await waitForFlag(); + const index = 0; + const uuidParams = { uuid: member.userSocialMedia[index].uuid }; + const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; + await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + + const expectedUserSocialMedia0 = { + url: socialMediaParams.socialMedia.url, + type: member.userSocialMedia[index].type, + uuid: uuidParams.uuid, + }; + const updatedMember = await conn.manager.findOne( + UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, + ); + + expect(updatedMember.userSocialMedia).toHaveLength(5); + const targetUserSocialMedia = updatedMember.userSocialMedia.find( + (socialMedia) => socialMedia.uuid === expectedUserSocialMedia0.uuid, + ); + expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia0.url); + expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia0.type); + }); + + test.concurrent('concurrent updates properly persist on successful submission 1', async () => { + await waitForFlag(); + const index = 1; + const uuidParams = { uuid: member.userSocialMedia[index].uuid }; + const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; + await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + + const expectedUserSocialMedia1 = { + url: socialMediaParams.socialMedia.url, + type: member.userSocialMedia[index].type, + uuid: uuidParams.uuid, + }; + const updatedMember = await conn.manager.findOne( + UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, + ); + + expect(updatedMember.userSocialMedia).toHaveLength(5); + const targetUserSocialMedia = updatedMember.userSocialMedia.find( + (socialMedia) => socialMedia.uuid === expectedUserSocialMedia1.uuid, + ); + expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia1.url); + expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia1.type); + }); + + test.concurrent('concurrent updates properly persist on successful submission 2', async () => { + await waitForFlag(); + const index = 2; + const uuidParams = { uuid: member.userSocialMedia[index].uuid }; + const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; + await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + + const expectedUserSocialMedia2 = { + url: socialMediaParams.socialMedia.url, + type: member.userSocialMedia[index].type, + uuid: uuidParams.uuid, + }; + const updatedMember = await conn.manager.findOne( + UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, + ); + + expect(updatedMember.userSocialMedia).toHaveLength(5); + const targetUserSocialMedia = updatedMember.userSocialMedia.find( + (socialMedia) => socialMedia.uuid === expectedUserSocialMedia2.uuid, + ); + expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia2.url); + expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia2.type); + }); + + test.concurrent('concurrent updates properly persist on successful submission 3', async () => { + await waitForFlag(); + const index = 3; + const uuidParams = { uuid: member.userSocialMedia[index].uuid }; + const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; + await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + + const expectedUserSocialMedia3 = { + url: socialMediaParams.socialMedia.url, + type: member.userSocialMedia[index].type, + uuid: uuidParams.uuid, + }; + const updatedMember = await conn.manager.findOne( + UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, + ); + + expect(updatedMember.userSocialMedia).toHaveLength(5); + const targetUserSocialMedia = updatedMember.userSocialMedia.find( + (socialMedia) => socialMedia.uuid === expectedUserSocialMedia3.uuid, + ); + expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia3.url); + expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia3.type); + }); + + test.concurrent('concurrent updates properly persist on successful submission 4', async () => { + await waitForFlag(); + const index = 4; + const uuidParams = { uuid: member.userSocialMedia[index].uuid }; + const socialMediaParams = { socialMedia: { url: faker.internet.url() } }; + await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member); + + const expectedUserSocialMedia4 = { + url: socialMediaParams.socialMedia.url, + type: member.userSocialMedia[index].type, + uuid: uuidParams.uuid, + }; + const updatedMember = await conn.manager.findOne( + UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] }, + ); + + expect(updatedMember.userSocialMedia).toHaveLength(5); + const targetUserSocialMedia = updatedMember.userSocialMedia.find( + (socialMedia) => socialMedia.uuid === expectedUserSocialMedia4.uuid, + ); + expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia4.url); + expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia4.type); + }); +}); diff --git a/yarn.lock b/yarn.lock index f53205a52..c84255721 100644 --- a/yarn.lock +++ b/yarn.lock @@ -641,6 +641,13 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/async-retry@^1.4.8": + version "1.4.8" + resolved "https://registry.yarnpkg.com/@types/async-retry/-/async-retry-1.4.8.tgz#eb32df13aceb9ba1a8a80e7fe518ff4e3fe46bb3" + integrity sha512-Qup/B5PWLe86yI5I3av6ePGaeQrIHNKCwbsQotD6aHQ6YkHsMUxVZkZsmx/Ry3VZQ6uysHwTjQ7666+k6UjVJA== + dependencies: + "@types/retry" "*" + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.1.18" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.18.tgz#1a29abcc411a9c05e2094c98f9a1b7da6cdf49f8" @@ -823,6 +830,11 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/retry@*": + version "0.12.5" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e" + integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw== + "@types/rfdc@^1.1.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/rfdc/-/rfdc-1.2.0.tgz#ff0aedd2d33589442f3a1285d37bdbb54d18dfbc" @@ -1169,6 +1181,13 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async-retry@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" + integrity sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw== + dependencies: + retry "0.13.1" + async@0.9.x: version "0.9.2" resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" @@ -4918,6 +4937,11 @@ responselike@^1.0.2: dependencies: lowercase-keys "^1.0.0" +retry@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" From 9aac2d183222b3b76cc2ff1e6768c9cf8f20bd45 Mon Sep 17 00:00:00 2001 From: Eric Chang <55172722+echang594@users.noreply.github.com> Date: Thu, 29 Feb 2024 20:47:51 -0800 Subject: [PATCH 7/8] Better error message for event deletion when event has attendances (#410) * Throw error when deleting event with attendance * Fixed tests that assumed repository query results are in order * Test events with no attendances can be deleted * Bump version number --- package.json | 2 +- services/EventService.ts | 4 ++- tests/admin.test.ts | 58 +++++++++++++++++++--------------------- tests/event.test.ts | 52 ++++++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 9d4a142a1..42b144b69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.4.0", + "version": "3.4.1", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/services/EventService.ts b/services/EventService.ts index bdcdc352e..9994e080e 100644 --- a/services/EventService.ts +++ b/services/EventService.ts @@ -1,6 +1,6 @@ import { Service } from 'typedi'; import { InjectManager } from 'typeorm-typedi-extensions'; -import { NotFoundError } from 'routing-controllers'; +import { ForbiddenError, NotFoundError } from 'routing-controllers'; import { EntityManager } from 'typeorm'; import { EventModel } from '../models/EventModel'; import { Uuid, PublicEvent, Event, EventSearchOptions } from '../types'; @@ -81,6 +81,8 @@ export default class EventService { const eventRepository = Repositories.event(txn); const event = await eventRepository.findByUuid(uuid); if (!event) throw new NotFoundError('Event not found'); + const attendances = await Repositories.attendance(txn).getAttendancesForEvent(uuid); + if (attendances.length > 0) throw new ForbiddenError('Cannot delete event that has attendances'); await eventRepository.deleteEvent(event); }); } diff --git a/tests/admin.test.ts b/tests/admin.test.ts index 03faaddcc..8c697a580 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -200,37 +200,31 @@ describe('updating user access level', () => { .createUsers(admin, staffUser, standardUser, marketingUser, merchStoreDistributorUser) .write(); + // Note that PortalState.createUsers changes the user emails to be lowercase so this array is created afterwards + const accessUpdates = [ + { user: staffUser.email, accessType: UserAccessType.MERCH_STORE_MANAGER }, + { user: standardUser.email, accessType: UserAccessType.MARKETING }, + { user: marketingUser.email, accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }, + { user: merchStoreDistributorUser.email, accessType: UserAccessType.STAFF }, + ].sort((a, b) => a.user.localeCompare(b.user)); + const adminController = ControllerFactory.admin(conn); - const accessLevelResponse = await adminController.updateUserAccessLevel({ - accessUpdates: [ - { user: staffUser.email, accessType: UserAccessType.MERCH_STORE_MANAGER }, - { user: standardUser.email, accessType: UserAccessType.MARKETING }, - { user: marketingUser.email, accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }, - { user: merchStoreDistributorUser.email, accessType: UserAccessType.STAFF }, - ], - }, admin); + const accessLevelResponse = await adminController.updateUserAccessLevel({ accessUpdates }, admin); + accessLevelResponse.updatedUsers.sort((a, b) => a.email.localeCompare(b.email)); const repository = conn.getRepository(UserModel); const updatedUsers = await repository.find({ email: In([staffUser.email, standardUser.email, marketingUser.email, merchStoreDistributorUser.email]), }); + // sorting to guarantee order + updatedUsers.sort((a, b) => a.email.localeCompare(b.email)); - expect(updatedUsers[0].email).toEqual(staffUser.email); - expect(updatedUsers[0].accessType).toEqual(UserAccessType.MERCH_STORE_MANAGER); - expect(accessLevelResponse.updatedUsers[0].accessType).toEqual(UserAccessType.MERCH_STORE_MANAGER); - - expect(updatedUsers[1].email).toEqual(standardUser.email); - expect(updatedUsers[1].accessType).toEqual(UserAccessType.MARKETING); - expect(accessLevelResponse.updatedUsers[1].accessType).toEqual(UserAccessType.MARKETING); - - expect(updatedUsers[2].email).toEqual(marketingUser.email); - expect(updatedUsers[2].accessType).toEqual(UserAccessType.MERCH_STORE_DISTRIBUTOR); - expect(accessLevelResponse.updatedUsers[2].accessType).toEqual(UserAccessType.MERCH_STORE_DISTRIBUTOR); - - expect(updatedUsers[3].email).toEqual(merchStoreDistributorUser.email); - expect(updatedUsers[3].accessType).toEqual(UserAccessType.STAFF); - expect(accessLevelResponse.updatedUsers[3].accessType).toEqual(UserAccessType.STAFF); + accessUpdates.forEach((accessUpdate, index) => { + expect(updatedUsers[index].email).toEqual(accessUpdate.user); + expect(updatedUsers[index].accessType).toEqual(accessUpdate.accessType); + expect(accessLevelResponse.updatedUsers[index].accessType).toEqual(accessUpdate.accessType); + }); }); test('attempt to update when user is not an admin', async () => { @@ -246,6 +240,10 @@ describe('updating user access level', () => { .createUsers(staffUser, standardUser, marketingUser, merchStoreDistributorUser, standard) .write(); + // Note that PortalState.createUsers changes the user emails to be lowercase so this array is created afterwards + const users = [staffUser, standardUser, marketingUser, merchStoreDistributorUser] + .sort((a, b) => a.email.localeCompare(b.email)); + const adminController = ControllerFactory.admin(conn); await expect(async () => { @@ -263,15 +261,13 @@ describe('updating user access level', () => { const updatedUsers = await repository.find({ email: In([staffUser.email, standardUser.email, marketingUser.email, merchStoreDistributorUser.email]), }); + // sorting to guarantee order + updatedUsers.sort((a, b) => a.email.localeCompare(b.email)); - expect(updatedUsers[0].email).toEqual(staffUser.email); - expect(updatedUsers[0].accessType).toEqual(UserAccessType.STAFF); - expect(updatedUsers[1].email).toEqual(standardUser.email); - expect(updatedUsers[1].accessType).toEqual(UserAccessType.STANDARD); - expect(updatedUsers[2].email).toEqual(marketingUser.email); - expect(updatedUsers[2].accessType).toEqual(UserAccessType.MARKETING); - expect(updatedUsers[3].email).toEqual(merchStoreDistributorUser.email); - expect(updatedUsers[3].accessType).toEqual(UserAccessType.MERCH_STORE_DISTRIBUTOR); + users.forEach((user, index) => { + expect(updatedUsers[index].email).toEqual(user.email); + expect(updatedUsers[index].accessType).toEqual(user.accessType); + }); }); test('attempt to update duplicate users', async () => { diff --git a/tests/event.test.ts b/tests/event.test.ts index e6d38a242..ef1d64dd1 100644 --- a/tests/event.test.ts +++ b/tests/event.test.ts @@ -2,8 +2,9 @@ import * as moment from 'moment'; import { ForbiddenError } from 'routing-controllers'; import { UserAccessType } from '../types'; import { ControllerFactory } from './controllers'; -import { DatabaseConnection, PortalState, UserFactory } from './data'; +import { DatabaseConnection, EventFactory, PortalState, UserFactory } from './data'; import { CreateEventRequest } from '../api/validators/EventControllerRequests'; +import { EventModel } from '../models/EventModel'; beforeAll(async () => { await DatabaseConnection.connect(); @@ -126,3 +127,52 @@ describe('event creation', () => { .rejects.toThrow('Start date after end date'); }); }); + +describe('event deletion', () => { + test('should delete event that has no attendances', async () => { + // setting up inputs + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const event = EventFactory.fake(); + + await new PortalState() + .createUsers(admin) + .createEvents(event) + .write(); + + // deleting the event + const eventController = ControllerFactory.event(conn); + await eventController.deleteEvent({ uuid: event.uuid }, admin); + + // verifying event deleted + const repository = conn.getRepository(EventModel); + const repositoryEvent = await repository.find({ uuid: event.uuid }); + + expect(repositoryEvent).toHaveLength(0); + }); + + test('should not delete event that has attendances', async () => { + // setting up inputs + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const user = UserFactory.fake(); + const event = EventFactory.fake(); + + await new PortalState() + .createUsers(admin, user) + .createEvents(event) + .attendEvents([user], [event]) + .write(); + + // verifying correct error thrown + const eventController = ControllerFactory.event(conn); + await expect(eventController.deleteEvent({ uuid: event.uuid }, admin)) + .rejects.toThrow('Cannot delete event that has attendances'); + + // verifying event not deleted + const repository = conn.getRepository(EventModel); + const repositoryEvent = await repository.findOne({ uuid: event.uuid }); + + expect(repositoryEvent).toEqual(event); + }); +}); From db9d7dbc65768d9dcc58ccd64d2856a24de24420 Mon Sep 17 00:00:00 2001 From: Max Weng <73797155+maxwn04@users.noreply.github.com> Date: Wed, 6 Mar 2024 17:28:21 -0800 Subject: [PATCH 8/8] add lowercase emails to when in registration test (#408) * change email to lowercase in whens * update version number --- package.json | 2 +- tests/auth.test.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 42b144b69..ffa2d67fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "3.4.1", + "version": "3.4.2", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 9a9fb06c2..cb935ab57 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -41,7 +41,7 @@ describe('account registration', () => { // register member const emailService = mock(EmailService); - when(emailService.sendEmailVerification(user.email, user.firstName, anyString())) + when(emailService.sendEmailVerification(user.email.toLowerCase(), user.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); const registerRequest = { user }; @@ -89,7 +89,7 @@ describe('account registration', () => { }); const emailService = mock(EmailService); - when(emailService.sendEmailVerification(user.email, user.firstName, anyString())) + when(emailService.sendEmailVerification(user.email.toLowerCase(), user.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); const registerRequest = { user }; @@ -113,7 +113,7 @@ describe('account registration', () => { }); const emailService = mock(EmailService); - when(emailService.sendEmailVerification(user.email, user.firstName, anyString())) + when(emailService.sendEmailVerification(user.email.toLowerCase(), user.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); const registerRequest = { user }; @@ -140,7 +140,7 @@ describe('account registration', () => { // register member const emailService = mock(EmailService); - when(emailService.sendEmailVerification(user.email, user.firstName, anyString())) + when(emailService.sendEmailVerification(user.email.toLowerCase(), user.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); const registerRequest = { user }; @@ -163,7 +163,7 @@ describe('account registration', () => { // register member const emailService = mock(EmailService); - when(emailService.sendEmailVerification(user.email, user.firstName, anyString())) + when(emailService.sendEmailVerification(user.email.toLowerCase(), user.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); const registerRequest = { user }; @@ -283,7 +283,7 @@ describe('verifying email', () => { .write(); const emailService = mock(EmailService); - when(emailService.sendEmailVerification(member.email, member.firstName, anyString())) + when(emailService.sendEmailVerification(member.email.toLowerCase(), member.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); await authController.verifyEmail({ accessCode }); @@ -305,7 +305,7 @@ describe('verifying email', () => { .write(); const emailService = mock(EmailService); - when(emailService.sendEmailVerification(member.email, member.firstName, anyString())) + when(emailService.sendEmailVerification(member.email.toLowerCase(), member.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); await expect(authController.verifyEmail({ accessCode: FactoryUtils.randomHexString() })) @@ -326,7 +326,7 @@ describe('resending email verification', () => { .write(); const emailService = mock(EmailService); - when(emailService.sendEmailVerification(member.email, member.firstName, anyString())) + when(emailService.sendEmailVerification(member.email.toLowerCase(), member.firstName, anyString())) .thenResolve(); const authController = ControllerFactory.auth(conn, instance(emailService)); const params = { email: member.email }; @@ -478,7 +478,7 @@ describe('resending password reset email', () => { .write(); const emailService = mock(EmailService); - when(emailService.sendPasswordReset(member.email, member.firstName, anyString())); + when(emailService.sendPasswordReset(member.email.toLowerCase(), member.firstName, anyString())); const authController = ControllerFactory.auth(conn, instance(emailService)); const params = { email: member.email }; await authController.sendPasswordResetEmail(params, FactoryUtils.randomHexString()); @@ -495,7 +495,7 @@ describe('resending password reset email', () => { const member = UserFactory.fake(); const emailService = mock(EmailService); - when(emailService.sendPasswordReset(member.email, member.firstName, anyString())); + when(emailService.sendPasswordReset(member.email.toLowerCase(), member.firstName, anyString())); const authController = ControllerFactory.auth(conn, instance(emailService)); const params = { email: member.email }; await expect(authController.sendPasswordResetEmail(params, FactoryUtils.randomHexString()))