diff --git a/README.md b/README.md index 2119ed7..0da47ed 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ * daily view * infinite scroll +* Move to different day + * handle regular customers ** every n days ** copy over to next week @@ -13,6 +15,10 @@ * products CRM +## Start + + docker run --name some-postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres + A [re-frame](https://github.com/day8/re-frame) application designed to ... well, that part is up to you. diff --git a/project.clj b/project.clj index ee6134c..1879694 100644 --- a/project.clj +++ b/project.clj @@ -12,6 +12,8 @@ [ns-tracker "0.4.0"] [compojure "1.6.2"] [yogthos/config "1.1.7"] + [seancorfield/next.jdbc "1.1.613"] + [org.postgresql/postgresql "42.2.6"] [ring-basic-authentication "1.1.0"] [ring-cors "0.1.13"] [ring "1.8.1"]] diff --git a/resources/public/schema.sql b/resources/public/schema.sql new file mode 100644 index 0000000..58ff7cf --- /dev/null +++ b/resources/public/schema.sql @@ -0,0 +1,53 @@ +CREATE EXTENSION pgcrypto; +CREATE TABLE users ( + id SERIAL, + name VARCHAR(256) UNIQUE, + password VARCHAR(256), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY(id) +); + +CREATE TABLE customers ( + id SERIAL, + name VARCHAR(512) UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted BOOLEAN, + user_id INT, + PRIMARY KEY(id), + CONSTRAINT fk_users FOREIGN KEY(user_id) REFERENCES users(id) +); + +CREATE TABLE products ( + id SERIAL, + name VARCHAR(512) UNIQUE, + amount NUMERIC, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted BOOLEAN, + user_id INT, + PRIMARY KEY(id), + CONSTRAINT fk_users FOREIGN KEY(user_id) REFERENCES users(id) +); + +CREATE TYPE order_state AS ENUM('waiting', 'fulfilled', 'canceled'); +CREATE TABLE orders ( + id SERIAL, + customer_id INT, + notes TEXT, + status order_state DEFAULT 'waiting', + order_date TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + user_id INT, + PRIMARY KEY(id), + CONSTRAINT fk_customer FOREIGN KEY(customer_id) REFERENCES customers(id), + CONSTRAINT fk_users FOREIGN KEY(user_id) REFERENCES users(id) +); + +CREATE TABLE order_products ( + id SERIAL, + order_id INT, + product_id INT, + amount NUMERIC, + PRIMARY KEY(id), + CONSTRAINT fk_order FOREIGN KEY(order_id) REFERENCES orders(id), + CONSTRAINT fk_product FOREIGN KEY(product_id) REFERENCES products(id) +); diff --git a/src/clj/chicken_master/customers.clj b/src/clj/chicken_master/customers.clj new file mode 100644 index 0000000..0307296 --- /dev/null +++ b/src/clj/chicken_master/customers.clj @@ -0,0 +1,20 @@ +(ns chicken-master.customers + (:require [next.jdbc :as jdbc] + [next.jdbc.sql :as sql] + [chicken-master.db :as db] + [chicken-master.orders :as orders])) + +(defn get-all [] + (->> (sql/query db/db-uri ["select * from customers where deleted is null"]) + (map (fn [{:customers/keys [id name]}] {:id id :name name})))) + +(defn create! [name] + (jdbc/execute! db/db-uri + ["INSERT INTO customers (name) VALUES(?) ON CONFLICT (name) DO UPDATE SET deleted = NULL" + name]) + {:customers (get-all)}) + +(defn delete! [id] + (sql/update! db/db-uri :customers {:deleted true} {:id id}) + {:orders (orders/get-all) + :customers (get-all)}) diff --git a/src/clj/chicken_master/db.clj b/src/clj/chicken_master/db.clj new file mode 100644 index 0000000..71fd175 --- /dev/null +++ b/src/clj/chicken_master/db.clj @@ -0,0 +1,26 @@ +(ns chicken-master.db + (:require [clojure.string :as str] + [next.jdbc :as jdbc] + [next.jdbc.types :as jdbc.types] + [next.jdbc.sql :as sql] + [chicken-master.time :as t])) + +(def db-uri {:jdbcUrl (or (System/getenv "DB_URI") "jdbc:postgresql://localhost/postgres?user=postgres&password=mysecretpassword")}) + +(defn psql-list + ([items] (psql-list items "")) + ([items prefix] (str "(" (str/join ", " (repeat (count items) (str prefix "?"))) ")"))) + +(defn create-user [name passwd] + (jdbc/execute-one! + db-uri + ["INSERT INTO users (name, password) VALUES (?, crypt(?, gen_salt('bf')))" name passwd])) + +(defn valid-user? [name passwd] + (jdbc/execute-one! + db-uri + [" SELECT * FROM users WHERE name = ? AND password = crypt(?, password)" name passwd])) + +(comment + (create-user "siloa" "krach") + (valid-user? "siloa" "krach")) diff --git a/src/clj/chicken_master/handler.clj b/src/clj/chicken_master/handler.clj index 6585b4b..3f4e916 100644 --- a/src/clj/chicken_master/handler.clj +++ b/src/clj/chicken_master/handler.clj @@ -1,5 +1,9 @@ (ns chicken-master.handler (:require [chicken-master.mocks :as mocks] + [chicken-master.db :as db] + [chicken-master.orders :as orders] + [chicken-master.customers :as customers] + [chicken-master.products :as products] [clojure.edn :as edn] [compojure.core :refer [GET POST PUT DELETE defroutes]] [compojure.route :refer [resources]] @@ -8,21 +12,21 @@ [ring.middleware.basic-authentication :refer [wrap-basic-authentication]] [ring.middleware.cors :refer [wrap-cors]])) -(defn get-customers [] {:body (mocks/fetch-customers {})}) -(defn add-customer [request] {:body (some-> request :body :name mocks/add-customer)}) -(defn delete-customer [id] {:body (mocks/delete-customer (edn/read-string id))}) +(defn get-customers [] {:body (customers/get-all)}) +(defn add-customer [request] {:body (some-> request :body :name customers/create!)}) +(defn delete-customer [id] {:body (customers/delete! (edn/read-string id))}) -(defn get-products [_] {:body (mocks/get-all-products)}) -(defn save-products [request] {:body (some-> request :body mocks/save-stocks)}) +(defn get-products [_] {:body (products/get-all)}) +(defn save-products [request] {:body (some-> request :body products/update!)}) -(defn get-orders [params] {:body {:orders (mocks/get-orders params)}}) +(defn get-orders [params] {:body {:orders (orders/get-all)}}) (defn update-order [request] (let [id (some-> request :route-params :id (Integer/parseInt)) - body (some->> request :body)] - {:body (mocks/replace-order id body)})) + order (-> request :body (update :id #(or % id)))] + {:body (orders/replace! order)})) -(defn delete-order [id] {:body (mocks/delete-order (edn/read-string id))}) -(defn set-order-state [id status] {:body (mocks/order-state (edn/read-string id) status)}) +(defn delete-order [id] {:body (orders/delete! (edn/read-string id))}) +(defn set-order-state [id status] {:body (orders/change-state! (edn/read-string id) status)}) (defn get-stock [params] {:body @@ -49,7 +53,7 @@ (defn- handle-edn [response] - (if (string? (:body response)) + (if (-> response :body type #{java.io.File java.lang.String}) response (-> response (assoc-in [:headers "Content-Type"] "application/edn") @@ -71,8 +75,7 @@ request))))) (defn authenticated? [name pass] - (and (= name "siloa") - (= pass "krach"))) + (db/valid-user? name pass)) (def handler (-> routes (wrap-basic-authentication authenticated?) diff --git a/src/clj/chicken_master/mocks.clj b/src/clj/chicken_master/mocks.clj index 9cd9b19..05ce88b 100644 --- a/src/clj/chicken_master/mocks.clj +++ b/src/clj/chicken_master/mocks.clj @@ -71,6 +71,8 @@ (defn get-orders [params] @orders) (defn replace-order [id order] + (prn id) + (prn order) (println "replacing order" order) (let [prev-day (:day (@orders (:id order))) order (update order :id #(or % (swap! id-counter inc)))] diff --git a/src/clj/chicken_master/orders.clj b/src/clj/chicken_master/orders.clj new file mode 100644 index 0000000..6a78db7 --- /dev/null +++ b/src/clj/chicken_master/orders.clj @@ -0,0 +1,99 @@ +(ns chicken-master.orders + (:require [next.jdbc :as jdbc] + [next.jdbc.types :as jdbc.types] + [next.jdbc.sql :as sql] + [chicken-master.db :as db] + [chicken-master.products :as products] + [chicken-master.time :as t])) + +(defn- upsert-order! [tx user-id {:keys [id day state notes]}] + (let [order {:customer_id user-id + :notes notes + :status (some-> state name jdbc.types/as-other) + :order_date (some-> day t/parse-date t/inst->timestamp)}] + (if id + (do (sql/update! tx :orders order {:id id}) id) + (:orders/id (sql/insert! tx :orders order))))) + +(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 (into {} + (for [{:keys [order_products/amount products/name]} items] + [(keyword name) amount]))}) + +(def orders-query + "SELECT o.id, o.notes, o.status, o.order_date, c.id, c.name, p.name, op.amount + FROM orders o JOIN customers c ON o.customer_id = c.id + JOIN order_products op ON o.id = op.order_id + 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))) + +(defn get-order [tx id] + (first (get-orders tx "WHERE o.id = ?" [id]))) + +(defn get-all [] (get-orders db/db-uri nil [])) + +(defn- orders-for-days [tx & days] + (let [days (remove nil? days)] + (->> days + (map t/inst->timestamp) + (map jdbc.types/as-date) + (get-orders tx (str "WHERE o.order_date::date IN " (db/psql-list days)))))) + +(defn- orders-between [tx from to] + (get-orders + tx + "WHERE o.order_date::date >= ? AND o.order_date::date <= ?" + [(some-> from t/inst->timestamp jdbc.types/as-date) + (some-> to t/inst->timestamp jdbc.types/as-date)])) + +(defn replace! [{:keys [who products] :as order}] + (jdbc/with-transaction [tx db/db-uri] + (let [user-id (or (:id who) + (:customers/id (sql/get-by-id tx :customers (:name who) :name {}))) + products-map (products/products-map tx products) + previous-day (some->> order :id (sql/get-by-id tx :orders) :orders/order_date (.toInstant)) + order-id (upsert-order! tx user-id order)] + (sql/delete! tx :order_products {:order_id order-id}) + (sql/insert-multi! tx :order_products + [:order_id :product_id :amount] + (for [[n amount] products + :let [product-id (-> n name products-map)] + :when product-id] + [order-id product-id amount])) + (orders-for-days tx previous-day (some-> order :day t/parse-date))))) + +(defn delete! [id] + (jdbc/with-transaction [tx db/db-uri] + (let [day (some->> id (sql/get-by-id tx :orders) :orders/order_date (.toInstant))] + (sql/delete! tx :orders {:id id}) + (when day (orders-for-days tx 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)" + [id state] + (jdbc/with-transaction [tx db/db-uri] + (let [order (get-order tx id) + operator (condp = state + "fulfilled" "-" + "waiting" "+")] + (when (not= (:state order) state) + (doseq [[prod 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 (-> order :day t/parse-date))))) diff --git a/src/clj/chicken_master/products.clj b/src/clj/chicken_master/products.clj new file mode 100644 index 0000000..a39146d --- /dev/null +++ b/src/clj/chicken_master/products.clj @@ -0,0 +1,33 @@ +(ns chicken-master.products + (:require [next.jdbc :as jdbc] + [next.jdbc.sql :as sql] + [chicken-master.db :as db])) + +(defn get-all [] + (prn "asd" (->> (sql/query db/db-uri ["select * from products where deleted is null"]) + (map (fn [{:products/keys [name amount]}] [(keyword name) amount])) + (into {}))) + (->> (sql/query db/db-uri ["select * from products where deleted is null"]) + (map (fn [{:products/keys [name amount]}] [(keyword name) amount])) + (into {}))) + +(defn products-map [tx products] + (->> (map name (keys products)) + (into [(str "SELECT id, name from products where name IN " (db/psql-list (keys products)))]) + (sql/query tx) + (map #(vector (:products/name %) (:products/id %))) + (into {}))) + +(defn update! [new-products] + (prn new-products) + (jdbc/with-transaction [tx db/db-uri] + (doseq [[prod amount] new-products] + (jdbc/execute! tx + ["INSERT INTO products (name, amount) VALUES(?, ?) + ON CONFLICT (name) DO UPDATE SET amount = EXCLUDED.amount, deleted = NULL" + (name prod) amount])) + (sql/update! tx :products + {:deleted true} + (into [(str "name NOT IN " (db/psql-list (keys new-products)))] + (->> new-products keys (map name))))) + (get-all)) diff --git a/src/clj/chicken_master/server.clj b/src/clj/chicken_master/server.clj index b44b0a0..2b5a718 100644 --- a/src/clj/chicken_master/server.clj +++ b/src/clj/chicken_master/server.clj @@ -8,7 +8,8 @@ (let [port (or (env :port) 3000)] (run-jetty handler {:port port :join? false}))) -(def h - (let [port (or (env :port) 3000)] - (run-jetty handler {:port port :join? false}))) -(.stop h) +(comment + (def h + (let [port (or (env :port) 3000)] + (run-jetty handler {:port port :join? false}))) + (.stop h)) diff --git a/src/clj/chicken_master/time.clj b/src/clj/chicken_master/time.clj new file mode 100644 index 0000000..684701c --- /dev/null +++ b/src/clj/chicken_master/time.clj @@ -0,0 +1,11 @@ +(ns chicken-master.time + (:import [java.time Instant LocalDate ZoneOffset] + [java.sql Timestamp])) + + +(defn parse-date [date] + (-> date (LocalDate/parse) (.atStartOfDay) (.toInstant ZoneOffset/UTC))) + +(defn inst->timestamp [inst] (Timestamp/from inst)) + +(defn now [] (Instant/now)) diff --git a/src/cljs/chicken_master/config.cljs b/src/cljs/chicken_master/config.cljs index e1d199b..424e944 100644 --- a/src/cljs/chicken_master/config.cljs +++ b/src/cljs/chicken_master/config.cljs @@ -32,7 +32,6 @@ :editable-number-inputs (get-setting :editable-number-inputs false) ; only allow number modifications in the edit modal :hide-fulfilled-orders (get-setting :hide-fulfilled-orders false) - :http-dispatch (get-setting :http-dispatch :http);-xhrio :backend-url (get-setting :backend-url "http://localhost:3000/") }) @@ -83,12 +82,6 @@ (input :hide-fulfilled-orders "ukryj wydane zamówienia" {:type :checkbox}) [:h3 "Ustawienia tyłu"] - [:label {:for :http-dispatch} "re-frame http dispatcher"] - [:select {:id :http-dispatch :name :http-dispatch :value (settings :http-dispatch) - :on-change #(change-setting :http-dispatch (-> % .-target .-value keyword))} - [:option {:value :http} "client side mock"] - [:option {:value :http-xhrio} "re-frame-http-fx"]] - (input :backend-url "backend URL" {}) [:button {:on-click #(re-frame/dispatch diff --git a/src/cljs/chicken_master/events.cljs b/src/cljs/chicken_master/events.cljs index 2fdb479..604ace6 100644 --- a/src/cljs/chicken_master/events.cljs +++ b/src/cljs/chicken_master/events.cljs @@ -53,7 +53,7 @@ (re-frame/reg-event-fx ::remove-order (fn [_ [_ id]] - {(settings :http-dispatch) (http-request :delete (str "orders/" id))})) + {:http-xhrio (http-request :delete (str "orders/" id))})) (re-frame/reg-event-db ::failed-request @@ -67,7 +67,7 @@ (re-frame/reg-event-fx ::move-order (fn [{{orders :orders start-date :start-date} :db} [_ id day]] - {(settings :http-dispatch) + {:http-xhrio (http-request :put (str "orders/" id) :body (-> id orders (assoc :day day :start-from start-date)))})) @@ -83,38 +83,44 @@ ::fulfill-order (fn [{db :db} [_ id]] {:db (assoc-in db [:orders id :state] :pending) - (settings :http-dispatch) (http-request :post (str "orders/" id "/fulfilled"))})) + :http-xhrio (http-request :post (str "orders/" id "/fulfilled"))})) (re-frame/reg-event-fx ::reset-order (fn [{db :db} [_ id]] {:db (assoc-in db [:orders id :state] :waiting) - (settings :http-dispatch) (http-request :post (str "orders/" id "/waiting"))})) + :http-xhrio (http-request :post (str "orders/" id "/waiting"))})) (re-frame/reg-event-fx ::save-order (fn [{{order :order-edit} :db} [_ form]] {:dispatch [::hide-modal :order-edit] - (settings :http-dispatch) (http-post (str "orders") + :http-xhrio (http-post (str "orders") (merge (select-keys order [:id :day :hour :state]) (select-keys form [:id :day :hour :state :who :notes :products])))})) (re-frame/reg-event-db ::process-fetched-days - (fn [db [_ days]] + (fn [db [_ orders]] + (prn orders) + (prn (:current-days db)) + (prn + (let [days (group-by :day orders)] + (for [[day orders] (:current-days db)] + [day (if (contains? days day) (days day) orders)]))) (-> db (assoc :loading? nil) - (update :current-days #(map (fn [[day orders]] - [day (if (contains? days day) - (days day) orders)]) %)) - (update :orders #(reduce (fn [m cust] (assoc m (:id cust) cust)) % (-> days vals flatten)))))) + (update :current-days (fn [current-days] + (let [days (group-by :day orders)] + (for [[day orders] current-days] + [day (if (contains? days day) (days day) orders)])))) + (update :orders #(reduce (fn [m cust] (assoc m (:id cust) cust)) % orders))))) (re-frame/reg-event-fx ::scroll-weeks (fn [{db :db} [_ offset]] - {:fx [;[:dispatch [::fetch-stock]] - [:dispatch [::start-loading]] + {:fx [[:dispatch [::start-loading]] [:dispatch [::show-from-date (-> db :start-date time/parse-date @@ -136,7 +142,7 @@ ::fetch-orders (fn [_ [_ from to]] {:dispatch [::start-loading] - (settings :http-dispatch) (http-get "orders" {} ::process-stock)})) + :http-xhrio (http-get "orders" {} ::process-stock)})) ;; Customers events (re-frame/reg-event-fx @@ -148,14 +154,14 @@ (re-frame/reg-event-fx ::add-customer (fn [_ [_ customer-name]] - {(settings :http-dispatch) (http-request :post "customers" + {:http-xhrio (http-request :post "customers" :body {:name customer-name} :on-success ::process-stock)})) (re-frame/reg-event-fx ::remove-customer (fn [_ [_ id]] {:dispatch [::start-loading] - (settings :http-dispatch) (http-request :delete (str "customers/" id) + :http-xhrio (http-request :delete (str "customers/" id) :on-success ::process-stock)})) ;;; Storage events @@ -170,7 +176,7 @@ ::fetch-stock (fn [_ _] {:dispatch [::start-loading] - (settings :http-dispatch) (http-get "stock" {} ::process-stock)})) + :http-xhrio (http-get "stock" {} ::process-stock)})) (defn assoc-if [coll key val] (if val (assoc coll key val) coll)) (re-frame/reg-event-fx @@ -179,7 +185,7 @@ {:db (-> db (assoc-if :products products) (assoc-if :customers customers) - (assoc-if :orders orders)) + (assoc-if :orders (some->> orders (into {} (map #(vector (:id %) %)))))) :dispatch [::scroll-weeks 0] })) @@ -188,7 +194,7 @@ (fn [_ [_ products]] {:fx [[:dispatch [::hide-modal :stock]] [:dispatch [::start-loading]]] - (settings :http-dispatch) (http-request :post "products" :body products :on-sucess ::process-stock)})) + :http-xhrio (http-request :post "products" :body products :on-sucess ::process-stock)})) ;; Settings @@ -217,38 +223,3 @@ {:fx [[:dispatch [::start-loading]] [:dispatch [::fetch-stock]] [:dispatch [::fetch-orders]]]})) - -(re-frame/reg-fx - :http - (fn [{:keys [method uri params body on-success on-fail]}] - (condp = uri - "http://localhost:3000/stock" (re-frame/dispatch (conj on-success (mocks/fetch-stock params))) - - "get-customers" (re-frame/dispatch (conj on-success (mocks/fetch-customers params))) - "add-customer" (re-frame/dispatch (conj on-success (mocks/add-customer params))) - "save-stock" (re-frame/dispatch (conj on-success (mocks/save-stocks body))) - (let [parts (clojure.string/split uri "/")] - (cond - (and (= method :get) (= uri "http://localhost:3000/orders")) - (re-frame/dispatch (conj on-success (mocks/fetch-orders params))) - - (and (= method :post) (= uri "http://localhost:3000/orders")) - (re-frame/dispatch (conj on-success (mocks/replace-order nil (cljs.reader/read-string body)))) - - (and (= method :post) (= uri "http://localhost:3000/products")) - (re-frame/dispatch (conj on-success (mocks/save-stocks (cljs.reader/read-string body)))) - - (and (= method :delete) (= (nth parts 3) "orders")) - (re-frame/dispatch (conj on-success (mocks/delete-order (-> parts (nth 4) (js/parseInt))))) - - (and (= method :delete) (= (nth parts 3) "customers")) - (re-frame/dispatch (conj on-success (mocks/delete-customer (-> parts (nth 4) (js/parseInt))))) - - (and (= method :post) (= uri "http://localhost:3000/customers")) - (re-frame/dispatch (conj on-success (mocks/add-customer (cljs.reader/read-string body)))) - - (-> parts last #{"fulfilled" "waiting"}) - (re-frame/dispatch (conj on-success (mocks/order-state {:id (-> parts (nth 4) (js/parseInt)) :state (keyword (last parts))}))) - true (prn "unhandled" method uri) - )) - )))