Skip to content

Commit

Permalink
Implement order states for order cancellation, fulfillment, pickup mi…
Browse files Browse the repository at this point in the history
…sses, and pickup cancellation (#207)

* Adds main code for Order Status routes

* Add email templates for pickup missed, pickup cancelled, order cancelled emails

* Add validators and requests for editing merch orders

* Progress on connecting edit order with email service

* Fix field accessing for pickup event in templates

* Implement editing merch order states v1

* Add migration for Orders.status field

* Add status query param to GET /order route

* Refund user when order is cancelled

* Implement delete functionality for pickup events

* Add check for pickup event timeline in order cancellation logic

* Rename map variable to mention name

Co-authored-by: Sumeet Bansal <[email protected]>

* Update services/MerchStoreService.ts

Co-authored-by: Sumeet Bansal <[email protected]>

* Update services/MerchStoreService.ts

Co-authored-by: Sumeet Bansal <[email protected]>

* Split PATCH /order/:uuid into separate routes to serve state machine behavior

* Add validation for order state machine

* Fix method name in suggestion merge

* Integrate order verification changes from master into order state changes

* Convert forEach to for loop + remove duplicate variable access

* fixed various PR comments

* Update templates to display proper portal url for cancelling events

* Split merch.test.ts into merch.store.test.ts and merch.order.test.ts

* Add skeleton test code

* Add email mocking + fix archived collection <-> hidden item invariant + write first test

* Update date format in email template

* Progress on tests

* More progress on tests

* Add email for updating pickup event for an order + finish order state tests

* Rename merch order and merch store test files

* Change 'verify' -> 'validate' in merch controller/service

* Rename /order/miss -> /order/missed

* Implement partial-order cancellation

* Remove OrderStates.PARTIALLY_FULFILLED

* Implement gradual fulfillment across multiple requests

* Implement partial-order cancellation refund test

* Write tests for POST /order/cleanup

* Add member.reload() calls to make clearer that credits are refreshed

* Add activity logging for orders

* Add GET /order/pickup/past route and test

* Add tests for order activity

* lint

* Disallow creating pickup events within 2 days of the event start

* Ensure item has at least 1 option on creation

* make my tests deterministic

* Fix minor style guides

* Remove GET status filter from /order and rename to GET /orders

* linty lint

Co-authored-by: Michael Shao <[email protected]>
Co-authored-by: Sumeet Bansal <[email protected]>
  • Loading branch information
3 people authored Nov 18, 2021
1 parent 7c34f23 commit c097558
Show file tree
Hide file tree
Showing 25 changed files with 2,078 additions and 277 deletions.
83 changes: 70 additions & 13 deletions api/controllers/MerchStoreController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@ import {
PlaceMerchOrderResponse,
VerifyMerchOrderResponse,
EditMerchOrderResponse,
FulfillMerchOrderResponse,
CreateMerchItemOptionResponse,
DeleteMerchItemOptionResponse,
MerchItemOptionAndQuantity,
CreateOrderPickupEventResponse,
GetOrderPickupEventsResponse,
DeleteOrderPickupEventResponse,
EditOrderPickupEventResponse,
CancelAllPendingOrdersResponse,
} from '../../types';
import { UuidParam } from '../validators/GenericRequests';
import { AuthenticatedUser } from '../decorators/AuthenticatedUser';
Expand All @@ -46,11 +50,13 @@ import {
PlaceMerchOrderRequest,
VerifyMerchOrderRequest,
FulfillMerchOrderRequest,
EditMerchOrderPickupRequest,
CreateMerchItemOptionRequest,
CreateOrderPickupEventRequest,
EditOrderPickupEventRequest,
} from '../validators/MerchStoreRequests';
import { UserError } from '../../utils/Errors';
import { OrderModel } from '../../models/OrderModel';

@UseBefore(UserAuthentication)
@JsonController('/merch')
Expand Down Expand Up @@ -163,32 +169,36 @@ export class MerchStoreController {
return { error: null, order };
}

@Get('/order')
@Get('/orders')
async getAllMerchOrders(@AuthenticatedUser() user: UserModel): Promise<GetAllMerchOrdersResponse> {
if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
const canSeeAllOrders = PermissionsService.canSeeAllMerchOrders(user);
const orders = await this.merchStoreService.getAllOrders(user, canSeeAllOrders);
return { error: null, orders };
let orders: OrderModel[];
if (canSeeAllOrders) {
orders = await this.merchStoreService.getAllOrdersForAllUsers();
} else {
orders = await this.merchStoreService.getAllOrdersForUser(user);
}
return { error: null, orders: orders.map((o) => o.getPublicOrder()) };
}

@Post('/order')
async placeMerchOrder(@Body() placeOrderRequest: PlaceMerchOrderRequest,
@AuthenticatedUser() user: UserModel): Promise<PlaceMerchOrderResponse> {
const originalOrder = this.verifyMerchOrderRequest(placeOrderRequest.order, user);
const originalOrder = this.validateMerchOrderRequest(placeOrderRequest.order);
const order = await this.merchStoreService.placeOrder(originalOrder, user, placeOrderRequest.pickupEvent);
return { error: null, order };
}

@Post('/order/verification')
async verifyMerchOrder(@Body() verifyOrderRequest: VerifyMerchOrderRequest,
@AuthenticatedUser() user: UserModel): Promise<VerifyMerchOrderResponse> {
const originalOrder = this.verifyMerchOrderRequest(verifyOrderRequest.order, user);
await this.merchStoreService.verifyOrder(originalOrder, user);
const originalOrder = this.validateMerchOrderRequest(verifyOrderRequest.order);
await this.merchStoreService.validateOrder(originalOrder, user);
return { error: null };
}

private verifyMerchOrderRequest(orderRequest: MerchItemOptionAndQuantity[],
user: UserModel): MerchItemOptionAndQuantity[] {
private validateMerchOrderRequest(orderRequest: MerchItemOptionAndQuantity[]): MerchItemOptionAndQuantity[] {
const originalOrder = orderRequest.filter((oi) => oi.quantity > 0);
const orderIsEmpty = originalOrder.reduce((x, n) => x + n.quantity, 0) === 0;
if (orderIsEmpty) throw new UserError('There are no items in this order');
Expand All @@ -197,18 +207,57 @@ export class MerchStoreController {
return originalOrder;
}

@Patch('/order')
async editMerchOrder(@Body() fulfillOrderRequest: FulfillMerchOrderRequest,
@Patch('/order/:uuid/pickup')
async editMerchOrderPickup(@Params() params: UuidParam,
@Body() editOrderRequest: EditMerchOrderPickupRequest,
@AuthenticatedUser() user: UserModel): Promise<EditMerchOrderResponse> {
if (!PermissionsService.canFulfillMerchOrders(user)) throw new ForbiddenError();
if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
await this.merchStoreService.editMerchOrderPickup(params.uuid, editOrderRequest.pickupEvent, user);
return { error: null };
}

@Post('/order/:uuid/cancel')
async cancelMerchOrder(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel) {
if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
const order = await this.merchStoreService.cancelMerchOrder(params.uuid, user);
return { error: null, order };
}

@Post('/order/:uuid/missed')
async markOrderAsMissed(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel) {
if (!PermissionsService.canManageMerchOrders(user)) throw new ForbiddenError();
const order = await this.merchStoreService.markOrderAsMissed(params.uuid, user);
return { error: null, order };
}

@Post('/order/:uuid/fulfill')
async fulfillMerchOrderItems(@Params() params: UuidParam, @Body() fulfillOrderRequest: FulfillMerchOrderRequest,
@AuthenticatedUser() user: UserModel): Promise<FulfillMerchOrderResponse> {
if (!PermissionsService.canManageMerchOrders(user)) throw new ForbiddenError();
const numUniqueUuids = (new Set(fulfillOrderRequest.items.map((oi) => oi.uuid))).size;
if (fulfillOrderRequest.items.length !== numUniqueUuids) {
throw new BadRequestError('There are duplicate order items');
}
await this.merchStoreService.updateOrderItems(fulfillOrderRequest.items);
await this.merchStoreService.fulfillOrderItems(fulfillOrderRequest.items, params.uuid, user);
return { error: null };
}

@Post('/order/cleanup')
async cancelAllPendingMerchOrders(@AuthenticatedUser() user: UserModel): Promise<CancelAllPendingOrdersResponse> {
if (!PermissionsService.canCancelAllPendingOrders(user)) throw new ForbiddenError();
await this.merchStoreService.cancelAllPendingOrders(user);
return { error: null };
}

@Get('/order/pickup/past')
async getPastPickupEvents(@AuthenticatedUser() user: UserModel): Promise<GetOrderPickupEventsResponse> {
const pickupEvents = await this.merchStoreService.getPastPickupEvents();
const canSeePickupEventOrders = PermissionsService.canSeePickupEventOrders(user);
const publicPickupEvents = pickupEvents.map((pickupEvent) => pickupEvent
.getPublicOrderPickupEvent(canSeePickupEventOrders));
return { error: null, pickupEvents: publicPickupEvents };
}

@Get('/order/pickup/future')
async getFuturePickupEvents(@AuthenticatedUser() user: UserModel): Promise<GetOrderPickupEventsResponse> {
const pickupEvents = await this.merchStoreService.getFuturePickupEvents();
Expand All @@ -229,10 +278,18 @@ export class MerchStoreController {
@Patch('/order/pickup/:uuid')
async editPickupEvent(@Params() params: UuidParam,
@Body() editOrderPickupEventRequest: EditOrderPickupEventRequest,
@AuthenticatedUser() user: UserModel): Promise<CreateOrderPickupEventResponse> {
@AuthenticatedUser() user: UserModel): Promise<EditOrderPickupEventResponse> {
if (!PermissionsService.canManagePickupEvents(user)) throw new ForbiddenError();
const pickupEvent = await this.merchStoreService.editPickupEvent(params.uuid,
editOrderPickupEventRequest.pickupEvent);
return { error: null, pickupEvent: pickupEvent.getPublicOrderPickupEvent() };
}

@Delete('/order/pickup/:uuid')
async deletePickupEvent(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel):
Promise<DeleteOrderPickupEventResponse> {
if (!PermissionsService.canManagePickupEvents(user)) throw new ForbiddenError();
await this.merchStoreService.deletePickupEvent(params.uuid);
return { error: null };
}
}
17 changes: 16 additions & 1 deletion api/decorators/Validators.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
ValidatorConstraintInterface, registerDecorator, ValidationOptions, ValidatorConstraint,
} from 'class-validator';
import { PasswordChange, FeedbackType, FeedbackStatus } from '../../types';
import { PasswordChange, FeedbackType, FeedbackStatus, OrderStatus } from '../../types';

function templatedValidationDecorator(
validator: ValidatorConstraintInterface | Function, validationOptions?: ValidationOptions,
Expand Down Expand Up @@ -138,3 +138,18 @@ class FeedbackStatusValidator implements ValidatorConstraintInterface {
export function IsValidFeedbackStatus(validationOptions?: ValidationOptions) {
return templatedValidationDecorator(FeedbackStatusValidator, validationOptions);
}

@ValidatorConstraint()
class OrderStatusValidator implements ValidatorConstraintInterface {
validate(orderStatus: OrderStatus): boolean {
return Object.values(OrderStatus).includes(orderStatus);
}

defaultMessage(): string {
return `Order status must be one of ${JSON.stringify(Object.values(OrderStatus))}`;
}
}

export function IsValidOrderStatus(validationOptions?: ValidationOptions) {
return templatedValidationDecorator(OrderStatusValidator, validationOptions);
}
18 changes: 15 additions & 3 deletions api/validators/MerchStoreRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ValidateNested,
IsHexColor,
IsDateString,
ArrayNotEmpty,
} from 'class-validator';
import { Type } from 'class-transformer';
import {
Expand All @@ -19,6 +20,7 @@ import {
PlaceMerchOrderRequest as IPlaceMerchOrderRequest,
VerifyMerchOrderRequest as IVerifyMerchOrderRequest,
FulfillMerchOrderRequest as IFulfillMerchOrderRequest,
EditMerchOrderPickupRequest as IEditMerchOrderPickupRequest,
CreateOrderPickupEventRequest as ICreateOrderPickupEventRequest,
EditOrderPickupEventRequest as IEditOrderPickupEventRequest,
MerchItemOptionAndQuantity as IMerchItemOptionAndQuantity,
Expand All @@ -30,6 +32,7 @@ import {
MerchItemOption as IMerchItemOption,
MerchItemOptionEdit as IMerchItemOptionEdit,
MerchItemOptionMetadata as IMerchItemOptionMetadata,
MerchOrderEdit as IMerchOrderEdit,
OrderPickupEvent as IOrderPickupEvent,
OrderPickupEventEdit as IOrderPickupEventEdit,
} from '../../types';
Expand Down Expand Up @@ -155,6 +158,7 @@ export class MerchItem implements IMerchItem {
@Type(() => MerchItemOption)
@ValidateNested()
@IsDefined()
@ArrayNotEmpty()
options: MerchItemOption[];
}

Expand Down Expand Up @@ -197,9 +201,6 @@ export class OrderItemFulfillmentUpdate implements IOrderItemFulfillmentUpdate {
@IsUUID()
uuid: string;

@Allow()
fulfilled?: boolean;

@Allow()
notes?: string;
}
Expand Down Expand Up @@ -235,6 +236,11 @@ export class OrderPickupEventEdit implements IOrderPickupEventEdit {
description?: string;
}

export class MerchOrderEdit implements IMerchOrderEdit {
@IsUUID()
pickupEvent: string;
}

export class CreateMerchCollectionRequest implements ICreateMerchCollectionRequest {
@Type(() => MerchCollection)
@ValidateNested()
Expand Down Expand Up @@ -295,6 +301,12 @@ export class FulfillMerchOrderRequest implements IFulfillMerchOrderRequest {
items: OrderItemFulfillmentUpdate[];
}

export class EditMerchOrderPickupRequest implements IEditMerchOrderPickupRequest {
@IsDefined()
@IsUUID()
pickupEvent: string;
}

export class CreateOrderPickupEventRequest implements ICreateOrderPickupEventRequest {
@Type(() => OrderPickupEvent)
@ValidateNested()
Expand Down
22 changes: 22 additions & 0 deletions migrations/0030-add-order-status-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from 'typeorm';

const TABLE_NAME = 'Orders';

export class AddOrderStatusField1633030219180 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(TABLE_NAME, new TableColumn({
name: 'status',
type: 'varchar(255)',
default: '\'PLACED\'',
}));
await queryRunner.createIndex(TABLE_NAME, new TableIndex({
name: 'orders_by_status_index',
columnNames: ['status'],
}));
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex(TABLE_NAME, 'orders_by_status_index');
await queryRunner.dropColumn(TABLE_NAME, 'status');
}
}
1 change: 1 addition & 0 deletions models/OrderItemModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class OrderItemModel extends BaseEntity {
@JoinColumn({ name: 'option' })
option: MerchandiseItemOptionModel;

// the price paid by the user at checkout
@Column('integer')
salePriceAtPurchase: number;

Expand Down
6 changes: 5 additions & 1 deletion models/OrderModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Uuid, PublicOrder } from '../types';
import { Uuid, PublicOrder, OrderStatus } from '../types';
import { UserModel } from './UserModel';
import { OrderItemModel } from './OrderItemModel';
import { OrderPickupEventModel } from './OrderPickupEventModel';
Expand All @@ -26,6 +26,9 @@ export class OrderModel extends BaseEntity {
@Column('integer')
totalCost: number;

@Column('varchar', { length: 255, default: OrderStatus.PLACED })
status: OrderStatus;

@Column('timestamptz', { default: () => 'CURRENT_TIMESTAMP(6)' })
@Index('recent_orders_index')
orderedAt: Date;
Expand All @@ -46,6 +49,7 @@ export class OrderModel extends BaseEntity {
uuid: this.uuid,
user: this.user.uuid,
totalCost: this.totalCost,
status: this.status,
orderedAt: this.orderedAt,
pickupEvent: this.pickupEvent?.getPublicOrderPickupEvent(),
items: this.items.map((oi) => oi.getPublicOrderItem()),
Expand Down
36 changes: 24 additions & 12 deletions repositories/MerchOrderRepository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EntityRepository, SelectQueryBuilder } from 'typeorm';
import { Uuid } from '../types';
import { OrderStatus, Uuid } from '../types';
import { OrderModel } from '../models/OrderModel';
import { UserModel } from '../models/UserModel';
import { OrderItemModel } from '../models/OrderItemModel';
Expand All @@ -8,21 +8,25 @@ import { BaseRepository } from './BaseRepository';

@EntityRepository(OrderModel)
export class MerchOrderRepository extends BaseRepository<OrderModel> {
public async createMerchOrder(order: OrderModel): Promise<OrderModel> {
return this.repository.save(order);
}

public async findByUuid(uuid: Uuid): Promise<OrderModel> {
return this.repository.findOne(uuid);
}

public async getAllOrdersForAllUsers(): Promise<OrderModel[]> {
public async getAllOrdersForAllUsers(status?: OrderStatus): Promise<OrderModel[]> {
if (status) {
return this.repository.find({ status });
}
return this.repository.find();
}

public async getAllOrdersForUser(user: UserModel): Promise<OrderModel[]> {
return this.repository.find({ user });
}

public async upsertMerchOrder(order: OrderModel, changes?: Partial<OrderModel>): Promise<OrderModel> {
if (changes) order = OrderModel.merge(order, changes);
return this.repository.save(order);
}
}

@EntityRepository(OrderItemModel)
Expand All @@ -32,11 +36,9 @@ export class OrderItemRepository extends BaseRepository<OrderItemModel> {
return new Map(items.map((i) => [i.uuid, i]));
}

public async fulfillOrderItem(orderItem: OrderItemModel, fulfilled?: boolean, notes?: string) {
if (fulfilled) {
orderItem.fulfilled = true;
orderItem.fulfilledAt = new Date();
}
public async fulfillOrderItem(orderItem: OrderItemModel, notes?: string) {
orderItem.fulfilled = true;
orderItem.fulfilledAt = new Date();
if (notes) orderItem.notes = notes;
return this.repository.save(orderItem);
}
Expand Down Expand Up @@ -66,6 +68,13 @@ export class OrderItemRepository extends BaseRepository<OrderItemModel> {

@EntityRepository(OrderPickupEventModel)
export class OrderPickupEventRepository extends BaseRepository<OrderPickupEventModel> {
public async getPastPickupEvents(): Promise<OrderPickupEventModel[]> {
return this.getBaseFindQuery()
.where('"end" < :now')
.setParameter('now', new Date())
.getMany();
}

public async getFuturePickupEvents(): Promise<OrderPickupEventModel[]> {
return this.getBaseFindQuery()
.where('"end" >= :now')
Expand All @@ -88,6 +97,9 @@ export class OrderPickupEventRepository extends BaseRepository<OrderPickupEventM
private getBaseFindQuery(): SelectQueryBuilder<OrderPickupEventModel> {
return this.repository
.createQueryBuilder('orderPickupEvent')
.leftJoinAndSelect('orderPickupEvent.orders', 'orders');
.leftJoinAndSelect('orderPickupEvent.orders', 'order')
.leftJoinAndSelect('order.items', 'item')
.leftJoinAndSelect('order.user', 'user')
.leftJoinAndSelect('item.option', 'option');
}
}
Loading

0 comments on commit c097558

Please sign in to comment.