bookmark-folder.ts 17 KB

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