Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement order states for order cancellation, fulfillment, pickup misses, and pickup cancellation #207

Merged
merged 52 commits into from
Nov 18, 2021

Conversation

shravanhariharan2
Copy link
Collaborator

@shravanhariharan2 shravanhariharan2 commented Oct 1, 2021

Closes #205, #204, #203, #218, and #217

Details for this feature can be found in the Merch Store Specification on Notion: https://www.notion.so/acmucsd/Merch-Store-Specification-2021-d0d5fa4d16de472aa7e13534e060f8ec (under the "Orders" section of the API routes)

@github-actions
Copy link

github-actions bot commented Oct 1, 2021

Thanks for contributing! If you've made changes to the API's functionality, please make sure to bump the package version—see this guide to semantic versioning for details—and document those changes as appropriate.

@shravanhariharan2
Copy link
Collaborator Author

Tests still need to be written, but I'll publish this PR for now since it's pretty large and might need a bit of time to look at

@shravanhariharan2 shravanhariharan2 marked this pull request as ready for review October 1, 2021 05:39
@shravanhariharan2 shravanhariharan2 linked an issue Oct 1, 2021 that may be closed by this pull request
Comment on lines 143 to 153
class OrderStatusEditValidator implements ValidatorConstraintInterface {
validate(orderStatus: OrderStatus): boolean {
return Object.values(OrderStatus).includes(orderStatus)
&& orderStatus !== OrderStatus.PLACED
&& orderStatus !== OrderStatus.FULFILLED;
}

defaultMessage(): string {
return 'Order status must be one of [\'PICKUP_MISSED\', \'CANCELLED\', \'PICKUP_CANCELLED\']';
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error message says status must be one of [...] so let's check for that instead of checking that it's not the other things.

class OrderStatusEditValidator implements ValidatorConstraintInterface {
  private static allowedStatuses = [
    OrderStatus.PICKUP_MISSED,
    OrderStatus.CANCELLED,
    OrderStatus.PICKUP_CANCELLED
  ];
  
  validate(orderStatus: OrderStatus): boolean {
    return allowedStatuses.includes(orderStatus);
  }

  defaultMessage(): string {
    return `Order status must be one of ${allowedStatuses}`;
  }
}

Comment on lines 166 to 167
return 'Order status must be one of [\'PLACED\', \'FULFILLED\', \'PICKUP_MISSED\', '
+ '\'CANCELLED\', \'PICKUP_CANCELLED\']';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should print out the array correctly? This way we don't have to update this if we add another order state too.

Suggested change
return 'Order status must be one of [\'PLACED\', \'FULFILLED\', \'PICKUP_MISSED\', '
+ '\'CANCELLED\', \'PICKUP_CANCELLED\']';
return `Order status must be one of ${Object.values(OrderStatus)}`;

migrations/0030-add-order-status-field.ts Show resolved Hide resolved
// status and pickup event are not allowed to be updated at the same time,
// since they are not logically related, and thus should never have to be updated at the same request
if (status && pickupEvent) throw new UserError('Only status or pickupEvent can be updated at once, not both');
// members are only allowed to cancel orders. store admins can perform any operation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Can an order be canceled and then re-placed? Should that be allowed? If it is, then we need to update quantities and all that.
  2. Should this endpoint be split into separate member and admin edits? e.g. DELETE /order/:uuid for members canceling orders and PATCH /order/:uuid for admins canceling orders or something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or, PATCH /order/:uuid where the only edit is pickup event, and then a separate DELETE /order/:uuid for canceling. Like, if we consider order status as a state machine, we could expose endpoints as state transitions (e.g. /cancel, /missed) and do the necessary operations than to directly edit the state itself. Or get the state in the endpoint and call different MerchOrderService methods like canceledOrder, missedPickup, canceledPickup, which might all do slightly different things. I think the service methods is the way to go. We're already doing this with fulfillOrderItems.

We could even do PATCH /order/:uuid editMerchOrder/changePickupEvent for editing pickup event and PATCH /order/:uuid/:state changeOrderStatus so we're not just forcing one endpoint to handle all types of edits.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can an order be canceled and then re-placed? Should that be allowed? If it is, then we need to update quantities and all that.

No, this isn't allowed per store requirements.

Should this endpoint be split into separate member and admin edits? e.g. DELETE /order/:uuid for members canceling orders and PATCH /order/:uuid for admins canceling orders or something?

I like the idea of splitting the giant PATCH /order/:uuid route into separate routes like /cancellation or /missed or /fulfillment. I think in the case with member vs. admin edit, it should be fine to keep them in the same route, since we do the same sort of split with other routes. Also I just realized I wrote some buggy ass code where if an admin cancells a user's order, then the admin gets refunded LMAO

return { error: null };
}

@Post('/order/:uuid/fulfillment')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, I know, nouns but I kinda like POST /order/:uuid/[fulfill/cancel]. What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a fan of it too - personally I'm of the opinion of the 'convention' for REST API naming is whatever naming we establish (since I don't think there's a set of 'naming conventions' for APIs). So I don't think we should feel too bad about having verbs in the name - especially since it also distinguishes 'actions' (ie. reset password or fulfill orders) from 'resource operations' (ie. editing a field of a model).

services/MerchStoreService.ts Outdated Show resolved Hide resolved
*/
private static getOptionPriceAndQuantitiesFromOrder(order: OrderModel): Map<string, OrderItemPriceAndQuantity> {
const optionToPriceAndQuantity = new Map<string, OrderItemPriceAndQuantity>();
order.items.forEach((oi) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does forEach process all the elements concurrently?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like it's blocking. IMO we should use for-each for pure functions and prefer for-loops if we're editing maps though. What do you think?

Copy link
Collaborator Author

@shravanhariharan2 shravanhariharan2 Oct 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, forEach is a blocking call - https://stackoverflow.com/questions/5050265/javascript-node-js-is-array-foreach-asynchronous

IMO we should use for-each for pure functions and prefer for-loops if we're editing maps though. What do you think?

That makes sense to me - since forEach does by nature look more functional, we can use regular for-loops to emphasize that there's some state being changed within the iteration

const { uuid } = oi.option;
if (optionToPriceAndQuantity.has(oi.option.uuid)) {
optionToPriceAndQuantity.set(uuid, {
quantity: optionToPriceAndQuantity.get(uuid).quantity + 1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we can put optionToPrice.get(uuid) in a variable since we reference it twice.

services/MerchStoreService.ts Outdated Show resolved Hide resolved
const pickupEvent = await orderPickupEventRepository.findByUuid(uuid);
if (pickupEvent.orders.length > 0) {
throw new UserError('Cannot delete an order pickup event that has orders assigned to it');
}
// Manually set all the orders' pickup events to null before deleting event
// email order cancellation for order, update order status, and set pickupEvent to null before deleting
pickupEvent.orders.forEach(async (order) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If forEach is blocking then let's do these concurrently so we're not sending emails one at a time and blocking on each one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked at the email template to see if we could just send the same email to all the orderers, and it seems not, but it looks like we don't specify a URL here? Or am I misreading the HTML?

https://github.com/acmucsd/membership-portal/blob/OrderStates/templates/orderPickupCancelled.ejs#L120

Copy link
Member

@sumeet-bansal sumeet-bansal Oct 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And can we send the same email to all the orderers? e.g. "Hey your order got canceled" and check the portal to see which order? Kind of a dick move though, but it means we only send one email.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I meant to ping Steven about what URL to use there but I never ended up doing that.

And can we send the same email to all the orderers? e.g. "Hey your order got canceled" and check the portal to see which order? Kind of a dick move though, but it means we only send one email.

Haha interesting idea, I think it might be better UX if we send each person a separate email though so that they can see what specific items were in their cancelled order and so they each get their own URL to reschedule. Might need to look if there's some kind of rate limiting on Sendgrid's end to make sure mass mailing won't fail for some emails.

@shravanhariharan2
Copy link
Collaborator Author

Just added the 'pending order cleanup' route for admins to cancel all orders.

TODOs (for myself):

  • Add activity logs for fulfilling, cancelling, marking as missed, and cancel-all-ing
  • Add GET /order/pickup/past route (since admins would want to scroll through past pickup events to see if orders have been missed)
  • (TBD) Add functionality for rescheduling a partially picked up order? e.g. a member can only fulfill a subset of items in 1 event so they should be able to reschedule their pickup to a future one.

@shravanhariharan2
Copy link
Collaborator Author

Will create issues for each of the above, since this PR is getting a bit large. Still need to do some refactoring for code quality, then should be ready for review

@shravanhariharan2
Copy link
Collaborator Author

Actually got around to implementing activity logging + the route in this PR 😅. Should be the last large PR for store in the next few weeks

Copy link
Member

@sumeet-bansal sumeet-bansal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't look at the tests, maybe Steven or someone else can take a look before merging?

if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
const canSeeAllOrders = PermissionsService.canSeeAllMerchOrders(user);
const orders = await this.merchStoreService.getAllOrders(user, canSeeAllOrders);
const orders = await this.merchStoreService.getAllOrders(user, filters.status, canSeeAllOrders);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^

@AuthenticatedUser() user: UserModel): Promise<EditMerchOrderResponse> {
if (!PermissionsService.canFulfillMerchOrders(user)) throw new ForbiddenError();
if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
if (!PermissionsService.canManageMerchOrders(user)) throw new ForbiddenError();

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Members can reschedule their pickup events, so they don't need store manager / store distributor permissions for this route


@Post('/order/:uuid/cancel')
async cancelMerchOrder(@Params() params: UuidParam, @AuthenticatedUser() user: UserModel) {
if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!PermissionsService.canAccessMerchStore(user)) throw new ForbiddenError();
if (!PermissionsService.canManageMerchOrders(user)) throw new ForbiddenError();

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Members can cancel their own orders, so they don't need 'manage merch order' permissions since that's something a store manager / store distributor would need

return { error: null };
}

@Post('/order/cleanup')
async cancelAllPendingMerchOrders(@AuthenticatedUser() user: UserModel): Promise<CancelAllPendingOrdersResponse> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this route isn't needed yet, can we make this PR a little smaller and put up another PR when it is needed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirmed with Steven, we will be rolling with /order/cleanup route and removing the status query param - frontend can do any filtering if needed since there's no current use case for it

services/MerchStoreService.ts Outdated Show resolved Hide resolved
* @param uuid order uuid
* @returns updated order
*/
public async markOrderAsMissed(uuid: Uuid, proxy: UserModel): Promise<OrderModel> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we talked a while back about not using proxy but maybe admin isn't the best alternative? It's better than proxy at least.

Suggested change
public async markOrderAsMissed(uuid: Uuid, proxy: UserModel): Promise<OrderModel> {
public async markOrderAsMissed(uuid: Uuid, admin: UserModel): Promise<OrderModel> {

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hesitant to use admin since soon we'll have STORE_MANAGER and STORE_DISTRIBUTOR roles who aren't necessarily admins but can mark orders as missed. We can probably leave it as admin for now but change it later to something like manager? But yeah, something that conveys 'on behalf of' would be ideal.

Comment on lines +474 to +475
* @param uuid order uuid
* @returns updated order
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove these annotations and rename the param orderUuid.

if (MerchStoreService.isLessThanTwoDaysBeforePickupEvent(order.pickupEvent)) {
throw new NotFoundError('Cannot cancel an order with a pickup date less than 2 days away');
}
const customer = order.user;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used anywhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's used in the activity logs to describe who's order has been cancelled (since admins can cancel other members orders in the case of 'cancel all pending orders')

const refundValue = unfulfilledItems.reduce((refund, item) => refund + item.salePriceAtPurchase, 0);
await MerchStoreService.refundUser(user, refundValue, txn);

// build email with only the unfulfilled items
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think the code explains itself here.

@@ -498,6 +710,25 @@ export default class MerchStoreService {
}, 0);
}

public async cancelAllPendingOrders(user: UserModel): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this cancel all pending orders or only the ones that were placed a long time ago?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would cancel all pending orders, since there could be the case where someone orders for the last pickup event of a quarter and wasn't able to make it or wasn't able to fulfill all of it

@shravanhariharan2
Copy link
Collaborator Author

This should be ready to merge now, addressed all of Sumeet's comments and ran through the tests with Steven to see what else needs to be added to get 100% store coverage

@shravanhariharan2 shravanhariharan2 merged commit c097558 into master Nov 18, 2021
@shravanhariharan2 shravanhariharan2 deleted the OrderStates branch November 18, 2021 03:57
nick-ls pushed a commit to nick-ls/membership-portal that referenced this pull request Aug 6, 2024
…sses, and pickup cancellation (acmucsd#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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants