Browse Source

Merge pull request #8 from annybs/herds

Add herd and task functionality
Anny 1 year ago
parent
commit
77fdb8f849
17 changed files with 964 additions and 265 deletions
  1. 6 0
      package-lock.json
  2. 1 0
      package.json
  3. 31 32
      src/account/api.ts
  4. 4 2
      src/account/model.ts
  5. 11 0
      src/api.ts
  6. 34 19
      src/auth.ts
  7. 8 0
      src/db.ts
  8. 222 0
      src/herd/api.ts
  9. 104 0
      src/herd/model.ts
  10. 15 0
      src/herd/types.ts
  11. 24 13
      src/http.ts
  12. 4 0
      src/index.ts
  13. 316 0
      src/task/api.ts
  14. 154 0
      src/task/model.ts
  15. 23 0
      src/task/types.ts
  16. 7 0
      src/types.ts
  17. 0 199
      src/validate.ts

+ 6 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.0.0",
       "license": "SEE LICENSE IN LICENSE.md",
       "dependencies": {
+        "@edge/misc-utils": "^1.0.4",
         "dotenv": "^16.3.1",
         "express": "^4.18.2",
         "jsonwebtoken": "^9.0.2",
@@ -47,6 +48,11 @@
         "node": ">=12"
       }
     },
