001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.petstore.console.orders;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.time.*;
022import java.time.format.DateTimeFormatter;
023import java.util.*;
024
025import org.apache.juneau.commons.beanquery.*;
026import org.apache.juneau.commons.Schema;
027import org.apache.juneau.http.*;
028import org.apache.juneau.http.response.*;
029import org.apache.juneau.marshall.collections.*;
030import org.apache.juneau.petstore.console.*;
031import org.apache.juneau.petstore.console.pages.*;
032import org.apache.juneau.petstore.dto.*;
033import org.apache.juneau.petstore.service.*;
034import org.apache.juneau.rest.server.*;
035import org.apache.juneau.console.datatables.*;
036import org.apache.juneau.console.datatables.adapter.*;
037import org.apache.juneau.rest.server.view.*;
038import org.apache.juneau.rest.server.view.freemarker.*;
039import org.apache.juneau.console.views.*;
040import org.apache.juneau.console.widgets.*;
041
042/**
043 * The Orders page and its endpoints: the server-mode datatable query over BeanQuery, the {@code approve} and
044 * {@code deliver} status transitions, and the cancel dialog form with its {@code DELETE}.
045 *
046 * <h5 class='section'>Example:</h5>
047 * <p class='bjava'>
048 *    <jc>// PLACED to APPROVED; any other status is a 409.</jc>
049 *    <ja>@RestPost</ja>(path=<js>"/{id}/approve"</js>)
050 *    <jk>public</jk> ActionResult approve(RestRequest <jv>req</jv>, <ja>@Path</ja>(<js>"id"</js>) <jk>long</jk> <jv>id</jv>) {
051 *       <jk>return</jk> transition(<jv>req</jv>, <jv>id</jv>, <js>"approve"</js>, OrderStatus.<jsf>PLACED</jsf>, OrderStatus.<jsf>APPROVED</jsf>);
052 *    }
053 * </p>
054 * <p class='bcode'>
055 *    &lt;@card type="datatables" id="orders"&gt;
056 *    { dataMode:'server', dataUrl:'/console/orders/query', rowActions:[{id:'approve', endpoint:'/console/orders/{id}/approve', method:'POST'}] }
057 *    &lt;/@card&gt;
058 * </p>
059 */
060@Rest(path="/orders", title="Orders")
061@SuppressWarnings({
062   "java:S110", // Inheritance depth comes from the BasicRestServlet hierarchy, not this page.
063   "java:S1192" // Route paths and field names repeat as inline literals by design.
064})
065public class OrdersRest extends PetstoreConsolePage {
066
067   private static final long serialVersionUID = 1L;
068
069   /**
070    * The Orders page.
071    *
072    * @param req The request; {@code ?builder} renders the builder twin.
073    * @return The page.
074    */
075   @RestGet(path="/")
076   public View page(RestRequest req) {
077      return builder(req) ? twin(req, OrdersPageBuilders.orders()) : FreemarkerView.of("orders.ftlh");
078   }
079
080   /**
081    * Server-mode DataTables query over all orders.
082    *
083    * <p>
084    * The optional {@code day} query parameter ({@code yyyy-mm-dd}) narrows the orders to those whose ship date falls in
085    * that day's window, before the request's own column filters apply, so the two combine.  A missing, blank or
086    * malformed value is ignored.
087    *
088    * <p>
089    * The optional {@code start} and {@code end} query parameters (the {@code window} URL-state directive's defaults;
090    * each a {@code yyyy-mm-dd} date or an ISO date-time, whose calendar date is used) keep the orders whose ship date
091    * falls between them, both days included, and either may be given alone.  A bound that is blank or malformed is
092    * ignored.  An order with no readable ship date is outside any window that has a usable bound.  The window and
093    * {@code day} combine with each other and with the request's own column filters.
094    *
095    * @param req The DataTables request.
096    * @param day The day to keep, or {@code null}.
097    * @param start The first ship day to keep, or {@code null}.
098    * @param end The last ship day to keep, or {@code null}.
099    * @return One page of orders, or a 200 {@code error} envelope for a malformed search expression.
100    */
101   @RestPost(path="/query")
102   public DataTablesResults<Order> query(@Content DataTablesRequest req, @Query("day") @Schema(aev=true) String day,
103         @Query("start") @Schema(aev=true) String start, @Query("end") @Schema(aev=true) String end) {
104      var wanted = parseDay(day);
105      var from = shipDay(start);
106      var to = shipDay(end);
107      var orders = store().getOrders();
108      if (wanted != null)
109         orders = orders.stream().filter(o -> wanted.equals(shipDay(o.getShipDate()))).toList();
110      if (from != null || to != null)
111         orders = orders.stream().filter(o -> inWindow(shipDay(o.getShipDate()), from, to)).toList();
112      try (var s = PetStore.ORDER_QUERY.getSession(orders)) {
113         return DataTablesQuery.run(req, s);
114      } catch (BeanQuerySyntaxException e) {
115         return DataTablesResults.error(req.getDraw(), e.getMessage());
116      }
117   }
118
119   /** Whether a ship day is inside the window; either bound may be {@code null} (open), and no ship day is never inside. */
120   private static boolean inWindow(LocalDate shipped, LocalDate from, LocalDate to) {
121      return shipped != null && (from == null || ! shipped.isBefore(from)) && (to == null || ! shipped.isAfter(to));
122   }
123
124   private static LocalDate parseDay(String value) {
125      if (value == null)
126         return null;
127      try {
128         return LocalDate.parse(value.trim());
129      } catch (@SuppressWarnings("unused") DateTimeException e) {
130         return null;
131      }
132   }
133
134   /** The calendar date of an order's ship date, which is an ISO date or an ISO date-time; {@code null} if it is neither. */
135   private static LocalDate shipDay(String value) {
136      var day = parseDay(value);
137      if (day != null || value == null)
138         return day;
139      try {
140         return DateTimeFormatter.ISO_DATE_TIME.parse(value.trim(), LocalDate::from);
141      } catch (@SuppressWarnings("unused") DateTimeException e) {
142         return null;
143      }
144   }
145
146   /**
147    * The new-order wizard, step one: pick a pet that is for sale and an optional ship date.
148    *
149    * <p>
150    * The {@code review} button opens the stacked second step and carries these inputs to it as drafts.  That step's
151    * Confirm posts to {@code /console/orders}, as declared on the child action.
152    *
153    * @return The modal, on the compact chrome.
154    */
155   @RestGet(path="/new-form")
156   public ModalDef newForm() {
157      var pet = FormDef.Input.of("petId", "Pet", "select").required();
158      store().getPets().stream().filter(p -> p.getStatus() == PetStatus.AVAILABLE)
159         .sorted(Comparator.comparingLong(Pet::getId))
160         .forEach(p -> pet.option(String.valueOf(p.getId()), p.getName()));
161      var form = FormDef.create()
162         .field(pet)
163         .field(FormDef.Input.of("shipDate", "Ship date (yyyy-mm-dd)", "text"))
164         .field(FormDef.Input.of("review", "Review order", "action").actionId("review"));
165      return ModalDef.create("New order: choose a pet").chrome("compact")
166         .form(form)
167         .childAction(ModalDef.ChildAction.of("review", "Review order").form("/console/orders/review-form").carryDrafts(true)
168            // The stacked step's Confirm submits with the child's own endpoint and method; without them it sends nothing.
169            .endpoint("/console/orders").method("POST").onSuccess("redraw"));
170   }
171
172   /**
173    * The new-order wizard, step two: the order about to be placed, read back from the first step's drafts.
174    *
175    * @param drafts The first step's inputs as JSON, carried by the dialog as {@code juneauDrafts}.
176    * @return The modal, on the compact chrome, whose form re-posts the validated values.
177    * @throws BadRequest If the drafts are missing, not JSON, or carry a bad ship date.
178    * @throws NotFound If the pet does not exist.
179    * @throws Conflict If the pet is not available.
180    */
181   @RestGet(path="/review-form")
182   public ModalDef reviewForm(@Query("juneauDrafts") String drafts) {
183      if (ib(drafts))
184         throw new BadRequest("Missing order draft");
185      JsonMap m;
186      try {
187         m = new JsonMap(drafts);
188      } catch (Exception e) {
189         throw new BadRequest("Invalid order draft: '%s'", e.getMessage());
190      }
191      var pet = pet(m.getString("petId"));
192      var shipDate = shipDate(m.getString("shipDate"));
193      return ModalDef.create("Review order").chrome("compact")
194         .field("Pet", pet.getName())
195         .field("Ship date", shipDate == null ? "(none)" : shipDate)
196         .form(FormDef.create()
197            .field(FormDef.Input.of("petId", "Pet", "text").value(String.valueOf(pet.getId())))
198            .field(FormDef.Input.of("shipDate", "Ship date", "text").value(shipDate == null ? "" : shipDate)));
199   }
200
201   /**
202    * Places a new order.
203    *
204    * @param req The request (audit actor).
205    * @param body The order, as the plain {@code {petId, shipDate}} or the dialog envelope {@code {fields:{...}}}.
206    * @return The result toast, carrying the stored order.
207    * @throws BadRequest On a missing body, non-object {@code fields}, missing pet or a bad ship date.
208    * @throws NotFound If the pet does not exist.
209    * @throws Conflict If the pet is not available.
210    */
211   @RestPost(path="/")
212   public ActionResult create(RestRequest req, @Content JsonMap body) {
213      var actor = ConsoleWrites.actor(req);
214      if (body == null)
215         throw new BadRequest("Missing order body");
216      JsonMap fields;
217      try {
218         fields = body.getMap("fields");
219      } catch (Exception e) {
220         throw new BadRequest("Invalid order fields: '%s'", e.getMessage());
221      }
222      if (fields == null && body.get("fields") != null)
223         throw new BadRequest("Invalid order fields: '%s'", body.get("fields"));
224      var f = fields != null ? fields : body;
225      var pet = pet(f.getString("petId"));
226      var order = new Order().setPetId(pet.getId()).setShipDate(shipDate(f.getString("shipDate"))).setStatus(OrderStatus.PLACED);
227      var stored = store().createOrder(order, actor);
228      return ActionResult.success(stored).message(f("Placed order '%s' for '%s'", stored.getId(), pet.getName()));
229   }
230
231   private Pet pet(String id) {
232      if (ib(id))
233         throw new BadRequest("Missing pet");
234      Pet p = null;
235      try {
236         p = store().getPet(Long.parseLong(id.trim()));
237      } catch (NumberFormatException e) {
238         throw new BadRequest("Invalid pet '%s'", id);
239      }
240      if (p == null)
241         throw new NotFound("Unknown pet '%s'", id);
242      if (p.getStatus() != PetStatus.AVAILABLE)
243         throw new Conflict("Pet '%s' is '%s'; only an available pet can be ordered", id, p.getStatus());
244      return p;
245   }
246
247   private static String shipDate(String value) {
248      if (ib(value))
249         return null;
250      try {
251         return LocalDate.parse(value.trim()).toString();
252      } catch (DateTimeException e) {
253         throw new BadRequest("Invalid ship date '%s'", value);
254      }
255   }
256
257   /**
258    * Approves a placed order.
259    *
260    * @param req The request.
261    * @param id The order id.
262    * @return The result toast, carrying the order.
263    * @throws NotFound If the order does not exist.
264    * @throws Conflict If it is not {@code PLACED}.
265    */
266   @RestPost(path="/{id}/approve")
267   public ActionResult approve(RestRequest req, @Path("id") long id) {
268      return transition(req, id, "approve", OrderStatus.PLACED, OrderStatus.APPROVED);
269   }
270
271   /**
272    * Delivers an approved order.
273    *
274    * @param req The request.
275    * @param id The order id.
276    * @return The result toast, carrying the order.
277    * @throws NotFound If the order does not exist.
278    * @throws Conflict If it is not {@code APPROVED}.
279    */
280   @RestPost(path="/{id}/deliver")
281   public ActionResult deliver(RestRequest req, @Path("id") long id) {
282      return transition(req, id, "deliver", OrderStatus.APPROVED, OrderStatus.DELIVERED);
283   }
284
285   /**
286    * The cancel dialog form: a reason and whether to notify the customer.
287    *
288    * @param id The order id.
289    * @return The modal.
290    * @throws NotFound If the order does not exist.
291    */
292   @RestGet(path="/{id}/cancel-form")
293   public ModalDef cancelForm(@Path("id") long id) {
294      require(id);
295      return ModalDef.create(f("Cancel order '%s'", id))
296         .form(FormDef.create()
297            .field(FormDef.Input.of("reason", "Reason", "text"))
298            .field(FormDef.Input.of("notify", "Notify the customer", "checkbox")));
299   }
300
301   /**
302    * Deletes (cancels) an order.
303    *
304    * @param req The request.
305    * @param id The order id.
306    * @return The result toast.
307    * @throws BadRequest If the optional body is not a JSON object.
308    * @throws NotFound If the order does not exist.
309    */
310   @RestDelete(path="/{id}")
311   public ActionResult cancel(RestRequest req, @Path("id") long id) {
312      var actor = ConsoleWrites.actor(req);
313      require(id);
314      var reason = reason(req);
315      store().deleteOrder(id, actor, reason);
316      return ActionResult.success(null).message(f("Cancelled order '%s'", id));
317   }
318
319   /** The optional body: the cancel dialog envelope {@code {fields:{reason, notify}}}, or {@code {reason}} from a plain API call. */
320   private static String reason(RestRequest req) {
321      try {
322         var text = req.getContent().asString();
323         if (ib(text))
324            return null;
325         var body = new JsonMap(text);
326         var fields = body.getMap("fields");
327         return (fields != null ? fields : body).getString("reason");
328      } catch (Exception e) {
329         throw new BadRequest("Invalid cancel body: '%s'", e.getMessage());
330      }
331   }
332
333   private ActionResult transition(RestRequest req, long id, String verb, OrderStatus from, OrderStatus to) {
334      var actor = ConsoleWrites.actor(req);
335      var o = require(id);
336      if (o.getStatus() != from)
337         throw new Conflict("Order '%s' is '%s'; cannot '%s'", id, o.getStatus(), verb);
338      // Update a copy: the live order must not change unless the store accepts the write.
339      var next = new Order().setId(o.getId()).setPetId(o.getPetId()).setShipDate(o.getShipDate()).setStatus(to);
340      return ActionResult.success(store().updateOrder(next, actor)).message(f("Order '%s' %s", id, to));
341   }
342
343   private Order require(long id) {
344      var o = store().getOrder(id);
345      if (o == null)
346         throw new NotFound("Unknown order '%s'", id);
347      return o;
348   }
349}