Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ Tasks are stored as maps with:
- `GET /tasks` -> list tasks
- Optional query params: `status`, `tag`, `limit`, `offset`
- `POST /tasks` -> create task
- `POST /tasks/bulk/create` -> bulk create tasks
- `PATCH /tasks/bulk/update` -> bulk update tasks
- `POST /tasks/bulk/delete` -> bulk delete tasks
- `GET /tasks/:id` -> fetch task
- `PATCH /tasks/:id` -> update task
- `DELETE /tasks/:id` -> delete task
Expand All @@ -75,6 +78,18 @@ curl -s http://localhost:3000/tasks?tag=docs
curl -s -X PATCH http://localhost:3000/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"status":"done"}'

curl -s -X POST http://localhost:3000/tasks/bulk/create \
-H 'Content-Type: application/json' \
-d '{"tasks":[{"name":"a"},{"name":"b","tags":["ops"]}]}'

curl -s -X PATCH http://localhost:3000/tasks/bulk/update \
-H 'Content-Type: application/json' \
-d '{"updates":[{"id":1,"changes":{"status":"done"}},{"id":2,"changes":{"name":"updated"}}]}'

curl -s -X POST http://localhost:3000/tasks/bulk/delete \
-H 'Content-Type: application/json' \
-d '{"ids":[1,2,3]}'
```

## Use as a library
Expand Down Expand Up @@ -141,10 +156,16 @@ store is used.
- `get-task` -> task or `nil`
- `update-task` -> `{:ok task}` or `{:error ...}`
- `delete-task` -> `{:ok task}` or `{:error ...}`
- `bulk-create-tasks` -> `{:ok {:created [...] :errors [...] :total n :succeeded n :failed n}}`
- `bulk-update-tasks` -> `{:ok {:updated [...] :errors [...] :total n :succeeded n :failed n}}`
- `bulk-delete-tasks` -> `{:ok {:deleted [...] :errors [...] :total n :succeeded n :failed n}}`

Tasks are maps with `:id`, `:name`, `:description`, `:status`, `:priority`,
`:due-at`, `:tags`, `:created-at`, and optional `:updated-at`.

Bulk operations are non-atomic. Valid items are applied while invalid or missing
items are returned under `:errors` with an `:index` and structured error body.

## Hooks
Hooks let you plug in behavior before/after create, update, and delete without
changing core logic. Hooks receive a context map and may return a map to merge
Expand Down Expand Up @@ -221,7 +242,6 @@ TASKLANE_DB=/tmp/tasklane.db clj -M:run
## Next release
Planned additions are documented here so the current release stays lean:
- Multi-user + auth
- Bulk API operations
- Planner enhancements (grouping, SLA scoring, CSV exports)

## Run tests
Expand Down
25 changes: 25 additions & 0 deletions src/tasklane/http.clj
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@
(if-let [task (:ok result)]
(created task)
(bad-request (:error result)))))
handle-bulk-create (fn [req]
(let [tasks (:tasks (:body req))
result (service/bulk-create-tasks store tasks)]
(if-let [ok-result (:ok result)]
(ok ok-result)
(bad-request (:error result)))))
handle-list (fn [req]
(let [query (list-query (:query-params req))]
(if-let [filters (:ok query)]
Expand All @@ -88,13 +94,32 @@
(if-let [task (:ok result)]
(ok task)
(not-found (:error result)))))
handle-bulk-update (fn [req]
(let [updates (:updates (:body req))
result (service/bulk-update-tasks store updates)]
(if-let [ok-result (:ok result)]
(ok ok-result)
(bad-request (:error result)))))
handle-bulk-delete (fn [req]
(let [ids (:ids (:body req))
result (service/bulk-delete-tasks store ids)]
(if-let [ok-result (:ok result)]
(ok ok-result)
(bad-request (:error result)))))
handler
(ring/ring-handler
(ring/router
[["/health" {:get (fn [_] (ok {:status "OK"}))}]
["/tasks"
{:get handle-list
:post handle-create}]
["/tasks/bulk/create"
{:post handle-bulk-create}]
["/tasks/bulk/update"
{:patch handle-bulk-update}]
["/tasks/bulk/delete"
{:post handle-bulk-delete
:delete handle-bulk-delete}]
["/tasks/:id"
{:get (fn [req]
(if-let [id (parse-id (get-in req [:path-params :id]))]
Expand Down
109 changes: 109 additions & 0 deletions src/tasklane/service.clj
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,112 @@
{:ok (:result ctx)}))))
(error-result :not-found "Task not found"
[{:field :id :message "no task for that id"}]))))

(defn- sequential-items?
[value]
(and (sequential? value) (not (map? value))))

(defn- bulk-summary
[ok-key ok-items error-items]
{:ok (assoc {ok-key ok-items
:errors error-items}
:total (+ (count ok-items) (count error-items))
:succeeded (count ok-items)
:failed (count error-items))})

(defn bulk-create-tasks
"Create tasks in bulk. Returns {:ok {:created [...] :errors [...] ...}} or {:error ...}."
([tasks]
(bulk-create-tasks default-store tasks))
([store tasks]
(bulk-create-tasks store tasks nil))
([store tasks opts]
(if (sequential-items? tasks)
(let [result (reduce (fn [acc [index task]]
(let [response (create-task store task opts)]
(if-let [created (:ok response)]
(update acc :created conj created)
(update acc :errors conj {:index index
:error (:error response)}))))
{:created []
:errors []}
(map-indexed vector tasks))]
(bulk-summary :created (:created result) (:errors result)))
(error-result :validation
"Bulk create body must contain a list of tasks"
[{:field :tasks :message "expected a list"}]))))

(defn bulk-update-tasks
"Update tasks in bulk. Input items must be maps with :id and :changes."
([items]
(bulk-update-tasks default-store items))
([store items]
(bulk-update-tasks store items nil))
([store items opts]
(if (sequential-items? items)
(let [result (reduce (fn [acc [index item]]
(cond
(not (map? item))
(update acc :errors conj {:index index
:error {:type :validation
:message "Bulk update item must be an object"
:errors [{:field :item
:message "expected an object"}]}})

(not (integer? (:id item)))
(update acc :errors conj {:index index
:error {:type :validation
:message "Bulk update item must include integer id"
:errors [{:field :id
:message "id must be an integer"}]}})

(not (map? (:changes item)))
(update acc :errors conj {:index index
:error {:type :validation
:message "Bulk update item must include changes object"
:errors [{:field :changes
:message "expected an object"}]}})

:else
(let [response (update-task store (:id item) (:changes item) opts)]
(if-let [updated (:ok response)]
(update acc :updated conj updated)
(update acc :errors conj {:index index
:id (:id item)
:error (:error response)})))))
{:updated []
:errors []}
(map-indexed vector items))]
(bulk-summary :updated (:updated result) (:errors result)))
(error-result :validation
"Bulk update body must contain a list of updates"
[{:field :updates :message "expected a list"}]))))

(defn bulk-delete-tasks
"Delete tasks in bulk by id. Returns per-item errors for missing/invalid ids."
([ids]
(bulk-delete-tasks default-store ids))
([store ids]
(bulk-delete-tasks store ids nil))
([store ids opts]
(if (sequential-items? ids)
(let [result (reduce (fn [acc [index id]]
(if (integer? id)
(let [response (delete-task store id opts)]
(if-let [deleted (:ok response)]
(update acc :deleted conj deleted)
(update acc :errors conj {:index index
:id id
:error (:error response)})))
(update acc :errors conj {:index index
:error {:type :validation
:message "Bulk delete ids must be integers"
:errors [{:field :id
:message "id must be an integer"}]}})))
{:deleted []
:errors []}
(map-indexed vector ids))]
(bulk-summary :deleted (:deleted result) (:errors result)))
(error-result :validation
"Bulk delete body must contain a list of ids"
[{:field :ids :message "expected a list"}]))))
42 changes: 42 additions & 0 deletions test/tasklane/http_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,45 @@
(is (= "c" (:name (first list-body))))
(is (= 200 (:status tag-resp)))
(is (= #{"b" "c"} (set (map :name tag-body))))))

(deftest bulk-api-test
(let [bulk-create-req (-> (mock/request :post "/tasks/bulk/create"
(json/write-str {:tasks [{:name "a"}
{:priority 3}
{:name "c"}]}))
(mock/content-type "application/json"))
bulk-create-resp (http/app bulk-create-req)
bulk-create-body (parse-json bulk-create-resp)
created-ids (map :id (:created bulk-create-body))
bulk-update-req (-> (mock/request :patch "/tasks/bulk/update"
(json/write-str {:updates [{:id (first created-ids)
:changes {:status "done"}}
{:id 9999
:changes {:name "missing"}}
{:id (second created-ids)
:changes {:name "c2"}}]}))
(mock/content-type "application/json"))
bulk-update-resp (http/app bulk-update-req)
bulk-update-body (parse-json bulk-update-resp)
bulk-delete-req (-> (mock/request :post "/tasks/bulk/delete"
(json/write-str {:ids [(first created-ids) "bad"]}))
(mock/content-type "application/json"))
bulk-delete-resp (http/app bulk-delete-req)
bulk-delete-body (parse-json bulk-delete-resp)]
(is (= 200 (:status bulk-create-resp)))
(is (= 3 (:total bulk-create-body)))
(is (= 2 (:succeeded bulk-create-body)))
(is (= 1 (:failed bulk-create-body)))
(is (= "validation" (get-in bulk-create-body [:errors 0 :error :type])))

(is (= 200 (:status bulk-update-resp)))
(is (= 3 (:total bulk-update-body)))
(is (= 2 (:succeeded bulk-update-body)))
(is (= 1 (:failed bulk-update-body)))
(is (= "not-found" (get-in bulk-update-body [:errors 0 :error :type])))

(is (= 200 (:status bulk-delete-resp)))
(is (= 2 (:total bulk-delete-body)))
(is (= 1 (:succeeded bulk-delete-body)))
(is (= 1 (:failed bulk-delete-body)))
(is (= "validation" (get-in bulk-delete-body [:errors 0 :error :type])))))
36 changes: 36 additions & 0 deletions test/tasklane/service_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,39 @@
(let [result (service/create-task {:name "global"})]
(is (= "global" (get-in result [:ok :name])))
(is (= [:global-before :global-after] @events)))))

(deftest bulk-operations-test
(testing "bulk create returns created tasks and per-item errors"
(let [result (service/bulk-create-tasks [{:name "a"}
{:priority 2}
{:name "c"}])
body (:ok result)]
(is (= 3 (:total body)))
(is (= 2 (:succeeded body)))
(is (= 1 (:failed body)))
(is (= #{"a" "c"} (set (map :name (:created body)))))
(is (= :validation (get-in body [:errors 0 :error :type])))))
(testing "bulk update handles mixed success and not-found"
(let [t1 (:ok (service/create-task {:name "a"}))
t2 (:ok (service/create-task {:name "b"}))
result (service/bulk-update-tasks [{:id (:id t1) :changes {:status "done"}}
{:id 999 :changes {:name "missing"}}
{:id (:id t2) :changes {:name "b2"}}])
body (:ok result)]
(is (= 3 (:total body)))
(is (= 2 (:succeeded body)))
(is (= 1 (:failed body)))
(is (= #{"a" "b2"} (set (map :name (:updated body)))))
(is (= :done (:status (first (filter #(= (:id t1) (:id %)) (:updated body))))))
(is (= :not-found (get-in body [:errors 0 :error :type])))))
(testing "bulk delete validates ids and deletes existing tasks"
(let [t1 (:ok (service/create-task {:name "a"}))
t2 (:ok (service/create-task {:name "b"}))
result (service/bulk-delete-tasks [(:id t1) "bad" 12345 (:id t2)])
body (:ok result)]
(is (= 4 (:total body)))
(is (= 2 (:succeeded body)))
(is (= 2 (:failed body)))
(is (= #{"a" "b"} (set (map :name (:deleted body)))))
(is (= :validation (get-in body [:errors 0 :error :type])))
(is (= :not-found (get-in body [:errors 1 :error :type]))))))
13 changes: 13 additions & 0 deletions test/tasklane/sqlite_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,16 @@
task (service/get-task store 1)]
(is (= "legacy" (:name task)))
(is (= [] (:tags task))))))

(deftest sqlite-bulk-operations-test
(let [store (sqlite/open-store (str "jdbc:sqlite:" (temp-db-path)))
created (:ok (service/bulk-create-tasks store [{:name "a"} {:name "b"}]))
ids (mapv :id (:created created))
updated (:ok (service/bulk-update-tasks store [{:id (first ids) :changes {:status "done"}}
{:id (second ids) :changes {:name "b2"}}]))
deleted (:ok (service/bulk-delete-tasks store ids))]
(is (= 2 (:succeeded created)))
(is (= 2 (:succeeded updated)))
(is (= 2 (:succeeded deleted)))
(is (= :done (:status (first (:updated updated)))))
(is (= 0 (count (service/list-tasks store {}))))))