Skip to content

Commit

Permalink
Create Multiple Gallery Store Images (#351)
Browse files Browse the repository at this point in the history
* started migration

* almost completed the query

* change MerchandiseItemModel picture to pictures

* renamed Merchandise to MercahndiseItems

* generated a new model for the pictures

* fixed syntax error with migration

* understand and fix casting error

* edited requests and created some todos

* return position == 0 instead of first picture in array

* migration temp fix

* this one line of code would attach pictures to the collection so that the frontend can display the first picture

* edited some todos to implement a new idea

* edited the service

* created a repository for photos

* Completed the photo create route

* completed the photo deletion route

* getting started with seeding

* make sure the index is consistent

* removed the current file name from url for security purpose

* quick linting

* edited seeding to ensure correctness

* update MerchFactory item for photo support

* refactor and renaming variables

* wrote outline for test and rewrote a method

* fix error

* edits

* removing some junk code

* the error is playing hide and seek with me

* im such a genius

* removing partial debug msgs

* edits

* fixed the order item test

* finished creating tests and pass all tests

* fixed some error

* I CHATGPTED THE SQL AND IT WORKED

* fixed linting error

* edit migration number order

* clean up some unused variables

* renamed picture to uploadedPhoto and photo to merchPhoto for clarity, added some documentation

* remove magic number

* slight seeding edit

* removed position logic

* clarify cascading quetsion

* fixed cascade

* clean up

* clarify seeding data structure

* link fix

* change position in request to string because form data does not accept number

* throw error if position is not a number

* updated deletion logic to delete from s3 first

* link fix

* default url logic fix for positions no longer being 0

* Test automated migrations–get another user's attendance (#379)

* attendences from user uuid

* lint and bugfix

* check same user

* controller factory changes

* lint fixes

* unit test for get attendance by uuid

* lint

* add permision

* add types

* add everything else

* rename migrtion

* test when permision is off

* lint

* forgor to add

* change permission name and fix logic a bit

* rename permission, change patch user

* lint fix

* lint fix

* oops

* check user exists

* lint

* new ci

---------

Co-authored-by: Max Weng <[email protected]>

* bumped my migration file number

* bumped my migration file number v2

* remove local settings.json change

* added edge case for migration up

* lint

* lint

* such developer velocity

* removed debugging

---------

Co-authored-by: Nikhil Dange <[email protected]>
Co-authored-by: Max Weng <[email protected]>
Co-authored-by: Nikhil Dange <[email protected]>
  • Loading branch information
4 people authored Dec 31, 2023
1 parent 57f6c4a commit f658e57
Show file tree
Hide file tree
Showing 18 changed files with 676 additions and 43 deletions.
45 changes: 36 additions & 9 deletions api/controllers/MerchStoreController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -41,7 +42,8 @@ import {
CancelAllPendingOrdersResponse,
MediaType,
File,
UpdateMerchPhotoResponse,
CreateMerchPhotoResponse,
DeleteMerchItemPhotoResponse,
CompleteOrderPickupEventResponse,
GetOrderPickupEventResponse,
CancelOrderPickupEventResponse,
Expand All @@ -60,6 +62,7 @@ import {
FulfillMerchOrderRequest,
RescheduleOrderPickupRequest,
CreateMerchItemOptionRequest,
CreateMerchItemPhotoRequest,
CreateOrderPickupEventRequest,
EditOrderPickupEventRequest,
GetCartRequest,
Expand Down Expand Up @@ -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<UpdateMerchPhotoResponse> {
@Body() createItemPhotoRequest: CreateMerchItemPhotoRequest,
@AuthenticatedUser() user: UserModel): Promise<CreateMerchPhotoResponse> {
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<DeleteMerchItemPhotoResponse> {
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')
Expand All @@ -189,9 +214,11 @@ export class MerchStoreController {
async getOneMerchOrder(@Params() params: UuidParam,
@AuthenticatedUser() user: UserModel): Promise<GetOneMerchOrderResponse> {
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')
Expand Down
34 changes: 32 additions & 2 deletions api/validators/MerchStoreRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
IsDateString,
ArrayNotEmpty,
IsNumber,
IsNumberString,
} from 'class-validator';
import { Type } from 'class-transformer';
import {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -142,7 +166,7 @@ export class MerchItem implements IMerchItem {
description: string;

@Allow()
picture?: string;
merchPhotos: MerchItemPhoto[];

@Min(0)
quantity?: number;
Expand Down Expand Up @@ -177,7 +201,7 @@ export class MerchItemEdit implements IMerchItemEdit {
description?: string;

@Allow()
picture?: string;
merchPhotos?: MerchItemPhotoEdit[];

@Allow()
hidden?: boolean;
Expand Down Expand Up @@ -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()
Expand Down
88 changes: 88 additions & 0 deletions migrations/0038-add-merch-item-image-table.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void> {
// 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);
}
}
20 changes: 15 additions & 5 deletions models/MerchandiseItemModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand All @@ -38,14 +36,17 @@ 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[];

public getPublicMerchItem(): PublicMerchItem {
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,
Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion models/MerchandiseItemOptionModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
34 changes: 34 additions & 0 deletions models/MerchandiseItemPhotoModel.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
2 changes: 2 additions & 0 deletions models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,7 @@ export const models = [
AttendanceModel,
MerchandiseCollectionModel,
MerchandiseItemModel,
MerchandiseItemPhotoModel,
MerchandiseItemOptionModel,
OrderModel,
OrderItemModel,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
Loading

0 comments on commit f658e57

Please sign in to comment.