boards.js 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417
  1. Boards = new Mongo.Collection('boards');
  2. /**
  3. * This is a Board.
  4. */
  5. Boards.attachSchema(
  6. new SimpleSchema({
  7. title: {
  8. /**
  9. * The title of the board
  10. */
  11. type: String,
  12. },
  13. slug: {
  14. /**
  15. * The title slugified.
  16. */
  17. type: String,
  18. // eslint-disable-next-line consistent-return
  19. autoValue() {
  20. // XXX We need to improve slug management. Only the id should be necessary
  21. // to identify a board in the code.
  22. // XXX If the board title is updated, the slug should also be updated.
  23. // In some cases (Chinese and Japanese for instance) the `getSlug` function
  24. // return an empty string. This is causes bugs in our application so we set
  25. // a default slug in this case.
  26. if (this.isInsert && !this.isSet) {
  27. let slug = 'board';
  28. const title = this.field('title');
  29. if (title.isSet) {
  30. slug = getSlug(title.value) || slug;
  31. }
  32. return slug;
  33. }
  34. },
  35. },
  36. archived: {
  37. /**
  38. * Is the board archived?
  39. */
  40. type: Boolean,
  41. // eslint-disable-next-line consistent-return
  42. autoValue() {
  43. if (this.isInsert && !this.isSet) {
  44. return false;
  45. }
  46. },
  47. },
  48. createdAt: {
  49. /**
  50. * Creation time of the board
  51. */
  52. type: Date,
  53. // eslint-disable-next-line consistent-return
  54. autoValue() {
  55. if (this.isInsert) {
  56. return new Date();
  57. } else if (this.isUpsert) {
  58. return { $setOnInsert: new Date() };
  59. } else {
  60. this.unset();
  61. }
  62. },
  63. },
  64. // XXX Inconsistent field naming
  65. modifiedAt: {
  66. /**
  67. * Last modification time of the board
  68. */
  69. type: Date,
  70. optional: true,
  71. // eslint-disable-next-line consistent-return
  72. autoValue() {
  73. if (this.isInsert || this.isUpsert || this.isUpdate) {
  74. return new Date();
  75. } else {
  76. this.unset();
  77. }
  78. },
  79. },
  80. // De-normalized number of users that have starred this board
  81. stars: {
  82. /**
  83. * How many stars the board has
  84. */
  85. type: Number,
  86. // eslint-disable-next-line consistent-return
  87. autoValue() {
  88. if (this.isInsert) {
  89. return 0;
  90. }
  91. },
  92. },
  93. // De-normalized label system
  94. labels: {
  95. /**
  96. * List of labels attached to a board
  97. */
  98. type: [Object],
  99. // eslint-disable-next-line consistent-return
  100. autoValue() {
  101. if (this.isInsert && !this.isSet) {
  102. const colors = Boards.simpleSchema()._schema['labels.$.color']
  103. .allowedValues;
  104. const defaultLabelsColors = _.clone(colors).splice(0, 6);
  105. return defaultLabelsColors.map(color => ({
  106. color,
  107. _id: Random.id(6),
  108. name: '',
  109. }));
  110. }
  111. },
  112. },
  113. 'labels.$._id': {
  114. /**
  115. * Unique id of a label
  116. */
  117. // We don't specify that this field must be unique in the board because that
  118. // will cause performance penalties and is not necessary since this field is
  119. // always set on the server.
  120. // XXX Actually if we create a new label, the `_id` is set on the client
  121. // without being overwritten by the server, could it be a problem?
  122. type: String,
  123. },
  124. 'labels.$.name': {
  125. /**
  126. * Name of a label
  127. */
  128. type: String,
  129. optional: true,
  130. },
  131. 'labels.$.color': {
  132. /**
  133. * color of a label.
  134. *
  135. * Can be amongst `green`, `yellow`, `orange`, `red`, `purple`,
  136. * `blue`, `sky`, `lime`, `pink`, `black`,
  137. * `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`,
  138. * `slateblue`, `magenta`, `gold`, `navy`, `gray`,
  139. * `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`
  140. */
  141. type: String,
  142. allowedValues: [
  143. 'green',
  144. 'yellow',
  145. 'orange',
  146. 'red',
  147. 'purple',
  148. 'blue',
  149. 'sky',
  150. 'lime',
  151. 'pink',
  152. 'black',
  153. 'silver',
  154. 'peachpuff',
  155. 'crimson',
  156. 'plum',
  157. 'darkgreen',
  158. 'slateblue',
  159. 'magenta',
  160. 'gold',
  161. 'navy',
  162. 'gray',
  163. 'saddlebrown',
  164. 'paleturquoise',
  165. 'mistyrose',
  166. 'indigo',
  167. ],
  168. },
  169. // XXX We might want to maintain more informations under the member sub-
  170. // documents like de-normalized meta-data (the date the member joined the
  171. // board, the number of contributions, etc.).
  172. members: {
  173. /**
  174. * List of members of a board
  175. */
  176. type: [Object],
  177. // eslint-disable-next-line consistent-return
  178. autoValue() {
  179. if (this.isInsert && !this.isSet) {
  180. return [
  181. {
  182. userId: this.userId,
  183. isAdmin: true,
  184. isActive: true,
  185. isNoComments: false,
  186. isCommentOnly: false,
  187. },
  188. ];
  189. }
  190. },
  191. },
  192. 'members.$.userId': {
  193. /**
  194. * The uniq ID of the member
  195. */
  196. type: String,
  197. },
  198. 'members.$.isAdmin': {
  199. /**
  200. * Is the member an admin of the board?
  201. */
  202. type: Boolean,
  203. },
  204. 'members.$.isActive': {
  205. /**
  206. * Is the member active?
  207. */
  208. type: Boolean,
  209. },
  210. 'members.$.isNoComments': {
  211. /**
  212. * Is the member not allowed to make comments
  213. */
  214. type: Boolean,
  215. optional: true,
  216. },
  217. 'members.$.isCommentOnly': {
  218. /**
  219. * Is the member only allowed to comment on the board
  220. */
  221. type: Boolean,
  222. optional: true,
  223. },
  224. permission: {
  225. /**
  226. * visibility of the board
  227. */
  228. type: String,
  229. allowedValues: ['public', 'private'],
  230. },
  231. color: {
  232. /**
  233. * The color of the board.
  234. */
  235. type: String,
  236. allowedValues: [
  237. 'belize',
  238. 'nephritis',
  239. 'pomegranate',
  240. 'pumpkin',
  241. 'wisteria',
  242. 'moderatepink',
  243. 'strongcyan',
  244. 'limegreen',
  245. 'midnight',
  246. 'dark',
  247. 'relax',
  248. 'corteza',
  249. ],
  250. // eslint-disable-next-line consistent-return
  251. autoValue() {
  252. if (this.isInsert && !this.isSet) {
  253. return Boards.simpleSchema()._schema.color.allowedValues[0];
  254. }
  255. },
  256. },
  257. description: {
  258. /**
  259. * The description of the board
  260. */
  261. type: String,
  262. optional: true,
  263. },
  264. subtasksDefaultBoardId: {
  265. /**
  266. * The default board ID assigned to subtasks.
  267. */
  268. type: String,
  269. optional: true,
  270. defaultValue: null,
  271. },
  272. subtasksDefaultListId: {
  273. /**
  274. * The default List ID assigned to subtasks.
  275. */
  276. type: String,
  277. optional: true,
  278. defaultValue: null,
  279. },
  280. allowsSubtasks: {
  281. /**
  282. * Does the board allows subtasks?
  283. */
  284. type: Boolean,
  285. defaultValue: true,
  286. },
  287. presentParentTask: {
  288. /**
  289. * Controls how to present the parent task:
  290. *
  291. * - `prefix-with-full-path`: add a prefix with the full path
  292. * - `prefix-with-parent`: add a prefisx with the parent name
  293. * - `subtext-with-full-path`: add a subtext with the full path
  294. * - `subtext-with-parent`: add a subtext with the parent name
  295. * - `no-parent`: does not show the parent at all
  296. */
  297. type: String,
  298. allowedValues: [
  299. 'prefix-with-full-path',
  300. 'prefix-with-parent',
  301. 'subtext-with-full-path',
  302. 'subtext-with-parent',
  303. 'no-parent',
  304. ],
  305. optional: true,
  306. defaultValue: 'no-parent',
  307. },
  308. startAt: {
  309. /**
  310. * Starting date of the board.
  311. */
  312. type: Date,
  313. optional: true,
  314. },
  315. dueAt: {
  316. /**
  317. * Due date of the board.
  318. */
  319. type: Date,
  320. optional: true,
  321. },
  322. endAt: {
  323. /**
  324. * End date of the board.
  325. */
  326. type: Date,
  327. optional: true,
  328. },
  329. spentTime: {
  330. /**
  331. * Time spent in the board.
  332. */
  333. type: Number,
  334. decimal: true,
  335. optional: true,
  336. },
  337. isOvertime: {
  338. /**
  339. * Is the board overtimed?
  340. */
  341. type: Boolean,
  342. defaultValue: false,
  343. optional: true,
  344. },
  345. type: {
  346. /**
  347. * The type of board
  348. */
  349. type: String,
  350. defaultValue: 'board',
  351. },
  352. }),
  353. );
  354. Boards.helpers({
  355. copy() {
  356. const oldId = this._id;
  357. delete this._id;
  358. const _id = Boards.insert(this);
  359. // Copy all swimlanes in board
  360. Swimlanes.find({
  361. boardId: oldId,
  362. archived: false,
  363. }).forEach(swimlane => {
  364. swimlane.type = 'swimlane';
  365. swimlane.copy(_id);
  366. });
  367. },
  368. /**
  369. * Is supplied user authorized to view this board?
  370. */
  371. isVisibleBy(user) {
  372. if (this.isPublic()) {
  373. // public boards are visible to everyone
  374. return true;
  375. } else {
  376. // otherwise you have to be logged-in and active member
  377. return user && this.isActiveMember(user._id);
  378. }
  379. },
  380. /**
  381. * Is the user one of the active members of the board?
  382. *
  383. * @param userId
  384. * @returns {boolean} the member that matches, or undefined/false
  385. */
  386. isActiveMember(userId) {
  387. if (userId) {
  388. return this.members.find(
  389. member => member.userId === userId && member.isActive,
  390. );
  391. } else {
  392. return false;
  393. }
  394. },
  395. isPublic() {
  396. return this.permission === 'public';
  397. },
  398. cards() {
  399. return Cards.find(
  400. { boardId: this._id, archived: false },
  401. { sort: { title: 1 } },
  402. );
  403. },
  404. lists() {
  405. return Lists.find({ boardId: this._id }, { sort: { sort: 1 } });
  406. },
  407. nullSortLists() {
  408. return Lists.find({
  409. boardId: this._id,
  410. archived: false,
  411. sort: { $eq: null },
  412. });
  413. },
  414. swimlanes() {
  415. return Swimlanes.find(
  416. { boardId: this._id, archived: false },
  417. { sort: { sort: 1 } },
  418. );
  419. },
  420. nextSwimlane(swimlane) {
  421. return Swimlanes.findOne(
  422. {
  423. boardId: this._id,
  424. archived: false,
  425. sort: { $gte: swimlane.sort },
  426. _id: { $ne: swimlane._id },
  427. },
  428. {
  429. sort: { sort: 1 },
  430. },
  431. );
  432. },
  433. nullSortSwimlanes() {
  434. return Swimlanes.find({
  435. boardId: this._id,
  436. archived: false,
  437. sort: { $eq: null },
  438. });
  439. },
  440. hasOvertimeCards() {
  441. const card = Cards.findOne({
  442. isOvertime: true,
  443. boardId: this._id,
  444. archived: false,
  445. });
  446. return card !== undefined;
  447. },
  448. hasSpentTimeCards() {
  449. const card = Cards.findOne({
  450. spentTime: { $gt: 0 },
  451. boardId: this._id,
  452. archived: false,
  453. });
  454. return card !== undefined;
  455. },
  456. activities() {
  457. return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 } });
  458. },
  459. activeMembers() {
  460. return _.where(this.members, { isActive: true });
  461. },
  462. activeAdmins() {
  463. return _.where(this.members, { isActive: true, isAdmin: true });
  464. },
  465. memberUsers() {
  466. return Users.find({ _id: { $in: _.pluck(this.members, 'userId') } });
  467. },
  468. getLabel(name, color) {
  469. return _.findWhere(this.labels, { name, color });
  470. },
  471. getLabelById(labelId) {
  472. return _.findWhere(this.labels, { _id: labelId });
  473. },
  474. labelIndex(labelId) {
  475. return _.pluck(this.labels, '_id').indexOf(labelId);
  476. },
  477. memberIndex(memberId) {
  478. return _.pluck(this.members, 'userId').indexOf(memberId);
  479. },
  480. hasMember(memberId) {
  481. return !!_.findWhere(this.members, { userId: memberId, isActive: true });
  482. },
  483. hasAdmin(memberId) {
  484. return !!_.findWhere(this.members, {
  485. userId: memberId,
  486. isActive: true,
  487. isAdmin: true,
  488. });
  489. },
  490. hasNoComments(memberId) {
  491. return !!_.findWhere(this.members, {
  492. userId: memberId,
  493. isActive: true,
  494. isAdmin: false,
  495. isNoComments: true,
  496. });
  497. },
  498. hasCommentOnly(memberId) {
  499. return !!_.findWhere(this.members, {
  500. userId: memberId,
  501. isActive: true,
  502. isAdmin: false,
  503. isCommentOnly: true,
  504. });
  505. },
  506. absoluteUrl() {
  507. return FlowRouter.url('board', { id: this._id, slug: this.slug });
  508. },
  509. colorClass() {
  510. return `board-color-${this.color}`;
  511. },
  512. customFields() {
  513. return CustomFields.find(
  514. { boardIds: { $in: [this._id] } },
  515. { sort: { name: 1 } },
  516. );
  517. },
  518. // XXX currently mutations return no value so we have an issue when using addLabel in import
  519. // XXX waiting on https://github.com/mquandalle/meteor-collection-mutations/issues/1 to remove...
  520. pushLabel(name, color) {
  521. const _id = Random.id(6);
  522. Boards.direct.update(this._id, { $push: { labels: { _id, name, color } } });
  523. return _id;
  524. },
  525. searchBoards(term) {
  526. check(term, Match.OneOf(String, null, undefined));
  527. const query = { boardId: this._id };
  528. query.type = 'cardType-linkedBoard';
  529. query.archived = false;
  530. const projection = { limit: 10, sort: { createdAt: -1 } };
  531. if (term) {
  532. const regex = new RegExp(term, 'i');
  533. query.$or = [{ title: regex }, { description: regex }];
  534. }
  535. return Cards.find(query, projection);
  536. },
  537. searchSwimlanes(term) {
  538. check(term, Match.OneOf(String, null, undefined));
  539. const query = { boardId: this._id };
  540. if (this.isTemplatesBoard()) {
  541. query.type = 'template-swimlane';
  542. query.archived = false;
  543. } else {
  544. query.type = { $nin: ['template-swimlane'] };
  545. }
  546. const projection = { limit: 10, sort: { createdAt: -1 } };
  547. if (term) {
  548. const regex = new RegExp(term, 'i');
  549. query.$or = [{ title: regex }, { description: regex }];
  550. }
  551. return Swimlanes.find(query, projection);
  552. },
  553. searchLists(term) {
  554. check(term, Match.OneOf(String, null, undefined));
  555. const query = { boardId: this._id };
  556. if (this.isTemplatesBoard()) {
  557. query.type = 'template-list';
  558. query.archived = false;
  559. } else {
  560. query.type = { $nin: ['template-list'] };
  561. }
  562. const projection = { limit: 10, sort: { createdAt: -1 } };
  563. if (term) {
  564. const regex = new RegExp(term, 'i');
  565. query.$or = [{ title: regex }, { description: regex }];
  566. }
  567. return Lists.find(query, projection);
  568. },
  569. searchCards(term, excludeLinked) {
  570. check(term, Match.OneOf(String, null, undefined));
  571. const query = { boardId: this._id };
  572. if (excludeLinked) {
  573. query.linkedId = null;
  574. }
  575. if (this.isTemplatesBoard()) {
  576. query.type = 'template-card';
  577. query.archived = false;
  578. } else {
  579. query.type = { $nin: ['template-card'] };
  580. }
  581. const projection = { limit: 10, sort: { createdAt: -1 } };
  582. if (term) {
  583. const regex = new RegExp(term, 'i');
  584. query.$or = [{ title: regex }, { description: regex }];
  585. }
  586. return Cards.find(query, projection);
  587. },
  588. // A board alwasy has another board where it deposits subtasks of thasks
  589. // that belong to itself.
  590. getDefaultSubtasksBoardId() {
  591. if (
  592. this.subtasksDefaultBoardId === null ||
  593. this.subtasksDefaultBoardId === undefined
  594. ) {
  595. this.subtasksDefaultBoardId = Boards.insert({
  596. title: `^${this.title}^`,
  597. permission: this.permission,
  598. members: this.members,
  599. color: this.color,
  600. description: TAPi18n.__('default-subtasks-board', {
  601. board: this.title,
  602. }),
  603. });
  604. Swimlanes.insert({
  605. title: TAPi18n.__('default'),
  606. boardId: this.subtasksDefaultBoardId,
  607. });
  608. Boards.update(this._id, {
  609. $set: {
  610. subtasksDefaultBoardId: this.subtasksDefaultBoardId,
  611. },
  612. });
  613. }
  614. return this.subtasksDefaultBoardId;
  615. },
  616. getDefaultSubtasksBoard() {
  617. return Boards.findOne(this.getDefaultSubtasksBoardId());
  618. },
  619. getDefaultSubtasksListId() {
  620. if (
  621. this.subtasksDefaultListId === null ||
  622. this.subtasksDefaultListId === undefined
  623. ) {
  624. this.subtasksDefaultListId = Lists.insert({
  625. title: TAPi18n.__('queue'),
  626. boardId: this._id,
  627. });
  628. this.setSubtasksDefaultListId(this.subtasksDefaultListId);
  629. }
  630. return this.subtasksDefaultListId;
  631. },
  632. getDefaultSubtasksList() {
  633. return Lists.findOne(this.getDefaultSubtasksListId());
  634. },
  635. getDefaultSwimline() {
  636. let result = Swimlanes.findOne({ boardId: this._id });
  637. if (result === undefined) {
  638. Swimlanes.insert({
  639. title: TAPi18n.__('default'),
  640. boardId: this._id,
  641. });
  642. result = Swimlanes.findOne({ boardId: this._id });
  643. }
  644. return result;
  645. },
  646. cardsInInterval(start, end) {
  647. return Cards.find({
  648. boardId: this._id,
  649. $or: [
  650. {
  651. startAt: {
  652. $lte: start,
  653. },
  654. endAt: {
  655. $gte: start,
  656. },
  657. },
  658. {
  659. startAt: {
  660. $lte: end,
  661. },
  662. endAt: {
  663. $gte: end,
  664. },
  665. },
  666. {
  667. startAt: {
  668. $gte: start,
  669. },
  670. endAt: {
  671. $lte: end,
  672. },
  673. },
  674. ],
  675. });
  676. },
  677. isTemplateBoard() {
  678. return this.type === 'template-board';
  679. },
  680. isTemplatesBoard() {
  681. return this.type === 'template-container';
  682. },
  683. });
  684. Boards.mutations({
  685. archive() {
  686. return { $set: { archived: true } };
  687. },
  688. restore() {
  689. return { $set: { archived: false } };
  690. },
  691. rename(title) {
  692. return { $set: { title } };
  693. },
  694. setDescription(description) {
  695. return { $set: { description } };
  696. },
  697. setColor(color) {
  698. return { $set: { color } };
  699. },
  700. setVisibility(visibility) {
  701. return { $set: { permission: visibility } };
  702. },
  703. addLabel(name, color) {
  704. // If label with the same name and color already exists we don't want to
  705. // create another one because they would be indistinguishable in the UI
  706. // (they would still have different `_id` but that is not exposed to the
  707. // user).
  708. if (!this.getLabel(name, color)) {
  709. const _id = Random.id(6);
  710. return { $push: { labels: { _id, name, color } } };
  711. }
  712. return {};
  713. },
  714. editLabel(labelId, name, color) {
  715. if (!this.getLabel(name, color)) {
  716. const labelIndex = this.labelIndex(labelId);
  717. return {
  718. $set: {
  719. [`labels.${labelIndex}.name`]: name,
  720. [`labels.${labelIndex}.color`]: color,
  721. },
  722. };
  723. }
  724. return {};
  725. },
  726. removeLabel(labelId) {
  727. return { $pull: { labels: { _id: labelId } } };
  728. },
  729. changeOwnership(fromId, toId) {
  730. const memberIndex = this.memberIndex(fromId);
  731. return {
  732. $set: {
  733. [`members.${memberIndex}.userId`]: toId,
  734. },
  735. };
  736. },
  737. addMember(memberId) {
  738. const memberIndex = this.memberIndex(memberId);
  739. if (memberIndex >= 0) {
  740. return {
  741. $set: {
  742. [`members.${memberIndex}.isActive`]: true,
  743. },
  744. };
  745. }
  746. return {
  747. $push: {
  748. members: {
  749. userId: memberId,
  750. isAdmin: false,
  751. isActive: true,
  752. isNoComments: false,
  753. isCommentOnly: false,
  754. },
  755. },
  756. };
  757. },
  758. removeMember(memberId) {
  759. const memberIndex = this.memberIndex(memberId);
  760. // we do not allow the only one admin to be removed
  761. const allowRemove =
  762. !this.members[memberIndex].isAdmin || this.activeAdmins().length > 1;
  763. if (!allowRemove) {
  764. return {
  765. $set: {
  766. [`members.${memberIndex}.isActive`]: true,
  767. },
  768. };
  769. }
  770. return {
  771. $set: {
  772. [`members.${memberIndex}.isActive`]: false,
  773. [`members.${memberIndex}.isAdmin`]: false,
  774. },
  775. };
  776. },
  777. setMemberPermission(
  778. memberId,
  779. isAdmin,
  780. isNoComments,
  781. isCommentOnly,
  782. currentUserId = Meteor.userId(),
  783. ) {
  784. const memberIndex = this.memberIndex(memberId);
  785. // do not allow change permission of self
  786. if (memberId === currentUserId) {
  787. isAdmin = this.members[memberIndex].isAdmin;
  788. }
  789. return {
  790. $set: {
  791. [`members.${memberIndex}.isAdmin`]: isAdmin,
  792. [`members.${memberIndex}.isNoComments`]: isNoComments,
  793. [`members.${memberIndex}.isCommentOnly`]: isCommentOnly,
  794. },
  795. };
  796. },
  797. setAllowsSubtasks(allowsSubtasks) {
  798. return { $set: { allowsSubtasks } };
  799. },
  800. setSubtasksDefaultBoardId(subtasksDefaultBoardId) {
  801. return { $set: { subtasksDefaultBoardId } };
  802. },
  803. setSubtasksDefaultListId(subtasksDefaultListId) {
  804. return { $set: { subtasksDefaultListId } };
  805. },
  806. setPresentParentTask(presentParentTask) {
  807. return { $set: { presentParentTask } };
  808. },
  809. });
  810. function boardRemover(userId, doc) {
  811. [Cards, Lists, Swimlanes, Integrations, Rules, Activities].forEach(
  812. element => {
  813. element.remove({ boardId: doc._id });
  814. },
  815. );
  816. }
  817. if (Meteor.isServer) {
  818. Boards.allow({
  819. insert: Meteor.userId,
  820. update: allowIsBoardAdmin,
  821. remove: allowIsBoardAdmin,
  822. fetch: ['members'],
  823. });
  824. // The number of users that have starred this board is managed by trusted code
  825. // and the user is not allowed to update it
  826. Boards.deny({
  827. update(userId, board, fieldNames) {
  828. return _.contains(fieldNames, 'stars');
  829. },
  830. fetch: [],
  831. });
  832. // We can't remove a member if it is the last administrator
  833. Boards.deny({
  834. update(userId, doc, fieldNames, modifier) {
  835. if (!_.contains(fieldNames, 'members')) return false;
  836. // We only care in case of a $pull operation, ie remove a member
  837. if (!_.isObject(modifier.$pull && modifier.$pull.members)) return false;
  838. // If there is more than one admin, it's ok to remove anyone
  839. const nbAdmins = _.where(doc.members, { isActive: true, isAdmin: true })
  840. .length;
  841. if (nbAdmins > 1) return false;
  842. // If all the previous conditions were verified, we can't remove
  843. // a user if it's an admin
  844. const removedMemberId = modifier.$pull.members.userId;
  845. return Boolean(
  846. _.findWhere(doc.members, {
  847. userId: removedMemberId,
  848. isAdmin: true,
  849. }),
  850. );
  851. },
  852. fetch: ['members'],
  853. });
  854. Meteor.methods({
  855. quitBoard(boardId) {
  856. check(boardId, String);
  857. const board = Boards.findOne(boardId);
  858. if (board) {
  859. const userId = Meteor.userId();
  860. const index = board.memberIndex(userId);
  861. if (index >= 0) {
  862. board.removeMember(userId);
  863. return true;
  864. } else throw new Meteor.Error('error-board-notAMember');
  865. } else throw new Meteor.Error('error-board-doesNotExist');
  866. },
  867. acceptInvite(boardId) {
  868. check(boardId, String);
  869. const board = Boards.findOne(boardId);
  870. if (!board) {
  871. throw new Meteor.Error('error-board-doesNotExist');
  872. }
  873. Meteor.users.update(Meteor.userId(), {
  874. $pull: {
  875. 'profile.invitedBoards': boardId,
  876. },
  877. });
  878. },
  879. });
  880. Meteor.methods({
  881. archiveBoard(boardId) {
  882. check(boardId, String);
  883. const board = Boards.findOne(boardId);
  884. if (board) {
  885. const userId = Meteor.userId();
  886. const index = board.memberIndex(userId);
  887. if (index >= 0) {
  888. board.archive();
  889. return true;
  890. } else throw new Meteor.Error('error-board-notAMember');
  891. } else throw new Meteor.Error('error-board-doesNotExist');
  892. },
  893. });
  894. }
  895. if (Meteor.isServer) {
  896. // Let MongoDB ensure that a member is not included twice in the same board
  897. Meteor.startup(() => {
  898. Boards._collection._ensureIndex({ modifiedAt: -1 });
  899. Boards._collection._ensureIndex(
  900. {
  901. _id: 1,
  902. 'members.userId': 1,
  903. },
  904. { unique: true },
  905. );
  906. Boards._collection._ensureIndex({ 'members.userId': 1 });
  907. });
  908. // Genesis: the first activity of the newly created board
  909. Boards.after.insert((userId, doc) => {
  910. Activities.insert({
  911. userId,
  912. type: 'board',
  913. activityTypeId: doc._id,
  914. activityType: 'createBoard',
  915. boardId: doc._id,
  916. });
  917. });
  918. // If the user remove one label from a board, we cant to remove reference of
  919. // this label in any card of this board.
  920. Boards.after.update((userId, doc, fieldNames, modifier) => {
  921. if (
  922. !_.contains(fieldNames, 'labels') ||
  923. !modifier.$pull ||
  924. !modifier.$pull.labels ||
  925. !modifier.$pull.labels._id
  926. ) {
  927. return;
  928. }
  929. const removedLabelId = modifier.$pull.labels._id;
  930. Cards.update(
  931. { boardId: doc._id },
  932. {
  933. $pull: {
  934. labelIds: removedLabelId,
  935. },
  936. },
  937. { multi: true },
  938. );
  939. });
  940. const foreachRemovedMember = (doc, modifier, callback) => {
  941. Object.keys(modifier).forEach(set => {
  942. if (modifier[set] !== false) {
  943. return;
  944. }
  945. const parts = set.split('.');
  946. if (
  947. parts.length === 3 &&
  948. parts[0] === 'members' &&
  949. parts[2] === 'isActive'
  950. ) {
  951. callback(doc.members[parts[1]].userId);
  952. }
  953. });
  954. };
  955. // Remove a member from all objects of the board before leaving the board
  956. Boards.before.update((userId, doc, fieldNames, modifier) => {
  957. if (!_.contains(fieldNames, 'members')) {
  958. return;
  959. }
  960. if (modifier.$set) {
  961. const boardId = doc._id;
  962. foreachRemovedMember(doc, modifier.$set, memberId => {
  963. Cards.update(
  964. { boardId },
  965. {
  966. $pull: {
  967. members: memberId,
  968. watchers: memberId,
  969. },
  970. },
  971. { multi: true },
  972. );
  973. Lists.update(
  974. { boardId },
  975. {
  976. $pull: {
  977. watchers: memberId,
  978. },
  979. },
  980. { multi: true },
  981. );
  982. const board = Boards._transform(doc);
  983. board.setWatcher(memberId, false);
  984. // Remove board from users starred list
  985. if (!board.isPublic()) {
  986. Users.update(memberId, {
  987. $pull: {
  988. 'profile.starredBoards': boardId,
  989. },
  990. });
  991. }
  992. });
  993. }
  994. });
  995. Boards.before.remove((userId, doc) => {
  996. boardRemover(userId, doc);
  997. // Add removeBoard activity to keep it
  998. Activities.insert({
  999. userId,
  1000. type: 'board',
  1001. activityTypeId: doc._id,
  1002. activityType: 'removeBoard',
  1003. boardId: doc._id,
  1004. });
  1005. });
  1006. // Add a new activity if we add or remove a member to the board
  1007. Boards.after.update((userId, doc, fieldNames, modifier) => {
  1008. if (!_.contains(fieldNames, 'members')) {
  1009. return;
  1010. }
  1011. // Say hello to the new member
  1012. if (modifier.$push && modifier.$push.members) {
  1013. const memberId = modifier.$push.members.userId;
  1014. Activities.insert({
  1015. userId,
  1016. memberId,
  1017. type: 'member',
  1018. activityType: 'addBoardMember',
  1019. boardId: doc._id,
  1020. });
  1021. }
  1022. // Say goodbye to the former member
  1023. if (modifier.$set) {
  1024. foreachRemovedMember(doc, modifier.$set, memberId => {
  1025. Activities.insert({
  1026. userId,
  1027. memberId,
  1028. type: 'member',
  1029. activityType: 'removeBoardMember',
  1030. boardId: doc._id,
  1031. });
  1032. });
  1033. }
  1034. });
  1035. }
  1036. //BOARDS REST API
  1037. if (Meteor.isServer) {
  1038. /**
  1039. * @operation get_boards_from_user
  1040. * @summary Get all boards attached to a user
  1041. *
  1042. * @param {string} userId the ID of the user to retrieve the data
  1043. * @return_type [{_id: string,
  1044. title: string}]
  1045. */
  1046. JsonRoutes.add('GET', '/api/users/:userId/boards', function(req, res) {
  1047. try {
  1048. Authentication.checkLoggedIn(req.userId);
  1049. const paramUserId = req.params.userId;
  1050. // A normal user should be able to see their own boards,
  1051. // admins can access boards of any user
  1052. Authentication.checkAdminOrCondition(
  1053. req.userId,
  1054. req.userId === paramUserId,
  1055. );
  1056. const data = Boards.find(
  1057. {
  1058. archived: false,
  1059. 'members.userId': paramUserId,
  1060. },
  1061. {
  1062. sort: ['title'],
  1063. },
  1064. ).map(function(board) {
  1065. return {
  1066. _id: board._id,
  1067. title: board.title,
  1068. };
  1069. });
  1070. JsonRoutes.sendResult(res, { code: 200, data });
  1071. } catch (error) {
  1072. JsonRoutes.sendResult(res, {
  1073. code: 200,
  1074. data: error,
  1075. });
  1076. }
  1077. });
  1078. /**
  1079. * @operation get_public_boards
  1080. * @summary Get all public boards
  1081. *
  1082. * @return_type [{_id: string,
  1083. title: string}]
  1084. */
  1085. JsonRoutes.add('GET', '/api/boards', function(req, res) {
  1086. try {
  1087. Authentication.checkUserId(req.userId);
  1088. JsonRoutes.sendResult(res, {
  1089. code: 200,
  1090. data: Boards.find({ permission: 'public' }).map(function(doc) {
  1091. return {
  1092. _id: doc._id,
  1093. title: doc.title,
  1094. };
  1095. }),
  1096. });
  1097. } catch (error) {
  1098. JsonRoutes.sendResult(res, {
  1099. code: 200,
  1100. data: error,
  1101. });
  1102. }
  1103. });
  1104. /**
  1105. * @operation get_board
  1106. * @summary Get the board with that particular ID
  1107. *
  1108. * @param {string} boardId the ID of the board to retrieve the data
  1109. * @return_type Boards
  1110. */
  1111. JsonRoutes.add('GET', '/api/boards/:boardId', function(req, res) {
  1112. try {
  1113. const id = req.params.boardId;
  1114. Authentication.checkBoardAccess(req.userId, id);
  1115. JsonRoutes.sendResult(res, {
  1116. code: 200,
  1117. data: Boards.findOne({ _id: id }),
  1118. });
  1119. } catch (error) {
  1120. JsonRoutes.sendResult(res, {
  1121. code: 200,
  1122. data: error,
  1123. });
  1124. }
  1125. });
  1126. /**
  1127. * @operation new_board
  1128. * @summary Create a board
  1129. *
  1130. * @description This allows to create a board.
  1131. *
  1132. * The color has to be chosen between `belize`, `nephritis`, `pomegranate`,
  1133. * `pumpkin`, `wisteria`, `moderatepink`, `strongcyan`,
  1134. * `limegreen`, `midnight`, `dark`, `relax`, `corteza`:
  1135. *
  1136. * <img src="https://wekan.github.io/board-colors.png" width="40%" alt="Wekan logo" />
  1137. *
  1138. * @param {string} title the new title of the board
  1139. * @param {string} owner "ABCDE12345" <= User ID in Wekan.
  1140. * (Not username or email)
  1141. * @param {boolean} [isAdmin] is the owner an admin of the board (default true)
  1142. * @param {boolean} [isActive] is the board active (default true)
  1143. * @param {boolean} [isNoComments] disable comments (default false)
  1144. * @param {boolean} [isCommentOnly] only enable comments (default false)
  1145. * @param {string} [permission] "private" board <== Set to "public" if you
  1146. * want public Wekan board
  1147. * @param {string} [color] the color of the board
  1148. *
  1149. * @return_type {_id: string,
  1150. defaultSwimlaneId: string}
  1151. */
  1152. JsonRoutes.add('POST', '/api/boards', function(req, res) {
  1153. try {
  1154. Authentication.checkUserId(req.userId);
  1155. const id = Boards.insert({
  1156. title: req.body.title,
  1157. members: [
  1158. {
  1159. userId: req.body.owner,
  1160. isAdmin: req.body.isAdmin || true,
  1161. isActive: req.body.isActive || true,
  1162. isNoComments: req.body.isNoComments || false,
  1163. isCommentOnly: req.body.isCommentOnly || false,
  1164. },
  1165. ],
  1166. permission: req.body.permission || 'private',
  1167. color: req.body.color || 'belize',
  1168. });
  1169. const swimlaneId = Swimlanes.insert({
  1170. title: TAPi18n.__('default'),
  1171. boardId: id,
  1172. });
  1173. JsonRoutes.sendResult(res, {
  1174. code: 200,
  1175. data: {
  1176. _id: id,
  1177. defaultSwimlaneId: swimlaneId,
  1178. },
  1179. });
  1180. } catch (error) {
  1181. JsonRoutes.sendResult(res, {
  1182. code: 200,
  1183. data: error,
  1184. });
  1185. }
  1186. });
  1187. /**
  1188. * @operation delete_board
  1189. * @summary Delete a board
  1190. *
  1191. * @param {string} boardId the ID of the board
  1192. */
  1193. JsonRoutes.add('DELETE', '/api/boards/:boardId', function(req, res) {
  1194. try {
  1195. Authentication.checkUserId(req.userId);
  1196. const id = req.params.boardId;
  1197. Boards.remove({ _id: id });
  1198. JsonRoutes.sendResult(res, {
  1199. code: 200,
  1200. data: {
  1201. _id: id,
  1202. },
  1203. });
  1204. } catch (error) {
  1205. JsonRoutes.sendResult(res, {
  1206. code: 200,
  1207. data: error,
  1208. });
  1209. }
  1210. });
  1211. /**
  1212. * @operation add_board_label
  1213. * @summary Add a label to a board
  1214. *
  1215. * @description If the board doesn't have the name/color label, this function
  1216. * adds the label to the board.
  1217. *
  1218. * @param {string} boardId the board
  1219. * @param {string} color the color of the new label
  1220. * @param {string} name the name of the new label
  1221. *
  1222. * @return_type string
  1223. */
  1224. JsonRoutes.add('PUT', '/api/boards/:boardId/labels', function(req, res) {
  1225. Authentication.checkUserId(req.userId);
  1226. const id = req.params.boardId;
  1227. try {
  1228. if (req.body.hasOwnProperty('label')) {
  1229. const board = Boards.findOne({ _id: id });
  1230. const color = req.body.label.color;
  1231. const name = req.body.label.name;
  1232. const labelId = Random.id(6);
  1233. if (!board.getLabel(name, color)) {
  1234. Boards.direct.update(
  1235. { _id: id },
  1236. { $push: { labels: { _id: labelId, name, color } } },
  1237. );
  1238. JsonRoutes.sendResult(res, {
  1239. code: 200,
  1240. data: labelId,
  1241. });
  1242. } else {
  1243. JsonRoutes.sendResult(res, {
  1244. code: 200,
  1245. });
  1246. }
  1247. }
  1248. } catch (error) {
  1249. JsonRoutes.sendResult(res, {
  1250. data: error,
  1251. });
  1252. }
  1253. });
  1254. /**
  1255. * @operation set_board_member_permission
  1256. * @tag Users
  1257. * @summary Change the permission of a member of a board
  1258. *
  1259. * @param {string} boardId the ID of the board that we are changing
  1260. * @param {string} memberId the ID of the user to change permissions
  1261. * @param {boolean} isAdmin admin capability
  1262. * @param {boolean} isNoComments NoComments capability
  1263. * @param {boolean} isCommentOnly CommentsOnly capability
  1264. */
  1265. JsonRoutes.add('POST', '/api/boards/:boardId/members/:memberId', function(
  1266. req,
  1267. res,
  1268. ) {
  1269. try {
  1270. const boardId = req.params.boardId;
  1271. const memberId = req.params.memberId;
  1272. const { isAdmin, isNoComments, isCommentOnly } = req.body;
  1273. Authentication.checkBoardAccess(req.userId, boardId);
  1274. const board = Boards.findOne({ _id: boardId });
  1275. function isTrue(data) {
  1276. try {
  1277. return data.toLowerCase() === 'true';
  1278. } catch (error) {
  1279. return data;
  1280. }
  1281. }
  1282. const query = board.setMemberPermission(
  1283. memberId,
  1284. isTrue(isAdmin),
  1285. isTrue(isNoComments),
  1286. isTrue(isCommentOnly),
  1287. req.userId,
  1288. );
  1289. JsonRoutes.sendResult(res, {
  1290. code: 200,
  1291. data: query,
  1292. });
  1293. } catch (error) {
  1294. JsonRoutes.sendResult(res, {
  1295. code: 200,
  1296. data: error,
  1297. });
  1298. }
  1299. });
  1300. }
  1301. export default Boards;