+    "node_modules/@edge/misc-utils": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@edge/misc-utils/-/misc-utils-1.0.4.tgz",
+      "integrity": "sha512-MY6EsTLHYYXzg4j4YQxi2Vj76saWAFbhMIs3IZRqFIjKp+AmzIYR6j1dXi+B4nxXBAipfRMSmaIdB9i2f8Ka3g=="
+    },
     "node_modules/@eslint-community/eslint-utils": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "typescript": "^5.3.2"
   },
   "dependencies": {
+    "@edge/misc-utils": "^1.0.4",
     "dotenv": "^16.3.1",
     "express": "^4.18.2",
     "jsonwebtoken": "^9.0.2",

+ 31 - 32
src/account/api.ts

@@ -1,9 +1,9 @@
-import * as v from '../validate'
 import type { AuthRequestHandler } from '../auth'
 import type { Context } from '../types'
 import { ObjectId } from 'mongodb'
 import type { RequestHandler } from 'express'
 import type { WithId } from 'mongodb'
+import { validate as v } from '@edge/misc-utils'
 import type { Account, AccountCreate, AccountUpdate } from './types'
 import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
 
@@ -20,7 +20,7 @@ export function createAccount({ model }: Context): RequestHandler {
   const readRequestData = v.validate<RequestData>({
     account: {
       email: v.email,
-      password: v.seq(v.minLength(8)),
+      password: v.seq(v.str, v.minLength(8)),
     },
   })
 
@@ -45,23 +45,22 @@ export function createAccount({ model }: Context): RequestHandler {
   }
 }
 
-/**
- * Delete an account.
- * The request must be verified, and the user may not modify any account other than their own.
- */
+/** Delete an account. */
 export function deleteAccount({ model }: Context): AuthRequestHandler {
   interface ResponseData {
     account: WithId<Account>
   }
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
+    if (!req.account) return sendUnauthorized(res, next)
 
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
-    if (!req.accountId?.equals(id)) return sendForbidden(res, next)
+    // Get account ID and assert access
+    const id = req.params.id || req.account._id
+    if (!id) return sendBadRequest(res, next)
+    if (!req.account._id.equals(id)) return sendForbidden(res, next)
 
     try {
+      // Delete account
       /** @todo delete related data */
       const account = await model.account.collection.findOneAndDelete({ _id: new ObjectId(id) })
       if (!account) return sendNotFound(res, next)
@@ -75,27 +74,22 @@ export function deleteAccount({ model }: Context): AuthRequestHandler {
   }
 }
 
-/**
- * Get an account.
- * The request must be verified, and the user may not access any account other than their own.
- */
-export function getAccount({ model }: Context): AuthRequestHandler {
+/** Get an account. */
+export function getAccount(): AuthRequestHandler {
   interface ResponseData {
     account: WithId<Account>
   }
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
+    if (!req.account) return sendUnauthorized(res, next)
 
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
-    if (!req.accountId?.equals(id)) return sendForbidden(res, next)
+    // Get account ID and assert access
+    const id = req.params.id || req.account._id
+    if (!req.account._id.equals(id)) return sendForbidden(res, next)
 
     try {
-      const account = await model.account.collection.findOne({ _id: new ObjectId(id) })
-      if (!account) return sendNotFound(res, next)
-
-      const output: ResponseData = { account }
+      // Send output
+      const output: ResponseData = { account: req.account }
       res.send(output)
       next()
     } catch (err) {
@@ -127,16 +121,21 @@ export function loginAccount({ auth, model }: Context): RequestHandler {
 
   return async function (req, res, next) {
     try {
+      // Read input
       const input = readRequestData(req.body)
 
+      // Get account
       const account = await model.account.collection.findOne({ email: input.account.email })
       if (!account) return sendNotFound(res, next)
 
+      // Validate password
       const password = model.account.hashPassword(input.account.password, account.passwordSalt)
       if (password !== account.password) return sendBadRequest(res, next, { reason: 'invalid password' })
 
+      // Create JWT
       const token = await auth.sign(account._id)
 
+      // Send output
       const output: ResponseData = { token, account }
       res.send(output)
       next()
@@ -151,10 +150,7 @@ export function loginAccount({ auth, model }: Context): RequestHandler {
   }
 }
 
-/**
- * Update an account.
- * The request must be verified, and the user may not modify any account other than their own.
- */
+/** Update an account. */
 export function updateAccount({ model }: Context): AuthRequestHandler {
   interface RequestData {
     account: AccountUpdate
@@ -167,26 +163,29 @@ export function updateAccount({ model }: Context): AuthRequestHandler {
   const readRequestData = v.validate<RequestData>({
     account: {
       email: v.seq(v.optional, v.email),
-      password: v.seq(v.optional, v.minLength(8)),
+      password: v.seq(v.optional, v.str, v.minLength(8)),
     },
   })
 
   return async function (req, res, next) {
-    if (!req.verified) return sendUnauthorized(res, next)
+    if (!req.account) return sendUnauthorized(res, next)
 
-    const id = req.params.id || req.accountId
-    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
-    if (!req.accountId?.equals(id)) return sendForbidden(res, next)
+    // Get account ID and assert access
+    const id = req.params.id || req.account._id
+    if (!req.account._id.equals(id)) return sendForbidden(res, next)
 
     try {
+      // Read input
       const input = readRequestData(req.body)
       if (!input.account.email && !input.account.password) {
         return sendBadRequest(res, next, { reason: 'no changes' })
       }
 
+      // Update account
       const account = await model.account.update(id, input.account)
       if (!account) return sendNotFound(res, next)
 
+      // Send output
       const output: ResponseData = { account }
       res.send(output)
       next()

+ 4 - 2
src/account/model.ts

@@ -39,9 +39,11 @@ async function createAccountModel(ctx: Context) {
 
   /** Initialize the account collection. */
   async function init() {
-    const exists = await collection.indexExists('unq_email')
+    await ctx.db.createCollection('account')
+
+    const exists = await collection.indexExists('account_unq_email')
     if (!exists) {
-      await collection.createIndex({ email: 1 }, { unique: true })
+      await collection.createIndex({ email: 1 }, { name: 'account_unq_email', unique: true })
     }
   }
 

+ 11 - 0
src/api.ts

@@ -0,0 +1,11 @@
+/**
+ * Standard search result type.
+ */
+export interface SearchResult<T> {
+  results: T[]
+  metadata: {
+    limit: number
+    page: number
+    totalCount: number
+  }
+}

+ 34 - 19
src/auth.ts

@@ -1,5 +1,7 @@
+import type { Account } from './account/types'
 import type { Context } from './types'
 import { ObjectId } from 'mongodb'
+import type { WithId } from 'mongodb'
 import jwt from 'jsonwebtoken'
 import type { NextFunction, Request, Response } from 'express'
 
@@ -13,14 +15,13 @@ export type Auth = ReturnType<typeof createAuth>
 
 /** AuthRequest is an Express Request with some additional properties. */
 export type AuthRequest = Request & {
-  /** Account ID decoded from JWT. */
-  accountId?: ObjectId
   /**
-   * Indicates whether the request is verified with a JWT.
-   * This does not necessarily mean the corresponding accountId is internally valid or applicable to the contents of
-   * the request; only that the JWT was in itself valid.
+   * Account document set by authentication middleware.
+   * If this value exists, then the the request is authenticated with this account.
+   *
+   * @see Auth.verifyRequestMiddleware
    */
-  verified?: boolean
+  account?: WithId<Account>
 }
 
 /**
@@ -67,24 +68,38 @@ function createAuth(ctx: Context) {
   }
 
   /**
-   * Verify and decode a JWT provided in an HTTP request's authorization header.
+   * Express middleware to verify and decode a JWT provided in an HTTP request's authorization header.
+   *
+   * If an account ID is successfully decoded from the JWT, this will attempt to load the account automatically before
+   * further request processing.
+   * The account is attached to the request and tacitly available to subsequent request handlers that implement the
+   * AuthRequestHandler type.
    *
-   * If the JWT is verified, some additional properties are set on the request object as a side effect.
-   * These properties are then available to other, subsequent request handlers that use the `AuthRequestHandler` type.
-   * This function is thus suitable for use in Express middleware.
+   * If an invalid token is provided or the account does not exist, an error will be passed along, blocking request
+   * handling.
    */
-  async function verifyRequest(req: Request): Promise<ObjectId | undefined> {
+  const verifyRequestMiddleware: AuthRequestHandler = async (req, res, next) => {
+    // Read header and skip processing if bearer token missing or malformed
     const header = req.header('authorization')
-    if (!header) return
-    if (!/^[Bb]earer /.test(header)) return
-    const token = header.substring(7)
-    const accountId = await verify(token);
-    (req as AuthRequest).accountId = accountId;
-    (req as AuthRequest).verified = true
-    return accountId
+    if (!header) return next()
+    if (!/^[Bb]earer /.test(header)) return next()
+
+    try {
+      // Read and verify token
+      const token = header.substring(7)
+      const _id = await verify(token)
+
+      // Load account
+      const account = await ctx.model.account.collection.findOne({ _id })
+      if (!account) throw new Error(`account ${_id.toString()} not found`)
+      req.account = account
+      next()
+    } catch (err) {
+      return next(err)
+    }
   }
 
-  return { sign, verify, verifyRequest }
+  return { sign, verify, verifyRequestMiddleware }
 }
 
 export default createAuth

+ 8 - 0
src/db.ts

@@ -1,7 +1,11 @@
 import type { AccountModel } from './account/model'
 import type { Context } from './types'
+import type { HerdModel } from './herd/model'
 import { MongoClient } from 'mongodb'
+import type { TaskModel } from './task/model'
 import createAccountModel from './account/model'
+import createHerdModel from './herd/model'
+import createTaskModel from './task/model'
 
 /**
  * Models context.
@@ -9,6 +13,8 @@ import createAccountModel from './account/model'
  */
 export interface Models {
   account: AccountModel
+  herd: HerdModel
+  task: TaskModel
 }
 
 /** Create a MongoDB connection and initialize models. */
@@ -22,6 +28,8 @@ async function createDatabase(ctx: Context) {
   const dbCtx = { ...ctx, mongo, db }
   const model = <Models>{
     account: await createAccountModel(dbCtx),
+    herd: await createHerdModel(dbCtx),
+    task: await createTaskModel(dbCtx),
   }
 
   return { mongo, db, model }

+ 222 - 0
src/herd/api.ts

@@ -0,0 +1,222 @@
+import type { AuthRequestHandler } from '../auth'
+import type { Context } from '../types'
+import { ObjectId } from 'mongodb'
+import type { SearchResult } from '../api'
+import type { WithId } from 'mongodb'
+import type { Herd, HerdCreate, HerdUpdate } from './types'
+import { query, validate as v } from '@edge/misc-utils'
+import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
+
+/** Create a herd. */
+export function createHerd({ model }: Context): AuthRequestHandler {
+  interface RequestData {
+    herd: HerdCreate<string>
+  }
+
+  interface ResponseData {
+    herd: WithId<Herd>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    herd: {
+      _account: v.seq(v.str, v.exactLength(24)),
+      name: v.seq(v.str, v.minLength(1)),
+    },
+  })
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Read input
+      const input = readRequestData(req.body)
+
+      // Assert ability to assign herd
+      if (!req.account._id.equals(input.herd._account)) return sendForbidden(res, next)
+
+      // Create herd
+      const herd = await model.herd.create({ ...input.herd, _account: req.account._id })
+      if (!herd) return sendNotFound(res, next, { reason: 'unexpectedly failed to get new herd' })
+
+      // Send output
+      const output: ResponseData = { herd }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
+  }
+}
+
+/** Delete a herd. */
+export function deleteHerd({ model }: Context): AuthRequestHandler {
+  interface ResponseData {
+    herd: WithId<Herd>
+    /** Number of tasks deleted */
+    tasks: {
+      deletedCount: number
+    }
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to herd
+      const herd = await model.herd.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!herd) return sendNotFound(res, next)
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+
+      // Delete herd
+      const result = await model.herd.delete(herd._id)
+
+      // Send output
+      const output: ResponseData = {
+        herd: result.herd as WithId<Herd>,
+        tasks: {
+          deletedCount: result.deletedCount,
+        },
+      }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
+/** Get a herd. */
+export function getHerd({ model }: Context): AuthRequestHandler {
+  interface ResponseData {
+    herd: WithId<Herd>
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to herd
+      const herd = await model.herd.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!herd) return sendNotFound(res, next)
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+
+      // Send output
+      const output: ResponseData = { herd }
+      res.send(output)
+      next()
+    } catch (err) {
+      return next(err)
+    }
+  }
+}
+
+/** Search herds. */
+export function searchHerds({ model }: Context): AuthRequestHandler {
+  type ResponseData = SearchResult<{
+    herd: WithId<Herd>
+  }>
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    // Read parameters
+    const limit = query.integer(req.query.limit, 1, 100) || 10
+    const page = query.integer(req.query.page, 1) || 1
+    const search = query.str(req.query.search)
+    const sort = query.sorts(req.query.sort, ['name'], ['name', 'ASC'])
+
+    // Build filter and skip
+    const filter: Record<string, unknown> = {
+      _account: req.account._id,
+    }
+    if (search) filter.$text = { $search: search }
+    const skip = (page - 1) * limit
+
+    try {
+      // Get total documents count for filter
+      const totalCount = await model.herd.collection.countDocuments(filter)
+
+      // Build cursor
+      let cursor = model.herd.collection.find(filter)
+      for (const [prop, dir] of sort) {
+        cursor = cursor.sort(prop, dir === 'ASC' ? 1 : -1)
+      }
+      cursor = cursor.skip(skip).limit(limit)
+
+      // Get results and send output
+      const data = await cursor.toArray()
+      const output: ResponseData = {
+        results: data.map(herd => ({ herd })),
+        metadata: { limit, page, totalCount },
+      }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
+/** Update a herd. */
+export function updateHerd({ model }: Context): AuthRequestHandler {
+  interface RequestData {
+    herd: HerdUpdate<string>
+  }
+
+  interface ResponseData {
+    herd: WithId<Herd>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    herd: {
+      _account: v.seq(v.optional, v.str, v.exactLength(24)),
+      name: v.seq(v.optional, v.str, v.minLength(1)),
+    },
+  })
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to herd
+      let herd = await model.herd.collection.findOne({ _id: new ObjectId(req.params.id) })
+      if (!herd) return sendNotFound(res, next)
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+
+      // Read input
+      const input = readRequestData(req.body)
+      if (!input.herd._account && !input.herd.name) {
+        return sendBadRequest(res, next, { reason: 'no changes' })
+      }
+
+      // Assert ability to assign herd, if specified in update
+      if (input.herd._account) {
+        if (!req.account._id.equals(input.herd._account)) return sendForbidden(res, next)
+      }
+
+      // Update herd
+      herd = await model.herd.update(herd._id, {
+        ...input.herd,
+        _account: input.herd._account && new ObjectId(input.herd._account) || undefined,
+      })
+      if (!herd) return sendNotFound(res, next)
+
+      // Send output
+      const output: ResponseData = { herd }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
+  }
+}

+ 104 - 0
src/herd/model.ts

@@ -0,0 +1,104 @@
+import type { Context } from '../types'
+import { ObjectId } from 'mongodb'
+import type { Task } from '../task/types'
+import type { Herd, HerdCreate, HerdUpdate } from './types'
+
+/** Model for accessing and managing herds. */
+export type HerdModel = Awaited<ReturnType<typeof createHerdModel>>
+
+/** Create a herd model. */
+async function createHerdModel(ctx: Context) {
+  const collection = ctx.db.collection<Herd>('herd')
+  const taskCollection = ctx.db.collection<Task>('task')
+
+  /** Create a herd. */
+  async function create(input: HerdCreate) {
+    const result = await collection.insertOne({
+      _account: input._account,
+      name: input.name,
+    })
+
+    return await collection.findOne({ _id: result.insertedId })
+  }
+
+  /** Initialize the herd collection. */
+  async function init() {
+    await ctx.db.createCollection('herd')
+
+    let exists = await collection.indexExists('herd_unq_account_name')
+    if (!exists) {
+      await collection.createIndex({ _account: 1, name: 1 }, { name: 'herd_unq_account_name', unique: true })
+    }
+
+    exists = await collection.indexExists('herd_text')
+    if (!exists) {
+      await collection.createIndex({ name: 'text' }, { name: 'herd_text' })
+    }
+  }
+
+  /**
+   * Delete a herd.
+   * This function removes all tasks associated with the herd and returns the number of tasks deleted.
+   */
+  async function _delete(id: ObjectId) {
+    if (ctx.config.mongo.useTransactions) return _deleteTx(id)
+
+    // Delete tasks
+    const { deletedCount } = await taskCollection.deleteMany({ _herd: id })
+
+    // Delete herd
+    const herd = await collection.findOneAndDelete({ _id: id })
+
+    return { herd, deletedCount }
+  }
+
+  /** Delete a herd using a transaction. */
+  async function _deleteTx(id: ObjectId) {
+    const session = ctx.mongo.startSession()
+    try {
+      session.startTransaction()
+
+      // Delete tasks
+      const { deletedCount } = await taskCollection.deleteMany({ _herd: id }, { session })
+
+      // Delete herd
+      const herd = await collection.findOneAndDelete({ _id: id })
+
+      // Commit and return
+      await session.commitTransaction()
+      return { herd, deletedCount }
+    } catch (err) {
+      await session.abortTransaction()
+      throw err
+    } finally {
+      await session.endSession()
+    }
+  }
+
+  /**
+   * Update a herd.
+   */
+  async function update(id: ObjectId | string, input: HerdUpdate) {
+    const changes = <Partial<Herd>>{}
+    if (input.name) changes.name = input.name
+
+    return collection.findOneAndUpdate(
+      { _id: new ObjectId(id) },
+      { $set: changes },
+      { returnDocument: 'after' },
+    )
+  }
+
+  // Initialize on startup
+  await init()
+
+  return {
+    collection,
+    create,
+    delete: _delete,
+    init,
+    update,
+  }
+}
+
+export default createHerdModel

+ 15 - 0
src/herd/types.ts

@@ -0,0 +1,15 @@
+import type { ObjectId } from 'mongodb'
+
+/** Herd data. */
+export interface Herd<T extends ObjectId | string = ObjectId> {
+  /** Account ID. */
+  _account: T
+  /** Name. */
+  name: string
+}
+
+/** Subset of herd data when creating a new herd. */
+export type HerdCreate<T extends ObjectId | string = ObjectId> = Pick<Herd<T>, '_account' | 'name'>
+
+/** Subset of herd data when updating a herd. */
+export type HerdUpdate<T extends ObjectId | string = ObjectId> = Partial<Pick<Herd<T>, '_account' | 'name'>>

+ 24 - 13
src/http.ts

@@ -1,33 +1,44 @@
 import * as account from './account/api'
+import * as herd from './herd/api'
+import * as task from './task/api'
 import type { Context } from './types'
 import express from 'express'
 import type { ErrorRequestHandler, NextFunction, Response } from 'express'
 
 /** Create an Express application. */
 export function createExpress(ctx: Context) {
-  // Initialize app with JSON middleware
+  // Initialize app with JSON and auth middleware
   const app = express()
   app.use(express.json())
-
-  // Add request verification middleware.
-  // See ./auth.ts
-  app.use(async (req, res, next) => {
-    try {
-      await ctx.auth.verifyRequest(req)
-    } catch (err) {
-      ctx.log.warn('Failed to verify request', err)
-    }
-    next()
-  })
+  app.use(ctx.auth.verifyRequestMiddleware)
 
   const prefix = ctx.config.api.prefix
 
   // Account APIs
   app.post(`${prefix}/account`, account.createAccount(ctx))
-  app.get(`${prefix}/account/:id?`, account.getAccount(ctx))
+  app.get(`${prefix}/account/:id?`, account.getAccount())
   app.put(`${prefix}/account/:id?`, account.updateAccount(ctx))
   app.delete(`${prefix}/account/:id?`, account.deleteAccount(ctx))
 
+  // Herd APIs
+  app.get(`${prefix}/herds`, herd.searchHerds(ctx))
+  app.post(`${prefix}/herd`, herd.createHerd(ctx))
+  app.get(`${prefix}/herd/:id`, herd.getHerd(ctx))
+  app.put(`${prefix}/herd/:id`, herd.updateHerd(ctx))
+  app.delete(`${prefix}/herd/:id`, herd.deleteHerd(ctx))
+
+  // Task APIs
+  app.get(`${prefix}/tasks`, task.searchTasks(ctx))
+  app.get(`${prefix}/herd/:herd/tasks`, task.searchTasks(ctx))
+  app.post(`${prefix}/task`, task.createTask(ctx))
+  app.get(`${prefix}/task/:id`, task.getTask(ctx))
+  app.put(`${prefix}/task/:id`, task.updateTask(ctx))
+  app.delete(`${prefix}/task/:id`, task.deleteTask(ctx))
+
+  // Task patch APIs
+  app.patch(`${prefix}/task/:id/done`, task.toggleTaskDone(ctx))
+  app.patch(`${prefix}/task/:id/move/:position`, task.moveTask(ctx))
+
   // Authentication APIs
   app.post(`${prefix}/login/account`, account.loginAccount(ctx))
 

+ 4 - 0
src/index.ts

@@ -10,6 +10,9 @@ dotenv.config()
  */
 const dynamicConfig: Record<string, unknown> = {}
 
+/** String truthy values. */
+const TRUE = ['1', 't', 'y', 'on', 'yes', 'true']
+
 // Run the app
 main({
   api: {
@@ -39,6 +42,7 @@ main({
   mongo: {
     db: process.env.MONGO_DB || 'herda',
     uri: process.env.MONGO_URI || 'mongodb://root:root@localhost:27017',
+    useTransactions: TRUE.includes(process.env.MONGO_USE_TRANSACTIONS || 'false'),
   },
   shutdownTimeout: parseInt(process.env.SHUTDOWN_TIMEOUT || '60000'),
 }).catch(err => {

+ 316 - 0
src/task/api.ts

@@ -0,0 +1,316 @@
+import type { AuthRequestHandler } from '../auth'
+import type { Context } from '../types'
+import { ObjectId } from 'mongodb'
+import type { SearchResult } from '../api'
+import type { WithId } from 'mongodb'
+import type { Task, TaskCreate, TaskUpdate } from './types'
+import { query, validate as v } from '@edge/misc-utils'
+import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
+
+/** Create a task. */
+export function createTask({ model }: Context): AuthRequestHandler {
+  interface RequestData {
+    task: TaskCreate<string>
+  }
+
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    task: {
+      _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.optional, v.numeric, v.min(1)),
+      done: v.seq(v.optional, v.bool),
+    },
+  })
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Read input
+      const input = readRequestData(req.body)
+
+      // Assert ability to assign task
+      if (!req.account._id.equals(input.task._account)) return sendForbidden(res, next)
+
+      // Assert access to herd
+      const herd = await model.herd.collection.findOne({ _id: new ObjectId(input.task._herd) })
+      if (!herd) return sendNotFound(res, next, { reason: 'herd not found' })
+      if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+
+      const task = await model.task.create({
+        ...input.task,
+        _herd: new ObjectId(input.task._herd),
+        _account: new ObjectId(input.task._account),
+      })
+      if (!task) return sendNotFound(res, next, { reason: 'unexpectedly failed to get new task' })
+
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
+  }
+}
+
+/** Delete a task. */
+export function deleteTask({ model }: Context): AuthRequestHandler {
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // 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)
+
+      // Delete task
+      await model.task.collection.deleteOne({ _id: task._id })
+
+      // Send output
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
+/** Get a task. */
+export function getTask({ model }: Context): AuthRequestHandler {
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // 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)
+
+      // Send output
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      return next(err)
+    }
+  }
+}
+
+/**
+ * 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 {
+  type ResponseData = SearchResult<{
+    task: WithId<Task>
+  }>
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    // Read parameters
+    const herd = req.params.herd || undefined
+    const limit = query.integer(req.query.limit, 1, 100) || 10
+    const page = query.integer(req.query.page, 1) || 1
+    const search = query.str(req.query.search)
+    const sort = query.sorts(req.query.sort, ['description', 'position'], ['position', 'ASC'])
+
+    // Build filters and skip
+    const filter: Record<string, unknown> = {
+      _account: req.account._id,
+    }
+    if (herd) {
+      // Add herd filter if set.
+      // We don't need to verify access as it is implicitly asserted by the _account filter
+      try {
+        filter._herd = new ObjectId(herd)
+      } catch (err) {
+        return sendBadRequest(res, next, { reason: 'invalid herd' })
+      }
+    }
+    if (search) filter.$text = { $search: search }
+    const skip = (page - 1) * limit
+
+    try {
+      // Get total documents count for filter
+      const totalCount = await model.herd.collection.countDocuments(filter)
+
+      // Build cursor
+      let cursor = model.task.collection.find(filter)
+      for (const [prop, dir] of sort) {
+        cursor = cursor.sort(prop, dir === 'ASC' ? 1 : -1)
+      }
+      cursor = cursor.skip(skip).limit(limit)
+
+      // Get results and send output
+      const data = await cursor.toArray()
+      const output: ResponseData = {
+        results: data.map(task => ({ task })),
+        metadata: { limit, page, totalCount },
+      }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
+export function toggleTaskDone({ model }: Context): AuthRequestHandler {
+  type ResponseData = {
+    task: WithId<Task>
+  }
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to task
+      let 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)
+
+      // Update to switch task done status
+      task = await model.task.collection.findOneAndUpdate({ _id: task._id }, { $set: { done: !task.done }}, { returnDocument: 'after' })
+      if (!task) throw new Error('failed to get updated task')
+
+      // Send output
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
+/** Update a task. */
+export function updateTask({ model }: Context): AuthRequestHandler {
+  interface RequestData {
+    task: TaskUpdate<string>
+  }
+
+  interface ResponseData {
+    task: WithId<Task>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    task: {
+      _herd: v.seq(v.optional, v.str, v.exactLength(24)),
+      _account: v.seq(v.optional, v.str, v.exactLength(24)),
+      description: v.seq(v.optional, v.str, v.minLength(1)),
+      position: v.seq(v.optional, v.numeric, v.min(1)),
+      done: v.seq(v.optional, v.bool),
+    },
+  })
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    try {
+      // Assert access to task
+      let 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)
+
+      // Read input
+      const input = readRequestData(req.body)
+      if (Object.keys(input.task).length < 1) {
+        return sendBadRequest(res, next, { reason: 'no changes' })
+      }
+
+      // Assert ability to assign task, if specified in update
+      if (input.task._account) {
+        if (!req.account._id.equals(input.task._account)) return sendForbidden(res, next)
+      }
+
+      // Assert access to herd, if specified in update
+      if (input.task._herd) {
+        const herd = await model.task.collection.findOne({ _id: new ObjectId(input.task._herd) })
+        if (!herd) return sendNotFound(res, next, { reason: 'herd not found' })
+        if (!req.account._id.equals(herd._account)) return sendForbidden(res, next)
+      }
+
+      // Update task
+      task = await model.task.update(task._id, {
+        ...input.task,
+        _herd: input.task._herd && new ObjectId(input.task._herd) || undefined,
+        _account: input.task._account && new ObjectId(input.task._account) || undefined,
+      })
+      if (!task) return sendNotFound(res, next)
+
+      // Send output
+      const output: ResponseData = { task }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
+  }
+}

+ 154 - 0
src/task/model.ts

@@ -0,0 +1,154 @@
+import type { Context } from '../types'
+import { ObjectId } from 'mongodb'
+import type { Task, TaskCreate, TaskUpdate } from './types'
+
+/** Model for accessing and managing tasks. */
+export type TaskModel = Awaited<ReturnType<typeof createTaskModel>>
+
+/** Create a task model. */
+async function createTaskModel(ctx: Context) {
+  const collection = ctx.db.collection<Task>('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,
+      done: Boolean(input.done),
+    })
+
+    return await collection.findOne({ _id: result.insertedId })
+  }
+
+  /** Initialize the task collection. */
+  async function init() {
+    await ctx.db.createCollection('task')
+
+    const exists = await collection.indexExists('task_text')
+    if (!exists) {
+      await collection.createIndex({ description: 'text' }, { name: 'task_text' })
+    }
+  }
+
+  /**
+   * 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.
+   */
+  async function update(id: ObjectId | string, input: TaskUpdate) {
+    const changes = <Partial<Task>>{}
+    if (input._herd) changes._herd = input._herd
+    if (input._account) changes._account = input._account
+    if (input.description) changes.description = input.description
+    if (input.position !== undefined) changes.position = input.position
+    if (input.done !== undefined) changes.done = input.done
+
+    return collection.findOneAndUpdate(
+      { _id: new ObjectId(id) },
+      { $set: changes },
+      { returnDocument: 'after' },
+    )
+  }
+
+  // Initialize on startup
+  await init()
+
+  return {
+    collection,
+    create,
+    init,
+    move,
+    update,
+  }
+}
+
+export default createTaskModel

+ 23 - 0
src/task/types.ts

@@ -0,0 +1,23 @@
+import type { ObjectId } from 'mongodb'
+
+/** Task data. */
+export interface Task<T extends ObjectId | string = ObjectId> {
+  /** Herd ID. */
+  _herd: T
+  /** Account ID reflecting the task assignee. */
+  _account: T
+  /** Description. */
+  description: string
+  /** Position in herd. */
+  position: number
+  /** Flag signifying whether the task is done. */
+  done: boolean
+}
+
+/** Subset of task data when creating a new task. */
+export type TaskCreate<T extends ObjectId | string = ObjectId> =
+  Pick<Task<T>, '_herd' | '_account' | 'description'> &
+  Partial<Pick<Task<T>, 'position' | 'done'>>
+
+/** Subset of task data when updating a task. */
+export type TaskUpdate<T extends ObjectId | string = ObjectId> = Partial<Pick<Task<T>, '_herd' | '_account' | 'description' | 'position' | 'done'>>

+ 7 - 0
src/types.ts

@@ -39,6 +39,13 @@ export interface Config {
      * @see https://www.mongodb.com/docs/drivers/node/current/quick-start/create-a-connection-string/
      */
     uri: string
+    /**
+     * Whether to use transactions to encapsulate multiple document writes in MongoDB clusters or replica sets
+     * (default: false)
+     *
+     * @see https://www.mongodb.com/docs/v7.0/core/transactions/#feature-compatibility-version--fcv-
+     */
+    useTransactions: boolean
   }
   /**
    * If the application cannot shut down because a process has stalled, it will force shutdown with `process.exit(1)`

+ 0 - 199
src/validate.ts

@@ -1,199 +0,0 @@
-/**
- * IMPORTED EXTERNAL LIB - DO NOT MODIFY
- */
-
-/**
- * Validation specification.
- * Given an object type, a specification outline is derived to ensure that validation is defined for all scalar
- * properties.
- *
- * If a property is array-type, a single validator must be specified which receives the entire array.
- * If a property is otherwise object-type, the validation spec extends deeply into it.
- *
- * Optional properties must still be specified; the `optional()` function can be used to allow them to be skipped if
- * the value is undefined.
- */
-export type Spec<T> =
-  T extends Array<unknown>
-    ? ValidateFn
-    : T extends object
-      ? { [P in keyof Required<T>]: Spec<T[P]> }
-      : ValidateFn
-
-/** Validation error thrown by `validate()`. Provides the failed parameter along with the error message. */
-export class ValidateError extends Error {
-  param: string
-
-  constructor(param: string, message: string) {
-    super(message)
-    this.name = 'ValidateError'
-    this.param = param
-  }
-}
-
-/**
- * A validation function takes any input and throws an error if it does not satisfy the relevant condition.
- *
- * It may return `true` signifying that further validation is not required; see `seq()` for more detail.
- *
- * When used in conjunction with `validate()` to parse an object, an `origInput` is also passed giving the entire
- * data object.
- * This can be used to validate one property based on the value of another, though as the type of the parent input
- * is `unknown` knowledge of the structure has to be asserted by the caller.
- */
-export type ValidateFn = (input: unknown, origInput?: unknown) => true | void
-
-/** Validate input is Boolean. */
-export const bool: ValidateFn = input => {
-  if (typeof input !== 'boolean') throw new Error('must be boolean')
-}
-
-/**
- * FQDN format expression.
- *
- * Based on https://stackoverflow.com/a/62917037/1717753
- */
-const domainRegexp = /^((?=[a-z0-9-_]{1,63}\.)(xn--)?[a-z0-9_]+(-[a-z0-9_]+)*\.)+[a-z]{2,63}$/
-
-/**
- * Validate input is a fully-qualified domain name.
- * Implicitly validates `str()` for convenience.
- */
-export const domain: ValidateFn = input => {
-  str(input)
-  if (!domainRegexp.test(input as string)) throw new Error('invalid domain')
-}
-
-/** Email format expression. */
-const emailRegexp = /^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
-
-/**
- * Validate input is an email address.
- * Implicitly validates `str()` for convenience.
- */
-export const email: ValidateFn = input => {
-  str(input)
-  if (!emailRegexp.test(input as string)) throw new Error('invalid email address')
-}
-
-/** Validate input is exactly equal to a comparison value. */
-export const eq = <T>(cmp: T): ValidateFn => input => {
-  if (input !== cmp) throw new Error(`must be ${cmp}`)
-}
-
-/** Validate exact length of string. */
-export const exactLength = (n: number): ValidateFn => input => {
-  if ((input as string).length !== n) throw new Error(`must contain exactly ${n} characters`)
-}
-
-/** Lowercase hexadecimal expression. */
-const hexRegexp = /^[a-f0-9]*$/
-
-/**
- * Validate string is lowercase hexadecimal.
- * Implicitly validates `str()` for convenience.
- */
-export const hex: ValidateFn = input => {
-  str(input)
-  if (!hexRegexp.test(input as string)) throw new Error('invalid characters')
-}
-
-/**
- * Validate input is an integer.
- * Implicitly validates `numeric()` for convenience.
- */
-export const integer: ValidateFn = input => {
-  numeric(input)
-  if ((input as number).toString().indexOf('.') > -1) throw new Error('must be an integer')
-}
-
-/** Validate maximum number. */
-export const max = (n: number): ValidateFn => input => {
-  if (input as number > n) throw new Error(`must be no more than ${n}`)
-}
-
-/** Validate maximum length of string. */
-export const maxLength = (n: number): ValidateFn => input => {
-  if ((input as string).length > n) throw new Error(`must be no longer than ${n} characters`)
-}
-
-/** Validate minimum number. */
-export const min = (n: number): ValidateFn => input => {
-  if (input as number < n) throw new Error(`must be no less than ${n}`)
-}
-
-/** Validate minimum length of string. */
-export const minLength = (n: number): ValidateFn => input => {
-  if ((input as string).length < n) throw new Error(`must be no shorter than ${n} characters`)
-}
-
-/** Validate input is numeric. */
-export const numeric: ValidateFn = input => {
-  if (typeof input !== 'number') throw new Error('must be a number')
-}
-
-/** Validate input is one of a range of options. */
-export const oneOf = <T>(range: T[]): ValidateFn => input => {
-  if (!range.includes(input as T)) throw new Error(`must be one of: ${range.join(', ')}`)
-}
-
-/**
- * Special non-validator that returns true if the input is null or undefined, and never throws an error.
- * Normally used with `seq()`.
- */
-export const optional: ValidateFn = input => {
-  if (input === null || input === undefined) return true
-}
-
-/** Validate string against regular expression. */
-export const regexp = (re: RegExp): ValidateFn => input => {
-  if (!re.test(input as string)) throw new Error('invalid characters')
-}
-
-/**
- * Sequentially runs multiple validation functions.
- * If a function returns true, for example `optional()`, subsequent validation is ignored.
- */
-export const seq = (...fs: ValidateFn[]): ValidateFn => (input, origInput) => {
-  for (let i = 0; i < fs.length; i++) {
-    if (fs[i](input, origInput)) return
-  }
-}
-
-/** Validate input is a string. */
-export const str: ValidateFn = input => {
-  if (typeof input !== 'string') throw new Error('must be a string')
-}
-
-/**
- * Read an unknown input and assert it matches an object specification.
- * This function immediately throws a ValidateError if any validation fails.
- * Otherwise, a typed copy of the input is returned.
- *
- * This method does not support validation across multiple properties or asynchronous validation.
- *
- * @todo Remove usage of `any` type.
- */
-export const validate = <T extends object>(spec: Spec<Required<T>>, parent = '') =>
-  (input: unknown, origInput?: unknown): T => {
-    type V = keyof Spec<Required<T>>
-    type I = keyof T
-    if (typeof input !== 'object' || input === null) throw new Error('no data')
-    return Object.keys(spec).reduce((v, k) => {
-      const f = spec[k as V]
-      const value = (input as T)[k as I]
-      if (typeof f === 'function') {
-        try {
-          f(value, origInput || input)
-          v[k as I] = value
-        } catch (err) {
-          const param = parent ? `${parent}.${k}` : k
-          throw new ValidateError(param, (err as Error).message)
-        }
-      } else {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        v[k as I] = validate(f as any, k as string)(value as any, origInput || input) as any
-      }
-      return v
-    }, <T>{})
-  }