diff --git a/api/controllers/MerchStoreController.ts b/api/controllers/MerchStoreController.ts index 5c9b4268..bfaca0b9 100644 --- a/api/controllers/MerchStoreController.ts +++ b/api/controllers/MerchStoreController.ts @@ -12,6 +12,7 @@ import { BadRequestError, UploadedFile, } from 'routing-controllers'; +import { v4 as uuid } from 'uuid'; import PermissionsService from '../../services/PermissionsService'; import { UserAuthentication } from '../middleware/UserAuthentication'; import { @@ -41,7 +42,8 @@ import { CancelAllPendingOrdersResponse, MediaType, File, - UpdateMerchPhotoResponse, + CreateMerchPhotoResponse, + DeleteMerchItemPhotoResponse, CompleteOrderPickupEventResponse, GetOrderPickupEventResponse, CancelOrderPickupEventResponse, @@ -60,6 +62,7 @@ import { FulfillMerchOrderRequest, RescheduleOrderPickupRequest, CreateMerchItemOptionRequest, + CreateMerchItemPhotoRequest, CreateOrderPickupEventRequest, EditOrderPickupEventRequest, GetCartRequest, @@ -158,14 +161,36 @@ export class MerchStoreController { @UseBefore(UserAuthentication) @Post('/item/picture/:uuid') - async updateMerchPhoto(@UploadedFile('image', + async createMerchItemPhoto(@UploadedFile('image', { options: StorageService.getFileOptions(MediaType.MERCH_PHOTO) }) file: File, @Params() params: UuidParam, - @AuthenticatedUser() user: UserModel): Promise { + @Body() createItemPhotoRequest: CreateMerchItemPhotoRequest, + @AuthenticatedUser() user: UserModel): Promise { if (!PermissionsService.canEditMerchStore(user)) throw new ForbiddenError(); - const picture = await this.storageService.upload(file, MediaType.MERCH_PHOTO, params.uuid); - const item = await this.merchStoreService.editItem(params.uuid, { picture }); - return { error: null, item }; + + // generate a random string for the uploaded photo url + const position = Number(createItemPhotoRequest.position); + if (Number.isNaN(position)) throw new BadRequestError('Position is not a number'); + const uniqueFileName = uuid(); + const uploadedPhoto = await this.storageService.uploadToFolder( + file, MediaType.MERCH_PHOTO, uniqueFileName, params.uuid, + ); + const merchPhoto = await this.merchStoreService.createItemPhoto( + params.uuid, { uploadedPhoto, position }, + ); + + return { error: null, merchPhoto }; + } + + @UseBefore(UserAuthentication) + @Delete('/item/picture/:uuid') + async deleteMerchItemPhoto(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel): + Promise { + if (!PermissionsService.canEditMerchStore(user)) throw new ForbiddenError(); + const photoToDelete = await this.merchStoreService.getItemPhotoForDeletion(params.uuid); + await this.storageService.deleteAtUrl(photoToDelete.uploadedPhoto); + await this.merchStoreService.deleteItemPhoto(photoToDelete); + return { error: null }; } @Post('/option/:uuid') @@ -189,9 +214,11 @@ export class MerchStoreController { async getOneMerchOrder(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel): Promise { if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError(); - const order = await this.merchStoreService.findOrderByUuid(params.uuid); - if (!PermissionsService.canSeeMerchOrder(user, order)) throw new NotFoundError(); - return { error: null, order: order.getPublicOrderWithItems() }; + // get "public" order bc canSeeMerchOrder need singular merchPhoto field + // default order has merchPhotos field, which cause incorrect casting + const publicOrder = (await this.merchStoreService.findOrderByUuid(params.uuid)).getPublicOrderWithItems(); + if (!PermissionsService.canSeeMerchOrder(user, publicOrder)) throw new NotFoundError(); + return { error: null, order: publicOrder }; } @Get('/orders/all') diff --git a/api/validators/MerchStoreRequests.ts b/api/validators/MerchStoreRequests.ts index 78fc6283..3d8f0ac4 100644 --- a/api/validators/MerchStoreRequests.ts +++ b/api/validators/MerchStoreRequests.ts @@ -10,6 +10,7 @@ import { IsDateString, ArrayNotEmpty, IsNumber, + IsNumberString, } from 'class-validator'; import { Type } from 'class-transformer'; import { @@ -18,6 +19,7 @@ import { CreateMerchItemRequest as ICreateMerchItemRequest, EditMerchItemRequest as IEditMerchItemRequest, CreateMerchItemOptionRequest as ICreateMerchItemOptionRequest, + CreateMerchItemPhotoRequest as ICreateMerchItemPhotoRequest, PlaceMerchOrderRequest as IPlaceMerchOrderRequest, VerifyMerchOrderRequest as IVerifyMerchOrderRequest, FulfillMerchOrderRequest as IFulfillMerchOrderRequest, @@ -34,6 +36,8 @@ import { MerchItemOption as IMerchItemOption, MerchItemOptionEdit as IMerchItemOptionEdit, MerchItemOptionMetadata as IMerchItemOptionMetadata, + MerchItemPhoto as IMerchItemPhoto, + MerchItemPhotoEdit as IMerchItemPhotoEdit, MerchOrderEdit as IMerchOrderEdit, OrderPickupEvent as IOrderPickupEvent, OrderPickupEventEdit as IOrderPickupEventEdit, @@ -129,6 +133,26 @@ export class MerchItemOptionEdit implements IMerchItemOptionEdit { metadata?: MerchItemOptionMetadata; } +export class MerchItemPhoto implements IMerchItemPhoto { + @Allow() + uploadedPhoto: string; + + @IsNumber() + position: number; +} + +export class MerchItemPhotoEdit implements IMerchItemPhotoEdit { + @IsDefined() + @IsUUID() + uuid: string; + + @Allow() + uploadedPhoto?: string; + + @IsNumber() + position?: number; +} + export class MerchItem implements IMerchItem { @IsDefined() @IsNotEmpty() @@ -142,7 +166,7 @@ export class MerchItem implements IMerchItem { description: string; @Allow() - picture?: string; + merchPhotos: MerchItemPhoto[]; @Min(0) quantity?: number; @@ -177,7 +201,7 @@ export class MerchItemEdit implements IMerchItemEdit { description?: string; @Allow() - picture?: string; + merchPhotos?: MerchItemPhotoEdit[]; @Allow() hidden?: boolean; @@ -293,6 +317,12 @@ export class CreateMerchItemOptionRequest implements ICreateMerchItemOptionReque option: MerchItemOption; } +export class CreateMerchItemPhotoRequest implements ICreateMerchItemPhotoRequest { + @IsDefined() + @IsNumberString() + position: string; +} + export class PlaceMerchOrderRequest implements IPlaceMerchOrderRequest { @Type(() => MerchItemOptionAndQuantity) @ValidateNested() diff --git a/migrations/0038-add-merch-item-image-table.ts b/migrations/0038-add-merch-item-image-table.ts new file mode 100644 index 00000000..5802a986 --- /dev/null +++ b/migrations/0038-add-merch-item-image-table.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; + +const TABLE_NAME = 'MerchandiseItemPhotos'; +const MERCH_TABLE_NAME = 'MerchandiseItems'; + +export class AddMerchItemImageTable1691286073347 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // instantiates table with columns: uuid, merchItem, uploadedPhoto, uploadedAt, position + await queryRunner.createTable(new Table({ + name: TABLE_NAME, + columns: [ + { + name: 'uuid', + type: 'uuid', + isGenerated: true, + isPrimary: true, + generationStrategy: 'uuid', + }, + { + name: 'merchItem', + type: 'uuid', + }, + { + name: 'uploadedPhoto', + type: 'varchar(255)', + }, + { + name: 'uploadedAt', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP(6)', + }, + { + name: 'position', + type: 'integer', + }, + ], + // optimize searching + indices: [ + { + name: 'images_by_item_index', + columnNames: ['merchItem'], + }, + ], + // cascade delete + foreignKeys: [ + { + columnNames: ['merchItem'], + referencedTableName: MERCH_TABLE_NAME, + referencedColumnNames: ['uuid'], + onDelete: 'CASCADE', + }, + ], + })); + + // add images from each item of the merchandise table to the photo table + await queryRunner.query( + `INSERT INTO "${TABLE_NAME}" ("merchItem", "uploadedPhoto", position) ` + + `SELECT uuid, picture, 0 AS position FROM "${MERCH_TABLE_NAME}" ` + + 'WHERE picture IS NOT NULL', + ); + + // remove the column from the old table + await queryRunner.dropColumn(`${MERCH_TABLE_NAME}`, 'picture'); + } + + public async down(queryRunner: QueryRunner): Promise { + // create old column (copied from migration #7) + await queryRunner.addColumn(`${MERCH_TABLE_NAME}`, new TableColumn({ + name: 'picture', + type: 'varchar(255)', + isNullable: true, + })); + + // fill old column with the first image from the photo table + await queryRunner.query( + `UPDATE "${MERCH_TABLE_NAME}" m ` + + 'SET picture = (' + + 'SELECT "uploadedPhoto" ' + + `FROM "${TABLE_NAME}" p ` + + 'WHERE p."merchItem" = m.uuid ' + + 'ORDER BY p."uploadedAt" ' + + 'LIMIT 1' + + ')', + ); + + await queryRunner.dropTable(TABLE_NAME); + } +} diff --git a/models/MerchandiseItemModel.ts b/models/MerchandiseItemModel.ts index 8ba7fd48..e27971d3 100644 --- a/models/MerchandiseItemModel.ts +++ b/models/MerchandiseItemModel.ts @@ -4,6 +4,7 @@ import { import { Uuid, PublicMerchItem, PublicCartMerchItem } from '../types'; import { MerchandiseCollectionModel } from './MerchandiseCollectionModel'; import { MerchandiseItemOptionModel } from './MerchandiseItemOptionModel'; +import { MerchandiseItemPhotoModel } from './MerchandiseItemPhotoModel'; @Entity('MerchandiseItems') export class MerchandiseItemModel extends BaseEntity { @@ -20,9 +21,6 @@ export class MerchandiseItemModel extends BaseEntity { @JoinColumn({ name: 'collection' }) collection: MerchandiseCollectionModel; - @Column('varchar', { length: 255, nullable: true }) - picture: string; - @Column('text') description: string; @@ -38,6 +36,9 @@ export class MerchandiseItemModel extends BaseEntity { @Column('boolean', { default: false }) hasVariantsEnabled: boolean; + @OneToMany((type) => MerchandiseItemPhotoModel, (merchPhoto) => merchPhoto.merchItem, { cascade: true }) + merchPhotos: MerchandiseItemPhotoModel[]; + @OneToMany((type) => MerchandiseItemOptionModel, (option) => option.item, { cascade: true }) options: MerchandiseItemOptionModel[]; @@ -45,7 +46,7 @@ export class MerchandiseItemModel extends BaseEntity { const baseMerchItem: PublicMerchItem = { uuid: this.uuid, itemName: this.itemName, - picture: this.picture, + merchPhotos: this.merchPhotos.map((o) => o.getPublicMerchItemPhoto()).sort((a, b) => a.position - b.position), description: this.description, options: this.options.map((o) => o.getPublicMerchItemOption()), monthlyLimit: this.monthlyLimit, @@ -61,8 +62,17 @@ export class MerchandiseItemModel extends BaseEntity { return { uuid: this.uuid, itemName: this.itemName, - picture: this.picture, + uploadedPhoto: this.getDefaultPhotoUrl(), description: this.description, }; } + + // get the first index of photo if possible + public getDefaultPhotoUrl(): string { + if (this.merchPhotos.length === 0) return null; + return this.merchPhotos.reduce( + (min, current) => ((min.position < current.position) ? min : current), + this.merchPhotos[0], + ).uploadedPhoto; + } } diff --git a/models/MerchandiseItemOptionModel.ts b/models/MerchandiseItemOptionModel.ts index e2c8908f..4da70499 100644 --- a/models/MerchandiseItemOptionModel.ts +++ b/models/MerchandiseItemOptionModel.ts @@ -11,8 +11,8 @@ export class MerchandiseItemOptionModel extends BaseEntity { uuid: Uuid; @ManyToOne((type) => MerchandiseItemModel, (merchItem) => merchItem.options, { nullable: false, onDelete: 'CASCADE' }) - @Index('merch_item_options_index') @JoinColumn({ name: 'item' }) + @Index('merch_item_options_index') item: MerchandiseItemModel; @Column('integer', { default: 0 }) diff --git a/models/MerchandiseItemPhotoModel.ts b/models/MerchandiseItemPhotoModel.ts new file mode 100644 index 00000000..e20334e3 --- /dev/null +++ b/models/MerchandiseItemPhotoModel.ts @@ -0,0 +1,34 @@ +import { BaseEntity, Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { PublicMerchItemPhoto, Uuid } from '../types'; +import { MerchandiseItemModel } from './MerchandiseItemModel'; + +@Entity('MerchandiseItemPhotos') +export class MerchandiseItemPhotoModel extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + uuid: Uuid; + + @ManyToOne((type) => MerchandiseItemModel, + (merchItem) => merchItem.merchPhotos, + { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'merchItem' }) + @Index('images_by_item_index') + merchItem: MerchandiseItemModel; + + @Column('varchar', { length: 255, nullable: false }) + uploadedPhoto: string; + + @Column('timestamptz', { default: () => 'CURRENT_TIMESTAMP(6)', nullable: false }) + uploadedAt: Date; + + @Column('integer') + position: number; + + public getPublicMerchItemPhoto(): PublicMerchItemPhoto { + return { + uuid: this.uuid, + uploadedPhoto: this.uploadedPhoto, + uploadedAt: this.uploadedAt, + position: this.position, + }; + } +} diff --git a/models/index.ts b/models/index.ts index 4df9c059..e571f4e2 100644 --- a/models/index.ts +++ b/models/index.ts @@ -4,6 +4,7 @@ import { EventModel } from './EventModel'; import { AttendanceModel } from './AttendanceModel'; import { MerchandiseCollectionModel } from './MerchandiseCollectionModel'; import { MerchandiseItemModel } from './MerchandiseItemModel'; +import { MerchandiseItemPhotoModel } from './MerchandiseItemPhotoModel'; import { OrderModel } from './OrderModel'; import { OrderItemModel } from './OrderItemModel'; import { MerchandiseItemOptionModel } from './MerchandiseItemOptionModel'; @@ -20,6 +21,7 @@ export const models = [ AttendanceModel, MerchandiseCollectionModel, MerchandiseItemModel, + MerchandiseItemPhotoModel, MerchandiseItemOptionModel, OrderModel, OrderItemModel, diff --git a/package.json b/package.json index 542e7dab..5c6ef264 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "2.12.0", + "version": "3.0.0", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/repositories/MerchOrderRepository.ts b/repositories/MerchOrderRepository.ts index 7cdd1843..0695a149 100644 --- a/repositories/MerchOrderRepository.ts +++ b/repositories/MerchOrderRepository.ts @@ -24,6 +24,7 @@ export class MerchOrderRepository extends BaseRepository { .leftJoinAndSelect('order.user', 'user') .leftJoinAndSelect('orderItem.option', 'option') .leftJoinAndSelect('option.item', 'merchItem') + .leftJoinAndSelect('merchItem.merchPhotos', 'merchPhotos') .where('order.uuid = :uuid', { uuid }) .getOne(); } @@ -52,7 +53,7 @@ export class MerchOrderRepository extends BaseRepository { /** * Gets all orders for a given user. Returns the order joined with its pickup event, user, - * merch item options, and merch items. + * merch item options, merch items, and merch item photos. */ public async getAllOrdersWithItemsForUser(user: UserModel): Promise { return this.repository @@ -62,6 +63,7 @@ export class MerchOrderRepository extends BaseRepository { .leftJoinAndSelect('order.user', 'user') .leftJoinAndSelect('orderItem.option', 'option') .leftJoinAndSelect('option.item', 'merchItem') + .leftJoinAndSelect('merchItem.merchPhotos', 'merchPhotos') .where('order.user = :uuid', { uuid: user.uuid }) .getMany(); } @@ -114,6 +116,7 @@ export class OrderItemRepository extends BaseRepository { .innerJoinAndSelect('oi.order', 'order') .innerJoinAndSelect('order.user', 'user') .innerJoinAndSelect('option.item', 'item') + .innerJoinAndSelect('item.merchPhotos', 'merchPhotos') .where('item.uuid = :itemUuid', { itemUuid: item.uuid }) .andWhere('user.uuid = :userUuid', { userUuid: user.uuid }) .getMany(); @@ -153,6 +156,9 @@ export class OrderPickupEventRepository extends BaseRepository): Promise { if (changes) pickupEvent = OrderPickupEventModel.merge(pickupEvent, changes); @@ -170,7 +176,8 @@ export class OrderPickupEventRepository extends BaseRepository { diff --git a/repositories/MerchStoreRepository.ts b/repositories/MerchStoreRepository.ts index 955eb532..7f5f7e47 100644 --- a/repositories/MerchStoreRepository.ts +++ b/repositories/MerchStoreRepository.ts @@ -1,5 +1,6 @@ import { EntityRepository, SelectQueryBuilder } from 'typeorm'; import { MerchandiseItemOptionModel } from '../models/MerchandiseItemOptionModel'; +import { MerchandiseItemPhotoModel } from '../models/MerchandiseItemPhotoModel'; import { MerchandiseCollectionModel } from '../models/MerchandiseCollectionModel'; import { MerchandiseItemModel } from '../models/MerchandiseItemModel'; import { Uuid } from '../types'; @@ -39,14 +40,15 @@ export class MerchCollectionRepository extends BaseRepository { return this.repository.createQueryBuilder('collection') .leftJoinAndSelect('collection.items', 'items') - .leftJoinAndSelect('items.options', 'options'); + .leftJoinAndSelect('items.options', 'options') + .leftJoinAndSelect('items.merchPhotos', 'merchPhotos'); } } @EntityRepository(MerchandiseItemModel) export class MerchItemRepository extends BaseRepository { public async findByUuid(uuid: Uuid): Promise { - return this.repository.findOne(uuid, { relations: ['collection', 'options'] }); + return this.repository.findOne(uuid, { relations: ['collection', 'options', 'merchPhotos'] }); } public async upsertMerchItem(item: MerchandiseItemModel, changes?: Partial): @@ -74,11 +76,11 @@ export class MerchItemRepository extends BaseRepository { @EntityRepository(MerchandiseItemOptionModel) export class MerchItemOptionRepository extends BaseRepository { public async findByUuid(uuid: Uuid): Promise { - return this.repository.findOne(uuid, { relations: ['item'] }); + return this.repository.findOne(uuid, { relations: ['item', 'item.merchPhotos'] }); } public async batchFindByUuid(uuids: Uuid[]): Promise> { - const options = await this.repository.findByIds(uuids, { relations: ['item'] }); + const options = await this.repository.findByIds(uuids, { relations: ['item', 'item.merchPhotos'] }); return new Map(options.map((o) => [o.uuid, o])); } @@ -106,3 +108,27 @@ export class MerchItemOptionRepository extends BaseRepository { + public async findByUuid(uuid: Uuid): Promise { + return this.repository.findOne(uuid, { relations: ['merchItem'] }); + } + + // for querying a group of photos together + public async batchFindByUuid(uuids: Uuid[]): Promise> { + const merchPhotos = await this.repository.findByIds(uuids, { relations: ['merchItem'] }); + return new Map(merchPhotos.map((o) => [o.uuid, o])); + } + + public async upsertMerchItemPhoto(merchPhoto: MerchandiseItemPhotoModel, + changes?: Partial): Promise { + if (changes) merchPhoto = MerchandiseItemPhotoModel.merge(merchPhoto, changes); + return this.repository.save(merchPhoto); + } + + public async deleteMerchItemPhoto(merchPhoto: MerchandiseItemPhotoModel): Promise { + await this.repository.remove(merchPhoto); + } +} diff --git a/repositories/index.ts b/repositories/index.ts index 2f14390f..02a50c92 100644 --- a/repositories/index.ts +++ b/repositories/index.ts @@ -4,7 +4,12 @@ import { FeedbackRepository } from './FeedbackRepository'; import { AttendanceRepository } from './AttendanceRepository'; import { EventRepository } from './EventRepository'; import { MerchOrderRepository, OrderItemRepository, OrderPickupEventRepository } from './MerchOrderRepository'; -import { MerchCollectionRepository, MerchItemRepository, MerchItemOptionRepository } from './MerchStoreRepository'; +import { + MerchCollectionRepository, + MerchItemRepository, + MerchItemOptionRepository, + MerchItemPhotoRepository, +} from './MerchStoreRepository'; import { ActivityRepository } from './ActivityRepository'; import { LeaderboardRepository } from './LeaderboardRepository'; import { ResumeRepository } from './ResumeRepository'; @@ -47,6 +52,10 @@ export default class Repositories { return transactionalEntityManager.getCustomRepository(MerchItemRepository); } + public static merchStoreItemPhoto(transactionalEntityManager: EntityManager): MerchItemPhotoRepository { + return transactionalEntityManager.getCustomRepository(MerchItemPhotoRepository); + } + public static merchStoreItemOption(transactionalEntityManager: EntityManager): MerchItemOptionRepository { return transactionalEntityManager.getCustomRepository(MerchItemOptionRepository); } diff --git a/services/MerchStoreService.ts b/services/MerchStoreService.ts index d083fd09..ee1b5f76 100644 --- a/services/MerchStoreService.ts +++ b/services/MerchStoreService.ts @@ -23,6 +23,8 @@ import { OrderPickupEventEdit, PublicMerchItemWithPurchaseLimits, OrderPickupEventStatus, + PublicMerchItemPhoto, + MerchItemPhoto, } from '../types'; import { MerchandiseItemModel } from '../models/MerchandiseItemModel'; import { OrderModel } from '../models/OrderModel'; @@ -33,9 +35,12 @@ import EmailService, { OrderInfo, OrderPickupEventInfo } from './EmailService'; import { UserError } from '../utils/Errors'; import { OrderItemModel } from '../models/OrderItemModel'; import { OrderPickupEventModel } from '../models/OrderPickupEventModel'; +import { MerchandiseItemPhotoModel } from '../models/MerchandiseItemPhotoModel'; @Service() export default class MerchStoreService { + private static readonly MAX_MERCH_PHOTO_COUNT = 5; + private emailService: EmailService; private transactions: TransactionsManager; @@ -188,10 +193,11 @@ export default class MerchStoreService { const merchItemRepository = Repositories.merchStoreItem(txn); const item = await merchItemRepository.findByUuid(uuid); if (!item) throw new NotFoundError(); + if (itemEdit.hidden === false && item.options.length === 0) { throw new UserError('Item cannot be set to visible if it has 0 options.'); } - const { options, collection: updatedCollection, ...changes } = itemEdit; + const { options, merchPhotos, collection: updatedCollection, ...changes } = itemEdit; if (options) { const optionUpdatesByUuid = new Map(options.map((option) => [option.uuid, option])); item.options.map((currentOption) => { @@ -210,6 +216,26 @@ export default class MerchStoreService { }); } + // this part only handles updating the positions of the pictures + if (merchPhotos) { + // error on duplicate photo uuids + const dupSet = new Set(); + merchPhotos.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(merchPhotos.map((merchPhoto) => [merchPhoto.uuid, merchPhoto])); + + item.merchPhotos.map((currentPhoto) => { + if (!photoUpdatesByUuid.has(currentPhoto.uuid)) return; + const photoUpdate = photoUpdatesByUuid.get(currentPhoto.uuid); + return MerchandiseItemPhotoModel.merge(currentPhoto, photoUpdate); + }); + } + const updatedItem = MerchandiseItemModel.merge(item, changes); MerchStoreService.verifyItemHasValidOptions(updatedItem); @@ -281,6 +307,76 @@ export default class MerchStoreService { }); } + /** + * Verify that items have valid options. An item with variants disabled cannot have multiple + * options, and an item with variants enabled cannot have multiple option types. + */ + private static verifyItemHasValidPhotos(item: MerchItem | MerchandiseItemModel) { + if (item.merchPhotos.length > MerchStoreService.MAX_MERCH_PHOTO_COUNT) { + throw new UserError('Merch items cannot have more than 5 pictures'); + } + } + + /** + * Creates an item photo and assign it the corresponding picture url + * and append the photo to the photos list from merchItem + * @param item merch item uuid + * @param properties merch item photo picture url and position + * @returns created item photo + */ + public async createItemPhoto(item: Uuid, properties: MerchItemPhoto): Promise { + return this.transactions.readWrite(async (txn) => { + const merchItem = await Repositories.merchStoreItem(txn).findByUuid(item); + if (!merchItem) throw new NotFoundError('Merch item not found'); + + const createdPhoto = MerchandiseItemPhotoModel.create({ ...properties, merchItem }); + const merchStoreItemPhotoRepository = Repositories.merchStoreItemPhoto(txn); + + // verify the result photos array + merchItem.merchPhotos.push(createdPhoto); + MerchStoreService.verifyItemHasValidPhotos(merchItem); + + const upsertedPhoto = await merchStoreItemPhotoRepository.upsertMerchItemPhoto(createdPhoto); + return upsertedPhoto.getPublicMerchItemPhoto(); + }); + } + + /** + * 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 getItemPhotoForDeletion(uuid: Uuid): Promise { + return this.transactions.readWrite(async (txn) => { + const merchStoreItemPhotoRepository = Repositories.merchStoreItemPhoto(txn); + const merchPhoto = await merchStoreItemPhotoRepository.findByUuid(uuid); + if (!merchPhoto) throw new NotFoundError('Merch item photo not found'); + + const merchItem = await Repositories.merchStoreItem(txn).findByUuid(merchPhoto.merchItem.uuid); + if (merchItem.merchPhotos.length === 1 && !merchItem.hidden) { + throw new UserError('Cannot delete the only photo for a visible merch item'); + } + + return merchPhoto; + }); + } + + /** + * Deletes the given item photo. + * + * @param merchPhoto the photo object to be removed + * @returns the photo object removed from database + */ + public async deleteItemPhoto(merchPhoto: MerchandiseItemPhotoModel): Promise { + return this.transactions.readWrite(async (txn) => { + const merchStoreItemPhotoRepository = Repositories.merchStoreItemPhoto(txn); + await merchStoreItemPhotoRepository.deleteMerchItemPhoto(merchPhoto); + return merchPhoto; + }); + } + public async findOrderByUuid(uuid: Uuid): Promise { const order = await this.transactions.readOnly(async (txn) => Repositories .merchOrder(txn) @@ -386,6 +482,7 @@ export default class MerchStoreService { const { item } = option; return { ...item, + picture: item.getDefaultPhotoUrl(), quantityRequested: oi.quantity, salePrice: option.getPrice(), total: oi.quantity * option.getPrice(), @@ -577,6 +674,7 @@ export default class MerchStoreService { && MerchStoreService.isLessThanTwoDaysBeforePickupEvent(order.pickupEvent)) { throw new NotFoundError('Cannot cancel an order with a pickup date less than 2 days away'); } + const customer = order.user; await this.refundAndConfirmOrderCancellation(order, user, txn); const activityRepository = Repositories.activity(txn); @@ -683,6 +781,7 @@ export default class MerchStoreService { const { quantity, price } = optionPricesAndQuantities.get(option); return { ...item, + picture: item.getDefaultPhotoUrl(), quantityRequested: quantity, salePrice: price, total: quantity * price, diff --git a/services/StorageService.ts b/services/StorageService.ts index 911b1c18..647c3413 100644 --- a/services/StorageService.ts +++ b/services/StorageService.ts @@ -68,6 +68,20 @@ export default class StorageService { }; } + public static getRandomString(): string { + const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-'; + const stringLength = 25; + // according to nanoID: ~611 trillion years needed, in order to have a 1% + // probability of at least one collision. + + let result = ''; + for (let i = 0; i < stringLength; i += 1) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return result; + } + private static getMediaConfig(type: MediaType): MediaTypeConfig { switch (type) { case MediaType.EVENT_COVER: { diff --git a/tests/Seeds.ts b/tests/Seeds.ts index a15a8cdf..717f19d1 100644 --- a/tests/Seeds.ts +++ b/tests/Seeds.ts @@ -377,7 +377,6 @@ async function seed(): Promise { const MERCH_ITEM_1 = MerchFactory.fakeItem({ collection: MERCH_COLLECTION_1, itemName: 'Unisex Hack School Anorak', - picture: 'https://i.imgur.com/jkTcUJO.jpg', description: 'San Diego has an average annual precipitation less than 12 inches,' + 'but that doesn\'t mean you don\'t need one of these.', monthlyLimit: 1, @@ -447,10 +446,30 @@ async function seed(): Promise { MERCH_ITEM_1_OPTION_L, MERCH_ITEM_1_OPTION_XL, ]; + // uploadedPhoto is 'https://www.fakepicture.com/' by default, test if this applies + const MERCH_ITEM_1_PHOTO_0 = MerchFactory.fakePhoto({ + merchItem: MERCH_ITEM_1, + position: 0, + }); + 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({ + merchItem: MERCH_ITEM_1, + uploadedPhoto: 'https://i.imgur.com/pSZ921P.png', + position: 2, + }); + MERCH_ITEM_1.merchPhotos = [ + MERCH_ITEM_1_PHOTO_0, + MERCH_ITEM_1_PHOTO_1, + MERCH_ITEM_1_PHOTO_2, + ]; + const MERCH_ITEM_2 = MerchFactory.fakeItem({ collection: MERCH_COLLECTION_1, itemName: 'Hack School Sticker Pack (4) - Cyan', - picture: 'https://i.imgur.com/pSZ921P.png', description: 'Make space on your laptop cover for these Cyan stickers. Pack of 4, size in inches.', monthlyLimit: 5, lifetimeLimit: 25, @@ -495,6 +514,20 @@ async function seed(): Promise { MERCH_ITEM_2_OPTION_3X3, MERCH_ITEM_2_OPTION_4X4, ]; + 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({ + merchItem: MERCH_ITEM_2, + uploadedPhoto: 'https://i.imgur.com/pSZ921P.png', + position: 1, + }); + MERCH_ITEM_2.merchPhotos = [ + MERCH_ITEM_2_PHOTO_0, + MERCH_ITEM_2_PHOTO_1, + ]; MERCH_COLLECTION_1.items = [MERCH_ITEM_1, MERCH_ITEM_2]; const MERCH_COLLECTION_2 = MerchFactory.fakeCollection({ @@ -504,7 +537,6 @@ async function seed(): Promise { const MERCH_ITEM_3 = MerchFactory.fakeItem({ collection: MERCH_COLLECTION_2, itemName: 'Camp Snoopy Snapback', - picture: 'https://i.imgur.com/QNdhfuO.png', description: 'Guaranteed 2x return on Grailed.', monthlyLimit: 2, lifetimeLimit: 5, @@ -518,10 +550,42 @@ async function seed(): Promise { discountPercentage: 5, }); MERCH_ITEM_3.options = [MERCH_ITEM_3_OPTION]; + const MERCH_ITEM_3_PHOTO_0 = MerchFactory.fakePhoto({ + merchItem: MERCH_ITEM_3, + uploadedPhoto: 'https://i.imgur.com/QNdhfuO.png', + position: 0, + }); + 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({ + merchItem: MERCH_ITEM_3, + uploadedPhoto: 'https://i.imgur.com/pSZ921P.png', + position: 2, + }); + 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 = [ + MERCH_ITEM_3_PHOTO_0, + MERCH_ITEM_3_PHOTO_1, + MERCH_ITEM_3_PHOTO_2, + MERCH_ITEM_3_PHOTO_3, + MERCH_ITEM_3_PHOTO_4, + ]; + const MERCH_ITEM_4 = MerchFactory.fakeItem({ collection: MERCH_COLLECTION_2, itemName: 'Salt & Pepper (Canyon) Shakers', - picture: 'https://i.pinimg.com/originals/df/c5/72/dfc5729a0dea666c31c5f4daea851619.jpg', description: 'Salt and pepper not included.', monthlyLimit: 3, lifetimeLimit: 10, @@ -535,10 +599,15 @@ async function seed(): Promise { discountPercentage: 20, }); MERCH_ITEM_4.options = [MERCH_ITEM_4_OPTION]; + const MERCH_ITEM_4_PHOTO_0 = MerchFactory.fakePhoto({ + merchItem: MERCH_ITEM_4, + position: 0, + uploadedPhoto: 'https://i.pinimg.com/originals/df/c5/72/dfc5729a0dea666c31c5f4daea851619.jpg', + }); + MERCH_ITEM_4.merchPhotos = [MERCH_ITEM_4_PHOTO_0]; const MERCH_ITEM_5 = MerchFactory.fakeItem({ collection: MERCH_COLLECTION_2, itemName: 'Unisex Raccoon Print Shell Jacket', - picture: 'https://i.etsystatic.com/8812670/r/il/655afa/3440382093/il_340x270.3440382093_cbui.jpg', description: 'Self-explanatory.', monthlyLimit: 1, lifetimeLimit: 2, @@ -568,6 +637,12 @@ async function seed(): Promise { }, }); MERCH_ITEM_5.options = [MERCH_ITEM_5_MEDIUM, MERCH_ITEM_5_LARGE]; + const MERCH_ITEM_5_PHOTO_0 = MerchFactory.fakePhoto({ + merchItem: MERCH_ITEM_5, + position: 0, + uploadedPhoto: 'https://i.etsystatic.com/8812670/r/il/655afa/3440382093/il_340x270.3440382093_cbui.jpg', + }); + MERCH_ITEM_5.merchPhotos = [MERCH_ITEM_5_PHOTO_0]; MERCH_COLLECTION_2.items = [MERCH_ITEM_3, MERCH_ITEM_4, MERCH_ITEM_5]; const PAST_ORDER_PICKUP_EVENT = MerchFactory.fakeOrderPickupEvent({ diff --git a/tests/data/MerchFactory.ts b/tests/data/MerchFactory.ts index 37263bda..fa4b9b35 100644 --- a/tests/data/MerchFactory.ts +++ b/tests/data/MerchFactory.ts @@ -1,12 +1,13 @@ 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 { MerchandiseItemModel } from '../../models/MerchandiseItemModel'; import { MerchandiseItemOptionModel } from '../../models/MerchandiseItemOptionModel'; -import FactoryUtils from './FactoryUtils'; +import { MerchandiseItemPhotoModel } from '../../models/MerchandiseItemPhotoModel'; export class MerchFactory { public static fakeCollection(substitute?: Partial): MerchandiseCollectionModel { @@ -35,14 +36,12 @@ export class MerchFactory { const fake = MerchandiseItemModel.create({ uuid: uuid(), itemName: faker.datatype.hexaDecimal(10), - picture: faker.image.cats(), description: faker.lorem.sentences(2), hasVariantsEnabled, monthlyLimit: FactoryUtils.getRandomNumber(1, 5), lifetimeLimit: FactoryUtils.getRandomNumber(6, 10), hidden: false, }); - // merging arrays returns a union of fake.options and substitute.options so only create // fake.options if the substitute doesn't provide any if (!substitute?.options) { @@ -51,9 +50,25 @@ export class MerchFactory { .createOptions(numOptions) .map((option) => MerchandiseItemOptionModel.merge(option, { item: fake })); } + if (!substitute?.merchPhotos) { + const numPhotos = FactoryUtils.getRandomNumber(1, 5); + fake.merchPhotos = MerchFactory + .createPhotos(numPhotos) + .map((merchPhoto) => MerchandiseItemPhotoModel.merge(merchPhoto, { merchItem: fake })); + } return MerchandiseItemModel.merge(fake, substitute); } + public static fakePhoto(substitute?: Partial): MerchandiseItemPhotoModel { + const fake = MerchandiseItemPhotoModel.create({ + uuid: uuid(), + position: 0, + uploadedPhoto: 'https://www.fakepicture.com/', + uploadedAt: faker.date.recent(), + }); + return MerchandiseItemPhotoModel.merge(fake, substitute); + } + public static fakeOption(substitute?: Partial): MerchandiseItemOptionModel { const fake = MerchandiseItemOptionModel.create({ uuid: uuid(), @@ -132,6 +147,12 @@ export class MerchFactory { return FactoryUtils.create(n, () => MerchFactory.fakeOptionWithType(type)); } + private static createPhotos(n: number): MerchandiseItemPhotoModel[] { + return FactoryUtils + .create(n, () => MerchFactory.fakePhoto()) + .map((merchPhoto, i) => MerchandiseItemPhotoModel.merge(merchPhoto, { position: i })); + } + private static randomPrice(): number { // some multiple of 50, min 250 and max 50_000 return FactoryUtils.getRandomNumber(250, 50_000, 50); diff --git a/tests/merchStore.test.ts b/tests/merchStore.test.ts index 00e23646..c04a8e7c 100644 --- a/tests/merchStore.test.ts +++ b/tests/merchStore.test.ts @@ -1,13 +1,16 @@ import * as faker from 'faker'; -import { ForbiddenError } from 'routing-controllers'; +import { ForbiddenError, NotFoundError } from 'routing-controllers'; import { zip } from 'underscore'; -import { anything, instance, mock, when } from 'ts-mockito'; +import { anything, instance, verify, mock, when } from 'ts-mockito'; import { OrderModel } from '../models/OrderModel'; import { MerchandiseItemOptionModel } from '../models/MerchandiseItemOptionModel'; -import { MerchItemEdit, UserAccessType } from '../types'; +import { MediaType, MerchItemEdit, UserAccessType } from '../types'; import { ControllerFactory } from './controllers'; import { DatabaseConnection, MerchFactory, PortalState, UserFactory } from './data'; import EmailService from '../services/EmailService'; +import { FileFactory } from './data/FileFactory'; +import { Config } from '../config'; +import Mocks from './mocks/MockFactory'; beforeAll(async () => { await DatabaseConnection.connect(); @@ -753,6 +756,171 @@ describe('merch item options', () => { }); }); +describe('merch item photos', () => { + const folderLocation = 'https://s3.amazonaws.com/upload-photo/'; + + test('can create an item with up to 5 pictures', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const photo1 = MerchFactory.fakePhoto(); + const item = MerchFactory.fakeItem({ merchPhotos: [photo1] }); + + await new PortalState() + .createUsers(admin) + .createMerchItem(item) + .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: item.uuid }; + + const response2 = await merchStoreController.createMerchItemPhoto(image2, params, { position: '1' }, admin); + const response3 = await merchStoreController.createMerchItemPhoto(image3, params, { position: '2' }, admin); + const response4 = await merchStoreController.createMerchItemPhoto(image4, params, { position: '3' }, admin); + const response5 = await merchStoreController.createMerchItemPhoto(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.merchPhoto; + const photo3 = response3.merchPhoto; + const photo4 = response4.merchPhoto; + const photo5 = response5.merchPhoto; + + // 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.getOneMerchItem(params, admin)).item.merchPhotos) + .toEqual(photos); + + expect(merchStoreController.createMerchItemPhoto(imageExtra, params, { position: '5' }, admin)) + .rejects.toThrow('Merch items cannot have more than 5 pictures'); + }); + + test('can remap the picture of an item to different orders', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const photo1 = MerchFactory.fakePhoto({ position: 0 }); + const photo2 = MerchFactory.fakePhoto({ position: 1 }); + const photo3 = MerchFactory.fakePhoto({ position: 2 }); + const photo4 = MerchFactory.fakePhoto({ position: 3 }); + const photo5 = MerchFactory.fakePhoto({ position: 4 }); + const merchPhotos = [photo1, photo2, photo3, photo4, photo5]; + const item = MerchFactory.fakeItem({ merchPhotos }); + + await new PortalState() + .createUsers(admin) + .createMerchItem(item) + .write(); + + const merchStoreController = ControllerFactory.merchStore(conn); + const params = { uuid: item.uuid }; + + // check before remap whether photos are correctly positioned + expect((await merchStoreController.getOneMerchItem(params, admin)).item.merchPhotos).toEqual(merchPhotos); + + // reversing the order of the photos + const editMerchItemRequest = { merchandise: { + merchPhotos: [ + { 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.editMerchItem(params, editMerchItemRequest, admin); + + const newPhotos = (await merchStoreController.getOneMerchItem(params, admin)).item.merchPhotos; + 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 item is deleted', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + const photo1 = MerchFactory.fakePhoto({ position: 0 }); + const photo2 = MerchFactory.fakePhoto({ position: 1 }); + const merchPhotos = [photo1, photo2]; + const item = MerchFactory.fakeItem({ merchPhotos }); + + await new PortalState() + .createUsers(admin) + .createMerchItem(item) + .write(); + + const storageService = Mocks.storage(); + const merchStoreController = ControllerFactory.merchStore( + conn, + undefined, + instance(storageService), + ); + const params = { uuid: item.uuid }; + + // verify before deleting, the photos all exist + const itemInDatabase = (await merchStoreController.getOneMerchItem(params, admin)).item; + expect(itemInDatabase.merchPhotos).toEqual(merchPhotos); + + const deleteMerchItemPhotoParam1 = { uuid: photo1.uuid }; + const deleteMerchItemPhotoParam2 = { uuid: photo2.uuid }; + + // verify deletion delete correctly + await merchStoreController.deleteMerchItemPhoto(deleteMerchItemPhotoParam1, admin); + const expectedUrl = itemInDatabase.merchPhotos[0].uploadedPhoto; + verify(storageService.deleteAtUrl(expectedUrl)).called(); + + const newPhotos = (await merchStoreController.getOneMerchItem(params, admin)).item.merchPhotos; + + expect(newPhotos).toHaveLength(1); + expect(newPhotos[0].uuid).toEqual(photo2.uuid); + expect(newPhotos[0].position).toEqual(1); + + // verify visible item photo limitation + expect(merchStoreController.deleteMerchItemPhoto(deleteMerchItemPhotoParam2, admin)) + .rejects.toThrow('Cannot delete the only photo for a visible merch item'); + + // check cascade + await merchStoreController.deleteMerchItem(params, admin); + expect(merchStoreController.deleteMerchItemPhoto(deleteMerchItemPhotoParam2, admin)) + .rejects.toThrow(NotFoundError); + }); +}); + describe('checkout cart', () => { test('passing in valid item option uuids returns the full options and their items', async () => { const conn = await DatabaseConnection.get(); @@ -782,7 +950,6 @@ describe('checkout cart', () => { const params = { items: options.map((o) => o.uuid) }; const merchStoreController = ControllerFactory.merchStore(conn); const getCartResponse = await merchStoreController.getCartItems(params, member); - const { cart } = getCartResponse; expect(cart).toHaveLength(3); diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index ff271f3b..f8ef3c3e 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -205,6 +205,10 @@ export interface CreateMerchItemOptionRequest { option: MerchItemOption; } +export interface CreateMerchItemPhotoRequest { + position: string; +} + export interface PlaceMerchOrderRequest { order: MerchItemOptionAndQuantity[]; pickupEvent: Uuid; @@ -237,7 +241,6 @@ export interface CommonMerchItemProperties { itemName: string; collection: string; description: string; - picture?: string; hidden?: boolean; monthlyLimit?: number; lifetimeLimit?: number; @@ -250,6 +253,16 @@ export interface MerchItemOptionMetadata { position: number; } +export interface MerchItemPhoto { + uploadedPhoto: string; + position: number; +} + +export interface MerchItemPhotoEdit { + uuid: string; + position?: number; +} + export interface MerchItemOption { quantity: number; price: number; @@ -259,6 +272,7 @@ export interface MerchItemOption { export interface MerchItem extends CommonMerchItemProperties { options: MerchItemOption[]; + merchPhotos: MerchItemPhoto[]; } export interface MerchItemOptionEdit { @@ -271,6 +285,7 @@ export interface MerchItemOptionEdit { export interface MerchItemEdit extends Partial { options?: MerchItemOptionEdit[]; + merchPhotos?: MerchItemPhotoEdit[]; } export interface MerchOrderEdit { diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index 97d25732..9133d18a 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -164,12 +164,12 @@ export interface PublicMerchItem { uuid: Uuid; itemName: string; collection?: PublicMerchCollection; - picture: string; description: string; monthlyLimit: number; lifetimeLimit: number; hidden: boolean; hasVariantsEnabled: boolean; + merchPhotos: PublicMerchItemPhoto[]; options: PublicMerchItemOption[]; } @@ -181,7 +181,7 @@ export interface PublicMerchItemWithPurchaseLimits extends PublicMerchItem { export interface PublicCartMerchItem { uuid: Uuid; itemName: string; - picture: string; + uploadedPhoto: string; description: string; } @@ -193,6 +193,13 @@ export interface PublicMerchItemOption { metadata: MerchItemOptionMetadata; } +export interface PublicMerchItemPhoto { + uuid: Uuid; + uploadedPhoto: string; + position: number; + uploadedAt: Date; +} + export interface PublicOrderMerchItemOption { uuid: Uuid; price: number; @@ -256,10 +263,12 @@ export interface EditMerchItemResponse extends ApiResponse { export interface DeleteMerchItemResponse extends ApiResponse {} -export interface UpdateMerchPhotoResponse extends ApiResponse { - item: PublicMerchItem; +export interface CreateMerchPhotoResponse extends ApiResponse { + merchPhoto: PublicMerchItemPhoto; } +export interface DeleteMerchItemPhotoResponse extends ApiResponse {} + export interface CreateMerchItemOptionResponse extends ApiResponse { option: PublicMerchItemOption; }