api.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import type { AuthRequestHandler } from '../auth'
  2. import type { Context } from '../types'
  3. import { ObjectId } from 'mongodb'
  4. import type { SearchResult } from '../api'
  5. import type { WithId } from 'mongodb'
  6. import type { Task, TaskCreate, TaskUpdate } from './types'
  7. import { http, query, validate as v } from '@edge/misc-utils'
  8. /** Create a task. */
  9. export function createTask({ model }: Context): AuthRequestHandler {
  10. interface RequestData {
  11. task: TaskCreate<string>
  12. }
  13. interface ResponseData {
  14. task: WithId<Task>
  15. }
  16. const readRequestData = v.validate<RequestData>({
  17. task: {
  18. _herd: v.seq(v.str, v.exactLength(24)),
  19. _account: v.seq(v.str, v.exactLength(24)),
  20. description: v.seq(v.str, v.minLength(1)),
  21. position: v.seq(v.optional, v.numeric, v.min(1)),
  22. done: v.seq(v.optional, v.bool),
  23. },
  24. })
  25. return async function (req, res, next) {
  26. if (!req.account) return http.unauthorized(res, next)
  27. try {
  28. // Read input
  29. const input = readRequestData(req.body)
  30. // Assert ability to assign task
  31. if (!req.account._id.equals(input.task._account)) return http.forbidden(res, next)
  32. // Assert access to herd
  33. const herd = await model.herd.collection.findOne({ _id: new ObjectId(input.task._herd) })
  34. if (!herd) return http.notFound(res, next, { reason: 'herd not found' })
  35. if (!req.account._id.equals(herd._account)) return http.forbidden(res, next)
  36. const task = await model.task.create({
  37. ...input.task,
  38. _herd: new ObjectId(input.task._herd),
  39. _account: new ObjectId(input.task._account),
  40. })
  41. if (!task) return http.notFound(res, next, { reason: 'unexpectedly failed to get new task' })
  42. const output: ResponseData = { task }
  43. res.send(output)
  44. next()
  45. } catch (err) {
  46. const name = (err as Error).name
  47. if (name === 'ValidateError') {
  48. const ve = err as v.ValidateError
  49. return http.badRequest(res, next, { param: ve.param, reason: ve.message })
  50. }
  51. return next(err)
  52. }
  53. }
  54. }
  55. /** Delete a task. */
  56. export function deleteTask({ model }: Context): AuthRequestHandler {
  57. interface ResponseData {
  58. task: WithId<Task>
  59. }
  60. return async function (req, res, next) {
  61. if (!req.account) return http.unauthorized(res, next)
  62. try {
  63. // Assert access to task
  64. const task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
  65. if (!task) return http.notFound(res, next)
  66. if (!req.account._id.equals(task._account)) return http.forbidden(res, next)
  67. // Delete task
  68. await model.task.collection.deleteOne({ _id: task._id })
  69. // Send output
  70. const output: ResponseData = { task }
  71. res.send(output)
  72. next()
  73. } catch (err) {
  74. next(err)
  75. }
  76. }
  77. }
  78. /** Get a task. */
  79. export function getTask({ model }: Context): AuthRequestHandler {
  80. interface ResponseData {
  81. task: WithId<Task>
  82. }
  83. return async function (req, res, next) {
  84. if (!req.account) return http.unauthorized(res, next)
  85. try {
  86. // Assert access to task
  87. const task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
  88. if (!task) return http.notFound(res, next)
  89. if (!req.account._id.equals(task._account)) return http.forbidden(res, next)
  90. // Send output
  91. const output: ResponseData = { task }
  92. res.send(output)
  93. next()
  94. } catch (err) {
  95. return next(err)
  96. }
  97. }
  98. }
  99. /**
  100. * Move task within a herd.
  101. * This updates the task's position, and also updates the position of any tasks after it.
  102. */
  103. export function moveTask({ model }: Context): AuthRequestHandler {
  104. interface ResponseData {
  105. task: WithId<Task>
  106. tasks: {
  107. /** Number of tasks affected, including the original task */
  108. affectedCount: number
  109. }
  110. }
  111. return async function (req, res, next) {
  112. if (!req.account) return http.unauthorized(res, next)
  113. try {
  114. // Read position parameter
  115. if (!req.params.position) return http.badRequest(res, next)
  116. const position = parseInt(req.params.position)
  117. if (isNaN(position) || position < 1) return http.badRequest(res, next)
  118. // Assert access to task
  119. const task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
  120. if (!task) return http.notFound(res, next)
  121. if (!req.account._id.equals(task._account)) return http.forbidden(res, next)
  122. // Update task
  123. const result = await model.task.move(task._id, position)
  124. // Send output
  125. const output: ResponseData = {
  126. task: result.task,
  127. tasks: {
  128. affectedCount: result.affectedCount,
  129. },
  130. }
  131. res.send(output)
  132. next()
  133. } catch (err) {
  134. return next(err)
  135. }
  136. }
  137. }
  138. /** Search tasks. */
  139. export function searchTasks({ model }: Context): AuthRequestHandler {
  140. type ResponseData = SearchResult<{
  141. task: WithId<Task>
  142. }>
  143. return async function (req, res, next) {
  144. if (!req.account) return http.unauthorized(res, next)
  145. // Read parameters
  146. const herd = req.params.herd || undefined
  147. const limit = query.integer(req.query.limit, 1, 100) || 10
  148. const page = query.integer(req.query.page, 1) || 1
  149. const search = query.str(req.query.search)
  150. const sort = query.sorts(req.query.sort, ['description', 'position'], ['position', 'ASC'])
  151. // Build filters and skip
  152. const filter: Record<string, unknown> = {
  153. _account: req.account._id,
  154. }
  155. if (herd) {
  156. // Add herd filter if set.
  157. // We don't need to verify access as it is implicitly asserted by the _account filter
  158. try {
  159. filter._herd = new ObjectId(herd)
  160. } catch (err) {
  161. return http.badRequest(res, next, { reason: 'invalid herd' })
  162. }
  163. }
  164. if (search) filter.$text = { $search: search }
  165. const skip = (page - 1) * limit
  166. try {
  167. // Get total documents count for filter
  168. const totalCount = await model.task.collection.countDocuments(filter)
  169. // Build cursor
  170. let cursor = model.task.collection.find(filter)
  171. for (const [prop, dir] of sort) {
  172. cursor = cursor.sort(prop, dir === 'ASC' ? 1 : -1)
  173. }
  174. cursor = cursor.skip(skip).limit(limit)
  175. // Get results and send output
  176. const data = await cursor.toArray()
  177. const output: ResponseData = {
  178. results: data.map(task => ({ task })),
  179. metadata: { limit, page, totalCount },
  180. }
  181. res.send(output)
  182. next()
  183. } catch (err) {
  184. next(err)
  185. }
  186. }
  187. }
  188. /**
  189. * Toggle task done flag.
  190. * If the task is not done, it will become done, and vice versa.
  191. */
  192. export function toggleTaskDone({ model }: Context): AuthRequestHandler {
  193. type ResponseData = {
  194. task: WithId<Task>
  195. }
  196. return async function (req, res, next) {
  197. if (!req.account) return http.unauthorized(res, next)
  198. try {
  199. // Assert access to task
  200. let task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
  201. if (!task) return http.notFound(res, next)
  202. if (!req.account._id.equals(task._account)) return http.forbidden(res, next)
  203. // Update to switch task done status
  204. task = await model.task.collection.findOneAndUpdate({ _id: task._id }, { $set: { done: !task.done }}, { returnDocument: 'after' })
  205. if (!task) throw new Error('failed to get updated task')
  206. // Send output
  207. const output: ResponseData = { task }
  208. res.send(output)
  209. next()
  210. } catch (err) {
  211. next(err)
  212. }
  213. }
  214. }
  215. /** Update a task. */
  216. export function updateTask({ model }: Context): AuthRequestHandler {
  217. interface RequestData {
  218. task: TaskUpdate<string>
  219. }
  220. interface ResponseData {
  221. task: WithId<Task>
  222. }
  223. const readRequestData = v.validate<RequestData>({
  224. task: {
  225. _herd: v.seq(v.optional, v.str, v.exactLength(24)),
  226. _account: v.seq(v.optional, v.str, v.exactLength(24)),
  227. description: v.seq(v.optional, v.str, v.minLength(1)),
  228. position: v.seq(v.optional, v.numeric, v.min(1)),
  229. done: v.seq(v.optional, v.bool),
  230. },
  231. })
  232. return async function (req, res, next) {
  233. if (!req.account) return http.unauthorized(res, next)
  234. try {
  235. // Assert access to task
  236. let task = await model.task.collection.findOne({ _id: new ObjectId(req.params.id) })
  237. if (!task) return http.notFound(res, next)
  238. if (!req.account._id.equals(task._account)) return http.forbidden(res, next)
  239. // Read input
  240. const input = readRequestData(req.body)
  241. if (Object.keys(input.task).length < 1) {
  242. return http.badRequest(res, next, { reason: 'no changes' })
  243. }
  244. // Assert ability to assign task, if specified in update
  245. if (input.task._account) {
  246. if (!req.account._id.equals(input.task._account)) return http.forbidden(res, next)
  247. }
  248. // Assert access to herd, if specified in update
  249. if (input.task._herd) {
  250. const herd = await model.task.collection.findOne({ _id: new ObjectId(input.task._herd) })
  251. if (!herd) return http.notFound(res, next, { reason: 'herd not found' })
  252. if (!req.account._id.equals(herd._account)) return http.forbidden(res, next)
  253. }
  254. // Update task
  255. task = await model.task.update(task._id, {
  256. ...input.task,
  257. _herd: input.task._herd && new ObjectId(input.task._herd) || undefined,
  258. _account: input.task._account && new ObjectId(input.task._account) || undefined,
  259. })
  260. if (!task) return http.notFound(res, next)
  261. // Send output
  262. const output: ResponseData = { task }
  263. res.send(output)
  264. next()
  265. } catch (err) {
  266. const name = (err as Error).name
  267. if (name === 'ValidateError') {
  268. const ve = err as v.ValidateError
  269. return http.badRequest(res, next, { param: ve.param, reason: ve.message })
  270. }
  271. return next(err)
  272. }
  273. }
  274. }