bookmark-folder.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. import { ErrorV3 } from '@growi/core/dist/models';
  2. import { body } from 'express-validator';
  3. import type { Types } from 'mongoose';
  4. import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
  5. import { SCOPE } from '~/interfaces/scope';
  6. import { accessTokenParser } from '~/server/middlewares/access-token-parser';
  7. import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
  8. import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
  9. import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
  10. import loggerFactory from '~/utils/logger';
  11. import BookmarkFolder from '../../models/bookmark-folder';
  12. const logger = loggerFactory('growi:routes:apiv3:bookmark-folder');
  13. const express = require('express');
  14. const router = express.Router();
  15. /**
  16. * @swagger
  17. *
  18. * components:
  19. * schemas:
  20. * BookmarkFolder:
  21. * description: Bookmark Folder
  22. * type: object
  23. * properties:
  24. * _id:
  25. * type: string
  26. * description: Bookmark Folder ID
  27. * __v:
  28. * type: number
  29. * description: Version of the bookmark folder
  30. * name:
  31. * type: string
  32. * description: Name of the bookmark folder
  33. * owner:
  34. * type: string
  35. * description: Owner user ID of the bookmark folder
  36. * bookmarks:
  37. * type: array
  38. * items:
  39. * type: object
  40. * properties:
  41. * _id:
  42. * type: string
  43. * description: Bookmark ID
  44. * user:
  45. * type: string
  46. * description: User ID of the bookmarker
  47. * createdAt:
  48. * type: string
  49. * description: Date and time when the bookmark was created
  50. * __v:
  51. * type: number
  52. * description: Version of the bookmark
  53. * page:
  54. * description: Pages that are bookmarked in the folder
  55. * allOf:
  56. * - $ref: '#/components/schemas/Page'
  57. * - type: object
  58. * properties:
  59. * id:
  60. * type: string
  61. * description: Page ID
  62. * example: "671b5cd38d45e62b52217ff8"
  63. * parent:
  64. * type: string
  65. * description: Parent page ID
  66. * example: 669a5aa48d45e62b521d00da
  67. * descendantCount:
  68. * type: number
  69. * description: Number of descendants
  70. * example: 0
  71. * isEmpty:
  72. * type: boolean
  73. * description: Whether the page is empty
  74. * example: false
  75. * grantedGroups:
  76. * type: array
  77. * description: List of granted groups
  78. * items:
  79. * type: string
  80. * creator:
  81. * type: string
  82. * description: Creator user ID
  83. * example: "669a5aa48d45e62b521d00e4"
  84. * latestRevisionBodyLength:
  85. * type: number
  86. * description: Length of the latest revision body
  87. * example: 241
  88. * childFolder:
  89. * type: array
  90. * items:
  91. * type: object
  92. * $ref: '#/components/schemas/BookmarkFolder'
  93. */
  94. const validator = {
  95. bookmarkFolder: [
  96. body('name').isString().withMessage('name must be a string'),
  97. body('parent').isMongoId().optional({ nullable: true })
  98. .custom(async(parent: string) => {
  99. const parentFolder = await BookmarkFolder.findById(parent);
  100. if (parentFolder == null || parentFolder.parent != null) {
  101. throw new Error('Maximum folder hierarchy of 2 levels');
  102. }
  103. }),
  104. body('childFolder').optional().isArray().withMessage('Children must be an array'),
  105. body('bookmarkFolderId').optional().isMongoId().withMessage('Bookark Folder ID must be a valid mongo ID'),
  106. ],
  107. bookmarkPage: [
  108. body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
  109. body('folderId').optional({ nullable: true }).isMongoId().withMessage('Folder ID must be a valid mongo ID'),
  110. ],
  111. bookmark: [
  112. body('pageId').isMongoId().withMessage('Page ID must be a valid mongo ID'),
  113. body('status').isBoolean().withMessage('status must be one of true or false'),
  114. ],
  115. };
  116. /** @param {import('~/server/crowi').default} crowi Crowi instance */
  117. module.exports = (crowi) => {
  118. const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
  119. /**
  120. * @swagger
  121. *
  122. * /bookmark-folder:
  123. * post:
  124. * tags: [BookmarkFolders]
  125. * operationId: createBookmarkFolder
  126. * security:
  127. * - bearer: []
  128. * - accessTokenInQuery: []
  129. * summary: Create bookmark folder
  130. * description: Create a new bookmark folder
  131. * requestBody:
  132. * content:
  133. * application/json:
  134. * schema:
  135. * properties:
  136. * name:
  137. * type: string
  138. * description: Name of the bookmark folder
  139. * nullable: false
  140. * parent:
  141. * type: string
  142. * description: Parent folder ID
  143. * responses:
  144. * 200:
  145. * description: Resources are available
  146. * content:
  147. * application/json:
  148. * schema:
  149. * properties:
  150. * bookmarkFolder:
  151. * type: object
  152. * $ref: '#/components/schemas/BookmarkFolder'
  153. */
  154. router.post('/',
  155. accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
  156. loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
  157. const owner = req.user?._id;
  158. const { name, parent } = req.body;
  159. const params = {
  160. name, owner, parent,
  161. };
  162. try {
  163. const bookmarkFolder = await BookmarkFolder.createByParameters(params);
  164. logger.debug('bookmark folder created', bookmarkFolder);
  165. return res.apiv3({ bookmarkFolder });
  166. }
  167. catch (err) {
  168. logger.error(err);
  169. if (err instanceof InvalidParentBookmarkFolderError) {
  170. return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
  171. }
  172. return res.apiv3Err(err, 500);
  173. }
  174. });
  175. /**
  176. * @swagger
  177. *
  178. * /bookmark-folder/list/{userId}:
  179. * get:
  180. * tags: [BookmarkFolders]
  181. * operationId: listBookmarkFolders
  182. * security:
  183. * - bearer: []
  184. * - accessTokenInQuery: []
  185. * summary: List bookmark folders of a user
  186. * description: List bookmark folders of a user
  187. * parameters:
  188. * - name: userId
  189. * in: path
  190. * required: true
  191. * description: User ID
  192. * schema:
  193. * type: string
  194. * responses:
  195. * 200:
  196. * description: Resources are available
  197. * content:
  198. * application/json:
  199. * schema:
  200. * properties:
  201. * bookmarkFolderItems:
  202. * type: array
  203. * items:
  204. * type: object
  205. * $ref: '#/components/schemas/BookmarkFolder'
  206. */
  207. router.get('/list/:userId', accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
  208. const { userId } = req.params;
  209. const getBookmarkFolders = async(
  210. userId: Types.ObjectId | string,
  211. parentFolderId?: Types.ObjectId | string,
  212. ) => {
  213. const folders = await BookmarkFolder.find({ owner: userId, parent: parentFolderId })
  214. .populate('childFolder')
  215. .populate({
  216. path: 'bookmarks',
  217. model: 'Bookmark',
  218. populate: {
  219. path: 'page',
  220. model: 'Page',
  221. populate: {
  222. path: 'lastUpdateUser',
  223. model: 'User',
  224. },
  225. },
  226. }).exec() as never as BookmarkFolderItems[];
  227. const returnValue: BookmarkFolderItems[] = [];
  228. const promises = folders.map(async(folder: BookmarkFolderItems) => {
  229. const childFolder = await getBookmarkFolders(userId, folder._id);
  230. // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
  231. // Serializing outside of promises will cause not populated.
  232. const bookmarks = folder.bookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
  233. const res = {
  234. _id: folder._id.toString(),
  235. name: folder.name,
  236. owner: folder.owner,
  237. bookmarks,
  238. childFolder,
  239. parent: folder.parent,
  240. };
  241. return res;
  242. });
  243. const results = await Promise.all(promises) as unknown as BookmarkFolderItems[];
  244. returnValue.push(...results);
  245. return returnValue;
  246. };
  247. try {
  248. const bookmarkFolderItems = await getBookmarkFolders(userId, undefined);
  249. return res.apiv3({ bookmarkFolderItems });
  250. }
  251. catch (err) {
  252. logger.error(err);
  253. return res.apiv3Err(err, 500);
  254. }
  255. });
  256. /**
  257. * @swagger
  258. *
  259. * /bookmark-folder/{id}:
  260. * delete:
  261. * tags: [BookmarkFolders]
  262. * operationId: deleteBookmarkFolder
  263. * security:
  264. * - bearer: []
  265. * - accessTokenInQuery: []
  266. * summary: Delete bookmark folder
  267. * description: Delete a bookmark folder and its children
  268. * parameters:
  269. * - name: id
  270. * in: path
  271. * required: true
  272. * description: Bookmark Folder ID
  273. * schema:
  274. * type: string
  275. * responses:
  276. * 200:
  277. * description: Deleted successfully
  278. * content:
  279. * application/json:
  280. * schema:
  281. * properties:
  282. * deletedCount:
  283. * type: number
  284. * description: Number of deleted folders
  285. * example: 1
  286. */
  287. router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
  288. const { id } = req.params;
  289. try {
  290. const result = await BookmarkFolder.deleteFolderAndChildren(id);
  291. const { deletedCount } = result;
  292. return res.apiv3({ deletedCount });
  293. }
  294. catch (err) {
  295. logger.error(err);
  296. return res.apiv3Err(err, 500);
  297. }
  298. });
  299. /**
  300. * @swagger
  301. *
  302. * /bookmark-folder:
  303. * put:
  304. * tags: [BookmarkFolders]
  305. * operationId: updateBookmarkFolder
  306. * security:
  307. * - bearer: []
  308. * - accessTokenInQuery: []
  309. * summary: Update bookmark folder
  310. * description: Update a bookmark folder
  311. * requestBody:
  312. * content:
  313. * application/json:
  314. * schema:
  315. * properties:
  316. * bookmarkFolderId:
  317. * type: string
  318. * description: Bookmark Folder ID
  319. * name:
  320. * type: string
  321. * description: Name of the bookmark folder
  322. * nullable: false
  323. * parent:
  324. * type: string
  325. * description: Parent folder ID
  326. * childFolder:
  327. * type: array
  328. * description: Child folders
  329. * items:
  330. * type: object
  331. * $ref: '#/components/schemas/BookmarkFolder'
  332. * responses:
  333. * 200:
  334. * description: Resources are available
  335. * content:
  336. * application/json:
  337. * schema:
  338. * properties:
  339. * bookmarkFolder:
  340. * type: object
  341. * $ref: '#/components/schemas/BookmarkFolder'
  342. */
  343. router.put('/',
  344. accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
  345. const {
  346. bookmarkFolderId, name, parent, childFolder,
  347. } = req.body;
  348. try {
  349. const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
  350. return res.apiv3({ bookmarkFolder });
  351. }
  352. catch (err) {
  353. logger.error(err);
  354. return res.apiv3Err(err, 500);
  355. }
  356. });
  357. /**
  358. * @swagger
  359. *
  360. * /bookmark-folder/add-bookmark-to-folder:
  361. * post:
  362. * tags: [BookmarkFolders]
  363. * operationId: addBookmarkToFolder
  364. * security:
  365. * - bearer: []
  366. * - accessTokenInQuery: []
  367. * summary: Update bookmark folder
  368. * description: Update a bookmark folder
  369. * requestBody:
  370. * content:
  371. * application/json:
  372. * schema:
  373. * properties:
  374. * pageId:
  375. * type: string
  376. * description: Page ID
  377. * nullable: false
  378. * folderId:
  379. * type: string
  380. * description: Folder ID
  381. * nullable: true
  382. * responses:
  383. * 200:
  384. * description: Resources are available
  385. * content:
  386. * application/json:
  387. * schema:
  388. * properties:
  389. * bookmarkFolder:
  390. * type: object
  391. * $ref: '#/components/schemas/BookmarkFolder'
  392. */
  393. router.post('/add-bookmark-to-folder',
  394. accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator,
  395. async(req, res) => {
  396. const userId = req.user?._id;
  397. const { pageId, folderId } = req.body;
  398. try {
  399. const bookmarkFolder = await BookmarkFolder.insertOrUpdateBookmarkedPage(pageId, userId, folderId);
  400. logger.debug('bookmark added to folder', bookmarkFolder);
  401. return res.apiv3({ bookmarkFolder });
  402. }
  403. catch (err) {
  404. logger.error(err);
  405. return res.apiv3Err(err, 500);
  406. }
  407. });
  408. /**
  409. * @swagger
  410. *
  411. * /bookmark-folder/update-bookmark:
  412. * put:
  413. * tags: [BookmarkFolders]
  414. * operationId: updateBookmarkInFolder
  415. * security:
  416. * - bearer: []
  417. * - accessTokenInQuery: []
  418. * summary: Update bookmark in folder
  419. * description: Update a bookmark in a folder
  420. * requestBody:
  421. * content:
  422. * application/json:
  423. * schema:
  424. * properties:
  425. * pageId:
  426. * type: string
  427. * description: Page ID
  428. * nullable: false
  429. * status:
  430. * type: string
  431. * description: Bookmark status
  432. * responses:
  433. * 200:
  434. * description: Resources are available
  435. * content:
  436. * application/json:
  437. * schema:
  438. * properties:
  439. * bookmarkFolder:
  440. * type: object
  441. * $ref: '#/components/schemas/BookmarkFolder'
  442. */
  443. router.put('/update-bookmark',
  444. accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmark, async(req, res) => {
  445. const { pageId, status } = req.body;
  446. const userId = req.user?._id;
  447. try {
  448. const bookmarkFolder = await BookmarkFolder.updateBookmark(pageId, status, userId);
  449. return res.apiv3({ bookmarkFolder });
  450. }
  451. catch (err) {
  452. logger.error(err);
  453. return res.apiv3Err(err, 500);
  454. }
  455. });
  456. return router;
  457. };