diff --git a/backend/deps.edn b/backend/deps.edn index 2c64aab..ad855dd 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -7,7 +7,9 @@ org.postgresql/postgresql {:mvn/version "42.2.6"} ring-basic-authentication/ring-basic-authentication {:mvn/version "1.1.0"} ring-cors/ring-cors {:mvn/version "0.1.13"} - ring/ring {:mvn/version "1.8.1"}} + ring/ring {:mvn/version "1.8.1"} + org.dmfs/lib-recur {:mvn/version "0.12.2"} +} :aliases {:dev {:jvm-opts ["-Dconfig=config/dev/config.edn"] diff --git a/backend/resources/migrations/004-recurring-orders.edn b/backend/resources/migrations/004-recurring-orders.edn new file mode 100644 index 0000000..b3e1677 --- /dev/null +++ b/backend/resources/migrations/004-recurring-orders.edn @@ -0,0 +1,14 @@ +{:up ["ALTER TABLE orders ADD recurrence VARCHAR(512)" + "ALTER TABLE orders ADD end_date TIMESTAMPTZ" + "CREATE TABLE recurrence_exceptions( + order_id INT, + order_date TIMESTAMPTZ NOT NULL, + status order_state DEFAULT 'waiting', + PRIMARY KEY(order_id, order_date), + CONSTRAINT fk_customer FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE)" + "UPDATE orders SET end_date = o.order_date + FROM orders AS o + WHERE orders.id = o.id"] + :down ["DROP TABLE recurrence_exceptions" + "ALTER TABLE orders DROP COLUMN end_date" + "ALTER TABLE orders DROP COLUMN recurrence"]} diff --git a/backend/resources/migrations/005-move-statuses.edn b/backend/resources/migrations/005-move-statuses.edn new file mode 100644 index 0000000..670bee9 --- /dev/null +++ b/backend/resources/migrations/005-move-statuses.edn @@ -0,0 +1,10 @@ +{:up ["INSERT INTO recurrence_exceptions (order_id, order_date, status) + SELECT id AS order_id, order_date, status FROM orders + WHERE status != 'waiting' + ON conflict do nothing" + "ALTER TABLE orders DROP COLUMN status"] + :down ["ALTER TABLE orders ADD status order_state DEFAULT 'waiting'" + "UPDATE orders SET status = ex.status + FROM recurrence_exceptions AS ex + WHERE orders.id = ex.order_id AND orders.order_date = ex.order_date"]} +; diff --git a/backend/src/chicken_master/api.clj b/backend/src/chicken_master/api.clj index 6e618ac..6bf60c5 100644 --- a/backend/src/chicken_master/api.clj +++ b/backend/src/chicken_master/api.clj @@ -43,8 +43,8 @@ order (-> request :body (update :id #(or % id)))] (as-edn (orders/replace! user-id order)))) -(defn delete-order [user-id id] (->> id edn/read-string (orders/delete! user-id) as-edn)) -(defn set-order-state [user-id id status] (as-edn (orders/change-state! user-id (edn/read-string id) status))) +(defn delete-order [user-id id day] (->> id edn/read-string (orders/delete! user-id day) as-edn)) +(defn set-order-state [user-id id day status] (as-edn (orders/change-state! user-id (edn/read-string id) day status))) (defn get-stock [user-id] (get-values user-id [:customers :products])) @@ -64,5 +64,5 @@ (GET "/orders" [:as {user-id :basic-authentication}] (get-orders user-id)) (POST "/orders" request (update-order request)) (PUT "/orders/:id" request (update-order request)) - (DELETE "/orders/:id" [id :as {user-id :basic-authentication}] (delete-order user-id id)) - (POST "/orders/:id/:status" [id status :as {user-id :basic-authentication}] (set-order-state user-id id status))) + (DELETE "/orders/:id" [id :as {user-id :basic-authentication body :body}] (delete-order user-id id (:day body))) + (POST "/orders/:id/:status" [id status :as {user-id :basic-authentication body :body}] (set-order-state user-id id (:day body) status))) diff --git a/backend/src/chicken_master/orders.clj b/backend/src/chicken_master/orders.clj index 2f345bf..342ad56 100644 --- a/backend/src/chicken_master/orders.clj +++ b/backend/src/chicken_master/orders.clj @@ -7,53 +7,86 @@ [chicken-master.customers :as customers] [chicken-master.time :as t])) -(defn upsert-order! [tx user-id customer-id {:keys [id day state notes]}] +(defn upsert-order! [tx user-id customer-id {:keys [id day notes]}] (let [order {:customer_id customer-id :notes notes - :status (some-> (or state "waiting") name jdbc.types/as-other) - :order_date (some-> day t/parse-date t/inst->timestamp)}] + :order_date (some-> day t/to-db-date) + :end_date (some-> day t/to-db-date)}] (if (db/get-by-id tx user-id :orders id) (do (sql/update! tx :orders order {:id id}) id) (:orders/id (sql/insert! tx :orders (assoc order :user_id user-id)))))) - (defn structure-order [items] - {:id (-> items first :orders/id) - :notes (-> items first :orders/notes) - :state (-> items first :orders/status keyword) - :day (-> items first :orders/order_date (.toInstant) str (subs 0 10)) - :who {:id (-> items first :customers/id) - :name (-> items first :customers/name)} - :products (->> items - (filter :products/name) - (reduce (fn [coll {:keys [order_products/amount order_products/price products/name]}] - (assoc coll (keyword name) {:amount amount :price price})) {}))}) + {:id (-> items first :orders/id) + :notes (-> items first :orders/notes) + :recurrence (-> items first :orders/recurrence) + :who {:id (-> items first :customers/id) + :name (-> items first :customers/name)} + :products (->> items + (filter :products/name) + (reduce (fn [coll {:keys [order_products/amount order_products/price products/name]}] + (assoc coll (keyword name) {:amount amount :price price})) {}))}) + +(defn item-days + "Get all days between `from` and `to` (inclusively) for which the order applies." + [from to items] + (let [{:orders/keys [recurrence order_date]} (first items)] + (->> (t/recurrence->dates (t/latest from order_date) (or recurrence "FREQ=MONTHLY;COUNT=1")) + (take-while #(not (t/after % (t/to-inst to)))) + (map #(vector (t/format-date %) :waiting)) + (into {})))) + +(defn order-iterator [items days] + (->> items + (filter :recurrence_exceptions/status) + (reduce (fn [coll {:recurrence_exceptions/keys [status order_date]}] + (assoc coll (t/format-date order_date) (keyword status))) + days))) + +(defn items->orders [from to items] + (let [base-order (structure-order items)] + (->> items + (item-days from to) + (order-iterator items) + (map (fn [[date status]] (assoc base-order :day date :state status)))))) (def orders-query - "SELECT o.id, o.notes, o.status, o.order_date, c.id, c.name, p.name, op.amount, op.price + "SELECT o.id, o.notes, ex.status, o.order_date, o.recurrence, c.id, c.name, p.name, op.amount, op.price, ex.order_date FROM orders o JOIN customers c ON o.customer_id = c.id + LEFT OUTER JOIN recurrence_exceptions ex ON o.id = ex.order_id LEFT OUTER JOIN order_products op ON o.id = op.order_id LEFT OUTER JOIN products p on p.id = op.product_id ") -(defn- get-orders [tx where params] - (->> (into [(if where (str orders-query where) orders-query)] params) - (sql/query tx) - (group-by :orders/id) - vals - (map structure-order))) +(def date-filter-clause "WHERE o.order_date >= ? AND o.end_date <= ? ") +(def orders-date-query (str orders-query date-filter-clause)) -(defn get-order [tx user-id id] - (first (get-orders tx "WHERE o.id = ? AND o.user_id = ?" [id user-id]))) +(defn- get-orders + ([tx where params] (get-orders tx t/min-date t/max-date where params)) + ([tx from to where params] + (->> (into [(str orders-date-query (if where (str " AND " where) "")) + (t/to-db-date from) + (t/to-db-date to)] params) + (sql/query tx) + (group-by :orders/id) + vals + (map (partial items->orders from to)) + (apply concat) + (filter #(t/between from (:day %) to))))) -(defn get-all [user-id] (group-by :day (get-orders db/db-uri "WHERE o.user_id = ?" [user-id]))) +(defn get-order [tx user-id id & [day]] + (first + (if day + (->> (get-orders tx day day "o.id = ? AND o.user_id = ?" [id user-id]) + (filter #(= (t/format-date day) (:day %)))) + (get-orders tx "o.id = ? AND o.user_id = ?" [id user-id])))) + +(defn get-all [user-id] (group-by :day (get-orders db/db-uri "o.user_id = ?" [user-id]))) (defn- orders-for-days [tx user-id & days] - (let [days (remove nil? days)] - (->> days - (map t/inst->timestamp) - (map jdbc.types/as-date) - (into [user-id]) - (get-orders tx (str "WHERE o.user_id = ? AND o.order_date::date IN " (db/psql-list days))) + (let [days (->> days (remove nil?) (map t/to-inst)) + from (apply t/earliest days) + to (apply t/latest days)] + (->> (get-orders tx from to "o.user_id = ?" [user-id]) (group-by :day) (merge (reduce #(assoc %1 (t/format-date %2) {}) {} days))))) @@ -61,32 +94,59 @@ (jdbc/with-transaction [tx db/db-uri] (let [customer-id (or (:id who) (customers/get-or-create-by-name tx user-id (:name who))) - previous-day (some->> order :id (db/get-by-id tx user-id :orders) :orders/order_date (.toInstant))] + previous-day (some->> order :id (db/get-by-id tx user-id :orders) :orders/order_date t/to-inst)] (products/update-products-mapping! tx user-id :order (upsert-order! tx user-id customer-id order) products) (orders-for-days tx user-id previous-day (some-> order :day t/parse-date))))) -(defn delete! [user-id id] - (jdbc/with-transaction [tx db/db-uri] - (let [day (some->> id (db/get-by-id tx user-id :orders) :orders/order_date (.toInstant))] - (sql/delete! tx :orders {:id id :user_id user-id}) - (when day (orders-for-days tx user-id day))))) - (defn change-state! "Update the state of the given order and also modify the number of products available: * when `fulfilled` decrement the number of products * when `waiting` increment the number (as this means a previously fulfilled order has been returned)" - [user-id id state] - (jdbc/with-transaction [tx db/db-uri] - (let [order (get-order tx user-id id) - operator (condp = state - "fulfilled" "-" - "waiting" "+")] - (when (not= (:state order) (keyword state)) - (doseq [[prod {:keys [amount]}] (:products order)] - (jdbc/execute-one! tx - [(str "UPDATE products SET amount = amount " operator " ? WHERE name = ?") + ([user-id id day state] (jdbc/with-transaction [tx db/db-uri] (change-state! tx user-id id day state))) + ([tx user-id id day state] + (let [order (get-order tx user-id id day) + operator (condp = state + "fulfilled" "-" + "waiting" "+" + "canceled" "+")] + (when (not= (:state order) (keyword state)) + ;; update product counts + (doseq [[prod {:keys [amount]}] (:products order)] + (jdbc/execute-one! tx + [(str "UPDATE products SET amount = amount " operator " ? WHERE name = ?") amount (name prod)])) - (sql/update! tx :orders {:status (jdbc.types/as-other state)} {:id id})) - (orders-for-days tx user-id (-> order :day t/parse-date))))) + + ;; upsert the state for the given day + (if (jdbc/execute-one! tx + ["SELECT * from recurrence_exceptions WHERE order_id = ? AND order_date = ?" id (t/to-db-date day)]) + (sql/update! tx :recurrence_exceptions {:status (jdbc.types/as-other state)} + {:order_id id :order_date (t/to-db-date day)}) + (sql/insert! tx :recurrence_exceptions {:order_id id + :order_date (t/to-db-date day) + :status (jdbc.types/as-other state)}))) + (orders-for-days tx user-id day)))) + +(defn delete! [user-id day id] + (jdbc/with-transaction [tx db/db-uri] + (if day + ;; Only delete the one day + (change-state! tx user-id id day "canceled") + ;; Delete the order along with all recurrences + (when-let [{:orders/keys [order_date end_date]} (some->> id (db/get-by-id tx user-id :orders))] + (sql/delete! tx :orders {:id id :user_id user-id}) + (orders-for-days tx user-id order_date end_date))))) + +;; (delete! 2 "2022-04-20" 240) +;; (delete! 2 nil 241) + +;; (change-state! 2 240 "2022-04-20" "waiting") +;; (change-state! 2 250 "2022-04-23" "fulfilled") +;; (get-orders db/db-uri (t/to-inst #inst "2022-04-20T00:00:00Z") (t/to-inst #inst "2022-04-20T00:00:00Z") nil nil) +;; (get-orders db/db-uri (t/to-inst #inst "2022-04-23T00:00:00Z") (t/to-inst #inst "2022-04-24T00:00:00Z") nil nil) +;; (get-order db/db-uri 2 242 (t/to-inst #inst "2022-04-20T00:00:00Z")) +;; (orders-for-days db/db-uri 2 #inst "2022-04-23T00:00:00Z" #inst "2022-04-23T00:00:00Z") +;; (orders-for-days db/db-uri 2 #inst "2022-04-23T00:00:00Z") +;; (orders-for-days db/db-uri 2 "2022-04-19") +;; (get-all 2) diff --git a/backend/src/chicken_master/time.clj b/backend/src/chicken_master/time.clj index adc5030..64c59c4 100644 --- a/backend/src/chicken_master/time.clj +++ b/backend/src/chicken_master/time.clj @@ -1,17 +1,59 @@ (ns chicken-master.time (:import [java.time Instant LocalDate ZoneOffset] [java.time.format DateTimeFormatter] - [java.sql Timestamp])) - + [java.sql Timestamp] + [org.dmfs.rfc5545.recur RecurrenceRule] + [org.dmfs.rfc5545 DateTime])) +(defn recurrence->dates [start rule] + (let [iterator (.iterator (RecurrenceRule. rule) + (-> start (.toEpochMilli) (DateTime.)))] + (take-while identity + (repeatedly #(when (.hasNext iterator) + (-> iterator (.nextDateTime) (.getTimestamp) (Instant/ofEpochMilli))))))) (defn parse-date [date] - (-> date (LocalDate/parse) (.atStartOfDay) (.toInstant ZoneOffset/UTC))) + (if (= (count date) 10) + (-> date (LocalDate/parse) (.atStartOfDay) (.toInstant ZoneOffset/UTC)) + (Instant/parse date))) -(defn format-date [date] - (-> DateTimeFormatter/ISO_LOCAL_DATE - (.withZone ZoneOffset/UTC) - (.format date))) +(defprotocol TimeHelpers + (to-inst [d]) + (to-db-date [d]) + (format-date [date]) + (before [d1 d2]) + (after [d1 d2])) -(defn inst->timestamp [inst] (Timestamp/from inst)) +(extend-type Instant + TimeHelpers + (to-inst [d] d) + (to-db-date [d] (Timestamp/from d)) + (format-date [date] + (-> DateTimeFormatter/ISO_LOCAL_DATE + (.withZone ZoneOffset/UTC) + (.format date))) + (before [d1 d2] (.isBefore d1 d2)) + (after [d1 d2] (.isBefore d2 d1))) + +(extend-type java.util.Date + TimeHelpers + (to-inst [d] (.toInstant d)) + (to-db-date [d] (-> d to-inst to-db-date)) + (format-date [date] (format-date (to-inst date))) + (before [d1 d2] (< (.compareTo d1 d2) 0)) + (after [d1 d2] (> (.compareTo d1 d2) 0))) + +(extend-type java.lang.String + TimeHelpers + (to-inst [d] (parse-date d)) + (to-db-date [d] (-> d to-inst to-db-date)) + (format-date [date] (format-date (to-inst date))) + (before [d1 d2] (before (to-inst d1) (to-inst d2))) + (after [d1 d2] (after (to-inst d1) (to-inst d2)))) + +(defn earliest [& ds] (->> ds (map to-inst) (sort before) first)) +(defn latest [& ds] (->> ds (map to-inst) (sort after) first)) +(defn between [d1 d2 d3] (and (not (before d2 d1)) (not (after d2 d3)))) (defn now [] (Instant/now)) +(def min-date (parse-date "2020-01-01")) +(def max-date (.plusSeconds (now) (* 10 356 24 60 60))) ; 10 years from now - can't be bothered to do this properly... diff --git a/backend/test/chicken_master/orders_test.clj b/backend/test/chicken_master/orders_test.clj index d09cb95..e1ee467 100644 --- a/backend/test/chicken_master/orders_test.clj +++ b/backend/test/chicken_master/orders_test.clj @@ -2,8 +2,10 @@ (:require [next.jdbc :as jdbc] [next.jdbc.sql :as sql] + [next.jdbc.types :as jdbc.types] [chicken-master.orders :as sut] [chicken-master.products :as products] + [chicken-master.time :as t] [clojure.string :as str] [clojure.test :refer [deftest is testing]])) @@ -13,53 +15,55 @@ products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}}] (if products (for [[product {:keys [amount price]}] products] - (merge #:orders{:id id :notes notes :status status :order_date date} + (merge #:orders{:id id :notes notes :order_date date :end_date date} + #:recurrence_exceptions{:order_id id :order_date date :status status} #:customers{:id user_id :name user_name} {:products/name (name product) :order_products/price price :order_products/amount amount})) - [(merge #:orders{:id id :notes notes :status status :order_date date} + [(merge #:orders{:id id :notes notes :order_date date :end_date date} + #:recurrence_exceptions{:order_id id :order_date date :status status} #:customers{:id user_id :name user_name} {:products/name nil :order_products/price nil :order_products/amount nil})])) (deftest structure-order-test (testing "basic structure" (is (= (sut/structure-order (raw-order-row)) - {:id 1, :notes "note", :state :pending, :day "2020-01-01", + {:id 1, :notes "note", :recurrence nil, :who {:id 2, :name "mr blobby"}, :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}))) (testing "missing products" (is (= (sut/structure-order (raw-order-row :products nil)) - {:id 1, :notes "note", :state :pending, :day "2020-01-01", + {:id 1, :notes "note", :recurrence nil :who {:id 2, :name "mr blobby"}, :products {}})))) (deftest test-get-order (testing "correct values returned" (with-redefs [sql/query (fn [_ [query & params]] - (is (str/ends-with? query "WHERE o.id = ? AND o.user_id = ?")) - (is (= params [123 "1"])) + (is (str/ends-with? query "WHERE o.order_date >= ? AND o.end_date <= ? AND o.id = ? AND o.user_id = ?")) + (is (= params [(t/to-db-date t/min-date) (t/to-db-date t/max-date) 123 "1"])) (raw-order-row))] (is (= (sut/get-order :tx "1" 123) - {:id 1, :notes "note", :state :pending, :day "2020-01-01", + {:id 1, :notes "note", :recurrence nil :state :pending, :day "2020-01-01", :who {:id 2, :name "mr blobby"}, :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}})))) (testing "Only 1 item returned" (with-redefs [sql/query (fn [_ [query & params]] - (is (str/ends-with? query "WHERE o.id = ? AND o.user_id = ?")) - (is (= params [123 "1"])) + (is (str/ends-with? query "WHERE o.order_date >= ? AND o.end_date <= ? AND o.id = ? AND o.user_id = ?")) + (is (= params [(t/to-db-date t/min-date) (t/to-db-date t/max-date) 123 "1"])) (concat (raw-order-row) (raw-order-row :id 21)))] (is (= (sut/get-order :tx "1" 123) {:id 1, :notes "note", :state :pending, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}))))) (deftest test-get-all (testing "correct values returned" (with-redefs [sql/query (fn [_ [query & params]] - (is (str/ends-with? query "WHERE o.user_id = ?")) - (is (= params ["1"])) + (is (str/ends-with? query "WHERE o.order_date >= ? AND o.end_date <= ? AND o.user_id = ?")) + (is (= params [(t/to-db-date t/min-date) (t/to-db-date t/max-date) "1"])) (concat (raw-order-row :id 1 :status "waiting") (raw-order-row :id 2 :date #inst "2020-01-03") @@ -67,16 +71,16 @@ (raw-order-row :id 4)))] (is (= (sut/get-all "1") {"2020-01-01" [{:id 1, :notes "note", :state :waiting, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}} {:id 3, :notes "note", :state :pending, :day "2020-01-01", - :who {:id 43, :name "John"}, + :who {:id 43, :name "John"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}} {:id 4, :notes "note", :state :pending, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}] "2020-01-03" [{:id 2, :notes "note", :state :pending, :day "2020-01-03", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]}))))) (deftest test-replace! @@ -99,10 +103,10 @@ (raw-order-row :id 4)))] (is (= (sut/replace! :user-id order) {"2020-01-01" [{:id 1, :notes "note", :state :waiting, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}} {:id 4, :notes "note", :state :pending, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]}))))) (testing "replace order from different day" @@ -124,10 +128,10 @@ (raw-order-row :id 4)))] (is (= (sut/replace! :user-id order) {"2020-01-01" [{:id 4, :notes "note", :state :pending, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}] "2020-01-02" [{:id 1, :notes "note", :state :waiting, :day "2020-01-02", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]}))))) (testing "unknown products are ignored" @@ -149,10 +153,10 @@ (raw-order-row :id 4)))] (is (= (sut/replace! :user-id order) {"2020-01-01" [{:id 1, :notes "note", :state :waiting, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}} {:id 4, :notes "note", :state :pending, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})))))) (deftest test-delete! @@ -163,9 +167,9 @@ (is (= table :orders)) (is (= by {:id 1 :user_id :user-id}))) sql/query (constantly (raw-order-row :id 4))] - (is (= (sut/delete! :user-id 1) + (is (= (sut/delete! :user-id nil 1) {"2020-01-01" [{:id 4, :notes "note", :state :pending, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})))) (testing "nothing returned if no date set for the given order" @@ -175,32 +179,102 @@ (is (= table :orders)) (is (= by {:id 1 :user_id :user-id}))) sql/query (constantly (raw-order-row :id 4))] - (is (nil? (sut/delete! :user-id 1)))))) + (is (nil? (sut/delete! :user-id nil 1))))) + + (let [invocations (atom [])] + (with-redefs [jdbc.types/as-other identity + jdbc/transact (fn [_ f & args] (apply f args)) + jdbc/execute-one! (constantly {:orders/order_date #inst "2020-01-01"}) + sql/delete! (fn [_ table by] + (swap! invocations conj ["deleting" table by])) + sql/query (constantly (raw-order-row :id 4)) + sql/update! (fn [_ table status key] (swap! invocations conj ["updating" table status key])) + sql/insert! (fn [_ table values] (swap! invocations conj ["inserting" table values]))] + (testing "deleting without provided a date will remove the whole order" + (reset! invocations []) + (is (= (sut/delete! :user-id nil 1) + {"2020-01-01" [{:id 4, :notes "note", :state :pending, :day "2020-01-01", + :who {:id 2, :name "mr blobby"}, :recurrence nil + :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})) + (is (= [["deleting" :orders {:id 1 :user_id :user-id}]] + @invocations))) + + (testing "deleting with a provided date will soft remove a single order by updating it if it exists" + (reset! invocations []) + (is (= (sut/delete! :user-id "2020-01-01" 1) + {"2020-01-01" [{:id 4, :notes "note", :state :pending, :day "2020-01-01", + :who {:id 2, :name "mr blobby"}, :recurrence nil + :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})) + (is (= [["updating" :recurrence_exceptions {:status "canceled"} + {:order_id 1, :order_date (t/to-db-date "2020-01-01")}]] + @invocations))) + + (testing "deleting with a provided date will soft remove a single order by adding an exception if none provided" + (with-redefs [jdbc/execute-one! (fn [_ [q]] + (when-not (str/includes? q "recurrence_exceptions") + {:orders/order_date #inst "2020-01-01"}))] + + (reset! invocations []) + (is (= (sut/delete! :user-id "2020-01-01" 1) + {"2020-01-01" [{:id 4, :notes "note", :state :pending, :day "2020-01-01", + :who {:id 2, :name "mr blobby"}, :recurrence nil + :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})) + (is (= [["inserting" :recurrence_exceptions {:order_id 1, :order_date (t/to-db-date "2020-01-01") :status "canceled"}]] + @invocations))))))) (deftest test-change-state! - (testing "states get changed" - (let [updates (atom [])] - (with-redefs [jdbc/transact (fn [_ f & args] (apply f args)) - jdbc/execute-one! #(swap! updates conj %2) - sql/update! (fn [_ table _ val] - (is (= table :orders)) - (is (= val {:id 1}))) - sql/query (constantly (raw-order-row :id 1 :status "waiting"))] - (is (= (sut/change-state! :user-id 1 "fulfilled") + (let [updates (atom [])] + (with-redefs [jdbc.types/as-other identity + jdbc/transact (fn [_ f & args] (apply f args)) + jdbc/execute-one! #(swap! updates conj %2) + sql/update! (fn [_ table _ val] + (swap! updates conj ["updating" table val])) + sql/query (constantly (raw-order-row :id 1 :status "waiting")) + sql/insert! (fn [_ table values] (swap! updates conj ["inserting" table values]))] + (testing "states get changed - update when prexisiting exception" + (reset! updates []) + (is (= (sut/change-state! :user-id 1 "2020-01-01" "fulfilled") {"2020-01-01" [{:id 1, :notes "note", :state :waiting, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})) - (is (= @updates [["UPDATE products SET amount = amount - ? WHERE name = ?" 12 "eggs"] - ["UPDATE products SET amount = amount - ? WHERE name = ?" 3 "milk"]]))))) + (is (= [;; product updates + ["UPDATE products SET amount = amount - ? WHERE name = ?" 12 "eggs"] + ["UPDATE products SET amount = amount - ? WHERE name = ?" 3 "milk"] + + ;; check whether to insert or update + ["SELECT * from recurrence_exceptions WHERE order_id = ? AND order_date = ?" 1 (t/to-db-date "2020-01-01")] + ;; update + ["updating" :recurrence_exceptions {:order_id 1, :order_date (t/to-db-date "2020-01-01")}]] + @updates))) + + (testing "states get changed - insert when no such exception" + (with-redefs [jdbc/execute-one! (fn [_ q] + (swap! updates conj q) + (when-not (str/includes? (first q) "recurrence_exceptions") + (raw-order-row :id 1 :status "waiting")))] + (reset! updates []) + (is (= (sut/change-state! :user-id 1 "2020-01-01" "fulfilled") + {"2020-01-01" [{:id 1, :notes "note", :state :waiting, :day "2020-01-01", + :who {:id 2, :name "mr blobby"}, :recurrence nil + :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})) + (is (= [;; product updates + ["UPDATE products SET amount = amount - ? WHERE name = ?" 12 "eggs"] + ["UPDATE products SET amount = amount - ? WHERE name = ?" 3 "milk"] + + ;; check whether to insert or update + ["SELECT * from recurrence_exceptions WHERE order_id = ? AND order_date = ?" 1 (t/to-db-date "2020-01-01")] + ;; update + ["inserting" :recurrence_exceptions {:order_id 1, :order_date (t/to-db-date "2020-01-01"), :status "fulfilled"}]] + @updates)))))) (testing "nothing happens if the state is already set" (let [updates (atom [])] (with-redefs [jdbc/transact (fn [_ f & args] (apply f args)) jdbc/execute-one! #(swap! updates conj %2) sql/query (constantly (raw-order-row :id 1 :status "waiting"))] - (is (= (sut/change-state! :user-id 1 "waiting") + (is (= (sut/change-state! :user-id 1 "2020-01-01" "waiting") {"2020-01-01" [{:id 1, :notes "note", :state :waiting, :day "2020-01-01", - :who {:id 2, :name "mr blobby"}, + :who {:id 2, :name "mr blobby"}, :recurrence nil :products {:eggs {:amount 12 :price nil} :milk {:amount 3 :price 423}}}]})) (is (= @updates []))))) diff --git a/frontend/src/chicken_master/calendar.cljs b/frontend/src/chicken_master/calendar.cljs index fb76810..74e93df 100644 --- a/frontend/src/chicken_master/calendar.cljs +++ b/frontend/src/chicken_master/calendar.cljs @@ -89,8 +89,8 @@ :on-drag-start #(-> % .-dataTransfer (.setData "text" id))} [:div {:class :actions} (condp = state - :waiting [:button {:on-click #(re-frame/dispatch [::event/fulfill-order id])} "✓"] - :fulfilled [:button {:on-click #(re-frame/dispatch [::event/reset-order id])} "X"] + :waiting [:button {:on-click #(re-frame/dispatch [::event/fulfill-order id day])} "✓"] + :fulfilled [:button {:on-click #(re-frame/dispatch [::event/reset-order id day])} "X"] :pending nil nil nil) [:button {:on-click #(re-frame/dispatch [::event/edit-order day id])} "E"] @@ -120,6 +120,7 @@ (->> (if (settings :hide-fulfilled-orders) (remove (comp #{:fulfilled} :state) orders) orders) + (remove (comp #{:canceled} :state)) (map (partial format-order settings)) doall) (when (settings :show-day-add-order) diff --git a/frontend/src/chicken_master/events.cljs b/frontend/src/chicken_master/events.cljs index 8f6d2e0..8d0eb78 100644 --- a/frontend/src/chicken_master/events.cljs +++ b/frontend/src/chicken_master/events.cljs @@ -46,7 +46,6 @@ (re-frame/reg-event-fx ::load-db (fn [_ _] - (prn "loading") (time/update-settings config/default-settings) {:fx [[:dispatch [::show-from-date (time/iso-date (time/today))]] [:dispatch [::start-loading]] @@ -98,15 +97,17 @@ (re-frame/reg-event-fx ::fulfill-order - (fn [{db :db} [_ id]] + (fn [{db :db} [_ id day]] {:db (assoc-in db [:orders id :state] :pending) - :http-xhrio (http-request :post (str "orders/" id "/fulfilled"))})) + :http-xhrio (http-request :post (str "orders/" id "/fulfilled") + :body {:day day})})) (re-frame/reg-event-fx ::reset-order - (fn [{db :db} [_ id]] + (fn [{db :db} [_ id day]] {:db (assoc-in db [:orders id :state] :waiting) - :http-xhrio (http-request :post (str "orders/" id "/waiting"))})) + :http-xhrio (http-request :post (str "orders/" id "/waiting") + :body {:day day})})) (re-frame/reg-event-fx ::save-order diff --git a/frontend/src/chicken_master/stock.cljs b/frontend/src/chicken_master/stock.cljs index c2199ba..3440b62 100644 --- a/frontend/src/chicken_master/stock.cljs +++ b/frontend/src/chicken_master/stock.cljs @@ -47,4 +47,4 @@ [:h2 "Magazyn"] [stock-form @(re-frame/subscribe [::subs/available-products])]] ;; On success - :on-submit (fn [form] (prn form) (prn (process-form form)) (re-frame/dispatch [::event/save-stock (process-form form)])))) + :on-submit (fn [form] (re-frame/dispatch [::event/save-stock (process-form form)]))))