lists.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890
  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. collapsed: {
  160. /**
  161. * is the list collapsed
  162. */
  163. type: Boolean,
  164. defaultValue: false,
  165. },
  166. }),
  167. );
  168. Lists.allow({
  169. insert(userId, doc) {
  170. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  171. },
  172. update(userId, doc) {
  173. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  174. },
  175. remove(userId, doc) {
  176. return allowIsBoardMemberCommentOnly(userId, ReactiveCache.getBoard(doc.boardId));
  177. },
  178. fetch: ['boardId'],
  179. });
  180. Lists.helpers({
  181. copy(boardId, swimlaneId) {
  182. const oldId = this._id;
  183. const oldSwimlaneId = this.swimlaneId || null;
  184. this.boardId = boardId;
  185. this.swimlaneId = swimlaneId;
  186. let _id = null;
  187. const existingListWithSameName = ReactiveCache.getList({
  188. boardId,
  189. title: this.title,
  190. archived: false,
  191. });
  192. if (existingListWithSameName) {
  193. _id = existingListWithSameName._id;
  194. } else {
  195. delete this._id;
  196. this.swimlaneId = swimlaneId; // Set the target swimlane for the copied list
  197. _id = Lists.insert(this);
  198. }
  199. // Copy all cards in list
  200. ReactiveCache.getCards({
  201. swimlaneId: oldSwimlaneId,
  202. listId: oldId,
  203. archived: false,
  204. }).forEach(card => {
  205. card.copy(boardId, swimlaneId, _id);
  206. });
  207. },
  208. move(boardId, swimlaneId) {
  209. const boardList = ReactiveCache.getList({
  210. boardId,
  211. title: this.title,
  212. archived: false,
  213. });
  214. let listId;
  215. if (boardList) {
  216. listId = boardList._id;
  217. this.cards().forEach(card => {
  218. card.move(boardId, this._id, boardList._id);
  219. });
  220. } else {
  221. console.log('list.title:', this.title);
  222. console.log('boardList:', boardList);
  223. listId = Lists.insert({
  224. title: this.title,
  225. boardId,
  226. type: this.type,
  227. archived: false,
  228. wipLimit: this.wipLimit,
  229. swimlaneId: swimlaneId, // Set the target swimlane for the moved list
  230. });
  231. }
  232. this.cards(swimlaneId).forEach(card => {
  233. card.move(boardId, swimlaneId, listId);
  234. });
  235. },
  236. cards(swimlaneId) {
  237. const selector = {
  238. listId: this._id,
  239. archived: false,
  240. };
  241. if (swimlaneId) selector.swimlaneId = swimlaneId;
  242. const ret = ReactiveCache.getCards(Filter.mongoSelector(selector), { sort: ['sort'] });
  243. return ret;
  244. },
  245. cardsUnfiltered(swimlaneId) {
  246. const selector = {
  247. listId: this._id,
  248. archived: false,
  249. };
  250. if (swimlaneId) selector.swimlaneId = swimlaneId;
  251. const ret = ReactiveCache.getCards(selector, { sort: ['sort'] });
  252. return ret;
  253. },
  254. allCards() {
  255. const ret = ReactiveCache.getCards({ listId: this._id });
  256. return ret;
  257. },
  258. board() {
  259. return ReactiveCache.getBoard(this.boardId);
  260. },
  261. getWipLimit(option) {
  262. const list = ReactiveCache.getList(this._id);
  263. if (!list.wipLimit) {
  264. // Necessary check to avoid exceptions for the case where the doc doesn't have the wipLimit field yet set
  265. return 0;
  266. } else if (!option) {
  267. return list.wipLimit;
  268. } else {
  269. 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
  270. }
  271. },
  272. colorClass() {
  273. if (this.color) return `list-header-${this.color}`;
  274. return '';
  275. },
  276. isTemplateList() {
  277. return this.type === 'template-list';
  278. },
  279. isStarred() {
  280. return this.starred === true;
  281. },
  282. isCollapsed() {
  283. return this.collapsed === true;
  284. },
  285. absoluteUrl() {
  286. const card = ReactiveCache.getCard({ listId: this._id });
  287. return card && card.absoluteUrl();
  288. },
  289. originRelativeUrl() {
  290. const card = ReactiveCache.getCard({ listId: this._id });
  291. return card && card.originRelativeUrl();
  292. },
  293. remove() {
  294. Lists.remove({ _id: this._id });
  295. },
  296. });
  297. Lists.mutations({
  298. rename(title) {
  299. // Basic client-side validation - server will handle full sanitization
  300. if (typeof title === 'string') {
  301. // Basic length check to prevent abuse
  302. const sanitizedTitle = title.length > 1000 ? title.substring(0, 1000) : title;
  303. return { $set: { title: sanitizedTitle } };
  304. }
  305. return { $set: { title } };
  306. },
  307. star(enable = true) {
  308. return { $set: { starred: !!enable } };
  309. },
  310. collapse(enable = true) {
  311. return { $set: { collapsed: !!enable } };
  312. },
  313. archive() {
  314. if (this.isTemplateList()) {
  315. this.cards().forEach(card => {
  316. return card.archive();
  317. });
  318. }
  319. return { $set: { archived: true, archivedAt: new Date() } };
  320. },
  321. restore() {
  322. if (this.isTemplateList()) {
  323. this.allCards().forEach(card => {
  324. return card.restore();
  325. });
  326. }
  327. return { $set: { archived: false } };
  328. },
  329. toggleSoftLimit(toggle) {
  330. return { $set: { 'wipLimit.soft': toggle } };
  331. },
  332. toggleWipLimit(toggle) {
  333. return { $set: { 'wipLimit.enabled': toggle } };
  334. },
  335. setWipLimit(limit) {
  336. return { $set: { 'wipLimit.value': limit } };
  337. },
  338. setColor(newColor) {
  339. return {
  340. $set: {
  341. color: newColor,
  342. },
  343. };
  344. },
  345. });
  346. Lists.userArchivedLists = userId => {
  347. return ReactiveCache.getLists({
  348. boardId: { $in: Boards.userBoardIds(userId, null) },
  349. archived: true,
  350. })
  351. };
  352. Lists.userArchivedListIds = () => {
  353. return Lists.userArchivedLists().map(list => { return list._id; });
  354. };
  355. Lists.archivedLists = () => {
  356. return ReactiveCache.getLists({ archived: true });
  357. };
  358. Lists.archivedListIds = () => {
  359. return Lists.archivedLists().map(list => {
  360. return list._id;
  361. });
  362. };
  363. Meteor.methods({
  364. applyWipLimit(listId, limit) {
  365. check(listId, String);
  366. check(limit, Number);
  367. if (limit === 0) {
  368. limit = 1;
  369. }
  370. ReactiveCache.getList(listId).setWipLimit(limit);
  371. },
  372. enableWipLimit(listId) {
  373. check(listId, String);
  374. const list = ReactiveCache.getList(listId);
  375. if (list.getWipLimit('value') === 0) {
  376. list.setWipLimit(1);
  377. }
  378. list.toggleWipLimit(!list.getWipLimit('enabled'));
  379. },
  380. enableSoftLimit(listId) {
  381. check(listId, String);
  382. const list = ReactiveCache.getList(listId);
  383. list.toggleSoftLimit(!list.getWipLimit('soft'));
  384. },
  385. myLists() {
  386. // my lists
  387. return _.uniq(
  388. ReactiveCache.getLists(
  389. {
  390. boardId: { $in: Boards.userBoardIds(this.userId) },
  391. archived: false,
  392. },
  393. {
  394. fields: { title: 1 },
  395. },
  396. )
  397. .map(list => {
  398. return list.title;
  399. }),
  400. ).sort();
  401. },
  402. });
  403. Lists.hookOptions.after.update = { fetchPrevious: false };
  404. if (Meteor.isServer) {
  405. Meteor.startup(() => {
  406. Lists._collection.createIndex({ modifiedAt: -1 });
  407. Lists._collection.createIndex({ boardId: 1 });
  408. Lists._collection.createIndex({ archivedAt: -1 });
  409. });
  410. Lists.after.insert((userId, doc) => {
  411. Activities.insert({
  412. userId,
  413. type: 'list',
  414. activityType: 'createList',
  415. boardId: doc.boardId,
  416. listId: doc._id,
  417. // this preserves the name so that the activity can be useful after the
  418. // list is deleted
  419. title: doc.title,
  420. });
  421. // Track original position for new lists
  422. Meteor.setTimeout(() => {
  423. const list = Lists.findOne(doc._id);
  424. if (list) {
  425. list.trackOriginalPosition();
  426. }
  427. }, 100);
  428. });
  429. Lists.before.remove((userId, doc) => {
  430. const cards = ReactiveCache.getCards({ listId: doc._id });
  431. if (cards) {
  432. cards.forEach(card => {
  433. Cards.remove(card._id);
  434. });
  435. }
  436. Activities.insert({
  437. userId,
  438. type: 'list',
  439. activityType: 'removeList',
  440. boardId: doc.boardId,
  441. listId: doc._id,
  442. title: doc.title,
  443. });
  444. });
  445. Lists.after.update((userId, doc, fieldNames) => {
  446. if (fieldNames.includes('title')) {
  447. Activities.insert({
  448. userId,
  449. type: 'list',
  450. activityType: 'changedListTitle',
  451. listId: doc._id,
  452. boardId: doc.boardId,
  453. // this preserves the name so that the activity can be useful after the
  454. // list is deleted
  455. title: doc.title,
  456. });
  457. } else if (doc.archived) {
  458. Activities.insert({
  459. userId,
  460. type: 'list',
  461. activityType: 'archivedList',
  462. listId: doc._id,
  463. boardId: doc.boardId,
  464. // this preserves the name so that the activity can be useful after the
  465. // list is deleted
  466. title: doc.title,
  467. });
  468. } else if (fieldNames.includes('archived')) {
  469. Activities.insert({
  470. userId,
  471. type: 'list',
  472. activityType: 'restoredList',
  473. listId: doc._id,
  474. boardId: doc.boardId,
  475. // this preserves the name so that the activity can be useful after the
  476. // list is deleted
  477. title: doc.title,
  478. });
  479. }
  480. });
  481. }
  482. //LISTS REST API
  483. if (Meteor.isServer) {
  484. /**
  485. * @operation get_all_lists
  486. * @summary Get the list of Lists attached to a board
  487. *
  488. * @param {string} boardId the board ID
  489. * @return_type [{_id: string,
  490. * title: string}]
  491. */
  492. JsonRoutes.add('GET', '/api/boards/:boardId/lists', function(req, res) {
  493. try {
  494. const paramBoardId = req.params.boardId;
  495. Authentication.checkBoardAccess(req.userId, paramBoardId);
  496. JsonRoutes.sendResult(res, {
  497. code: 200,
  498. data: ReactiveCache.getLists({ boardId: paramBoardId, archived: false }).map(
  499. function(doc) {
  500. return {
  501. _id: doc._id,
  502. title: doc.title,
  503. };
  504. },
  505. ),
  506. });
  507. } catch (error) {
  508. JsonRoutes.sendResult(res, {
  509. code: 200,
  510. data: error,
  511. });
  512. }
  513. });
  514. /**
  515. * @operation get_list
  516. * @summary Get a List attached to a board
  517. *
  518. * @param {string} boardId the board ID
  519. * @param {string} listId the List ID
  520. * @return_type Lists
  521. */
  522. JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId', function(
  523. req,
  524. res,
  525. ) {
  526. try {
  527. const paramBoardId = req.params.boardId;
  528. const paramListId = req.params.listId;
  529. Authentication.checkBoardAccess(req.userId, paramBoardId);
  530. JsonRoutes.sendResult(res, {
  531. code: 200,
  532. data: ReactiveCache.getList({
  533. _id: paramListId,
  534. boardId: paramBoardId,
  535. archived: false,
  536. }),
  537. });
  538. } catch (error) {
  539. JsonRoutes.sendResult(res, {
  540. code: 200,
  541. data: error,
  542. });
  543. }
  544. });
  545. /**
  546. * @operation new_list
  547. * @summary Add a List to a board
  548. *
  549. * @param {string} boardId the board ID
  550. * @param {string} title the title of the List
  551. * @return_type {_id: string}
  552. */
  553. JsonRoutes.add('POST', '/api/boards/:boardId/lists', function(req, res) {
  554. try {
  555. const paramBoardId = req.params.boardId;
  556. Authentication.checkBoardAccess(req.userId, paramBoardId);
  557. const board = ReactiveCache.getBoard(paramBoardId);
  558. const id = Lists.insert({
  559. title: req.body.title,
  560. boardId: paramBoardId,
  561. sort: board.lists().length,
  562. swimlaneId: req.body.swimlaneId || board.getDefaultSwimline()._id, // Use provided swimlaneId or default
  563. });
  564. JsonRoutes.sendResult(res, {
  565. code: 200,
  566. data: {
  567. _id: id,
  568. },
  569. });
  570. } catch (error) {
  571. JsonRoutes.sendResult(res, {
  572. code: 200,
  573. data: error,
  574. });
  575. }
  576. });
  577. /**
  578. * @operation edit_list
  579. * @summary Edit a List
  580. *
  581. * @description This updates a list on a board.
  582. * You can update the title, color, wipLimit, starred, and collapsed properties.
  583. *
  584. * @param {string} boardId the board ID
  585. * @param {string} listId the ID of the list to update
  586. * @param {string} [title] the new title of the list
  587. * @param {string} [color] the new color of the list
  588. * @param {Object} [wipLimit] the WIP limit configuration
  589. * @param {boolean} [starred] whether the list is starred
  590. * @param {boolean} [collapsed] whether the list is collapsed
  591. * @return_type {_id: string}
  592. */
  593. JsonRoutes.add('PUT', '/api/boards/:boardId/lists/:listId', function(
  594. req,
  595. res,
  596. ) {
  597. try {
  598. const paramBoardId = req.params.boardId;
  599. const paramListId = req.params.listId;
  600. let updated = false;
  601. Authentication.checkBoardAccess(req.userId, paramBoardId);
  602. const list = ReactiveCache.getList({
  603. _id: paramListId,
  604. boardId: paramBoardId,
  605. archived: false,
  606. });
  607. if (!list) {
  608. JsonRoutes.sendResult(res, {
  609. code: 404,
  610. data: { error: 'List not found' },
  611. });
  612. return;
  613. }
  614. // Update title if provided
  615. if (req.body.title) {
  616. // Basic client-side validation - server will handle full sanitization
  617. const newTitle = req.body.title.length > 1000 ? req.body.title.substring(0, 1000) : req.body.title;
  618. if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
  619. console.warn('Sanitized list title input:', req.body.title, '->', newTitle);
  620. }
  621. Lists.direct.update(
  622. {
  623. _id: paramListId,
  624. boardId: paramBoardId,
  625. archived: false,
  626. },
  627. {
  628. $set: {
  629. title: newTitle,
  630. },
  631. },
  632. );
  633. updated = true;
  634. }
  635. // Update color if provided
  636. if (req.body.color) {
  637. const newColor = req.body.color;
  638. Lists.direct.update(
  639. {
  640. _id: paramListId,
  641. boardId: paramBoardId,
  642. archived: false,
  643. },
  644. {
  645. $set: {
  646. color: newColor,
  647. },
  648. },
  649. );
  650. updated = true;
  651. }
  652. // Update starred status if provided
  653. if (req.body.hasOwnProperty('starred')) {
  654. const newStarred = req.body.starred;
  655. Lists.direct.update(
  656. {
  657. _id: paramListId,
  658. boardId: paramBoardId,
  659. archived: false,
  660. },
  661. {
  662. $set: {
  663. starred: newStarred,
  664. },
  665. },
  666. );
  667. updated = true;
  668. }
  669. // Update collapsed status if provided
  670. if (req.body.hasOwnProperty('collapsed')) {
  671. const newCollapsed = req.body.collapsed;
  672. Lists.direct.update(
  673. {
  674. _id: paramListId,
  675. boardId: paramBoardId,
  676. archived: false,
  677. },
  678. {
  679. $set: {
  680. collapsed: newCollapsed,
  681. },
  682. },
  683. );
  684. updated = true;
  685. }
  686. // Update wipLimit if provided
  687. if (req.body.wipLimit) {
  688. const newWipLimit = req.body.wipLimit;
  689. Lists.direct.update(
  690. {
  691. _id: paramListId,
  692. boardId: paramBoardId,
  693. archived: false,
  694. },
  695. {
  696. $set: {
  697. wipLimit: newWipLimit,
  698. },
  699. },
  700. );
  701. updated = true;
  702. }
  703. // Check if update is true or false
  704. if (!updated) {
  705. JsonRoutes.sendResult(res, {
  706. code: 404,
  707. data: {
  708. message: 'Error',
  709. },
  710. });
  711. return;
  712. }
  713. JsonRoutes.sendResult(res, {
  714. code: 200,
  715. data: {
  716. _id: paramListId,
  717. },
  718. });
  719. } catch (error) {
  720. JsonRoutes.sendResult(res, {
  721. code: 200,
  722. data: error,
  723. });
  724. }
  725. });
  726. /**
  727. * @operation delete_list
  728. * @summary Delete a List
  729. *
  730. * @description This **deletes** a list from a board.
  731. * The list is not put in the recycle bin.
  732. *
  733. * @param {string} boardId the board ID
  734. * @param {string} listId the ID of the list to remove
  735. * @return_type {_id: string}
  736. */
  737. JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId', function(
  738. req,
  739. res,
  740. ) {
  741. try {
  742. const paramBoardId = req.params.boardId;
  743. const paramListId = req.params.listId;
  744. Authentication.checkBoardAccess(req.userId, paramBoardId);
  745. Lists.remove({ _id: paramListId, boardId: paramBoardId });
  746. JsonRoutes.sendResult(res, {
  747. code: 200,
  748. data: {
  749. _id: paramListId,
  750. },
  751. });
  752. } catch (error) {
  753. JsonRoutes.sendResult(res, {
  754. code: 200,
  755. data: error,
  756. });
  757. }
  758. });
  759. }
  760. // Position history tracking methods
  761. Lists.helpers({
  762. /**
  763. * Track the original position of this list
  764. */
  765. trackOriginalPosition() {
  766. const existingHistory = PositionHistory.findOne({
  767. boardId: this.boardId,
  768. entityType: 'list',
  769. entityId: this._id,
  770. });
  771. if (!existingHistory) {
  772. PositionHistory.insert({
  773. boardId: this.boardId,
  774. entityType: 'list',
  775. entityId: this._id,
  776. originalPosition: {
  777. sort: this.sort,
  778. title: this.title,
  779. },
  780. originalSwimlaneId: this.swimlaneId || null,
  781. originalTitle: this.title,
  782. createdAt: new Date(),
  783. updatedAt: new Date(),
  784. });
  785. }
  786. },
  787. /**
  788. * Get the original position history for this list
  789. */
  790. getOriginalPosition() {
  791. return PositionHistory.findOne({
  792. boardId: this.boardId,
  793. entityType: 'list',
  794. entityId: this._id,
  795. });
  796. },
  797. /**
  798. * Check if this list has moved from its original position
  799. */
  800. hasMovedFromOriginalPosition() {
  801. const history = this.getOriginalPosition();
  802. if (!history) return false;
  803. const currentSwimlaneId = this.swimlaneId || null;
  804. return history.originalPosition.sort !== this.sort ||
  805. history.originalSwimlaneId !== currentSwimlaneId;
  806. },
  807. /**
  808. * Get a description of the original position
  809. */
  810. getOriginalPositionDescription() {
  811. const history = this.getOriginalPosition();
  812. if (!history) return 'No original position data';
  813. const swimlaneInfo = history.originalSwimlaneId ?
  814. ` in swimlane ${history.originalSwimlaneId}` :
  815. ' in default swimlane';
  816. return `Original position: ${history.originalPosition.sort || 0}${swimlaneInfo}`;
  817. },
  818. /**
  819. * Get the effective swimlane ID (for backward compatibility)
  820. */
  821. getEffectiveSwimlaneId() {
  822. return this.swimlaneId || null;
  823. },
  824. });
  825. export default Lists;