mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Split shared models from Meteor 3 server runtime
This commit is contained in:
1 parent
8ffc0cb5ff
commit
63ce45c538
171 files changed
+10539
-11006
No files matched your search
+113
-43
@@ -1,53 +1,123 @@
|
||||
/**
|
||||
* Collection extensions shim (Meteor 3.4 migration).
|
||||
*
|
||||
* CRITICAL: This file must use ONLY require() — NO import statements.
|
||||
* import statements are hoisted and can trigger evaluation chains that
|
||||
* reach model files (via reactiveCache → i18n → translation.js) BEFORE
|
||||
* collection2 has patched Mongo.Collection.prototype.attachSchema.
|
||||
*
|
||||
* With require(), each call completes synchronously before the next,
|
||||
* guaranteeing this execution order:
|
||||
* 1. Mongo loaded
|
||||
* 2. SimpleSchema loaded + set as global
|
||||
* 3. collection2 loaded (patches attachSchema onto Mongo.Collection)
|
||||
* 4. Collection.helpers() shim installed
|
||||
*
|
||||
* Only THEN can model files safely call attachSchema/helpers.
|
||||
* This module only patches collection prototype helpers that older app code
|
||||
* still relies on. SimpleSchema now lives in `/imports/simpleSchema`.
|
||||
*/
|
||||
const MeteorPackage = typeof Package !== 'undefined' ? Package.meteor : undefined;
|
||||
const MongoPackage = typeof Package !== 'undefined' ? Package.mongo : undefined;
|
||||
const CollectionHooksPackage =
|
||||
typeof Package !== 'undefined' ? Package['matb33:collection-hooks'] : undefined;
|
||||
const Meteor = MeteorPackage && MeteorPackage.Meteor;
|
||||
const Mongo = MongoPackage && MongoPackage.Mongo;
|
||||
const CollectionHooks = CollectionHooksPackage && CollectionHooksPackage.CollectionHooks;
|
||||
|
||||
const { Mongo } = require('meteor/mongo');
|
||||
|
||||
// Load SimpleSchema and set as global (used by 30+ model files without importing)
|
||||
const SimpleSchema = require('meteor/aldeed:simple-schema').default;
|
||||
if (typeof window !== 'undefined') window.SimpleSchema = SimpleSchema;
|
||||
else if (typeof global !== 'undefined') global.SimpleSchema = SimpleSchema;
|
||||
|
||||
// Register collection2 schema extensions removed from simple-schema v2
|
||||
SimpleSchema.extendOptions(['denyUpdate', 'denyInsert']);
|
||||
|
||||
// Ensure collection2 augments Mongo.Collection.prototype with attachSchema
|
||||
require('meteor/aldeed:collection2');
|
||||
|
||||
// Shim for dburles:collection-helpers (removed — absorbed into Meteor 3.4 core)
|
||||
if (!Mongo.Collection.prototype.helpers) {
|
||||
Mongo.Collection.prototype.helpers = function (helpers) {
|
||||
if (this._transform && !this._helpers)
|
||||
throw new Meteor.Error(
|
||||
"Can't apply helpers to '" +
|
||||
this._name +
|
||||
"' a transform function already exists!",
|
||||
if (Mongo && Mongo.Collection && Mongo.Collection.prototype && !Mongo.Collection.prototype.helpers) {
|
||||
Mongo.Collection.prototype.helpers = function helpers(helpersMap) {
|
||||
if (this._transform && !this._helpersConstructor) {
|
||||
throw new Error(
|
||||
`Can't apply helpers to '${this._name}': a transform function already exists.`,
|
||||
);
|
||||
|
||||
if (!this._helpers) {
|
||||
this._helpers = function Document(doc) {
|
||||
return Object.assign(this, doc);
|
||||
};
|
||||
this._transform = (doc) => new this._helpers(doc);
|
||||
}
|
||||
|
||||
Object.keys(helpers).forEach(
|
||||
(key) => (this._helpers.prototype[key] = helpers[key]),
|
||||
);
|
||||
if (!this._helpersConstructor) {
|
||||
this._helpersConstructor = function CollectionDocument(doc) {
|
||||
Object.assign(this, doc);
|
||||
};
|
||||
this._transform = doc => new this._helpersConstructor(doc);
|
||||
}
|
||||
|
||||
Object.keys(helpersMap).forEach(key => {
|
||||
this._helpersConstructor.prototype[key] = helpersMap[key];
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (Mongo && Mongo.Collection && Mongo.Collection.prototype && !Mongo.Collection.prototype.attachSchema) {
|
||||
Mongo.Collection.prototype.attachSchema = function attachSchema(schema) {
|
||||
if (schema && schema._schemaDefinition) {
|
||||
schema._schema = schema._schemaDefinition;
|
||||
}
|
||||
this._simpleSchema = schema;
|
||||
return this;
|
||||
};
|
||||
}
|
||||
|
||||
if (Mongo && Mongo.Collection && Mongo.Collection.prototype && !Mongo.Collection.prototype.simpleSchema) {
|
||||
Mongo.Collection.prototype.simpleSchema = function simpleSchema() {
|
||||
return this._simpleSchema;
|
||||
};
|
||||
}
|
||||
|
||||
if (Mongo && Mongo.Collection && CollectionHooks && !Mongo.Collection._wekanHookBootstrapPatched) {
|
||||
const OriginalCollection = Mongo.Collection;
|
||||
const originalExtendCollectionInstance = CollectionHooks.extendCollectionInstance;
|
||||
|
||||
function ensureHookSurface(collection, constructor) {
|
||||
if (!collection || collection._wekanHookSurfaceReady) {
|
||||
return collection;
|
||||
}
|
||||
|
||||
const safeConstructor =
|
||||
constructor && constructor.prototype ? constructor : OriginalCollection;
|
||||
|
||||
if (
|
||||
Meteor &&
|
||||
Meteor.isServer &&
|
||||
(!collection._collection ||
|
||||
typeof collection._collection.insertAsync !== 'function' ||
|
||||
typeof collection._collection.updateAsync !== 'function' ||
|
||||
typeof collection._collection.removeAsync !== 'function')
|
||||
) {
|
||||
Object.defineProperty(collection, '_collection', {
|
||||
value: collection,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
originalExtendCollectionInstance.call(CollectionHooks, collection, safeConstructor);
|
||||
Object.defineProperty(collection, '_wekanHookSurfaceReady', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
});
|
||||
return collection;
|
||||
}
|
||||
|
||||
CollectionHooks.extendCollectionInstance = function extendCollectionInstance(collection, constructor) {
|
||||
return ensureHookSurface(collection, constructor);
|
||||
};
|
||||
|
||||
function PatchedCollection(...args) {
|
||||
const ret = OriginalCollection.apply(this, args);
|
||||
const collection =
|
||||
ret && typeof ret === 'object' ? ret : this;
|
||||
|
||||
ensureHookSurface(collection, OriginalCollection);
|
||||
return ret;
|
||||
}
|
||||
|
||||
PatchedCollection.prototype = OriginalCollection.prototype;
|
||||
PatchedCollection.prototype.constructor = PatchedCollection;
|
||||
|
||||
Object.keys(OriginalCollection).forEach(key => {
|
||||
PatchedCollection[key] = OriginalCollection[key];
|
||||
});
|
||||
|
||||
Object.defineProperty(PatchedCollection, '_wekanHookBootstrapPatched', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Mongo.Collection = PatchedCollection;
|
||||
if (Meteor) {
|
||||
Meteor.Collection = PatchedCollection;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
|
||||
class ReactiveValueCache {
|
||||
constructor(compare, shouldStop) {
|
||||
this.shouldStop = shouldStop || (() => true);
|
||||
this.compare = compare || ((a, b) => a === b);
|
||||
this.values = {};
|
||||
this.deps = {};
|
||||
}
|
||||
|
||||
ensureDependency(key) {
|
||||
if (!this.deps[key]) {
|
||||
this.deps[key] = new Tracker.Dependency();
|
||||
}
|
||||
return this.deps[key];
|
||||
}
|
||||
|
||||
checkDeletion(key) {
|
||||
const dep = this.ensureDependency(key);
|
||||
if (dep.hasDependents()) {
|
||||
return false;
|
||||
}
|
||||
delete this.values[key];
|
||||
delete this.deps[key];
|
||||
return true;
|
||||
}
|
||||
|
||||
del(key) {
|
||||
const dep = this.ensureDependency(key);
|
||||
delete this.values[key];
|
||||
if (this.checkDeletion(key)) {
|
||||
return;
|
||||
}
|
||||
dep.changed();
|
||||
}
|
||||
|
||||
set(key, data, bypassCompare) {
|
||||
const dep = this.ensureDependency(key);
|
||||
const current = this.values[key];
|
||||
this.values[key] = data;
|
||||
if (!this.compare(current, data) || bypassCompare) {
|
||||
dep.changed();
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const data = this.values[key];
|
||||
if (Tracker.currentComputation) {
|
||||
const dep = this.ensureDependency(key);
|
||||
dep.depend();
|
||||
Tracker.currentComputation.onStop(() => {
|
||||
if (!this.shouldStop(key)) {
|
||||
return;
|
||||
}
|
||||
this.checkDeletion(key);
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class DataCache {
|
||||
constructor(getData, options) {
|
||||
this.options = {
|
||||
timeout: 60 * 1000,
|
||||
...(typeof options === 'function' ? { compare: options } : options),
|
||||
};
|
||||
this.getData = getData;
|
||||
this.cache = new ReactiveValueCache(this.options.compare, () => false);
|
||||
this.timeouts = {};
|
||||
this.computations = {};
|
||||
}
|
||||
|
||||
ensureComputation(key) {
|
||||
if (this.timeouts[key]) {
|
||||
clearTimeout(this.timeouts[key]);
|
||||
delete this.timeouts[key];
|
||||
}
|
||||
if (this.computations[key] && !this.computations[key].stopped) {
|
||||
return;
|
||||
}
|
||||
this.computations[key] = Tracker.nonreactive(() =>
|
||||
Tracker.autorun(() => {
|
||||
this.cache.set(key, this.getData(key));
|
||||
}),
|
||||
);
|
||||
|
||||
this.computations[key].onInvalidate(() => this.checkStop(key));
|
||||
}
|
||||
|
||||
checkStop(key) {
|
||||
if (this.cache.ensureDependency(key).hasDependents()) {
|
||||
return;
|
||||
}
|
||||
if (this.timeouts[key]) {
|
||||
clearTimeout(this.timeouts[key]);
|
||||
delete this.timeouts[key];
|
||||
}
|
||||
this.timeouts[key] = setTimeout(() => {
|
||||
if (!this.computations[key]) {
|
||||
return;
|
||||
}
|
||||
this.computations[key].stop();
|
||||
delete this.computations[key];
|
||||
this.cache.del(key);
|
||||
}, this.options.timeout);
|
||||
}
|
||||
|
||||
get(key) {
|
||||
if (!Tracker.currentComputation) {
|
||||
let data = this.cache.get(key);
|
||||
if (data == null) {
|
||||
data = this.getData(key);
|
||||
this.cache.set(key, data);
|
||||
this.checkStop(key);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
this.ensureComputation(key);
|
||||
const data = this.cache.get(key);
|
||||
Tracker.currentComputation.onStop(() => this.checkStop(key));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export { DataCache };
|
||||
export default DataCache;
|
||||
@@ -1,10 +1,79 @@
|
||||
import { DataCache } from '@wekanteam/meteor-reactive-cache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { EJSON } from 'meteor/ejson';
|
||||
import { DataCache } from '/imports/lib/dataCache';
|
||||
import { groupBy, indexBy } from '/imports/lib/collectionHelpers';
|
||||
import Settings from '../models/settings';
|
||||
|
||||
function lazyCollectionProxy(loadCollection) {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
const collection = loadCollection();
|
||||
const value = collection[prop];
|
||||
return typeof value === 'function' ? value.bind(collection) : value;
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
const collection = loadCollection();
|
||||
collection[prop] = value;
|
||||
return true;
|
||||
},
|
||||
has(_target, prop) {
|
||||
return prop in loadCollection();
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(loadCollection());
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, prop) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
loadCollection(),
|
||||
prop,
|
||||
);
|
||||
if (descriptor) {
|
||||
return descriptor;
|
||||
}
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: loadCollection()[prop],
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const Actions = lazyCollectionProxy(() => require('/models/actions').default);
|
||||
const Activities = lazyCollectionProxy(() => require('/models/activities').default);
|
||||
const Attachments = lazyCollectionProxy(() => require('/models/attachments').default);
|
||||
const Avatars = lazyCollectionProxy(() => require('/models/avatars').default);
|
||||
const Boards = lazyCollectionProxy(() => require('/models/boards').default);
|
||||
const CardCommentReactions = lazyCollectionProxy(
|
||||
() => require('/models/cardCommentReactions').default,
|
||||
);
|
||||
const CardComments = lazyCollectionProxy(() => require('/models/cardComments').default);
|
||||
const Cards = lazyCollectionProxy(() => require('/models/cards').default);
|
||||
const ChecklistItems = lazyCollectionProxy(() => require('/models/checklistItems').default);
|
||||
const Checklists = lazyCollectionProxy(() => require('/models/checklists').default);
|
||||
const CustomFields = lazyCollectionProxy(() => require('/models/customFields').default);
|
||||
const ImpersonatedUsers = lazyCollectionProxy(
|
||||
() => require('/models/impersonatedUsers').default,
|
||||
);
|
||||
const Integrations = lazyCollectionProxy(() => require('/models/integrations').default);
|
||||
const InvitationCodes = lazyCollectionProxy(() => require('/models/invitationCodes').default);
|
||||
const Lists = lazyCollectionProxy(() => require('/models/lists').default);
|
||||
const Org = lazyCollectionProxy(() => require('/models/org').default);
|
||||
const Rules = lazyCollectionProxy(() => require('/models/rules').default);
|
||||
const SessionData = lazyCollectionProxy(() => require('/models/usersessiondata').default);
|
||||
const Settings = lazyCollectionProxy(() => require('/models/settings').default);
|
||||
const Swimlanes = lazyCollectionProxy(() => require('/models/swimlanes').default);
|
||||
const Team = lazyCollectionProxy(() => require('/models/team').default);
|
||||
const Translation = lazyCollectionProxy(() => require('/models/translation').default);
|
||||
const Triggers = lazyCollectionProxy(() => require('/models/triggers').default);
|
||||
const Users = lazyCollectionProxy(() => require('/models/users').default);
|
||||
|
||||
// Server isn't reactive, so search for the data always.
|
||||
// All methods are async for Meteor 3.0 compatibility.
|
||||
ReactiveCacheServer = {
|
||||
const ReactiveCacheServer = {
|
||||
async getBoard(idOrFirstObjectSelector = {}, options = {}) {
|
||||
const ret = await Boards.findOneAsync(idOrFirstObjectSelector, options);
|
||||
return ret;
|
||||
@@ -136,7 +205,7 @@ ReactiveCacheServer = {
|
||||
return ret;
|
||||
},
|
||||
async getAvatar(idOrFirstObjectSelector = {}, options = {}) {
|
||||
const ret = Avatars.findOne(idOrFirstObjectSelector, options);
|
||||
const ret = await Avatars.findOneAsync(idOrFirstObjectSelector, options);
|
||||
return ret;
|
||||
},
|
||||
async getAvatars(selector = {}, options = {}, getQuery = false) {
|
||||
@@ -291,7 +360,7 @@ ReactiveCacheServer = {
|
||||
// only the Client is reactive
|
||||
// saving the result has a big advantage if the query is big and often searched for the same data again and again
|
||||
// if the data is changed in the client, the data is saved to the server and depending code is reactive called again
|
||||
ReactiveCacheClient = {
|
||||
const ReactiveCacheClient = {
|
||||
getBoard(idOrFirstObjectSelector = {}, options = {}) {
|
||||
const idOrFirstObjectSelect = { idOrFirstObjectSelector, options };
|
||||
if (!this.__board) {
|
||||
@@ -1016,7 +1085,7 @@ ReactiveCacheClient = {
|
||||
// Methods are NOT async - they return a Promise on server (from async ReactiveCacheServer)
|
||||
// and synchronous data on client (from ReactiveCacheClient).
|
||||
// Server callers must await; client code uses the return value directly.
|
||||
ReactiveCache = {
|
||||
const ReactiveCache = {
|
||||
getBoard(idOrFirstObjectSelector = {}, options = {}) {
|
||||
if (Meteor.isServer) {
|
||||
return ReactiveCacheServer.getBoard(idOrFirstObjectSelector, options);
|
||||
@@ -1417,7 +1486,7 @@ ReactiveCache = {
|
||||
};
|
||||
|
||||
// Server isn't reactive, so search for the data always.
|
||||
ReactiveMiniMongoIndexServer = {
|
||||
const ReactiveMiniMongoIndexServer = {
|
||||
async getSubTasksWithParentId(parentId, addSelect = {}, options = {}) {
|
||||
let ret = [];
|
||||
if (parentId) {
|
||||
@@ -1462,7 +1531,7 @@ ReactiveMiniMongoIndexServer = {
|
||||
};
|
||||
|
||||
// Client side little MiniMongo DB "Index"
|
||||
ReactiveMiniMongoIndexClient = {
|
||||
const ReactiveMiniMongoIndexClient = {
|
||||
getSubTasksWithParentId(parentId, addSelect = {}, options = {}) {
|
||||
let ret = [];
|
||||
if (parentId) {
|
||||
@@ -1581,7 +1650,7 @@ ReactiveMiniMongoIndexClient = {
|
||||
// having this class here has several advantages:
|
||||
// - The Programmer hasn't to care about in which context he call's this class
|
||||
// - having all queries together in 1 class to make it possible to see which queries in Wekan happens, e.g. with console.log
|
||||
ReactiveMiniMongoIndex = {
|
||||
const ReactiveMiniMongoIndex = {
|
||||
getSubTasksWithParentId(parentId, addSelect = {}, options = {}) {
|
||||
let ret;
|
||||
if (Meteor.isServer) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const schemaPackage = require('meteor/aldeed:simple-schema');
|
||||
|
||||
const SimpleSchema =
|
||||
schemaPackage.default || schemaPackage.SimpleSchema || schemaPackage;
|
||||
|
||||
if (!SimpleSchema._wekanExtendedOptions) {
|
||||
SimpleSchema.extendOptions(['index', 'unique']);
|
||||
SimpleSchema._wekanExtendedOptions = true;
|
||||
}
|
||||
|
||||
module.exports = { SimpleSchema };
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
AccessibilitySettings = new Mongo.Collection('accessibilitySettings');
|
||||
const AccessibilitySettings = new Mongo.Collection('accessibilitySettings');
|
||||
|
||||
AccessibilitySettings.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -32,7 +34,6 @@ AccessibilitySettings.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -45,21 +46,4 @@ AccessibilitySettings.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
AccessibilitySettings.allow({
|
||||
async update(userId) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
return user && user.isAdmin;
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await AccessibilitySettings._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
const accessibilitySetting = AccessibilitySettings.findOne({});
|
||||
if (!accessibilitySetting) {
|
||||
AccessibilitySettings.insert({ enabled: false, sort: 0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default AccessibilitySettings;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
AccountSettings = new Mongo.Collection('accountSettings');
|
||||
const AccountSettings = new Mongo.Collection('accountSettings');
|
||||
|
||||
AccountSettings.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -13,7 +15,6 @@ AccountSettings.attachSchema(
|
||||
},
|
||||
sort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
@@ -31,7 +32,6 @@ AccountSettings.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -44,46 +44,6 @@ AccountSettings.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
AccountSettings.allow({
|
||||
async update(userId) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
return user && user.isAdmin;
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await AccountSettings._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
AccountSettings.upsert(
|
||||
{ _id: 'accounts-allowEmailChange' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
booleanValue: false,
|
||||
sort: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
AccountSettings.upsert(
|
||||
{ _id: 'accounts-allowUserNameChange' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
booleanValue: false,
|
||||
sort: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
AccountSettings.upsert(
|
||||
{ _id: 'accounts-allowUserDelete' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
booleanValue: false,
|
||||
sort: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
AccountSettings.helpers({
|
||||
allowEmailChange() {
|
||||
return AccountSettings.findOne('accounts-allowEmailChange').booleanValue;
|
||||
|
||||
+2
-20
@@ -1,19 +1,7 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
Actions = new Mongo.Collection('actions');
|
||||
|
||||
Actions.allow({
|
||||
async insert(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
});
|
||||
const Actions = new Mongo.Collection('actions');
|
||||
|
||||
Actions.before.insert((userId, doc) => {
|
||||
doc.createdAt = new Date();
|
||||
@@ -31,10 +19,4 @@ Actions.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Actions._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default Actions;
|
||||
+3
-356
@@ -1,3 +1,5 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { findWhere, where } from '/imports/lib/collectionHelpers';
|
||||
|
||||
@@ -11,7 +13,7 @@ import { findWhere, where } from '/imports/lib/collectionHelpers';
|
||||
// instance if a user archive a card, and un-archive it a few seconds later we
|
||||
// should remove both activities assuming it was an error the user decided to
|
||||
// revert.
|
||||
Activities = new Mongo.Collection('activities');
|
||||
const Activities = new Mongo.Collection('activities');
|
||||
|
||||
Activities.helpers({
|
||||
board() {
|
||||
@@ -75,359 +77,4 @@ Activities.before.insert((userId, doc) => {
|
||||
doc.modifiedAt = doc.createdAt;
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Activities.after.insert(async (userId, doc) => {
|
||||
const activity = Activities._transform(doc);
|
||||
try {
|
||||
await RulesHelper.executeRules(activity);
|
||||
} catch (e) {
|
||||
console.error('RulesHelper.executeRules error for activity', doc._id, e);
|
||||
}
|
||||
});
|
||||
|
||||
// For efficiency create indexes on the date of creation, and on the date of
|
||||
// creation in conjunction with the card or board id, as corresponding views
|
||||
// are largely used in the App. See #524.
|
||||
Meteor.startup(async () => {
|
||||
await Activities._collection.createIndexAsync({ createdAt: -1 });
|
||||
await Activities._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await Activities._collection.createIndexAsync({ cardId: 1, createdAt: -1 });
|
||||
await Activities._collection.createIndexAsync({ boardId: 1, createdAt: -1 });
|
||||
await Activities._collection.createIndexAsync(
|
||||
{ commentId: 1 },
|
||||
{ partialFilterExpression: { commentId: { $exists: true } } },
|
||||
);
|
||||
await Activities._collection.createIndexAsync(
|
||||
{ attachmentId: 1 },
|
||||
{ partialFilterExpression: { attachmentId: { $exists: true } } },
|
||||
);
|
||||
await Activities._collection.createIndexAsync(
|
||||
{ customFieldId: 1 },
|
||||
{ partialFilterExpression: { customFieldId: { $exists: true } } },
|
||||
);
|
||||
// Label activity did not work yet, unable to edit labels when tried this.
|
||||
//Activities._collection.dropIndex({ labelId: 1 }, { "indexKey": -1 });
|
||||
//Activities._collection.dropIndex({ labelId: 1 }, { partialFilterExpression: { labelId: { $exists: true } } });
|
||||
});
|
||||
|
||||
Activities.after.insert(async (userId, doc) => {
|
||||
const activity = Activities._transform(doc);
|
||||
let participants = [];
|
||||
let watchers = [];
|
||||
let title = 'act-activity-notify';
|
||||
const board = await ReactiveCache.getBoard(activity.boardId);
|
||||
const description = `act-${activity.activityType}`;
|
||||
const params = {
|
||||
activityId: activity._id,
|
||||
};
|
||||
if (activity.userId) {
|
||||
// No need send notification to user of activity
|
||||
// participants = [...new Set([...participants, activity.userId])];
|
||||
const user = await activity.user();
|
||||
if (user) {
|
||||
if (user.getName()) {
|
||||
params.user = user.getName();
|
||||
}
|
||||
if (user.emails) {
|
||||
params.userEmails = user.emails;
|
||||
}
|
||||
if (activity.userId) {
|
||||
params.userId = activity.userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activity.boardId) {
|
||||
if (board.title) {
|
||||
if (board.title.length > 0) {
|
||||
params.board = board.title;
|
||||
} else {
|
||||
params.board = '';
|
||||
}
|
||||
} else {
|
||||
params.board = '';
|
||||
}
|
||||
title = 'act-withBoardTitle';
|
||||
params.url = board.absoluteUrl();
|
||||
params.boardId = activity.boardId;
|
||||
}
|
||||
if (activity.oldBoardId) {
|
||||
const oldBoard = await activity.oldBoard();
|
||||
if (oldBoard) {
|
||||
watchers = [...new Set([...watchers, ...(oldBoard.watchers || [])])];
|
||||
params.oldBoard = oldBoard.title;
|
||||
params.oldBoardId = activity.oldBoardId;
|
||||
}
|
||||
}
|
||||
if (activity.memberId) {
|
||||
participants = [...new Set([...participants, activity.memberId])];
|
||||
params.member = (await activity.member()).getName();
|
||||
}
|
||||
if (activity.listId) {
|
||||
const list = await activity.list();
|
||||
if (list) {
|
||||
if (list.watchers !== undefined) {
|
||||
watchers = [...new Set([...watchers, ...(list.watchers || [])])];
|
||||
}
|
||||
params.list = list.title;
|
||||
params.listId = activity.listId;
|
||||
}
|
||||
}
|
||||
if (activity.oldListId) {
|
||||
const oldList = await activity.oldList();
|
||||
if (oldList) {
|
||||
watchers = [...new Set([...watchers, ...(oldList.watchers || [])])];
|
||||
params.oldList = oldList.title;
|
||||
params.oldListId = activity.oldListId;
|
||||
}
|
||||
}
|
||||
if (activity.oldSwimlaneId) {
|
||||
const oldSwimlane = await activity.oldSwimlane();
|
||||
if (oldSwimlane) {
|
||||
watchers = [...new Set([...watchers, ...(oldSwimlane.watchers || [])])];
|
||||
params.oldSwimlane = oldSwimlane.title;
|
||||
params.oldSwimlaneId = activity.oldSwimlaneId;
|
||||
}
|
||||
}
|
||||
if (activity.cardId) {
|
||||
const card = await activity.card();
|
||||
participants = [...new Set([...participants, card.userId, ...(card.members || [])])];
|
||||
watchers = [...new Set([...watchers, ...(card.watchers || [])])];
|
||||
params.card = card.title;
|
||||
title = 'act-withCardTitle';
|
||||
params.url = card.absoluteUrl();
|
||||
params.cardId = activity.cardId;
|
||||
}
|
||||
if (activity.swimlaneId) {
|
||||
const swimlane = await activity.swimlane();
|
||||
params.swimlane = swimlane.title;
|
||||
params.swimlaneId = activity.swimlaneId;
|
||||
}
|
||||
if (activity.commentId) {
|
||||
const comment = await activity.comment();
|
||||
params.comment = comment.text;
|
||||
let hasMentions = false; // Track if comment has @mentions
|
||||
if (board) {
|
||||
const comment = params.comment;
|
||||
// Build knownUsers with async user lookups
|
||||
const knownUsers = [];
|
||||
for (const member of board.members) {
|
||||
const u = await ReactiveCache.getUser(member.userId);
|
||||
if (u) {
|
||||
member.username = u.username;
|
||||
member.emails = u.emails;
|
||||
}
|
||||
knownUsers.push(member);
|
||||
}
|
||||
// Match @mentions including usernames with @ symbols (like email addresses)
|
||||
// Pattern matches: @username, @user@example.com, @"quoted username"
|
||||
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.@-]+))/gi;
|
||||
let currentMention;
|
||||
|
||||
while ((currentMention = mentionRegex.exec(comment)) !== null) {
|
||||
/*eslint no-unused-vars: ["error", { "varsIgnorePattern": "[iI]gnored" }]*/
|
||||
const [ignored, quoteduser, simple] = currentMention;
|
||||
const username = quoteduser || simple;
|
||||
// Removed the check that prevented self-mentions from creating notifications
|
||||
// Users can now mention themselves in comments to create notifications
|
||||
|
||||
if (activity.boardId && username === 'board_members') {
|
||||
// mentions all board members
|
||||
const validUserIds = [];
|
||||
for (const u of knownUsers) {
|
||||
const user = await ReactiveCache.getUser(u.userId);
|
||||
if (user && user._id) {
|
||||
validUserIds.push(u.userId);
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...validUserIds])];
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.boardId && username === 'board_assignees') {
|
||||
// mentions all assignees of all cards on the board
|
||||
const allCards = await ReactiveCache.getCards({ boardId: activity.boardId });
|
||||
const assigneeIds = [];
|
||||
for (const card of allCards) {
|
||||
if (card.assignees && card.assignees.length > 0) {
|
||||
for (const assigneeId of card.assignees) {
|
||||
// Only add if the user exists and is a board member
|
||||
const user = await ReactiveCache.getUser(assigneeId);
|
||||
if (user && findWhere(knownUsers, { userId: assigneeId })) {
|
||||
assigneeIds.push(assigneeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...assigneeIds])];
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_members') {
|
||||
// mentions all card members if assigned
|
||||
const card = await activity.card();
|
||||
if (card && card.members && card.members.length > 0) {
|
||||
// Filter to only valid users who are board members
|
||||
const validMembers = [];
|
||||
for (const memberId of card.members) {
|
||||
const user = await ReactiveCache.getUser(memberId);
|
||||
if (user && user._id && findWhere(knownUsers, { userId: memberId })) {
|
||||
validMembers.push(memberId);
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...validMembers])];
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_assignees') {
|
||||
// mentions all assignees of the current card
|
||||
const card = await activity.card();
|
||||
if (card && card.assignees && card.assignees.length > 0) {
|
||||
// Filter to only valid users who are board members
|
||||
const validAssignees = [];
|
||||
for (const assigneeId of card.assignees) {
|
||||
const user = await ReactiveCache.getUser(assigneeId);
|
||||
if (user && user._id && findWhere(knownUsers, { userId: assigneeId })) {
|
||||
validAssignees.push(assigneeId);
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...validAssignees])];
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else {
|
||||
const atUser = findWhere(knownUsers, { username });
|
||||
if (!atUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uid = atUser.userId;
|
||||
params.atUsername = username;
|
||||
params.atEmails = atUser.emails;
|
||||
title = 'act-atUserComment';
|
||||
watchers = [...new Set([...watchers, uid])];
|
||||
hasMentions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
params.commentId = comment._id;
|
||||
params.hasMentions = hasMentions; // Store for later use
|
||||
}
|
||||
if (activity.attachmentId) {
|
||||
params.attachment = activity.attachmentName;
|
||||
params.attachmentId = activity.attachmentId;
|
||||
}
|
||||
if (activity.checklistId) {
|
||||
const checklist = await activity.checklist();
|
||||
if (checklist) {
|
||||
if (checklist.title) {
|
||||
params.checklist = checklist.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activity.checklistItemId) {
|
||||
const checklistItem = await activity.checklistItem();
|
||||
if (checklistItem) {
|
||||
if (checklistItem.title) {
|
||||
params.checklistItem = checklistItem.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activity.customFieldId) {
|
||||
const customField = await activity.customField();
|
||||
if (customField) {
|
||||
if (customField.name) {
|
||||
params.customField = customField.name;
|
||||
}
|
||||
if (activity.value) {
|
||||
params.customFieldValue = activity.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Label activity did not work yet, unable to edit labels when tried this.
|
||||
if (activity.labelId) {
|
||||
const label = await activity.label();
|
||||
if (label) {
|
||||
if (label.name) {
|
||||
params.label = label.name;
|
||||
} else if (label.color) {
|
||||
params.label = label.color;
|
||||
}
|
||||
if (label._id) {
|
||||
params.labelId = label._id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
(!activity.timeKey || activity.timeKey === 'dueAt') &&
|
||||
activity.timeValue
|
||||
) {
|
||||
// due time reminder, if it doesn't have old value, it's a brand new set, need some differentiation
|
||||
title = activity.timeOldValue ? 'act-withDue' : 'act-newDue';
|
||||
}
|
||||
['timeValue', 'timeOldValue'].forEach((key) => {
|
||||
// copy time related keys & values to params
|
||||
const value = activity[key];
|
||||
if (value) params[key] = value;
|
||||
});
|
||||
if (board) {
|
||||
const activeMemberIds = (board.members || []).filter(m => m.isActive === true).map(m => m.userId);
|
||||
const BIGEVENTS = process.env.BIGEVENTS_PATTERN; // if environment BIGEVENTS_PATTERN is set, any activityType matching it is important
|
||||
if (BIGEVENTS) {
|
||||
try {
|
||||
const atype = activity.activityType;
|
||||
if (new RegExp(BIGEVENTS).exec(atype)) {
|
||||
watchers = [...new Set([...watchers, ...activeMemberIds])]; // notify all active members for important events
|
||||
}
|
||||
} catch (e) {
|
||||
// passed env var BIGEVENTS_PATTERN is not a valid regex
|
||||
}
|
||||
}
|
||||
|
||||
const watchingUsers = where(board.watchers, { level: 'watching' }).map(x => x.userId);
|
||||
const trackingUsers = where(board.watchers, { level: 'tracking' }).map(x => x.userId);
|
||||
// Only add board watchers if there were no @mentions in the comment
|
||||
// When users are explicitly @mentioned, only notify those users
|
||||
if (!params.hasMentions) {
|
||||
watchers = [...new Set([
|
||||
...watchers,
|
||||
...watchingUsers,
|
||||
...participants.filter(x => trackingUsers.includes(x)),
|
||||
])];
|
||||
}
|
||||
|
||||
// Ensure notifications only go to active members of the current board.
|
||||
watchers = watchers.filter(x => activeMemberIds.includes(x));
|
||||
}
|
||||
(await Notifications.getUsers(watchers)).forEach((user) => {
|
||||
// Skip if user is undefined or doesn't have an _id (e.g., deleted user or invalid ID)
|
||||
if (!user || !user._id) return;
|
||||
|
||||
// Don't notify a user of their own behavior, EXCEPT for self-mentions
|
||||
const isSelfMention = (user._id === userId && title === 'act-atUserComment');
|
||||
if (user._id !== userId || isSelfMention) {
|
||||
Notifications.notify(user, title, description, params);
|
||||
}
|
||||
});
|
||||
|
||||
const integrations = await ReactiveCache.getIntegrations({
|
||||
boardId: { $in: [board._id, Integrations.Const.GLOBAL_WEBHOOK_ID] },
|
||||
// type: 'outgoing-webhooks', // all types
|
||||
enabled: true,
|
||||
activities: { $in: [description, 'all'] },
|
||||
});
|
||||
if (integrations.length > 0) {
|
||||
params.watchers = watchers;
|
||||
integrations.forEach((integration) => {
|
||||
Meteor.call(
|
||||
'outgoingWebhooks',
|
||||
integration,
|
||||
description,
|
||||
params,
|
||||
() => {
|
||||
return;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default Activities;
|
||||
+3
-20
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Announcements = new Mongo.Collection('announcements');
|
||||
const Announcements = new Mongo.Collection('announcements');
|
||||
|
||||
Announcements.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -18,7 +20,6 @@ Announcements.attachSchema(
|
||||
},
|
||||
sort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
@@ -36,7 +37,6 @@ Announcements.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -49,21 +49,4 @@ Announcements.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
Announcements.allow({
|
||||
async update(userId) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
return user && user.isAdmin;
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Announcements._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
const announcements = Announcements.findOne({});
|
||||
if (!announcements) {
|
||||
Announcements.insert({ enabled: false, sort: 0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default Announcements;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
const AttachmentMigrationStatus = new Mongo.Collection('attachmentMigrationStatus');
|
||||
|
||||
export default AttachmentMigrationStatus;
|
||||
@@ -1,12 +1,9 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import SimpleSchema from 'meteor/aldeed:simple-schema';
|
||||
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS } from '/models/lib/fileStoreStrategy';
|
||||
// DISABLED: S3 storage removed due to Node.js compatibility
|
||||
// import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS } from '/models/lib/fileStoreConstants';
|
||||
|
||||
// Attachment Storage Settings Collection
|
||||
AttachmentStorageSettings = new Mongo.Collection('attachmentStorageSettings');
|
||||
const AttachmentStorageSettings = new Mongo.Collection('attachmentStorageSettings');
|
||||
|
||||
// Schema for attachment storage settings
|
||||
AttachmentStorageSettings.attachSchema(
|
||||
@@ -253,140 +250,4 @@ AttachmentStorageSettings.helpers({
|
||||
}
|
||||
});
|
||||
|
||||
// Server-side methods
|
||||
if (Meteor.isServer) {
|
||||
// Get or create default settings
|
||||
Meteor.methods({
|
||||
async 'getAttachmentStorageSettings'() {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||
}
|
||||
|
||||
let settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings
|
||||
settings = {
|
||||
defaultStorage: STORAGE_NAME_FILESYSTEM,
|
||||
storageConfig: {
|
||||
filesystem: {
|
||||
enabled: true,
|
||||
path: process.env.WRITABLE_PATH ? `${process.env.WRITABLE_PATH}/attachments` : '/data/attachments'
|
||||
},
|
||||
gridfs: {
|
||||
enabled: true
|
||||
},
|
||||
// DISABLED: S3 storage removed due to Node.js compatibility
|
||||
// s3: {
|
||||
// enabled: false
|
||||
// }
|
||||
},
|
||||
uploadSettings: {
|
||||
maxFileSize: process.env.ATTACHMENTS_UPLOAD_MAX_SIZE ? parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) : 0,
|
||||
allowedMimeTypes: process.env.ATTACHMENTS_UPLOAD_MIME_TYPES ? process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',').map(t => t.trim()) : []
|
||||
},
|
||||
migrationSettings: {
|
||||
autoMigrate: false,
|
||||
batchSize: 10,
|
||||
delayMs: 1000,
|
||||
cpuThreshold: 70
|
||||
},
|
||||
createdBy: this.userId,
|
||||
updatedBy: this.userId
|
||||
};
|
||||
|
||||
await AttachmentStorageSettings.insertAsync(settings);
|
||||
settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
}
|
||||
|
||||
return settings;
|
||||
},
|
||||
|
||||
async 'updateAttachmentStorageSettings'(settings) {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
const schema = AttachmentStorageSettings.simpleSchema();
|
||||
schema.validate(settings);
|
||||
|
||||
// Update settings
|
||||
const result = await AttachmentStorageSettings.upsertAsync(
|
||||
{},
|
||||
{
|
||||
$set: {
|
||||
...settings,
|
||||
updatedBy: this.userId,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async 'getDefaultAttachmentStorage'() {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
return settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
|
||||
},
|
||||
|
||||
async 'setDefaultAttachmentStorage'(storageName) {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||
}
|
||||
|
||||
if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS].includes(storageName)) {
|
||||
throw new Meteor.Error('invalid-storage', 'Invalid storage backend');
|
||||
}
|
||||
|
||||
const result = await AttachmentStorageSettings.upsertAsync(
|
||||
{},
|
||||
{
|
||||
$set: {
|
||||
defaultStorage: storageName,
|
||||
updatedBy: this.userId,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Publication for settings
|
||||
Meteor.publish('attachmentStorageSettings', async function() {
|
||||
if (!this.userId) {
|
||||
return this.ready();
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
return this.ready();
|
||||
}
|
||||
|
||||
return AttachmentStorageSettings.find({});
|
||||
});
|
||||
}
|
||||
|
||||
export default AttachmentStorageSettings;
|
||||
+9
-347
@@ -1,59 +1,11 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { FilesCollection } from 'meteor/ostrio:files';
|
||||
import { isFileValid } from './fileValidation';
|
||||
import { createBucket } from './lib/grid/createBucket';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs } from '/models/lib/attachmentStoreStrategy';
|
||||
// DISABLED: S3 storage strategy removed due to Node.js compatibility
|
||||
// import { AttachmentStoreStrategyS3 } from '/models/lib/attachmentStoreStrategy';
|
||||
import FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS} from '/models/lib/fileStoreStrategy';
|
||||
// DISABLED: S3 storage removed due to Node.js compatibility
|
||||
// import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
|
||||
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
|
||||
import AttachmentStorageSettings from './attachmentStorageSettings';
|
||||
import { generateUniversalAttachmentUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
|
||||
|
||||
let attachmentUploadExternalProgram;
|
||||
let attachmentUploadMimeTypes = [];
|
||||
let attachmentUploadSize = 0;
|
||||
let attachmentBucket;
|
||||
let storagePath;
|
||||
|
||||
if (Meteor.isServer) {
|
||||
attachmentBucket = createBucket('attachments');
|
||||
|
||||
if (process.env.ATTACHMENTS_UPLOAD_MIME_TYPES) {
|
||||
attachmentUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',');
|
||||
attachmentUploadMimeTypes = attachmentUploadMimeTypes.map(value => value.trim());
|
||||
}
|
||||
|
||||
if (process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) {
|
||||
attachmentUploadSize = parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE);
|
||||
|
||||
if (isNaN(attachmentUploadSize)) {
|
||||
attachmentUploadSize = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM) {
|
||||
attachmentUploadExternalProgram = process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM;
|
||||
|
||||
if (!attachmentUploadExternalProgram.includes("{file}")) {
|
||||
attachmentUploadExternalProgram = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'attachments');
|
||||
}
|
||||
|
||||
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(AttachmentStoreStrategyFilesystem, storagePath, AttachmentStoreStrategyGridFs, attachmentBucket);
|
||||
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
|
||||
|
||||
// XXX Enforce a schema for the Attachments FilesCollection
|
||||
// see: https://github.com/VeliovGroup/Meteor-Files/wiki/Schema
|
||||
|
||||
Attachments = new FilesCollection({
|
||||
const Attachments = new FilesCollection({
|
||||
debug: false, // Change to `true` for debugging
|
||||
collectionName: 'attachments',
|
||||
allowClientCode: true,
|
||||
@@ -93,17 +45,11 @@ Attachments = new FilesCollection({
|
||||
// keep the original filename
|
||||
return str;
|
||||
},
|
||||
storagePath() {
|
||||
const ret = fileStoreStrategyFactory.storagePath;
|
||||
return ret;
|
||||
},
|
||||
onBeforeUpload(file) {
|
||||
// SECURITY: Sanitize filename to prevent path traversal attacks
|
||||
// Import sanitizeFilename from fileStoreStrategy - but since we can't import here,
|
||||
// we'll implement a minimal inline version to be safe
|
||||
if (file.name && typeof file.name === 'string') {
|
||||
// Use path.basename to strip directory components and prevent path traversal
|
||||
let safeName = path.basename(file.name);
|
||||
// Strip directory components (client-safe alternative to path.basename)
|
||||
let safeName = file.name.split(/[\\/]/).pop();
|
||||
// Remove null bytes
|
||||
safeName = safeName.replace(/\0/g, '');
|
||||
// Remove path traversal sequences
|
||||
@@ -136,72 +82,6 @@ Attachments = new FilesCollection({
|
||||
|
||||
return true;
|
||||
},
|
||||
async onAfterUpload(fileObj) {
|
||||
// Get default storage backend from settings
|
||||
let defaultStorage = STORAGE_NAME_FILESYSTEM;
|
||||
try {
|
||||
const settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
if (settings) {
|
||||
defaultStorage = settings.getDefaultStorage();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not get attachment storage settings, using default:', error);
|
||||
}
|
||||
|
||||
// Set initial storage to filesystem (temporary)
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
|
||||
});
|
||||
|
||||
this._now = new Date();
|
||||
await Attachments.updateAsync({ _id: fileObj._id }, { $set: { "versions" : fileObj.versions } });
|
||||
await Attachments.updateAsync({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio" : this._now } });
|
||||
|
||||
// Use selected storage backend or copy storage if specified
|
||||
let storageDestination = fileObj.meta.copyStorage || defaultStorage;
|
||||
|
||||
// Only migrate if the destination is different from filesystem
|
||||
if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
|
||||
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
|
||||
}
|
||||
},
|
||||
interceptDownload(http, fileObj, versionName) {
|
||||
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
|
||||
return ret;
|
||||
},
|
||||
onAfterRemove(filesInput) {
|
||||
const files = normalizeRemovedFiles(filesInput);
|
||||
|
||||
files.forEach(fileObj => {
|
||||
if (!fileObj || !fileObj.versions) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
|
||||
});
|
||||
});
|
||||
},
|
||||
// We authorize the attachment download either:
|
||||
// - if the board is public, everyone (even unconnected) can download it
|
||||
// - if the board is private, only board members can download it
|
||||
protected(fileObj) {
|
||||
// file may have been deleted already again after upload validation failed
|
||||
if (!fileObj) {
|
||||
return false;
|
||||
}
|
||||
// ostrio:files calls this synchronously inside _checkAccess (no await support),
|
||||
// so use synchronous Boards.findOne which works within the Fiber context
|
||||
// that ostrio:files sets up via Meteor.bindEnvironment.
|
||||
const board = Boards.findOne(fileObj.meta.boardId);
|
||||
if (!board) {
|
||||
return false;
|
||||
}
|
||||
if (board.isPublic()) {
|
||||
return true;
|
||||
}
|
||||
return board.hasMember(this.userId);
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeRemovedFiles(filesInput) {
|
||||
@@ -236,230 +116,12 @@ function normalizeRemovedFiles(filesInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Attachments.allow({
|
||||
async insert(userId, fileObj) {
|
||||
// ReadOnly users cannot upload attachments
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(fileObj.boardId));
|
||||
},
|
||||
async update(userId, fileObj, fields) {
|
||||
// SECURITY: The 'name' field is sanitized in onBeforeUpload and server-side methods,
|
||||
// but we block direct client-side $set operations on 'versions.*.path' to prevent
|
||||
// path traversal attacks via storage migration exploits.
|
||||
// Export normalizeRemovedFiles for use in server file
|
||||
export { normalizeRemovedFiles };
|
||||
|
||||
// Block direct updates to version paths (the attack vector)
|
||||
const hasPathUpdate = fields.some(field => field.includes('versions') && field.includes('path'));
|
||||
if (hasPathUpdate) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked attempt to update attachment version paths:', fields);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow normal updates for file upload/management
|
||||
const allowedFields = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta', 'versions'];
|
||||
const isAllowedField = fields.every(field => {
|
||||
// Allow field itself or nested properties like 'versions.original'
|
||||
const baseField = field.split('.')[0];
|
||||
return allowedFields.includes(baseField);
|
||||
});
|
||||
|
||||
if (!isAllowedField) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked attempt to update restricted attachment fields:', fields);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ReadOnly users cannot update attachments
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(fileObj.boardId));
|
||||
},
|
||||
async remove(userId, fileObj) {
|
||||
// Additional security check: ensure the file belongs to the board the user has access to
|
||||
if (!fileObj || !fileObj.boardId) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked attachment removal: file has no boardId');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const board = await Boards.findOneAsync(fileObj.boardId);
|
||||
if (!board) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked attachment removal: board not found');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ReadOnly users cannot delete attachments
|
||||
return allowIsBoardMemberWithWriteAccess(userId, board);
|
||||
},
|
||||
fetch: ['meta', 'boardId'],
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
// Validate image URL to prevent SVG-based DoS attacks
|
||||
validateImageUrl(imageUrl) {
|
||||
check(imageUrl, String);
|
||||
|
||||
if (!imageUrl) {
|
||||
return { valid: false, reason: 'Empty URL' };
|
||||
}
|
||||
|
||||
// Block SVG files and data URIs
|
||||
if (imageUrl.endsWith('.svg') || imageUrl.startsWith('data:image/svg')) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked potentially malicious SVG image URL:', imageUrl);
|
||||
}
|
||||
return { valid: false, reason: 'SVG images are blocked for security reasons' };
|
||||
}
|
||||
|
||||
// Block data URIs that could contain malicious content
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked data URI image URL:', imageUrl);
|
||||
}
|
||||
return { valid: false, reason: 'Data URIs are blocked for security reasons' };
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const url = new URL(imageUrl);
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { valid: false, reason: 'Invalid URL format' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
},
|
||||
async moveAttachmentToStorage(fileObjId, storageDestination) {
|
||||
check(fileObjId, String);
|
||||
check(storageDestination, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
||||
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
||||
}
|
||||
|
||||
// Allowlist storage destinations
|
||||
const allowedDestinations = ['fs', 'gridfs', 's3'];
|
||||
if (!allowedDestinations.includes(storageDestination)) {
|
||||
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
|
||||
}
|
||||
|
||||
moveToStorage(fileObj, storageDestination, fileStoreStrategyFactory);
|
||||
},
|
||||
async renameAttachment(fileObjId, newName) {
|
||||
check(fileObjId, String);
|
||||
check(newName, String);
|
||||
|
||||
const currentUserId = Meteor.userId();
|
||||
if (!currentUserId) {
|
||||
throw new Meteor.Error('not-authorized', 'User must be logged in');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('file-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
// Verify the user has permission to modify this attachment
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||
}
|
||||
|
||||
if (!allowIsBoardMember(currentUserId, board)) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn(`Blocked unauthorized attachment rename attempt: user ${currentUserId} tried to rename attachment ${fileObjId} in board ${fileObj.boardId}`);
|
||||
}
|
||||
throw new Meteor.Error('not-authorized', 'You do not have permission to modify this attachment');
|
||||
}
|
||||
|
||||
rename(fileObj, newName, fileStoreStrategyFactory);
|
||||
},
|
||||
async validateAttachment(fileObjId) {
|
||||
check(fileObjId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
||||
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
||||
}
|
||||
|
||||
const isValid = await isFileValid(fileObj, attachmentUploadMimeTypes, attachmentUploadSize, attachmentUploadExternalProgram);
|
||||
|
||||
if (!isValid) {
|
||||
Attachments.remove(fileObjId);
|
||||
}
|
||||
},
|
||||
async validateAttachmentAndMoveToStorage(fileObjId, storageDestination) {
|
||||
check(fileObjId, String);
|
||||
check(storageDestination, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
||||
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
||||
}
|
||||
|
||||
// Allowlist storage destinations
|
||||
const allowedDestinations = ['fs', 'gridfs', 's3'];
|
||||
if (!allowedDestinations.includes(storageDestination)) {
|
||||
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
|
||||
}
|
||||
|
||||
await Meteor.callAsync('validateAttachment', fileObjId);
|
||||
|
||||
const fileObjAfter = await ReactiveCache.getAttachment(fileObjId);
|
||||
|
||||
if (fileObjAfter) {
|
||||
Meteor.defer(() => Meteor.call('moveAttachmentToStorage', fileObjId, storageDestination));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Attachments.collection.createIndexAsync({ 'meta.cardId': 1 });
|
||||
const storagePath = fileStoreStrategyFactory.storagePath;
|
||||
if (!fs.existsSync(storagePath)) {
|
||||
console.log("create storagePath because it doesn't exist: " + storagePath);
|
||||
fs.mkdirSync(storagePath, { recursive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add backward compatibility methods - available on both client and server
|
||||
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
|
||||
Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
|
||||
// Backward compatibility stubs (overridden in attachments.server.js with real implementations)
|
||||
Attachments.getAttachmentWithBackwardCompatibility = async () => null;
|
||||
Attachments.getAttachmentsWithBackwardCompatibility = async () => [];
|
||||
|
||||
// Override Attachments.link to use universal short URLs and to bypass
|
||||
// the `check(fileRef, Object)` inside the original which fails when fileRef is
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { check } from 'meteor/check';
|
||||
import { isFileValid } from './fileValidation';
|
||||
import { createBucket } from './lib/grid/createBucket';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs } from '/models/lib/attachmentStoreStrategy';
|
||||
import FileStoreStrategyFactory, { moveToStorage, rename, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS } from '/models/lib/fileStoreStrategy';
|
||||
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
|
||||
import AttachmentStorageSettings from './attachmentStorageSettings';
|
||||
import Attachments, { normalizeRemovedFiles } from './attachments';
|
||||
import Boards from '/models/boards';
|
||||
import { allowIsBoardMember } from '/server/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-only configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let attachmentUploadExternalProgram;
|
||||
let attachmentUploadMimeTypes = [];
|
||||
let attachmentUploadSize = 0;
|
||||
|
||||
const attachmentBucket = createBucket('attachments');
|
||||
const storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'attachments');
|
||||
|
||||
if (process.env.ATTACHMENTS_UPLOAD_MIME_TYPES) {
|
||||
attachmentUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',');
|
||||
attachmentUploadMimeTypes = attachmentUploadMimeTypes.map(value => value.trim());
|
||||
}
|
||||
|
||||
if (process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) {
|
||||
attachmentUploadSize = parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE);
|
||||
|
||||
if (isNaN(attachmentUploadSize)) {
|
||||
attachmentUploadSize = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM) {
|
||||
attachmentUploadExternalProgram = process.env.ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM;
|
||||
|
||||
if (!attachmentUploadExternalProgram.includes("{file}")) {
|
||||
attachmentUploadExternalProgram = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File store strategy factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(
|
||||
AttachmentStoreStrategyFilesystem, storagePath,
|
||||
AttachmentStoreStrategyGridFs, attachmentBucket,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assign server-only FilesCollection callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Attachments.storagePath = function () {
|
||||
return fileStoreStrategyFactory.storagePath;
|
||||
};
|
||||
|
||||
Attachments.onAfterUpload = async function (fileObj) {
|
||||
// Get default storage backend from settings
|
||||
let defaultStorage = STORAGE_NAME_FILESYSTEM;
|
||||
try {
|
||||
const settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
if (settings) {
|
||||
defaultStorage = settings.getDefaultStorage();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not get attachment storage settings, using default:', error);
|
||||
}
|
||||
|
||||
// Set initial storage to filesystem (temporary)
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
|
||||
});
|
||||
|
||||
this._now = new Date();
|
||||
await Attachments.updateAsync({ _id: fileObj._id }, { $set: { "versions": fileObj.versions } });
|
||||
await Attachments.updateAsync({ _id: fileObj.uploadedAtOstrio }, { $set: { "uploadedAtOstrio": this._now } });
|
||||
|
||||
// Use selected storage backend or copy storage if specified
|
||||
let storageDestination = fileObj.meta.copyStorage || defaultStorage;
|
||||
|
||||
// Only migrate if the destination is different from filesystem
|
||||
if (storageDestination !== STORAGE_NAME_FILESYSTEM) {
|
||||
Meteor.defer(() => Meteor.call('validateAttachmentAndMoveToStorage', fileObj._id, storageDestination));
|
||||
}
|
||||
};
|
||||
|
||||
Attachments.interceptDownload = function (http, fileObj, versionName) {
|
||||
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
|
||||
return ret;
|
||||
};
|
||||
|
||||
Attachments.onAfterRemove = function (filesInput) {
|
||||
const files = normalizeRemovedFiles(filesInput);
|
||||
|
||||
files.forEach(fileObj => {
|
||||
if (!fileObj || !fileObj.versions) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// We authorize the attachment download either:
|
||||
// - if the board is public, everyone (even unconnected) can download it
|
||||
// - if the board is private, only board members can download it
|
||||
Attachments.protected = function (fileObj) {
|
||||
// file may have been deleted already again after upload validation failed
|
||||
if (!fileObj) {
|
||||
return false;
|
||||
}
|
||||
// ostrio:files calls this synchronously inside _checkAccess (no await support),
|
||||
// so use synchronous Boards.findOne.
|
||||
const board = Boards.findOne(fileObj.meta.boardId);
|
||||
if (!board) {
|
||||
return false;
|
||||
}
|
||||
if (board.isPublic()) {
|
||||
return true;
|
||||
}
|
||||
return board.hasMember(this.userId);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward compatibility methods (override client stubs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
|
||||
Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meteor methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Meteor.methods({
|
||||
// Validate image URL to prevent SVG-based DoS attacks
|
||||
validateImageUrl(imageUrl) {
|
||||
check(imageUrl, String);
|
||||
|
||||
if (!imageUrl) {
|
||||
return { valid: false, reason: 'Empty URL' };
|
||||
}
|
||||
|
||||
// Block SVG files and data URIs
|
||||
if (imageUrl.endsWith('.svg') || imageUrl.startsWith('data:image/svg')) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked potentially malicious SVG image URL:', imageUrl);
|
||||
}
|
||||
return { valid: false, reason: 'SVG images are blocked for security reasons' };
|
||||
}
|
||||
|
||||
// Block data URIs that could contain malicious content
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Blocked data URI image URL:', imageUrl);
|
||||
}
|
||||
return { valid: false, reason: 'Data URIs are blocked for security reasons' };
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const url = new URL(imageUrl);
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { valid: false, reason: 'Invalid URL format' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
},
|
||||
async moveAttachmentToStorage(fileObjId, storageDestination) {
|
||||
check(fileObjId, String);
|
||||
check(storageDestination, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
||||
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
||||
}
|
||||
|
||||
// Allowlist storage destinations
|
||||
const allowedDestinations = ['fs', 'gridfs', 's3'];
|
||||
if (!allowedDestinations.includes(storageDestination)) {
|
||||
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
|
||||
}
|
||||
|
||||
moveToStorage(fileObj, storageDestination, fileStoreStrategyFactory);
|
||||
},
|
||||
async renameAttachment(fileObjId, newName) {
|
||||
check(fileObjId, String);
|
||||
check(newName, String);
|
||||
|
||||
const currentUserId = this.userId;
|
||||
if (!currentUserId) {
|
||||
throw new Meteor.Error('not-authorized', 'User must be logged in');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('file-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
// Verify the user has permission to modify this attachment
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||
}
|
||||
|
||||
if (!allowIsBoardMember(currentUserId, board)) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn(`Blocked unauthorized attachment rename attempt: user ${currentUserId} tried to rename attachment ${fileObjId} in board ${fileObj.boardId}`);
|
||||
}
|
||||
throw new Meteor.Error('not-authorized', 'You do not have permission to modify this attachment');
|
||||
}
|
||||
|
||||
rename(fileObj, newName, fileStoreStrategyFactory);
|
||||
},
|
||||
async validateAttachment(fileObjId) {
|
||||
check(fileObjId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
||||
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
||||
}
|
||||
|
||||
const isValid = await isFileValid(fileObj, attachmentUploadMimeTypes, attachmentUploadSize, attachmentUploadExternalProgram);
|
||||
|
||||
if (!isValid) {
|
||||
await Attachments.removeAsync(fileObjId);
|
||||
}
|
||||
},
|
||||
async validateAttachmentAndMoveToStorage(fileObjId, storageDestination) {
|
||||
check(fileObjId, String);
|
||||
check(storageDestination, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const fileObj = await ReactiveCache.getAttachment(fileObjId);
|
||||
if (!fileObj) {
|
||||
throw new Meteor.Error('attachment-not-found', 'Attachment not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(fileObj.boardId);
|
||||
if (!board || !board.isVisibleBy({ _id: this.userId })) {
|
||||
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
||||
}
|
||||
|
||||
// Allowlist storage destinations
|
||||
const allowedDestinations = ['fs', 'gridfs', 's3'];
|
||||
if (!allowedDestinations.includes(storageDestination)) {
|
||||
throw new Meteor.Error('invalid-storage-destination', 'Invalid storage destination');
|
||||
}
|
||||
|
||||
await Meteor.callAsync('validateAttachment', fileObjId);
|
||||
|
||||
const fileObjAfter = await ReactiveCache.getAttachment(fileObjId);
|
||||
|
||||
if (fileObjAfter) {
|
||||
Meteor.defer(() => Meteor.call('moveAttachmentToStorage', fileObjId, storageDestination));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Attachments.collection.createIndexAsync({ 'meta.cardId': 1 });
|
||||
const sp = fileStoreStrategyFactory.storagePath;
|
||||
if (!fs.existsSync(sp)) {
|
||||
console.log("create storagePath because it doesn't exist: " + sp);
|
||||
fs.mkdirSync(sp, { recursive: true });
|
||||
}
|
||||
});
|
||||
+9
-114
@@ -1,52 +1,13 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { FilesCollection } from 'meteor/ostrio:files';
|
||||
import { formatFleURL } from 'meteor/ostrio:files/lib';
|
||||
import { isFileValid } from './fileValidation';
|
||||
import { createBucket } from './lib/grid/createBucket';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
|
||||
import { generateUniversalAvatarUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
|
||||
import { generateUniversalAvatarUrl } from '/models/lib/universalUrlGenerator';
|
||||
|
||||
const filesize = require('filesize');
|
||||
const { filesize } = require('filesize');
|
||||
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
|
||||
|
||||
let avatarsUploadExternalProgram;
|
||||
let avatarsUploadMimeTypes = [];
|
||||
let avatarsUploadSize = 72000;
|
||||
let avatarsBucket;
|
||||
let storagePath;
|
||||
|
||||
if (Meteor.isServer) {
|
||||
if (process.env.AVATARS_UPLOAD_MIME_TYPES) {
|
||||
avatarsUploadMimeTypes = process.env.AVATARS_UPLOAD_MIME_TYPES.split(',');
|
||||
avatarsUploadMimeTypes = avatarsUploadMimeTypes.map(value => value.trim());
|
||||
}
|
||||
|
||||
if (process.env.AVATARS_UPLOAD_MAX_SIZE) {
|
||||
avatarsUploadSize_ = parseInt(process.env.AVATARS_UPLOAD_MAX_SIZE);
|
||||
|
||||
if (typeof avatarsUploadSize_ === 'number' && avatarsUploadSize_ > 0) {
|
||||
avatarsUploadSize = avatarsUploadSize_;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM) {
|
||||
avatarsUploadExternalProgram = process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM;
|
||||
|
||||
if (!avatarsUploadExternalProgram.includes("{file}")) {
|
||||
avatarsUploadExternalProgram = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
avatarsBucket = createBucket('avatars');
|
||||
storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars');
|
||||
}
|
||||
|
||||
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket);
|
||||
|
||||
Avatars = new FilesCollection({
|
||||
const Avatars = new FilesCollection({
|
||||
debug: false, // Change to `true` for debugging
|
||||
collectionName: 'avatars',
|
||||
allowClientCode: true,
|
||||
@@ -81,10 +42,6 @@ Avatars = new FilesCollection({
|
||||
// keep the original filename
|
||||
return str;
|
||||
},
|
||||
storagePath() {
|
||||
const ret = fileStoreStrategyFactory.storagePath;
|
||||
return ret;
|
||||
},
|
||||
onBeforeUpload(file) {
|
||||
// Block SVG files for avatars to prevent XSS attacks
|
||||
if (file.name && file.name.toLowerCase().endsWith('.svg')) {
|
||||
@@ -104,55 +61,7 @@ Avatars = new FilesCollection({
|
||||
if (file.size <= avatarsUploadSize && file.type.startsWith('image/')) {
|
||||
return true;
|
||||
}
|
||||
return TAPi18n.__('avatar-too-big', {size: filesize(avatarsUploadSize)});
|
||||
},
|
||||
async onAfterUpload(fileObj) {
|
||||
// current storage is the filesystem, update object and database
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
|
||||
});
|
||||
|
||||
Avatars.update({ _id: fileObj._id }, { $set: { "versions": fileObj.versions } });
|
||||
|
||||
const isValid = await isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram);
|
||||
|
||||
if (isValid) {
|
||||
// Set avatar URL using universal URL generator (URL-agnostic)
|
||||
const universalUrl = generateUniversalAvatarUrl(fileObj._id);
|
||||
const user = await ReactiveCache.getUser(fileObj.userId);
|
||||
user.setAvatarUrl(universalUrl);
|
||||
} else {
|
||||
Avatars.remove(fileObj._id);
|
||||
}
|
||||
},
|
||||
interceptDownload(http, fileObj, versionName) {
|
||||
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
|
||||
return ret;
|
||||
},
|
||||
async onBeforeRemove(filesInput) {
|
||||
const files = normalizeRemovedFiles(filesInput);
|
||||
|
||||
for (const fileObj of files) {
|
||||
if (fileObj && fileObj.userId) {
|
||||
const user = await ReactiveCache.getUser(fileObj.userId);
|
||||
user.setAvatarUrl('');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onAfterRemove(filesInput) {
|
||||
const files = normalizeRemovedFiles(filesInput);
|
||||
|
||||
files.forEach(fileObj => {
|
||||
if (!fileObj || !fileObj.versions) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
|
||||
});
|
||||
});
|
||||
return getTAPi18n().__('avatar-too-big', {size: filesize(avatarsUploadSize)});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -188,25 +97,11 @@ function normalizeRemovedFiles(filesInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isOwner(userId, doc) {
|
||||
return userId && userId === doc.userId;
|
||||
}
|
||||
// Export normalizeRemovedFiles and avatarsUploadSize setter for use in server file
|
||||
export { normalizeRemovedFiles };
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Avatars.allow({
|
||||
insert: isOwner,
|
||||
update: isOwner,
|
||||
remove: isOwner,
|
||||
fetch: ['userId'],
|
||||
});
|
||||
|
||||
Meteor.startup(() => {
|
||||
const storagePath = fileStoreStrategyFactory.storagePath;
|
||||
if (!fs.existsSync(storagePath)) {
|
||||
console.log("create storagePath because it doesn't exist: " + storagePath);
|
||||
fs.mkdirSync(storagePath, { recursive: true });
|
||||
}
|
||||
});
|
||||
export function setAvatarsUploadSize(size) {
|
||||
avatarsUploadSize = size;
|
||||
}
|
||||
|
||||
// Override the link method to use universal URLs
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { isFileValid } from './fileValidation';
|
||||
import { createBucket } from './lib/grid/createBucket';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
|
||||
import { generateUniversalAvatarUrl } from '/models/lib/universalUrlGenerator';
|
||||
import Avatars, { normalizeRemovedFiles, setAvatarsUploadSize } from './avatars';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-only configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let avatarsUploadExternalProgram;
|
||||
let avatarsUploadMimeTypes = [];
|
||||
let avatarsUploadSize = 72000;
|
||||
|
||||
if (process.env.AVATARS_UPLOAD_MIME_TYPES) {
|
||||
avatarsUploadMimeTypes = process.env.AVATARS_UPLOAD_MIME_TYPES.split(',');
|
||||
avatarsUploadMimeTypes = avatarsUploadMimeTypes.map(value => value.trim());
|
||||
}
|
||||
|
||||
if (process.env.AVATARS_UPLOAD_MAX_SIZE) {
|
||||
const avatarsUploadSize_ = parseInt(process.env.AVATARS_UPLOAD_MAX_SIZE);
|
||||
|
||||
if (typeof avatarsUploadSize_ === 'number' && avatarsUploadSize_ > 0) {
|
||||
avatarsUploadSize = avatarsUploadSize_;
|
||||
// Sync the upload size to the shared module for onBeforeUpload validation
|
||||
setAvatarsUploadSize(avatarsUploadSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM) {
|
||||
avatarsUploadExternalProgram = process.env.AVATARS_UPLOAD_EXTERNAL_PROGRAM;
|
||||
|
||||
if (!avatarsUploadExternalProgram.includes("{file}")) {
|
||||
avatarsUploadExternalProgram = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const avatarsBucket = createBucket('avatars');
|
||||
const storagePath = path.join(process.env.WRITABLE_PATH || process.cwd(), 'avatars');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File store strategy factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const fileStoreStrategyFactory = new FileStoreStrategyFactory(
|
||||
FileStoreStrategyFilesystem, storagePath,
|
||||
FileStoreStrategyGridFs, avatarsBucket,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assign server-only FilesCollection callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Avatars.storagePath = function () {
|
||||
return fileStoreStrategyFactory.storagePath;
|
||||
};
|
||||
|
||||
Avatars.onAfterUpload = async function (fileObj) {
|
||||
// current storage is the filesystem, update object and database
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
|
||||
});
|
||||
|
||||
await Avatars.updateAsync({ _id: fileObj._id }, { $set: { "versions": fileObj.versions } });
|
||||
|
||||
const isValid = await isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram);
|
||||
|
||||
if (isValid) {
|
||||
// Set avatar URL using universal URL generator (URL-agnostic)
|
||||
const universalUrl = generateUniversalAvatarUrl(fileObj._id);
|
||||
const user = await ReactiveCache.getUser(fileObj.userId);
|
||||
user.setAvatarUrl(universalUrl);
|
||||
} else {
|
||||
await Avatars.removeAsync(fileObj._id);
|
||||
}
|
||||
};
|
||||
|
||||
Avatars.interceptDownload = function (http, fileObj, versionName) {
|
||||
const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
|
||||
return ret;
|
||||
};
|
||||
|
||||
Avatars.onBeforeRemove = async function (filesInput) {
|
||||
const files = normalizeRemovedFiles(filesInput);
|
||||
|
||||
for (const fileObj of files) {
|
||||
if (fileObj && fileObj.userId) {
|
||||
const user = await ReactiveCache.getUser(fileObj.userId);
|
||||
user.setAvatarUrl('');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Avatars.onAfterRemove = function (filesInput) {
|
||||
const files = normalizeRemovedFiles(filesInput);
|
||||
|
||||
files.forEach(fileObj => {
|
||||
if (!fileObj || !fileObj.versions) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(fileObj.versions).forEach(versionName => {
|
||||
fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Meteor.startup(() => {
|
||||
const sp = fileStoreStrategyFactory.storagePath;
|
||||
if (!fs.existsSync(sp)) {
|
||||
console.log("create storagePath because it doesn't exist: " + sp);
|
||||
fs.mkdirSync(sp, { recursive: true });
|
||||
}
|
||||
});
|
||||
+133
-903
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,6 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
const commentReactionSchema = new SimpleSchema({
|
||||
reactionCodepoint: {
|
||||
@@ -11,10 +13,12 @@ const commentReactionSchema = new SimpleSchema({
|
||||
}
|
||||
},
|
||||
},
|
||||
userIds: { type: [String], defaultValue: [] }
|
||||
userIds: { type: Array, defaultValue: [] },
|
||||
'userIds.$': { type: String }
|
||||
});
|
||||
|
||||
CardCommentReactions = new Mongo.Collection('card_comment_reactions');
|
||||
const CardCommentReactions = new Mongo.Collection('card_comment_reactions');
|
||||
export default CardCommentReactions;
|
||||
|
||||
/**
|
||||
* All reactions of a card comment
|
||||
@@ -43,28 +47,11 @@ CardCommentReactions.attachSchema(
|
||||
optional: false
|
||||
},
|
||||
reactions: {
|
||||
type: [commentReactionSchema],
|
||||
type: Array,
|
||||
defaultValue: []
|
||||
},
|
||||
'reactions.$': {
|
||||
type: commentReactionSchema,
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
CardCommentReactions.allow({
|
||||
async insert(userId, doc) {
|
||||
return allowIsBoardMember(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
return allowIsBoardMember(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
return allowIsBoardMember(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
fetch: ['boardId'],
|
||||
});
|
||||
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await CardCommentReactions._collection.createIndexAsync({ cardCommentId: 1 }, { unique: true });
|
||||
});
|
||||
}
|
||||
+10
-226
@@ -1,6 +1,10 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import escapeForRegex from 'escape-string-regexp';
|
||||
import DOMPurify from 'dompurify';
|
||||
import Boards from '/models/boards';
|
||||
import CardCommentReactions from '/models/cardCommentReactions';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
// Server-side text sanitization function
|
||||
function sanitizeText(text) {
|
||||
@@ -9,7 +13,7 @@ function sanitizeText(text) {
|
||||
return text.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
CardComments = new Mongo.Collection('card_comments');
|
||||
const CardComments = new Mongo.Collection('card_comments');
|
||||
|
||||
/**
|
||||
* A comment on a card
|
||||
@@ -40,7 +44,6 @@ CardComments.attachSchema(
|
||||
* when was the comment created
|
||||
*/
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert) {
|
||||
@@ -54,7 +57,6 @@ CardComments.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -80,25 +82,11 @@ CardComments.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
CardComments.allow({
|
||||
async insert(userId, doc) {
|
||||
// ReadOnly users cannot add comments. Only members who can comment are allowed.
|
||||
return allowIsBoardMemberCommentOnly(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
return userId === doc.userId || allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
return userId === doc.userId || allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
fetch: ['userId', 'boardId'],
|
||||
});
|
||||
|
||||
CardComments.helpers({
|
||||
copy(newCardId) {
|
||||
this.cardId = newCardId;
|
||||
delete this._id;
|
||||
CardComments.insert(this);
|
||||
return CardComments.insertAsync(this);
|
||||
},
|
||||
|
||||
user() {
|
||||
@@ -139,9 +127,9 @@ CardComments.helpers({
|
||||
|
||||
// If no reaction doc exists yet create otherwise update reaction set
|
||||
if (!!cardCommentReactions) {
|
||||
return CardCommentReactions.update({ _id: cardCommentReactions._id }, { $set: { reactions } });
|
||||
return CardCommentReactions.updateAsync({ _id: cardCommentReactions._id }, { $set: { reactions } });
|
||||
} else {
|
||||
return CardCommentReactions.insert({
|
||||
return CardCommentReactions.insertAsync({
|
||||
boardId: this.boardId,
|
||||
cardCommentId: this._id,
|
||||
cardId: this.cardId,
|
||||
@@ -156,7 +144,7 @@ CardComments.hookOptions.after.update = { fetchPrevious: false };
|
||||
|
||||
async function commentCreation(userId, doc) {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
Activities.insert({
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
activityType: 'addComment',
|
||||
boardId: doc.boardId,
|
||||
@@ -189,208 +177,4 @@ CardComments.textSearch = async (userId, textArray) => {
|
||||
return comments;
|
||||
};
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Comments are often fetched within a card, so we create an index to make these
|
||||
// queries more efficient.
|
||||
Meteor.startup(async () => {
|
||||
await CardComments._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await CardComments._collection.createIndexAsync({ cardId: 1, createdAt: -1 });
|
||||
});
|
||||
|
||||
CardComments.after.insert(async (userId, doc) => {
|
||||
await commentCreation(userId, doc);
|
||||
});
|
||||
|
||||
CardComments.after.update(async (userId, doc) => {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'editComment',
|
||||
boardId: doc.boardId,
|
||||
cardId: doc.cardId,
|
||||
commentId: doc._id,
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
});
|
||||
});
|
||||
|
||||
CardComments.before.remove(async (userId, doc) => {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'deleteComment',
|
||||
boardId: doc.boardId,
|
||||
cardId: doc.cardId,
|
||||
commentId: doc._id,
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
});
|
||||
const activity = await ReactiveCache.getActivity({ commentId: doc._id });
|
||||
if (activity) {
|
||||
Activities.remove(activity._id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//CARD COMMENT REST API
|
||||
if (Meteor.isServer) {
|
||||
/**
|
||||
* @operation get_all_comments
|
||||
* @summary Get all comments attached to a card
|
||||
*
|
||||
* @param {string} boardId the board ID of the card
|
||||
* @param {string} cardId the ID of the card
|
||||
* @return_type [{_id: string,
|
||||
* comment: string,
|
||||
* authorId: string}]
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', async function (
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCardId = req.params.cardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: (await ReactiveCache.getCardComments({
|
||||
boardId: paramBoardId,
|
||||
cardId: paramCardId,
|
||||
})).map(function (doc) {
|
||||
return {
|
||||
_id: doc._id,
|
||||
comment: doc.text,
|
||||
authorId: doc.userId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation get_comment
|
||||
* @summary Get a comment on a card
|
||||
*
|
||||
* @param {string} boardId the board ID of the card
|
||||
* @param {string} cardId the ID of the card
|
||||
* @param {string} commentId the ID of the comment to retrieve
|
||||
* @return_type CardComments
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'GET',
|
||||
'/api/boards/:boardId/cards/:cardId/comments/:commentId',
|
||||
async function (req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCommentId = req.params.commentId;
|
||||
const paramCardId = req.params.cardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: await ReactiveCache.getCardComment({
|
||||
_id: paramCommentId,
|
||||
cardId: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation new_comment
|
||||
* @summary Add a comment on a card
|
||||
*
|
||||
* @param {string} boardId the board ID of the card
|
||||
* @param {string} cardId the ID of the card
|
||||
* @param {string} comment the content of the comment
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'POST',
|
||||
'/api/boards/:boardId/cards/:cardId/comments',
|
||||
async function (req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCardId = req.params.cardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const id = CardComments.direct.insert({
|
||||
userId: req.userId,
|
||||
text: req.body.comment,
|
||||
cardId: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const cardComment = await ReactiveCache.getCardComment({
|
||||
_id: id,
|
||||
cardId: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
await commentCreation(req.userId, cardComment);
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation delete_comment
|
||||
* @summary Delete a comment on a card
|
||||
*
|
||||
* @param {string} boardId the board ID of the card
|
||||
* @param {string} cardId the ID of the card
|
||||
* @param {string} commentId the ID of the comment to delete
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'DELETE',
|
||||
'/api/boards/:boardId/cards/:cardId/comments/:commentId',
|
||||
function (req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCommentId = req.params.commentId;
|
||||
const paramCardId = req.params.cardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
CardComments.remove({
|
||||
_id: paramCommentId,
|
||||
cardId: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramCardId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default CardComments;
|
||||
+176
-1582
File diff suppressed because it is too large.
Load diff
+15
-279
@@ -1,6 +1,10 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Activities from '/models/activities';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
ChecklistItems = new Mongo.Collection('checklistItems');
|
||||
const ChecklistItems = new Mongo.Collection('checklistItems');
|
||||
|
||||
/**
|
||||
* An item in a checklist
|
||||
@@ -18,7 +22,6 @@ ChecklistItems.attachSchema(
|
||||
* the sorting field of the item
|
||||
*/
|
||||
type: Number,
|
||||
decimal: true,
|
||||
},
|
||||
isFinished: {
|
||||
/**
|
||||
@@ -55,7 +58,6 @@ ChecklistItems.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -68,22 +70,6 @@ ChecklistItems.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
ChecklistItems.allow({
|
||||
async insert(userId, doc) {
|
||||
// ReadOnly users cannot create checklist items
|
||||
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
// ReadOnly users cannot edit checklist items
|
||||
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
// ReadOnly users cannot delete checklist items
|
||||
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
|
||||
},
|
||||
fetch: ['userId', 'cardId'],
|
||||
});
|
||||
|
||||
ChecklistItems.before.insert((userId, doc) => {
|
||||
if (!doc.userId) {
|
||||
doc.userId = userId;
|
||||
@@ -113,10 +99,10 @@ ChecklistItems.helpers({
|
||||
});
|
||||
|
||||
// Activities helper
|
||||
async function itemCreation(userId, doc) {
|
||||
export async function itemCreation(userId, doc) {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
const boardId = card.boardId;
|
||||
Activities.insert({
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
activityType: 'addChecklistItem',
|
||||
cardId: doc.cardId,
|
||||
@@ -129,13 +115,13 @@ async function itemCreation(userId, doc) {
|
||||
});
|
||||
}
|
||||
|
||||
function itemRemover(userId, doc) {
|
||||
Activities.remove({
|
||||
export async function itemRemover(userId, doc) {
|
||||
await Activities.removeAsync({
|
||||
checklistItemId: doc._id,
|
||||
});
|
||||
}
|
||||
|
||||
async function publishCheckActivity(userId, doc) {
|
||||
export async function publishCheckActivity(userId, doc) {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
const boardId = card.boardId;
|
||||
let activityType;
|
||||
@@ -155,10 +141,10 @@ async function publishCheckActivity(userId, doc) {
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
};
|
||||
Activities.insert(act);
|
||||
await Activities.insertAsync(act);
|
||||
}
|
||||
|
||||
async function publishChekListCompleted(userId, doc) {
|
||||
export async function publishChekListCompleted(userId, doc) {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
const boardId = card.boardId;
|
||||
const checklistId = doc.checklistId;
|
||||
@@ -177,11 +163,11 @@ async function publishChekListCompleted(userId, doc) {
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
};
|
||||
Activities.insert(act);
|
||||
await Activities.insertAsync(act);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishChekListUncompleted(userId, doc) {
|
||||
export async function publishChekListUncompleted(userId, doc) {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
const boardId = card.boardId;
|
||||
const checklistId = doc.checklistId;
|
||||
@@ -213,258 +199,8 @@ async function publishChekListUncompleted(userId, doc) {
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
};
|
||||
Activities.insert(act);
|
||||
await Activities.insertAsync(act);
|
||||
}
|
||||
}
|
||||
|
||||
// Activities
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await ChecklistItems._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await ChecklistItems._collection.createIndexAsync({ checklistId: 1 });
|
||||
await ChecklistItems._collection.createIndexAsync({ cardId: 1 });
|
||||
});
|
||||
|
||||
ChecklistItems.after.update(async (userId, doc, fieldNames) => {
|
||||
await publishCheckActivity(userId, doc);
|
||||
await publishChekListCompleted(userId, doc, fieldNames);
|
||||
});
|
||||
|
||||
ChecklistItems.before.update(async (userId, doc, fieldNames) => {
|
||||
await publishChekListUncompleted(userId, doc, fieldNames);
|
||||
});
|
||||
|
||||
ChecklistItems.after.insert(async (userId, doc) => {
|
||||
await itemCreation(userId, doc);
|
||||
});
|
||||
|
||||
ChecklistItems.before.remove(async (userId, doc) => {
|
||||
itemRemover(userId, doc);
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
const boardId = card.boardId;
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'removedChecklistItem',
|
||||
cardId: doc.cardId,
|
||||
boardId,
|
||||
checklistId: doc.checklistId,
|
||||
checklistItemId: doc._id,
|
||||
checklistItemName: doc.title,
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
/**
|
||||
* @operation get_checklist_item
|
||||
* @tag Checklists
|
||||
* @summary Get a checklist item
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @param {string} checklistId the checklist ID
|
||||
* @param {string} itemId the ID of the item
|
||||
* @return_type ChecklistItems
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'GET',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCardId = req.params.cardId;
|
||||
const paramChecklistId = req.params.checklistId;
|
||||
const paramItemId = req.params.itemId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const checklistItem = await ReactiveCache.getChecklistItem(paramItemId);
|
||||
if (checklistItem && checklistItem.cardId === paramCardId && checklistItem.checklistId === paramChecklistId) {
|
||||
const card = await ReactiveCache.getCard(checklistItem.cardId);
|
||||
if (card && card.boardId === paramBoardId) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: checklistItem,
|
||||
});
|
||||
} else {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation new_checklist_item
|
||||
* @summary add a new item to a checklist
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @param {string} checklistId the ID of the checklist
|
||||
* @param {string} title the title of the new item
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
|
||||
JsonRoutes.add(
|
||||
'POST',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramChecklistId = req.params.checklistId;
|
||||
const paramCardId = req.params.cardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const checklist = await ReactiveCache.getChecklist({
|
||||
_id: paramChecklistId,
|
||||
cardId: paramCardId,
|
||||
});
|
||||
if (checklist) {
|
||||
const card = await ReactiveCache.getCard(paramCardId);
|
||||
if (card && card.boardId === paramBoardId) {
|
||||
const id = ChecklistItems.insert({
|
||||
cardId: paramCardId,
|
||||
checklistId: paramChecklistId,
|
||||
title: req.body.title,
|
||||
isFinished: false,
|
||||
sort: 0,
|
||||
});
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation edit_checklist_item
|
||||
* @tag Checklists
|
||||
* @summary Edit a checklist item
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @param {string} checklistId the checklist ID
|
||||
* @param {string} itemId the ID of the item
|
||||
* @param {string} [isFinished] is the item checked?
|
||||
* @param {string} [title] the new text of the item
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'PUT',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCardId = req.params.cardId;
|
||||
const paramChecklistId = req.params.checklistId;
|
||||
const paramItemId = req.params.itemId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
const checklistItem = await ReactiveCache.getChecklistItem(paramItemId);
|
||||
if (!checklistItem || checklistItem.cardId !== paramCardId || checklistItem.checklistId !== paramChecklistId) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const card = await ReactiveCache.getCard(checklistItem.cardId);
|
||||
if (!card || card.boardId !== paramBoardId) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
function isTrue(data) {
|
||||
try {
|
||||
return data.toLowerCase() === 'true';
|
||||
} catch (error) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.hasOwnProperty('isFinished')) {
|
||||
ChecklistItems.direct.update(
|
||||
{ _id: paramItemId },
|
||||
{ $set: { isFinished: isTrue(req.body.isFinished) } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('title')) {
|
||||
ChecklistItems.direct.update(
|
||||
{ _id: paramItemId },
|
||||
{ $set: { title: req.body.title } },
|
||||
);
|
||||
}
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramItemId,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation delete_checklist_item
|
||||
* @tag Checklists
|
||||
* @summary Delete a checklist item
|
||||
*
|
||||
* @description Note: this operation can't be reverted.
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @param {string} checklistId the checklist ID
|
||||
* @param {string} itemId the ID of the item to be removed
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'DELETE',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCardId = req.params.cardId;
|
||||
const paramChecklistId = req.params.checklistId;
|
||||
const paramItemId = req.params.itemId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
const checklistItem = await ReactiveCache.getChecklistItem(paramItemId);
|
||||
if (!checklistItem || checklistItem.cardId !== paramCardId || checklistItem.checklistId !== paramChecklistId) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const card = await ReactiveCache.getCard(checklistItem.cardId);
|
||||
if (!card || card.boardId !== paramBoardId) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ChecklistItems.direct.remove({ _id: paramItemId });
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramItemId,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default ChecklistItems;
|
||||
+8
-366
@@ -1,6 +1,11 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache, ReactiveMiniMongoIndex } from '/imports/reactiveCache';
|
||||
import Activities from '/models/activities';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Checklists = new Mongo.Collection('checklists');
|
||||
const Checklists = new Mongo.Collection('checklists');
|
||||
|
||||
/**
|
||||
* A Checklist
|
||||
@@ -32,7 +37,6 @@ Checklists.attachSchema(
|
||||
* Creation date of the checklist
|
||||
*/
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert) {
|
||||
@@ -46,7 +50,6 @@ Checklists.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -61,7 +64,6 @@ Checklists.attachSchema(
|
||||
* sorting value of the checklist
|
||||
*/
|
||||
type: Number,
|
||||
decimal: true,
|
||||
},
|
||||
hideCheckedChecklistItems: {
|
||||
/**
|
||||
@@ -92,13 +94,13 @@ Checklists.helpers({
|
||||
let copyObj = Object.assign({}, this);
|
||||
delete copyObj._id;
|
||||
copyObj.cardId = newCardId;
|
||||
const newChecklistId = Checklists.insert(copyObj);
|
||||
const newChecklistId = await Checklists.insertAsync(copyObj);
|
||||
const items = await ReactiveCache.getChecklistItems({ checklistId: this._id });
|
||||
for (const item of items) {
|
||||
item._id = null;
|
||||
item.checklistId = newChecklistId;
|
||||
item.cardId = newCardId;
|
||||
ChecklistItems.insert(item);
|
||||
await ChecklistItems.insertAsync(item);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -194,370 +196,10 @@ Checklists.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
Checklists.allow({
|
||||
async insert(userId, doc) {
|
||||
// ReadOnly users cannot create checklists
|
||||
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
// ReadOnly users cannot edit checklists
|
||||
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
// ReadOnly users cannot delete checklists
|
||||
return await allowIsBoardMemberWithWriteAccessByCard(userId, await Cards.findOneAsync(doc.cardId));
|
||||
},
|
||||
fetch: ['userId', 'cardId'],
|
||||
});
|
||||
|
||||
Checklists.before.insert((userId, doc) => {
|
||||
doc.createdAt = new Date();
|
||||
if (!doc.userId) {
|
||||
doc.userId = userId;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.methods({
|
||||
async moveChecklist(checklistId, newCardId) {
|
||||
check(checklistId, String);
|
||||
check(newCardId, String);
|
||||
|
||||
const checklist = await ReactiveCache.getChecklist(checklistId);
|
||||
if (!checklist) {
|
||||
throw new Meteor.Error('checklist-not-found', 'Checklist not found');
|
||||
}
|
||||
|
||||
const newCard = await ReactiveCache.getCard(newCardId);
|
||||
if (!newCard) {
|
||||
throw new Meteor.Error('card-not-found', 'Target card not found');
|
||||
}
|
||||
|
||||
// Check permissions on both source and target cards
|
||||
const sourceCard = await ReactiveCache.getCard(checklist.cardId);
|
||||
if (!allowIsBoardMemberByCard(this.userId, sourceCard)) {
|
||||
throw new Meteor.Error('not-authorized', 'Not authorized to move checklist from source card');
|
||||
}
|
||||
if (!allowIsBoardMemberByCard(this.userId, newCard)) {
|
||||
throw new Meteor.Error('not-authorized', 'Not authorized to move checklist to target card');
|
||||
}
|
||||
|
||||
// Update activities
|
||||
const activities = await ReactiveCache.getActivities({ checklistId });
|
||||
for (const activity of activities) {
|
||||
Activities.update(activity._id, {
|
||||
$set: {
|
||||
cardId: newCardId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update checklist items
|
||||
const checklistItems = await ReactiveCache.getChecklistItems({ checklistId });
|
||||
for (const checklistItem of checklistItems) {
|
||||
ChecklistItems.update(checklistItem._id, {
|
||||
$set: {
|
||||
cardId: newCardId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update the checklist itself
|
||||
Checklists.update(checklistId, {
|
||||
$set: {
|
||||
cardId: newCardId,
|
||||
},
|
||||
});
|
||||
|
||||
return checklistId;
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Checklists._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await Checklists._collection.createIndexAsync({ cardId: 1, createdAt: 1 });
|
||||
});
|
||||
|
||||
Checklists.after.insert(async (userId, doc) => {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'addChecklist',
|
||||
cardId: doc.cardId,
|
||||
boardId: card.boardId,
|
||||
checklistId: doc._id,
|
||||
checklistName: doc.title,
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
});
|
||||
});
|
||||
|
||||
Checklists.before.remove(async (userId, doc) => {
|
||||
const activities = await ReactiveCache.getActivities({ checklistId: doc._id });
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
if (activities) {
|
||||
for (const activity of activities) {
|
||||
Activities.remove(activity._id);
|
||||
}
|
||||
}
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'removeChecklist',
|
||||
cardId: doc.cardId,
|
||||
boardId: (await ReactiveCache.getCard(doc.cardId)).boardId,
|
||||
checklistId: doc._id,
|
||||
checklistName: doc.title,
|
||||
listId: card.listId,
|
||||
swimlaneId: card.swimlaneId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
/**
|
||||
* @operation get_all_checklists
|
||||
* @summary Get the list of checklists attached to a card
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @return_type [{_id: string,
|
||||
* title: string}]
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'GET',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCardId = req.params.cardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
// Verify the card belongs to the board
|
||||
const card = await ReactiveCache.getCard({
|
||||
_id: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
if (!card) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
data: { error: 'Card not found or does not belong to the specified board' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const checklists = (await ReactiveCache.getChecklists({ cardId: paramCardId })).map(function(
|
||||
doc,
|
||||
) {
|
||||
return {
|
||||
_id: doc._id,
|
||||
title: doc.title,
|
||||
};
|
||||
});
|
||||
if (checklists) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: checklists,
|
||||
});
|
||||
} else {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation get_checklist
|
||||
* @summary Get a checklist
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @param {string} checklistId the ID of the checklist
|
||||
* @return_type {cardId: string,
|
||||
* title: string,
|
||||
* finishedAt: string,
|
||||
* createdAt: string,
|
||||
* sort: number,
|
||||
* items: [{_id: string,
|
||||
* title: string,
|
||||
* isFinished: boolean}]}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'GET',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramChecklistId = req.params.checklistId;
|
||||
const paramCardId = req.params.cardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
// Verify the card belongs to the board
|
||||
const card = await ReactiveCache.getCard({
|
||||
_id: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
if (!card) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
data: { error: 'Card not found or does not belong to the specified board' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const checklist = await ReactiveCache.getChecklist({
|
||||
_id: paramChecklistId,
|
||||
cardId: paramCardId,
|
||||
});
|
||||
if (checklist) {
|
||||
checklist.items = (await ReactiveCache.getChecklistItems({
|
||||
checklistId: checklist._id,
|
||||
})).map(function(doc) {
|
||||
return {
|
||||
_id: doc._id,
|
||||
title: doc.title,
|
||||
isFinished: doc.isFinished,
|
||||
};
|
||||
});
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: checklist,
|
||||
});
|
||||
} else {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation new_checklist
|
||||
* @summary create a new checklist
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @param {string} title the title of the new checklist
|
||||
* @param {string} [items] the list of items on the new checklist
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'POST',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists',
|
||||
async function(req, res) {
|
||||
// Check user is logged in
|
||||
//Authentication.checkLoggedIn(req.userId);
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
// Check user has permission to add checklist to the card
|
||||
const board = await ReactiveCache.getBoard(paramBoardId);
|
||||
const addPermission = allowIsBoardMemberCommentOnly(req.userId, board);
|
||||
Authentication.checkAdminOrCondition(req.userId, addPermission);
|
||||
const paramCardId = req.params.cardId;
|
||||
|
||||
// Verify the card belongs to the board
|
||||
const card = await ReactiveCache.getCard({
|
||||
_id: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
if (!card) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
data: { error: 'Card not found or does not belong to the specified board' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = Checklists.insert({
|
||||
title: req.body.title,
|
||||
cardId: paramCardId,
|
||||
sort: 0,
|
||||
});
|
||||
if (id) {
|
||||
let items = req.body.items || [];
|
||||
if (typeof items === 'string') {
|
||||
if (items === '') {
|
||||
items = [];
|
||||
} else {
|
||||
items = [items];
|
||||
}
|
||||
}
|
||||
items.forEach(function(item, idx) {
|
||||
ChecklistItems.insert({
|
||||
cardId: paramCardId,
|
||||
checklistId: id,
|
||||
title: item,
|
||||
sort: idx,
|
||||
});
|
||||
});
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 400,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation delete_checklist
|
||||
* @summary Delete a checklist
|
||||
*
|
||||
* @description The checklist will be removed, not put in the recycle bin.
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} cardId the card ID
|
||||
* @param {string} checklistId the ID of the checklist to remove
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'DELETE',
|
||||
'/api/boards/:boardId/cards/:cardId/checklists/:checklistId',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCardId = req.params.cardId;
|
||||
const paramChecklistId = req.params.checklistId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
// Verify the card belongs to the board
|
||||
const card = await ReactiveCache.getCard({
|
||||
_id: paramCardId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
if (!card) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
data: { error: 'Card not found or does not belong to the specified board' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the checklist exists and belongs to the card
|
||||
const checklist = await ReactiveCache.getChecklist({
|
||||
_id: paramChecklistId,
|
||||
cardId: paramCardId,
|
||||
});
|
||||
if (!checklist) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
data: { error: 'Checklist not found or does not belong to the specified card' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Checklists.remove({ _id: paramChecklistId });
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramChecklistId,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default Checklists;
|
||||
+5
-2
@@ -1,4 +1,6 @@
|
||||
Counters = new Mongo.Collection('counters');
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
const Counters = new Mongo.Collection('counters');
|
||||
|
||||
async function incrementCounterAsync(counterName, amount = 1) {
|
||||
const result = await Counters.rawCollection().findOneAndUpdate(
|
||||
@@ -12,4 +14,5 @@ async function incrementCounterAsync(counterName, amount = 1) {
|
||||
// Alias for backward compatibility — all callers should use the async version
|
||||
const incrementCounter = incrementCounterAsync;
|
||||
|
||||
export { incrementCounter, incrementCounterAsync };
|
||||
export { Counters, incrementCounter, incrementCounterAsync };
|
||||
export default Counters;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
const CronJobStatus = new Mongo.Collection('cronJobStatus');
|
||||
|
||||
export default CronJobStatus;
|
||||
+23
-17
@@ -1,6 +1,12 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { isEmptyObject } from 'jquery';
|
||||
import Activities from '/models/activities';
|
||||
import Boards from './boards';
|
||||
import Cards from '/models/cards';
|
||||
import CustomFields from '/models/customFields';
|
||||
import Lists from '/models/lists';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
|
||||
export class CsvCreator {
|
||||
constructor(data) {
|
||||
@@ -127,8 +133,8 @@ export class CsvCreator {
|
||||
}
|
||||
this.fieldIndex = index;
|
||||
}
|
||||
createCustomFields(boardId) {
|
||||
this.fieldIndex.customFields.forEach(customField => {
|
||||
async createCustomFields(boardId) {
|
||||
for (const customField of this.fieldIndex.customFields) {
|
||||
let settings = {};
|
||||
if (customField.type === 'dropdown') {
|
||||
settings = {
|
||||
@@ -143,7 +149,7 @@ export class CsvCreator {
|
||||
} else {
|
||||
settings = {};
|
||||
}
|
||||
const id = CustomFields.direct.insert({
|
||||
const id = await CustomFields.direct.insertAsync({
|
||||
name: customField.name,
|
||||
type: customField.type,
|
||||
settings,
|
||||
@@ -155,10 +161,10 @@ export class CsvCreator {
|
||||
});
|
||||
customField.id = id;
|
||||
customField.settings = settings;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createBoard(csvData) {
|
||||
async createBoard(csvData) {
|
||||
const boardToCreate = {
|
||||
archived: false,
|
||||
color: 'belize',
|
||||
@@ -212,14 +218,14 @@ export class CsvCreator {
|
||||
boardToCreate.labels.push(labelToCreate);
|
||||
}
|
||||
|
||||
const boardId = Boards.direct.insert(boardToCreate);
|
||||
Boards.direct.update(boardId, {
|
||||
const boardId = await Boards.direct.insertAsync(boardToCreate);
|
||||
await Boards.direct.updateAsync(boardId, {
|
||||
$set: {
|
||||
modifiedAt: this._now(),
|
||||
},
|
||||
});
|
||||
// log activity
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
activityType: 'importBoard',
|
||||
boardId,
|
||||
createdAt: this._now(),
|
||||
@@ -234,7 +240,7 @@ export class CsvCreator {
|
||||
return boardId;
|
||||
}
|
||||
|
||||
createSwimlanes(boardId) {
|
||||
async createSwimlanes(boardId) {
|
||||
const swimlaneToCreate = {
|
||||
archived: false,
|
||||
boardId,
|
||||
@@ -242,8 +248,8 @@ export class CsvCreator {
|
||||
title: 'Default',
|
||||
sort: 1,
|
||||
};
|
||||
const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate);
|
||||
Swimlanes.direct.update(swimlaneId, { $set: { updatedAt: this._now() } });
|
||||
const swimlaneId = await Swimlanes.direct.insertAsync(swimlaneToCreate);
|
||||
await Swimlanes.direct.updateAsync(swimlaneId, { $set: { updatedAt: this._now() } });
|
||||
this.swimlane = swimlaneId;
|
||||
}
|
||||
|
||||
@@ -267,10 +273,10 @@ export class CsvCreator {
|
||||
}
|
||||
} else listToCreate.title = `Imported List ${this._now()}`;
|
||||
|
||||
const listId = Lists.direct.insert(listToCreate);
|
||||
const listId = await Lists.direct.insertAsync(listToCreate);
|
||||
this.lists[csvData[i][this.fieldIndex.stage]] = listId;
|
||||
numOfCreatedLists++;
|
||||
Lists.direct.update(listId, {
|
||||
await Lists.direct.updateAsync(listId, {
|
||||
$set: {
|
||||
updatedAt: this._now(),
|
||||
sort: numOfCreatedLists,
|
||||
@@ -378,7 +384,7 @@ export class CsvCreator {
|
||||
cardToCreate.customFields = customFields;
|
||||
});
|
||||
}
|
||||
Cards.direct.insert(cardToCreate);
|
||||
await Cards.direct.insertAsync(cardToCreate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,10 +398,10 @@ export class CsvCreator {
|
||||
await currentBoard.archive();
|
||||
}
|
||||
this.mapHeadertoCardFieldIndex(board[0]);
|
||||
const boardId = this.createBoard(board);
|
||||
const boardId = await this.createBoard(board);
|
||||
await this.createLists(board, boardId);
|
||||
this.createSwimlanes(boardId);
|
||||
this.createCustomFields(boardId);
|
||||
await this.createSwimlanes(boardId);
|
||||
await this.createCustomFields(boardId);
|
||||
await this.createCards(board, boardId);
|
||||
return boardId;
|
||||
}
|
||||
|
||||
+11
-464
@@ -1,6 +1,8 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import Cards from '/models/cards';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
CustomFields = new Mongo.Collection('customFields');
|
||||
const CustomFields = new Mongo.Collection('customFields');
|
||||
|
||||
/**
|
||||
* A custom field on a card in the board
|
||||
@@ -11,7 +13,10 @@ CustomFields.attachSchema(
|
||||
/**
|
||||
* the ID of the board
|
||||
*/
|
||||
type: [String],
|
||||
type: Array,
|
||||
},
|
||||
'boardIds.$': {
|
||||
type: String,
|
||||
},
|
||||
name: {
|
||||
/**
|
||||
@@ -48,7 +53,7 @@ CustomFields.attachSchema(
|
||||
/**
|
||||
* list of drop down items objects
|
||||
*/
|
||||
type: [Object],
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'settings.dropdownItems.$': {
|
||||
@@ -126,7 +131,6 @@ CustomFields.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -139,8 +143,8 @@ CustomFields.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
CustomFields.addToAllCards = cf => {
|
||||
Cards.update(
|
||||
CustomFields.addToAllCards = async cf => {
|
||||
await Cards.updateAsync(
|
||||
{
|
||||
boardId: { $in: cf.boardIds },
|
||||
customFields: { $not: { $elemMatch: { _id: cf._id } } },
|
||||
@@ -163,461 +167,4 @@ CustomFields.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
CustomFields.allow({
|
||||
async insert(userId, doc) {
|
||||
return allowIsAnyBoardMember(
|
||||
userId,
|
||||
await Boards.find({
|
||||
_id: { $in: doc.boardIds },
|
||||
}).fetchAsync(),
|
||||
);
|
||||
},
|
||||
async update(userId, doc) {
|
||||
return allowIsAnyBoardMember(
|
||||
userId,
|
||||
await Boards.find({
|
||||
_id: { $in: doc.boardIds },
|
||||
}).fetchAsync(),
|
||||
);
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
return allowIsAnyBoardMember(
|
||||
userId,
|
||||
await Boards.find({
|
||||
_id: { $in: doc.boardIds },
|
||||
}).fetchAsync(),
|
||||
);
|
||||
},
|
||||
fetch: ['userId', 'boardIds'],
|
||||
});
|
||||
|
||||
// not sure if we need this?
|
||||
//CustomFields.hookOptions.after.update = { fetchPrevious: false };
|
||||
|
||||
function customFieldCreation(userId, doc) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'createCustomField',
|
||||
boardId: doc.boardIds[0], // We are creating a customField, it has only one boardId
|
||||
customFieldId: doc._id,
|
||||
});
|
||||
}
|
||||
|
||||
function customFieldDeletion(userId, doc) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'deleteCustomField',
|
||||
boardId: doc.boardIds[0], // We are creating a customField, it has only one boardId
|
||||
customFieldId: doc._id,
|
||||
});
|
||||
}
|
||||
|
||||
// This has some bug, it does not show edited customField value at Outgoing Webhook,
|
||||
// instead it shows undefined, and no listId and swimlaneId.
|
||||
async function customFieldEdit(userId, doc) {
|
||||
const card = await ReactiveCache.getCard(doc.cardId);
|
||||
const customFieldValue = (await ReactiveCache.getActivity({ customFieldId: doc._id })).value;
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'setCustomField',
|
||||
boardId: doc.boardIds[0], // We are creating a customField, it has only one boardId
|
||||
customFieldId: doc._id,
|
||||
customFieldValue,
|
||||
listId: doc.listId,
|
||||
swimlaneId: doc.swimlaneId,
|
||||
});
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await CustomFields._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await CustomFields._collection.createIndexAsync({ boardIds: 1 });
|
||||
});
|
||||
|
||||
CustomFields.after.insert((userId, doc) => {
|
||||
customFieldCreation(userId, doc);
|
||||
|
||||
if (doc.alwaysOnCard) {
|
||||
CustomFields.addToAllCards(doc);
|
||||
}
|
||||
});
|
||||
|
||||
CustomFields.before.update(async (userId, doc, fieldNames, modifier) => {
|
||||
if (fieldNames.includes('boardIds') && modifier.$pull) {
|
||||
Cards.update(
|
||||
{ boardId: modifier.$pull.boardIds, 'customFields._id': doc._id },
|
||||
{ $pull: { customFields: { _id: doc._id } } },
|
||||
{ multi: true },
|
||||
);
|
||||
await customFieldEdit(userId, doc);
|
||||
Activities.remove({
|
||||
customFieldId: doc._id,
|
||||
boardId: modifier.$pull.boardIds,
|
||||
listId: doc.listId,
|
||||
swimlaneId: doc.swimlaneId,
|
||||
});
|
||||
} else if (fieldNames.includes('boardIds') && modifier.$push) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
activityType: 'createCustomField',
|
||||
boardId: modifier.$push.boardIds,
|
||||
customFieldId: doc._id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
CustomFields.after.update((userId, doc) => {
|
||||
if (doc.alwaysOnCard) {
|
||||
CustomFields.addToAllCards(doc);
|
||||
}
|
||||
});
|
||||
CustomFields.before.remove((userId, doc) => {
|
||||
customFieldDeletion(userId, doc);
|
||||
Activities.remove({
|
||||
customFieldId: doc._id,
|
||||
});
|
||||
|
||||
Cards.update(
|
||||
{ boardId: { $in: doc.boardIds }, 'customFields._id': doc._id },
|
||||
{ $pull: { customFields: { _id: doc._id } } },
|
||||
{ multi: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//CUSTOM FIELD REST API
|
||||
if (Meteor.isServer) {
|
||||
/**
|
||||
* @operation get_all_custom_fields
|
||||
* @summary Get the list of Custom Fields attached to a board
|
||||
*
|
||||
* @param {string} boardID the ID of the board
|
||||
* @return_type [{_id: string,
|
||||
* name: string,
|
||||
* type: string}]
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/custom-fields', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: (await ReactiveCache.getCustomFields({ boardIds: { $in: [paramBoardId] } })).map(
|
||||
function(cf) {
|
||||
return {
|
||||
_id: cf._id,
|
||||
name: cf.name,
|
||||
type: cf.type,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation get_custom_field
|
||||
* @summary Get a Custom Fields attached to a board
|
||||
*
|
||||
* @param {string} boardID the ID of the board
|
||||
* @param {string} customFieldId the ID of the custom field
|
||||
* @return_type [{_id: string,
|
||||
* boardIds: string}]
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'GET',
|
||||
'/api/boards/:boardId/custom-fields/:customFieldId',
|
||||
async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCustomFieldId = req.params.customFieldId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: await ReactiveCache.getCustomField({
|
||||
_id: paramCustomFieldId,
|
||||
boardIds: { $in: [paramBoardId] },
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation new_custom_field
|
||||
* @summary Create a Custom Field
|
||||
*
|
||||
* @param {string} boardID the ID of the board
|
||||
* @param {string} name the name of the custom field
|
||||
* @param {string} type the type of the custom field
|
||||
* @param {string} settings the settings object of the custom field
|
||||
* @param {boolean} showOnCard should we show the custom field on cards?
|
||||
* @param {boolean} automaticallyOnCard should the custom fields automatically be added on cards?
|
||||
* @param {boolean} showLabelOnMiniCard should the label of the custom field be shown on minicards?
|
||||
* @param {boolean} showSumAtTopOfList should the sum of the custom fields be shown at top of list?
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('POST', '/api/boards/:boardId/custom-fields', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const board = await ReactiveCache.getBoard(paramBoardId);
|
||||
const id = CustomFields.direct.insert({
|
||||
name: req.body.name,
|
||||
type: req.body.type,
|
||||
settings: req.body.settings,
|
||||
showOnCard: req.body.showOnCard,
|
||||
automaticallyOnCard: req.body.automaticallyOnCard,
|
||||
showLabelOnMiniCard: req.body.showLabelOnMiniCard,
|
||||
showSumAtTopOfList: req.body.showSumAtTopOfList,
|
||||
boardIds: [board._id],
|
||||
});
|
||||
|
||||
const customField = await ReactiveCache.getCustomField({
|
||||
_id: id,
|
||||
boardIds: { $in: [paramBoardId] },
|
||||
});
|
||||
customFieldCreation(req.body.authorId, customField);
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation edit_custom_field
|
||||
* @summary Update a Custom Field
|
||||
*
|
||||
* @param {string} name the name of the custom field
|
||||
* @param {string} type the type of the custom field
|
||||
* @param {string} settings the settings object of the custom field
|
||||
* @param {boolean} showOnCard should we show the custom field on cards
|
||||
* @param {boolean} automaticallyOnCard should the custom fields automatically be added on cards
|
||||
* @param {boolean} showLabelOnMiniCard should the label of the custom field be shown on minicards
|
||||
* @param {boolean} showSumAtTopOfList should the sum of the custom fields be shown at top of list
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'PUT',
|
||||
'/api/boards/:boardId/custom-fields/:customFieldId',
|
||||
(req, res) => {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramFieldId = req.params.customFieldId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
const boardScopedField = {
|
||||
_id: paramFieldId,
|
||||
boardIds: { $in: [paramBoardId] },
|
||||
};
|
||||
|
||||
if (req.body.hasOwnProperty('name')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { name: req.body.name } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('type')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { type: req.body.type } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('settings')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { settings: req.body.settings } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('showOnCard')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { showOnCard: req.body.showOnCard } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('automaticallyOnCard')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { automaticallyOnCard: req.body.automaticallyOnCard } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('alwaysOnCard')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { alwaysOnCard: req.body.alwaysOnCard } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('showLabelOnMiniCard')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { showLabelOnMiniCard: req.body.showLabelOnMiniCard } },
|
||||
);
|
||||
}
|
||||
|
||||
if (req.body.hasOwnProperty('showSumAtTopOfList')) {
|
||||
CustomFields.direct.update(
|
||||
boardScopedField,
|
||||
{ $set: { showSumAtTopOfList: req.body.showSumAtTopOfList } },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: { _id: paramFieldId },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation add_custom_field_dropdown_items
|
||||
* @summary Update a Custom Field's dropdown items
|
||||
*
|
||||
* @param {string} [items] names of the custom field
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'POST',
|
||||
'/api/boards/:boardId/custom-fields/:customFieldId/dropdown-items',
|
||||
(req, res) => {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCustomFieldId = req.params.customFieldId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const paramItems = req.body.items;
|
||||
|
||||
if (req.body.hasOwnProperty('items')) {
|
||||
if (Array.isArray(paramItems)) {
|
||||
CustomFields.direct.update(
|
||||
{
|
||||
_id: paramCustomFieldId,
|
||||
boardIds: { $in: [paramBoardId] },
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
'settings.dropdownItems': {
|
||||
$each: paramItems
|
||||
.filter(name => typeof name === 'string')
|
||||
.map(name => ({
|
||||
_id: Random.id(6),
|
||||
name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: { _id: paramCustomFieldId },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation edit_custom_field_dropdown_item
|
||||
* @summary Update a Custom Field's dropdown item
|
||||
*
|
||||
* @param {string} name names of the custom field
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'PUT',
|
||||
'/api/boards/:boardId/custom-fields/:customFieldId/dropdown-items/:dropdownItemId',
|
||||
(req, res) => {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramDropdownItemId = req.params.dropdownItemId;
|
||||
const paramCustomFieldId = req.params.customFieldId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const paramName = req.body.name;
|
||||
|
||||
if (req.body.hasOwnProperty('name')) {
|
||||
CustomFields.direct.update(
|
||||
{
|
||||
_id: paramCustomFieldId,
|
||||
boardIds: { $in: [paramBoardId] },
|
||||
'settings.dropdownItems._id': paramDropdownItemId,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'settings.dropdownItems.$': {
|
||||
_id: paramDropdownItemId,
|
||||
name: paramName,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: { _id: paramDropdownItemId },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation delete_custom_field_dropdown_item
|
||||
* @summary Update a Custom Field's dropdown items
|
||||
*
|
||||
* @param {string} itemId ID of the dropdown item
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'DELETE',
|
||||
'/api/boards/:boardId/custom-fields/:customFieldId/dropdown-items/:dropdownItemId',
|
||||
(req, res) => {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramCustomFieldId = req.params.customFieldId;
|
||||
const paramDropdownItemId = req.params.dropdownItemId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
CustomFields.direct.update(
|
||||
{ _id: paramCustomFieldId, boardIds: { $in: [paramBoardId] } },
|
||||
{
|
||||
$pull: {
|
||||
'settings.dropdownItems': { _id: paramDropdownItemId },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: { _id: paramCustomFieldId },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation delete_custom_field
|
||||
* @summary Delete a Custom Fields attached to a board
|
||||
*
|
||||
* @description The Custom Field can't be retrieved after this operation
|
||||
*
|
||||
* @param {string} boardID the ID of the board
|
||||
* @param {string} customFieldId the ID of the custom field
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'DELETE',
|
||||
'/api/boards/:boardId/custom-fields/:customFieldId',
|
||||
function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const id = req.params.customFieldId;
|
||||
CustomFields.remove({ _id: id, boardIds: { $in: [paramBoardId] } });
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomFields;
|
||||
+19
-18
@@ -1,10 +1,12 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Exporter } from './exporter';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import ImpersonatedUsers from '/models/impersonatedUsers';
|
||||
|
||||
/* global JsonRoutes */
|
||||
if (Meteor.isServer) {
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
const { WebApp } = require('meteor/webapp');
|
||||
const { sendJsonResult } = require('/server/apiMiddleware');
|
||||
const { Authentication } = require('/server/authentication');
|
||||
|
||||
// todo XXX once we have a real API in place, move that route there
|
||||
// todo XXX also share the route definition between the client and the server
|
||||
@@ -27,7 +29,7 @@ if (Meteor.isServer) {
|
||||
* @param {string} boardId the ID of the board we are exporting
|
||||
* @param {string} authToken the loginToken
|
||||
*/
|
||||
JsonRoutes.add('get', '/api/boards/:boardId/export', async function (req, res) {
|
||||
WebApp.handlers.get('/api/boards/:boardId/export', async function (req, res) {
|
||||
const boardId = req.params.boardId;
|
||||
let user = null;
|
||||
let impersonateDone = false;
|
||||
@@ -36,7 +38,7 @@ if (Meteor.isServer) {
|
||||
// First check if board exists and is public to avoid unnecessary authentication
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
JsonRoutes.sendResult(res, 404);
|
||||
sendJsonResult(res, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,7 +46,7 @@ if (Meteor.isServer) {
|
||||
if (board.isPublic()) {
|
||||
// Public boards don't require authentication - skip hash operations
|
||||
const exporter = new Exporter(boardId);
|
||||
JsonRoutes.sendResult(res, {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: await exporter.build(),
|
||||
});
|
||||
@@ -59,7 +61,7 @@ if (Meteor.isServer) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
|
||||
}
|
||||
JsonRoutes.sendResult(res, 400);
|
||||
sendJsonResult(res, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,21 +79,21 @@ if (Meteor.isServer) {
|
||||
const exporter = new Exporter(boardId);
|
||||
if (await exporter.canExport(user) || impersonateDone) {
|
||||
if (impersonateDone) {
|
||||
ImpersonatedUsers.insert({
|
||||
await ImpersonatedUsers.insertAsync({
|
||||
adminId: adminId,
|
||||
boardId: boardId,
|
||||
reason: 'exportJSON',
|
||||
});
|
||||
}
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: await exporter.build(),
|
||||
});
|
||||
} else {
|
||||
// we could send an explicit error message, but on the other hand the only
|
||||
// way to get there is by hacking the UI so let's keep it raw.
|
||||
JsonRoutes.sendResult(res, 403);
|
||||
sendJsonResult(res, 403);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,8 +117,7 @@ if (Meteor.isServer) {
|
||||
* @param {string} attachmentId the ID of the attachment we are exporting
|
||||
* @param {string} authToken the loginToken
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'get',
|
||||
WebApp.handlers.get(
|
||||
'/api/boards/:boardId/attachments/:attachmentId/export',
|
||||
async function (req, res) {
|
||||
const boardId = req.params.boardId;
|
||||
@@ -128,7 +129,7 @@ if (Meteor.isServer) {
|
||||
// First check if board exists and is public to avoid unnecessary authentication
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
JsonRoutes.sendResult(res, 404);
|
||||
sendJsonResult(res, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ if (Meteor.isServer) {
|
||||
if (board.isPublic()) {
|
||||
// Public boards don't require authentication - skip hash operations
|
||||
const exporter = new Exporter(boardId, attachmentId);
|
||||
JsonRoutes.sendResult(res, {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: await exporter.build(),
|
||||
});
|
||||
@@ -151,7 +152,7 @@ if (Meteor.isServer) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
|
||||
}
|
||||
JsonRoutes.sendResult(res, 400);
|
||||
sendJsonResult(res, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,21 +170,21 @@ if (Meteor.isServer) {
|
||||
const exporter = new Exporter(boardId, attachmentId);
|
||||
if (await exporter.canExport(user) || impersonateDone) {
|
||||
if (impersonateDone) {
|
||||
ImpersonatedUsers.insert({
|
||||
await ImpersonatedUsers.insertAsync({
|
||||
adminId: adminId,
|
||||
boardId: boardId,
|
||||
attachmentId: attachmentId,
|
||||
reason: 'exportJSONattachment',
|
||||
});
|
||||
}
|
||||
JsonRoutes.sendResult(res, {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: await exporter.build(),
|
||||
});
|
||||
} else {
|
||||
// we could send an explicit error message, but on the other hand the only
|
||||
// way to get there is by hacking the UI so let's keep it raw.
|
||||
JsonRoutes.sendResult(res, 403);
|
||||
sendJsonResult(res, 403);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -276,7 +277,7 @@ if (Meteor.isServer) {
|
||||
if( params.query.delimiter == "\t" ) {
|
||||
exportType = 'exportTSV';
|
||||
}
|
||||
ImpersonatedUsers.insert({
|
||||
await ImpersonatedUsers.insertAsync({
|
||||
adminId: adminId,
|
||||
boardId: boardId,
|
||||
reason: exportType,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { runOnServer } from './runOnServer';
|
||||
import ImpersonatedUsers from '/models/impersonatedUsers';
|
||||
|
||||
runOnServer(function() {
|
||||
// the ExporterExcel class is only available on server and in order to import
|
||||
// it here we use runOnServer to have it inside a function instead of an
|
||||
// if (Meteor.isServer) block
|
||||
import { ExporterExcel } from './server/ExporterExcel';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
const { ExporterExcel } = require('./server/ExporterExcel');
|
||||
const { WebApp } = require('meteor/webapp');
|
||||
const { Authentication } = require('/server/authentication');
|
||||
|
||||
// todo XXX once we have a real API in place, move that route there
|
||||
// todo XXX also share the route definition between the client and the server
|
||||
@@ -85,7 +88,7 @@ runOnServer(function() {
|
||||
const exporterExcel = new ExporterExcel(boardId, userLanguage);
|
||||
if (exporterExcel.canExport(user) || impersonateDone) {
|
||||
if (impersonateDone) {
|
||||
ImpersonatedUsers.insert({
|
||||
await ImpersonatedUsers.insertAsync({
|
||||
adminId: adminId,
|
||||
boardId: boardId,
|
||||
reason: 'exportExcel',
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { runOnServer } from './runOnServer';
|
||||
import ImpersonatedUsers from '/models/impersonatedUsers';
|
||||
|
||||
runOnServer(function() {
|
||||
import { ExporterExcelCard, ALL_FIELDS } from './server/ExporterExcelCard';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
const { ExporterExcelCard, ALL_FIELDS } = require('./server/ExporterExcelCard');
|
||||
const { WebApp } = require('meteor/webapp');
|
||||
const { Authentication } = require('/server/authentication');
|
||||
|
||||
/**
|
||||
* @operation exportExcelCard
|
||||
@@ -95,7 +98,7 @@ runOnServer(function() {
|
||||
const exporter = new ExporterExcelCard(boardId, paramListId, paramCardId, userLanguage, fields, user);
|
||||
if ((await exporter.canExport(user)) || impersonateDone) {
|
||||
if (impersonateDone) {
|
||||
ImpersonatedUsers.insert({
|
||||
await ImpersonatedUsers.insertAsync({
|
||||
adminId,
|
||||
boardId,
|
||||
reason: 'exportExcelCard',
|
||||
|
||||
+6
-3
@@ -1,13 +1,16 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { runOnServer } from './runOnServer';
|
||||
import ImpersonatedUsers from '/models/impersonatedUsers';
|
||||
|
||||
runOnServer(function() {
|
||||
// the ExporterCardPDF class is only available on server and in order to import
|
||||
// it here we use runOnServer to have it inside a function instead of an
|
||||
// if (Meteor.isServer) block
|
||||
import { ExporterCardPDF } from './server/ExporterCardPDF';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
const { ExporterCardPDF } = require('./server/ExporterCardPDF');
|
||||
const { WebApp } = require('meteor/webapp');
|
||||
const { Authentication } = require('/server/authentication');
|
||||
|
||||
// todo XXX once we have a real API in place, move that route there
|
||||
// todo XXX also share the route definition between the client and the server
|
||||
@@ -95,7 +98,7 @@ runOnServer(function() {
|
||||
);
|
||||
if (await exporterCardPDF.canExport(user) || impersonateDone) {
|
||||
if (impersonateDone) {
|
||||
ImpersonatedUsers.insert({
|
||||
await ImpersonatedUsers.insertAsync({
|
||||
adminId: adminId,
|
||||
boardId: boardId,
|
||||
reason: 'exportCardPDF',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
ImpersonatedUsers = new Mongo.Collection('impersonatedUsers');
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
const ImpersonatedUsers = new Mongo.Collection('impersonatedUsers');
|
||||
|
||||
/**
|
||||
* A Impersonated User in wekan
|
||||
@@ -63,7 +66,6 @@ ImpersonatedUsers.attachSchema(
|
||||
* modified date of the impersonation
|
||||
*/
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { TrelloCreator } from './trelloCreator';
|
||||
import { WekanCreator } from './wekanCreator';
|
||||
import { CsvCreator } from './csvCreator';
|
||||
|
||||
+8
-315
@@ -1,6 +1,9 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Integrations = new Mongo.Collection('integrations');
|
||||
const Integrations = new Mongo.Collection('integrations');
|
||||
|
||||
/**
|
||||
* Integration with third-party applications
|
||||
@@ -32,9 +35,12 @@ Integrations.attachSchema(
|
||||
/**
|
||||
* activities the integration gets triggered (list)
|
||||
*/
|
||||
type: [String],
|
||||
type: Array,
|
||||
defaultValue: ['all'],
|
||||
},
|
||||
'activities.$': {
|
||||
type: String,
|
||||
},
|
||||
url: {
|
||||
// URL validation with SSRF protection
|
||||
/**
|
||||
@@ -94,7 +100,6 @@ Integrations.attachSchema(
|
||||
* Creation date of the integration
|
||||
*/
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert) {
|
||||
@@ -108,7 +113,6 @@ Integrations.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -134,315 +138,4 @@ Integrations.Const = {
|
||||
return [this.ONEWAY, this.TWOWAY];
|
||||
},
|
||||
};
|
||||
const permissionHelper = {
|
||||
async allow(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
const isAdmin = user && user.isAdmin;
|
||||
return isAdmin || allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
};
|
||||
Integrations.allow({
|
||||
async insert(userId, doc) {
|
||||
return await permissionHelper.allow(userId, doc);
|
||||
},
|
||||
async update(userId, doc) {
|
||||
return await permissionHelper.allow(userId, doc);
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
return await permissionHelper.allow(userId, doc);
|
||||
},
|
||||
fetch: ['boardId'],
|
||||
});
|
||||
|
||||
//INTEGRATIONS REST API
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Integrations._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await Integrations._collection.createIndexAsync({ boardId: 1 });
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation get_all_integrations
|
||||
* @summary Get all integrations in board
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @return_type [Integrations]
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/integrations', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
const data = (await ReactiveCache.getIntegrations(
|
||||
{ boardId: paramBoardId },
|
||||
{ fields: { token: 0 } },
|
||||
)).map(function(doc) {
|
||||
return doc;
|
||||
});
|
||||
|
||||
JsonRoutes.sendResult(res, { code: 200, data });
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation get_integration
|
||||
* @summary Get a single integration in board
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} intId the integration ID
|
||||
* @return_type Integrations
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/integrations/:intId', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramIntId = req.params.intId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: await ReactiveCache.getIntegration(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ fields: { token: 0 } },
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation new_integration
|
||||
* @summary Create a new integration
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} url the URL of the integration
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('POST', '/api/boards/:boardId/integrations', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
|
||||
const id = Integrations.insert({
|
||||
userId: req.userId,
|
||||
boardId: paramBoardId,
|
||||
url: req.body.url,
|
||||
});
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation edit_integration
|
||||
* @summary Edit integration data
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} intId the integration ID
|
||||
* @param {string} [enabled] is the integration enabled?
|
||||
* @param {string} [title] new name of the integration
|
||||
* @param {string} [url] new URL of the integration
|
||||
* @param {string} [token] new token of the integration
|
||||
* @param {string} [activities] new list of activities of the integration
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('PUT', '/api/boards/:boardId/integrations/:intId', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramIntId = req.params.intId;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
|
||||
if (req.body.hasOwnProperty('enabled')) {
|
||||
const newEnabled = req.body.enabled;
|
||||
Integrations.direct.update(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ $set: { enabled: newEnabled } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('title')) {
|
||||
const newTitle = req.body.title;
|
||||
Integrations.direct.update(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ $set: { title: newTitle } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('url')) {
|
||||
const newUrl = req.body.url;
|
||||
Integrations.direct.update(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ $set: { url: newUrl } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('token')) {
|
||||
const newToken = req.body.token;
|
||||
Integrations.direct.update(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ $set: { token: newToken } },
|
||||
);
|
||||
}
|
||||
if (req.body.hasOwnProperty('activities')) {
|
||||
const newActivities = req.body.activities;
|
||||
Integrations.direct.update(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ $set: { activities: newActivities } },
|
||||
);
|
||||
}
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramIntId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation delete_integration_activities
|
||||
* @summary Delete subscribed activities
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} intId the integration ID
|
||||
* @param {string} newActivities the activities to remove from the integration
|
||||
* @return_type Integrations
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'DELETE',
|
||||
'/api/boards/:boardId/integrations/:intId/activities',
|
||||
async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramIntId = req.params.intId;
|
||||
const newActivities = req.body.activities;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
|
||||
Integrations.direct.update(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ $pullAll: { activities: newActivities } },
|
||||
);
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: await ReactiveCache.getIntegration(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ fields: { _id: 1, activities: 1 } },
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation new_integration_activities
|
||||
* @summary Add subscribed activities
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} intId the integration ID
|
||||
* @param {string} newActivities the activities to add to the integration
|
||||
* @return_type Integrations
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'POST',
|
||||
'/api/boards/:boardId/integrations/:intId/activities',
|
||||
async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramIntId = req.params.intId;
|
||||
const newActivities = req.body.activities;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
|
||||
Integrations.direct.update(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ $addToSet: { activities: { $each: newActivities } } },
|
||||
);
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: await ReactiveCache.getIntegration(
|
||||
{ _id: paramIntId, boardId: paramBoardId },
|
||||
{ fields: { _id: 1, activities: 1 } },
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @operation delete_integration
|
||||
* @summary Delete integration
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} intId the integration ID
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('DELETE', '/api/boards/:boardId/integrations/:intId', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramIntId = req.params.intId;
|
||||
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
|
||||
|
||||
Integrations.direct.remove({ _id: paramIntId, boardId: paramBoardId });
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramIntId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default Integrations;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
InvitationCodes = new Mongo.Collection('invitation_codes');
|
||||
const InvitationCodes = new Mongo.Collection('invitation_codes');
|
||||
|
||||
InvitationCodes.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -14,7 +16,6 @@ InvitationCodes.attachSchema(
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
optional: true,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
@@ -29,7 +30,6 @@ InvitationCodes.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -44,9 +44,12 @@ InvitationCodes.attachSchema(
|
||||
type: String,
|
||||
},
|
||||
boardsToBeInvited: {
|
||||
type: [String],
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'boardsToBeInvited.$': {
|
||||
type: String,
|
||||
},
|
||||
valid: {
|
||||
type: Boolean,
|
||||
defaultValue: true,
|
||||
@@ -65,13 +68,4 @@ InvitationCodes.helpers({
|
||||
// doc.authorId = userId;
|
||||
// });
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await InvitationCodes._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
});
|
||||
Boards.deny({
|
||||
fetch: ['members'],
|
||||
});
|
||||
}
|
||||
|
||||
export default InvitationCodes;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
import { Mongo, MongoInternals } from 'meteor/mongo';
|
||||
|
||||
/**
|
||||
* Backward compatibility layer for CollectionFS to Meteor-Files migration
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from 'fs';
|
||||
import Activities from '/models/activities';
|
||||
import FileStoreStrategy, {FileStoreStrategyFilesystem, FileStoreStrategyGridFs, FileStoreStrategyS3} from './fileStoreStrategy'
|
||||
|
||||
const insertActivity = (fileObj, activityType) =>
|
||||
Activities.insert({
|
||||
Activities.insertAsync({
|
||||
userId: fileObj.userId,
|
||||
type: 'card',
|
||||
activityType,
|
||||
@@ -12,6 +12,8 @@ const insertActivity = (fileObj, activityType) =>
|
||||
cardId: fileObj.meta.cardId,
|
||||
listId: fileObj.meta.listId,
|
||||
swimlaneId: fileObj.meta.swimlaneId,
|
||||
}).catch(error => {
|
||||
console.error('Failed to insert attachment activity:', error);
|
||||
});
|
||||
|
||||
/** Strategy to store attachments at GridFS (MongoDB) */
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Storage backend name constants — shared between client and server.
|
||||
// Extracted from fileStoreStrategy.js to avoid pulling fs/path into the client.
|
||||
|
||||
export const STORAGE_NAME_FILESYSTEM = 'fs';
|
||||
export const STORAGE_NAME_GRIDFS = 'gridfs';
|
||||
export const STORAGE_NAME_S3 = 's3';
|
||||
@@ -1,15 +1,20 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { createObjectId } from './grid/createObjectId';
|
||||
import { httpStreamOutput } from './httpStream.js';
|
||||
import {
|
||||
STORAGE_NAME_FILESYSTEM,
|
||||
STORAGE_NAME_GRIDFS,
|
||||
STORAGE_NAME_S3,
|
||||
} from './fileStoreConstants';
|
||||
//import {} from './s3/Server-side-file-store.js';
|
||||
import { ObjectID } from 'bson';
|
||||
import { ObjectId } from 'bson';
|
||||
// DISABLED: Minio support removed due to Node.js compatibility issues
|
||||
// var Minio = require('minio');
|
||||
|
||||
export const STORAGE_NAME_FILESYSTEM = "fs";
|
||||
export const STORAGE_NAME_GRIDFS = "gridfs";
|
||||
export const STORAGE_NAME_S3 = "s3";
|
||||
// Re-export constants from shared module (keeps existing import paths working)
|
||||
export { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from './fileStoreConstants';
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent path traversal attacks
|
||||
@@ -246,7 +251,12 @@ export class FileStoreStrategyGridFs extends FileStoreStrategy {
|
||||
*/
|
||||
writeStreamFinished(finishedData) {
|
||||
const gridFsFileIdName = this.getGridFsFileIdName();
|
||||
Attachments.update({ _id: this.fileObj._id }, { $set: { [gridFsFileIdName]: finishedData._id.toHexString(), } });
|
||||
Attachments.updateAsync(
|
||||
{ _id: this.fileObj._id },
|
||||
{ $set: { [gridFsFileIdName]: finishedData._id.toHexString() } },
|
||||
).catch(error => {
|
||||
console.error('Failed to persist GridFS file id:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/** remove the file */
|
||||
@@ -261,7 +271,12 @@ export class FileStoreStrategyGridFs extends FileStoreStrategy {
|
||||
}
|
||||
|
||||
const gridFsFileIdName = this.getGridFsFileIdName();
|
||||
Attachments.update({ _id: this.fileObj._id }, { $unset: { [gridFsFileIdName]: 1 } });
|
||||
Attachments.updateAsync(
|
||||
{ _id: this.fileObj._id },
|
||||
{ $unset: { [gridFsFileIdName]: 1 } },
|
||||
).catch(error => {
|
||||
console.error('Failed to clear GridFS file id:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/** return the storage name
|
||||
@@ -508,7 +523,9 @@ export const moveToStorage = function(fileObj, storageDestination, fileStoreStra
|
||||
const safeName = sanitizeFilename(fileObj.name);
|
||||
if (safeName !== fileObj.name) {
|
||||
// Update the database with the sanitized name
|
||||
Attachments.update({ _id: fileObj._id }, { $set: { name: safeName } });
|
||||
Attachments.updateAsync({ _id: fileObj._id }, { $set: { name: safeName } }).catch(error => {
|
||||
console.error('Failed to persist sanitized attachment name:', error);
|
||||
});
|
||||
// Update the local object for use in this function
|
||||
fileObj.name = safeName;
|
||||
}
|
||||
@@ -531,18 +548,20 @@ export const moveToStorage = function(fileObj, storageDestination, fileStoreStra
|
||||
console.error('[readStream error]: ', error, fileObj._id);
|
||||
});
|
||||
|
||||
writeStream.on('finish', Meteor.bindEnvironment((finishedData) => {
|
||||
writeStream.on('finish', finishedData => {
|
||||
strategyWrite.writeStreamFinished(finishedData);
|
||||
}));
|
||||
});
|
||||
|
||||
// https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
|
||||
readStream.on('end', Meteor.bindEnvironment(() => {
|
||||
Attachments.update({ _id: fileObj._id }, { $set: {
|
||||
readStream.on('end', () => {
|
||||
Attachments.updateAsync({ _id: fileObj._id }, { $set: {
|
||||
[`versions.${versionName}.storage`]: strategyWrite.getStorageName(),
|
||||
[`versions.${versionName}.path`]: filePath,
|
||||
} });
|
||||
} }).catch(error => {
|
||||
console.error('Failed to update attachment storage metadata:', error);
|
||||
});
|
||||
strategyRead.unlink();
|
||||
}));
|
||||
});
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
}
|
||||
@@ -569,8 +588,8 @@ export const copyFile = async function(fileObj, newCardId, fileStoreStrategyFact
|
||||
});
|
||||
|
||||
// https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
|
||||
readStream.on('end', Meteor.bindEnvironment(() => {
|
||||
const fileId = new ObjectID().toString();
|
||||
readStream.on('end', () => {
|
||||
const fileId = new ObjectId().toString();
|
||||
Attachments.addFile(
|
||||
tempPath,
|
||||
{
|
||||
@@ -594,12 +613,14 @@ export const copyFile = async function(fileObj, newCardId, fileStoreStrategyFact
|
||||
console.log(err);
|
||||
} else {
|
||||
// Set the userId again
|
||||
Attachments.update({ _id: fileRef._id }, { $set: { userId: fileObj.userId } });
|
||||
Attachments.updateAsync({ _id: fileRef._id }, { $set: { userId: fileObj.userId } }).catch(error => {
|
||||
console.error('Failed to update copied attachment userId:', error);
|
||||
});
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
}));
|
||||
});
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
@@ -614,9 +635,11 @@ export const rename = function(fileObj, newName, fileStoreStrategyFactory) {
|
||||
const newFilePath = strategy.getNewPath(fileStoreStrategyFactory.storagePath, safeName);
|
||||
strategy.rename(newFilePath);
|
||||
|
||||
Attachments.update({ _id: fileObj._id }, { $set: {
|
||||
Attachments.updateAsync({ _id: fileObj._id }, { $set: {
|
||||
"name": safeName,
|
||||
[`versions.${versionName}.path`]: newFilePath,
|
||||
} });
|
||||
} }).catch(error => {
|
||||
console.error('Failed to persist renamed attachment path:', error);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export const createOnAfterUpload = bucket =>
|
||||
// that occurred during the upload to prevent zombie files
|
||||
.on('error', err => {
|
||||
// console.error("[createOnAfterUpload error]", err);
|
||||
self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS
|
||||
self.unlink(file, versionName); // Unlink files from FS
|
||||
})
|
||||
|
||||
// once we are finished, we attach the gridFS Object id on the
|
||||
@@ -35,17 +35,19 @@ export const createOnAfterUpload = bucket =>
|
||||
// upload file from the filesystem
|
||||
.on(
|
||||
'finish',
|
||||
Meteor.bindEnvironment(ver => {
|
||||
ver => {
|
||||
const property = `versions.${versionName}.meta.gridFsFileId`;
|
||||
|
||||
self.collection.update(file._id, {
|
||||
self.collection.updateAsync(file._id, {
|
||||
$set: {
|
||||
[property]: ver._id.toHexString(),
|
||||
},
|
||||
}).catch(err => {
|
||||
console.error('[createOnAfterUpload update error]', err);
|
||||
});
|
||||
|
||||
self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS
|
||||
}),
|
||||
self.unlink(file, versionName); // Unlink files from FS
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { DDP } from 'meteor/ddp';
|
||||
import { mongodbConnectionManager } from './mongodbConnectionManager';
|
||||
import { mongodbDriverManager } from './mongodbDriverManager';
|
||||
|
||||
+40
-863
@@ -1,8 +1,13 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ALLOWED_COLORS } from '/config/const';
|
||||
import { LIST_COLORS } from '/models/metadata/colors';
|
||||
import PositionHistory from './positionHistory';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Lists = new Mongo.Collection('lists');
|
||||
const Lists = new Mongo.Collection('lists');
|
||||
|
||||
/**
|
||||
* A list (column) in the Wekan board.
|
||||
@@ -78,7 +83,6 @@ Lists.attachSchema(
|
||||
* is the list sorted
|
||||
*/
|
||||
type: Number,
|
||||
decimal: true,
|
||||
// XXX We should probably provide a default
|
||||
optional: true,
|
||||
},
|
||||
@@ -99,7 +103,6 @@ Lists.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
// this is redundant with updatedAt
|
||||
@@ -125,7 +128,6 @@ Lists.attachSchema(
|
||||
* value of the WIP
|
||||
*/
|
||||
type: Number,
|
||||
decimal: false,
|
||||
defaultValue: 1,
|
||||
},
|
||||
'wipLimit.enabled': {
|
||||
@@ -149,7 +151,7 @@ Lists.attachSchema(
|
||||
type: String,
|
||||
optional: true,
|
||||
// silver is the default
|
||||
allowedValues: ALLOWED_COLORS,
|
||||
allowedValues: LIST_COLORS,
|
||||
},
|
||||
type: {
|
||||
/**
|
||||
@@ -179,22 +181,6 @@ Lists.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
Lists.allow({
|
||||
async insert(userId, doc) {
|
||||
// ReadOnly and CommentOnly users cannot create lists
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
// ReadOnly and CommentOnly users cannot edit lists
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
// ReadOnly and CommentOnly users cannot delete lists
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
fetch: ['boardId'],
|
||||
});
|
||||
|
||||
Lists.helpers({
|
||||
async copy(boardId, swimlaneId) {
|
||||
const oldId = this._id;
|
||||
@@ -424,868 +410,59 @@ Lists.archivedListIds = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const hasBoardWriteAccess = (userId, board) => {
|
||||
if (!userId || !board) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof allowIsBoardMemberWithWriteAccess === 'function') {
|
||||
return allowIsBoardMemberWithWriteAccess(userId, board);
|
||||
}
|
||||
|
||||
// Fallback for client method simulation where server-only globals are unavailable.
|
||||
return (
|
||||
board.hasMember(userId) &&
|
||||
!board.hasNoComments(userId) &&
|
||||
!board.hasCommentOnly(userId) &&
|
||||
!board.hasWorker(userId) &&
|
||||
!board.hasReadOnly(userId) &&
|
||||
!board.hasReadAssignedOnly(userId)
|
||||
);
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
async createListAfter(params) {
|
||||
check(params, {
|
||||
title: String,
|
||||
boardId: String,
|
||||
swimlaneId: Match.OneOf(String, null, undefined),
|
||||
afterListId: Match.OneOf(String, null, undefined),
|
||||
nextListId: Match.OneOf(String, null, undefined),
|
||||
type: Match.Maybe(String),
|
||||
});
|
||||
|
||||
const {
|
||||
title,
|
||||
boardId,
|
||||
swimlaneId = null,
|
||||
afterListId = null,
|
||||
nextListId = null,
|
||||
type = 'list',
|
||||
} = params;
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
// Avoid client-side stub writes; server method call is authoritative.
|
||||
if (Meteor.isClient && this.isSimulation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
try {
|
||||
if (typeof Authentication !== 'undefined' && Authentication?.checkBoardWriteAccess) {
|
||||
await Authentication.checkBoardWriteAccess(this.userId, boardId);
|
||||
} else if (!hasBoardWriteAccess(this.userId, board)) {
|
||||
throw new Meteor.Error('not-authorized', 'Access denied');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Meteor.Error('not-authorized', 'Access denied');
|
||||
}
|
||||
} else if (!hasBoardWriteAccess(this.userId, board)) {
|
||||
throw new Meteor.Error('not-authorized', 'Access denied');
|
||||
}
|
||||
|
||||
const normalizeSwimlaneId = value => value || '';
|
||||
const getResolvedSwimlaneId = (list, fallback = '') => {
|
||||
if (!list) return normalizeSwimlaneId(fallback);
|
||||
if (typeof list.getEffectiveSwimlaneId === 'function') {
|
||||
return normalizeSwimlaneId(list.getEffectiveSwimlaneId() || fallback);
|
||||
}
|
||||
return normalizeSwimlaneId(list.swimlaneId || fallback);
|
||||
};
|
||||
|
||||
const defaultSwimlane = board.getDefaultSwimline ? board.getDefaultSwimline() : null;
|
||||
let targetSwimlaneId = normalizeSwimlaneId(swimlaneId || (defaultSwimlane && defaultSwimlane._id));
|
||||
|
||||
let sort = 0;
|
||||
if (afterListId) {
|
||||
const selectedList = await ReactiveCache.getList({
|
||||
_id: afterListId,
|
||||
boardId,
|
||||
archived: false,
|
||||
});
|
||||
|
||||
if (!selectedList) {
|
||||
throw new Meteor.Error('list-not-found', 'Selected list not found');
|
||||
}
|
||||
|
||||
targetSwimlaneId = getResolvedSwimlaneId(selectedList, targetSwimlaneId);
|
||||
|
||||
const swimlaneLists = (await ReactiveCache.getLists({
|
||||
boardId,
|
||||
archived: false,
|
||||
}))
|
||||
.filter(list => getResolvedSwimlaneId(list, targetSwimlaneId) === targetSwimlaneId)
|
||||
.sort((a, b) => a.sort - b.sort);
|
||||
|
||||
let nextList = null;
|
||||
if (nextListId) {
|
||||
nextList = await ReactiveCache.getList({
|
||||
_id: nextListId,
|
||||
boardId,
|
||||
archived: false,
|
||||
});
|
||||
if (nextList) {
|
||||
const nextSwimlaneId = getResolvedSwimlaneId(nextList, targetSwimlaneId);
|
||||
if (nextSwimlaneId !== targetSwimlaneId) {
|
||||
nextList = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextList) {
|
||||
const selectedIndex = swimlaneLists.findIndex(list => list._id === selectedList._id);
|
||||
nextList = selectedIndex >= 0 ? swimlaneLists[selectedIndex + 1] : null;
|
||||
}
|
||||
|
||||
if (
|
||||
nextList &&
|
||||
Number.isFinite(nextList.sort) &&
|
||||
Number.isFinite(selectedList.sort) &&
|
||||
nextList.sort > selectedList.sort
|
||||
) {
|
||||
sort = selectedList.sort + (nextList.sort - selectedList.sort) / 2;
|
||||
} else if (Number.isFinite(selectedList.sort)) {
|
||||
sort = selectedList.sort + 1;
|
||||
} else {
|
||||
const last = swimlaneLists[swimlaneLists.length - 1];
|
||||
sort = Number.isFinite(last?.sort) ? last.sort + 1 : 0;
|
||||
}
|
||||
} else {
|
||||
const swimlaneLists = (await ReactiveCache.getLists({
|
||||
boardId,
|
||||
archived: false,
|
||||
}))
|
||||
.filter(list => getResolvedSwimlaneId(list, targetSwimlaneId) === targetSwimlaneId)
|
||||
.sort((a, b) => a.sort - b.sort);
|
||||
|
||||
const last = swimlaneLists[swimlaneLists.length - 1];
|
||||
sort = Number.isFinite(last?.sort) ? last.sort + 1 : 0;
|
||||
}
|
||||
|
||||
const listId = await Lists.insertAsync({
|
||||
title,
|
||||
boardId,
|
||||
sort,
|
||||
type,
|
||||
swimlaneId: targetSwimlaneId,
|
||||
});
|
||||
|
||||
return listId;
|
||||
},
|
||||
|
||||
async copyList(listId, boardId, swimlaneId, title, neighborListId, position) {
|
||||
check(listId, String);
|
||||
check(boardId, String);
|
||||
check(swimlaneId, String);
|
||||
check(title, String);
|
||||
check(neighborListId, Match.OneOf(String, null, undefined));
|
||||
check(position, Match.OneOf(String, null, undefined));
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
if (Meteor.isClient) return null;
|
||||
|
||||
const list = await ReactiveCache.getList(listId);
|
||||
if (!list) {
|
||||
throw new Meteor.Error('list-not-found', 'List not found');
|
||||
}
|
||||
|
||||
const targetBoard = await ReactiveCache.getBoard(boardId);
|
||||
if (!targetBoard) {
|
||||
throw new Meteor.Error('board-not-found', 'Target board not found');
|
||||
}
|
||||
|
||||
// Compute sort value relative to neighbor list
|
||||
let sort = (await ReactiveCache.getLists({ boardId, archived: false })).length;
|
||||
if (neighborListId) {
|
||||
const neighborList = await ReactiveCache.getList({ _id: neighborListId, boardId, archived: false });
|
||||
if (neighborList && Number.isFinite(neighborList.sort)) {
|
||||
const allLists = (await ReactiveCache.getLists({ boardId, swimlaneId, archived: false }))
|
||||
.sort((a, b) => a.sort - b.sort);
|
||||
const neighborIndex = allLists.findIndex(l => l._id === neighborListId);
|
||||
if (position === 'left') {
|
||||
const prev = allLists[neighborIndex - 1];
|
||||
sort = prev && Number.isFinite(prev.sort)
|
||||
? (prev.sort + neighborList.sort) / 2
|
||||
: neighborList.sort - 1;
|
||||
} else {
|
||||
const next = allLists[neighborIndex + 1];
|
||||
sort = next && Number.isFinite(next.sort)
|
||||
? (neighborList.sort + next.sort) / 2
|
||||
: neighborList.sort + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newListId = await Lists.insertAsync({
|
||||
title: title || list.title,
|
||||
boardId,
|
||||
swimlaneId,
|
||||
type: list.type,
|
||||
archived: false,
|
||||
wipLimit: list.wipLimit,
|
||||
sort,
|
||||
});
|
||||
|
||||
const cards = await ReactiveCache.getCards({ listId: list._id, archived: false });
|
||||
for (const card of cards) {
|
||||
await card.copy(boardId, swimlaneId, newListId);
|
||||
}
|
||||
|
||||
return newListId;
|
||||
},
|
||||
|
||||
async moveList(listId, boardId, swimlaneId, neighborListId, position, title) {
|
||||
check(listId, String);
|
||||
check(boardId, String);
|
||||
check(swimlaneId, String);
|
||||
check(neighborListId, Match.OneOf(String, null, undefined));
|
||||
check(position, Match.OneOf(String, null, undefined));
|
||||
check(title, Match.OneOf(String, null, undefined));
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
if (Meteor.isClient) return null;
|
||||
|
||||
const list = await ReactiveCache.getList(listId);
|
||||
if (!list) {
|
||||
throw new Meteor.Error('list-not-found', 'List not found');
|
||||
}
|
||||
const desiredTitle = typeof title === 'string' && title.trim().length > 0
|
||||
? title.trim()
|
||||
: list.title;
|
||||
|
||||
const targetBoard = await ReactiveCache.getBoard(boardId);
|
||||
if (!targetBoard) {
|
||||
throw new Meteor.Error('board-not-found', 'Target board not found');
|
||||
}
|
||||
|
||||
// list.move() relies on this.title to determine destination list identity.
|
||||
list.title = desiredTitle;
|
||||
await list.move(boardId, swimlaneId);
|
||||
|
||||
// Update sort of resulting list when a neighbor is specified
|
||||
if (neighborListId) {
|
||||
const movedList = await ReactiveCache.getList({ boardId, title: desiredTitle, archived: false });
|
||||
const neighborList = await ReactiveCache.getList({ _id: neighborListId, boardId, archived: false });
|
||||
if (movedList && neighborList && Number.isFinite(neighborList.sort)) {
|
||||
const allLists = (await ReactiveCache.getLists({ boardId, swimlaneId, archived: false }))
|
||||
.filter(l => l._id !== movedList._id)
|
||||
.sort((a, b) => a.sort - b.sort);
|
||||
const neighborIndex = allLists.findIndex(l => l._id === neighborListId);
|
||||
let newSort;
|
||||
if (position === 'left') {
|
||||
const prev = allLists[neighborIndex - 1];
|
||||
newSort = prev && Number.isFinite(prev.sort)
|
||||
? (prev.sort + neighborList.sort) / 2
|
||||
: neighborList.sort - 1;
|
||||
} else {
|
||||
const next = allLists[neighborIndex + 1];
|
||||
newSort = next && Number.isFinite(next.sort)
|
||||
? (neighborList.sort + next.sort) / 2
|
||||
: neighborList.sort + 1;
|
||||
}
|
||||
await Lists.updateAsync(movedList._id, { $set: { sort: newSort } });
|
||||
}
|
||||
}
|
||||
|
||||
await list.archive();
|
||||
},
|
||||
|
||||
async applyWipLimit(listId, limit) {
|
||||
check(listId, String);
|
||||
check(limit, Number);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const list = await ReactiveCache.getList(listId);
|
||||
if (!list) {
|
||||
throw new Meteor.Error('list-not-found', 'List not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(list.boardId);
|
||||
if (!board || !board.hasAdmin(this.userId)) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be a board admin to modify WIP limits.');
|
||||
}
|
||||
|
||||
if (limit === 0) {
|
||||
limit = 1;
|
||||
}
|
||||
await list.setWipLimit(limit);
|
||||
},
|
||||
|
||||
async enableWipLimit(listId) {
|
||||
check(listId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const list = await ReactiveCache.getList(listId);
|
||||
if (!list) {
|
||||
throw new Meteor.Error('list-not-found', 'List not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(list.boardId);
|
||||
if (!board || !board.hasAdmin(this.userId)) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be a board admin to modify WIP limits.');
|
||||
}
|
||||
|
||||
if ((await list.getWipLimit('value')) === 0) {
|
||||
await list.setWipLimit(1);
|
||||
}
|
||||
await list.toggleWipLimit(!(await list.getWipLimit('enabled')));
|
||||
},
|
||||
|
||||
async enableSoftLimit(listId) {
|
||||
check(listId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in.');
|
||||
}
|
||||
|
||||
const list = await ReactiveCache.getList(listId);
|
||||
if (!list) {
|
||||
throw new Meteor.Error('list-not-found', 'List not found');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(list.boardId);
|
||||
if (!board || !board.hasAdmin(this.userId)) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be a board admin to modify WIP limits.');
|
||||
}
|
||||
|
||||
await list.toggleSoftLimit(!(await list.getWipLimit('soft')));
|
||||
},
|
||||
|
||||
async myLists() {
|
||||
// my lists
|
||||
const lists = await ReactiveCache.getLists(
|
||||
{
|
||||
boardId: { $in: await Boards.userBoardIds(this.userId) },
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
fields: { title: 1 },
|
||||
},
|
||||
);
|
||||
return [...new Set(lists.map(list => list.title))].sort();
|
||||
},
|
||||
|
||||
async updateListSort(listId, boardId, updateData) {
|
||||
check(listId, String);
|
||||
check(boardId, String);
|
||||
check(updateData, Object);
|
||||
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('board-not-found', 'Board not found');
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
if (typeof allowIsBoardMember === 'function') {
|
||||
if (!allowIsBoardMember(this.userId, board)) {
|
||||
throw new Meteor.Error('permission-denied', 'User does not have permission to modify this board');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const list = await ReactiveCache.getList(listId);
|
||||
if (!list) {
|
||||
throw new Meteor.Error('list-not-found', 'List not found');
|
||||
}
|
||||
|
||||
const validUpdateFields = ['sort', 'swimlaneId', 'updatedAt', 'modifiedAt'];
|
||||
Object.keys(updateData).forEach(field => {
|
||||
if (!validUpdateFields.includes(field)) {
|
||||
throw new Meteor.Error('invalid-field', `Field ${field} is not allowed`);
|
||||
}
|
||||
});
|
||||
|
||||
if (updateData.swimlaneId) {
|
||||
const swimlane = await ReactiveCache.getSwimlane(updateData.swimlaneId);
|
||||
if (!swimlane || swimlane.boardId !== boardId) {
|
||||
throw new Meteor.Error('invalid-swimlane', 'Invalid swimlane for this board');
|
||||
}
|
||||
}
|
||||
|
||||
await Lists.updateAsync(
|
||||
listId,
|
||||
{
|
||||
$set: {
|
||||
...updateData,
|
||||
modifiedAt: new Date(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
listId,
|
||||
updatedFields: Object.keys(updateData),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Lists._collection.rawCollection().createIndex({ modifiedAt: -1 });
|
||||
await Lists._collection.rawCollection().createIndex({ boardId: 1 });
|
||||
await Lists._collection.rawCollection().createIndex({ archivedAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
Lists.after.insert((userId, doc) => {
|
||||
if (Meteor.isClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'list',
|
||||
activityType: 'createList',
|
||||
boardId: doc.boardId,
|
||||
listId: doc._id,
|
||||
// this preserves the name so that the activity can be useful after the
|
||||
// list is deleted
|
||||
title: doc.title,
|
||||
});
|
||||
|
||||
// Track original position for new lists
|
||||
Meteor.setTimeout(() => {
|
||||
const list = Lists.findOne(doc._id);
|
||||
if (list) {
|
||||
list.trackOriginalPosition();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
Lists.before.remove(async (userId, doc) => {
|
||||
if (Meteor.isClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cards = await ReactiveCache.getCards({ listId: doc._id });
|
||||
if (cards) {
|
||||
for (const card of cards) {
|
||||
// For non-archived lists, preserve existing behavior: deleting the list
|
||||
// deletes all its cards.
|
||||
if (!doc.archived) {
|
||||
await Cards.removeAsync(card._id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// For archived lists, only delete cards that were archived together with
|
||||
// the list (or after it). Keep cards that were archived before the list
|
||||
// was archived so deleting the list does not remove independently
|
||||
// archived cards.
|
||||
const listArchivedAt = doc.archivedAt;
|
||||
const cardArchivedAt = card.archivedAt;
|
||||
const shouldDeleteCard =
|
||||
!listArchivedAt ||
|
||||
!card.archived ||
|
||||
!cardArchivedAt ||
|
||||
cardArchivedAt >= listArchivedAt;
|
||||
|
||||
if (shouldDeleteCard) {
|
||||
await Cards.removeAsync(card._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
type: 'list',
|
||||
activityType: 'removeList',
|
||||
boardId: doc.boardId,
|
||||
listId: doc._id,
|
||||
title: doc.title,
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure we don't fetch previous doc in after.update hook
|
||||
Lists.hookOptions.after.update = { fetchPrevious: false };
|
||||
|
||||
Lists.after.update((userId, doc, fieldNames) => {
|
||||
if (Meteor.isClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldNames.includes('title')) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'list',
|
||||
activityType: 'changedListTitle',
|
||||
listId: doc._id,
|
||||
boardId: doc.boardId,
|
||||
// this preserves the name so that the activity can be useful after the
|
||||
// list is deleted
|
||||
title: doc.title,
|
||||
});
|
||||
} else if (doc.archived) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'list',
|
||||
activityType: 'archivedList',
|
||||
listId: doc._id,
|
||||
boardId: doc.boardId,
|
||||
// this preserves the name so that the activity can be useful after the
|
||||
// list is deleted
|
||||
title: doc.title,
|
||||
});
|
||||
} else if (fieldNames.includes('archived')) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'list',
|
||||
activityType: 'restoredList',
|
||||
listId: doc._id,
|
||||
boardId: doc.boardId,
|
||||
// this preserves the name so that the activity can be useful after the
|
||||
// list is deleted
|
||||
title: doc.title,
|
||||
});
|
||||
}
|
||||
|
||||
// When sort or swimlaneId change, trigger a pub/sub refresh marker
|
||||
if (fieldNames.includes('sort') || fieldNames.includes('swimlaneId')) {
|
||||
Lists.direct.update(
|
||||
{ _id: doc._id },
|
||||
{ $set: { _updatedAt: new Date() } },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
//LISTS REST API
|
||||
if (Meteor.isServer) {
|
||||
/**
|
||||
* @operation get_all_lists
|
||||
* @summary Get the list of Lists attached to a board
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @return_type [{_id: string,
|
||||
* title: string}]
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/lists', async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: (await ReactiveCache.getLists({ boardId: paramBoardId, archived: false })).map(
|
||||
function(doc) {
|
||||
return {
|
||||
_id: doc._id,
|
||||
title: doc.title,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation get_list
|
||||
* @summary Get a List attached to a board
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} listId the List ID
|
||||
* @return_type Lists
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramListId = req.params.listId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: await ReactiveCache.getList({
|
||||
_id: paramListId,
|
||||
boardId: paramBoardId,
|
||||
archived: false,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation new_list
|
||||
* @summary Add a List to a board
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} title the title of the List
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('POST', '/api/boards/:boardId/lists', async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
||||
const board = await ReactiveCache.getBoard(paramBoardId);
|
||||
const id = await Lists.insertAsync({
|
||||
title: req.body.title,
|
||||
boardId: paramBoardId,
|
||||
sort: board.lists().length,
|
||||
swimlaneId: req.body.swimlaneId || board.getDefaultSwimline()._id, // Use provided swimlaneId or default
|
||||
});
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation edit_list
|
||||
* @summary Edit a List
|
||||
*
|
||||
* @description This updates a list on a board.
|
||||
* You can update the title, color, wipLimit, starred, and collapsed properties.
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} listId the ID of the list to update
|
||||
* @param {string} [title] the new title of the list
|
||||
* @param {string} [color] the new color of the list
|
||||
* @param {Object} [wipLimit] the WIP limit configuration
|
||||
* @param {boolean} [starred] whether the list is starred
|
||||
* @param {boolean} [collapsed] whether the list is collapsed
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('PUT', '/api/boards/:boardId/lists/:listId', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramListId = req.params.listId;
|
||||
let updated = false;
|
||||
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
||||
|
||||
const list = await ReactiveCache.getList({
|
||||
_id: paramListId,
|
||||
boardId: paramBoardId,
|
||||
archived: false,
|
||||
});
|
||||
|
||||
if (!list) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
data: { error: 'List not found' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update title if provided
|
||||
if (req.body.title) {
|
||||
// Basic client-side validation - server will handle full sanitization
|
||||
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 list title input:', req.body.title, '->', newTitle);
|
||||
}
|
||||
|
||||
Lists.direct.update(
|
||||
{
|
||||
_id: paramListId,
|
||||
boardId: paramBoardId,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
title: newTitle,
|
||||
},
|
||||
},
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// Update color if provided
|
||||
if (req.body.color) {
|
||||
const newColor = req.body.color;
|
||||
Lists.direct.update(
|
||||
{
|
||||
_id: paramListId,
|
||||
boardId: paramBoardId,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
color: newColor,
|
||||
},
|
||||
},
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// Update starred status if provided
|
||||
if (req.body.hasOwnProperty('starred')) {
|
||||
const newStarred = req.body.starred;
|
||||
Lists.direct.update(
|
||||
{
|
||||
_id: paramListId,
|
||||
boardId: paramBoardId,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
starred: newStarred,
|
||||
},
|
||||
},
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// NOTE: collapsed state removed from board-level
|
||||
// It's per-user only - use user profile methods instead
|
||||
|
||||
// Update wipLimit if provided
|
||||
if (req.body.wipLimit) {
|
||||
const newWipLimit = req.body.wipLimit;
|
||||
Lists.direct.update(
|
||||
{
|
||||
_id: paramListId,
|
||||
boardId: paramBoardId,
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
wipLimit: newWipLimit,
|
||||
},
|
||||
},
|
||||
);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// Check if update is true or false
|
||||
if (!updated) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 404,
|
||||
data: {
|
||||
message: 'Error',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramListId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation delete_list
|
||||
* @summary Delete a List
|
||||
*
|
||||
* @description This **deletes** a list from a board.
|
||||
* The list is not put in the recycle bin.
|
||||
*
|
||||
* @param {string} boardId the board ID
|
||||
* @param {string} listId the ID of the list to remove
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId', function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramListId = req.params.listId;
|
||||
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
||||
Lists.remove({ _id: paramListId, boardId: paramBoardId });
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramListId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Position history tracking methods
|
||||
Lists.helpers({
|
||||
/**
|
||||
* Track the original position of this list
|
||||
*/
|
||||
trackOriginalPosition() {
|
||||
const existingHistory = PositionHistory.findOne({
|
||||
const selector = {
|
||||
boardId: this.boardId,
|
||||
entityType: 'list',
|
||||
entityId: this._id,
|
||||
});
|
||||
};
|
||||
const document = {
|
||||
boardId: this.boardId,
|
||||
entityType: 'list',
|
||||
entityId: this._id,
|
||||
originalPosition: {
|
||||
sort: this.sort,
|
||||
title: this.title,
|
||||
},
|
||||
originalSwimlaneId: this.swimlaneId || null,
|
||||
originalTitle: this.title,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (!existingHistory) {
|
||||
PositionHistory.insert({
|
||||
boardId: this.boardId,
|
||||
entityType: 'list',
|
||||
entityId: this._id,
|
||||
originalPosition: {
|
||||
sort: this.sort,
|
||||
title: this.title,
|
||||
},
|
||||
originalSwimlaneId: this.swimlaneId || null,
|
||||
originalTitle: this.title,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
if (Meteor.isServer) {
|
||||
return PositionHistory.findOneAsync(selector).then(existingHistory => {
|
||||
if (!existingHistory) {
|
||||
return PositionHistory.insertAsync(document);
|
||||
}
|
||||
return existingHistory;
|
||||
});
|
||||
}
|
||||
|
||||
const existingHistory = PositionHistory.findOne(selector);
|
||||
if (!existingHistory) {
|
||||
PositionHistory.insert(document);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the original position history for this list
|
||||
*/
|
||||
getOriginalPosition() {
|
||||
return PositionHistory.findOne({
|
||||
const selector = {
|
||||
boardId: this.boardId,
|
||||
entityType: 'list',
|
||||
entityId: this._id,
|
||||
});
|
||||
};
|
||||
if (Meteor.isServer) {
|
||||
return PositionHistory.findOneAsync(selector);
|
||||
}
|
||||
return PositionHistory.findOne(selector);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
LockoutSettings = new Mongo.Collection('lockoutSettings');
|
||||
const LockoutSettings = new Mongo.Collection('lockoutSettings');
|
||||
|
||||
LockoutSettings.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -9,14 +11,12 @@ LockoutSettings.attachSchema(
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
decimal: false,
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
},
|
||||
sort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
@@ -34,7 +34,6 @@ LockoutSettings.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -47,96 +46,6 @@ LockoutSettings.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
LockoutSettings.allow({
|
||||
async update(userId) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
return user && user.isAdmin;
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await LockoutSettings._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
|
||||
// Known users settings
|
||||
await LockoutSettings.upsertAsync(
|
||||
{ _id: 'known-failuresBeforeLockout' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE
|
||||
? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE, 10) : 3,
|
||||
category: 'known',
|
||||
sort: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await LockoutSettings.upsertAsync(
|
||||
{ _id: 'known-lockoutPeriod' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD
|
||||
? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD, 10) : 60,
|
||||
category: 'known',
|
||||
sort: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await LockoutSettings.upsertAsync(
|
||||
{ _id: 'known-failureWindow' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
value: process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW
|
||||
? parseInt(process.env.ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW, 10) : 15,
|
||||
category: 'known',
|
||||
sort: 2,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Unknown users settings
|
||||
const typoVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE;
|
||||
const correctVar = process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BEFORE;
|
||||
|
||||
await LockoutSettings.upsertAsync(
|
||||
{ _id: 'unknown-failuresBeforeLockout' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
value: (correctVar || typoVar)
|
||||
? parseInt(correctVar || typoVar, 10) : 3,
|
||||
category: 'unknown',
|
||||
sort: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await LockoutSettings.upsertAsync(
|
||||
{ _id: 'unknown-lockoutPeriod' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD
|
||||
? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD, 10) : 60,
|
||||
category: 'unknown',
|
||||
sort: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await LockoutSettings.upsertAsync(
|
||||
{ _id: 'unknown-failureWindow' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
value: process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW
|
||||
? parseInt(process.env.ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW, 10) : 15,
|
||||
category: 'unknown',
|
||||
sort: 2,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
LockoutSettings.helpers({
|
||||
getKnownConfig() {
|
||||
// Fetch all settings in one query instead of 3 separate queries
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ALLOWED_BOARD_COLORS, ALLOWED_COLORS } from '/config/const';
|
||||
|
||||
const LABEL_COLORS = [...ALLOWED_COLORS];
|
||||
const LIST_COLORS = [...ALLOWED_COLORS];
|
||||
const CARD_COLORS = [...ALLOWED_COLORS];
|
||||
const SWIMLANE_COLORS = [...ALLOWED_COLORS];
|
||||
const BOARD_COLORS = [...ALLOWED_BOARD_COLORS];
|
||||
|
||||
export {
|
||||
BOARD_COLORS,
|
||||
CARD_COLORS,
|
||||
LABEL_COLORS,
|
||||
LIST_COLORS,
|
||||
SWIMLANE_COLORS,
|
||||
};
|
||||
|
||||
+3
-224
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Org = new Mongo.Collection('org');
|
||||
const Org = new Mongo.Collection('org');
|
||||
|
||||
/**
|
||||
* A Organization in Wekan. A Enterprise in Trello.
|
||||
@@ -58,7 +60,6 @@ Org.attachSchema(
|
||||
* creation date of the organization
|
||||
*/
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert) {
|
||||
@@ -72,7 +73,6 @@ Org.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -85,225 +85,4 @@ Org.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Org.allow({
|
||||
async insert(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
async update(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
fetch: [],
|
||||
});
|
||||
|
||||
|
||||
Meteor.methods({
|
||||
async setCreateOrg(
|
||||
orgDisplayName,
|
||||
orgDesc,
|
||||
orgShortName,
|
||||
orgAutoAddUsersWithDomainName,
|
||||
orgWebsite,
|
||||
orgIsActive,
|
||||
) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(orgDisplayName, String);
|
||||
check(orgDesc, String);
|
||||
check(orgShortName, String);
|
||||
check(orgAutoAddUsersWithDomainName, String);
|
||||
check(orgWebsite, String);
|
||||
check(orgIsActive, Boolean);
|
||||
|
||||
const nOrgNames = (await ReactiveCache.getOrgs({ orgShortName })).length;
|
||||
if (nOrgNames > 0) {
|
||||
throw new Meteor.Error('orgname-already-taken');
|
||||
} else {
|
||||
await Org.insertAsync({
|
||||
orgDisplayName,
|
||||
orgDesc,
|
||||
orgShortName,
|
||||
orgAutoAddUsersWithDomainName,
|
||||
orgWebsite,
|
||||
orgIsActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async setCreateOrgFromOidc(
|
||||
orgDisplayName,
|
||||
orgDesc,
|
||||
orgShortName,
|
||||
orgAutoAddUsersWithDomainName,
|
||||
orgWebsite,
|
||||
orgIsActive,
|
||||
) {
|
||||
check(orgDisplayName, String);
|
||||
check(orgDesc, String);
|
||||
check(orgShortName, String);
|
||||
check(orgAutoAddUsersWithDomainName, String);
|
||||
check(orgWebsite, String);
|
||||
check(orgIsActive, Boolean);
|
||||
|
||||
const nOrgNames = (await ReactiveCache.getOrgs({ orgShortName })).length;
|
||||
if (nOrgNames > 0) {
|
||||
throw new Meteor.Error('orgname-already-taken');
|
||||
} else {
|
||||
await Org.insertAsync({
|
||||
orgDisplayName,
|
||||
orgDesc,
|
||||
orgShortName,
|
||||
orgAutoAddUsersWithDomainName,
|
||||
orgWebsite,
|
||||
orgIsActive,
|
||||
});
|
||||
}
|
||||
},
|
||||
async setOrgDisplayName(org, orgDisplayName) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(org, Object);
|
||||
check(orgDisplayName, String);
|
||||
Org.update(org, {
|
||||
$set: { orgDisplayName: orgDisplayName },
|
||||
});
|
||||
await Meteor.callAsync('setUsersOrgsOrgDisplayName', org._id, orgDisplayName);
|
||||
}
|
||||
},
|
||||
|
||||
async setOrgDesc(org, orgDesc) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(org, Object);
|
||||
check(orgDesc, String);
|
||||
Org.update(org, {
|
||||
$set: { orgDesc: orgDesc },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async setOrgShortName(org, orgShortName) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(org, Object);
|
||||
check(orgShortName, String);
|
||||
Org.update(org, {
|
||||
$set: { orgShortName: orgShortName },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async setAutoAddUsersWithDomainName(org, orgAutoAddUsersWithDomainName) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(org, Object);
|
||||
check(orgAutoAddUsersWithDomainName, String);
|
||||
Org.update(org, {
|
||||
$set: { orgAutoAddUsersWithDomainName: orgAutoAddUsersWithDomainName },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async setOrgIsActive(org, orgIsActive) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(org, Object);
|
||||
check(orgIsActive, Boolean);
|
||||
Org.update(org, {
|
||||
$set: { orgIsActive: orgIsActive },
|
||||
});
|
||||
}
|
||||
},
|
||||
async setOrgAllFieldsFromOidc(
|
||||
org,
|
||||
orgDisplayName,
|
||||
orgDesc,
|
||||
orgShortName,
|
||||
orgAutoAddUsersWithDomainName,
|
||||
orgWebsite,
|
||||
orgIsActive,
|
||||
) {
|
||||
check(org, Object);
|
||||
check(orgDisplayName, String);
|
||||
check(orgDesc, String);
|
||||
check(orgShortName, String);
|
||||
check(orgAutoAddUsersWithDomainName, String);
|
||||
check(orgWebsite, String);
|
||||
check(orgIsActive, Boolean);
|
||||
Org.update(org, {
|
||||
$set: {
|
||||
orgDisplayName: orgDisplayName,
|
||||
orgDesc: orgDesc,
|
||||
orgShortName: orgShortName,
|
||||
orgAutoAddUsersWithDomainName: orgAutoAddUsersWithDomainName,
|
||||
orgWebsite: orgWebsite,
|
||||
orgIsActive: orgIsActive,
|
||||
},
|
||||
});
|
||||
await Meteor.callAsync('setUsersOrgsOrgDisplayName', org._id, orgDisplayName);
|
||||
},
|
||||
async setOrgAllFields(
|
||||
org,
|
||||
orgDisplayName,
|
||||
orgDesc,
|
||||
orgShortName,
|
||||
orgAutoAddUsersWithDomainName,
|
||||
orgWebsite,
|
||||
orgIsActive,
|
||||
) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(org, Object);
|
||||
check(orgDisplayName, String);
|
||||
check(orgDesc, String);
|
||||
check(orgShortName, String);
|
||||
check(orgAutoAddUsersWithDomainName, String);
|
||||
check(orgWebsite, String);
|
||||
check(orgIsActive, Boolean);
|
||||
Org.update(org, {
|
||||
$set: {
|
||||
orgDisplayName: orgDisplayName,
|
||||
orgDesc: orgDesc,
|
||||
orgShortName: orgShortName,
|
||||
orgAutoAddUsersWithDomainName: orgAutoAddUsersWithDomainName,
|
||||
orgWebsite: orgWebsite,
|
||||
orgIsActive: orgIsActive,
|
||||
},
|
||||
});
|
||||
await Meteor.callAsync('setUsersOrgsOrgDisplayName', org._id, orgDisplayName);
|
||||
}
|
||||
},
|
||||
async getOrgsCollectionCount(query = {}) {
|
||||
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
check(query, Match.OneOf(Object, null, undefined));
|
||||
const count = await (await ReactiveCache.getOrgs(query, {}, true)).count();
|
||||
return count;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Index for Organization name.
|
||||
Meteor.startup(async () => {
|
||||
// Org._collection.createIndexAsync({ name: -1 });
|
||||
await Org._collection.createIndexAsync({ orgDisplayName: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default Org;
|
||||
+3
-10
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { incrementCounter } from './counters';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
OrgUser = new Mongo.Collection('orgUser');
|
||||
const OrgUser = new Mongo.Collection('orgUser');
|
||||
|
||||
/**
|
||||
* A Organization User in wekan
|
||||
@@ -60,7 +62,6 @@ OrgUser.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -73,12 +74,4 @@ OrgUser.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Index for Organization User.
|
||||
Meteor.startup(async () => {
|
||||
await OrgUser._collection.createIndexAsync({ orgId: -1 });
|
||||
await OrgUser._collection.createIndexAsync({ orgId: -1, userId: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default OrgUser;
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
/**
|
||||
* PositionHistory collection to track original positions of swimlanes, lists, and cards
|
||||
* before the list naming feature was added in commit 719ef87efceacfe91461a8eeca7cf74d11f4cc0a
|
||||
*/
|
||||
PositionHistory = new Mongo.Collection('positionHistory');
|
||||
const PositionHistory = new Mongo.Collection('positionHistory');
|
||||
|
||||
PositionHistory.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -159,12 +161,4 @@ PositionHistory.helpers({
|
||||
}
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await PositionHistory._collection.createIndexAsync({ boardId: 1, entityType: 1, entityId: 1 });
|
||||
await PositionHistory._collection.createIndexAsync({ boardId: 1, entityType: 1 });
|
||||
await PositionHistory._collection.createIndexAsync({ createdAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default PositionHistory;
|
||||
+4
-13
@@ -1,15 +1,6 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
// Define presences collection
|
||||
Presences = new Mongo.Collection('presences');
|
||||
const Presences = new Mongo.Collection('presences');
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(() => {
|
||||
// Date of 7 days ago
|
||||
let lastWeek = new Date();
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
|
||||
Presences.remove({ ttl: { $lte: lastWeek } });
|
||||
|
||||
// Create index for serverId that is queried often
|
||||
Presences._collection._ensureIndex({ serverId: -1 });
|
||||
});
|
||||
}
|
||||
export default Presences;
|
||||
+3
-21
@@ -1,7 +1,8 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Rules = new Mongo.Collection('rules');
|
||||
const Rules = new Mongo.Collection('rules');
|
||||
|
||||
Rules.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -37,7 +38,6 @@ Rules.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -71,22 +71,4 @@ Rules.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
Rules.allow({
|
||||
async insert(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Rules._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default Rules;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
/**
|
||||
* Executes a function only if we are on the server. Use in combination
|
||||
* with package-sepcific loader functions to create a "nested" import that
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { createWorkbook } from './createWorkbook';
|
||||
import { fileStoreStrategyFactory } from '/models/attachments';
|
||||
import { fileStoreStrategyFactory } from '/models/attachments.server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
+10
-367
@@ -2,10 +2,16 @@
|
||||
// settings.js → ReactiveCache → i18n/tap → translation.js → ReactiveCache → settings.js
|
||||
// All dependencies use require() so Settings collection is defined first.
|
||||
|
||||
Settings = new Mongo.Collection('settings');
|
||||
const { FlowRouter } = require('meteor/ostrio:flow-router-extra');
|
||||
const { ReactiveCache } = require('/imports/reactiveCache');
|
||||
const { TAPi18n } = require('/imports/i18n');
|
||||
const Meteor = Package.meteor.Meteor;
|
||||
const Mongo = Package.mongo.Mongo;
|
||||
const Settings = new Mongo.Collection('settings');
|
||||
const FlowRouter = Package['ostrio:flow-router-extra'].FlowRouter;
|
||||
// Lazy getter — avoids circular dependency (reactiveCache imports settings)
|
||||
const getReactiveCache = () => require('/imports/reactiveCache').ReactiveCache;
|
||||
// Lazy getter — avoids circular dependency (i18n/tap → reactiveCache → settings)
|
||||
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
const InvitationCodes = require('/models/invitationCodes').default;
|
||||
//var nodemailer = require('nodemailer');
|
||||
|
||||
// Sandstorm context is detected using the METEOR_SETTINGS environment variable
|
||||
@@ -190,7 +196,6 @@ Settings.attachSchema(
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
denyUpdate: true,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert) {
|
||||
@@ -229,367 +234,5 @@ Settings.helpers({
|
||||
)}@${this.mailServer.host}:${this.mailServer.port}/`;
|
||||
},
|
||||
});
|
||||
Settings.allow({
|
||||
async update(userId) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
return user && user.isAdmin;
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Settings._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
const setting = await ReactiveCache.getCurrentSetting();
|
||||
if (!setting) {
|
||||
const now = new Date();
|
||||
const domain = process.env.ROOT_URL.match(
|
||||
/\/\/(?:www\.)?(.*)?(?:\/)?/,
|
||||
)[1];
|
||||
const from = `Boards Support <support@${domain}>`;
|
||||
const defaultSetting = {
|
||||
disableRegistration: false,
|
||||
mailServer: {
|
||||
username: '',
|
||||
password: '',
|
||||
host: '',
|
||||
port: '',
|
||||
enableTLS: false,
|
||||
from,
|
||||
},
|
||||
createdAt: now,
|
||||
modifiedAt: now,
|
||||
displayAuthenticationMethod: true,
|
||||
defaultAuthenticationMethod: 'password',
|
||||
};
|
||||
Settings.insert(defaultSetting);
|
||||
}
|
||||
if (isSandstorm) {
|
||||
// At Sandstorm, Admin Panel has SMTP settings
|
||||
const newSetting = await ReactiveCache.getCurrentSetting();
|
||||
if (!process.env.MAIL_URL && newSetting.mailUrl())
|
||||
process.env.MAIL_URL = newSetting.mailUrl();
|
||||
Accounts.emailTemplates.from = process.env.MAIL_FROM
|
||||
? process.env.MAIL_FROM
|
||||
: newSetting.mailServer.from;
|
||||
} else {
|
||||
// Not running on Sandstorm, so using environment variables
|
||||
Accounts.emailTemplates.from = process.env.MAIL_FROM;
|
||||
}
|
||||
});
|
||||
if (isSandstorm) {
|
||||
// At Sandstorm Wekan Admin Panel, save SMTP settings.
|
||||
Settings.after.update((userId, doc, fieldNames) => {
|
||||
// assign new values to mail-from & MAIL_URL in environment
|
||||
if (fieldNames.includes('mailServer') && doc.mailServer.host) {
|
||||
const protocol = doc.mailServer.enableTLS ? 'smtps://' : 'smtp://';
|
||||
if (!doc.mailServer.username && !doc.mailServer.password) {
|
||||
process.env.MAIL_URL = `${protocol}${doc.mailServer.host}:${doc.mailServer.port}/`;
|
||||
} else {
|
||||
process.env.MAIL_URL = `${protocol}${
|
||||
doc.mailServer.username
|
||||
}:${encodeURIComponent(doc.mailServer.password)}@${
|
||||
doc.mailServer.host
|
||||
}:${doc.mailServer.port}/`;
|
||||
}
|
||||
Accounts.emailTemplates.from = doc.mailServer.from;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRandomNum(min, max) {
|
||||
const range = max - min;
|
||||
const rand = Math.random();
|
||||
return min + Math.round(rand * range);
|
||||
}
|
||||
|
||||
function getEnvVar(name) {
|
||||
const value = process.env[name];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
throw new Meteor.Error([
|
||||
'var-not-exist',
|
||||
`The environment variable ${name} does not exist`,
|
||||
]);
|
||||
}
|
||||
|
||||
function loadOidcConfig(service){
|
||||
check(service, String);
|
||||
var config = ServiceConfiguration.configurations.findOne({service: service});
|
||||
return config;
|
||||
}
|
||||
|
||||
async function sendInvitationEmail(_id) {
|
||||
const icode = await ReactiveCache.getInvitationCode(_id);
|
||||
const author = await ReactiveCache.getCurrentUser();
|
||||
try {
|
||||
const authorUser = await ReactiveCache.getUser(icode.authorId);
|
||||
const fullName = authorUser?.profile?.fullname || "";
|
||||
|
||||
const params = {
|
||||
email: icode.email,
|
||||
inviter: fullName != "" ? fullName + " (" + authorUser.username + " )" : authorUser.username,
|
||||
user: icode.email.split('@')[0],
|
||||
icode: icode.code,
|
||||
url: FlowRouter.url('sign-up'),
|
||||
};
|
||||
const lang = author.getLanguage();
|
||||
// Use EmailLocalization utility to handle email in the proper language
|
||||
if (typeof EmailLocalization !== 'undefined') {
|
||||
await EmailLocalization.sendEmail({
|
||||
to: icode.email,
|
||||
from: Accounts.emailTemplates.from,
|
||||
subject: 'email-invite-register-subject',
|
||||
text: 'email-invite-register-text',
|
||||
params: params,
|
||||
language: lang
|
||||
});
|
||||
} else {
|
||||
// Fallback if EmailLocalization is not available
|
||||
await Email.sendAsync({
|
||||
to: icode.email,
|
||||
from: Accounts.emailTemplates.from,
|
||||
subject: TAPi18n.__('email-invite-register-subject', params, lang),
|
||||
text: TAPi18n.__('email-invite-register-text', params, lang),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
InvitationCodes.remove(_id);
|
||||
throw new Meteor.Error('email-fail', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function isNonAdminAllowedToSendMail(currentUser){
|
||||
const currSett = await ReactiveCache.getCurrentSetting();
|
||||
let isAllowed = false;
|
||||
if(currSett && currSett != undefined && currSett.disableRegistration && currSett.mailDomainName !== undefined && currSett.mailDomainName != ""){
|
||||
for(let i = 0; i < currentUser.emails.length; i++) {
|
||||
if(currentUser.emails[i].address.endsWith(currSett.mailDomainName)){
|
||||
isAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return isAllowed;
|
||||
}
|
||||
|
||||
function isLdapEnabled() {
|
||||
return (
|
||||
process.env.LDAP_ENABLE === 'true' || process.env.LDAP_ENABLE === true
|
||||
);
|
||||
}
|
||||
|
||||
function isOauth2Enabled() {
|
||||
return (
|
||||
process.env.OAUTH2_ENABLED === 'true' ||
|
||||
process.env.OAUTH2_ENABLED === true
|
||||
);
|
||||
}
|
||||
|
||||
function isCasEnabled() {
|
||||
return (
|
||||
process.env.CAS_ENABLED === 'true' || process.env.CAS_ENABLED === true
|
||||
);
|
||||
}
|
||||
|
||||
function isApiEnabled() {
|
||||
return process.env.WITH_API === 'true' || process.env.WITH_API === true;
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
async sendInvitation(emails, boards) {
|
||||
let rc = 0;
|
||||
check(emails, [String]);
|
||||
check(boards, [String]);
|
||||
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
if (!user.isAdmin && !(await isNonAdminAllowedToSendMail(user))) {
|
||||
rc = -1;
|
||||
throw new Meteor.Error('not-allowed');
|
||||
}
|
||||
for (const email of emails) {
|
||||
if (email && SimpleSchema.RegEx.Email.test(email)) {
|
||||
// Checks if the email is already link to an account.
|
||||
const userExist = await ReactiveCache.getUser({ email });
|
||||
if (userExist) {
|
||||
rc = -1;
|
||||
throw new Meteor.Error(
|
||||
'user-exist',
|
||||
`The user with the email ${email} has already an account.`,
|
||||
);
|
||||
}
|
||||
// Checks if the email is already link to an invitation.
|
||||
const invitation = await ReactiveCache.getInvitationCode({ email });
|
||||
if (invitation) {
|
||||
await InvitationCodes.updateAsync(invitation, {
|
||||
$set: { boardsToBeInvited: boards },
|
||||
});
|
||||
await sendInvitationEmail(invitation._id);
|
||||
} else {
|
||||
const code = getRandomNum(100000, 999999);
|
||||
const _id = await InvitationCodes.insertAsync(
|
||||
{
|
||||
code,
|
||||
email,
|
||||
boardsToBeInvited: boards,
|
||||
createdAt: new Date(),
|
||||
authorId: Meteor.userId(),
|
||||
},
|
||||
);
|
||||
if (_id) {
|
||||
await sendInvitationEmail(_id);
|
||||
} else {
|
||||
rc = -1;
|
||||
throw new Meteor.Error(
|
||||
'invitation-generated-fail',
|
||||
'Failed to create invitation code',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rc;
|
||||
},
|
||||
|
||||
async sendSMTPTestEmail() {
|
||||
if (!Meteor.userId()) {
|
||||
throw new Meteor.Error('invalid-user');
|
||||
}
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
if (!user.emails || !user.emails[0] || !user.emails[0].address) {
|
||||
throw new Meteor.Error('email-invalid');
|
||||
}
|
||||
this.unblock();
|
||||
const lang = user.getLanguage();
|
||||
try {
|
||||
/*
|
||||
if (process.env.MAIL_SERVICE !== '') {
|
||||
let transporter = nodemailer.createTransport({
|
||||
service: process.env.MAIL_SERVICE,
|
||||
auth: {
|
||||
user: process.env.MAIL_SERVICE_USER,
|
||||
pass: process.env.MAIL_SERVICE_PASSWORD
|
||||
},
|
||||
})
|
||||
let info = transporter.sendMail({
|
||||
to: user.emails[0].address,
|
||||
from: Accounts.emailTemplates.from,
|
||||
subject: TAPi18n.__('email-smtp-test-subject', { lng: lang }),
|
||||
text: TAPi18n.__('email-smtp-test-text', { lng: lang }),
|
||||
})
|
||||
} else {
|
||||
await Email.sendAsync({
|
||||
to: user.emails[0].address,
|
||||
from: Accounts.emailTemplates.from,
|
||||
subject: TAPi18n.__('email-smtp-test-subject', { lng: lang }),
|
||||
text: TAPi18n.__('email-smtp-test-text', { lng: lang }),
|
||||
});
|
||||
}
|
||||
*/
|
||||
await Email.sendAsync({
|
||||
to: user.emails[0].address,
|
||||
from: Accounts.emailTemplates.from,
|
||||
subject: TAPi18n.__('email-smtp-test-subject', { lng: lang }),
|
||||
text: TAPi18n.__('email-smtp-test-text', { lng: lang }),
|
||||
});
|
||||
} catch ({ message }) {
|
||||
throw new Meteor.Error(
|
||||
'email-fail',
|
||||
`${TAPi18n.__('email-fail-text', { lng: lang })}: ${message}`,
|
||||
message,
|
||||
);
|
||||
}
|
||||
return {
|
||||
message: 'email-sent',
|
||||
email: user.emails[0].address,
|
||||
};
|
||||
},
|
||||
|
||||
async getCustomUI() {
|
||||
const setting = await ReactiveCache.getCurrentSetting();
|
||||
if (!setting.productName) {
|
||||
return {
|
||||
productName: '',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
productName: `${setting.productName}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async isDisableRegistration() {
|
||||
const setting = await ReactiveCache.getCurrentSetting();
|
||||
if (setting.disableRegistration === true) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async isDisableForgotPassword() {
|
||||
const setting = await ReactiveCache.getCurrentSetting();
|
||||
if (setting.disableForgotPassword === true) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getMatomoConf() {
|
||||
return {
|
||||
address: getEnvVar('MATOMO_ADDRESS'),
|
||||
siteId: getEnvVar('MATOMO_SITE_ID'),
|
||||
doNotTrack: process.env.MATOMO_DO_NOT_TRACK || false,
|
||||
withUserName: process.env.MATOMO_WITH_USERNAME || false,
|
||||
};
|
||||
},
|
||||
|
||||
_isLdapEnabled() {
|
||||
return isLdapEnabled();
|
||||
},
|
||||
|
||||
_isOauth2Enabled() {
|
||||
return isOauth2Enabled();
|
||||
},
|
||||
|
||||
_isCasEnabled() {
|
||||
return isCasEnabled();
|
||||
},
|
||||
|
||||
_isApiEnabled() {
|
||||
return isApiEnabled();
|
||||
},
|
||||
|
||||
// Gets all connection methods to use it in the Template
|
||||
getAuthenticationsEnabled() {
|
||||
return {
|
||||
ldap: isLdapEnabled(),
|
||||
oauth2: isOauth2Enabled(),
|
||||
cas: isCasEnabled(),
|
||||
};
|
||||
},
|
||||
|
||||
getOauthServerUrl(){
|
||||
return process.env.OAUTH2_SERVER_URL;
|
||||
},
|
||||
getOauthDashboardUrl(){
|
||||
return process.env.DASHBOARD_URL;
|
||||
},
|
||||
getDefaultAuthenticationMethod() {
|
||||
return process.env.DEFAULT_AUTHENTICATION_METHOD;
|
||||
},
|
||||
|
||||
isPasswordLoginEnabled() {
|
||||
return !(process.env.PASSWORD_LOGIN_ENABLED === 'false');
|
||||
},
|
||||
isOidcRedirectionEnabled(){
|
||||
return process.env.OIDC_REDIRECTION_ENABLED === 'true' && Object.keys(loadOidcConfig("oidc")).length > 0;
|
||||
},
|
||||
getServiceConfiguration(service){
|
||||
return loadOidcConfig(service);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
+41
-308
@@ -1,8 +1,15 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ALLOWED_COLORS } from '/config/const';
|
||||
import { SWIMLANE_COLORS } from '/models/metadata/colors';
|
||||
import PositionHistory from './positionHistory';
|
||||
import Activities from '/models/activities';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
import Lists from '/models/lists';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Swimlanes = new Mongo.Collection('swimlanes');
|
||||
const Swimlanes = new Mongo.Collection('swimlanes');
|
||||
|
||||
/**
|
||||
* A swimlane is an line in the kaban board.
|
||||
@@ -61,7 +68,6 @@ Swimlanes.attachSchema(
|
||||
* the sort value of the swimlane
|
||||
*/
|
||||
type: Number,
|
||||
decimal: true,
|
||||
// XXX We should probably provide a default
|
||||
optional: true,
|
||||
},
|
||||
@@ -72,7 +78,7 @@ Swimlanes.attachSchema(
|
||||
type: String,
|
||||
optional: true,
|
||||
// silver is the default
|
||||
allowedValues: ALLOWED_COLORS,
|
||||
allowedValues: SWIMLANE_COLORS,
|
||||
},
|
||||
updatedAt: {
|
||||
/**
|
||||
@@ -91,7 +97,6 @@ Swimlanes.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -130,22 +135,6 @@ Swimlanes.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
Swimlanes.allow({
|
||||
async insert(userId, doc) {
|
||||
// ReadOnly and CommentOnly users cannot create swimlanes
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
// ReadOnly and CommentOnly users cannot edit swimlanes
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
// ReadOnly and CommentOnly users cannot delete swimlanes
|
||||
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
fetch: ['boardId'],
|
||||
});
|
||||
|
||||
Swimlanes.helpers({
|
||||
async copy(boardId, targetSwimlaneId = null, position = 'below', title = '') {
|
||||
const oldId = this._id;
|
||||
@@ -488,314 +477,58 @@ Swimlanes.archivedSwimlaneIds = async () => {
|
||||
|
||||
Swimlanes.hookOptions.after.update = { fetchPrevious: false };
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Swimlanes._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await Swimlanes._collection.createIndexAsync({ boardId: 1 });
|
||||
});
|
||||
|
||||
Swimlanes.after.insert((userId, doc) => {
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'swimlane',
|
||||
activityType: 'createSwimlane',
|
||||
boardId: doc.boardId,
|
||||
swimlaneId: doc._id,
|
||||
});
|
||||
|
||||
// Track original position for new swimlanes
|
||||
Meteor.setTimeout(() => {
|
||||
const swimlane = Swimlanes.findOne(doc._id);
|
||||
if (swimlane) {
|
||||
swimlane.trackOriginalPosition();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
Swimlanes.before.remove(async function(userId, doc) {
|
||||
const lists = await ReactiveCache.getLists(
|
||||
{
|
||||
boardId: doc.boardId,
|
||||
swimlaneId: { $in: [doc._id, ''] },
|
||||
archived: false,
|
||||
},
|
||||
{ sort: ['sort'] },
|
||||
);
|
||||
|
||||
if (lists.length < 2) {
|
||||
for (const list of lists) {
|
||||
await list.remove();
|
||||
}
|
||||
} else {
|
||||
await Cards.removeAsync({ swimlaneId: doc._id });
|
||||
}
|
||||
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
type: 'swimlane',
|
||||
activityType: 'removeSwimlane',
|
||||
boardId: doc.boardId,
|
||||
swimlaneId: doc._id,
|
||||
title: doc.title,
|
||||
});
|
||||
});
|
||||
|
||||
Swimlanes.after.update((userId, doc, fieldNames) => {
|
||||
if (fieldNames.includes('title')) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'swimlane',
|
||||
activityType: 'changedSwimlaneTitle',
|
||||
listId: doc._id,
|
||||
boardId: doc.boardId,
|
||||
// this preserves the name so that the activity can be useful after the
|
||||
// list is deleted
|
||||
title: doc.title,
|
||||
});
|
||||
} else if (doc.archived) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'swimlane',
|
||||
activityType: 'archivedSwimlane',
|
||||
listId: doc._id,
|
||||
boardId: doc.boardId,
|
||||
// this preserves the name so that the activity can be useful after the
|
||||
// list is deleted
|
||||
title: doc.title,
|
||||
});
|
||||
} else if (fieldNames.includes('archived')) {
|
||||
Activities.insert({
|
||||
userId,
|
||||
type: 'swimlane',
|
||||
activityType: 'restoredSwimlane',
|
||||
listId: doc._id,
|
||||
boardId: doc.boardId,
|
||||
// this preserves the name so that the activity can be useful after the
|
||||
// list is deleted
|
||||
title: doc.title,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//SWIMLANE REST API
|
||||
if (Meteor.isServer) {
|
||||
/**
|
||||
* @operation get_all_swimlanes
|
||||
*
|
||||
* @summary Get the list of swimlanes attached to a board
|
||||
*
|
||||
* @param {string} boardId the ID of the board
|
||||
* @return_type [{_id: string,
|
||||
* title: string}]
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes', async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
const swimlanes = await ReactiveCache.getSwimlanes({ boardId: paramBoardId, archived: false });
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: swimlanes.map(
|
||||
function(doc) {
|
||||
return {
|
||||
_id: doc._id,
|
||||
title: doc.title,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation get_swimlane
|
||||
*
|
||||
* @summary Get a swimlane
|
||||
*
|
||||
* @param {string} boardId the ID of the board
|
||||
* @param {string} swimlaneId the ID of the swimlane
|
||||
* @return_type Swimlanes
|
||||
*/
|
||||
JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes/:swimlaneId', async function(
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramSwimlaneId = req.params.swimlaneId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: await ReactiveCache.getSwimlane({
|
||||
_id: paramSwimlaneId,
|
||||
boardId: paramBoardId,
|
||||
archived: false,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation new_swimlane
|
||||
*
|
||||
* @summary Add a swimlane to a board
|
||||
*
|
||||
* @param {string} boardId the ID of the board
|
||||
* @param {string} title the new title of the swimlane
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('POST', '/api/boards/:boardId/swimlanes', async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
||||
|
||||
const board = await ReactiveCache.getBoard(paramBoardId);
|
||||
const id = await Swimlanes.insertAsync({
|
||||
title: req.body.title,
|
||||
boardId: paramBoardId,
|
||||
sort: board.swimlanes().length,
|
||||
});
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation edit_swimlane
|
||||
*
|
||||
* @summary Edit the title of a swimlane
|
||||
*
|
||||
* @param {string} boardId the ID of the board
|
||||
* @param {string} swimlaneId the ID of the swimlane to edit
|
||||
* @param {string} title the new title of the swimlane
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add('PUT', '/api/boards/:boardId/swimlanes/:swimlaneId', async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramSwimlaneId = req.params.swimlaneId;
|
||||
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
||||
const board = await ReactiveCache.getBoard(paramBoardId);
|
||||
const swimlane = await ReactiveCache.getSwimlane({
|
||||
_id: paramSwimlaneId,
|
||||
boardId: paramBoardId,
|
||||
});
|
||||
if (!swimlane) {
|
||||
throw new Meteor.Error('not-found', 'Swimlane not found');
|
||||
}
|
||||
Swimlanes.direct.update(
|
||||
{ _id: paramSwimlaneId },
|
||||
{ $set: { title: req.body.title } }
|
||||
);
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramSwimlaneId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @operation delete_swimlane
|
||||
*
|
||||
* @summary Delete a swimlane
|
||||
*
|
||||
* @description The swimlane will be deleted, not moved to the recycle bin
|
||||
*
|
||||
* @param {string} boardId the ID of the board
|
||||
* @param {string} swimlaneId the ID of the swimlane
|
||||
* @return_type {_id: string}
|
||||
*/
|
||||
JsonRoutes.add(
|
||||
'DELETE',
|
||||
'/api/boards/:boardId/swimlanes/:swimlaneId',
|
||||
function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
const paramSwimlaneId = req.params.swimlaneId;
|
||||
Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
|
||||
Swimlanes.remove({ _id: paramSwimlaneId, boardId: paramBoardId });
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: paramSwimlaneId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
JsonRoutes.sendResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Position history tracking methods
|
||||
Swimlanes.helpers({
|
||||
/**
|
||||
* Track the original position of this swimlane
|
||||
*/
|
||||
trackOriginalPosition() {
|
||||
const existingHistory = PositionHistory.findOne({
|
||||
const selector = {
|
||||
boardId: this.boardId,
|
||||
entityType: 'swimlane',
|
||||
entityId: this._id,
|
||||
});
|
||||
};
|
||||
const document = {
|
||||
boardId: this.boardId,
|
||||
entityType: 'swimlane',
|
||||
entityId: this._id,
|
||||
originalPosition: {
|
||||
sort: this.sort,
|
||||
title: this.title,
|
||||
},
|
||||
originalTitle: this.title,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (!existingHistory) {
|
||||
PositionHistory.insert({
|
||||
boardId: this.boardId,
|
||||
entityType: 'swimlane',
|
||||
entityId: this._id,
|
||||
originalPosition: {
|
||||
sort: this.sort,
|
||||
title: this.title,
|
||||
},
|
||||
originalTitle: this.title,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
if (Meteor.isServer) {
|
||||
return PositionHistory.findOneAsync(selector).then(existingHistory => {
|
||||
if (!existingHistory) {
|
||||
return PositionHistory.insertAsync(document);
|
||||
}
|
||||
return existingHistory;
|
||||
});
|
||||
}
|
||||
|
||||
const existingHistory = PositionHistory.findOne(selector);
|
||||
if (!existingHistory) {
|
||||
PositionHistory.insert(document);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the original position history for this swimlane
|
||||
*/
|
||||
getOriginalPosition() {
|
||||
return PositionHistory.findOne({
|
||||
const selector = {
|
||||
boardId: this.boardId,
|
||||
entityType: 'swimlane',
|
||||
entityId: this._id,
|
||||
});
|
||||
};
|
||||
if (Meteor.isServer) {
|
||||
return PositionHistory.findOneAsync(selector);
|
||||
}
|
||||
return PositionHistory.findOne(selector);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
TableVisibilityModeSettings = new Mongo.Collection('tableVisibilityModeSettings');
|
||||
const TableVisibilityModeSettings = new Mongo.Collection('tableVisibilityModeSettings');
|
||||
|
||||
TableVisibilityModeSettings.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -13,7 +15,6 @@ TableVisibilityModeSettings.attachSchema(
|
||||
},
|
||||
sort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
@@ -31,7 +32,6 @@ TableVisibilityModeSettings.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -44,28 +44,6 @@ TableVisibilityModeSettings.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
TableVisibilityModeSettings.allow({
|
||||
async update(userId) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
return user && user.isAdmin;
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await TableVisibilityModeSettings._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
TableVisibilityModeSettings.upsert(
|
||||
{ _id: 'tableVisibilityMode-allowPrivateOnly' },
|
||||
{
|
||||
$setOnInsert: {
|
||||
booleanValue: false,
|
||||
sort: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
TableVisibilityModeSettings.helpers({
|
||||
allowPrivateOnly() {
|
||||
return TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
|
||||
|
||||
+3
-198
@@ -1,6 +1,8 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
Team = new Mongo.Collection('team');
|
||||
const Team = new Mongo.Collection('team');
|
||||
|
||||
/**
|
||||
* A Team in Wekan. Organization in Trello.
|
||||
@@ -63,7 +65,6 @@ Team.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -76,200 +77,4 @@ Team.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Team.allow({
|
||||
async insert(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
async update(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
fetch: [],
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
async setCreateTeam(
|
||||
teamDisplayName,
|
||||
teamDesc,
|
||||
teamShortName,
|
||||
teamWebsite,
|
||||
teamIsActive,
|
||||
) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(teamDisplayName, String);
|
||||
check(teamDesc, String);
|
||||
check(teamShortName, String);
|
||||
check(teamWebsite, String);
|
||||
check(teamIsActive, Boolean);
|
||||
|
||||
const nTeamNames = (await ReactiveCache.getTeams({ teamShortName })).length;
|
||||
if (nTeamNames > 0) {
|
||||
throw new Meteor.Error('teamname-already-taken');
|
||||
} else {
|
||||
await Team.insertAsync({
|
||||
teamDisplayName,
|
||||
teamDesc,
|
||||
teamShortName,
|
||||
teamWebsite,
|
||||
teamIsActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async setCreateTeamFromOidc(
|
||||
teamDisplayName,
|
||||
teamDesc,
|
||||
teamShortName,
|
||||
teamWebsite,
|
||||
teamIsActive,
|
||||
) {
|
||||
check(teamDisplayName, String);
|
||||
check(teamDesc, String);
|
||||
check(teamShortName, String);
|
||||
check(teamWebsite, String);
|
||||
check(teamIsActive, Boolean);
|
||||
const nTeamNames = (await ReactiveCache.getTeams({ teamShortName })).length;
|
||||
if (nTeamNames > 0) {
|
||||
throw new Meteor.Error('teamname-already-taken');
|
||||
} else {
|
||||
await Team.insertAsync({
|
||||
teamDisplayName,
|
||||
teamDesc,
|
||||
teamShortName,
|
||||
teamWebsite,
|
||||
teamIsActive,
|
||||
});
|
||||
}
|
||||
},
|
||||
async setTeamDisplayName(team, teamDisplayName) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(team, Object);
|
||||
check(teamDisplayName, String);
|
||||
Team.update(team, {
|
||||
$set: { teamDisplayName: teamDisplayName },
|
||||
});
|
||||
await Meteor.callAsync('setUsersTeamsTeamDisplayName', team._id, teamDisplayName);
|
||||
}
|
||||
},
|
||||
|
||||
async setTeamDesc(team, teamDesc) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(team, Object);
|
||||
check(teamDesc, String);
|
||||
Team.update(team, {
|
||||
$set: { teamDesc: teamDesc },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async setTeamShortName(team, teamShortName) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(team, Object);
|
||||
check(teamShortName, String);
|
||||
Team.update(team, {
|
||||
$set: { teamShortName: teamShortName },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async setTeamIsActive(team, teamIsActive) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(team, Object);
|
||||
check(teamIsActive, Boolean);
|
||||
Team.update(team, {
|
||||
$set: { teamIsActive: teamIsActive },
|
||||
});
|
||||
}
|
||||
},
|
||||
async setTeamAllFieldsFromOidc(
|
||||
team,
|
||||
teamDisplayName,
|
||||
teamDesc,
|
||||
teamShortName,
|
||||
teamWebsite,
|
||||
teamIsActive,
|
||||
) {
|
||||
check(team, Object);
|
||||
check(teamDisplayName, String);
|
||||
check(teamDesc, String);
|
||||
check(teamShortName, String);
|
||||
check(teamWebsite, String);
|
||||
check(teamIsActive, Boolean);
|
||||
Team.update(team, {
|
||||
$set: {
|
||||
teamDisplayName: teamDisplayName,
|
||||
teamDesc: teamDesc,
|
||||
teamShortName: teamShortName,
|
||||
teamWebsite: teamWebsite,
|
||||
teamIsActive: teamIsActive,
|
||||
},
|
||||
});
|
||||
await Meteor.callAsync('setUsersTeamsTeamDisplayName', team._id, teamDisplayName);
|
||||
},
|
||||
async setTeamAllFields(
|
||||
team,
|
||||
teamDisplayName,
|
||||
teamDesc,
|
||||
teamShortName,
|
||||
teamWebsite,
|
||||
teamIsActive,
|
||||
) {
|
||||
if ((await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
check(team, Object);
|
||||
check(teamDisplayName, String);
|
||||
check(teamDesc, String);
|
||||
check(teamShortName, String);
|
||||
check(teamWebsite, String);
|
||||
check(teamIsActive, Boolean);
|
||||
Team.update(team, {
|
||||
$set: {
|
||||
teamDisplayName: teamDisplayName,
|
||||
teamDesc: teamDesc,
|
||||
teamShortName: teamShortName,
|
||||
teamWebsite: teamWebsite,
|
||||
teamIsActive: teamIsActive,
|
||||
},
|
||||
});
|
||||
await Meteor.callAsync('setUsersTeamsTeamDisplayName', team._id, teamDisplayName);
|
||||
}
|
||||
},
|
||||
async getTeamsCollectionCount(query = {}) {
|
||||
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
check(query, Match.OneOf(Object, null, undefined));
|
||||
const count = await (await ReactiveCache.getTeams(query, {}, true)).count();
|
||||
return count;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Index for Team name.
|
||||
Meteor.startup(async () => {
|
||||
await Team._collection.createIndexAsync({ teamDisplayName: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default Team;
|
||||
+4
-90
@@ -1,4 +1,7 @@
|
||||
Translation = new Mongo.Collection('translation');
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
const Translation = new Mongo.Collection('translation');
|
||||
|
||||
/**
|
||||
* A Organization User in wekan
|
||||
@@ -43,7 +46,6 @@ Translation.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -56,92 +58,4 @@ Translation.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Translation.allow({
|
||||
async insert(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
async update(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
const user = await Meteor.users.findOneAsync(userId);
|
||||
if (user?.isAdmin)
|
||||
return true;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return doc._id === userId;
|
||||
},
|
||||
fetch: [],
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
async setCreateTranslation(
|
||||
language,
|
||||
text,
|
||||
translationText,
|
||||
) {
|
||||
check(language, String);
|
||||
check(text, String);
|
||||
check(translationText, String);
|
||||
|
||||
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
const nTexts = (await ReactiveCache.getTranslations({ language, text })).length;
|
||||
if (nTexts > 0) {
|
||||
throw new Meteor.Error('text-already-taken');
|
||||
} else {
|
||||
await Translation.insertAsync({
|
||||
language,
|
||||
text,
|
||||
translationText,
|
||||
});
|
||||
}
|
||||
},
|
||||
async setTranslationText(translation, translationText) {
|
||||
check(translation, Object);
|
||||
check(translationText, String);
|
||||
|
||||
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
Translation.update(translation, {
|
||||
$set: { translationText: translationText },
|
||||
});
|
||||
},
|
||||
async deleteTranslation(translationId) {
|
||||
check(translationId, String);
|
||||
|
||||
if (!(await ReactiveCache.getCurrentUser())?.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
Translation.remove(translationId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Index for Organization User.
|
||||
Meteor.startup(async () => {
|
||||
await Translation._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default Translation;
|
||||
+55
-44
@@ -1,6 +1,17 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { CustomFields } from './customFields';
|
||||
import Activities from '/models/activities';
|
||||
import Attachments from '/models/attachments';
|
||||
import Boards from '/models/boards';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import CardComments from '/models/cardComments';
|
||||
import Cards from '/models/cards';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
import Checklists from '/models/checklists';
|
||||
import CustomFields from './customFields';
|
||||
import Lists from '/models/lists';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
@@ -252,10 +263,10 @@ export class TrelloCreator {
|
||||
boardToCreate.labels.push(labelToCreate);
|
||||
});
|
||||
}
|
||||
const boardId = Boards.direct.insert(boardToCreate);
|
||||
Boards.direct.update(boardId, { $set: { modifiedAt: this._now() } });
|
||||
const boardId = await Boards.direct.insertAsync(boardToCreate);
|
||||
await Boards.direct.updateAsync(boardId, { $set: { modifiedAt: this._now() } });
|
||||
// log activity
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
activityType: 'importBoard',
|
||||
boardId,
|
||||
createdAt: this._now(),
|
||||
@@ -269,7 +280,7 @@ export class TrelloCreator {
|
||||
userId: this._user(),
|
||||
});
|
||||
if (trelloBoard.customFields) {
|
||||
trelloBoard.customFields.forEach(field => {
|
||||
for (const field of trelloBoard.customFields) {
|
||||
const fieldToCreate = {
|
||||
// trelloId: field.id,
|
||||
name: field.name,
|
||||
@@ -296,8 +307,8 @@ export class TrelloCreator {
|
||||
|
||||
// We need to remember them by Trello ID, as this is the only ref we have
|
||||
// when importing cards.
|
||||
this.customFields[field.id] = CustomFields.direct.insert(fieldToCreate);
|
||||
});
|
||||
this.customFields[field.id] = await CustomFields.direct.insertAsync(fieldToCreate);
|
||||
}
|
||||
}
|
||||
return boardId;
|
||||
}
|
||||
@@ -398,7 +409,7 @@ export class TrelloCreator {
|
||||
}
|
||||
|
||||
// insert card
|
||||
const cardId = Cards.direct.insert(cardToCreate);
|
||||
const cardId = await Cards.direct.insertAsync(cardToCreate);
|
||||
// keep track of Trello id => Wekan id
|
||||
this.cards[card.id] = cardId;
|
||||
// log activity
|
||||
@@ -420,7 +431,7 @@ export class TrelloCreator {
|
||||
// add comments
|
||||
const comments = this.comments[card.id];
|
||||
if (comments) {
|
||||
comments.forEach(comment => {
|
||||
for (const comment of comments) {
|
||||
const commentToCreate = {
|
||||
boardId,
|
||||
cardId,
|
||||
@@ -431,10 +442,10 @@ export class TrelloCreator {
|
||||
};
|
||||
// dateLastActivity will be set from activity insert, no need to
|
||||
// update it ourselves
|
||||
const commentId = CardComments.direct.insert(commentToCreate);
|
||||
const commentId = await CardComments.direct.insertAsync(commentToCreate);
|
||||
// We need to keep adding comment activities this way with Trello
|
||||
// because it doesn't provide a comment ID
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
activityType: 'addComment',
|
||||
boardId: commentToCreate.boardId,
|
||||
cardId: commentToCreate.cardId,
|
||||
@@ -444,7 +455,7 @@ export class TrelloCreator {
|
||||
// to the original author - it is needed by some UI elements.
|
||||
userId: commentToCreate.userId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
const attachments = this.attachments[card.id];
|
||||
const trelloCoverId = card.idAttachmentCover;
|
||||
@@ -460,13 +471,13 @@ export class TrelloCreator {
|
||||
source: 'import',
|
||||
},
|
||||
};
|
||||
const cb = (error, fileObj) => {
|
||||
const cb = async (error, fileObj) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
self.attachmentIds[att._id] = fileObj._id;
|
||||
if (trelloCoverId === att._id) {
|
||||
Cards.direct.update(cardId, {
|
||||
await Cards.direct.updateAsync(cardId, {
|
||||
$set: { coverId: fileObj._id },
|
||||
});
|
||||
}
|
||||
@@ -499,7 +510,7 @@ export class TrelloCreator {
|
||||
links.forEach(link => {
|
||||
desc += `* ${link}\n`;
|
||||
});
|
||||
Cards.direct.update(cardId, {
|
||||
await Cards.direct.updateAsync(cardId, {
|
||||
$set: {
|
||||
description: desc,
|
||||
},
|
||||
@@ -527,8 +538,8 @@ export class TrelloCreator {
|
||||
});
|
||||
}
|
||||
|
||||
createLists(trelloLists, boardId) {
|
||||
trelloLists.forEach(list => {
|
||||
async createLists(trelloLists, boardId) {
|
||||
for (const list of trelloLists) {
|
||||
const listToCreate = {
|
||||
archived: list.closed,
|
||||
boardId,
|
||||
@@ -540,8 +551,8 @@ export class TrelloCreator {
|
||||
title: list.name,
|
||||
sort: list.pos,
|
||||
};
|
||||
const listId = Lists.direct.insert(listToCreate);
|
||||
Lists.direct.update(listId, { $set: { updatedAt: this._now() } });
|
||||
const listId = await Lists.direct.insertAsync(listToCreate);
|
||||
await Lists.direct.updateAsync(listId, { $set: { updatedAt: this._now() } });
|
||||
this.lists[list.id] = listId;
|
||||
// log activity
|
||||
// Activities.direct.insert({
|
||||
@@ -557,10 +568,10 @@ export class TrelloCreator {
|
||||
// // not the creator of the original object
|
||||
// userId: this._user(),
|
||||
// });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createSwimlanes(boardId) {
|
||||
async createSwimlanes(boardId) {
|
||||
const swimlaneToCreate = {
|
||||
archived: false,
|
||||
boardId,
|
||||
@@ -572,13 +583,13 @@ export class TrelloCreator {
|
||||
title: 'Default',
|
||||
sort: 1,
|
||||
};
|
||||
const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate);
|
||||
Swimlanes.direct.update(swimlaneId, { $set: { updatedAt: this._now() } });
|
||||
const swimlaneId = await Swimlanes.direct.insertAsync(swimlaneToCreate);
|
||||
await Swimlanes.direct.updateAsync(swimlaneId, { $set: { updatedAt: this._now() } });
|
||||
this.swimlane = swimlaneId;
|
||||
}
|
||||
|
||||
createChecklists(trelloChecklists) {
|
||||
trelloChecklists.forEach(checklist => {
|
||||
async createChecklists(trelloChecklists) {
|
||||
for (const checklist of trelloChecklists) {
|
||||
if (this.cards[checklist.idCard]) {
|
||||
// Create the checklist
|
||||
const checklistToCreate = {
|
||||
@@ -587,12 +598,12 @@ export class TrelloCreator {
|
||||
createdAt: this._now(),
|
||||
sort: checklist.pos,
|
||||
};
|
||||
const checklistId = Checklists.direct.insert(checklistToCreate);
|
||||
const checklistId = await Checklists.direct.insertAsync(checklistToCreate);
|
||||
// keep track of Trello id => Wekan id
|
||||
this.checklists[checklist.id] = checklistId;
|
||||
// Now add the items to the checklistItems
|
||||
let counter = 0;
|
||||
checklist.checkItems.forEach(item => {
|
||||
for (const item of checklist.checkItems) {
|
||||
counter++;
|
||||
const checklistItemTocreate = {
|
||||
_id: checklistId + counter,
|
||||
@@ -602,10 +613,10 @@ export class TrelloCreator {
|
||||
sort: item.pos,
|
||||
isFinished: item.state === 'complete',
|
||||
};
|
||||
ChecklistItems.direct.insert(checklistItemTocreate);
|
||||
});
|
||||
await ChecklistItems.direct.insertAsync(checklistItemTocreate);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getAdmin(trelloMemberType) {
|
||||
@@ -626,7 +637,7 @@ export class TrelloCreator {
|
||||
grey: 'midnight',
|
||||
};
|
||||
const wekanColor = mapColors[trelloColorCode];
|
||||
return wekanColor || Boards.simpleSchema()._schema.color.allowedValues[0];
|
||||
return wekanColor || BOARD_COLORS[0];
|
||||
}
|
||||
|
||||
getPermission(trelloPermissionCode) {
|
||||
@@ -676,13 +687,13 @@ export class TrelloCreator {
|
||||
});
|
||||
}
|
||||
|
||||
importActions(actions, boardId) {
|
||||
actions.forEach(action => {
|
||||
async importActions(actions, boardId) {
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
// Board related actions
|
||||
// TODO: addBoardMember, removeBoardMember
|
||||
case 'createBoard': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(action.idMemberCreator),
|
||||
type: 'board',
|
||||
activityTypeId: boardId,
|
||||
@@ -695,7 +706,7 @@ export class TrelloCreator {
|
||||
// List related activities
|
||||
// TODO: removeList, archivedList
|
||||
case 'createList': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(action.idMemberCreator),
|
||||
type: 'list',
|
||||
activityType: 'createList',
|
||||
@@ -708,7 +719,7 @@ export class TrelloCreator {
|
||||
// Card related activities
|
||||
// TODO: archivedCard, restoredCard, joinMember, unjoinMember
|
||||
case 'createCard': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(action.idMemberCreator),
|
||||
activityType: 'createCard',
|
||||
listId: this.lists[action.data.list.id],
|
||||
@@ -720,7 +731,7 @@ export class TrelloCreator {
|
||||
}
|
||||
case 'updateCard': {
|
||||
if (action.data.old.idList) {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(action.idMemberCreator),
|
||||
oldListId: this.lists[action.data.old.idList],
|
||||
activityType: 'moveCard',
|
||||
@@ -736,7 +747,7 @@ export class TrelloCreator {
|
||||
// Trello doesn't export the comment id
|
||||
// Attachment related activities
|
||||
case 'addAttachmentToCard': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(action.idMemberCreator),
|
||||
type: 'card',
|
||||
activityType: 'addAttachment',
|
||||
@@ -749,7 +760,7 @@ export class TrelloCreator {
|
||||
}
|
||||
// Checklist related activities
|
||||
case 'addChecklistToCard': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(action.idMemberCreator),
|
||||
activityType: 'addChecklist',
|
||||
cardId: this.cards[action.data.card.id],
|
||||
@@ -761,7 +772,7 @@ export class TrelloCreator {
|
||||
}
|
||||
}
|
||||
// Trello doesn't have an add checklist item action
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
check(board) {
|
||||
@@ -792,11 +803,11 @@ export class TrelloCreator {
|
||||
}
|
||||
this.parseActions(board.actions);
|
||||
const boardId = await this.createBoardAndLabels(board);
|
||||
this.createLists(board.lists, boardId);
|
||||
this.createSwimlanes(boardId);
|
||||
await this.createLists(board.lists, boardId);
|
||||
await this.createSwimlanes(boardId);
|
||||
await this.createCards(board.cards, boardId);
|
||||
this.createChecklists(board.checklists);
|
||||
this.importActions(board.actions, boardId);
|
||||
await this.createChecklists(board.checklists);
|
||||
await this.importActions(board.actions, boardId);
|
||||
// XXX add members
|
||||
return boardId;
|
||||
}
|
||||
|
||||
+2
-20
@@ -1,7 +1,7 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
Triggers = new Mongo.Collection('triggers');
|
||||
const Triggers = new Mongo.Collection('triggers');
|
||||
|
||||
Triggers.before.insert((userId, doc) => {
|
||||
doc.createdAt = new Date();
|
||||
@@ -13,18 +13,6 @@ Triggers.before.update((userId, doc, fieldNames, modifier) => {
|
||||
modifier.$set.updatedAt = new Date();
|
||||
});
|
||||
|
||||
Triggers.allow({
|
||||
async insert(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async update(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
async remove(userId, doc) {
|
||||
return allowIsBoardAdmin(userId, await Boards.findOneAsync(doc.boardId));
|
||||
},
|
||||
});
|
||||
|
||||
Triggers.helpers({
|
||||
async rename(description) {
|
||||
return await Triggers.updateAsync(this._id, {
|
||||
@@ -63,10 +51,4 @@ Triggers.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await Triggers._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default Triggers;
|
||||
+4
-18
@@ -1,6 +1,9 @@
|
||||
// This collection shouldn't be manipulated directly by instead throw the
|
||||
// `UnsavedEdits` API on the client.
|
||||
UnsavedEditCollection = new Mongo.Collection('unsaved-edits');
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
const UnsavedEditCollection = new Mongo.Collection('unsaved-edits');
|
||||
|
||||
UnsavedEditCollection.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -38,7 +41,6 @@ UnsavedEditCollection.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
@@ -51,20 +53,4 @@ UnsavedEditCollection.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
if (Meteor.isServer) {
|
||||
function isAuthor(userId, doc, fieldNames = []) {
|
||||
return userId === doc.userId && fieldNames.indexOf('userId') === -1;
|
||||
}
|
||||
Meteor.startup(async () => {
|
||||
await UnsavedEditCollection._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await UnsavedEditCollection._collection.createIndexAsync({ userId: 1 });
|
||||
});
|
||||
UnsavedEditCollection.allow({
|
||||
insert: isAuthor,
|
||||
update: isAuthor,
|
||||
remove: isAuthor,
|
||||
fetch: ['userId'],
|
||||
});
|
||||
}
|
||||
|
||||
export default UnsavedEditCollection;
|
||||
+18
-237
@@ -1,10 +1,19 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { SimpleSchema } from '/imports/simpleSchema';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
import Checklists from '/models/checklists';
|
||||
import Lists from '/models/lists';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
|
||||
/**
|
||||
* UserPositionHistory collection - Per-user history of entity movements
|
||||
* Similar to Activities but specifically for tracking position changes with undo/redo support
|
||||
*/
|
||||
UserPositionHistory = new Mongo.Collection('userPositionHistory');
|
||||
const UserPositionHistory = new Mongo.Collection('userPositionHistory');
|
||||
|
||||
UserPositionHistory.attachSchema(
|
||||
new SimpleSchema({
|
||||
@@ -58,12 +67,10 @@ UserPositionHistory.attachSchema(
|
||||
// For easier undo operations, store specific fields
|
||||
previousSort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
optional: true,
|
||||
},
|
||||
newSort: {
|
||||
type: Number,
|
||||
decimal: true,
|
||||
optional: true,
|
||||
},
|
||||
previousSwimlaneId: {
|
||||
@@ -132,22 +139,6 @@ UserPositionHistory.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
UserPositionHistory.allow({
|
||||
insert(userId, doc) {
|
||||
// Only allow users to create their own history
|
||||
return userId && doc.userId === userId;
|
||||
},
|
||||
update(userId, doc) {
|
||||
// Only allow users to update their own history (for checkpoints)
|
||||
return userId && doc.userId === userId;
|
||||
},
|
||||
remove() {
|
||||
// Don't allow removal - history is permanent
|
||||
return false;
|
||||
},
|
||||
fetch: ['userId'],
|
||||
});
|
||||
|
||||
UserPositionHistory.helpers({
|
||||
/**
|
||||
* Get a human-readable description of this change
|
||||
@@ -186,7 +177,7 @@ UserPositionHistory.helpers({
|
||||
case 'checklist':
|
||||
return !!(await ReactiveCache.getChecklist(this.entityId));
|
||||
case 'checklistItem':
|
||||
return !!ChecklistItems.findOne(this.entityId);
|
||||
return !!(await ChecklistItems.findOneAsync(this.entityId));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -212,7 +203,7 @@ UserPositionHistory.helpers({
|
||||
const listId = this.previousListId || card.listId;
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : card.sort;
|
||||
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: {
|
||||
boardId,
|
||||
swimlaneId,
|
||||
@@ -229,7 +220,7 @@ UserPositionHistory.helpers({
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : list.sort;
|
||||
const swimlaneId = this.previousSwimlaneId || list.swimlaneId;
|
||||
|
||||
Lists.update(list._id, {
|
||||
await Lists.updateAsync(list._id, {
|
||||
$set: {
|
||||
sort,
|
||||
swimlaneId,
|
||||
@@ -243,7 +234,7 @@ UserPositionHistory.helpers({
|
||||
if (swimlane) {
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : swimlane.sort;
|
||||
|
||||
Swimlanes.update(swimlane._id, {
|
||||
await Swimlanes.updateAsync(swimlane._id, {
|
||||
$set: {
|
||||
sort,
|
||||
},
|
||||
@@ -256,7 +247,7 @@ UserPositionHistory.helpers({
|
||||
if (checklist) {
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : checklist.sort;
|
||||
|
||||
Checklists.update(checklist._id, {
|
||||
await Checklists.updateAsync(checklist._id, {
|
||||
$set: {
|
||||
sort,
|
||||
},
|
||||
@@ -266,12 +257,12 @@ UserPositionHistory.helpers({
|
||||
}
|
||||
case 'checklistItem': {
|
||||
if (typeof ChecklistItems !== 'undefined') {
|
||||
const item = ChecklistItems.findOne(this.entityId);
|
||||
const item = await ChecklistItems.findOneAsync(this.entityId);
|
||||
if (item) {
|
||||
const sort = this.previousSort !== undefined ? this.previousSort : item.sort;
|
||||
const checklistId = this.previousState?.checklistId || item.checklistId;
|
||||
|
||||
ChecklistItems.update(item._id, {
|
||||
await ChecklistItems.updateAsync(item._id, {
|
||||
$set: {
|
||||
sort,
|
||||
checklistId,
|
||||
@@ -285,214 +276,4 @@ UserPositionHistory.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.startup(async () => {
|
||||
await UserPositionHistory._collection.createIndexAsync({ userId: 1, boardId: 1, createdAt: -1 });
|
||||
await UserPositionHistory._collection.createIndexAsync({ userId: 1, entityType: 1, entityId: 1 });
|
||||
await UserPositionHistory._collection.createIndexAsync({ userId: 1, isCheckpoint: 1 });
|
||||
await UserPositionHistory._collection.createIndexAsync({ batchId: 1 });
|
||||
await UserPositionHistory._collection.createIndexAsync({ createdAt: 1 }); // For cleanup of old entries
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to track a position change
|
||||
*/
|
||||
UserPositionHistory.trackChange = function(options) {
|
||||
const {
|
||||
userId,
|
||||
boardId,
|
||||
entityType,
|
||||
entityId,
|
||||
actionType,
|
||||
previousState,
|
||||
newState,
|
||||
batchId,
|
||||
} = options;
|
||||
|
||||
if (!userId || !boardId || !entityType || !entityId || !actionType) {
|
||||
throw new Meteor.Error('invalid-params', 'Missing required parameters');
|
||||
}
|
||||
|
||||
const historyEntry = {
|
||||
userId,
|
||||
boardId,
|
||||
entityType,
|
||||
entityId,
|
||||
actionType,
|
||||
newState,
|
||||
};
|
||||
|
||||
if (previousState) {
|
||||
historyEntry.previousState = previousState;
|
||||
historyEntry.previousSort = previousState.sort;
|
||||
historyEntry.previousSwimlaneId = previousState.swimlaneId;
|
||||
historyEntry.previousListId = previousState.listId;
|
||||
historyEntry.previousBoardId = previousState.boardId;
|
||||
}
|
||||
|
||||
if (newState) {
|
||||
historyEntry.newSort = newState.sort;
|
||||
historyEntry.newSwimlaneId = newState.swimlaneId;
|
||||
historyEntry.newListId = newState.listId;
|
||||
historyEntry.newBoardId = newState.boardId;
|
||||
}
|
||||
|
||||
if (batchId) {
|
||||
historyEntry.batchId = batchId;
|
||||
}
|
||||
|
||||
return UserPositionHistory.insert(historyEntry);
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup old history entries (keep last 1000 per user per board)
|
||||
*/
|
||||
UserPositionHistory.cleanup = function() {
|
||||
const users = Meteor.users.find({}, { fields: { _id: 1 } }).fetch();
|
||||
|
||||
users.forEach(user => {
|
||||
const boards = Boards.find({ 'members.userId': user._id }, { fields: { _id: 1 } }).fetch();
|
||||
|
||||
boards.forEach(board => {
|
||||
const history = UserPositionHistory.find(
|
||||
{ userId: user._id, boardId: board._id, isCheckpoint: { $ne: true } },
|
||||
{ sort: { createdAt: -1 }, limit: 1000 }
|
||||
).fetch();
|
||||
|
||||
if (history.length >= 1000) {
|
||||
const oldestToKeep = history[999].createdAt;
|
||||
|
||||
// Remove entries older than the 1000th entry (except checkpoints)
|
||||
UserPositionHistory.remove({
|
||||
userId: user._id,
|
||||
boardId: board._id,
|
||||
createdAt: { $lt: oldestToKeep },
|
||||
isCheckpoint: { $ne: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Run cleanup daily
|
||||
if (Meteor.settings.public?.enableHistoryCleanup !== false) {
|
||||
Meteor.setInterval(() => {
|
||||
try {
|
||||
UserPositionHistory.cleanup();
|
||||
} catch (e) {
|
||||
console.error('Error during history cleanup:', e);
|
||||
}
|
||||
}, 24 * 60 * 60 * 1000); // Once per day
|
||||
}
|
||||
}
|
||||
|
||||
// Meteor Methods for client interaction
|
||||
Meteor.methods({
|
||||
async 'userPositionHistory.createCheckpoint'(boardId, checkpointName) {
|
||||
check(boardId, String);
|
||||
check(checkpointName, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
// Create a checkpoint entry
|
||||
return await UserPositionHistory.insertAsync({
|
||||
userId: this.userId,
|
||||
boardId,
|
||||
entityType: 'checkpoint',
|
||||
entityId: 'checkpoint',
|
||||
actionType: 'create',
|
||||
isCheckpoint: true,
|
||||
checkpointName,
|
||||
newState: {
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async 'userPositionHistory.undo'(historyId) {
|
||||
check(historyId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const history = UserPositionHistory.findOne({ _id: historyId, userId: this.userId });
|
||||
if (!history) {
|
||||
throw new Meteor.Error('not-found', 'History entry not found');
|
||||
}
|
||||
|
||||
return await history.undo();
|
||||
},
|
||||
|
||||
async 'userPositionHistory.getRecent'(boardId, limit = 50) {
|
||||
check(boardId, String);
|
||||
check(limit, Number);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
return await UserPositionHistory.find(
|
||||
{ userId: this.userId, boardId },
|
||||
{ sort: { createdAt: -1 }, limit: Math.min(limit, 100) }
|
||||
).fetchAsync();
|
||||
},
|
||||
|
||||
async 'userPositionHistory.getCheckpoints'(boardId) {
|
||||
check(boardId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
return await UserPositionHistory.find(
|
||||
{ userId: this.userId, boardId, isCheckpoint: true },
|
||||
{ sort: { createdAt: -1 } }
|
||||
).fetchAsync();
|
||||
},
|
||||
|
||||
async 'userPositionHistory.restoreToCheckpoint'(checkpointId) {
|
||||
check(checkpointId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const checkpoint = UserPositionHistory.findOne({
|
||||
_id: checkpointId,
|
||||
userId: this.userId,
|
||||
isCheckpoint: true,
|
||||
});
|
||||
|
||||
if (!checkpoint) {
|
||||
throw new Meteor.Error('not-found', 'Checkpoint not found');
|
||||
}
|
||||
|
||||
// Find all changes after this checkpoint and undo them in reverse order
|
||||
const changesToUndo = UserPositionHistory.find(
|
||||
{
|
||||
userId: this.userId,
|
||||
boardId: checkpoint.boardId,
|
||||
createdAt: { $gt: checkpoint.createdAt },
|
||||
isCheckpoint: { $ne: true },
|
||||
},
|
||||
{ sort: { createdAt: -1 } }
|
||||
).fetch();
|
||||
|
||||
let undoneCount = 0;
|
||||
for (const change of changesToUndo) {
|
||||
try {
|
||||
if (await change.canUndo()) {
|
||||
await change.undo();
|
||||
undoneCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to undo change:', change._id, e);
|
||||
}
|
||||
};
|
||||
|
||||
return { undoneCount, totalChanges: changesToUndo.length };
|
||||
},
|
||||
});
|
||||
export default UserPositionHistory;
|
||||
+50
-2030
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,9 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { incrementCounter } from './counters';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
SessionData = new Mongo.Collection('sessiondata');
|
||||
const SessionData = new Mongo.Collection('sessiondata');
|
||||
|
||||
/**
|
||||
* A UserSessionData in Wekan. Organization in Trello.
|
||||
@@ -56,9 +59,12 @@ SessionData.attachSchema(
|
||||
optional: true,
|
||||
},
|
||||
cards: {
|
||||
type: [String],
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'cards.$': {
|
||||
type: String,
|
||||
},
|
||||
selector: {
|
||||
type: String,
|
||||
optional: true,
|
||||
@@ -71,11 +77,14 @@ SessionData.attachSchema(
|
||||
defaultValue: {},
|
||||
},
|
||||
errorMessages: {
|
||||
type: [String],
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'errorMessages.$': {
|
||||
type: String,
|
||||
},
|
||||
errors: {
|
||||
type: [Object],
|
||||
type: Array,
|
||||
optional: true,
|
||||
defaultValue: [],
|
||||
},
|
||||
@@ -125,7 +134,6 @@ SessionData.attachSchema(
|
||||
},
|
||||
modifiedAt: {
|
||||
type: Date,
|
||||
denyUpdate: false,
|
||||
// eslint-disable-next-line consistent-return
|
||||
autoValue() {
|
||||
if (this.isInsert || this.isUpsert || this.isUpdate) {
|
||||
|
||||
+12
-2
@@ -1,10 +1,17 @@
|
||||
import Boards from '/models/boards';
|
||||
import Lists from '/models/lists';
|
||||
import Cards from '/models/cards';
|
||||
|
||||
// simple version, only toggle watch / unwatch
|
||||
const simpleWatchable = collection => {
|
||||
collection.attachSchema({
|
||||
watchers: {
|
||||
type: [String],
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'watchers.$': {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
|
||||
collection.helpers({
|
||||
@@ -37,9 +44,12 @@ const complexWatchDefault = 'muted';
|
||||
const complexWatchable = collection => {
|
||||
collection.attachSchema({
|
||||
watchers: {
|
||||
type: [Object],
|
||||
type: Array,
|
||||
optional: true,
|
||||
},
|
||||
'watchers.$': {
|
||||
type: Object,
|
||||
},
|
||||
'watchers.$.userId': {
|
||||
type: String,
|
||||
},
|
||||
|
||||
+81
-68
@@ -1,5 +1,18 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { CustomFields } from './customFields';
|
||||
import Actions from '/models/actions';
|
||||
import Activities from '/models/activities';
|
||||
import Attachments from '/models/attachments';
|
||||
import Boards from '/models/boards';
|
||||
import CardComments from '/models/cardComments';
|
||||
import Cards from '/models/cards';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
import Checklists from '/models/checklists';
|
||||
import CustomFields from './customFields';
|
||||
import Lists from '/models/lists';
|
||||
import Rules from '/models/rules';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import Triggers from '/models/triggers';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
@@ -338,14 +351,14 @@ export class WekanCreator {
|
||||
});
|
||||
}
|
||||
|
||||
const boardId = Boards.direct.insert(boardToCreate);
|
||||
Boards.direct.update(boardId, {
|
||||
const boardId = await Boards.direct.insertAsync(boardToCreate);
|
||||
await Boards.direct.updateAsync(boardId, {
|
||||
$set: {
|
||||
modifiedAt: this._now(),
|
||||
},
|
||||
});
|
||||
// log activity
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
activityType: 'importBoard',
|
||||
boardId,
|
||||
createdAt: this._now(),
|
||||
@@ -448,7 +461,7 @@ export class WekanCreator {
|
||||
}
|
||||
|
||||
// insert card
|
||||
const cardId = Cards.direct.insert(cardToCreate);
|
||||
const cardId = await Cards.direct.insertAsync(cardToCreate);
|
||||
// keep track of Wekan id => Wekan id
|
||||
this.cards[card._id] = cardId;
|
||||
// // log activity
|
||||
@@ -469,7 +482,7 @@ export class WekanCreator {
|
||||
// add comments
|
||||
const comments = this.comments[card._id];
|
||||
if (comments) {
|
||||
comments.forEach(comment => {
|
||||
for (const comment of comments) {
|
||||
const commentToCreate = {
|
||||
boardId,
|
||||
cardId,
|
||||
@@ -480,7 +493,7 @@ export class WekanCreator {
|
||||
};
|
||||
// dateLastActivity will be set from activity insert, no need to
|
||||
// update it ourselves
|
||||
const commentId = CardComments.direct.insert(commentToCreate);
|
||||
const commentId = await CardComments.direct.insertAsync(commentToCreate);
|
||||
this.commentIds[comment._id] = commentId;
|
||||
// Activities.direct.insert({
|
||||
// activityType: 'addComment',
|
||||
@@ -492,7 +505,7 @@ export class WekanCreator {
|
||||
// // to the original author - it is needed by some UI elements.
|
||||
// userId: commentToCreate.userId,
|
||||
// });
|
||||
});
|
||||
}
|
||||
}
|
||||
const attachments = this.attachments[card._id];
|
||||
const wekanCoverId = card.coverId;
|
||||
@@ -508,13 +521,13 @@ export class WekanCreator {
|
||||
source: 'import',
|
||||
},
|
||||
};
|
||||
const cb = (error, fileObj) => {
|
||||
const cb = async (error, fileObj) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
self.attachmentIds[att._id] = fileObj._id;
|
||||
if (wekanCoverId === att._id) {
|
||||
Cards.direct.update(cardId, {
|
||||
await Cards.direct.updateAsync(cardId, {
|
||||
$set: { coverId: fileObj._id },
|
||||
});
|
||||
}
|
||||
@@ -548,8 +561,8 @@ export class WekanCreator {
|
||||
* @param wekanCustomFields
|
||||
* @param boardId
|
||||
*/
|
||||
createCustomFields(wekanCustomFields, boardId) {
|
||||
wekanCustomFields.forEach((field, fieldIndex) => {
|
||||
async createCustomFields(wekanCustomFields, boardId) {
|
||||
for (const field of wekanCustomFields) {
|
||||
const fieldToCreate = {
|
||||
boardIds: [boardId],
|
||||
name: field.name,
|
||||
@@ -564,16 +577,16 @@ export class WekanCreator {
|
||||
modifiedAt: field.modifiedAt,
|
||||
};
|
||||
//insert copy of custom field
|
||||
const fieldId = CustomFields.direct.insert(fieldToCreate);
|
||||
const fieldId = await CustomFields.direct.insertAsync(fieldToCreate);
|
||||
//set modified date to now
|
||||
CustomFields.direct.update(fieldId, {
|
||||
await CustomFields.direct.updateAsync(fieldId, {
|
||||
$set: {
|
||||
modifiedAt: this._now(),
|
||||
},
|
||||
});
|
||||
//store mapping of old id to new id
|
||||
this.customFields[field._id] = fieldId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create labels if they do not exist and load this.labels.
|
||||
@@ -591,8 +604,8 @@ export class WekanCreator {
|
||||
});
|
||||
}
|
||||
|
||||
createLists(wekanLists, boardId) {
|
||||
wekanLists.forEach((list, listIndex) => {
|
||||
async createLists(wekanLists, boardId) {
|
||||
for (const [listIndex, list] of wekanLists.entries()) {
|
||||
const listToCreate = {
|
||||
archived: list.archived,
|
||||
boardId,
|
||||
@@ -604,8 +617,8 @@ export class WekanCreator {
|
||||
title: list.title,
|
||||
sort: list.sort ? list.sort : listIndex,
|
||||
};
|
||||
const listId = Lists.direct.insert(listToCreate);
|
||||
Lists.direct.update(listId, {
|
||||
const listId = await Lists.direct.insertAsync(listToCreate);
|
||||
await Lists.direct.updateAsync(listId, {
|
||||
$set: {
|
||||
updatedAt: this._now(),
|
||||
},
|
||||
@@ -625,10 +638,10 @@ export class WekanCreator {
|
||||
// // not the creator of the original object
|
||||
// userId: this._user(),
|
||||
// });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createSwimlanes(wekanSwimlanes, boardId) {
|
||||
async createSwimlanes(wekanSwimlanes, boardId) {
|
||||
// If no swimlanes provided, create a default so cards still render
|
||||
if (!wekanSwimlanes || wekanSwimlanes.length === 0) {
|
||||
const swimlaneToCreate = {
|
||||
@@ -638,8 +651,8 @@ export class WekanCreator {
|
||||
title: 'Default',
|
||||
sort: 0,
|
||||
};
|
||||
const created = Swimlanes.direct.insert(swimlaneToCreate);
|
||||
Swimlanes.direct.update(created, {
|
||||
const created = await Swimlanes.direct.insertAsync(swimlaneToCreate);
|
||||
await Swimlanes.direct.updateAsync(created, {
|
||||
$set: {
|
||||
updatedAt: this._now(),
|
||||
},
|
||||
@@ -648,7 +661,7 @@ export class WekanCreator {
|
||||
return;
|
||||
}
|
||||
|
||||
wekanSwimlanes.forEach((swimlane, swimlaneIndex) => {
|
||||
for (const [swimlaneIndex, swimlane] of wekanSwimlanes.entries()) {
|
||||
const swimlaneToCreate = {
|
||||
archived: swimlane.archived,
|
||||
boardId,
|
||||
@@ -664,8 +677,8 @@ export class WekanCreator {
|
||||
if (swimlane.color) {
|
||||
swimlaneToCreate.color = swimlane.color;
|
||||
}
|
||||
const swimlaneId = Swimlanes.direct.insert(swimlaneToCreate);
|
||||
Swimlanes.direct.update(swimlaneId, {
|
||||
const swimlaneId = await Swimlanes.direct.insertAsync(swimlaneToCreate);
|
||||
await Swimlanes.direct.updateAsync(swimlaneId, {
|
||||
$set: {
|
||||
updatedAt: this._now(),
|
||||
},
|
||||
@@ -674,7 +687,7 @@ export class WekanCreator {
|
||||
if (!this._defaultSwimlaneId) {
|
||||
this._defaultSwimlaneId = swimlaneId;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createSubtasks(wekanCards) {
|
||||
@@ -697,7 +710,7 @@ export class WekanCreator {
|
||||
//if the parent card exists, proceed
|
||||
if (await ReactiveCache.getCard(parentIdInNewBoard)) {
|
||||
//set parent id of the card in the new board to the new id of the parent
|
||||
Cards.direct.update(cardIdInNewBoard, {
|
||||
await Cards.direct.updateAsync(cardIdInNewBoard, {
|
||||
$set: {
|
||||
parentId: parentIdInNewBoard,
|
||||
},
|
||||
@@ -706,9 +719,9 @@ export class WekanCreator {
|
||||
}
|
||||
}
|
||||
|
||||
createChecklists(wekanChecklists) {
|
||||
async createChecklists(wekanChecklists) {
|
||||
const result = [];
|
||||
wekanChecklists.forEach((checklist, checklistIndex) => {
|
||||
for (const [checklistIndex, checklist] of wekanChecklists.entries()) {
|
||||
// Create the checklist
|
||||
const checklistToCreate = {
|
||||
cardId: this.cards[checklist.cardId],
|
||||
@@ -716,15 +729,15 @@ export class WekanCreator {
|
||||
createdAt: checklist.createdAt,
|
||||
sort: checklist.sort ? checklist.sort : checklistIndex,
|
||||
};
|
||||
const checklistId = Checklists.direct.insert(checklistToCreate);
|
||||
const checklistId = await Checklists.direct.insertAsync(checklistToCreate);
|
||||
this.checklists[checklist._id] = checklistId;
|
||||
result.push(checklistId);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
createTriggers(wekanTriggers, boardId) {
|
||||
wekanTriggers.forEach(trigger => {
|
||||
async createTriggers(wekanTriggers, boardId) {
|
||||
for (const trigger of wekanTriggers) {
|
||||
if (trigger.hasOwnProperty('labelId')) {
|
||||
trigger.labelId = this.labels[trigger.labelId];
|
||||
}
|
||||
@@ -734,12 +747,12 @@ export class WekanCreator {
|
||||
trigger.boardId = boardId;
|
||||
const oldId = trigger._id;
|
||||
delete trigger._id;
|
||||
this.triggers[oldId] = Triggers.direct.insert(trigger);
|
||||
});
|
||||
this.triggers[oldId] = await Triggers.direct.insertAsync(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
createActions(wekanActions, boardId) {
|
||||
wekanActions.forEach(action => {
|
||||
async createActions(wekanActions, boardId) {
|
||||
for (const action of wekanActions) {
|
||||
if (action.hasOwnProperty('labelId')) {
|
||||
action.labelId = this.labels[action.labelId];
|
||||
}
|
||||
@@ -749,23 +762,23 @@ export class WekanCreator {
|
||||
action.boardId = boardId;
|
||||
const oldId = action._id;
|
||||
delete action._id;
|
||||
this.actions[oldId] = Actions.direct.insert(action);
|
||||
});
|
||||
this.actions[oldId] = await Actions.direct.insertAsync(action);
|
||||
}
|
||||
}
|
||||
|
||||
createRules(wekanRules, boardId) {
|
||||
wekanRules.forEach(rule => {
|
||||
async createRules(wekanRules, boardId) {
|
||||
for (const rule of wekanRules) {
|
||||
// Create the rule
|
||||
rule.boardId = boardId;
|
||||
rule.triggerId = this.triggers[rule.triggerId];
|
||||
rule.actionId = this.actions[rule.actionId];
|
||||
delete rule._id;
|
||||
Rules.direct.insert(rule);
|
||||
});
|
||||
await Rules.direct.insertAsync(rule);
|
||||
}
|
||||
}
|
||||
|
||||
createChecklistItems(wekanChecklistItems) {
|
||||
wekanChecklistItems.forEach((checklistitem, checklistitemIndex) => {
|
||||
async createChecklistItems(wekanChecklistItems) {
|
||||
for (const [checklistitemIndex, checklistitem] of wekanChecklistItems.entries()) {
|
||||
//Check if the checklist for this item (still) exists
|
||||
//If a checklist was deleted, but items remain, the import would error out here
|
||||
//Leading to no further checklist items being imported
|
||||
@@ -779,12 +792,12 @@ export class WekanCreator {
|
||||
isFinished: checklistitem.isFinished,
|
||||
};
|
||||
|
||||
const checklistItemId = ChecklistItems.direct.insert(
|
||||
const checklistItemId = await ChecklistItems.direct.insertAsync(
|
||||
checklistItemTocreate,
|
||||
);
|
||||
this.checklistItems[checklistitem._id] = checklistItemId;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
parseActivities(wekanBoard) {
|
||||
@@ -851,13 +864,13 @@ export class WekanCreator {
|
||||
});
|
||||
}
|
||||
|
||||
importActivities(activities, boardId) {
|
||||
activities.forEach(activity => {
|
||||
async importActivities(activities, boardId) {
|
||||
for (const activity of activities) {
|
||||
switch (activity.activityType) {
|
||||
// Board related activities
|
||||
// TODO: addBoardMember, removeBoardMember
|
||||
case 'createBoard': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
type: 'board',
|
||||
activityTypeId: boardId,
|
||||
@@ -870,7 +883,7 @@ export class WekanCreator {
|
||||
// List related activities
|
||||
// TODO: removeList, archivedList
|
||||
case 'createList': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
type: 'list',
|
||||
activityType: activity.activityType,
|
||||
@@ -883,7 +896,7 @@ export class WekanCreator {
|
||||
// Card related activities
|
||||
// TODO: archivedCard, restoredCard, joinMember, unjoinMember
|
||||
case 'createCard': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
activityType: activity.activityType,
|
||||
listId: this.lists[activity.listId],
|
||||
@@ -894,7 +907,7 @@ export class WekanCreator {
|
||||
break;
|
||||
}
|
||||
case 'moveCard': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
oldListId: this.lists[activity.oldListId],
|
||||
activityType: activity.activityType,
|
||||
@@ -907,7 +920,7 @@ export class WekanCreator {
|
||||
}
|
||||
// Comment related activities
|
||||
case 'addComment': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
activityType: activity.activityType,
|
||||
cardId: this.cards[activity.cardId],
|
||||
@@ -919,7 +932,7 @@ export class WekanCreator {
|
||||
}
|
||||
// Attachment related activities
|
||||
case 'addAttachment': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
type: 'card',
|
||||
activityType: activity.activityType,
|
||||
@@ -932,7 +945,7 @@ export class WekanCreator {
|
||||
}
|
||||
// Checklist related activities
|
||||
case 'addChecklist': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
activityType: activity.activityType,
|
||||
cardId: this.cards[activity.cardId],
|
||||
@@ -943,7 +956,7 @@ export class WekanCreator {
|
||||
break;
|
||||
}
|
||||
case 'addChecklistItem': {
|
||||
Activities.direct.insert({
|
||||
await Activities.direct.insertAsync({
|
||||
userId: this._user(activity.userId),
|
||||
activityType: activity.activityType,
|
||||
cardId: this.cards[activity.cardId],
|
||||
@@ -958,7 +971,7 @@ export class WekanCreator {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//check(board) {
|
||||
@@ -995,17 +1008,17 @@ export class WekanCreator {
|
||||
}
|
||||
this.parseActivities(board);
|
||||
const boardId = await this.createBoardAndLabels(board);
|
||||
this.createLists(board.lists, boardId);
|
||||
this.createSwimlanes(board.swimlanes, boardId);
|
||||
this.createCustomFields(board.customFields, boardId);
|
||||
await this.createLists(board.lists, boardId);
|
||||
await this.createSwimlanes(board.swimlanes, boardId);
|
||||
await this.createCustomFields(board.customFields, boardId);
|
||||
await this.createCards(board.cards, boardId);
|
||||
await this.createSubtasks(board.cards);
|
||||
this.createChecklists(board.checklists);
|
||||
this.createChecklistItems(board.checklistItems);
|
||||
this.importActivities(board.activities, boardId);
|
||||
this.createTriggers(board.triggers, boardId);
|
||||
this.createActions(board.actions, boardId);
|
||||
this.createRules(board.rules, boardId);
|
||||
await this.createChecklists(board.checklists);
|
||||
await this.createChecklistItems(board.checklistItems);
|
||||
await this.importActivities(board.activities, boardId);
|
||||
await this.createTriggers(board.triggers, boardId);
|
||||
await this.createActions(board.actions, boardId);
|
||||
await this.createRules(board.rules, boardId);
|
||||
// XXX add members
|
||||
return boardId;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import * as markdownItEmoji from 'markdown-it-emoji';
|
||||
import { getSecureDOMPurifyConfig } from './secureDOMPurify';
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import { HTML } from 'meteor/htmljs';
|
||||
import { Template } from 'meteor/templating';
|
||||
|
||||
var Markdown = require('markdown-it')({
|
||||
const Markdown = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
@@ -39,20 +41,9 @@ for(var i=0; i<urlschemes.length;i++){
|
||||
Markdown.linkify.add(urlschemes[i]+":",'http:');
|
||||
}
|
||||
|
||||
// Try to load emoji support, but don't fail if it's not available
|
||||
try {
|
||||
var emoji = require('markdown-it-emoji');
|
||||
Markdown.use(emoji);
|
||||
} catch (e) {
|
||||
console.warn('markdown-it-emoji not available, emoji rendering disabled:', e.message);
|
||||
}
|
||||
|
||||
// Try to load mathjax3, but don't fail if it's not available
|
||||
try {
|
||||
var mathjax = require('markdown-it-mathjax3');
|
||||
Markdown.use(mathjax);
|
||||
} catch (e) {
|
||||
console.warn('markdown-it-mathjax3 not available, math rendering disabled:', e.message);
|
||||
const emojiPlugin = markdownItEmoji.full || markdownItEmoji.default || markdownItEmoji;
|
||||
if (emojiPlugin) {
|
||||
Markdown.use(emojiPlugin);
|
||||
}
|
||||
|
||||
// Custom plugin to prevent SVG-based DoS attacks
|
||||
|
||||
@@ -119,9 +119,9 @@ let _userData = {};
|
||||
//RoutePolicy.declare('/_cas/', 'network');
|
||||
|
||||
// Listen to incoming OAuth http requests
|
||||
WebApp.handlers.use(Meteor.bindEnvironment((req, res, next) => {
|
||||
WebApp.handlers.use((req, res, next) => {
|
||||
middleware(req, res, next);
|
||||
}));
|
||||
});
|
||||
|
||||
const middleware = (req, res, next) => {
|
||||
// Make sure to catch any exceptions because otherwise we'd crash
|
||||
|
||||
@@ -139,7 +139,7 @@ if (__meteor_runtime_config__.SANDSTORM) {
|
||||
});
|
||||
}
|
||||
|
||||
var handlePostToken = Meteor.bindEnvironment(async function (req, res) {
|
||||
var handlePostToken = async function (req, res) {
|
||||
try {
|
||||
// Note that cross-origin POSTs cannot set arbitrary Content-Types without explicit CORS
|
||||
// permission, so this effectively prevents XSRF.
|
||||
@@ -198,5 +198,5 @@ if (__meteor_runtime_config__.SANDSTORM) {
|
||||
});
|
||||
res.end(err.stack);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -440,7 +440,7 @@ function debounce(fn, wait) {
|
||||
};
|
||||
}
|
||||
|
||||
const addCronJob = debounce(Meteor.bindEnvironment(function addCronJobDebounced() {
|
||||
const addCronJob = debounce(function addCronJobDebounced() {
|
||||
let sc = SyncedCron;
|
||||
if (LDAP.settings_get('LDAP_BACKGROUND_SYNC') !== true) {
|
||||
log_info('Disabling LDAP Background Sync');
|
||||
@@ -466,7 +466,7 @@ const addCronJob = debounce(Meteor.bindEnvironment(function addCronJobDebounced(
|
||||
});
|
||||
sc.start();
|
||||
|
||||
}), 500);
|
||||
}, 500);
|
||||
|
||||
Meteor.startup(() => {
|
||||
Meteor.defer(() => {
|
||||
|
||||
@@ -2,18 +2,6 @@ import '../models/users';
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// Configure SyncedCron to suppress console logging
|
||||
// This must be done before any SyncedCron operations
|
||||
if (Meteor.isServer) {
|
||||
const { SyncedCron } = require('meteor/quave:synced-cron');
|
||||
SyncedCron.config({
|
||||
log: false, // Disable console logging
|
||||
collectionName: 'cronJobs', // Use custom collection name
|
||||
utc: false, // Use local time
|
||||
collectionTTL: 172800 // 2 days TTL
|
||||
});
|
||||
}
|
||||
|
||||
let errors = [];
|
||||
if (!process.env.WRITABLE_PATH) {
|
||||
errors.push("WRITABLE_PATH environment variable missing and/or unset, please configure !");
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
Meteor.startup(() => {
|
||||
if (Meteor.isServer) {
|
||||
import { AccountsCommon } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
Accounts.config({
|
||||
loginExpirationInDays: process.env.ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS || 90,
|
||||
});
|
||||
}
|
||||
Meteor.startup(() => {
|
||||
Accounts.config({
|
||||
loginExpirationInDays: process.env.ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS || 90,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Login and register REST endpoints.
|
||||
// Replaces communitypackages:rest-accounts-password.
|
||||
// Logic copied from the package source to maintain identical API behavior.
|
||||
|
||||
const { Meteor } = require('meteor/meteor');
|
||||
const { Accounts } = require('meteor/accounts-base');
|
||||
const { WebApp } = require('meteor/webapp');
|
||||
const { check, Match } = require('meteor/check');
|
||||
const { sendJsonResult } = require('/server/apiMiddleware');
|
||||
|
||||
const NonEmptyString = Match.Where(function (x) {
|
||||
check(x, String);
|
||||
return x.length > 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /users/login
|
||||
// ---------------------------------------------------------------------------
|
||||
WebApp.handlers.options('/users/login', function (req, res) {
|
||||
sendJsonResult(res);
|
||||
});
|
||||
|
||||
WebApp.handlers.post('/users/login', async function (req, res) {
|
||||
try {
|
||||
const options = req.body;
|
||||
|
||||
let user;
|
||||
if (options.email) {
|
||||
check(options, {
|
||||
email: String,
|
||||
password: String,
|
||||
code: Match.Optional(NonEmptyString),
|
||||
});
|
||||
user = await Meteor.users.findOneAsync({ 'emails.address': options.email });
|
||||
} else {
|
||||
check(options, {
|
||||
username: String,
|
||||
password: String,
|
||||
code: Match.Optional(NonEmptyString),
|
||||
});
|
||||
user = await Meteor.users.findOneAsync({ username: options.username });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error(
|
||||
'not-found',
|
||||
'User with that username or email address not found.',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await Accounts._checkPasswordAsync(user, options.password);
|
||||
check(result, {
|
||||
userId: String,
|
||||
error: Match.Optional(Meteor.Error),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
// 2FA support
|
||||
if (Accounts._check2faEnabled && Accounts._check2faEnabled(user)) {
|
||||
if (!options.code) {
|
||||
Accounts._handleError('2FA code must be informed', true, 'no-2fa-code');
|
||||
}
|
||||
if (
|
||||
!Accounts._isTokenValid(
|
||||
user.services.twoFactorAuthentication.secret,
|
||||
options.code,
|
||||
)
|
||||
) {
|
||||
Accounts._handleError('Invalid 2FA code', true, 'invalid-2fa-code');
|
||||
}
|
||||
}
|
||||
|
||||
const stampedLoginToken = Accounts._generateStampedLoginToken();
|
||||
check(stampedLoginToken, { token: String, when: Date });
|
||||
|
||||
await Accounts._insertLoginToken(result.userId, stampedLoginToken);
|
||||
|
||||
const tokenExpiration = Accounts._tokenExpiration(stampedLoginToken.when);
|
||||
check(tokenExpiration, Date);
|
||||
|
||||
sendJsonResult(res, {
|
||||
data: {
|
||||
id: result.userId,
|
||||
token: stampedLoginToken.token,
|
||||
tokenExpires: tokenExpiration,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.statusCode = error.statusCode || 401;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error.error || error.message || 'Login failed',
|
||||
reason: error.reason || error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /users/register
|
||||
// ---------------------------------------------------------------------------
|
||||
WebApp.handlers.options('/users/register', function (req, res) {
|
||||
sendJsonResult(res);
|
||||
});
|
||||
|
||||
WebApp.handlers.post('/users/register', async function (req, res) {
|
||||
try {
|
||||
if (Accounts._options.forbidClientAccountCreation) {
|
||||
sendJsonResult(res, { code: 403 });
|
||||
return;
|
||||
}
|
||||
|
||||
const options = req.body;
|
||||
check(options, {
|
||||
username: Match.Optional(String),
|
||||
email: Match.Optional(String),
|
||||
password: String,
|
||||
});
|
||||
|
||||
const userOptions = { password: options.password };
|
||||
if (options.username) userOptions.username = options.username;
|
||||
if (options.email) userOptions.email = options.email;
|
||||
|
||||
const userId = await Accounts.createUserAsync(userOptions);
|
||||
|
||||
const stampedLoginToken = Accounts._generateStampedLoginToken();
|
||||
check(stampedLoginToken, { token: String, when: Date });
|
||||
|
||||
await Accounts._insertLoginToken(userId, stampedLoginToken);
|
||||
|
||||
const tokenExpiration = Accounts._tokenExpiration(stampedLoginToken.when);
|
||||
check(tokenExpiration, Date);
|
||||
|
||||
sendJsonResult(res, {
|
||||
data: {
|
||||
token: stampedLoginToken.token,
|
||||
tokenExpires: tokenExpiration,
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.statusCode = error.statusCode || 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error.error || error.message || 'Registration failed',
|
||||
reason: error.reason || error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// API middleware: body parsing, auth token handling, API gate, and JSON response helper.
|
||||
// Replaces communitypackages:json-routes middleware chain.
|
||||
// Must be imported before model files that register API routes.
|
||||
|
||||
const { Meteor } = require('meteor/meteor');
|
||||
const { Accounts } = require('meteor/accounts-base');
|
||||
const { WebApp } = require('meteor/webapp');
|
||||
const bodyParser = require('body-parser');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Body parsing (previously registered by json-routes)
|
||||
// ---------------------------------------------------------------------------
|
||||
WebApp.handlers.use(bodyParser.urlencoded({ limit: '50mb', extended: false }));
|
||||
WebApp.handlers.use(bodyParser.json({ limit: '50mb' }));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. API gate — check WITH_API env var (previously in models/users.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
WebApp.handlers.use(function apiGate(req, res, next) {
|
||||
const api = req.url.startsWith('/api');
|
||||
if ((api && process.env.WITH_API === 'true') || !api) {
|
||||
return next();
|
||||
}
|
||||
res.writeHead(301, { Location: '/' });
|
||||
return res.end();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Bearer token parser (replaces communitypackages:rest-bearer-token-parser)
|
||||
// ---------------------------------------------------------------------------
|
||||
WebApp.handlers.use(function parseBearerToken(req, res, next) {
|
||||
// Check Authorization header first
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) {
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (match) {
|
||||
req.authToken = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to access_token query param
|
||||
if (!req.authToken && req.query && req.query.access_token) {
|
||||
req.authToken = req.query.access_token;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. User authentication (replaces communitypackages:authenticate-user-by-token)
|
||||
// ---------------------------------------------------------------------------
|
||||
WebApp.handlers.use(async function authenticateByToken(req, res, next) {
|
||||
if (req.authToken) {
|
||||
try {
|
||||
const hashedToken = Accounts._hashLoginToken(req.authToken);
|
||||
const user = await Meteor.users.findOneAsync(
|
||||
{ 'services.resume.loginTokens.hashedToken': hashedToken },
|
||||
{ fields: { _id: 1 } },
|
||||
);
|
||||
if (user) {
|
||||
req.userId = user._id;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore auth errors — routes handle missing userId themselves
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. sendJsonResult — drop-in replacement for JsonRoutes.sendResult
|
||||
// ---------------------------------------------------------------------------
|
||||
function sendJsonResult(res, options) {
|
||||
options = options || {};
|
||||
|
||||
// Default response headers (matching json-routes behavior)
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
|
||||
// Custom headers
|
||||
if (options.headers) {
|
||||
Object.entries(options.headers).forEach(function ([key, value]) {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Status code
|
||||
res.statusCode = options.code || 200;
|
||||
|
||||
// JSON body
|
||||
if (options.data !== undefined) {
|
||||
const shouldPrettyPrint = process.env.NODE_ENV === 'development';
|
||||
const spacer = shouldPrettyPrint ? 2 : null;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.write(JSON.stringify(options.data, null, spacer));
|
||||
}
|
||||
|
||||
res.end();
|
||||
}
|
||||
|
||||
module.exports = { sendJsonResult };
|
||||
+12
-13
@@ -1,16 +1,16 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Attachments, fileStoreStrategyFactory } from '/models/attachments';
|
||||
import Attachments from '/models/attachments';
|
||||
import { fileStoreStrategyFactory } from '/models/attachments.server';
|
||||
import { moveToStorage } from '/models/lib/fileStoreStrategy';
|
||||
import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
|
||||
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ObjectID } from 'bson';
|
||||
import { ObjectId } from 'bson';
|
||||
|
||||
// Attachment API methods
|
||||
if (Meteor.isServer) {
|
||||
Meteor.methods({
|
||||
Meteor.methods({
|
||||
// Upload attachment via API
|
||||
async 'api.attachment.upload'(boardId, swimlaneId, listId, cardId, fileData, fileName, fileType, storageBackend) {
|
||||
if (!this.userId) {
|
||||
@@ -47,7 +47,7 @@ if (Meteor.isServer) {
|
||||
let targetStorage = storageBackend;
|
||||
if (!targetStorage) {
|
||||
try {
|
||||
const settings = AttachmentStorageSettings.findOne({});
|
||||
const settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
targetStorage = settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
|
||||
} catch (error) {
|
||||
targetStorage = STORAGE_NAME_FILESYSTEM;
|
||||
@@ -65,7 +65,7 @@ if (Meteor.isServer) {
|
||||
const file = new File([fileBuffer], fileName, { type: fileType || 'application/octet-stream' });
|
||||
|
||||
// Create attachment metadata
|
||||
const fileId = new ObjectID().toString();
|
||||
const fileId = new ObjectId().toString();
|
||||
const meta = {
|
||||
boardId: boardId,
|
||||
swimlaneId: swimlaneId,
|
||||
@@ -77,7 +77,7 @@ if (Meteor.isServer) {
|
||||
};
|
||||
|
||||
// Create attachment
|
||||
const uploader = Attachments.insert({
|
||||
const uploader = await Attachments.insertAsync({
|
||||
file: file,
|
||||
meta: meta,
|
||||
isBase64: false,
|
||||
@@ -274,13 +274,13 @@ if (Meteor.isServer) {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
readStream.on('end', () => {
|
||||
readStream.on('end', async () => {
|
||||
try {
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
const file = new File([fileBuffer], sourceAttachment.name, { type: sourceAttachment.type });
|
||||
|
||||
// Create new attachment metadata
|
||||
const fileId = new ObjectID().toString();
|
||||
const fileId = new ObjectId().toString();
|
||||
const meta = {
|
||||
boardId: targetBoardId,
|
||||
swimlaneId: targetSwimlaneId,
|
||||
@@ -293,7 +293,7 @@ if (Meteor.isServer) {
|
||||
};
|
||||
|
||||
// Create new attachment
|
||||
const uploader = Attachments.insert({
|
||||
const uploader = await Attachments.insertAsync({
|
||||
file: file,
|
||||
meta: meta,
|
||||
isBase64: false,
|
||||
@@ -358,7 +358,7 @@ if (Meteor.isServer) {
|
||||
|
||||
try {
|
||||
// Update attachment metadata
|
||||
Attachments.update(attachmentId, {
|
||||
await Attachments.updateAsync(attachmentId, {
|
||||
$set: {
|
||||
'meta.boardId': targetBoardId,
|
||||
'meta.swimlaneId': targetSwimlaneId,
|
||||
@@ -404,7 +404,7 @@ if (Meteor.isServer) {
|
||||
|
||||
try {
|
||||
// Delete attachment
|
||||
Attachments.remove(attachmentId);
|
||||
await Attachments.removeAsync(attachmentId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -465,4 +465,3 @@ if (Meteor.isServer) {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { check } from 'meteor/check';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Attachments from '/models/attachments';
|
||||
import { AttachmentMigrationStatus } from './attachmentMigrationStatus';
|
||||
import AttachmentMigrationStatus from '/models/attachmentMigrationStatus';
|
||||
|
||||
// Reactive variables for tracking migration progress
|
||||
const migrationProgress = new ReactiveVar(0);
|
||||
@@ -61,9 +61,9 @@ class AttachmentMigrationService {
|
||||
console.log(`Starting attachment migration for board: ${boardId}`);
|
||||
|
||||
// Get all attachments for the board
|
||||
const attachments = Attachments.find({
|
||||
const attachments = await Attachments.find({
|
||||
'meta.boardId': boardId
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
|
||||
const totalAttachments = attachments.length;
|
||||
let migratedCount = 0;
|
||||
@@ -90,7 +90,7 @@ class AttachmentMigrationService {
|
||||
}
|
||||
|
||||
// Update unconverted attachments list
|
||||
const remainingUnconverted = this.getUnconvertedAttachments(boardId);
|
||||
const remainingUnconverted = await this.getUnconvertedAttachments(boardId);
|
||||
unconvertedAttachments.set(remainingUnconverted);
|
||||
|
||||
migrationStatus.set('Attachment migration completed');
|
||||
@@ -183,7 +183,7 @@ class AttachmentMigrationService {
|
||||
};
|
||||
}
|
||||
|
||||
Attachments.update(attachment._id, { $set: updateData });
|
||||
await Attachments.updateAsync(attachment._id, { $set: updateData });
|
||||
|
||||
console.log(`Migrated attachment ${attachment._id}`);
|
||||
|
||||
@@ -198,11 +198,11 @@ class AttachmentMigrationService {
|
||||
* @param {string} boardId - The board ID
|
||||
* @returns {Array} - Array of unconverted attachments
|
||||
*/
|
||||
getUnconvertedAttachments(boardId) {
|
||||
async getUnconvertedAttachments(boardId) {
|
||||
try {
|
||||
const attachments = Attachments.find({
|
||||
const attachments = await Attachments.find({
|
||||
'meta.boardId': boardId
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
|
||||
return attachments.filter(attachment => this.needsMigration(attachment));
|
||||
} catch (error) {
|
||||
@@ -216,11 +216,11 @@ class AttachmentMigrationService {
|
||||
* @param {string} boardId - The board ID
|
||||
* @returns {Object} - Migration progress data
|
||||
*/
|
||||
getMigrationProgress(boardId) {
|
||||
async getMigrationProgress(boardId) {
|
||||
const progress = migrationProgress.get();
|
||||
const status = migrationStatus.get();
|
||||
const unconverted = this.getUnconvertedAttachments(boardId);
|
||||
const total = Attachments.find({ 'meta.boardId': boardId }).count();
|
||||
const unconverted = await this.getUnconvertedAttachments(boardId);
|
||||
const total = await Attachments.find({ 'meta.boardId': boardId }).countAsync();
|
||||
const migratedCount = total - unconverted.length;
|
||||
|
||||
// Update status collection for pub/sub
|
||||
@@ -287,7 +287,7 @@ Meteor.methods({
|
||||
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
|
||||
}
|
||||
|
||||
return attachmentMigrationService.getMigrationProgress(boardId);
|
||||
return await attachmentMigrationService.getMigrationProgress(boardId);
|
||||
},
|
||||
|
||||
async 'attachmentMigration.getUnconvertedAttachments'(boardId) {
|
||||
@@ -321,4 +321,4 @@ Meteor.methods({
|
||||
}
|
||||
});
|
||||
|
||||
export { attachmentMigrationService };
|
||||
export { attachmentMigrationService };
|
||||
@@ -1,18 +1,14 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
// Server-side collection for attachment migration status
|
||||
export const AttachmentMigrationStatus = new Mongo.Collection('attachmentMigrationStatus');
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import AttachmentMigrationStatus from '/models/attachmentMigrationStatus';
|
||||
|
||||
// Allow/Deny rules
|
||||
// This collection is server-only and should not be modified by clients
|
||||
// Allow server-side operations (when userId is undefined) but deny all client operations
|
||||
if (Meteor.isServer) {
|
||||
AttachmentMigrationStatus.allow({
|
||||
insert: (userId) => !userId,
|
||||
update: (userId) => !userId,
|
||||
remove: (userId) => !userId,
|
||||
});
|
||||
}
|
||||
AttachmentMigrationStatus.allow({
|
||||
insert: (userId) => !userId,
|
||||
update: (userId) => !userId,
|
||||
remove: (userId) => !userId,
|
||||
});
|
||||
|
||||
// Create indexes for better query performance
|
||||
Meteor.startup(() => {
|
||||
|
||||
+21
-27
@@ -1,21 +1,8 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Fiber from 'fibers';
|
||||
|
||||
Meteor.startup(() => {
|
||||
// Node Fibers 100% CPU usage issue
|
||||
// https://github.com/wekan/wekan-mongodb/issues/2#issuecomment-381453161
|
||||
// https://github.com/meteor/meteor/issues/9796#issuecomment-381676326
|
||||
// https://github.com/sandstorm-io/sandstorm/blob/0f1fec013fe7208ed0fd97eb88b31b77e3c61f42/shell/server/00-startup.js#L99-L129
|
||||
Fiber.poolSize = 1e9;
|
||||
|
||||
Accounts.validateLoginAttempt(function(options) {
|
||||
const user = options.user || {};
|
||||
return !user.loginDisabled;
|
||||
});
|
||||
|
||||
Authentication = {};
|
||||
|
||||
Authentication.checkUserId = async function(userId) {
|
||||
// Authentication helpers — exported for use by API routes and model files
|
||||
export const Authentication = {
|
||||
async checkUserId(userId) {
|
||||
if (userId === undefined) {
|
||||
const error = new Meteor.Error('Unauthorized', 'Unauthorized');
|
||||
error.statusCode = 401;
|
||||
@@ -28,21 +15,21 @@ Meteor.startup(() => {
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// This will only check if the user is logged in.
|
||||
// The authorization checks for the user will have to be done inside each API endpoint
|
||||
Authentication.checkLoggedIn = function(userId) {
|
||||
checkLoggedIn(userId) {
|
||||
if (userId === undefined) {
|
||||
const error = new Meteor.Error('Unauthorized', 'Unauthorized');
|
||||
error.statusCode = 401;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// An admin should be authorized to access everything, so we use a separate check for admins
|
||||
// This throws an error if otherReq is false and the user is not an admin
|
||||
Authentication.checkAdminOrCondition = async function(userId, otherReq) {
|
||||
async checkAdminOrCondition(userId, otherReq) {
|
||||
if (otherReq) return;
|
||||
const admin = await ReactiveCache.getUser({ _id: userId, isAdmin: true });
|
||||
if (admin === undefined) {
|
||||
@@ -50,31 +37,38 @@ Meteor.startup(() => {
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Helper function. Will throw an error if the user is not active BoardAdmin or active Normal user of the board.
|
||||
Authentication.checkBoardAccess = async function(userId, boardId) {
|
||||
async checkBoardAccess(userId, boardId) {
|
||||
Authentication.checkLoggedIn(userId);
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
const normalAccess = board.members.some(e => e.userId === userId && e.isActive && !e.isNoComments && !e.isCommentOnly && !e.isWorker);
|
||||
await Authentication.checkAdminOrCondition(userId, normalAccess);
|
||||
};
|
||||
},
|
||||
|
||||
// Helper function. Will throw an error if the user does not have write access to the board (excludes read-only users).
|
||||
Authentication.checkBoardWriteAccess = async function(userId, boardId) {
|
||||
async checkBoardWriteAccess(userId, boardId) {
|
||||
Authentication.checkLoggedIn(userId);
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
const writeAccess = board.members.some(e => e.userId === userId && e.isActive && !e.isNoComments && !e.isCommentOnly && !e.isWorker && !e.isReadOnly && !e.isReadAssignedOnly);
|
||||
await Authentication.checkAdminOrCondition(userId, writeAccess);
|
||||
};
|
||||
},
|
||||
|
||||
// Helper function. Will throw an error if the user is not a board admin.
|
||||
Authentication.checkBoardAdmin = async function(userId, boardId) {
|
||||
async checkBoardAdmin(userId, boardId) {
|
||||
Authentication.checkLoggedIn(userId);
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
const adminAccess = board.members.some(e => e.userId === userId && e.isActive && e.isAdmin);
|
||||
await Authentication.checkAdminOrCondition(userId, adminAccess);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Meteor.startup(() => {
|
||||
Accounts.validateLoginAttempt(function(options) {
|
||||
const user = options.user || {};
|
||||
return !user.loginDisabled;
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
if (
|
||||
|
||||
@@ -60,9 +60,9 @@ class BoardMigrationDetector {
|
||||
/**
|
||||
* Check if system is idle and can run migrations
|
||||
*/
|
||||
isSystemIdle() {
|
||||
async isSystemIdle() {
|
||||
const resources = cronJobStorage.getSystemResources();
|
||||
const queueStats = cronJobStorage.getQueueStats();
|
||||
const queueStats = await cronJobStorage.getQueueStats();
|
||||
|
||||
// Check if no jobs are running
|
||||
if (queueStats.running > 0) {
|
||||
@@ -86,7 +86,7 @@ class BoardMigrationDetector {
|
||||
* Check for idle migration opportunities
|
||||
*/
|
||||
async checkForIdleMigration() {
|
||||
if (!this.isSystemIdle()) {
|
||||
if (!(await this.isSystemIdle())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class BoardMigrationDetector {
|
||||
}
|
||||
|
||||
// Check if we can start a new job
|
||||
const canStart = cronJobStorage.canStartNewJob();
|
||||
const canStart = await cronJobStorage.canStartNewJob();
|
||||
if (!canStart.canStart) {
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +122,7 @@ class BoardMigrationDetector {
|
||||
// Scanning for unmigrated boards
|
||||
|
||||
// Get all boards from the database
|
||||
const boards = this.getAllBoards();
|
||||
const boards = await this.getAllBoards();
|
||||
const unmigrated = [];
|
||||
|
||||
for (const board of boards) {
|
||||
@@ -147,13 +147,13 @@ class BoardMigrationDetector {
|
||||
/**
|
||||
* Get all boards from the database
|
||||
*/
|
||||
getAllBoards() {
|
||||
async getAllBoards() {
|
||||
// This would need to be implemented based on your board model
|
||||
// For now, we'll simulate getting boards
|
||||
try {
|
||||
// Assuming you have a Boards collection
|
||||
if (typeof Boards !== 'undefined') {
|
||||
return Boards.find({}, { fields: { _id: 1, title: 1, createdAt: 1, modifiedAt: 1 } }).fetch();
|
||||
return await Boards.find({}, { fields: { _id: 1, title: 1, createdAt: 1, modifiedAt: 1 } }).fetchAsync();
|
||||
}
|
||||
|
||||
// Fallback: return empty array if Boards collection not available
|
||||
@@ -170,7 +170,7 @@ class BoardMigrationDetector {
|
||||
async needsMigration(board) {
|
||||
try {
|
||||
// Check if board has been migrated by looking for migration markers
|
||||
const migrationMarkers = this.getMigrationMarkers(board._id);
|
||||
const migrationMarkers = await this.getMigrationMarkers(board._id);
|
||||
|
||||
// Check for specific migration indicators
|
||||
const needsListMigration = !migrationMarkers.listsMigrated;
|
||||
@@ -188,10 +188,10 @@ class BoardMigrationDetector {
|
||||
/**
|
||||
* Get migration markers for a board
|
||||
*/
|
||||
getMigrationMarkers(boardId) {
|
||||
async getMigrationMarkers(boardId) {
|
||||
try {
|
||||
// Check if board has migration metadata
|
||||
const board = Boards.findOne(boardId, { fields: { migrationMarkers: 1 } });
|
||||
const board = await Boards.findOneAsync(boardId, { fields: { migrationMarkers: 1 } });
|
||||
|
||||
if (!board || !board.migrationMarkers) {
|
||||
return {
|
||||
@@ -217,7 +217,7 @@ class BoardMigrationDetector {
|
||||
*/
|
||||
async startBoardMigration(boardId) {
|
||||
try {
|
||||
const board = Boards.findOne(boardId);
|
||||
const board = await Boards.findOneAsync(boardId);
|
||||
if (!board) {
|
||||
throw new Error(`Board ${boardId} not found`);
|
||||
}
|
||||
@@ -232,14 +232,14 @@ class BoardMigrationDetector {
|
||||
const jobId = `board_migration_${board._id}_${Date.now()}`;
|
||||
|
||||
// Add to job queue with high priority
|
||||
cronJobStorage.addToQueue(jobId, 'board_migration', 1, {
|
||||
await cronJobStorage.addToQueue(jobId, 'board_migration', 1, {
|
||||
boardId: board._id,
|
||||
boardTitle: board.title,
|
||||
migrationType: 'full_board_migration'
|
||||
});
|
||||
|
||||
// Save initial job status
|
||||
cronJobStorage.saveJobStatus(jobId, {
|
||||
await cronJobStorage.saveJobStatus(jobId, {
|
||||
jobType: 'board_migration',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
@@ -289,7 +289,7 @@ class BoardMigrationDetector {
|
||||
/**
|
||||
* Get detailed migration status for a specific board
|
||||
*/
|
||||
getBoardMigrationStatus(boardId) {
|
||||
async getBoardMigrationStatus(boardId) {
|
||||
const unmigrated = unmigratedBoards.get();
|
||||
const isUnmigrated = unmigrated.some(b => b._id === boardId);
|
||||
|
||||
@@ -297,7 +297,7 @@ class BoardMigrationDetector {
|
||||
return { needsMigration: false, reason: 'Board is already migrated' };
|
||||
}
|
||||
|
||||
const migrationMarkers = this.getMigrationMarkers(boardId);
|
||||
const migrationMarkers = await this.getMigrationMarkers(boardId);
|
||||
const needsMigration = !migrationMarkers.listsMigrated ||
|
||||
!migrationMarkers.attachmentsMigrated ||
|
||||
!migrationMarkers.swimlanesMigrated;
|
||||
@@ -312,7 +312,7 @@ class BoardMigrationDetector {
|
||||
/**
|
||||
* Mark a board as migrated
|
||||
*/
|
||||
markBoardAsMigrated(boardId, migrationType) {
|
||||
async markBoardAsMigrated(boardId, migrationType) {
|
||||
try {
|
||||
// Update migration markers and version
|
||||
const updateQuery = {};
|
||||
@@ -320,7 +320,7 @@ class BoardMigrationDetector {
|
||||
updateQuery['migrationMarkers.lastMigration'] = new Date();
|
||||
updateQuery['migrationVersion'] = 1; // Set migration version to prevent re-migration
|
||||
|
||||
Boards.update(boardId, { $set: updateQuery });
|
||||
await Boards.updateAsync(boardId, { $set: updateQuery });
|
||||
|
||||
// Remove from unmigrated list if present
|
||||
const currentUnmigrated = unmigratedBoards.get();
|
||||
@@ -364,17 +364,17 @@ Meteor.methods({
|
||||
return boardMigrationDetector.forceScan();
|
||||
},
|
||||
|
||||
'boardMigration.getBoardStatus'(boardId) {
|
||||
async 'boardMigration.getBoardStatus'(boardId) {
|
||||
check(boardId, String);
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
return boardMigrationDetector.getBoardMigrationStatus(boardId);
|
||||
return await boardMigrationDetector.getBoardMigrationStatus(boardId);
|
||||
},
|
||||
|
||||
'boardMigration.markAsMigrated'(boardId, migrationType) {
|
||||
async 'boardMigration.markAsMigrated'(boardId, migrationType) {
|
||||
check(boardId, String);
|
||||
check(migrationType, String);
|
||||
|
||||
@@ -382,7 +382,7 @@ Meteor.methods({
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
return boardMigrationDetector.markBoardAsMigrated(boardId, migrationType);
|
||||
return await boardMigrationDetector.markBoardAsMigrated(boardId, migrationType);
|
||||
},
|
||||
|
||||
'boardMigration.startBoardMigration'(boardId) {
|
||||
@@ -395,24 +395,24 @@ Meteor.methods({
|
||||
return boardMigrationDetector.startBoardMigration(boardId);
|
||||
},
|
||||
|
||||
'boardMigration.fixStuckBoards'() {
|
||||
async 'boardMigration.fixStuckBoards'() {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
// Find boards that have migration markers but no migrationVersion
|
||||
const stuckBoards = Boards.find({
|
||||
const stuckBoards = await Boards.find({
|
||||
'migrationMarkers.fullMigrationCompleted': true,
|
||||
$or: [
|
||||
{ migrationVersion: { $exists: false } },
|
||||
{ migrationVersion: { $lt: 1 } }
|
||||
]
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
|
||||
let fixedCount = 0;
|
||||
stuckBoards.forEach(board => {
|
||||
for (const board of stuckBoards) {
|
||||
try {
|
||||
Boards.update(board._id, {
|
||||
await Boards.updateAsync(board._id, {
|
||||
$set: {
|
||||
migrationVersion: 1,
|
||||
'migrationMarkers.lastMigration': new Date()
|
||||
@@ -423,7 +423,7 @@ Meteor.methods({
|
||||
} catch (error) {
|
||||
console.error(`Error fixing board ${board._id}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Fixed ${fixedCount} stuck boards`,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
let syncedCronConfigured = false;
|
||||
let syncedCronInstance = null;
|
||||
|
||||
function getSyncedCronInstance() {
|
||||
if (!syncedCronInstance) {
|
||||
syncedCronInstance = require('meteor/quave:synced-cron').SyncedCron;
|
||||
}
|
||||
return syncedCronInstance;
|
||||
}
|
||||
|
||||
export function configureSyncedCron() {
|
||||
if (syncedCronConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const SyncedCron = getSyncedCronInstance();
|
||||
SyncedCron.config({
|
||||
log: false,
|
||||
collectionName: 'cronJobs',
|
||||
utc: false,
|
||||
collectionTTL: 172800,
|
||||
});
|
||||
|
||||
syncedCronConfigured = true;
|
||||
}
|
||||
|
||||
export function startSyncedCron() {
|
||||
configureSyncedCron();
|
||||
getSyncedCronInstance().start();
|
||||
}
|
||||
|
||||
export const SyncedCron = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
return getSyncedCronInstance()[prop];
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
getSyncedCronInstance()[prop] = value;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
+73
-72
@@ -5,9 +5,10 @@
|
||||
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import CronJobStatus from '/models/cronJobStatus';
|
||||
|
||||
// Collections for persistent storage
|
||||
export const CronJobStatus = new Mongo.Collection('cronJobStatus');
|
||||
export { CronJobStatus };
|
||||
export const CronJobSteps = new Mongo.Collection('cronJobSteps');
|
||||
export const CronJobQueue = new Mongo.Collection('cronJobQueue');
|
||||
export const CronJobErrors = new Mongo.Collection('cronJobErrors');
|
||||
@@ -97,12 +98,12 @@ class CronJobStorage {
|
||||
/**
|
||||
* Save job status to persistent storage
|
||||
*/
|
||||
saveJobStatus(jobId, jobData) {
|
||||
async saveJobStatus(jobId, jobData) {
|
||||
const now = new Date();
|
||||
const existingJob = CronJobStatus.findOne({ jobId });
|
||||
const existingJob = await CronJobStatus.findOneAsync({ jobId });
|
||||
|
||||
if (existingJob) {
|
||||
CronJobStatus.update(
|
||||
await CronJobStatus.updateAsync(
|
||||
{ jobId },
|
||||
{
|
||||
$set: {
|
||||
@@ -112,7 +113,7 @@ class CronJobStorage {
|
||||
}
|
||||
);
|
||||
} else {
|
||||
CronJobStatus.insert({
|
||||
await CronJobStatus.insertAsync({
|
||||
jobId,
|
||||
...jobData,
|
||||
createdAt: now,
|
||||
@@ -124,28 +125,28 @@ class CronJobStorage {
|
||||
/**
|
||||
* Get job status from persistent storage
|
||||
*/
|
||||
getJobStatus(jobId) {
|
||||
return CronJobStatus.findOne({ jobId });
|
||||
async getJobStatus(jobId) {
|
||||
return await CronJobStatus.findOneAsync({ jobId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all incomplete jobs
|
||||
*/
|
||||
getIncompleteJobs() {
|
||||
return CronJobStatus.find({
|
||||
async getIncompleteJobs() {
|
||||
return await CronJobStatus.find({
|
||||
status: { $in: ['pending', 'running', 'paused'] }
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save job step status
|
||||
*/
|
||||
saveJobStep(jobId, stepIndex, stepData) {
|
||||
async saveJobStep(jobId, stepIndex, stepData) {
|
||||
const now = new Date();
|
||||
const existingStep = CronJobSteps.findOne({ jobId, stepIndex });
|
||||
const existingStep = await CronJobSteps.findOneAsync({ jobId, stepIndex });
|
||||
|
||||
if (existingStep) {
|
||||
CronJobSteps.update(
|
||||
await CronJobSteps.updateAsync(
|
||||
{ jobId, stepIndex },
|
||||
{
|
||||
$set: {
|
||||
@@ -155,7 +156,7 @@ class CronJobStorage {
|
||||
}
|
||||
);
|
||||
} else {
|
||||
CronJobSteps.insert({
|
||||
await CronJobSteps.insertAsync({
|
||||
jobId,
|
||||
stepIndex,
|
||||
...stepData,
|
||||
@@ -168,31 +169,31 @@ class CronJobStorage {
|
||||
/**
|
||||
* Get job steps
|
||||
*/
|
||||
getJobSteps(jobId) {
|
||||
return CronJobSteps.find(
|
||||
async getJobSteps(jobId) {
|
||||
return await CronJobSteps.find(
|
||||
{ jobId },
|
||||
{ sort: { stepIndex: 1 } }
|
||||
).fetch();
|
||||
).fetchAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incomplete steps for a job
|
||||
*/
|
||||
getIncompleteSteps(jobId) {
|
||||
return CronJobSteps.find({
|
||||
async getIncompleteSteps(jobId) {
|
||||
return await CronJobSteps.find({
|
||||
jobId,
|
||||
status: { $in: ['pending', 'running'] }
|
||||
}, { sort: { stepIndex: 1 } }).fetch();
|
||||
}, { sort: { stepIndex: 1 } }).fetchAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save job error to persistent storage
|
||||
*/
|
||||
saveJobError(jobId, errorData) {
|
||||
async saveJobError(jobId, errorData) {
|
||||
const now = new Date();
|
||||
const { stepId, stepIndex, error, severity = 'error', context = {} } = errorData;
|
||||
|
||||
CronJobErrors.insert({
|
||||
await CronJobErrors.insertAsync({
|
||||
jobId,
|
||||
stepId,
|
||||
stepIndex,
|
||||
@@ -207,7 +208,7 @@ class CronJobStorage {
|
||||
/**
|
||||
* Get job errors from persistent storage
|
||||
*/
|
||||
getJobErrors(jobId, options = {}) {
|
||||
async getJobErrors(jobId, options = {}) {
|
||||
const { limit = 100, severity = null } = options;
|
||||
|
||||
const query = { jobId };
|
||||
@@ -215,42 +216,42 @@ class CronJobStorage {
|
||||
query.severity = severity;
|
||||
}
|
||||
|
||||
return CronJobErrors.find(query, {
|
||||
return await CronJobErrors.find(query, {
|
||||
sort: { createdAt: -1 },
|
||||
limit
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recent errors across all jobs
|
||||
*/
|
||||
getAllRecentErrors(limit = 50) {
|
||||
return CronJobErrors.find({}, {
|
||||
async getAllRecentErrors(limit = 50) {
|
||||
return await CronJobErrors.find({}, {
|
||||
sort: { createdAt: -1 },
|
||||
limit
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear errors for a specific job
|
||||
*/
|
||||
clearJobErrors(jobId) {
|
||||
return CronJobErrors.remove({ jobId });
|
||||
async clearJobErrors(jobId) {
|
||||
return await CronJobErrors.removeAsync({ jobId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add job to queue
|
||||
*/
|
||||
addToQueue(jobId, jobType, priority = 5, jobData = {}) {
|
||||
async addToQueue(jobId, jobType, priority = 5, jobData = {}) {
|
||||
const now = new Date();
|
||||
|
||||
// Check if job already exists in queue
|
||||
const existingJob = CronJobQueue.findOne({ jobId });
|
||||
const existingJob = await CronJobQueue.findOneAsync({ jobId });
|
||||
if (existingJob) {
|
||||
return existingJob._id;
|
||||
}
|
||||
|
||||
return CronJobQueue.insert({
|
||||
return await CronJobQueue.insertAsync({
|
||||
jobId,
|
||||
jobType,
|
||||
priority,
|
||||
@@ -264,8 +265,8 @@ class CronJobStorage {
|
||||
/**
|
||||
* Get next job from queue
|
||||
*/
|
||||
getNextJob() {
|
||||
return CronJobQueue.findOne({
|
||||
async getNextJob() {
|
||||
return await CronJobQueue.findOneAsync({
|
||||
status: 'pending'
|
||||
}, {
|
||||
sort: { priority: 1, createdAt: 1 }
|
||||
@@ -275,9 +276,9 @@ class CronJobStorage {
|
||||
/**
|
||||
* Update job queue status
|
||||
*/
|
||||
updateQueueStatus(jobId, status, additionalData = {}) {
|
||||
async updateQueueStatus(jobId, status, additionalData = {}) {
|
||||
const now = new Date();
|
||||
CronJobQueue.update(
|
||||
await CronJobQueue.updateAsync(
|
||||
{ jobId },
|
||||
{
|
||||
$set: {
|
||||
@@ -292,8 +293,8 @@ class CronJobStorage {
|
||||
/**
|
||||
* Remove job from queue
|
||||
*/
|
||||
removeFromQueue(jobId) {
|
||||
CronJobQueue.remove({ jobId });
|
||||
async removeFromQueue(jobId) {
|
||||
await CronJobQueue.removeAsync({ jobId });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,9 +334,9 @@ class CronJobStorage {
|
||||
/**
|
||||
* Check if system can handle more jobs
|
||||
*/
|
||||
canStartNewJob() {
|
||||
async canStartNewJob() {
|
||||
const resources = this.getSystemResources();
|
||||
const runningJobs = CronJobQueue.find({ status: 'running' }).count();
|
||||
const runningJobs = await CronJobQueue.find({ status: 'running' }).countAsync();
|
||||
|
||||
// Check CPU and memory thresholds
|
||||
if (resources.cpuUsage > this.cpuThreshold) {
|
||||
@@ -357,12 +358,12 @@ class CronJobStorage {
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
getQueueStats() {
|
||||
const total = CronJobQueue.find().count();
|
||||
const pending = CronJobQueue.find({ status: 'pending' }).count();
|
||||
const running = CronJobQueue.find({ status: 'running' }).count();
|
||||
const completed = CronJobQueue.find({ status: 'completed' }).count();
|
||||
const failed = CronJobQueue.find({ status: 'failed' }).count();
|
||||
async getQueueStats() {
|
||||
const total = await CronJobQueue.find().countAsync();
|
||||
const pending = await CronJobQueue.find({ status: 'pending' }).countAsync();
|
||||
const running = await CronJobQueue.find({ status: 'running' }).countAsync();
|
||||
const completed = await CronJobQueue.find({ status: 'completed' }).countAsync();
|
||||
const failed = await CronJobQueue.find({ status: 'failed' }).countAsync();
|
||||
|
||||
return {
|
||||
total,
|
||||
@@ -377,24 +378,24 @@ class CronJobStorage {
|
||||
/**
|
||||
* Clean up old completed jobs
|
||||
*/
|
||||
cleanupOldJobs(daysOld = 7) {
|
||||
async cleanupOldJobs(daysOld = 7) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||
|
||||
// Remove old completed jobs from queue
|
||||
const removedQueue = CronJobQueue.remove({
|
||||
const removedQueue = await CronJobQueue.removeAsync({
|
||||
status: 'completed',
|
||||
updatedAt: { $lt: cutoffDate }
|
||||
});
|
||||
|
||||
// Remove old job statuses
|
||||
const removedStatus = CronJobStatus.remove({
|
||||
const removedStatus = await CronJobStatus.removeAsync({
|
||||
status: 'completed',
|
||||
updatedAt: { $lt: cutoffDate }
|
||||
});
|
||||
|
||||
// Remove old job steps
|
||||
const removedSteps = CronJobSteps.remove({
|
||||
const removedSteps = await CronJobSteps.removeAsync({
|
||||
status: 'completed',
|
||||
updatedAt: { $lt: cutoffDate }
|
||||
});
|
||||
@@ -409,14 +410,14 @@ class CronJobStorage {
|
||||
/**
|
||||
* Resume incomplete jobs on startup
|
||||
*/
|
||||
resumeIncompleteJobs() {
|
||||
const incompleteJobs = this.getIncompleteJobs();
|
||||
async resumeIncompleteJobs() {
|
||||
const incompleteJobs = await this.getIncompleteJobs();
|
||||
const resumedJobs = [];
|
||||
|
||||
incompleteJobs.forEach(job => {
|
||||
for (const job of incompleteJobs) {
|
||||
// Reset running jobs to pending
|
||||
if (job.status === 'running') {
|
||||
this.saveJobStatus(job.jobId, {
|
||||
await this.saveJobStatus(job.jobId, {
|
||||
...job,
|
||||
status: 'pending',
|
||||
error: 'Job was interrupted during startup'
|
||||
@@ -425,11 +426,11 @@ class CronJobStorage {
|
||||
}
|
||||
|
||||
// Add to queue if not already there
|
||||
const queueJob = CronJobQueue.findOne({ jobId: job.jobId });
|
||||
const queueJob = await CronJobQueue.findOneAsync({ jobId: job.jobId });
|
||||
if (!queueJob) {
|
||||
this.addToQueue(job.jobId, job.jobType || 'unknown', job.priority || 5, job);
|
||||
await this.addToQueue(job.jobId, job.jobType || 'unknown', job.priority || 5, job);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return resumedJobs;
|
||||
}
|
||||
@@ -437,8 +438,8 @@ class CronJobStorage {
|
||||
/**
|
||||
* Get job progress percentage
|
||||
*/
|
||||
getJobProgress(jobId) {
|
||||
const steps = this.getJobSteps(jobId);
|
||||
async getJobProgress(jobId) {
|
||||
const steps = await this.getJobSteps(jobId);
|
||||
if (steps.length === 0) return 0;
|
||||
|
||||
const completedSteps = steps.filter(step => step.status === 'completed').length;
|
||||
@@ -448,10 +449,10 @@ class CronJobStorage {
|
||||
/**
|
||||
* Get detailed job information
|
||||
*/
|
||||
getJobDetails(jobId) {
|
||||
const jobStatus = this.getJobStatus(jobId);
|
||||
const jobSteps = this.getJobSteps(jobId);
|
||||
const progress = this.getJobProgress(jobId);
|
||||
async getJobDetails(jobId) {
|
||||
const jobStatus = await this.getJobStatus(jobId);
|
||||
const jobSteps = await this.getJobSteps(jobId);
|
||||
const progress = await this.getJobProgress(jobId);
|
||||
|
||||
return {
|
||||
...jobStatus,
|
||||
@@ -465,13 +466,13 @@ class CronJobStorage {
|
||||
/**
|
||||
* Clear all jobs from storage
|
||||
*/
|
||||
clearAllJobs() {
|
||||
async clearAllJobs() {
|
||||
try {
|
||||
// Clear all collections
|
||||
CronJobStatus.remove({});
|
||||
CronJobSteps.remove({});
|
||||
CronJobQueue.remove({});
|
||||
CronJobErrors.remove({});
|
||||
await CronJobStatus.removeAsync({});
|
||||
await CronJobSteps.removeAsync({});
|
||||
await CronJobQueue.removeAsync({});
|
||||
await CronJobErrors.removeAsync({});
|
||||
|
||||
console.log('All cron job data cleared from storage');
|
||||
return { success: true, message: 'All cron job data cleared' };
|
||||
@@ -486,15 +487,15 @@ class CronJobStorage {
|
||||
export const cronJobStorage = new CronJobStorage();
|
||||
|
||||
// Cleanup old jobs on startup
|
||||
Meteor.startup(() => {
|
||||
Meteor.startup(async () => {
|
||||
// Resume incomplete jobs
|
||||
const resumedJobs = cronJobStorage.resumeIncompleteJobs();
|
||||
const resumedJobs = await cronJobStorage.resumeIncompleteJobs();
|
||||
if (resumedJobs.length > 0) {
|
||||
// Resumed incomplete cron jobs
|
||||
}
|
||||
|
||||
// Cleanup old jobs
|
||||
const cleanup = cronJobStorage.cleanupOldJobs();
|
||||
const cleanup = await cronJobStorage.cleanupOldJobs();
|
||||
if (cleanup.removedQueue > 0 || cleanup.removedStatus > 0 || cleanup.removedSteps > 0) {
|
||||
// Cleaned up old cron jobs
|
||||
}
|
||||
|
||||
+224
-231
File diff suppressed because it is too large.
Load diff
+71
-48
@@ -3,6 +3,12 @@
|
||||
// Loaded by server/main.js after bootstrap (collectionHelpers) completes.
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 0. API middleware & auth routes (must register before model routes)
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/server/apiMiddleware';
|
||||
import '/server/apiAuthRoutes';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1. Shared imports (i18n, utilities, reactive cache)
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -15,50 +21,19 @@ import '/imports/i18n/tap';
|
||||
import '/imports/lib/customHeadDefaults';
|
||||
import '/imports/lib/dateUtils';
|
||||
import '/imports/lib/secureDOMPurify';
|
||||
import '/imports/reactiveCache';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 2. Models — shared collections (loaded on both client and server)
|
||||
// 2. Models — shared collections + server-only extensions
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/models/accessibilitySettings';
|
||||
import '/models/accountSettings';
|
||||
import '/models/actions';
|
||||
import '/models/activities';
|
||||
import '/models/announcements';
|
||||
import '/models/attachments';
|
||||
import '/models/attachmentStorageSettings';
|
||||
import '/models/avatars';
|
||||
import '/models/boards';
|
||||
import '/models/cardCommentReactions';
|
||||
import '/models/cardComments';
|
||||
import '/models/cards';
|
||||
import '/models/checklistItems';
|
||||
import '/models/checklists';
|
||||
import '/models/counters';
|
||||
import '/models/customFields';
|
||||
import '/imports/startup/shared-models';
|
||||
import '/models/attachments.server';
|
||||
import '/models/avatars.server';
|
||||
import '/models/fileValidation';
|
||||
import '/models/impersonatedUsers';
|
||||
import '/models/integrations';
|
||||
import '/models/invitationCodes';
|
||||
import '/models/lists';
|
||||
import '/models/lockoutSettings';
|
||||
import '/models/org';
|
||||
import '/models/orgUser';
|
||||
import '/models/positionHistory';
|
||||
import '/models/presences';
|
||||
import '/models/rules';
|
||||
import '/models/runOnServer';
|
||||
import '/models/settings';
|
||||
import '/models/swimlanes';
|
||||
import '/models/tableVisibilityModeSettings';
|
||||
import '/models/team';
|
||||
import '/models/translation';
|
||||
import '/models/triggers';
|
||||
import '/models/unsavedEdits';
|
||||
import '/models/userPositionHistory';
|
||||
import '/models/usersessiondata';
|
||||
import '/models/users';
|
||||
import '/models/watchable';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 3. Shared runtime services
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/imports/reactiveCache';
|
||||
|
||||
// Models — server-only exporters
|
||||
import '/models/csvCreator';
|
||||
@@ -83,9 +58,6 @@ import '/models/lib/fsHooks/createOnAfterUpload';
|
||||
import '/models/lib/grid/createBucket';
|
||||
import '/models/lib/grid/createObjectId';
|
||||
import '/models/lib/httpStream';
|
||||
import '/models/lib/meteorMongoIntegration';
|
||||
import '/models/lib/mongodbConnectionManager';
|
||||
import '/models/lib/mongodbDriverManager';
|
||||
import '/models/lib/universalUrlGenerator';
|
||||
import '/models/lib/userStorageHelpers';
|
||||
|
||||
@@ -95,19 +67,38 @@ import '/models/server/ExporterCardPDF';
|
||||
import '/models/server/ExporterExcelCard';
|
||||
import '/models/server/ExporterExcel';
|
||||
import '/models/server/metrics';
|
||||
import '/server/models/actions';
|
||||
import '/server/models/activities';
|
||||
import '/server/models/attachmentStorageSettings';
|
||||
import '/server/models/boards';
|
||||
import '/server/models/cards';
|
||||
import '/server/models/cardComments';
|
||||
import '/server/models/checklistItems';
|
||||
import '/server/models/checklists';
|
||||
import '/server/models/collectionBootstrap';
|
||||
import '/server/models/customFields';
|
||||
import '/server/models/integrations';
|
||||
import '/server/models/lists';
|
||||
import '/server/models/org';
|
||||
import '/server/models/settings';
|
||||
import '/server/models/swimlanes';
|
||||
import '/server/models/team';
|
||||
import '/server/models/translation';
|
||||
import '/server/models/users';
|
||||
import '/server/models/userPositionHistory';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 3. Config (shared)
|
||||
// 4. Config (shared)
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/config/accounts';
|
||||
import '/config/const';
|
||||
import '/config/models';
|
||||
import '/config/query-classes';
|
||||
import '/config/router';
|
||||
// router.js is client-only (FlowRouter route actions use client-only modules)
|
||||
import '/config/search-const';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4. Server — core startup & configuration
|
||||
// 5. Server — core startup & configuration
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/server/00checkStartup';
|
||||
import '/server/accounts-common';
|
||||
@@ -117,7 +108,6 @@ import '/server/cors';
|
||||
import '/server/header-login';
|
||||
import '/server/max-image-pixel';
|
||||
import '/server/max-size';
|
||||
import '/server/mongodb-driver-startup';
|
||||
import '/server/policy';
|
||||
import '/server/richer-editor-setting-helper';
|
||||
import '/server/saml';
|
||||
@@ -142,6 +132,7 @@ import '/server/cronMigrationManager';
|
||||
// 7. Server — webhooks
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/server/card-opened-webhook';
|
||||
import '/server/cron/syncedCron';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 8. Server — import helpers
|
||||
@@ -235,6 +226,38 @@ import '/server/rulesHelper';
|
||||
import '/server/triggersDef';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 16. Sandstorm integration
|
||||
// 16. Server — collection permissions (allow/deny rules)
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/server/permissions/accessibilitySettings';
|
||||
import '/server/permissions/accountSettings';
|
||||
import '/server/permissions/actions';
|
||||
import '/server/permissions/announcements';
|
||||
import '/server/permissions/attachments';
|
||||
import '/server/permissions/avatars';
|
||||
import '/server/permissions/boards';
|
||||
import '/server/permissions/cardCommentReactions';
|
||||
import '/server/permissions/cardComments';
|
||||
import '/server/permissions/cards';
|
||||
import '/server/permissions/checklistItems';
|
||||
import '/server/permissions/checklists';
|
||||
import '/server/permissions/customFields';
|
||||
import '/server/permissions/integrations';
|
||||
import '/server/permissions/invitationCodes';
|
||||
import '/server/permissions/lists';
|
||||
import '/server/permissions/lockoutSettings';
|
||||
import '/server/permissions/org';
|
||||
import '/server/permissions/rules';
|
||||
import '/server/permissions/settings';
|
||||
import '/server/permissions/swimlanes';
|
||||
import '/server/permissions/tableVisibilityModeSettings';
|
||||
import '/server/permissions/team';
|
||||
import '/server/permissions/translation';
|
||||
import '/server/permissions/triggers';
|
||||
import '/server/permissions/unsavedEdits';
|
||||
import '/server/permissions/userPositionHistory';
|
||||
import '/server/permissions/users';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 17. Sandstorm integration
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/sandstorm';
|
||||
@@ -2,11 +2,24 @@ import { WebApp } from 'meteor/webapp';
|
||||
import { WebAppInternals } from 'meteor/webapp';
|
||||
import Settings from '/models/settings';
|
||||
|
||||
Meteor.startup(() => {
|
||||
// Cache the setting since the boilerplate callback is synchronous
|
||||
let cachedSetting = null;
|
||||
|
||||
Meteor.startup(async () => {
|
||||
// Load initial setting
|
||||
cachedSetting = await Settings.findOneAsync();
|
||||
|
||||
// Keep cache updated reactively
|
||||
Settings.find().observeChanges({
|
||||
added() { Settings.findOneAsync().then(s => { cachedSetting = s; }); },
|
||||
changed() { Settings.findOneAsync().then(s => { cachedSetting = s; }); },
|
||||
removed() { Settings.findOneAsync().then(s => { cachedSetting = s; }); },
|
||||
});
|
||||
|
||||
// Use Meteor's official API to modify the HTML boilerplate
|
||||
WebAppInternals.registerBoilerplateDataCallback('wekan-custom-head', (request, data) => {
|
||||
try {
|
||||
const setting = Settings.findOne();
|
||||
const setting = cachedSetting;
|
||||
|
||||
// Initialize head array if it doesn't exist
|
||||
if (!data.head) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
// Main object for email localization utilities
|
||||
EmailLocalization = {
|
||||
const EmailLocalization = {
|
||||
/**
|
||||
* Send an email using the recipient's preferred language
|
||||
* @param {Object} options - Standard email sending options plus language options
|
||||
@@ -55,4 +55,4 @@ EmailLocalization = {
|
||||
};
|
||||
|
||||
// Add module.exports to make it accessible from other files
|
||||
module.exports = EmailLocalization;
|
||||
export default EmailLocalization;
|
||||
@@ -2,7 +2,7 @@
|
||||
import { expect } from 'chai';
|
||||
import sinon from 'sinon';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import '/models/cards';
|
||||
import Cards from '/models/cards';
|
||||
|
||||
// Helpers to access method handlers
|
||||
const voteHandler = () => Meteor.server.method_handlers['cards.vote'];
|
||||
|
||||
+20
-18
@@ -1,46 +1,48 @@
|
||||
allowIsBoardAdmin = function(userId, board) {
|
||||
import Boards from '/models/boards';
|
||||
|
||||
export function allowIsBoardAdmin(userId, board) {
|
||||
return board && board.hasAdmin(userId);
|
||||
};
|
||||
}
|
||||
|
||||
allowIsBoardMember = function(userId, board) {
|
||||
export function allowIsBoardMember(userId, board) {
|
||||
return board && board.hasMember(userId);
|
||||
};
|
||||
}
|
||||
|
||||
allowIsAnyBoardMember = function(userId, boards) {
|
||||
export function allowIsAnyBoardMember(userId, boards) {
|
||||
return boards.some(board => {
|
||||
return board && board.hasMember(userId);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
allowIsBoardMemberCommentOnly = function(userId, board) {
|
||||
export function allowIsBoardMemberCommentOnly(userId, board) {
|
||||
return board && board.hasMember(userId) && !board.hasReadOnly(userId) && !board.hasReadAssignedOnly(userId) && !board.hasNoComments(userId);
|
||||
};
|
||||
}
|
||||
|
||||
allowIsBoardMemberNoComments = function(userId, board) {
|
||||
export function allowIsBoardMemberNoComments(userId, board) {
|
||||
return board && board.hasMember(userId) && !board.hasNoComments(userId);
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has write access to board (can create/edit cards and lists)
|
||||
allowIsBoardMemberWithWriteAccess = function(userId, board) {
|
||||
export function allowIsBoardMemberWithWriteAccess(userId, board) {
|
||||
return board && board.members && board.members.some(e => e.userId === userId && e.isActive && !e.isNoComments && !e.isCommentOnly && !e.isWorker && !e.isReadOnly && !e.isReadAssignedOnly);
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has write access via a card's board
|
||||
allowIsBoardMemberWithWriteAccessByCard = async function(userId, card) {
|
||||
export async function allowIsBoardMemberWithWriteAccessByCard(userId, card) {
|
||||
const board = card && await Boards.findOneAsync(card.boardId);
|
||||
return allowIsBoardMemberWithWriteAccess(userId, board);
|
||||
};
|
||||
}
|
||||
|
||||
allowIsBoardMemberByCard = async function(userId, card) {
|
||||
export async function allowIsBoardMemberByCard(userId, card) {
|
||||
const board = card && await Boards.findOneAsync(card.boardId);
|
||||
return board && board.hasMember(userId);
|
||||
};
|
||||
}
|
||||
|
||||
// Policy: can a user update a board's 'sort' field?
|
||||
// Requirements:
|
||||
// - user must be authenticated
|
||||
// - update must include 'sort' field
|
||||
// - user must be a member of the board
|
||||
canUpdateBoardSort = function(userId, board, fieldNames) {
|
||||
export function canUpdateBoardSort(userId, board, fieldNames) {
|
||||
return !!userId && (fieldNames || []).includes('sort') && allowIsBoardMember(userId, board);
|
||||
};
|
||||
}
|
||||
+6
-10
@@ -1,18 +1,14 @@
|
||||
// ============================================================================
|
||||
// WeKan Server Entry Point
|
||||
//
|
||||
// CRITICAL: This file uses require() — NOT import.
|
||||
// With ES module imports, ALL import statements are hoisted and evaluated
|
||||
// in dependency-graph order (not textual order). This means we cannot
|
||||
// guarantee that collection2/SimpleSchema load before model files.
|
||||
//
|
||||
// With require(), each call completes synchronously before the next:
|
||||
// 1. collectionHelpers → loads collection2, SimpleSchema, helpers shim
|
||||
// 2. App code → all models can safely use attachSchema/helpers
|
||||
// Uses require() to guarantee bootstrap runs before any model code.
|
||||
// ============================================================================
|
||||
|
||||
// 1. Bootstrap — MUST complete before any model code runs
|
||||
// 1. Helpers polyfill (server no-op for .helpers())
|
||||
require('/imports/collectionHelpers');
|
||||
|
||||
// 2. Load all application code
|
||||
// 2. Collection2 (server-only)
|
||||
require('meteor/aldeed:collection2');
|
||||
|
||||
// 3. Load all application code
|
||||
require('/server/imports');
|
||||
@@ -24,13 +24,13 @@ Meteor.methods({
|
||||
console.log('Starting duplicate lists fix for all boards...');
|
||||
}
|
||||
|
||||
const allBoards = Boards.find({}).fetch();
|
||||
const allBoards = await Boards.find({}).fetchAsync();
|
||||
let totalFixed = 0;
|
||||
let totalBoardsProcessed = 0;
|
||||
|
||||
for (const board of allBoards) {
|
||||
try {
|
||||
const result = fixDuplicateListsForBoard(board._id);
|
||||
const result = await fixDuplicateListsForBoard(board._id);
|
||||
totalFixed += result.fixed;
|
||||
totalBoardsProcessed++;
|
||||
|
||||
@@ -65,21 +65,21 @@ Meteor.methods({
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
return fixDuplicateListsForBoard(boardId);
|
||||
return await fixDuplicateListsForBoard(boardId);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions defined outside of Meteor.methods
|
||||
function fixDuplicateListsForBoard(boardId) {
|
||||
async function fixDuplicateListsForBoard(boardId) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log(`Fixing duplicate lists for board ${boardId}...`);
|
||||
}
|
||||
|
||||
// First, fix duplicate swimlanes
|
||||
const swimlaneResult = fixDuplicateSwimlanes(boardId);
|
||||
const swimlaneResult = await fixDuplicateSwimlanes(boardId);
|
||||
|
||||
// Then, fix duplicate lists
|
||||
const listResult = fixDuplicateLists(boardId);
|
||||
const listResult = await fixDuplicateLists(boardId);
|
||||
|
||||
return {
|
||||
boardId,
|
||||
@@ -90,8 +90,8 @@ function fixDuplicateListsForBoard(boardId) {
|
||||
}
|
||||
|
||||
// Helper functions defined outside of Meteor.methods
|
||||
function fixDuplicateSwimlanes(boardId) {
|
||||
const swimlanes = Swimlanes.find({ boardId }).fetch();
|
||||
async function fixDuplicateSwimlanes(boardId) {
|
||||
const swimlanes = await Swimlanes.find({ boardId }).fetchAsync();
|
||||
const swimlaneGroups = {};
|
||||
let fixed = 0;
|
||||
|
||||
@@ -105,7 +105,7 @@ function fixDuplicateSwimlanes(boardId) {
|
||||
});
|
||||
|
||||
// For each group with duplicates, keep the oldest and remove the rest
|
||||
Object.keys(swimlaneGroups).forEach(title => {
|
||||
for (const title of Object.keys(swimlaneGroups)) {
|
||||
const group = swimlaneGroups[title];
|
||||
if (group.length > 1) {
|
||||
// Sort by creation date, keep the oldest
|
||||
@@ -118,11 +118,11 @@ function fixDuplicateSwimlanes(boardId) {
|
||||
}
|
||||
|
||||
// Move all lists from duplicate swimlanes to the kept swimlane
|
||||
removeSwimlanes.forEach(swimlane => {
|
||||
const lists = Lists.find({ swimlaneId: swimlane._id }).fetch();
|
||||
lists.forEach(list => {
|
||||
for (const swimlane of removeSwimlanes) {
|
||||
const lists = await Lists.find({ swimlaneId: swimlane._id }).fetchAsync();
|
||||
for (const list of lists) {
|
||||
// Check if a list with the same title already exists in the kept swimlane
|
||||
const existingList = Lists.findOne({
|
||||
const existingList = await Lists.findOneAsync({
|
||||
boardId,
|
||||
swimlaneId: keepSwimlane._id,
|
||||
title: list.title
|
||||
@@ -130,37 +130,37 @@ function fixDuplicateSwimlanes(boardId) {
|
||||
|
||||
if (existingList) {
|
||||
// Move cards to existing list
|
||||
Cards.update(
|
||||
await Cards.updateAsync(
|
||||
{ listId: list._id },
|
||||
{ $set: { listId: existingList._id } },
|
||||
{ multi: true }
|
||||
);
|
||||
// Remove duplicate list
|
||||
Lists.remove(list._id);
|
||||
await Lists.removeAsync(list._id);
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log(`Moved cards from duplicate list "${list.title}" to existing list in kept swimlane`);
|
||||
}
|
||||
} else {
|
||||
// Move list to kept swimlane
|
||||
Lists.update(list._id, { $set: { swimlaneId: keepSwimlane._id } });
|
||||
await Lists.updateAsync(list._id, { $set: { swimlaneId: keepSwimlane._id } });
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log(`Moved list "${list.title}" to kept swimlane`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove duplicate swimlane
|
||||
Swimlanes.remove(swimlane._id);
|
||||
await Swimlanes.removeAsync(swimlane._id);
|
||||
fixed++;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { fixed };
|
||||
}
|
||||
|
||||
function fixDuplicateLists(boardId) {
|
||||
const lists = Lists.find({ boardId }).fetch();
|
||||
async function fixDuplicateLists(boardId) {
|
||||
const lists = await Lists.find({ boardId }).fetchAsync();
|
||||
const listGroups = {};
|
||||
let fixed = 0;
|
||||
|
||||
@@ -174,7 +174,7 @@ function fixDuplicateLists(boardId) {
|
||||
});
|
||||
|
||||
// For each group with duplicates, keep the oldest and remove the rest
|
||||
Object.keys(listGroups).forEach(key => {
|
||||
for (const key of Object.keys(listGroups)) {
|
||||
const group = listGroups[key];
|
||||
if (group.length > 1) {
|
||||
// Sort by creation date, keep the oldest
|
||||
@@ -187,22 +187,22 @@ function fixDuplicateLists(boardId) {
|
||||
}
|
||||
|
||||
// Move all cards from duplicate lists to the kept list
|
||||
removeLists.forEach(list => {
|
||||
Cards.update(
|
||||
for (const list of removeLists) {
|
||||
await Cards.updateAsync(
|
||||
{ listId: list._id },
|
||||
{ $set: { listId: keepList._id } },
|
||||
{ multi: true }
|
||||
);
|
||||
|
||||
// Remove duplicate list
|
||||
Lists.remove(list._id);
|
||||
await Lists.removeAsync(list._id);
|
||||
fixed++;
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log(`Moved cards from duplicate list "${list.title}" to kept list`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { fixed };
|
||||
}
|
||||
@@ -217,12 +217,12 @@ Meteor.methods({
|
||||
throw new Meteor.Error('not-authorized', 'Admin required');
|
||||
}
|
||||
|
||||
const allBoards = Boards.find({}).fetch();
|
||||
const allBoards = await Boards.find({}).fetchAsync();
|
||||
const report = [];
|
||||
|
||||
for (const board of allBoards) {
|
||||
const swimlanes = Swimlanes.find({ boardId: board._id }).fetch();
|
||||
const lists = Lists.find({ boardId: board._id }).fetch();
|
||||
const swimlanes = await Swimlanes.find({ boardId: board._id }).fetchAsync();
|
||||
const lists = await Lists.find({ boardId: board._id }).fetchAsync();
|
||||
|
||||
// Check for duplicate swimlanes
|
||||
const swimlaneGroups = {};
|
||||
@@ -266,4 +266,3 @@ Meteor.methods({
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
// Method to find locked users and release them if needed
|
||||
Meteor.methods({
|
||||
async getLockedUsers() {
|
||||
// Check if user has admin rights
|
||||
const userId = Meteor.userId();
|
||||
const userId = this.userId;
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
@@ -52,7 +53,7 @@ Meteor.methods({
|
||||
|
||||
async unlockUser(userId) {
|
||||
// Check if user has admin rights
|
||||
const adminId = Meteor.userId();
|
||||
const adminId = this.userId;
|
||||
if (!adminId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
@@ -82,7 +83,7 @@ Meteor.methods({
|
||||
|
||||
async unlockAllUsers() {
|
||||
// Check if user has admin rights
|
||||
const adminId = Meteor.userId();
|
||||
const adminId = this.userId;
|
||||
if (!adminId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { AccountsLockout } from 'meteor/wekan-accounts-lockout';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import LockoutSettings from '/models/lockoutSettings';
|
||||
@@ -5,7 +6,7 @@ import LockoutSettings from '/models/lockoutSettings';
|
||||
Meteor.methods({
|
||||
async reloadAccountsLockout() {
|
||||
// Check if user has admin rights
|
||||
const userId = Meteor.userId();
|
||||
const userId = this.userId;
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import Boards from '/models/boards';
|
||||
import Lists from '/models/lists';
|
||||
import Cards from '/models/cards';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import Users from '/models/users';
|
||||
import Attachments from '/models/attachments';
|
||||
import { generateUniversalAttachmentUrl, isUniversalFileUrl } from '/models/lib/universalUrlGenerator';
|
||||
|
||||
@@ -261,7 +262,7 @@ class ComprehensiveBoardMigration {
|
||||
});
|
||||
|
||||
// Mark board as processed
|
||||
Boards.update(boardId, {
|
||||
await Boards.updateAsync(boardId, {
|
||||
$set: {
|
||||
comprehensiveMigrationCompleted: true,
|
||||
comprehensiveMigrationCompletedAt: new Date(),
|
||||
@@ -329,7 +330,7 @@ class ComprehensiveBoardMigration {
|
||||
|
||||
if (!defaultList) {
|
||||
// Create a default list for this swimlane
|
||||
const newListId = Lists.insert({
|
||||
const newListId = await Lists.insertAsync({
|
||||
title: 'Default',
|
||||
boardId: boardId,
|
||||
swimlaneId: swimlaneId,
|
||||
@@ -347,7 +348,7 @@ class ComprehensiveBoardMigration {
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: {
|
||||
...updates,
|
||||
modifiedAt: new Date()
|
||||
@@ -449,14 +450,14 @@ class ComprehensiveBoardMigration {
|
||||
if (originalList.collapsed) newListData.collapsed = originalList.collapsed;
|
||||
|
||||
// Insert the new list
|
||||
const newListId = Lists.insert(newListData);
|
||||
const newListId = await Lists.insertAsync(newListData);
|
||||
targetList = { _id: newListId, ...newListData };
|
||||
listsCreated++;
|
||||
}
|
||||
|
||||
// Update all cards in this group to use the correct listId
|
||||
for (const card of cardsInList) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: {
|
||||
listId: targetList._id,
|
||||
modifiedAt: new Date()
|
||||
@@ -484,7 +485,7 @@ class ComprehensiveBoardMigration {
|
||||
for (const list of lists) {
|
||||
if (!list.swimlaneId || list.swimlaneId === '') {
|
||||
// Assign to default swimlane
|
||||
Lists.update(list._id, {
|
||||
await Lists.updateAsync(list._id, {
|
||||
$set: {
|
||||
swimlaneId: defaultSwimlane._id,
|
||||
modifiedAt: new Date()
|
||||
@@ -511,7 +512,7 @@ class ComprehensiveBoardMigration {
|
||||
|
||||
if (listCards.length === 0) {
|
||||
// Remove empty list
|
||||
Lists.remove(list._id);
|
||||
await Lists.removeAsync(list._id);
|
||||
listsRemoved++;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
@@ -591,7 +592,7 @@ class ComprehensiveBoardMigration {
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update user's avatar URL
|
||||
Users.update(user._id, {
|
||||
await Users.updateAsync(user._id, {
|
||||
$set: {
|
||||
'profile.avatarUrl': cleanUrl,
|
||||
modifiedAt: new Date()
|
||||
@@ -651,7 +652,7 @@ class ComprehensiveBoardMigration {
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update attachment URL
|
||||
Attachments.update(attachment._id, {
|
||||
await Attachments.updateAsync(attachment._id, {
|
||||
$set: {
|
||||
url: cleanUrl,
|
||||
modifiedAt: new Date()
|
||||
|
||||
@@ -145,7 +145,7 @@ class DeleteDuplicateEmptyListsMigration {
|
||||
|
||||
if (!existingList) {
|
||||
// Create new per-swimlane list
|
||||
const newListId = Lists.insert({
|
||||
const newListId = await Lists.insertAsync({
|
||||
title: sharedList.title,
|
||||
boardId: boardId,
|
||||
swimlaneId: swimlane._id,
|
||||
@@ -157,7 +157,7 @@ class DeleteDuplicateEmptyListsMigration {
|
||||
|
||||
// Move cards to the new list
|
||||
for (const card of swimlaneCards) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: {
|
||||
listId: newListId,
|
||||
swimlaneId: swimlane._id
|
||||
@@ -171,7 +171,7 @@ class DeleteDuplicateEmptyListsMigration {
|
||||
} else {
|
||||
// Move cards to existing per-swimlane list
|
||||
for (const card of swimlaneCards) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: {
|
||||
listId: existingList._id,
|
||||
swimlaneId: swimlane._id
|
||||
@@ -187,7 +187,7 @@ class DeleteDuplicateEmptyListsMigration {
|
||||
}
|
||||
|
||||
// Remove the shared list (now that all cards are moved)
|
||||
Lists.remove(sharedList._id);
|
||||
await Lists.removeAsync(sharedList._id);
|
||||
listsConverted++;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
@@ -253,7 +253,7 @@ class DeleteDuplicateEmptyListsMigration {
|
||||
}
|
||||
|
||||
// All safety checks passed - delete the empty per-swimlane list
|
||||
Lists.remove(list._id);
|
||||
await Lists.removeAsync(list._id);
|
||||
listsDeleted++;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
* This is similar to the existing rescue migration but specifically for swimlaneId validation
|
||||
*/
|
||||
|
||||
import Activities from '/models/activities';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
import Lists from '/models/lists';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
|
||||
// Helper collection to track migrations - must be defined first
|
||||
const Migrations = new Mongo.Collection('migrations');
|
||||
|
||||
@@ -38,19 +44,19 @@ export const MIGRATION_VERSION = 1;
|
||||
/**
|
||||
* Get or create a "Rescued Data" swimlane for a board
|
||||
*/
|
||||
function getOrCreateRescuedSwimlane(boardId) {
|
||||
const board = Boards.findOne(boardId);
|
||||
async function getOrCreateRescuedSwimlane(boardId) {
|
||||
const board = await Boards.findOneAsync(boardId);
|
||||
if (!board) return null;
|
||||
|
||||
// Look for existing rescued data swimlane
|
||||
let rescuedSwimlane = Swimlanes.findOne({
|
||||
let rescuedSwimlane = await Swimlanes.findOneAsync({
|
||||
boardId,
|
||||
title: { $regex: /rescued.*data/i },
|
||||
});
|
||||
|
||||
if (!rescuedSwimlane) {
|
||||
// Create a new rescued data swimlane
|
||||
const swimlaneId = Swimlanes.insert({
|
||||
const swimlaneId = await Swimlanes.insertAsync({
|
||||
title: 'Rescued Data (Missing Swimlane)',
|
||||
boardId,
|
||||
archived: false,
|
||||
@@ -59,9 +65,9 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
rescuedSwimlane = Swimlanes.findOne(swimlaneId);
|
||||
rescuedSwimlane = await Swimlanes.findOneAsync(swimlaneId);
|
||||
|
||||
Activities.insert({
|
||||
await Activities.insertAsync({
|
||||
userId: 'migration',
|
||||
type: 'swimlane',
|
||||
activityType: 'createSwimlane',
|
||||
@@ -77,29 +83,29 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
/**
|
||||
* Validate and fix cards without valid swimlaneId
|
||||
*/
|
||||
function fixCardsWithoutSwimlaneId() {
|
||||
async function fixCardsWithoutSwimlaneId() {
|
||||
let fixedCount = 0;
|
||||
let rescuedCount = 0;
|
||||
|
||||
const cardsWithoutSwimlane = Cards.find({
|
||||
const cardsWithoutSwimlane = await Cards.find({
|
||||
$or: [
|
||||
{ swimlaneId: { $exists: false } },
|
||||
{ swimlaneId: null },
|
||||
{ swimlaneId: '' },
|
||||
],
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
|
||||
console.log(`Found ${cardsWithoutSwimlane.length} cards without swimlaneId`);
|
||||
|
||||
cardsWithoutSwimlane.forEach(card => {
|
||||
const board = Boards.findOne(card.boardId);
|
||||
for (const card of cardsWithoutSwimlane) {
|
||||
const board = await Boards.findOneAsync(card.boardId);
|
||||
if (!board) {
|
||||
console.warn(`Card ${card._id} has invalid boardId: ${card.boardId}`);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to get default swimlane
|
||||
let defaultSwimlane = Swimlanes.findOne({
|
||||
let defaultSwimlane = await Swimlanes.findOneAsync({
|
||||
boardId: card.boardId,
|
||||
type: { $ne: 'template-swimlane' },
|
||||
archived: false,
|
||||
@@ -107,25 +113,25 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
|
||||
if (!defaultSwimlane) {
|
||||
// No swimlanes at all - create default
|
||||
const swimlaneId = Swimlanes.insert({
|
||||
const swimlaneId = await Swimlanes.insertAsync({
|
||||
title: 'Default',
|
||||
boardId: card.boardId,
|
||||
archived: false,
|
||||
sort: 0,
|
||||
type: 'swimlane',
|
||||
});
|
||||
defaultSwimlane = Swimlanes.findOne(swimlaneId);
|
||||
defaultSwimlane = await Swimlanes.findOneAsync(swimlaneId);
|
||||
}
|
||||
|
||||
if (defaultSwimlane) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: { swimlaneId: defaultSwimlane._id },
|
||||
});
|
||||
fixedCount++;
|
||||
} else {
|
||||
console.warn(`Could not find or create default swimlane for card ${card._id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { fixedCount, rescuedCount };
|
||||
}
|
||||
@@ -133,26 +139,26 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
/**
|
||||
* Validate and fix lists without valid swimlaneId
|
||||
*/
|
||||
function fixListsWithoutSwimlaneId() {
|
||||
async function fixListsWithoutSwimlaneId() {
|
||||
let fixedCount = 0;
|
||||
|
||||
const listsWithoutSwimlane = Lists.find({
|
||||
const listsWithoutSwimlane = await Lists.find({
|
||||
$or: [
|
||||
{ swimlaneId: { $exists: false } },
|
||||
{ swimlaneId: null },
|
||||
],
|
||||
}).fetch();
|
||||
}).fetchAsync();
|
||||
|
||||
console.log(`Found ${listsWithoutSwimlane.length} lists without swimlaneId`);
|
||||
|
||||
listsWithoutSwimlane.forEach(list => {
|
||||
for (const list of listsWithoutSwimlane) {
|
||||
// Set to empty string for backward compatibility
|
||||
// (lists can be shared across swimlanes)
|
||||
Lists.update(list._id, {
|
||||
await Lists.updateAsync(list._id, {
|
||||
$set: { swimlaneId: '' },
|
||||
});
|
||||
fixedCount++;
|
||||
});
|
||||
}
|
||||
|
||||
return { fixedCount };
|
||||
}
|
||||
@@ -160,27 +166,27 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
/**
|
||||
* Find and rescue orphaned cards (swimlaneId points to non-existent swimlane)
|
||||
*/
|
||||
function rescueOrphanedCards() {
|
||||
async function rescueOrphanedCards() {
|
||||
let rescuedCount = 0;
|
||||
|
||||
const allCards = Cards.find({}).fetch();
|
||||
const allCards = await Cards.find({}).fetchAsync();
|
||||
|
||||
allCards.forEach(card => {
|
||||
for (const card of allCards) {
|
||||
if (!card.swimlaneId) return; // Handled by fixCardsWithoutSwimlaneId
|
||||
|
||||
// Check if swimlane exists
|
||||
const swimlane = Swimlanes.findOne(card.swimlaneId);
|
||||
const swimlane = await Swimlanes.findOneAsync(card.swimlaneId);
|
||||
if (!swimlane) {
|
||||
// Orphaned card - swimlane doesn't exist
|
||||
const rescuedSwimlane = getOrCreateRescuedSwimlane(card.boardId);
|
||||
const rescuedSwimlane = await getOrCreateRescuedSwimlane(card.boardId);
|
||||
|
||||
if (rescuedSwimlane) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: { swimlaneId: rescuedSwimlane._id },
|
||||
});
|
||||
rescuedCount++;
|
||||
|
||||
Activities.insert({
|
||||
await Activities.insertAsync({
|
||||
userId: 'migration',
|
||||
type: 'card',
|
||||
activityType: 'moveCard',
|
||||
@@ -192,7 +198,7 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { rescuedCount };
|
||||
}
|
||||
@@ -203,11 +209,11 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
*/
|
||||
function addSwimlaneIdValidationHooks() {
|
||||
// Card insert hook
|
||||
Cards.before.insert(function(userId, doc) {
|
||||
Cards.before.insert(async function(userId, doc) {
|
||||
if (!doc.swimlaneId) {
|
||||
const board = Boards.findOne(doc.boardId);
|
||||
const board = await Boards.findOneAsync(doc.boardId);
|
||||
if (board) {
|
||||
const defaultSwimlane = Swimlanes.findOne({
|
||||
const defaultSwimlane = await Swimlanes.findOneAsync({
|
||||
boardId: doc.boardId,
|
||||
type: { $ne: 'template-swimlane' },
|
||||
archived: false,
|
||||
@@ -217,7 +223,7 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
doc.swimlaneId = defaultSwimlane._id;
|
||||
} else {
|
||||
console.warn('No default swimlane found for new card, creating one');
|
||||
const swimlaneId = Swimlanes.insert({
|
||||
const swimlaneId = await Swimlanes.insertAsync({
|
||||
title: 'Default',
|
||||
boardId: doc.boardId,
|
||||
archived: false,
|
||||
@@ -231,14 +237,14 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
});
|
||||
|
||||
// Card update hook - ensure swimlaneId is never removed
|
||||
Cards.before.update(function(userId, doc, fieldNames, modifier) {
|
||||
Cards.before.update(async function(userId, doc, fieldNames, modifier) {
|
||||
if (modifier.$unset && modifier.$unset.swimlaneId) {
|
||||
delete modifier.$unset.swimlaneId;
|
||||
console.warn('Prevented removal of swimlaneId from card', doc._id);
|
||||
}
|
||||
|
||||
if (modifier.$set && modifier.$set.swimlaneId === null) {
|
||||
const defaultSwimlane = Swimlanes.findOne({
|
||||
const defaultSwimlane = await Swimlanes.findOneAsync({
|
||||
boardId: doc.boardId,
|
||||
type: { $ne: 'template-swimlane' },
|
||||
archived: false,
|
||||
@@ -252,8 +258,8 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
}
|
||||
|
||||
// Exported function to run the migration from cron
|
||||
export function runEnsureValidSwimlaneIdsMigration() {
|
||||
const existingMigration = Migrations.findOne({ name: MIGRATION_NAME });
|
||||
export async function runEnsureValidSwimlaneIdsMigration() {
|
||||
const existingMigration = await Migrations.findOneAsync({ name: MIGRATION_NAME });
|
||||
if (existingMigration && existingMigration.version >= MIGRATION_VERSION) {
|
||||
console.log(`Migration ${MIGRATION_NAME} already completed`);
|
||||
return { alreadyCompleted: true, ...existingMigration.results };
|
||||
@@ -263,9 +269,9 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
|
||||
try {
|
||||
// Run all fix operations
|
||||
const cardResults = fixCardsWithoutSwimlaneId();
|
||||
const listResults = fixListsWithoutSwimlaneId();
|
||||
const rescueResults = rescueOrphanedCards();
|
||||
const cardResults = await fixCardsWithoutSwimlaneId();
|
||||
const listResults = await fixListsWithoutSwimlaneId();
|
||||
const rescueResults = await rescueOrphanedCards();
|
||||
|
||||
console.log('Migration results:');
|
||||
console.log(`- Fixed ${cardResults.fixedCount} cards without swimlaneId`);
|
||||
@@ -273,7 +279,7 @@ function getOrCreateRescuedSwimlane(boardId) {
|
||||
console.log(`- Rescued ${rescueResults.rescuedCount} orphaned cards`);
|
||||
|
||||
// Record migration completion
|
||||
Migrations.upsert(
|
||||
await Migrations.upsertAsync(
|
||||
{ name: MIGRATION_NAME },
|
||||
{
|
||||
$set: {
|
||||
|
||||
@@ -162,7 +162,7 @@ class FixAllFileUrlsMigration {
|
||||
|
||||
if (cleanUrl && cleanUrl !== avatarUrl) {
|
||||
// Update user's avatar URL
|
||||
Users.update(user._id, {
|
||||
await Users.updateAsync(user._id, {
|
||||
$set: {
|
||||
'profile.avatarUrl': cleanUrl,
|
||||
modifiedAt: new Date()
|
||||
@@ -203,7 +203,7 @@ class FixAllFileUrlsMigration {
|
||||
|
||||
if (cleanUrl && cleanUrl !== attachment.url) {
|
||||
// Update attachment URL
|
||||
Attachments.update(attachment._id, {
|
||||
await Attachments.updateAsync(attachment._id, {
|
||||
$set: {
|
||||
url: cleanUrl,
|
||||
modifiedAt: new Date()
|
||||
@@ -242,7 +242,7 @@ class FixAllFileUrlsMigration {
|
||||
|
||||
if (cleanUrl && cleanUrl !== attachment.url) {
|
||||
// Update attachment with fixed URL
|
||||
Attachments.update(attachment._id, {
|
||||
await Attachments.updateAsync(attachment._id, {
|
||||
$set: {
|
||||
url: cleanUrl,
|
||||
modifiedAt: new Date()
|
||||
|
||||
@@ -100,7 +100,7 @@ class FixAvatarUrlsMigration {
|
||||
|
||||
if (needsUpdate) {
|
||||
// Update user's avatar URL
|
||||
Users.update(user._id, {
|
||||
await Users.updateAsync(user._id, {
|
||||
$set: {
|
||||
'profile.avatarUrl': cleanUrl,
|
||||
modifiedAt: new Date()
|
||||
|
||||
@@ -165,7 +165,7 @@ class FixMissingListsMigration {
|
||||
if (originalList.collapsed) newListData.collapsed = originalList.collapsed;
|
||||
|
||||
// Insert the new list
|
||||
const newListId = Lists.insert(newListData);
|
||||
const newListId = await Lists.insertAsync(newListData);
|
||||
targetList = { _id: newListId, ...newListData };
|
||||
createdLists++;
|
||||
|
||||
@@ -176,7 +176,7 @@ class FixMissingListsMigration {
|
||||
|
||||
// Update all cards in this group to use the correct listId
|
||||
for (const card of cardsInList) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: {
|
||||
listId: targetList._id,
|
||||
modifiedAt: new Date()
|
||||
@@ -188,7 +188,7 @@ class FixMissingListsMigration {
|
||||
}
|
||||
|
||||
// Mark board as processed
|
||||
Boards.update(boardId, {
|
||||
await Boards.updateAsync(boardId, {
|
||||
$set: {
|
||||
fixMissingListsCompleted: true,
|
||||
fixMissingListsCompletedAt: new Date()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { getOldAttachmentData, getOldAttachmentDataBuffer } from '/models/lib/attachmentBackwardCompatibility';
|
||||
import Attachments from '/models/attachments';
|
||||
|
||||
/**
|
||||
* Migration script to convert old CollectionFS attachments to new Meteor-Files structure
|
||||
@@ -43,7 +44,7 @@ if (Meteor.isServer) {
|
||||
type: oldAttachment.type
|
||||
});
|
||||
|
||||
const uploader = Attachments.insert({
|
||||
const uploader = await Attachments.insertAsync({
|
||||
file: fileObj,
|
||||
meta: oldAttachment.meta,
|
||||
isBase64: false,
|
||||
@@ -88,7 +89,7 @@ if (Meteor.isServer) {
|
||||
const oldAttachments = await ReactiveCache.getAttachments({ 'meta.cardId': cardId });
|
||||
|
||||
for (const attachment of oldAttachments) {
|
||||
const result = Meteor.call('migrateAttachment', attachment._id);
|
||||
const result = await Meteor.callAsync('migrateAttachment', attachment._id);
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,11 @@ import Lists from '/models/lists';
|
||||
import Cards from '/models/cards';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
|
||||
function getTranslatedString(key, fallback, options) {
|
||||
const translated = TAPi18n.__(key, options);
|
||||
return typeof translated === 'string' ? translated : fallback;
|
||||
}
|
||||
|
||||
class RestoreAllArchivedMigration {
|
||||
constructor() {
|
||||
this.name = 'restoreAllArchived';
|
||||
@@ -66,7 +71,7 @@ class RestoreAllArchivedMigration {
|
||||
|
||||
// Restore all archived swimlanes
|
||||
for (const swimlane of archivedSwimlanes) {
|
||||
Swimlanes.update(swimlane._id, {
|
||||
await Swimlanes.updateAsync(swimlane._id, {
|
||||
$set: {
|
||||
archived: false,
|
||||
updatedAt: new Date()
|
||||
@@ -93,8 +98,8 @@ class RestoreAllArchivedMigration {
|
||||
|
||||
if (!targetSwimlane) {
|
||||
// No active swimlane found, create default
|
||||
const swimlaneId = Swimlanes.insert({
|
||||
title: TAPi18n.__('default'),
|
||||
const swimlaneId = await Swimlanes.insertAsync({
|
||||
title: getTranslatedString('default', 'Default'),
|
||||
boardId: boardId,
|
||||
sort: 0,
|
||||
createdAt: new Date(),
|
||||
@@ -112,7 +117,7 @@ class RestoreAllArchivedMigration {
|
||||
}
|
||||
}
|
||||
|
||||
Lists.update(list._id, {
|
||||
await Lists.updateAsync(list._id, {
|
||||
$set: updateFields
|
||||
});
|
||||
results.listsRestored++;
|
||||
@@ -144,8 +149,8 @@ class RestoreAllArchivedMigration {
|
||||
// No active list found, create one
|
||||
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
|
||||
|
||||
const listId = Lists.insert({
|
||||
title: TAPi18n.__('default'),
|
||||
const listId = await Lists.insertAsync({
|
||||
title: getTranslatedString('default', 'Default'),
|
||||
boardId: boardId,
|
||||
swimlaneId: defaultSwimlane._id,
|
||||
sort: 0,
|
||||
@@ -188,7 +193,7 @@ class RestoreAllArchivedMigration {
|
||||
}
|
||||
}
|
||||
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: updateFields
|
||||
});
|
||||
results.cardsRestored++;
|
||||
|
||||
@@ -107,7 +107,7 @@ class RestoreLostCardsMigration {
|
||||
// Find or create "Lost Cards" swimlane (only if there is actual work)
|
||||
let lostCardsSwimlane = swimlanes.find(s => s.title === TAPi18n.__('lost-cards'));
|
||||
if (!lostCardsSwimlane) {
|
||||
const swimlaneId = Swimlanes.insert({
|
||||
const swimlaneId = await Swimlanes.insertAsync({
|
||||
title: TAPi18n.__('lost-cards'),
|
||||
boardId: boardId,
|
||||
sort: 999999, // Put at the end
|
||||
@@ -126,7 +126,7 @@ class RestoreLostCardsMigration {
|
||||
// Restore lost lists (lists without swimlaneId)
|
||||
if (hasListsWork) {
|
||||
for (const list of lostLists) {
|
||||
Lists.update(list._id, {
|
||||
await Lists.updateAsync(list._id, {
|
||||
$set: {
|
||||
swimlaneId: lostCardsSwimlane._id,
|
||||
updatedAt: new Date()
|
||||
@@ -147,7 +147,7 @@ class RestoreLostCardsMigration {
|
||||
l.title === TAPi18n.__('lost-cards-list')
|
||||
);
|
||||
if (!defaultList) {
|
||||
const listId = Lists.insert({
|
||||
const listId = await Lists.insertAsync({
|
||||
title: TAPi18n.__('lost-cards-list'),
|
||||
boardId: boardId,
|
||||
swimlaneId: lostCardsSwimlane._id,
|
||||
@@ -169,7 +169,7 @@ class RestoreLostCardsMigration {
|
||||
const updateFields = { updatedAt: new Date() };
|
||||
if (!card.swimlaneId) updateFields.swimlaneId = lostCardsSwimlane._id;
|
||||
if (!card.listId) updateFields.listId = defaultList._id;
|
||||
Cards.update(card._id, { $set: updateFields });
|
||||
await Cards.updateAsync(card._id, { $set: updateFields });
|
||||
results.cardsRestored++;
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log(`Restored lost card: ${card.title}`);
|
||||
@@ -178,7 +178,7 @@ class RestoreLostCardsMigration {
|
||||
|
||||
// Restore orphaned cards (cards whose list doesn't exist)
|
||||
for (const card of orphanedCards) {
|
||||
Cards.update(card._id, {
|
||||
await Cards.updateAsync(card._id, {
|
||||
$set: {
|
||||
listId: defaultList._id,
|
||||
swimlaneId: lostCardsSwimlane._id,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import Actions from '/models/actions';
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Actions._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { findWhere, where } from '/imports/lib/collectionHelpers';
|
||||
import Activities from '/models/activities';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
import Integrations from '/models/integrations';
|
||||
import { RulesHelper } from '/server/rulesHelper';
|
||||
import { Notifications } from '/server/notifications/notifications';
|
||||
|
||||
function normalizeActivityText(value, fallback = '') {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function getActivityUserName(user, fallback = '') {
|
||||
if (!user) {
|
||||
return fallback;
|
||||
}
|
||||
return normalizeActivityText(
|
||||
user.getName?.() || user.username || user.profile?.fullname || user.profile?.name,
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
|
||||
Activities.after.insert(async (userId, doc) => {
|
||||
const activity = Activities._transform(doc);
|
||||
try {
|
||||
await RulesHelper.executeRules(activity);
|
||||
} catch (e) {
|
||||
console.error('RulesHelper.executeRules error for activity', doc._id, e);
|
||||
}
|
||||
});
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Activities._collection.createIndexAsync({ createdAt: -1 });
|
||||
await Activities._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await Activities._collection.createIndexAsync({ cardId: 1, createdAt: -1 });
|
||||
await Activities._collection.createIndexAsync({ boardId: 1, createdAt: -1 });
|
||||
await Activities._collection.createIndexAsync(
|
||||
{ commentId: 1 },
|
||||
{ partialFilterExpression: { commentId: { $exists: true } } },
|
||||
);
|
||||
await Activities._collection.createIndexAsync(
|
||||
{ attachmentId: 1 },
|
||||
{ partialFilterExpression: { attachmentId: { $exists: true } } },
|
||||
);
|
||||
await Activities._collection.createIndexAsync(
|
||||
{ customFieldId: 1 },
|
||||
{ partialFilterExpression: { customFieldId: { $exists: true } } },
|
||||
);
|
||||
});
|
||||
|
||||
Activities.after.insert(async (userId, doc) => {
|
||||
const activity = Activities._transform(doc);
|
||||
let participants = [];
|
||||
let watchers = [];
|
||||
let title = 'act-activity-notify';
|
||||
const board = activity.boardId
|
||||
? (await ReactiveCache.getBoard(activity.boardId)) || (await Boards.findOneAsync(activity.boardId))
|
||||
: null;
|
||||
const description = `act-${activity.activityType}`;
|
||||
const params = {
|
||||
activityId: activity._id,
|
||||
};
|
||||
|
||||
if (activity.userId) {
|
||||
const user = await activity.user();
|
||||
if (user) {
|
||||
params.user = getActivityUserName(user, activity.userId);
|
||||
if (user.emails) {
|
||||
params.userEmails = user.emails;
|
||||
}
|
||||
params.userId = activity.userId;
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.boardId) {
|
||||
params.board = normalizeActivityText(board?.title);
|
||||
title = 'act-withBoardTitle';
|
||||
if (board && typeof board.absoluteUrl === 'function') {
|
||||
params.url = board.absoluteUrl();
|
||||
}
|
||||
params.boardId = activity.boardId;
|
||||
}
|
||||
|
||||
if (activity.oldBoardId) {
|
||||
const oldBoard = await activity.oldBoard();
|
||||
if (oldBoard) {
|
||||
watchers = [...new Set([...watchers, ...(oldBoard.watchers || [])])];
|
||||
params.oldBoard = normalizeActivityText(oldBoard.title);
|
||||
params.oldBoardId = activity.oldBoardId;
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.memberId) {
|
||||
participants = [...new Set([...participants, activity.memberId])];
|
||||
params.member = getActivityUserName(await activity.member(), activity.memberId);
|
||||
}
|
||||
|
||||
if (activity.listId) {
|
||||
const list = await activity.list();
|
||||
if (list) {
|
||||
if (list.watchers !== undefined) {
|
||||
watchers = [...new Set([...watchers, ...(list.watchers || [])])];
|
||||
}
|
||||
params.list = normalizeActivityText(list.title);
|
||||
params.listId = activity.listId;
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.oldListId) {
|
||||
const oldList = await activity.oldList();
|
||||
if (oldList) {
|
||||
watchers = [...new Set([...watchers, ...(oldList.watchers || [])])];
|
||||
params.oldList = normalizeActivityText(oldList.title);
|
||||
params.oldListId = activity.oldListId;
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.oldSwimlaneId) {
|
||||
const oldSwimlane = await activity.oldSwimlane();
|
||||
if (oldSwimlane) {
|
||||
watchers = [...new Set([...watchers, ...(oldSwimlane.watchers || [])])];
|
||||
params.oldSwimlane = normalizeActivityText(oldSwimlane.title);
|
||||
params.oldSwimlaneId = activity.oldSwimlaneId;
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.cardId) {
|
||||
const card = (await activity.card()) || (await Cards.findOneAsync(activity.cardId));
|
||||
if (card) {
|
||||
participants = [...new Set([...participants, card.userId, ...(card.members || [])])];
|
||||
watchers = [...new Set([...watchers, ...(card.watchers || [])])];
|
||||
params.card = normalizeActivityText(card.title);
|
||||
title = 'act-withCardTitle';
|
||||
if (typeof card.absoluteUrl === 'function') {
|
||||
params.url = card.absoluteUrl();
|
||||
}
|
||||
params.cardId = activity.cardId;
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.swimlaneId) {
|
||||
const swimlane = await activity.swimlane();
|
||||
params.swimlane = normalizeActivityText(swimlane?.title);
|
||||
params.swimlaneId = activity.swimlaneId;
|
||||
}
|
||||
|
||||
if (activity.commentId) {
|
||||
const comment = await activity.comment();
|
||||
params.comment = normalizeActivityText(comment.text);
|
||||
let hasMentions = false;
|
||||
if (board) {
|
||||
const knownUsers = [];
|
||||
for (const member of board.members) {
|
||||
const u = await ReactiveCache.getUser(member.userId);
|
||||
if (u) {
|
||||
member.username = u.username;
|
||||
member.emails = u.emails;
|
||||
}
|
||||
knownUsers.push(member);
|
||||
}
|
||||
|
||||
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.@-]+))/gi;
|
||||
let currentMention;
|
||||
|
||||
while ((currentMention = mentionRegex.exec(params.comment)) !== null) {
|
||||
const [, quoteduser, simple] = currentMention;
|
||||
const username = quoteduser || simple;
|
||||
|
||||
if (activity.boardId && username === 'board_members') {
|
||||
const validUserIds = [];
|
||||
for (const u of knownUsers) {
|
||||
const user = await ReactiveCache.getUser(u.userId);
|
||||
if (user && user._id) {
|
||||
validUserIds.push(u.userId);
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...validUserIds])];
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.boardId && username === 'board_assignees') {
|
||||
const allCards = await ReactiveCache.getCards({ boardId: activity.boardId });
|
||||
const assigneeIds = [];
|
||||
for (const card of allCards) {
|
||||
if (card.assignees && card.assignees.length > 0) {
|
||||
for (const assigneeId of card.assignees) {
|
||||
const user = await ReactiveCache.getUser(assigneeId);
|
||||
if (user && findWhere(knownUsers, { userId: assigneeId })) {
|
||||
assigneeIds.push(assigneeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...assigneeIds])];
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_members') {
|
||||
const card = await activity.card();
|
||||
if (card && card.members && card.members.length > 0) {
|
||||
const validMembers = [];
|
||||
for (const memberId of card.members) {
|
||||
const user = await ReactiveCache.getUser(memberId);
|
||||
if (user && user._id && findWhere(knownUsers, { userId: memberId })) {
|
||||
validMembers.push(memberId);
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...validMembers])];
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_assignees') {
|
||||
const card = await activity.card();
|
||||
if (card && card.assignees && card.assignees.length > 0) {
|
||||
const validAssignees = [];
|
||||
for (const assigneeId of card.assignees) {
|
||||
const user = await ReactiveCache.getUser(assigneeId);
|
||||
if (user && user._id && findWhere(knownUsers, { userId: assigneeId })) {
|
||||
validAssignees.push(assigneeId);
|
||||
}
|
||||
}
|
||||
watchers = [...new Set([...watchers, ...validAssignees])];
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else {
|
||||
const atUser = findWhere(knownUsers, { username });
|
||||
if (!atUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
params.atUsername = username;
|
||||
params.atEmails = atUser.emails;
|
||||
title = 'act-atUserComment';
|
||||
watchers = [...new Set([...watchers, atUser.userId])];
|
||||
hasMentions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
params.commentId = comment._id;
|
||||
params.hasMentions = hasMentions;
|
||||
}
|
||||
|
||||
if (activity.attachmentId) {
|
||||
params.attachment = activity.attachmentName;
|
||||
params.attachmentId = activity.attachmentId;
|
||||
}
|
||||
|
||||
if (activity.checklistId) {
|
||||
const checklist = await activity.checklist();
|
||||
if (checklist?.title) {
|
||||
params.checklist = normalizeActivityText(checklist.title);
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.checklistItemId) {
|
||||
const checklistItem = await activity.checklistItem();
|
||||
if (checklistItem?.title) {
|
||||
params.checklistItem = normalizeActivityText(checklistItem.title);
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.customFieldId) {
|
||||
const customField = await activity.customField();
|
||||
if (customField) {
|
||||
if (customField.name) {
|
||||
params.customField = normalizeActivityText(customField.name);
|
||||
}
|
||||
if (activity.value) {
|
||||
params.customFieldValue = activity.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.labelId) {
|
||||
const label = await activity.label();
|
||||
if (label) {
|
||||
if (label.name) {
|
||||
params.label = normalizeActivityText(label.name);
|
||||
} else if (label.color) {
|
||||
params.label = normalizeActivityText(label.color);
|
||||
}
|
||||
if (label._id) {
|
||||
params.labelId = label._id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((!activity.timeKey || activity.timeKey === 'dueAt') && activity.timeValue) {
|
||||
title = activity.timeOldValue ? 'act-withDue' : 'act-newDue';
|
||||
}
|
||||
|
||||
['timeValue', 'timeOldValue'].forEach((key) => {
|
||||
const value = activity[key];
|
||||
if (value) params[key] = value;
|
||||
});
|
||||
|
||||
if (board) {
|
||||
const activeMemberIds = (board.members || []).filter(m => m.isActive === true).map(m => m.userId);
|
||||
const BIGEVENTS = process.env.BIGEVENTS_PATTERN;
|
||||
if (BIGEVENTS) {
|
||||
try {
|
||||
const atype = activity.activityType;
|
||||
if (new RegExp(BIGEVENTS).exec(atype)) {
|
||||
watchers = [...new Set([...watchers, ...activeMemberIds])];
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const watchingUsers = where(board.watchers, { level: 'watching' }).map(x => x.userId);
|
||||
const trackingUsers = where(board.watchers, { level: 'tracking' }).map(x => x.userId);
|
||||
if (!params.hasMentions) {
|
||||
watchers = [...new Set([
|
||||
...watchers,
|
||||
...watchingUsers,
|
||||
...participants.filter(x => trackingUsers.includes(x)),
|
||||
])];
|
||||
}
|
||||
|
||||
watchers = watchers.filter(x => activeMemberIds.includes(x));
|
||||
}
|
||||
|
||||
(await Notifications.getUsers(watchers)).forEach((user) => {
|
||||
if (!user || !user._id) return;
|
||||
const isSelfMention = user._id === userId && title === 'act-atUserComment';
|
||||
if (user._id !== userId || isSelfMention) {
|
||||
Notifications.notify(user, title, description, params);
|
||||
}
|
||||
});
|
||||
|
||||
const integrations = await ReactiveCache.getIntegrations({
|
||||
boardId: { $in: [board._id, Integrations.Const.GLOBAL_WEBHOOK_ID] },
|
||||
enabled: true,
|
||||
activities: { $in: [description, 'all'] },
|
||||
});
|
||||
if (integrations.length > 0) {
|
||||
params.watchers = watchers;
|
||||
integrations.forEach((integration) => {
|
||||
Meteor.call('outgoingWebhooks', integration, description, params, () => {
|
||||
return;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
|
||||
import {
|
||||
STORAGE_NAME_FILESYSTEM,
|
||||
STORAGE_NAME_GRIDFS,
|
||||
} from '/models/lib/fileStoreConstants';
|
||||
|
||||
Meteor.methods({
|
||||
async getAttachmentStorageSettings() {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||
}
|
||||
|
||||
let settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
if (!settings) {
|
||||
settings = {
|
||||
defaultStorage: STORAGE_NAME_FILESYSTEM,
|
||||
storageConfig: {
|
||||
filesystem: {
|
||||
enabled: true,
|
||||
path: process.env.WRITABLE_PATH ? `${process.env.WRITABLE_PATH}/attachments` : '/data/attachments',
|
||||
},
|
||||
gridfs: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
uploadSettings: {
|
||||
maxFileSize: process.env.ATTACHMENTS_UPLOAD_MAX_SIZE
|
||||
? parseInt(process.env.ATTACHMENTS_UPLOAD_MAX_SIZE) : 0,
|
||||
allowedMimeTypes: process.env.ATTACHMENTS_UPLOAD_MIME_TYPES
|
||||
? process.env.ATTACHMENTS_UPLOAD_MIME_TYPES.split(',').map(t => t.trim()) : [],
|
||||
},
|
||||
migrationSettings: {
|
||||
autoMigrate: false,
|
||||
batchSize: 10,
|
||||
delayMs: 1000,
|
||||
cpuThreshold: 70,
|
||||
},
|
||||
createdBy: this.userId,
|
||||
updatedBy: this.userId,
|
||||
};
|
||||
|
||||
await AttachmentStorageSettings.insertAsync(settings);
|
||||
settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
}
|
||||
|
||||
return settings;
|
||||
},
|
||||
|
||||
async updateAttachmentStorageSettings(settings) {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||
}
|
||||
|
||||
AttachmentStorageSettings.simpleSchema().validate(settings);
|
||||
|
||||
return AttachmentStorageSettings.upsertAsync(
|
||||
{},
|
||||
{
|
||||
$set: {
|
||||
...settings,
|
||||
updatedBy: this.userId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async getDefaultAttachmentStorage() {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const settings = await AttachmentStorageSettings.findOneAsync({});
|
||||
return settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
|
||||
},
|
||||
|
||||
async setDefaultAttachmentStorage(storageName) {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized', 'Must be logged in');
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||
}
|
||||
|
||||
if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS].includes(storageName)) {
|
||||
throw new Meteor.Error('invalid-storage', 'Invalid storage backend');
|
||||
}
|
||||
|
||||
return AttachmentStorageSettings.upsertAsync(
|
||||
{},
|
||||
{
|
||||
$set: {
|
||||
defaultStorage: storageName,
|
||||
updatedBy: this.userId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.publish('attachmentStorageSettings', async function() {
|
||||
if (!this.userId) {
|
||||
return this.ready();
|
||||
}
|
||||
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
return this.ready();
|
||||
}
|
||||
|
||||
return AttachmentStorageSettings.find({});
|
||||
});
|
||||
@@ -0,0 +1,756 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
import { check } from 'meteor/check';
|
||||
import { Random } from 'meteor/random';
|
||||
import { Authentication } from '/server/authentication';
|
||||
import { sendJsonResult } from '/server/apiMiddleware';
|
||||
import { allowIsBoardAdmin } from '/server/lib/utils';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Activities from '/models/activities';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
import Integrations from '/models/integrations';
|
||||
import Lists from '/models/lists';
|
||||
import Rules from '/models/rules';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import Triggers from '/models/triggers';
|
||||
import Users from '/models/users';
|
||||
|
||||
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
|
||||
|
||||
function getTranslatedString(key, fallback, options) {
|
||||
const i18n = getTAPi18n && getTAPi18n();
|
||||
if (!i18n || !i18n.i18n) {
|
||||
return fallback;
|
||||
}
|
||||
const translated = i18n.__(key, options);
|
||||
return typeof translated === 'string' ? translated : fallback;
|
||||
}
|
||||
|
||||
async function boardRemover(doc) {
|
||||
for (const element of [
|
||||
Cards,
|
||||
Lists,
|
||||
Swimlanes,
|
||||
Integrations,
|
||||
Rules,
|
||||
Activities,
|
||||
Triggers,
|
||||
]) {
|
||||
await element.removeAsync({ boardId: doc._id });
|
||||
}
|
||||
}
|
||||
|
||||
const foreachRemovedMember = (doc, modifier, callback) => {
|
||||
Object.keys(modifier).forEach(set => {
|
||||
if (modifier[set] !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = set.split('.');
|
||||
if (
|
||||
parts.length === 3 &&
|
||||
parts[0] === 'members' &&
|
||||
parts[2] === 'isActive'
|
||||
) {
|
||||
callback(doc.members[parts[1]].userId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
async createBoardWithInitialSwimlanes(payload) {
|
||||
check(
|
||||
payload,
|
||||
Match.ObjectIncluding({
|
||||
title: String,
|
||||
slug: String,
|
||||
permission: Match.Maybe(String),
|
||||
type: Match.Maybe(String),
|
||||
migrationVersion: Match.Maybe(Number),
|
||||
swimlanes: Match.Maybe(Array),
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
title,
|
||||
slug,
|
||||
permission = 'private',
|
||||
type = 'board',
|
||||
migrationVersion = 1,
|
||||
swimlanes = [],
|
||||
} = payload;
|
||||
|
||||
for (const swimlane of swimlanes) {
|
||||
check(swimlane, Object);
|
||||
check(swimlane.title, String);
|
||||
check(swimlane.sort, Match.Maybe(Number));
|
||||
check(swimlane.type, Match.Maybe(String));
|
||||
}
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
const boardId = await Boards.insertAsync({
|
||||
title,
|
||||
slug,
|
||||
permission,
|
||||
type,
|
||||
migrationVersion,
|
||||
members: [
|
||||
{
|
||||
userId: this.userId,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
isNoComments: false,
|
||||
isCommentOnly: false,
|
||||
isWorker: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const swimlane of swimlanes) {
|
||||
await Swimlanes.insertAsync({
|
||||
title: swimlane.title,
|
||||
boardId,
|
||||
sort: swimlane.sort,
|
||||
type: swimlane.type,
|
||||
});
|
||||
}
|
||||
|
||||
return boardId;
|
||||
},
|
||||
|
||||
async getBackgroundImageURL(boardId) {
|
||||
check(boardId, String);
|
||||
return await ReactiveCache.getBoard(boardId, {}, { backgroundImageUrl: 1 });
|
||||
},
|
||||
|
||||
async quitBoard(boardId) {
|
||||
check(boardId, String);
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('error-board-doesNotExist');
|
||||
}
|
||||
|
||||
const userId = this.userId;
|
||||
const index = board.memberIndex(userId);
|
||||
if (index < 0) {
|
||||
throw new Meteor.Error('error-board-notAMember');
|
||||
}
|
||||
|
||||
await board.removeMember(userId);
|
||||
return true;
|
||||
},
|
||||
|
||||
async acceptInvite(boardId) {
|
||||
check(boardId, String);
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('error-board-doesNotExist');
|
||||
}
|
||||
|
||||
await Meteor.users.updateAsync(this.userId, {
|
||||
$pull: {
|
||||
'profile.invitedBoards': boardId,
|
||||
},
|
||||
});
|
||||
|
||||
await Boards.updateAsync(
|
||||
{
|
||||
_id: boardId,
|
||||
'members.userId': this.userId,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'members.$.isActive': true,
|
||||
modifiedAt: new Date(),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async myLabelNames() {
|
||||
let names = [];
|
||||
const boards = await Boards.userBoards(this.userId);
|
||||
for (const board of boards) {
|
||||
if (board.labels !== undefined) {
|
||||
names = names.concat(
|
||||
board.labels
|
||||
.filter(label => !!label.name)
|
||||
.map(label => label.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
return [...new Set(names)].sort();
|
||||
},
|
||||
|
||||
async myBoardNames() {
|
||||
const boards = await Boards.userBoards(this.userId);
|
||||
return [...new Set(boards.map(board => board.title))].sort();
|
||||
},
|
||||
|
||||
async setAllBoardsHideActivities() {
|
||||
const currentUser = await ReactiveCache.getCurrentUser();
|
||||
if (!(currentUser || {}).isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Boards.updateAsync(
|
||||
{
|
||||
showActivities: true,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showActivities: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
multi: true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
async archiveBoard(boardId) {
|
||||
check(boardId, String);
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('error-board-doesNotExist');
|
||||
}
|
||||
|
||||
const userId = this.userId;
|
||||
const index = board.memberIndex(userId);
|
||||
if (index < 0) {
|
||||
throw new Meteor.Error('error-board-notAMember');
|
||||
}
|
||||
|
||||
await board.archive();
|
||||
return true;
|
||||
},
|
||||
|
||||
async setBoardOrgs(boardOrgsArray, currBoardId) {
|
||||
check(boardOrgsArray, Array);
|
||||
check(currBoardId, String);
|
||||
|
||||
const userId = this.userId;
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in to perform this action.');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(currBoardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('board-not-found', 'Board not found.');
|
||||
}
|
||||
if (!allowIsBoardAdmin(userId, board)) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be a board admin to perform this action.');
|
||||
}
|
||||
|
||||
for (const org of boardOrgsArray) {
|
||||
check(org.orgId, String);
|
||||
check(org.orgDisplayName, String);
|
||||
check(org.isActive, Boolean);
|
||||
}
|
||||
|
||||
await Boards.updateAsync(currBoardId, {
|
||||
$set: {
|
||||
orgs: boardOrgsArray,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async setBoardTeams(boardTeamsArray, membersArray, currBoardId) {
|
||||
check(boardTeamsArray, Array);
|
||||
check(membersArray, Array);
|
||||
check(currBoardId, String);
|
||||
|
||||
const userId = this.userId;
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be logged in to perform this action.');
|
||||
}
|
||||
|
||||
const board = await ReactiveCache.getBoard(currBoardId);
|
||||
if (!board) {
|
||||
throw new Meteor.Error('board-not-found', 'Board not found.');
|
||||
}
|
||||
if (!allowIsBoardAdmin(userId, board)) {
|
||||
throw new Meteor.Error('not-authorized', 'You must be a board admin to perform this action.');
|
||||
}
|
||||
|
||||
for (const team of boardTeamsArray) {
|
||||
check(team.teamId, String);
|
||||
check(team.teamDisplayName, String);
|
||||
check(team.isActive, Boolean);
|
||||
}
|
||||
|
||||
for (const member of membersArray) {
|
||||
check(member.userId, String);
|
||||
check(member.isAdmin, Boolean);
|
||||
check(member.isActive, Boolean);
|
||||
if (member.isNoComments !== undefined) check(member.isNoComments, Boolean);
|
||||
if (member.isCommentOnly !== undefined) check(member.isCommentOnly, Boolean);
|
||||
if (member.isWorker !== undefined) check(member.isWorker, Boolean);
|
||||
if (member.isNormalAssignedOnly !== undefined) check(member.isNormalAssignedOnly, Boolean);
|
||||
if (member.isCommentAssignedOnly !== undefined) check(member.isCommentAssignedOnly, Boolean);
|
||||
if (member.isReadOnly !== undefined) check(member.isReadOnly, Boolean);
|
||||
if (member.isReadAssignedOnly !== undefined) check(member.isReadAssignedOnly, Boolean);
|
||||
}
|
||||
|
||||
await Boards.updateAsync(currBoardId, {
|
||||
$set: {
|
||||
members: membersArray,
|
||||
teams: boardTeamsArray,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Boards.before.insert(async (userId, doc) => {
|
||||
const lastBoard = await ReactiveCache.getBoard(
|
||||
{ sort: { $exists: true } },
|
||||
{ sort: { sort: -1 } },
|
||||
);
|
||||
if (lastBoard && typeof lastBoard.sort !== 'undefined') {
|
||||
doc.sort = lastBoard.sort + 1;
|
||||
}
|
||||
});
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Boards._collection.createIndexAsync({ modifiedAt: -1 });
|
||||
await Boards._collection.createIndexAsync(
|
||||
{
|
||||
_id: 1,
|
||||
'members.userId': 1,
|
||||
},
|
||||
{ unique: true },
|
||||
);
|
||||
await Boards._collection.createIndexAsync({ 'members.userId': 1 });
|
||||
});
|
||||
|
||||
Boards.after.insert(async (userId, doc) => {
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
type: 'board',
|
||||
activityTypeId: doc._id,
|
||||
activityType: 'createBoard',
|
||||
boardId: doc._id,
|
||||
});
|
||||
});
|
||||
|
||||
Boards.after.update(async (userId, doc, fieldNames, modifier) => {
|
||||
if (
|
||||
!(fieldNames || []).includes('labels') ||
|
||||
!modifier.$pull ||
|
||||
!modifier.$pull.labels ||
|
||||
!modifier.$pull.labels._id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removedLabelId = modifier.$pull.labels._id;
|
||||
await Cards.updateAsync(
|
||||
{ boardId: doc._id },
|
||||
{
|
||||
$pull: {
|
||||
labelIds: removedLabelId,
|
||||
},
|
||||
},
|
||||
{ multi: true },
|
||||
);
|
||||
});
|
||||
|
||||
Boards.before.update((userId, doc, fieldNames, modifier) => {
|
||||
if (!(fieldNames || []).includes('members') || !modifier.$set) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardId = doc._id;
|
||||
foreachRemovedMember(doc, modifier.$set, async memberId => {
|
||||
await Cards.updateAsync(
|
||||
{ boardId },
|
||||
{
|
||||
$pull: {
|
||||
members: memberId,
|
||||
watchers: memberId,
|
||||
},
|
||||
},
|
||||
{ multi: true },
|
||||
);
|
||||
|
||||
await Lists.updateAsync(
|
||||
{ boardId },
|
||||
{
|
||||
$pull: {
|
||||
watchers: memberId,
|
||||
},
|
||||
},
|
||||
{ multi: true },
|
||||
);
|
||||
|
||||
const board = Boards._transform(doc);
|
||||
await board.setWatcher(memberId, false);
|
||||
|
||||
if (!board.isPublic()) {
|
||||
await Users.updateAsync(memberId, {
|
||||
$pull: {
|
||||
'profile.starredBoards': boardId,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Boards.before.remove(async (userId, doc) => {
|
||||
await boardRemover(doc);
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
type: 'board',
|
||||
activityTypeId: doc._id,
|
||||
activityType: 'removeBoard',
|
||||
boardId: doc._id,
|
||||
});
|
||||
});
|
||||
|
||||
Boards.after.update(async (userId, doc, fieldNames, modifier) => {
|
||||
if (fieldNames.includes('title')) {
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
type: 'board',
|
||||
activityType: 'changedBoardTitle',
|
||||
boardId: doc._id,
|
||||
title: doc.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(fieldNames || []).includes('members')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modifier.$push && modifier.$push.members) {
|
||||
const memberId = modifier.$push.members.userId;
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
memberId,
|
||||
type: 'member',
|
||||
activityType: 'addBoardMember',
|
||||
boardId: doc._id,
|
||||
});
|
||||
}
|
||||
|
||||
if (modifier.$set) {
|
||||
const removedMemberIds = [];
|
||||
foreachRemovedMember(doc, modifier.$set, memberId => {
|
||||
removedMemberIds.push(memberId);
|
||||
});
|
||||
|
||||
for (const memberId of removedMemberIds) {
|
||||
await Activities.insertAsync({
|
||||
userId,
|
||||
memberId,
|
||||
type: 'member',
|
||||
activityType: 'removeBoardMember',
|
||||
boardId: doc._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.get('/api/users/:userId/boards', async function(req, res) {
|
||||
try {
|
||||
Authentication.checkLoggedIn(req.userId);
|
||||
const paramUserId = req.params.userId;
|
||||
Authentication.checkAdminOrCondition(req.userId, req.userId === paramUserId);
|
||||
|
||||
const boards = await ReactiveCache.getBoards(
|
||||
{
|
||||
archived: false,
|
||||
'members.userId': paramUserId,
|
||||
},
|
||||
{
|
||||
sort: { sort: 1 },
|
||||
},
|
||||
);
|
||||
const data = boards.map(board => ({
|
||||
_id: board._id,
|
||||
title: board.title,
|
||||
}));
|
||||
|
||||
sendJsonResult(res, { code: 200, data });
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.get('/api/boards', async function(req, res) {
|
||||
try {
|
||||
Authentication.checkUserId(req.userId);
|
||||
const boards = await ReactiveCache.getBoards(
|
||||
{ permission: 'public' },
|
||||
{
|
||||
sort: { sort: 1 },
|
||||
},
|
||||
);
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: boards.map(doc => ({
|
||||
_id: doc._id,
|
||||
title: doc.title,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.get('/api/boards_count', async function(req, res) {
|
||||
try {
|
||||
Authentication.checkUserId(req.userId);
|
||||
const privateBoards = await ReactiveCache.getBoards({ permission: 'private' });
|
||||
const publicBoards = await ReactiveCache.getBoards({ permission: 'public' });
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
private: privateBoards.length,
|
||||
public: publicBoards.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.get('/api/boards/:boardId', async function(req, res) {
|
||||
try {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
|
||||
const board = await ReactiveCache.getBoard(paramBoardId);
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: board,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.post('/api/boards', async function(req, res) {
|
||||
try {
|
||||
Authentication.checkLoggedIn(req.userId);
|
||||
const allowPrivateOnly =
|
||||
(await TableVisibilityModeSettings.findOneAsync('tableVisibilityMode-allowPrivateOnly'))?.booleanValue;
|
||||
const permission = allowPrivateOnly ? 'private' : (req.body.permission || 'private');
|
||||
const id = await Boards.insertAsync({
|
||||
title: req.body.title,
|
||||
members: [
|
||||
{
|
||||
userId: req.body.owner,
|
||||
isAdmin: req.body.isAdmin || true,
|
||||
isActive: req.body.isActive || true,
|
||||
isNoComments: req.body.isNoComments || false,
|
||||
isCommentOnly: req.body.isCommentOnly || false,
|
||||
isWorker: req.body.isWorker || false,
|
||||
},
|
||||
],
|
||||
permission,
|
||||
color: req.body.color || 'belize',
|
||||
migrationVersion: 1,
|
||||
});
|
||||
const swimlaneId = await Swimlanes.insertAsync({
|
||||
title: getTranslatedString('default', 'Default'),
|
||||
boardId: id,
|
||||
});
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
defaultSwimlaneId: swimlaneId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.delete('/api/boards/:boardId', async function(req, res) {
|
||||
try {
|
||||
Authentication.checkUserId(req.userId);
|
||||
const id = req.params.boardId;
|
||||
await Boards.removeAsync({ _id: id });
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.put('/api/boards/:boardId/title', async function(req, res) {
|
||||
try {
|
||||
const boardId = req.params.boardId;
|
||||
Authentication.checkBoardWriteAccess(req.userId, boardId);
|
||||
const title = req.body.title;
|
||||
|
||||
await Boards.direct.updateAsync({ _id: boardId }, { $set: { title } });
|
||||
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: {
|
||||
_id: boardId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.put('/api/boards/:boardId/labels', async function(req, res) {
|
||||
const id = req.params.boardId;
|
||||
Authentication.checkBoardWriteAccess(req.userId, id);
|
||||
try {
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'label')) {
|
||||
const board = await ReactiveCache.getBoard(id);
|
||||
const color = req.body.label.color;
|
||||
const name = req.body.label.name;
|
||||
const labelId = Random.id(6);
|
||||
if (!board.getLabel(name, color)) {
|
||||
await Boards.direct.updateAsync(
|
||||
{ _id: id },
|
||||
{ $push: { labels: { _id: labelId, name, color } } },
|
||||
);
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: labelId,
|
||||
});
|
||||
} else {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.post('/api/boards/:boardId/copy', async function(req, res) {
|
||||
const id = req.params.boardId;
|
||||
const board = await ReactiveCache.getBoard(id);
|
||||
const adminAccess = board.members.some(e => e.userId === req.userId && e.isAdmin);
|
||||
Authentication.checkAdminOrCondition(req.userId, adminAccess);
|
||||
try {
|
||||
board.title = req.body.title || await Boards.uniqueTitle(board.title);
|
||||
const ret = await board.copy();
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: ret,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.post('/api/boards/:boardId/members/:memberId', async function(req, res) {
|
||||
try {
|
||||
Authentication.checkUserId(req.userId);
|
||||
const boardId = req.params.boardId;
|
||||
const memberId = req.params.memberId;
|
||||
const {
|
||||
isAdmin,
|
||||
isNoComments,
|
||||
isCommentOnly,
|
||||
isWorker,
|
||||
isNormalAssignedOnly,
|
||||
isCommentAssignedOnly,
|
||||
isReadOnly,
|
||||
isReadAssignedOnly,
|
||||
} = req.body;
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
|
||||
function isTrue(data) {
|
||||
try {
|
||||
return data.toLowerCase() === 'true';
|
||||
} catch (error) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
const query = await board.setMemberPermission(
|
||||
memberId,
|
||||
isTrue(isAdmin),
|
||||
isTrue(isNoComments),
|
||||
isTrue(isCommentOnly),
|
||||
isTrue(isWorker),
|
||||
isTrue(isNormalAssignedOnly),
|
||||
isTrue(isCommentAssignedOnly),
|
||||
isTrue(isReadOnly),
|
||||
isTrue(isReadAssignedOnly),
|
||||
req.userId,
|
||||
);
|
||||
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: query,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
WebApp.handlers.get('/api/boards/:boardId/attachments', async function(req, res) {
|
||||
const paramBoardId = req.params.boardId;
|
||||
Authentication.checkBoardAccess(req.userId, paramBoardId);
|
||||
const attachments = await ReactiveCache.getAttachments(
|
||||
{ 'meta.boardId': paramBoardId },
|
||||
{},
|
||||
true,
|
||||
);
|
||||
sendJsonResult(res, {
|
||||
code: 200,
|
||||
data: attachments.each().map(attachment => ({
|
||||
attachmentId: attachment._id,
|
||||
attachmentName: attachment.name,
|
||||
attachmentType: attachment.type,
|
||||
url: attachment.link(),
|
||||
urlDownload: `${attachment.link()}?download=true&token=`,
|
||||
boardId: attachment.meta.boardId,
|
||||
swimlaneId: attachment.meta.swimlaneId,
|
||||
listId: attachment.meta.listId,
|
||||
cardId: attachment.meta.cardId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user