lists.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { ALLOWED_COLORS } from '/config/const';
  3. import PositionHistory from './positionHistory';
  4. Lists = new Mongo.Collection('lists');
  5. /**
  6. * A list (column) in the Wekan board.
  7. */
  8. Lists.attachSchema(
  9. new SimpleSchema({
  10. title: {
  11. /**
  12. * the title of the list
  13. */
  14. type: String,
  15. },
  16. starred: {
  17. /**
  18. * if a list is stared
  19. * then we put it on the top
  20. */
  21. type: Boolean,
  22. optional: true,
  23. defaultValue: false,
  24. },
  25. archived: {
  26. /**
  27. * is the list archived
  28. */
  29. type: Boolean,
  30. // eslint-disable-next-line consistent-return
  31. autoValue() {
  32. if (this.isInsert && !this.isSet) {
  33. return false;
  34. }
  35. },
  36. },
  37. archivedAt: {
  38. /**
  39. * latest archiving date
  40. */
  41. type: Date,
  42. optional: true,
  43. },
  44. boardId: {
  45. /**
  46. * the board associated to this list
  47. */
  48. type: String,
  49. },
  50. swimlaneId: {
  51. /**
  52. * the swimlane associated to this list. Optional for backward compatibility
  53. */
  54. type: String,
  55. optional: true,
  56. defaultValue: '',
  57. },
  58. createdAt: {
  59. /**
  60. * creation date
  61. */
  62. type: Date,
  63. // eslint-disable-next-line consistent-return
  64. autoValue() {
  65. if (this.isInsert) {
  66. return new Date();
  67. } else if (this.isUpsert) {
  68. return { $setOnInsert: new Date() };
  69. } else {
  70. this.unset();
  71. }
  72. },
  73. },
  74. sort: {
  75. /**
  76. * is the list sorted
  77. */
  78. type: Number,
  79. decimal: true,
  80. // XXX We should probably provide a default
  81. optional: true,
  82. },
  83. updatedAt: {
  84. /**
  85. * last update of the list
  86. */
  87. type: Date,
  88. optional: true,
  89. // eslint-disable-next-line consistent-return
  90. autoValue() {
  91. if (this.isUpdate || this.isUpsert || this.isInsert) {
  92. return new Date();
  93. } else {
  94. this.unset();
  95. }
  96. },
  97. },
  98. modifiedAt: {
  99. type: Date,
  100. denyUpdate: false,
  101. // eslint-disable-next-line consistent-return
  102. autoValue() {
  103. // this is redundant with updatedAt
  104. /*if (this.isInsert || this.isUpsert || this.isUpdate) {
  105. return new Date();
  106. } else {
  107. this.unset();
  108. }*/
  109. if (!this.isSet) {
  110. return new Date();
  111. }
  112. },
  113. },
  114. wipLimit: {
  115. /**
  116. * WIP object, see below
  117. */
  118. type: Object,
  119. optional: true,
  120. },
  121. 'wipLimit.value': {
  122. /**
  123. * value of the WIP
  124. */
  125. type: Number,
  126. decimal: false,
  127. defaultValue: 1,
  128. },
  129. 'wipLimit.enabled': {
  130. /**
  131. * is the WIP enabled
  132. */
  133. type: Boolean,
  134. defaultValue: false,
  135. },
  136. 'wipLimit.soft': {
  137. /**
  138. * is the WIP a soft or hard requirement
  139. */
  140. type: Boolean,
  141. defaultValue: false,
  142. },
  143. color: {
  144. /**
  145. * the color of the list
  146. */
  147. type: String,
  148. optional: true,
  149. // silver is the default
  150. allowedValues: ALLOWED_COLORS,
  151. },
  152. type: {
  153. /**
  154. * The type of list
  155. */
  156. type: String,
  157. defaultValue: 'list',
  158. },
  159. width: {
  160. /**
  161. * The width of the list in pixels (100-1000).
  162. * Default width is 272 pixels.
  163. */
  164. type: Number,
  165. optional: true,
  166. defaultValue: 272,
  167. custom() {
  168. const w = this.value;
  169. if (w < 100 || w > 1000) {
  170. return 'widthOutOfRange';
  171. }
  172. },
  173. },
  174. // NOTE: collapsed state is per-user only, stored in user profile.collapsedLists
  175. // and localStorage for non-logged-in users
  176. // NOTE: width is per-board (shared with all users), stored in lists.width
  177. }),
  178. );
  179. Lists.allow({
  180. insert(userId, doc) {
  181. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  182. },
  183. update(userId, doc) {
  184. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  185. },
  186. remove(userId, doc) {
  187. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  188. },
  189. fetch: ['boardId'],
  190. });
  191. Lists.helpers({
  192. copy(boardId, swimlaneId) {
  193. const oldId = this._id;
  194. const oldSwimlaneId = this.swimlaneId || null;
  195. this.boardId = boardId;
  196. this.swimlaneId = swimlaneId;
  197. let _id = null;
  198. const existingListWithSameName = ReactiveCache.getList({
  199. boardId,
  200. title: this.title,
  201. archived: false,
  202. });
  203. if (existingListWithSameName) {
  204. _id = existingListWithSameName._id;
  205. } else {
  206. delete this._id;
  207. this.swimlaneId = swimlaneId; // Set the target swimlane for the copied list
  208. _id = Lists.insert(this);
  209. }
  210. // Copy all cards in list
  211. ReactiveCache.getCards({
  212. swimlaneId: oldSwimlaneId,
  213. listId: oldId,
  214. archived: false,
  215. }).forEach(card => {
  216. card.copy(boardId, swimlaneId, _id);
  217. });
  218. },
  219. move(boardId, swimlaneId) {
  220. const boardList = ReactiveCache.getList({
  221. boardId,
  222. title: this.title,
  223. archived: false,
  224. });
  225. let listId;
  226. if (boardList) {
  227. listId = boardList._id;
  228. this.cards().forEach(card => {
  229. card.move(boardId, this._id, boardList._id);
  230. });
  231. } else {
  232. console.log('list.title:', this.title);
  233. console.log('boardList:', boardList);
  234. listId = Lists.insert({
  235. title: this.title,
  236. boardId,
  237. type: this.type,
  238. archived: false,
  239. wipLimit: this.wipLimit,
  240. swimlaneId: swimlaneId, // Set the target swimlane for the moved list
  241. });
  242. }
  243. this.cards(swimlaneId).forEach(card => {
  244. card.move(boardId, swimlaneId, listId);
  245. });
  246. },
  247. cards(swimlaneId) {
  248. const selector = {
  249. listId: this._id,
  250. archived: false,
  251. };
  252. if (swimlaneId) selector.swimlaneId = swimlaneId;
  253. const ret = ReactiveCache.getCards(Filter.mongoSelector(selector), { sort: ['sort'] });
  254. return ret;
  255. },
  256. cardsUnfiltered(swimlaneId) {
  257. const selector = {
  258. listId: this._id,
  259. archived: false,
  260. };
  261. if (swimlaneId) selector.swimlaneId = swimlaneId;
  262. const ret = ReactiveCache.getCards(selector, { sort: ['sort'] });
  263. return ret;
  264. },
  265. allCards() {
  266. const ret = ReactiveCache.getCards({ listId: this._id });
  267. return ret;
  268. },
  269. board() {
  270. return ReactiveCache.getBoard(this.boardId);
  271. },
  272. getWipLimit(option) {
  273. const list = ReactiveCache.getList(this._id);
  274. if (!list.wipLimit) {
  275. // Necessary check to avoid exceptions for the case where the doc doesn't have the wipLimit field yet set
  276. return 0;
  277. } else if (!option) {
  278. return list.wipLimit;
  279. } else {
  280. return list.wipLimit[option] ? list.wipLimit[option] : 0; // Necessary check to avoid exceptions for the case where the doc doesn't have the wipLimit field yet set
  281. }
  282. },
  283. colorClass() {
  284. if (this.color) return `list-header-${this.color}`;
  285. return '';
  286. },
  287. isTemplateList() {
  288. return this.type === 'template-list';
  289. },
  290. isStarred() {
  291. return this.starred === true;
  292. },
  293. isCollapsed() {
  294. if (Meteor.isClient) {
  295. const user = ReactiveCache.getCurrentUser();
  296. // Logged-in users: prefer profile/cookie-backed state
  297. if (user && user.getCollapsedListFromStorage) {
  298. const stored = user.getCollapsedListFromStorage(this.boardId, this._id);
  299. if (typeof stored === 'boolean') {
  300. return stored;
  301. }
  302. }
  303. // Public users: fallback to cookie if available
  304. if (!user && Users.getPublicCollapsedList) {
  305. const stored = Users.getPublicCollapsedList(this.boardId, this._id);
  306. if (typeof stored === 'boolean') {
  307. return stored;
  308. }
  309. }
  310. }
  311. return this.collapsed === true;
  312. },
  313. absoluteUrl() {
  314. const card = ReactiveCache.getCard({ listId: this._id });
  315. return card && card.absoluteUrl();
  316. },
  317. originRelativeUrl() {
  318. const card = ReactiveCache.getCard({ listId: this._id });
  319. return card && card.originRelativeUrl();
  320. },
  321. remove() {
  322. Lists.remove({ _id: this._id });
  323. },
  324. });
  325. Lists.mutations({
  326. rename(title) {
  327. // Basic client-side validation - server will handle full sanitization
  328. if (typeof title === 'string') {
  329. // Basic length check to prevent abuse
  330. const sanitizedTitle = title.length > 1000 ? title.substring(0, 1000) : title;
  331. return { $set: { title: sanitizedTitle } };
  332. }
  333. return { $set: { title } };
  334. },
  335. star(enable = true) {
  336. return { $set: { starred: !!enable } };
  337. },
  338. collapse(enable = true) {
  339. return { $set: { collapsed: !!enable } };
  340. },
  341. archive() {
  342. if (this.isTemplateList()) {
  343. this.cards().forEach(card => {
  344. return card.archive();
  345. });
  346. }
  347. return { $set: { archived: true, archivedAt: new Date() } };
  348. },
  349. restore() {
  350. if (this.isTemplateList()) {
  351. this.allCards().forEach(card => {
  352. return card.restore();
  353. });
  354. }
  355. return { $set: { archived: false } };
  356. },
  357. toggleSoftLimit(toggle) {
  358. return { $set: { 'wipLimit.soft': toggle } };
  359. },
  360. toggleWipLimit(toggle) {
  361. return { $set: { 'wipLimit.enabled': toggle } };
  362. },
  363. setWipLimit(limit) {
  364. return { $set: { 'wipLimit.value': limit } };
  365. },
  366. setColor(newColor) {
  367. return {
  368. $set: {
  369. color: newColor,
  370. },
  371. };
  372. },
  373. });
  374. Lists.userArchivedLists = userId => {
  375. return ReactiveCache.getLists({
  376. boardId: { $in: Boards.userBoardIds(userId, null) },
  377. archived: true,
  378. })
  379. };
  380. Lists.userArchivedListIds = () => {
  381. return Lists.userArchivedLists().map(list => { return list._id; });
  382. };
  383. Lists.archivedLists = () => {
  384. return ReactiveCache.getLists({ archived: true });
  385. };
  386. Lists.archivedListIds = () => {
  387. return Lists.archivedLists().map(list => {
  388. return list._id;
  389. });
  390. };
  391. Meteor.methods({
  392. applyWipLimit(listId, limit) {
  393. check(listId, String);
  394. check(limit, Number);
  395. if (limit === 0) {
  396. limit = 1;
  397. }
  398. ReactiveCache.getList(listId).setWipLimit(limit);
  399. },
  400. enableWipLimit(listId) {
  401. check(listId, String);
  402. const list = ReactiveCache.getList(listId);
  403. if (list.getWipLimit('value') === 0) {
  404. list.setWipLimit(1);
  405. }
  406. list.toggleWipLimit(!list.getWipLimit('enabled'));
  407. },
  408. enableSoftLimit(listId) {
  409. check(listId, String);
  410. const list = ReactiveCache.getList(listId);
  411. list.toggleSoftLimit(!list.getWipLimit('soft'));
  412. },
  413. myLists() {
  414. // my lists
  415. return _.uniq(
  416. ReactiveCache.getLists(
  417. {
  418. boardId: { $in: Boards.userBoardIds(this.userId) },
  419. archived: false,
  420. },
  421. {
  422. fields: { title: 1 },
  423. },
  424. ).map(list => list.title),
  425. ).sort();
  426. },
  427. updateListSort(listId, boardId, updateData) {
  428. check(listId, String);
  429. check(boardId, String);
  430. check(updateData, Object);
  431. const board = ReactiveCache.getBoard(boardId);
  432. if (!board) {
  433. throw new Meteor.Error('board-not-found', 'Board not found');
  434. }
  435. if (Meteor.isServer) {
  436. if (typeof allowIsBoardMember === 'function') {
  437. if (!allowIsBoardMember(this.userId, board)) {
  438. throw new Meteor.Error('permission-denied', 'User does not have permission to modify this board');
  439. }
  440. }
  441. }
  442. const list = ReactiveCache.getList(listId);
  443. if (!list) {
  444. throw new Meteor.Error('list-not-found', 'List not found');
  445. }
  446. const validUpdateFields = ['sort', 'swimlaneId'];
  447. Object.keys(updateData).forEach(field => {
  448. if (!validUpdateFields.includes(field)) {
  449. throw new Meteor.Error('invalid-field', `Field ${field} is not allowed`);
  450. }
  451. });
  452. if (updateData.swimlaneId) {
  453. const swimlane = ReactiveCache.getSwimlane(updateData.swimlaneId);
  454. if (!swimlane || swimlane.boardId !== boardId) {
  455. throw new Meteor.Error('invalid-swimlane', 'Invalid swimlane for this board');
  456. }
  457. }
  458. Lists.update(
  459. { _id: listId, boardId },
  460. {
  461. $set: {
  462. ...updateData,
  463. modifiedAt: new Date(),
  464. },
  465. },
  466. );
  467. return {
  468. success: true,
  469. listId,
  470. updatedFields: Object.keys(updateData),
  471. timestamp: new Date().toISOString(),
  472. };
  473. },
  474. });
  475. if (Meteor.isServer) {
  476. Meteor.startup(() => {
  477. Lists._collection.rawCollection().createIndex({ modifiedAt: -1 });
  478. Lists._collection.rawCollection().createIndex({ boardId: 1 });
  479. Lists._collection.rawCollection().createIndex({ archivedAt: -1 });
  480. });
  481. }
  482. Lists.after.insert((userId, doc) => {
  483. Activities.insert({
  484. userId,
  485. type: 'list',
  486. activityType: 'createList',
  487. boardId: doc.boardId,
  488. listId: doc._id,
  489. // this preserves the name so that the activity can be useful after the
  490. // list is deleted
  491. title: doc.title,
  492. });
  493. // Track original position for new lists
  494. Meteor.setTimeout(() => {
  495. const list = Lists.findOne(doc._id);
  496. if (list) {
  497. list.trackOriginalPosition();
  498. }
  499. }, 100);
  500. });
  501. Lists.before.remove((userId, doc) => {
  502. const cards = ReactiveCache.getCards({ listId: doc._id });
  503. if (cards) {
  504. cards.forEach(card => {
  505. Cards.remove(card._id);
  506. });
  507. }
  508. Activities.insert({
  509. userId,
  510. type: 'list',
  511. activityType: 'removeList',
  512. boardId: doc.boardId,
  513. listId: doc._id,
  514. title: doc.title,
  515. });
  516. });
  517. // Ensure we don't fetch previous doc in after.update hook
  518. Lists.hookOptions.after.update = { fetchPrevious: false };
  519. Lists.after.update((userId, doc, fieldNames) => {
  520. if (fieldNames.includes('title')) {
  521. Activities.insert({
  522. userId,
  523. type: 'list',
  524. activityType: 'changedListTitle',
  525. listId: doc._id,
  526. boardId: doc.boardId,
  527. // this preserves the name so that the activity can be useful after the
  528. // list is deleted
  529. title: doc.title,
  530. });
  531. } else if (doc.archived) {
  532. Activities.insert({
  533. userId,
  534. type: 'list',
  535. activityType: 'archivedList',
  536. listId: doc._id,
  537. boardId: doc.boardId,
  538. // this preserves the name so that the activity can be useful after the
  539. // list is deleted
  540. title: doc.title,
  541. });
  542. } else if (fieldNames.includes('archived')) {
  543. Activities.insert({
  544. userId,
  545. type: 'list',
  546. activityType: 'restoredList',
  547. listId: doc._id,
  548. boardId: doc.boardId,
  549. // this preserves the name so that the activity can be useful after the
  550. // list is deleted
  551. title: doc.title,
  552. });
  553. }
  554. // When sort or swimlaneId change, trigger a pub/sub refresh marker
  555. if (fieldNames.includes('sort') || fieldNames.includes('swimlaneId')) {
  556. Lists.direct.update(
  557. { _id: doc._id },
  558. { $set: { _updatedAt: new Date() } },
  559. );
  560. }
  561. });
  562. //LISTS REST API
  563. if (Meteor.isServer) {
  564. /**
  565. * @operation get_all_lists
  566. * @summary Get the list of Lists attached to a board
  567. *
  568. * @param {string} boardId the board ID
  569. * @return_type [{_id: string,
  570. * title: string}]
  571. */
  572. JsonRoutes.add('GET', '/api/boards/:boardId/lists', function(req, res) {
  573. try {
  574. const paramBoardId = req.params.boardId;
  575. Authentication.checkBoardAccess(req.userId, paramBoardId);
  576. JsonRoutes.sendResult(res, {
  577. code: 200,
  578. data: ReactiveCache.getLists({ boardId: paramBoardId, archived: false }).map(
  579. function(doc) {
  580. return {
  581. _id: doc._id,
  582. title: doc.title,
  583. };
  584. },
  585. ),
  586. });
  587. } catch (error) {
  588. JsonRoutes.sendResult(res, {
  589. code: 200,
  590. data: error,
  591. });
  592. }
  593. });
  594. /**
  595. * @operation get_list
  596. * @summary Get a List attached to a board
  597. *
  598. * @param {string} boardId the board ID
  599. * @param {string} listId the List ID
  600. * @return_type Lists
  601. */
  602. JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId', function(
  603. req,
  604. res,
  605. ) {
  606. try {
  607. const paramBoardId = req.params.boardId;
  608. const paramListId = req.params.listId;
  609. Authentication.checkBoardAccess(req.userId, paramBoardId);
  610. JsonRoutes.sendResult(res, {
  611. code: 200,
  612. data: ReactiveCache.getList({
  613. _id: paramListId,
  614. boardId: paramBoardId,
  615. archived: false,
  616. }),
  617. });
  618. } catch (error) {
  619. JsonRoutes.sendResult(res, {
  620. code: 200,
  621. data: error,
  622. });
  623. }
  624. });
  625. /**
  626. * @operation new_list
  627. * @summary Add a List to a board
  628. *
  629. * @param {string} boardId the board ID
  630. * @param {string} title the title of the List
  631. * @return_type {_id: string}
  632. */
  633. JsonRoutes.add('POST', '/api/boards/:boardId/lists', function(req, res) {
  634. try {
  635. const paramBoardId = req.params.boardId;
  636. Authentication.checkBoardAccess(req.userId, paramBoardId);
  637. const board = ReactiveCache.getBoard(paramBoardId);
  638. const id = Lists.insert({
  639. title: req.body.title,
  640. boardId: paramBoardId,
  641. sort: board.lists().length,
  642. swimlaneId: req.body.swimlaneId || board.getDefaultSwimline()._id, // Use provided swimlaneId or default
  643. });
  644. JsonRoutes.sendResult(res, {
  645. code: 200,
  646. data: {
  647. _id: id,
  648. },
  649. });
  650. } catch (error) {
  651. JsonRoutes.sendResult(res, {
  652. code: 200,
  653. data: error,
  654. });
  655. }
  656. });
  657. /**
  658. * @operation edit_list
  659. * @summary Edit a List
  660. *
  661. * @description This updates a list on a board.
  662. * You can update the title, color, wipLimit, starred, and collapsed properties.
  663. *
  664. * @param {string} boardId the board ID
  665. * @param {string} listId the ID of the list to update
  666. * @param {string} [title] the new title of the list
  667. * @param {string} [color] the new color of the list
  668. * @param {Object} [wipLimit] the WIP limit configuration
  669. * @param {boolean} [starred] whether the list is starred
  670. * @param {boolean} [collapsed] whether the list is collapsed
  671. * @return_type {_id: string}
  672. */
  673. JsonRoutes.add('PUT', '/api/boards/:boardId/lists/:listId', function(
  674. req,
  675. res,
  676. ) {
  677. try {
  678. const paramBoardId = req.params.boardId;
  679. const paramListId = req.params.listId;
  680. let updated = false;
  681. Authentication.checkBoardAccess(req.userId, paramBoardId);
  682. const list = ReactiveCache.getList({
  683. _id: paramListId,
  684. boardId: paramBoardId,
  685. archived: false,
  686. });
  687. if (!list) {
  688. JsonRoutes.sendResult(res, {
  689. code: 404,
  690. data: { error: 'List not found' },
  691. });
  692. return;
  693. }
  694. // Update title if provided
  695. if (req.body.title) {
  696. // Basic client-side validation - server will handle full sanitization
  697. const newTitle = req.body.title.length > 1000 ? req.body.title.substring(0, 1000) : req.body.title;
  698. if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
  699. console.warn('Sanitized list title input:', req.body.title, '->', newTitle);
  700. }
  701. Lists.direct.update(
  702. {
  703. _id: paramListId,
  704. boardId: paramBoardId,
  705. archived: false,
  706. },
  707. {
  708. $set: {
  709. title: newTitle,
  710. },
  711. },
  712. );
  713. updated = true;
  714. }
  715. // Update color if provided
  716. if (req.body.color) {
  717. const newColor = req.body.color;
  718. Lists.direct.update(
  719. {
  720. _id: paramListId,
  721. boardId: paramBoardId,
  722. archived: false,
  723. },
  724. {
  725. $set: {
  726. color: newColor,
  727. },
  728. },
  729. );
  730. updated = true;
  731. }
  732. // Update starred status if provided
  733. if (req.body.hasOwnProperty('starred')) {
  734. const newStarred = req.body.starred;
  735. Lists.direct.update(
  736. {
  737. _id: paramListId,
  738. boardId: paramBoardId,
  739. archived: false,
  740. },
  741. {
  742. $set: {
  743. starred: newStarred,
  744. },
  745. },
  746. );
  747. updated = true;
  748. }
  749. // NOTE: collapsed state removed from board-level
  750. // It's per-user only - use user profile methods instead
  751. // Update wipLimit if provided
  752. if (req.body.wipLimit) {
  753. const newWipLimit = req.body.wipLimit;
  754. Lists.direct.update(
  755. {
  756. _id: paramListId,
  757. boardId: paramBoardId,
  758. archived: false,
  759. },
  760. {
  761. $set: {
  762. wipLimit: newWipLimit,
  763. },
  764. },
  765. );
  766. updated = true;
  767. }
  768. // Check if update is true or false
  769. if (!updated) {
  770. JsonRoutes.sendResult(res, {
  771. code: 404,
  772. data: {
  773. message: 'Error',
  774. },
  775. });
  776. return;
  777. }
  778. JsonRoutes.sendResult(res, {
  779. code: 200,
  780. data: {
  781. _id: paramListId,
  782. },
  783. });
  784. } catch (error) {
  785. JsonRoutes.sendResult(res, {
  786. code: 200,
  787. data: error,
  788. });
  789. }
  790. });
  791. /**
  792. * @operation delete_list
  793. * @summary Delete a List
  794. *
  795. * @description This **deletes** a list from a board.
  796. * The list is not put in the recycle bin.
  797. *
  798. * @param {string} boardId the board ID
  799. * @param {string} listId the ID of the list to remove
  800. * @return_type {_id: string}
  801. */
  802. JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId', function(
  803. req,
  804. res,
  805. ) {
  806. try {
  807. const paramBoardId = req.params.boardId;
  808. const paramListId = req.params.listId;
  809. Authentication.checkBoardAccess(req.userId, paramBoardId);
  810. Lists.remove({ _id: paramListId, boardId: paramBoardId });
  811. JsonRoutes.sendResult(res, {
  812. code: 200,
  813. data: {
  814. _id: paramListId,
  815. },
  816. });
  817. } catch (error) {
  818. JsonRoutes.sendResult(res, {
  819. code: 200,
  820. data: error,
  821. });
  822. }
  823. });
  824. }
  825. // Position history tracking methods
  826. Lists.helpers({
  827. /**
  828. * Track the original position of this list
  829. */
  830. trackOriginalPosition() {
  831. const existingHistory = PositionHistory.findOne({
  832. boardId: this.boardId,
  833. entityType: 'list',
  834. entityId: this._id,
  835. });
  836. if (!existingHistory) {
  837. PositionHistory.insert({
  838. boardId: this.boardId,
  839. entityType: 'list',
  840. entityId: this._id,
  841. originalPosition: {
  842. sort: this.sort,
  843. title: this.title,
  844. },
  845. originalSwimlaneId: this.swimlaneId || null,
  846. originalTitle: this.title,
  847. createdAt: new Date(),
  848. updatedAt: new Date(),
  849. });
  850. }
  851. },
  852. /**
  853. * Get the original position history for this list
  854. */
  855. getOriginalPosition() {
  856. return PositionHistory.findOne({
  857. boardId: this.boardId,
  858. entityType: 'list',
  859. entityId: this._id,
  860. });
  861. },
  862. /**
  863. * Check if this list has moved from its original position
  864. */
  865. hasMovedFromOriginalPosition() {
  866. const history = this.getOriginalPosition();
  867. if (!history) return false;
  868. const currentSwimlaneId = this.swimlaneId || null;
  869. return history.originalPosition.sort !== this.sort ||
  870. history.originalSwimlaneId !== currentSwimlaneId;
  871. },
  872. /**
  873. * Get a description of the original position
  874. */
  875. getOriginalPositionDescription() {
  876. const history = this.getOriginalPosition();
  877. if (!history) return 'No original position data';
  878. const swimlaneInfo = history.originalSwimlaneId ?
  879. ` in swimlane ${history.originalSwimlaneId}` :
  880. ' in default swimlane';
  881. return `Original position: ${history.originalPosition.sort || 0}${swimlaneInfo}`;
  882. },
  883. /**
  884. * Get the effective swimlane ID (for backward compatibility)
  885. */
  886. getEffectiveSwimlaneId() {
  887. return this.swimlaneId || null;
  888. },
  889. });
  890. export default Lists;