Thanks to xet7 and Claude ! Fixes #5998, fixes #5897, fixes #5846, fixes #5819, fixes #5813, fixes #4815, fixes #4811, fixes #3062, fixes #4743
1445 lines
50 KiB
JavaScript
1445 lines
50 KiB
JavaScript
import { Meteor } from 'meteor/meteor';
|
|
import { WebApp } from 'meteor/webapp';
|
|
import { check } from 'meteor/check';
|
|
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import { add, now } from '/imports/lib/dateUtils';
|
|
import { Authentication } from '/server/authentication';
|
|
import { sendJsonResult } from '/server/apiMiddleware';
|
|
import { allowIsBoardMember, allowIsBoardMemberCommentOnly, allowIsBoardMemberWithWriteAccess, computeSortForIndex, mergeLabelIds, canAssignCardMember, isCardDateClear } from '/server/lib/utils';
|
|
import Activities from '/models/activities';
|
|
import Boards from '/models/boards';
|
|
import Cards, {
|
|
addCronJob,
|
|
cardAssignees,
|
|
cardCreation,
|
|
cardCustomFields,
|
|
cardLabels,
|
|
cardMembers,
|
|
cardMove,
|
|
cardRemover,
|
|
cardState,
|
|
updateActivities,
|
|
} from '/models/cards';
|
|
import Lists from '/models/lists';
|
|
import Checklists from '/models/checklists';
|
|
import ChecklistItems from '/models/checklistItems';
|
|
import { ensureIndex } from '/server/lib/mongoStartup';
|
|
|
|
Meteor.methods({
|
|
async createCardWithDueDate(boardId, listId, title, dueDate, swimlaneId) {
|
|
check(boardId, String);
|
|
check(listId, String);
|
|
check(title, String);
|
|
check(dueDate, Date);
|
|
check(swimlaneId, String);
|
|
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
const destBoard = await Boards.findOneAsync(boardId);
|
|
if (!destBoard) throw new Meteor.Error('not-found');
|
|
if (!allowIsBoardMemberWithWriteAccess(this.userId, destBoard))
|
|
throw new Meteor.Error('not-authorized');
|
|
|
|
const card = {
|
|
title,
|
|
listId,
|
|
boardId,
|
|
swimlaneId,
|
|
createdAt: new Date(),
|
|
dueAt: dueDate,
|
|
sort: 0,
|
|
userId: this.userId,
|
|
};
|
|
const cardId = await Cards.insertAsync(card);
|
|
return cardId;
|
|
},
|
|
|
|
async 'cards.pokerVote'(cardId, state) {
|
|
check(cardId, String);
|
|
if (state !== undefined && state !== null) check(state, String);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!board) throw new Meteor.Error('not-found');
|
|
|
|
const isMember = allowIsBoardMember(this.userId, board);
|
|
const allowNBM = !!(card.poker && card.poker.allowNonBoardMembers);
|
|
if (!(isMember || allowNBM)) {
|
|
throw new Meteor.Error('not-authorized');
|
|
}
|
|
|
|
let mod = card.setPoker(this.userId, state);
|
|
if (!mod || typeof mod !== 'object') mod = {};
|
|
mod.$set = Object.assign({}, mod.$set, {
|
|
modifiedAt: new Date(),
|
|
dateLastActivity: new Date(),
|
|
});
|
|
return await Cards.updateAsync({ _id: cardId }, mod);
|
|
},
|
|
|
|
async 'cards.setPokerQuestion'(cardId, question, allowNonBoardMembers) {
|
|
check(cardId, String);
|
|
check(question, Boolean);
|
|
check(allowNonBoardMembers, Boolean);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
const modifier = {
|
|
$set: {
|
|
poker: {
|
|
question,
|
|
allowNonBoardMembers,
|
|
one: [],
|
|
two: [],
|
|
three: [],
|
|
five: [],
|
|
eight: [],
|
|
thirteen: [],
|
|
twenty: [],
|
|
forty: [],
|
|
oneHundred: [],
|
|
unsure: [],
|
|
},
|
|
modifiedAt: new Date(),
|
|
dateLastActivity: new Date(),
|
|
},
|
|
};
|
|
return await Cards.updateAsync({ _id: cardId }, modifier);
|
|
},
|
|
|
|
async 'cards.setPokerEnd'(cardId, end) {
|
|
check(cardId, String);
|
|
check(end, Date);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$set: {
|
|
'poker.end': end,
|
|
modifiedAt: new Date(),
|
|
dateLastActivity: new Date(),
|
|
},
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.unsetPokerEnd'(cardId) {
|
|
check(cardId, String);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$unset: { 'poker.end': '' },
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.unsetPoker'(cardId) {
|
|
check(cardId, String);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$unset: { poker: '' },
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.setPokerEstimation'(cardId, estimation) {
|
|
check(cardId, String);
|
|
check(estimation, Number);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$set: {
|
|
'poker.estimation': estimation,
|
|
modifiedAt: new Date(),
|
|
dateLastActivity: new Date(),
|
|
},
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.unsetPokerEstimation'(cardId) {
|
|
check(cardId, String);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$unset: { 'poker.estimation': '' },
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.replayPoker'(cardId) {
|
|
check(cardId, String);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$set: {
|
|
'poker.one': [],
|
|
'poker.two': [],
|
|
'poker.three': [],
|
|
'poker.five': [],
|
|
'poker.eight': [],
|
|
'poker.thirteen': [],
|
|
'poker.twenty': [],
|
|
'poker.forty': [],
|
|
'poker.oneHundred': [],
|
|
'poker.unsure': [],
|
|
modifiedAt: new Date(),
|
|
dateLastActivity: new Date(),
|
|
},
|
|
$unset: { 'poker.end': '' },
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.setVoteQuestion'(cardId, question, publicVote, allowNonBoardMembers) {
|
|
check(cardId, String);
|
|
check(question, String);
|
|
check(publicVote, Boolean);
|
|
check(allowNonBoardMembers, Boolean);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$set: {
|
|
vote: {
|
|
question,
|
|
public: publicVote,
|
|
allowNonBoardMembers,
|
|
positive: [],
|
|
negative: [],
|
|
},
|
|
modifiedAt: new Date(),
|
|
dateLastActivity: new Date(),
|
|
},
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.setVoteEnd'(cardId, end) {
|
|
check(cardId, String);
|
|
check(end, Date);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$set: {
|
|
'vote.end': end,
|
|
modifiedAt: new Date(),
|
|
dateLastActivity: new Date(),
|
|
},
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.unsetVoteEnd'(cardId) {
|
|
check(cardId, String);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$unset: { 'vote.end': '' },
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.unsetVote'(cardId) {
|
|
check(cardId, String);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
|
|
|
|
return await Cards.updateAsync(
|
|
{ _id: cardId },
|
|
{
|
|
$unset: { vote: '' },
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
},
|
|
);
|
|
},
|
|
|
|
async 'cards.vote'(cardId, forIt) {
|
|
check(cardId, String);
|
|
if (forIt !== undefined && forIt !== null) check(forIt, Boolean);
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
|
|
const card = (await ReactiveCache.getCard(cardId)) || (await Cards.findOneAsync(cardId));
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const board = (await ReactiveCache.getBoard(card.boardId)) || (await Boards.findOneAsync(card.boardId));
|
|
if (!board) throw new Meteor.Error('not-found');
|
|
|
|
const isMember = allowIsBoardMember(this.userId, board);
|
|
const allowNBM = !!(card.vote && card.vote.allowNonBoardMembers);
|
|
if (!(isMember || allowNBM)) {
|
|
throw new Meteor.Error('not-authorized');
|
|
}
|
|
|
|
let modifier;
|
|
if (forIt === true) {
|
|
modifier = {
|
|
$pull: { 'vote.negative': this.userId },
|
|
$addToSet: { 'vote.positive': this.userId },
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
};
|
|
} else if (forIt === false) {
|
|
modifier = {
|
|
$pull: { 'vote.positive': this.userId },
|
|
$addToSet: { 'vote.negative': this.userId },
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
};
|
|
} else {
|
|
modifier = {
|
|
$pull: {
|
|
'vote.positive': this.userId,
|
|
'vote.negative': this.userId,
|
|
},
|
|
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
|
|
};
|
|
}
|
|
|
|
return await Cards.updateAsync({ _id: cardId }, modifier);
|
|
},
|
|
|
|
async copyCard(cardId, boardId, swimlaneId, listId, insertAtTop, mergeCardValues) {
|
|
check(cardId, String);
|
|
check(boardId, String);
|
|
check(swimlaneId, String);
|
|
check(listId, String);
|
|
check(insertAtTop, Boolean);
|
|
check(mergeCardValues, Object);
|
|
|
|
if (!this.userId) throw new Meteor.Error('not-authorized');
|
|
const card = await ReactiveCache.getCard(cardId);
|
|
if (!card) throw new Meteor.Error('not-found');
|
|
const sourceBoard = await Boards.findOneAsync(card.boardId);
|
|
if (!allowIsBoardMember(this.userId, sourceBoard))
|
|
throw new Meteor.Error('not-authorized');
|
|
const destBoard = await Boards.findOneAsync(boardId);
|
|
if (!allowIsBoardMemberWithWriteAccess(this.userId, destBoard))
|
|
throw new Meteor.Error('not-authorized');
|
|
Object.assign(card, mergeCardValues);
|
|
|
|
const sort = await card.getSort(listId, swimlaneId, insertAtTop);
|
|
if (insertAtTop) {
|
|
card.sort = sort - 1;
|
|
} else {
|
|
card.sort = sort + 1;
|
|
}
|
|
|
|
return await card.copy(boardId, swimlaneId, listId);
|
|
},
|
|
});
|
|
|
|
Meteor.startup(async () => {
|
|
await ensureIndex(Cards, { modifiedAt: -1 });
|
|
await ensureIndex(Cards, { updatedAt: 1, deleted: 1 });
|
|
await ensureIndex(Cards, { boardId: 1, createdAt: -1 });
|
|
await ensureIndex(Cards, { boardId: 1, listId: 1 });
|
|
await ensureIndex(Cards, { parentId: 1 });
|
|
Meteor.defer(() => {
|
|
addCronJob();
|
|
});
|
|
});
|
|
|
|
Cards.after.insert(async (userId, doc) => {
|
|
await cardCreation(userId, doc);
|
|
|
|
Meteor.setTimeout(async () => {
|
|
const card = await Cards.findOneAsync(doc._id);
|
|
if (card) {
|
|
card.trackOriginalPosition();
|
|
}
|
|
}, 100);
|
|
});
|
|
|
|
Cards.after.update(async (userId, doc, fieldNames) => {
|
|
await cardState(userId, doc, fieldNames);
|
|
});
|
|
|
|
// When a card moves to another board, re-sync the denormalized boardId on its
|
|
// checklists and checklist items so the board publication — which filters those
|
|
// collections by boardId — shows them on the destination board. Uses .direct to
|
|
// skip the activity/derivation hooks (the docs' cardId did not change).
|
|
Cards.after.update(async (userId, doc, fieldNames) => {
|
|
if (!fieldNames.includes('boardId')) return;
|
|
const boardId = doc.boardId;
|
|
await Checklists.direct.updateAsync({ cardId: doc._id }, { $set: { boardId } }, { multi: true });
|
|
await ChecklistItems.direct.updateAsync({ cardId: doc._id }, { $set: { boardId } }, { multi: true });
|
|
});
|
|
|
|
Cards.after.update(async function(userId, doc, fieldNames) {
|
|
const previous = this.previous || {};
|
|
const oldListId = previous.listId || doc.listId;
|
|
const oldSwimlaneId = previous.swimlaneId || doc.swimlaneId;
|
|
const oldBoardId = previous.boardId || doc.boardId;
|
|
await cardMove(userId, doc, fieldNames, oldListId, oldSwimlaneId, oldBoardId);
|
|
});
|
|
|
|
Cards.before.update(async (userId, doc, fieldNames, modifier) => {
|
|
await cardMembers(userId, doc, fieldNames, modifier);
|
|
await updateActivities(doc, fieldNames, modifier);
|
|
});
|
|
|
|
Cards.before.update(async (userId, doc, fieldNames, modifier) => {
|
|
await cardAssignees(userId, doc, fieldNames, modifier);
|
|
await updateActivities(doc, fieldNames, modifier);
|
|
});
|
|
|
|
Cards.before.update((userId, doc, fieldNames, modifier) => {
|
|
cardLabels(userId, doc, fieldNames, modifier);
|
|
});
|
|
|
|
Cards.before.update((userId, doc, fieldNames, modifier) => {
|
|
cardCustomFields(userId, doc, fieldNames, modifier);
|
|
});
|
|
|
|
Cards.before.update(async (userId, doc, fieldNames, modifier) => {
|
|
const fields = fieldNames.filter(name => name !== 'dateLastActivity');
|
|
const timingaction = ['receivedAt', 'dueAt', 'startAt', 'endAt'];
|
|
const action = fields[0];
|
|
if (fields.length > 0 && timingaction.includes(action)) {
|
|
const value = modifier.$set[action];
|
|
const oldvalue = doc[action] || '';
|
|
const activityType = `a-${action}`;
|
|
const card = await ReactiveCache.getCard(doc._id);
|
|
if (!card) {
|
|
console.warn('[Cards.before.update] Card not found for cardId:', doc._id, '— skipping timing activity.');
|
|
return;
|
|
}
|
|
const list = await card.list();
|
|
if (list) {
|
|
const modifiedAt = add(now(), -1, 'year').toISOString();
|
|
const boardId = list.boardId;
|
|
await Lists.direct.updateAsync(
|
|
{ _id: list._id },
|
|
{ $set: { modifiedAt, boardId } },
|
|
);
|
|
}
|
|
const user = await ReactiveCache.getUser(userId);
|
|
await Activities.insertAsync({
|
|
userId,
|
|
username: user && user.username,
|
|
activityType,
|
|
boardId: doc.boardId,
|
|
cardId: doc._id,
|
|
cardTitle: doc.title,
|
|
timeKey: action,
|
|
timeValue: value,
|
|
timeOldValue: oldvalue,
|
|
listId: card.listId,
|
|
swimlaneId: card.swimlaneId,
|
|
});
|
|
}
|
|
});
|
|
|
|
Cards.before.remove(async (userId, doc) => {
|
|
// Must be awaited: cardRemover deletes the card's checklists, checklist items,
|
|
// comments and attachments. If not awaited, the card document is removed first
|
|
// and those cascade removals then run with the parent card already gone, whose
|
|
// before.remove hooks dereference an undefined card and crash SyncedCron with
|
|
// an unhandled rejection (TypeError: ... reading 'boardId'). Awaiting here
|
|
// removes the sub-items while the card still exists.
|
|
await cardRemover(userId, doc);
|
|
});
|
|
|
|
WebApp.handlers.get(
|
|
'/api/boards/:boardId/swimlanes/:swimlaneId/cards',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramSwimlaneId = req.params.swimlaneId;
|
|
await Authentication.checkBoardAccess(req.userId, paramBoardId);
|
|
const cards = await ReactiveCache.getCards(
|
|
{
|
|
boardId: paramBoardId,
|
|
swimlaneId: paramSwimlaneId,
|
|
archived: false,
|
|
},
|
|
{ sort: ['sort'] },
|
|
);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: cards.map(doc => ({
|
|
_id: doc._id,
|
|
title: doc.title,
|
|
description: doc.description,
|
|
listId: doc.listId,
|
|
receivedAt: doc.receivedAt,
|
|
startAt: doc.startAt,
|
|
dueAt: doc.dueAt,
|
|
endAt: doc.endAt,
|
|
assignees: doc.assignees,
|
|
sort: doc.sort,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
WebApp.handlers.get('/api/boards/:boardId/lists/:listId/cards', async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramListId = req.params.listId;
|
|
await Authentication.checkBoardAccess(req.userId, paramBoardId);
|
|
const cards = await ReactiveCache.getCards(
|
|
{
|
|
boardId: paramBoardId,
|
|
listId: paramListId,
|
|
archived: false,
|
|
},
|
|
{ sort: ['sort'] },
|
|
);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: cards.map(doc => ({
|
|
_id: doc._id,
|
|
title: doc.title,
|
|
description: doc.description,
|
|
swimlaneId: doc.swimlaneId,
|
|
receivedAt: doc.receivedAt,
|
|
startAt: doc.startAt,
|
|
dueAt: doc.dueAt,
|
|
endAt: doc.endAt,
|
|
assignees: doc.assignees,
|
|
sort: doc.sort,
|
|
})),
|
|
});
|
|
});
|
|
|
|
WebApp.handlers.get('/api/cards/:cardId', async function(req, res) {
|
|
const paramCardId = req.params.cardId;
|
|
const card = await ReactiveCache.getCard(paramCardId);
|
|
await Authentication.checkBoardAccess(req.userId, card.boardId);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: card,
|
|
});
|
|
});
|
|
|
|
WebApp.handlers.get(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramListId = req.params.listId;
|
|
const paramCardId = req.params.cardId;
|
|
await Authentication.checkBoardAccess(req.userId, paramBoardId);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: await ReactiveCache.getCard({
|
|
_id: paramCardId,
|
|
listId: paramListId,
|
|
boardId: paramBoardId,
|
|
archived: false,
|
|
}),
|
|
});
|
|
},
|
|
);
|
|
|
|
WebApp.handlers.post('/api/boards/:boardId/lists/:listId/cards', async function(req, res) {
|
|
Authentication.checkLoggedIn(req.userId);
|
|
const paramBoardId = req.params.boardId;
|
|
const board = await ReactiveCache.getBoard(paramBoardId);
|
|
const addPermission = allowIsBoardMemberCommentOnly(req.userId, board);
|
|
// Must be awaited: checkAdminOrCondition is async, so without await a denied
|
|
// (non board member) caller's rejection never blocks and the card was created
|
|
// anyway — an auth bypass (CWE-862). Awaiting enforces the membership check.
|
|
await Authentication.checkAdminOrCondition(req.userId, addPermission);
|
|
const paramListId = req.params.listId;
|
|
const paramParentId = req.params.parentId;
|
|
|
|
// Issue #5897: create a Linked Card. When linkedId is provided, the new card
|
|
// references an existing card (via Card.link, type cardType-linkedCard)
|
|
// instead of holding its own content. Linking across boards is allowed: the
|
|
// caller must have read access to the linked card's board.
|
|
if (req.body.linkedId) {
|
|
const sourceCard = await ReactiveCache.getCard(req.body.linkedId);
|
|
if (!sourceCard) {
|
|
sendJsonResult(res, { code: 404, data: { error: 'linkedId card not found' } });
|
|
return;
|
|
}
|
|
await Authentication.checkBoardAccess(req.userId, sourceCard.boardId);
|
|
const siblingCards = await ReactiveCache.getCards(
|
|
{ listId: paramListId, archived: false },
|
|
{ sort: ['sort'] },
|
|
);
|
|
const linkedSort = siblingCards.length;
|
|
const linkedNewId = await sourceCard.link(paramBoardId, req.body.swimlaneId, paramListId);
|
|
const linkedNextCardNumber = await board.getNextCardNumber();
|
|
await Cards.direct.updateAsync(
|
|
{ _id: linkedNewId },
|
|
{ $set: { cardNumber: linkedNextCardNumber, sort: linkedSort } },
|
|
);
|
|
sendJsonResult(res, { code: 200, data: { _id: linkedNewId } });
|
|
const linkedCard = await ReactiveCache.getCard(linkedNewId);
|
|
await cardCreation(req.body.authorId, linkedCard);
|
|
return;
|
|
}
|
|
|
|
const nextCardNumber = await board.getNextCardNumber();
|
|
|
|
const customFields = await ReactiveCache.getCustomFields({ boardIds: paramBoardId });
|
|
const customFieldsArr = [];
|
|
(customFields || []).forEach(field => {
|
|
if (field.automaticallyOnCard || field.alwaysOnCard) {
|
|
customFieldsArr.push({ _id: field._id, value: null });
|
|
}
|
|
});
|
|
|
|
const currentCards = await ReactiveCache.getCards(
|
|
{ listId: paramListId, archived: false },
|
|
{ sort: ['sort'] },
|
|
);
|
|
const checkUser = await ReactiveCache.getUser(req.body.authorId);
|
|
const members = req.body.members;
|
|
const assignees = req.body.assignees;
|
|
if (typeof checkUser !== 'undefined') {
|
|
const id = await Cards.direct.insertAsync({
|
|
title: req.body.title,
|
|
boardId: paramBoardId,
|
|
listId: paramListId,
|
|
parentId: paramParentId,
|
|
description: req.body.description,
|
|
userId: req.body.authorId,
|
|
swimlaneId: req.body.swimlaneId,
|
|
sort: currentCards.length,
|
|
cardNumber: nextCardNumber,
|
|
customFields: customFieldsArr,
|
|
members,
|
|
assignees,
|
|
});
|
|
sendJsonResult(res, { code: 200, data: { _id: id } });
|
|
|
|
const card = await ReactiveCache.getCard(id);
|
|
await cardCreation(req.body.authorId, card);
|
|
} else {
|
|
sendJsonResult(res, { code: 401 });
|
|
}
|
|
});
|
|
|
|
// Issue #4743: deleting and recreating hundreds of cards via one HTTP request
|
|
// per card pegs the CPU and makes WeKan unreachable. This bulk endpoint creates
|
|
// many cards in a single request, so a sync job no longer has to fan out
|
|
// hundreds of POST calls. Card numbers come from the atomic per-board counter
|
|
// (see Board.getNextCardNumber, Issue #5813), so they stay unique even here.
|
|
//
|
|
// Body: { authorId, swimlaneId, cards: [ { title, description, swimlaneId?,
|
|
// authorId?, members?, assignees? }, ... ] }
|
|
// authorId/swimlaneId at the top level are defaults; each card may override them.
|
|
const BULK_CARDS_MAX = 500;
|
|
WebApp.handlers.post(
|
|
'/api/boards/:boardId/lists/:listId/cards/bulk',
|
|
async function(req, res) {
|
|
Authentication.checkLoggedIn(req.userId);
|
|
const paramBoardId = req.params.boardId;
|
|
const board = await ReactiveCache.getBoard(paramBoardId);
|
|
const addPermission = allowIsBoardMemberCommentOnly(req.userId, board);
|
|
await Authentication.checkAdminOrCondition(req.userId, addPermission);
|
|
const paramListId = req.params.listId;
|
|
|
|
const cardsInput = req.body.cards;
|
|
if (!Array.isArray(cardsInput) || cardsInput.length === 0) {
|
|
sendJsonResult(res, { code: 400, data: { error: 'cards must be a non-empty array' } });
|
|
return;
|
|
}
|
|
if (cardsInput.length > BULK_CARDS_MAX) {
|
|
sendJsonResult(res, {
|
|
code: 400,
|
|
data: { error: `cards array too large (max ${BULK_CARDS_MAX} per request)` },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const customFields = await ReactiveCache.getCustomFields({ boardIds: paramBoardId });
|
|
const customFieldsArr = [];
|
|
(customFields || []).forEach(field => {
|
|
if (field.automaticallyOnCard || field.alwaysOnCard) {
|
|
customFieldsArr.push({ _id: field._id, value: null });
|
|
}
|
|
});
|
|
|
|
const currentCards = await ReactiveCache.getCards(
|
|
{ listId: paramListId, archived: false },
|
|
{ sort: ['sort'] },
|
|
);
|
|
const baseSort = currentCards.length;
|
|
|
|
const results = [];
|
|
for (let i = 0; i < cardsInput.length; i++) {
|
|
const input = cardsInput[i] || {};
|
|
const authorId = input.authorId || req.body.authorId;
|
|
const swimlaneId = input.swimlaneId || req.body.swimlaneId;
|
|
const checkUser = await ReactiveCache.getUser(authorId);
|
|
if (typeof checkUser === 'undefined') {
|
|
results.push({ index: i, error: 'authorId not found' });
|
|
continue;
|
|
}
|
|
// getNextCardNumber() is an atomic per-board counter, so calling it once
|
|
// per card in this loop still yields unique, sequential numbers.
|
|
const nextCardNumber = await board.getNextCardNumber();
|
|
const id = await Cards.direct.insertAsync({
|
|
title: input.title,
|
|
boardId: paramBoardId,
|
|
listId: paramListId,
|
|
parentId: input.parentId,
|
|
description: input.description,
|
|
userId: authorId,
|
|
swimlaneId,
|
|
sort: baseSort + i,
|
|
cardNumber: nextCardNumber,
|
|
customFields: customFieldsArr,
|
|
members: input.members,
|
|
assignees: input.assignees,
|
|
});
|
|
const card = await ReactiveCache.getCard(id);
|
|
await cardCreation(authorId, card);
|
|
results.push({ index: i, _id: id });
|
|
}
|
|
|
|
sendJsonResult(res, { code: 200, data: results });
|
|
},
|
|
);
|
|
|
|
WebApp.handlers.get('/api/boards/:boardId/cards_count', async function(req, res) {
|
|
try {
|
|
const paramBoardId = req.params.boardId;
|
|
await Authentication.checkBoardAccess(req.userId, paramBoardId);
|
|
const cards = await ReactiveCache.getCards({
|
|
boardId: paramBoardId,
|
|
archived: false,
|
|
});
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: { board_cards_count: cards.length },
|
|
});
|
|
} catch (error) {
|
|
sendJsonResult(res, { code: 200, data: error });
|
|
}
|
|
});
|
|
|
|
WebApp.handlers.get('/api/boards/:boardId/lists/:listId/cards_count', async function(req, res) {
|
|
try {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramListId = req.params.listId;
|
|
await Authentication.checkBoardAccess(req.userId, paramBoardId);
|
|
const cards = await ReactiveCache.getCards({
|
|
boardId: paramBoardId,
|
|
listId: paramListId,
|
|
archived: false,
|
|
});
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: { list_cards_count: cards.length },
|
|
});
|
|
} catch (error) {
|
|
sendJsonResult(res, { code: 200, data: error });
|
|
}
|
|
});
|
|
|
|
WebApp.handlers.put(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramCardId = req.params.cardId;
|
|
const paramListId = req.params.listId;
|
|
const newBoardId = req.body.newBoardId;
|
|
const newSwimlaneId = req.body.newSwimlaneId;
|
|
const newListId = req.body.newListId;
|
|
let updated = false;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
|
|
if (req.body.title) {
|
|
const newTitle =
|
|
req.body.title.length > 1000 ? req.body.title.substring(0, 1000) : req.body.title;
|
|
if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
|
|
console.warn('Sanitized card title input:', req.body.title, '->', newTitle);
|
|
}
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { title: newTitle } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.sort) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { sort: req.body.sort } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.parentId) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { parentId: req.body.parentId } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.description) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { description: req.body.description } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.color) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { color: req.body.color } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.vote) {
|
|
const newVote = req.body.vote;
|
|
newVote.positive = [];
|
|
newVote.negative = [];
|
|
if (!Object.prototype.hasOwnProperty.call(newVote, 'public')) newVote.public = false;
|
|
if (!Object.prototype.hasOwnProperty.call(newVote, 'allowNonBoardMembers')) {
|
|
newVote.allowNonBoardMembers = false;
|
|
}
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { vote: newVote } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.poker) {
|
|
const newPoker = req.body.poker;
|
|
newPoker.one = [];
|
|
newPoker.two = [];
|
|
newPoker.three = [];
|
|
newPoker.five = [];
|
|
newPoker.eight = [];
|
|
newPoker.thirteen = [];
|
|
newPoker.twenty = [];
|
|
newPoker.forty = [];
|
|
newPoker.oneHundred = [];
|
|
newPoker.unsure = [];
|
|
if (!Object.prototype.hasOwnProperty.call(newPoker, 'allowNonBoardMembers')) {
|
|
newPoker.allowNonBoardMembers = false;
|
|
}
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { poker: newPoker } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.labelIds) {
|
|
let newlabelIds = req.body.labelIds;
|
|
if (typeof newlabelIds === 'string') {
|
|
newlabelIds = newlabelIds === '' ? null : [newlabelIds];
|
|
}
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { labelIds: newlabelIds } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.requestedBy) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { requestedBy: req.body.requestedBy } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.assignedBy) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { assignedBy: req.body.assignedBy } },
|
|
);
|
|
updated = true;
|
|
}
|
|
// Issue #5846: add/remove card dates. Previously each date was only written
|
|
// when the body value was truthy (if (req.body.receivedAt)), so an empty
|
|
// string / null could not CLEAR a date via the API. Now, whenever the field
|
|
// is present in the request body, an empty string or null unsets the date
|
|
// ($unset) and any other value sets it ($set).
|
|
const dateFields = ['receivedAt', 'startAt', 'dueAt', 'endAt'];
|
|
for (const dateField of dateFields) {
|
|
if (Object.prototype.hasOwnProperty.call(req.body, dateField)) {
|
|
const value = req.body[dateField];
|
|
const isClear = isCardDateClear(value);
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
isClear ? { $unset: { [dateField]: '' } } : { $set: { [dateField]: value } },
|
|
);
|
|
updated = true;
|
|
}
|
|
}
|
|
if (req.body.spentTime) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { spentTime: req.body.spentTime } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.isOverTime) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { isOverTime: req.body.isOverTime } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.customFields) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { customFields: req.body.customFields } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.members) {
|
|
let newmembers = req.body.members;
|
|
if (typeof newmembers === 'string') {
|
|
newmembers = newmembers === '' ? null : [newmembers];
|
|
}
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { members: newmembers } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.assignees) {
|
|
let newassignees = req.body.assignees;
|
|
if (typeof newassignees === 'string') {
|
|
newassignees = newassignees === '' ? null : [newassignees];
|
|
}
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { assignees: newassignees } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.swimlaneId) {
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { swimlaneId: req.body.swimlaneId } },
|
|
);
|
|
updated = true;
|
|
}
|
|
if (req.body.listId) {
|
|
const newParamListId = req.body.listId;
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { listId: newParamListId } },
|
|
);
|
|
updated = true;
|
|
|
|
const card = await ReactiveCache.getCard(paramCardId);
|
|
await cardMove(req.body.authorId, card, { fieldName: 'listId' }, paramListId);
|
|
}
|
|
if (newBoardId && newSwimlaneId && newListId) {
|
|
await Authentication.checkBoardWriteAccess(req.userId, newBoardId);
|
|
const destList = await ReactiveCache.getList({
|
|
_id: newListId,
|
|
boardId: newBoardId,
|
|
archived: false,
|
|
});
|
|
if (!destList) {
|
|
sendJsonResult(res, {
|
|
code: 404,
|
|
data: { error: 'Destination list not found or does not belong to destination board' },
|
|
});
|
|
return;
|
|
}
|
|
const destSwimlane = await ReactiveCache.getSwimlane({
|
|
_id: newSwimlaneId,
|
|
boardId: newBoardId,
|
|
archived: false,
|
|
});
|
|
if (!destSwimlane) {
|
|
sendJsonResult(res, {
|
|
code: 404,
|
|
data: { error: 'Destination swimlane not found or does not belong to destination board' },
|
|
});
|
|
return;
|
|
}
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { boardId: newBoardId, swimlaneId: newSwimlaneId, listId: newListId } },
|
|
);
|
|
updated = true;
|
|
|
|
const card = await ReactiveCache.getCard(paramCardId);
|
|
await cardMove(req.userId, card, ['boardId', 'swimlaneId', 'listId'], newListId, newSwimlaneId, newBoardId);
|
|
}
|
|
if (req.body.archive) {
|
|
const archive = String(req.body.archive).toLowerCase() === 'true';
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: !archive },
|
|
{ $set: { archived: archive } },
|
|
);
|
|
updated = true;
|
|
}
|
|
|
|
if (!updated) {
|
|
sendJsonResult(res, { code: 404, data: { message: 'Error' } });
|
|
return;
|
|
}
|
|
|
|
sendJsonResult(res, { code: 200, data: { _id: paramCardId } });
|
|
},
|
|
);
|
|
|
|
WebApp.handlers.delete(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramListId = req.params.listId;
|
|
const paramCardId = req.params.cardId;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
|
|
const card = await ReactiveCache.getCard(paramCardId);
|
|
// Remove the card's checklists, checklist items, comments and attachments
|
|
// BEFORE removing the card itself, so their before.remove hooks still find
|
|
// the parent card (otherwise they dereference an undefined card and crash
|
|
// SyncedCron with an unhandled rejection). `Cards.direct.removeAsync`
|
|
// bypasses Cards.before.remove, so cardRemover is not run twice.
|
|
if (card) {
|
|
await cardRemover(req.body.authorId, card);
|
|
}
|
|
await Cards.direct.removeAsync({
|
|
_id: paramCardId,
|
|
listId: paramListId,
|
|
boardId: paramBoardId,
|
|
});
|
|
sendJsonResult(res, { code: 200, data: { _id: paramCardId } });
|
|
},
|
|
);
|
|
|
|
// Issue #4743: bulk delete cards in one request instead of one DELETE per card.
|
|
// Body: { cardIds: [ ... ], authorId? }
|
|
// Only cards that belong to :boardId are removed; unknown/foreign ids are
|
|
// reported back in notFound so the caller can reconcile.
|
|
WebApp.handlers.delete('/api/boards/:boardId/cards/bulk', async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
|
|
const cardIds = req.body.cardIds;
|
|
if (!Array.isArray(cardIds) || cardIds.length === 0) {
|
|
sendJsonResult(res, { code: 400, data: { error: 'cardIds must be a non-empty array' } });
|
|
return;
|
|
}
|
|
if (cardIds.length > BULK_CARDS_MAX) {
|
|
sendJsonResult(res, {
|
|
code: 400,
|
|
data: { error: `cardIds array too large (max ${BULK_CARDS_MAX} per request)` },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const deleted = [];
|
|
const notFound = [];
|
|
for (const cardId of cardIds) {
|
|
const card = await ReactiveCache.getCard({ _id: cardId, boardId: paramBoardId });
|
|
if (!card) {
|
|
notFound.push(cardId);
|
|
continue;
|
|
}
|
|
// Remove sub-items (checklists, items, comments, attachments) before the
|
|
// card itself so their before.remove hooks still find the parent card.
|
|
await cardRemover(req.body.authorId, card);
|
|
await Cards.direct.removeAsync({ _id: cardId, boardId: paramBoardId });
|
|
deleted.push(cardId);
|
|
}
|
|
|
|
sendJsonResult(res, { code: 200, data: { deleted, notFound } });
|
|
});
|
|
|
|
// Issue #5819: bulk add/remove labels across many cards in one request, MERGING
|
|
// (add labels without dropping existing ones, remove specific labels) instead
|
|
// of the replace-the-whole-array behavior of PUT card labelIds.
|
|
// Body: { cardIds: [ ... ], addLabelIds: [ ... ], removeLabelIds: [ ... ] }
|
|
WebApp.handlers.post('/api/boards/:boardId/cards/labels', async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
|
|
const cardIds = req.body.cardIds;
|
|
if (!Array.isArray(cardIds) || cardIds.length === 0) {
|
|
sendJsonResult(res, { code: 400, data: { error: 'cardIds must be a non-empty array' } });
|
|
return;
|
|
}
|
|
if (cardIds.length > BULK_CARDS_MAX) {
|
|
sendJsonResult(res, {
|
|
code: 400,
|
|
data: { error: `cardIds array too large (max ${BULK_CARDS_MAX} per request)` },
|
|
});
|
|
return;
|
|
}
|
|
const addLabelIds = Array.isArray(req.body.addLabelIds) ? req.body.addLabelIds : [];
|
|
const removeLabelIds = Array.isArray(req.body.removeLabelIds) ? req.body.removeLabelIds : [];
|
|
if (addLabelIds.length === 0 && removeLabelIds.length === 0) {
|
|
sendJsonResult(res, {
|
|
code: 400,
|
|
data: { error: 'provide at least one of addLabelIds or removeLabelIds' },
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Validate that every label being added actually exists on this board.
|
|
const board = await ReactiveCache.getBoard(paramBoardId);
|
|
const boardLabelIds = new Set((board.labels || []).map(label => label._id));
|
|
const invalidLabelIds = addLabelIds.filter(labelId => !boardLabelIds.has(labelId));
|
|
if (invalidLabelIds.length > 0) {
|
|
sendJsonResult(res, {
|
|
code: 400,
|
|
data: { error: 'addLabelIds contains labels not on this board', invalidLabelIds },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updated = [];
|
|
const notFound = [];
|
|
for (const cardId of cardIds) {
|
|
const card = await ReactiveCache.getCard({ _id: cardId, boardId: paramBoardId, archived: false });
|
|
if (!card) {
|
|
notFound.push(cardId);
|
|
continue;
|
|
}
|
|
// Merge: keep existing minus removed, then add new ones, de-duplicated.
|
|
const merged = mergeLabelIds(card.labelIds, addLabelIds, removeLabelIds);
|
|
await Cards.direct.updateAsync(
|
|
{ _id: cardId, boardId: paramBoardId, archived: false },
|
|
{ $set: { labelIds: merged } },
|
|
);
|
|
updated.push({ _id: cardId, labelIds: merged });
|
|
}
|
|
|
|
sendJsonResult(res, { code: 200, data: { updated, notFound } });
|
|
});
|
|
|
|
WebApp.handlers.get(
|
|
'/api/boards/:boardId/cardsByCustomField/:customFieldId/:customFieldValue',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramCustomFieldId = req.params.customFieldId;
|
|
const paramCustomFieldValue = req.params.customFieldValue;
|
|
await Authentication.checkBoardAccess(req.userId, paramBoardId);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: await ReactiveCache.getCards({
|
|
boardId: paramBoardId,
|
|
customFields: {
|
|
$elemMatch: { _id: paramCustomFieldId, value: paramCustomFieldValue },
|
|
},
|
|
archived: false,
|
|
}),
|
|
});
|
|
},
|
|
);
|
|
|
|
WebApp.handlers.post(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/customFields/:customFieldId',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramCardId = req.params.cardId;
|
|
const paramListId = req.params.listId;
|
|
const paramCustomFieldId = req.params.customFieldId;
|
|
const paramCustomFieldValue = req.body.value;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
const card = await ReactiveCache.getCard({
|
|
_id: paramCardId,
|
|
listId: paramListId,
|
|
boardId: paramBoardId,
|
|
archived: false,
|
|
});
|
|
if (!card) {
|
|
throw new Meteor.Error(404, 'Card not found');
|
|
}
|
|
const updatedCustomFields = (card.customFields || []).map(cf =>
|
|
cf._id === paramCustomFieldId ? { _id: cf._id, value: paramCustomFieldValue } : cf,
|
|
);
|
|
await Cards.direct.updateAsync(
|
|
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
|
|
{ $set: { customFields: updatedCustomFields } },
|
|
);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: { _id: paramCardId, customFields: updatedCustomFields },
|
|
});
|
|
},
|
|
);
|
|
|
|
WebApp.handlers.post(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/archive',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramCardId = req.params.cardId;
|
|
const paramListId = req.params.listId;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
const card = await ReactiveCache.getCard({
|
|
_id: paramCardId,
|
|
listId: paramListId,
|
|
boardId: paramBoardId,
|
|
archived: false,
|
|
});
|
|
if (!card) {
|
|
throw new Meteor.Error(404, 'Card not found');
|
|
}
|
|
await card.archive();
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: { _id: paramCardId, archived: true, archivedAt: new Date() },
|
|
});
|
|
},
|
|
);
|
|
|
|
WebApp.handlers.post(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/unarchive',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramCardId = req.params.cardId;
|
|
const paramListId = req.params.listId;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
const card = await ReactiveCache.getCard({
|
|
_id: paramCardId,
|
|
listId: paramListId,
|
|
boardId: paramBoardId,
|
|
archived: true,
|
|
});
|
|
if (!card) {
|
|
throw new Meteor.Error(404, 'Card not found');
|
|
}
|
|
await card.restore();
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: { _id: paramCardId, archived: false },
|
|
});
|
|
},
|
|
);
|
|
|
|
// Issue #5998: add/remove a single board member to/from a card as a card member
|
|
// or assignee, MERGE-style ($addToSet/$pull via Card.assignMember etc.), so
|
|
// callers don't have to read-modify-write the whole members/assignees array.
|
|
// The userId must be an active member of the card's board, otherwise 400.
|
|
async function cardMemberFieldHandler(req, res, field, paramUserKey, addNotRemove) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramListId = req.params.listId;
|
|
const paramCardId = req.params.cardId;
|
|
const targetUserId = req.params[paramUserKey];
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
|
|
const card = await ReactiveCache.getCard({
|
|
_id: paramCardId,
|
|
listId: paramListId,
|
|
boardId: paramBoardId,
|
|
archived: false,
|
|
});
|
|
if (!card) {
|
|
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
|
|
return;
|
|
}
|
|
|
|
if (addNotRemove) {
|
|
// Only validate board membership when ADDING; removing a stale id is allowed.
|
|
const board = await ReactiveCache.getBoard(paramBoardId);
|
|
if (!canAssignCardMember(board, targetUserId)) {
|
|
sendJsonResult(res, {
|
|
code: 400,
|
|
data: { error: 'userId is not an active member of this board' },
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (field === 'members') {
|
|
await (addNotRemove ? card.assignMember(targetUserId) : card.unassignMember(targetUserId));
|
|
} else {
|
|
await (addNotRemove ? card.assignAssignee(targetUserId) : card.unassignAssignee(targetUserId));
|
|
}
|
|
|
|
const updated = await ReactiveCache.getCard(paramCardId);
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: { _id: paramCardId, members: updated.members || [], assignees: updated.assignees || [] },
|
|
});
|
|
}
|
|
|
|
WebApp.handlers.post(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/members/:memberId',
|
|
async function(req, res) {
|
|
await cardMemberFieldHandler(req, res, 'members', 'memberId', true);
|
|
},
|
|
);
|
|
WebApp.handlers.delete(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/members/:memberId',
|
|
async function(req, res) {
|
|
await cardMemberFieldHandler(req, res, 'members', 'memberId', false);
|
|
},
|
|
);
|
|
WebApp.handlers.post(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/assignees/:assigneeId',
|
|
async function(req, res) {
|
|
await cardMemberFieldHandler(req, res, 'assignees', 'assigneeId', true);
|
|
},
|
|
);
|
|
WebApp.handlers.delete(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/assignees/:assigneeId',
|
|
async function(req, res) {
|
|
await cardMemberFieldHandler(req, res, 'assignees', 'assigneeId', false);
|
|
},
|
|
);
|
|
|
|
// Copy a card to the same or a different board/swimlane/list, at a 0-based
|
|
// `position` counted from the top of the destination list. Deep copy is handled
|
|
// by Card.copy (comments, checklists, attachments, custom field values).
|
|
// Body: { toBoardId?, toSwimlaneId?, toListId?, position? }
|
|
WebApp.handlers.post(
|
|
'/api/boards/:boardId/lists/:listId/cards/:cardId/copy',
|
|
async function(req, res) {
|
|
const paramBoardId = req.params.boardId;
|
|
const paramListId = req.params.listId;
|
|
const paramCardId = req.params.cardId;
|
|
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
|
|
|
const toBoardId = req.body.toBoardId || paramBoardId;
|
|
const toSwimlaneId = req.body.toSwimlaneId;
|
|
const toListId = req.body.toListId || paramListId;
|
|
if (!toSwimlaneId) {
|
|
sendJsonResult(res, { code: 400, data: { error: 'toSwimlaneId is required' } });
|
|
return;
|
|
}
|
|
// Require write access on the destination board too (may differ from source).
|
|
await Authentication.checkBoardWriteAccess(req.userId, toBoardId);
|
|
|
|
const card = await ReactiveCache.getCard({
|
|
_id: paramCardId,
|
|
listId: paramListId,
|
|
boardId: paramBoardId,
|
|
});
|
|
if (!card) {
|
|
sendJsonResult(res, { code: 404, data: { error: 'Card not found' } });
|
|
return;
|
|
}
|
|
|
|
const newId = await card.copy(toBoardId, toSwimlaneId, toListId);
|
|
|
|
if (Object.prototype.hasOwnProperty.call(req.body, 'position')) {
|
|
const siblings = await ReactiveCache.getCards(
|
|
{ listId: toListId, archived: false, _id: { $ne: newId } },
|
|
{ sort: ['sort'] },
|
|
);
|
|
const newSort = computeSortForIndex(siblings, Number(req.body.position));
|
|
await Cards.direct.updateAsync({ _id: newId }, { $set: { sort: newSort } });
|
|
}
|
|
|
|
sendJsonResult(res, { code: 200, data: { _id: newId } });
|
|
},
|
|
);
|
|
|
|
// Issue #4815: get the current user's cards (cards where they are a member or
|
|
// assignee). ?due=true returns only cards that have a due date; ?from= and ?to=
|
|
// (ISO 8601) restrict due cards to a date range. Returns a compact field set.
|
|
WebApp.handlers.get('/api/user/cards', async function(req, res) {
|
|
// Return a clean 401 rather than letting checkLoggedIn throw (which under
|
|
// Express 4 would leave the request hanging instead of responding).
|
|
if (!req.userId) {
|
|
sendJsonResult(res, { code: 401, data: { error: 'Unauthorized' } });
|
|
return;
|
|
}
|
|
const userId = req.userId;
|
|
|
|
const selector = {
|
|
archived: false,
|
|
$or: [{ members: userId }, { assignees: userId }],
|
|
};
|
|
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const dueOnly = ['true', '1', 'yes'].includes(
|
|
String(url.searchParams.get('due') || '').toLowerCase(),
|
|
);
|
|
const from = url.searchParams.get('from');
|
|
const to = url.searchParams.get('to');
|
|
if (dueOnly || from || to) {
|
|
selector.dueAt = { $exists: true, $ne: null };
|
|
if (from) {
|
|
selector.dueAt.$gte = new Date(from);
|
|
}
|
|
if (to) {
|
|
selector.dueAt.$lte = new Date(to);
|
|
}
|
|
}
|
|
|
|
const cards = await ReactiveCache.getCards(selector, { sort: { dueAt: 1 } });
|
|
sendJsonResult(res, {
|
|
code: 200,
|
|
data: (cards || []).map(card => ({
|
|
_id: card._id,
|
|
title: card.title,
|
|
boardId: card.boardId,
|
|
swimlaneId: card.swimlaneId,
|
|
listId: card.listId,
|
|
dueAt: card.dueAt,
|
|
startAt: card.startAt,
|
|
endAt: card.endAt,
|
|
members: card.members || [],
|
|
assignees: card.assignees || [],
|
|
})),
|
|
});
|
|
});
|