Browse Source

add better task sorting, patch api

Aneurin Barker Snook 1 year ago
parent
commit
d4c1bd9ee5
6 changed files with 157 additions and 10 deletions
  1. 11 2
      src/herd/api.ts
  2. 4 4
      src/herd/model.ts
  3. 3 0
      src/http.ts
  4. 44 1
      src/task/api.ts
  5. 92 2
      src/task/model.ts
  6. 3 1
      src/task/types.ts

+ 11 - 2
src/herd/api.ts

@@ -57,6 +57,10 @@ export function createHerd({ model }: Context): AuthRequestHandler {
 export function deleteHerd({ model }: Context): AuthRequestHandler {
   interface ResponseData {
     herd: WithId<Herd>
+    /** Number of tasks deleted */
+    tasks: {
+      deletedCount: number
+    }
   }
 
   return async function (req, res, next) {
@@ -69,10 +73,15 @@ export function deleteHerd({ model }: Context): AuthRequestHandler {
       if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
 
       // Delete herd
-      await model.herd.delete(herd._id)
+      const result = await model.herd.delete(herd._id)
 
       // Send output
-      const output: ResponseData = { herd }
+      const output: ResponseData = {
+        herd: result.herd as WithId<Herd>,
+        tasks: {
+          deletedCount: result.deletedCount,
+        },
+      }
       res.send(output)
       next()
     } catch (err) {

+ 4 - 4
src/herd/model.ts

@@ -47,9 +47,9 @@ async function createHerdModel(ctx: Context) {
     const { deletedCount } = await taskCollection.deleteMany({ _herd: id })
 
     // Delete herd
-    await collection.deleteOne({ _id: id })
+    const herd = await collection.findOneAndDelete({ _id: id })
 
-    return deletedCount
+    return { herd, deletedCount }
   }
 
   /** Delete a herd using a transaction. */
@@ -62,11 +62,11 @@ async function createHerdModel(ctx: Context) {
       const { deletedCount } = await taskCollection.deleteMany({ _herd: id }, { session })
 
       // Delete herd
-      await collection.deleteOne({ _id: id })
+      const herd = await collection.findOneAndDelete({ _id: id })
 
       // Commit and return
       await session.commitTransaction()
-      return deletedCount
+      return { herd, deletedCount }
     } catch (err) {
       await session.abortTransaction()
       throw err

+ 3 - 0
src/http.ts

@@ -35,6 +35,9 @@ export function createExpress(ctx: Context) {
   app.put(`${prefix}/task/:id`, task.updateTask(ctx))
   app.delete(`${prefix}/task/:id`, task.deleteTask(ctx))
 
+  // Task patch APIs
+  app.patch(`${prefix}/task/:id/move/:position`, task.moveTask(ctx))
+
   // Authentication APIs
   app.post(`${prefix}/login/account`, account.loginAccount(ctx))
 

+ 44 - 1
src/task/api.ts

@@ -22,7 +22,7 @@ export function createTask({ model }: Context): AuthRequestHandler {
       _herd: v.seq(v.str, v.exactLength(24)),
       _account: v.seq(v.str, v.exactLength(24)),
       description: v.seq(v.str, v.minLength(1)),
-      position: v.seq(v.numeric, v.min(1)),
+      position: v.seq(v.optional, v.numeric, v.min(1)),
     },
   })
 
@@ -115,6 +115,49 @@ export function getTask({ model }: Context): AuthRequestHandler {
   }
 }
 
+/**
+ * Move task within a herd.
+ * This updates the task's position, and also updates the position of any tasks after it.
+ */
+export function moveTask({ model }: Context): AuthRequestHandler {
+  interface ResponseData {
+    task: WithId<Task>
+    tasks: {
+      /** Number of tasks affected, including the original task */
+      affectedCount: number
+    }
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Read position parameter
+      if (!req.params.position) return sendBadRequest(res, next)
+      const position = parseInt(req.params.position)
+      if (isNaN(position) || position < 1) return sendBadRequest(res, next)
+
+      // Assert access to task
+      const task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!task) return sendNotFound(res, next)
+      if (!req.account._id.equals(task._account)) return sendForbidden(res, next)
+
+      const result = await model.task.move(task._id, position)
+
+      // Send output
+      const output: ResponseData = {
+        task: result.task,
+        tasks: {
+          affectedCount: result.affectedCount,
+        },
+      }
+      res.send(output)
+      next()
+    } catch (err) {
+      return next(err)
+    }
+  }
+}
 
 /** Search tasks. */
 export function searchTasks({ model }: Context): AuthRequestHandler {

+ 92 - 2
src/task/model.ts

@@ -9,13 +9,21 @@ export type TaskModel = Awaited<ReturnType<typeof createTaskModel>>
 async function createTaskModel(ctx: Context) {
   const collection = ctx.db.collection<Task>('task')
 
-  /** Create a task. */
+  /**
+   * Create a task.
+   * If position is omitted, the task is added to the end of its herd.
+   */
   async function create(input: TaskCreate) {
+    let position = input.position
+    if (!position) {
+      position = 1 + await collection.countDocuments({ _herd: input._herd })
+    }
+
     const result = await collection.insertOne({
       _herd: input._herd,
       _account: input._account,
       description: input.description,
-      position: input.position,
+      position,
     })
 
     return await collection.findOne({ _id: result.insertedId })
@@ -31,6 +39,87 @@ async function createTaskModel(ctx: Context) {
     }
   }
 
+  /**
+   * Move a task.
+   * This function updates the position of all subsequent tasks and returns the total number of tasks affected.
+   */
+  async function move(id: ObjectId | string, position: number) {
+    if (ctx.config.mongo.useTransactions) return moveTx(id, position)
+
+    // Update specified task
+    const task = await collection.findOneAndUpdate(
+      { _id: new ObjectId(id) },
+      { $set: { position } },
+      { returnDocument: 'after' },
+    )
+    // Not expected to happen, but type-safe
+    if (!task) throw new Error('failed to get updated task')
+
+    // Get subsequent tasks
+    const nextTasks = collection.find({
+      _herd: task._herd,
+      _id: { $ne: new ObjectId(id) },
+      position: { $gte: position },
+
+    }).sort('position', 1)
+
+    // Update subsequent tasks
+    let affectedCount = 1
+    for await (const task of nextTasks) {
+      await collection.updateOne(
+        { _id: task._id },
+        { $set: { position: position + affectedCount } },
+      )
+      affectedCount++
+    }
+
+    return { task, affectedCount }
+  }
+
+  /** Move a task using a transaction. */
+  async function moveTx(id: ObjectId | string, position: number) {
+    const session = ctx.mongo.startSession()
+    try {
+      session.startTransaction()
+
+      // Update specified task
+      const task = await collection.findOneAndUpdate(
+        { _id: new ObjectId(id) },
+        { $set: { position } },
+        { returnDocument: 'after', session },
+      )
+      // Not expected to happen, but type-safe
+      if (!task) throw new Error('failed to get updated task')
+
+      // Get subsequent tasks
+      const nextTasks = collection.find({
+        _herd: task._herd,
+        _id: { $ne: new ObjectId(id) },
+        position: { $gte: position },
+      }, { session }).sort('position', 1)
+
+      // Update subsequent tasks
+      let affectedCount = 1
+      for await (const task of nextTasks) {
+        await collection.updateOne(
+          { _id: task._id },
+          { $set: { position: position + affectedCount } },
+          { session },
+        )
+        affectedCount++
+      }
+
+      // Commit and return
+      await session.commitTransaction()
+      return { task, affectedCount }
+    } catch (err) {
+      await session.abortTransaction()
+      throw err
+    } finally {
+      await session.endSession()
+    }
+  }
+
   /**
    * Update a task.
    */
@@ -55,6 +144,7 @@ async function createTaskModel(ctx: Context) {
     collection,
     create,
     init,
+    move,
     update,
   }
 }

+ 3 - 1
src/task/types.ts

@@ -13,7 +13,9 @@ export interface Task<T extends ObjectId | string = ObjectId> {
 }
 
 /** Subset of task data when creating a new task. */
-export type TaskCreate<T extends ObjectId | string = ObjectId> = Pick<Task<T>, '_herd' | '_account' | 'description' | 'position'>
+export type TaskCreate<T extends ObjectId | string = ObjectId> =
+  Pick<Task<T>, '_herd' | '_account' | 'description'> &
+  Partial<Pick<Task<T>, 'position'>>
 
 /** Subset of task data when updating a task. */
 export type TaskUpdate<T extends ObjectId | string = ObjectId> = Partial<Pick<Task<T>, '_herd' | '_account' | 'description' | 'position'>>