import Planka upstream

This commit is contained in:
xiaojibeier
2026-07-18 11:48:22 +08:00
commit c26fd3b190
1564 changed files with 214813 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/* eslint-disable no-await-in-loop */
/* eslint-disable no-console */
const { read } = require('read');
const initKnex = require('knex');
const knexfile = require('./knexfile');
const knex = initKnex(knexfile);
const input = async (fieldName, options = {}) => {
const readOptions = {
prompt: `${fieldName}${!options.isRequired ? ' (optional)' : ''}: `,
};
if (options.isPassword) {
Object.assign(readOptions, {
silent: true,
replace: '*',
});
}
let value;
while (!value) {
value = await read(readOptions);
if (!options.isPassword) {
value = value.trim();
}
if (options.isRequired && !value) {
console.log(`${fieldName} cannot be blank!`);
} else {
break;
}
}
return value;
};
(async () => {
try {
await knex.migrate.latest();
process.env.DEFAULT_ADMIN_EMAIL = await input('Email', {
isRequired: true,
});
process.env.DEFAULT_ADMIN_PASSWORD = await input('Password', {
isRequired: true,
isPassword: true,
});
process.env.DEFAULT_ADMIN_NAME = await input('Name', {
isRequired: true,
});
process.env.DEFAULT_ADMIN_USERNAME = await input('Username');
await knex.seed.run({
specific: 'default.js',
});
} catch (error) {
process.exitCode = 1;
throw error;
} finally {
knex.destroy();
}
})();
+22
View File
@@ -0,0 +1,22 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const initKnex = require('knex');
const knexfile = require('./knexfile');
const knex = initKnex(knexfile);
(async () => {
try {
await knex.migrate.latest();
await knex.seed.run();
} catch (error) {
process.exitCode = 1;
throw error;
} finally {
knex.destroy();
}
})();
+39
View File
@@ -0,0 +1,39 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const path = require('path');
const dotenv = require('dotenv');
const _ = require('lodash');
dotenv.config({
path: path.resolve(__dirname, '../.env'),
quiet: true,
});
function buildSSLConfig() {
if (process.env.KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE === 'false') {
return {
rejectUnauthorized: false,
};
}
return false;
}
module.exports = {
client: 'pg',
connection: {
connectionString: process.env.DATABASE_URL,
ssl: buildSSLConfig(),
},
migrations: {
tableName: 'migration',
directory: path.join(__dirname, 'migrations'),
},
seeds: {
directory: path.join(__dirname, 'seeds'),
},
wrapIdentifier: (value, origImpl) => origImpl(_.snakeCase(value)),
};
@@ -0,0 +1,661 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports.up = async (knex) => {
await knex.raw(`
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE SEQUENCE next_id_seq;
CREATE FUNCTION next_id(OUT id BIGINT) AS $$
DECLARE
shard INT := 1;
epoch BIGINT := 1567191600000;
sequence BIGINT;
milliseconds BIGINT;
BEGIN
SELECT nextval('next_id_seq') % 1024 INTO sequence;
SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO milliseconds;
id := (milliseconds - epoch) << 23;
id := id | (shard << 10);
id := id | (sequence);
END;
$$ LANGUAGE PLPGSQL;
`);
await knex.schema.createTable('file_reference', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.integer('total');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('total');
});
await knex.schema
.createTable('user_account', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.text('email').notNullable();
table.text('password');
table.text('role').notNullable();
table.text('name').notNullable();
table.text('username');
table.jsonb('avatar');
table.text('phone');
table.text('organization');
table.text('language');
table.boolean('subscribe_to_own_cards').notNullable();
table.boolean('subscribe_to_card_when_commenting').notNullable();
table.boolean('turn_off_recent_card_highlighting').notNullable();
table.boolean('enable_favorites_by_default').notNullable();
table.text('default_editor_mode').notNullable();
table.text('default_home_view').notNullable();
table.text('default_projects_order').notNullable();
table.boolean('is_sso_user').notNullable();
table.boolean('is_deactivated').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
table.timestamp('password_changed_at', true);
/* Indexes */
table.unique('email');
table.index('role');
table.index('username');
table.index('is_deactivated');
})
.raw(
'ALTER TABLE user_account ADD CONSTRAINT user_account_username_unique EXCLUDE (username WITH =) WHERE (username IS NOT NULL);',
);
await knex.schema.createTable('identity_provider_user', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('user_id').notNullable();
table.text('issuer').notNullable();
table.text('sub').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['issuer', 'sub']);
table.index('user_id');
});
await knex.schema.createTable('session', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('user_id').notNullable();
table.text('access_token').notNullable();
table.text('http_only_token');
table.text('remote_address').notNullable();
table.text('user_agent');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
table.timestamp('deleted_at', true);
/* Indexes */
table.index('user_id');
table.unique('access_token');
table.index('remote_address');
});
await knex.schema.createTable('project', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('owner_project_manager_id');
table.bigInteger('background_image_id');
table.text('name').notNullable();
table.text('description');
table.text('background_type');
table.text('background_gradient');
table.boolean('is_hidden').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('owner_project_manager_id');
});
await knex.schema.createTable('project_favorite', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('project_id').notNullable();
table.bigInteger('user_id').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['project_id', 'user_id']);
table.index('user_id');
});
await knex.schema.createTable('project_manager', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('project_id').notNullable();
table.bigInteger('user_id').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['project_id', 'user_id']);
table.index('user_id');
});
await knex.schema.createTable('background_image', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('project_id').notNullable();
table.text('dirname').notNullable();
table.text('extension').notNullable();
table.bigInteger('size_in_bytes').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('project_id');
});
await knex.schema.createTable('base_custom_field_group', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('project_id').notNullable();
table.text('name').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('project_id');
});
await knex.schema.createTable('board', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('project_id').notNullable();
table.specificType('position', 'double precision').notNullable();
table.text('name').notNullable();
table.text('default_view').notNullable();
table.text('default_card_type').notNullable();
table.boolean('limit_card_types_to_default_one').notNullable();
table.boolean('always_display_card_creator').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('project_id');
table.index('position');
});
await knex.schema.createTable('board_subscription', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('board_id').notNullable();
table.bigInteger('user_id').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['board_id', 'user_id']);
table.index('user_id');
});
await knex.schema.createTable('board_membership', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('project_id').notNullable();
table.bigInteger('board_id').notNullable();
table.bigInteger('user_id').notNullable();
table.text('role').notNullable();
table.boolean('can_comment');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('project_id');
table.unique(['board_id', 'user_id']);
table.index('user_id');
});
await knex.schema.createTable('label', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('board_id').notNullable();
table.specificType('position', 'double precision').notNullable();
table.text('name');
table.text('color').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('board_id');
table.index('position');
});
await knex.schema.createTable('list', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('board_id').notNullable();
table.text('type').notNullable();
table.specificType('position', 'double precision');
table.text('name');
table.text('color');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('board_id');
table.index('type');
table.index('position');
});
await knex.schema.createTable('card', async (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('board_id').notNullable();
table.bigInteger('list_id').notNullable();
table.bigInteger('creator_user_id');
table.bigInteger('prev_list_id');
table.bigInteger('cover_attachment_id');
table.text('type').notNullable();
table.specificType('position', 'double precision');
table.text('name').notNullable();
table.text('description');
table.timestamp('due_date', true);
table.jsonb('stopwatch');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
table.timestamp('list_changed_at', true);
/* Indexes */
table.index('board_id');
table.index('list_id');
table.index('creator_user_id');
table.index('position');
table.index('list_changed_at');
}).raw(`
CREATE INDEX card_name_index ON card USING GIN (name gin_trgm_ops);
CREATE INDEX card_description_index ON card USING GIN (description gin_trgm_ops);
`);
await knex.schema.createTable('card_subscription', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.bigInteger('user_id').notNullable();
table.boolean('is_permanent').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['card_id', 'user_id']);
table.index('user_id');
});
await knex.schema.createTable('card_membership', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.bigInteger('user_id').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['card_id', 'user_id']);
table.index('user_id');
});
await knex.schema.createTable('card_label', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.bigInteger('label_id').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['card_id', 'label_id']);
table.index('label_id');
});
await knex.schema.createTable('task_list', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.specificType('position', 'double precision').notNullable();
table.text('name').notNullable();
table.boolean('show_on_front_of_card').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('card_id');
table.index('position');
});
await knex.schema.createTable('task', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('task_list_id').notNullable();
table.bigInteger('assignee_user_id');
table.specificType('position', 'double precision').notNullable();
table.text('name').notNullable();
table.boolean('is_completed').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('task_list_id');
table.index('assignee_user_id');
table.index('position');
});
await knex.schema.createTable('attachment', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.bigInteger('creator_user_id');
table.text('type').notNullable();
table.jsonb('data').notNullable();
table.text('name').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('card_id');
table.index('creator_user_id');
});
await knex.schema.createTable('custom_field_group', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('board_id');
table.bigInteger('card_id');
table.bigInteger('base_custom_field_group_id');
table.specificType('position', 'double precision').notNullable();
table.text('name');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('board_id');
table.index('card_id');
table.index('base_custom_field_group_id');
table.index('position');
});
await knex.schema.createTable('custom_field', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('base_custom_field_group_id');
table.bigInteger('custom_field_group_id');
table.specificType('position', 'double precision').notNullable();
table.text('name').notNullable();
table.boolean('show_on_front_of_card').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('base_custom_field_group_id');
table.index('custom_field_group_id');
table.index('position');
});
await knex.schema.createTable('custom_field_value', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.bigInteger('custom_field_group_id').notNullable();
table.bigInteger('custom_field_id').notNullable();
table.text('content').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['card_id', 'custom_field_group_id', 'custom_field_id']);
table.index('custom_field_group_id');
table.index('custom_field_id');
});
await knex.schema.createTable('comment', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.bigInteger('user_id');
table.text('text').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('card_id');
table.index('user_id');
});
await knex.schema.createTable('action', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('card_id').notNullable();
table.bigInteger('user_id');
table.text('type').notNullable();
table.jsonb('data').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('card_id');
table.index('user_id');
});
await knex.schema.createTable('notification', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('user_id').notNullable();
table.bigInteger('creator_user_id');
table.bigInteger('board_id').notNullable();
table.bigInteger('card_id').notNullable();
table.bigInteger('comment_id');
table.bigInteger('action_id');
table.text('type').notNullable();
table.jsonb('data').notNullable();
table.boolean('is_read').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('user_id');
table.index('creator_user_id');
table.index('card_id');
table.index('comment_id');
table.index('action_id');
table.index('is_read');
});
return knex.schema.createTable('notification_service', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('user_id');
table.bigInteger('board_id');
table.text('url').notNullable();
table.text('format').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('user_id');
table.index('board_id');
});
};
module.exports.down = async (knex) => {
await knex.schema.dropTable('file_reference');
await knex.schema.dropTable('user_account');
await knex.schema.dropTable('identity_provider_user');
await knex.schema.dropTable('session');
await knex.schema.dropTable('project');
await knex.schema.dropTable('project_favorite');
await knex.schema.dropTable('project_manager');
await knex.schema.dropTable('background_image');
await knex.schema.dropTable('base_custom_field_group');
await knex.schema.dropTable('board');
await knex.schema.dropTable('board_subscription');
await knex.schema.dropTable('board_membership');
await knex.schema.dropTable('label');
await knex.schema.dropTable('list');
await knex.schema.dropTable('card');
await knex.schema.dropTable('card_subscription');
await knex.schema.dropTable('card_membership');
await knex.schema.dropTable('card_label');
await knex.schema.dropTable('task_list');
await knex.schema.dropTable('task');
await knex.schema.dropTable('attachment');
await knex.schema.dropTable('custom_field_group');
await knex.schema.dropTable('custom_field');
await knex.schema.dropTable('custom_field_value');
await knex.schema.dropTable('comment');
await knex.schema.dropTable('action');
await knex.schema.dropTable('notification');
await knex.schema.dropTable('notification_service');
return knex.raw(`
DROP EXTENSION pg_trgm;
DROP SEQUENCE next_id_seq;
DROP FUNCTION next_id(OUT id BIGINT);
`);
};
@@ -0,0 +1,30 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('action', (table) => {
/* Columns */
table.bigInteger('board_id');
/* Indexes */
table.index('board_id');
});
return knex.raw(`
UPDATE action
SET
board_id = card.board_id,
data = data || jsonb_build_object('card', jsonb_build_object('name', card.name))
FROM card
WHERE action.card_id = card.id;
`);
};
exports.down = (knex) =>
knex.schema.table('action', (table) => {
table.dropColumn('board_id');
});
@@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('card', (table) => {
/* Columns */
table.integer('comments_total').notNullable().defaultTo(0);
});
await knex.raw(`
UPDATE card
SET comments_total = comments_total_by_card_id.comments_total
FROM (
SELECT card_id, COUNT(*) as comments_total
FROM comment
GROUP BY card_id
) AS comments_total_by_card_id
WHERE card.id = comments_total_by_card_id.card_id;
`);
return knex.schema.alterTable('card', (table) => {
table.integer('comments_total').notNullable().alter();
});
};
exports.down = (knex) =>
knex.schema.table('card', (table) => {
table.dropColumn('comments_total');
});
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = (knex) =>
knex.raw(`
UPDATE user_account
SET language =
CASE
WHEN language = 'sr-Cyrl-CS' THEN 'sr-Cyrl-RS'
WHEN language = 'sr-Latn-CS' THEN 'sr-Latn-RS'
END
WHERE language IN ('sr-Cyrl-CS', 'sr-Latn-CS');
`);
exports.down = (knex) =>
knex.raw(`
UPDATE user_account
SET language =
CASE
WHEN language = 'sr-Cyrl-RS' THEN 'sr-Cyrl-CS'
WHEN language = 'sr-Latn-RS' THEN 'sr-Latn-CS'
END
WHERE language IN ('sr-Cyrl-RS', 'sr-Latn-RS');
`);
@@ -0,0 +1,28 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = (knex) =>
knex.schema.createTable('webhook', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('board_id');
table.text('name').notNullable();
table.text('url').notNullable();
table.text('access_token');
table.specificType('events', 'text[]');
table.specificType('excluded_events', 'text[]');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.index('board_id');
});
exports.down = (knex) => knex.schema.dropTable('webhook');
@@ -0,0 +1,28 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('card', (table) => {
/* Columns */
table.boolean('is_closed').notNullable().default(false);
});
await knex.raw(`
UPDATE card
SET is_closed = TRUE
FROM list
WHERE card.list_id = list.id AND list.type = 'closed';
`);
return knex.schema.alterTable('card', (table) => {
table.boolean('is_closed').notNullable().alter();
});
};
exports.down = (knex) =>
knex.schema.table('card', (table) => {
table.dropColumn('is_closed');
});
@@ -0,0 +1,20 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) =>
knex.schema.alterTable('task', (table) => {
/* Columns */
table.bigInteger('linked_card_id');
/* Indexes */
table.index('linked_card_id');
});
exports.down = (knex) =>
knex.schema.table('task', (table) => {
table.dropColumn('linked_card_id');
});
@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('task_list', (table) => {
/* Columns */
table.boolean('hide_completed_tasks').notNullable().defaultTo(false);
});
return knex.schema.alterTable('task_list', (table) => {
table.boolean('hide_completed_tasks').notNullable().alter();
});
};
exports.down = (knex) =>
knex.schema.table('task_list', (table) => {
table.dropColumn('hide_completed_tasks');
});
@@ -0,0 +1,70 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.createTable('config', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.boolean('is_initialized').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
});
await knex.schema.alterTable('session', (table) => {
/* Columns */
table.text('pending_token');
/* Modifications */
table.setNullable('access_token');
/* Indexes */
table.unique('pending_token');
});
await knex.schema.alterTable('user_account', (table) => {
/* Columns */
table.text('terms_signature');
table.timestamp('terms_accepted_at', true);
});
const isInitialized = !!(await knex('user_account').first());
await knex('config').insert({
isInitialized,
id: 1,
createdAt: new Date().toISOString(),
});
await knex('session')
.update({
deletedAt: new Date().toISOString(),
})
.whereNull('deletedAt');
};
exports.down = async (knex) => {
await knex.schema.dropTable('config');
await knex('session').del().whereNull('access_token');
await knex.schema.alterTable('session', (table) => {
table.dropColumn('pending_token');
table.dropNullable('access_token');
});
return knex.schema.table('user_account', (table) => {
table.dropColumn('terms_signature');
table.dropColumn('terms_accepted_at');
});
};
@@ -0,0 +1,161 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const mime = require('mime-types');
exports.up = async (knex) => {
await knex.schema.createTable('storage_usage', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('total').notNullable();
table.bigInteger('user_avatars').notNullable();
table.bigInteger('background_images').notNullable();
table.bigInteger('attachments').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
});
await knex.schema.alterTable('file_reference', (table) => {
table.dropPrimary();
table.dropIndex('total');
});
await knex.schema.renameTable('file_reference', 'uploaded_file');
await knex.schema.alterTable('uploaded_file', (table) => {
/* Columns */
table.text('type').notNullable().defaultTo('attachment');
table.text('mime_type');
table.bigInteger('size').notNullable().defaultTo(0);
/* Modifications */
table.text('id').primary().defaultTo(knex.raw('next_id()')).alter();
table.renameColumn('total', 'references_total');
/* Indexes */
table.index('type');
table.index('references_total');
});
await knex.schema.alterTable('uploaded_file', (table) => {
table.text('type').notNullable().alter();
table.bigInteger('size').notNullable().alter();
});
await knex.raw(`
UPDATE user_account
SET avatar = avatar - 'dirname' - 'sizeInBytes' || jsonb_build_object('uploadedFileId', avatar->'dirname', 'size', avatar->'sizeInBytes')
WHERE avatar IS NOT NULL;
`);
await knex.schema.alterTable('background_image', (table) => {
table.renameColumn('dirname', 'uploaded_file_id');
table.renameColumn('size_in_bytes', 'size');
});
await knex.raw(`
UPDATE attachment
SET data = data - 'fileReferenceId' - 'sizeInBytes' || jsonb_build_object('uploadedFileId', data->'fileReferenceId', 'size', data->'sizeInBytes')
WHERE type = 'file';
`);
await knex.raw(`
UPDATE uploaded_file
SET
type = 'attachment',
mime_type = attachment.data->>'mimeType',
size = (attachment.data->>'size')::bigint
FROM attachment
WHERE (attachment.data->>'uploadedFileId')::text = uploaded_file.id AND attachment.type = 'file';
`);
const users = await knex('user_account').whereNotNull('avatar');
const createdAt = new Date().toISOString();
await knex.batchInsert(
'uploaded_file',
users.map(({ avatar }) => ({
createdAt,
id: avatar.uploadedFileId,
type: 'userAvatar',
referencesTotal: 1,
mimeType: mime.lookup(avatar.extension) || null,
size: avatar.size,
})),
);
const backgroundImages = await knex('background_image');
await knex.batchInsert(
'uploaded_file',
backgroundImages.map((backgroundImage) => ({
id: backgroundImage.uploaded_file_id,
type: 'backgroundImage',
referencesTotal: 1,
mimeType: mime.lookup(backgroundImage.extension) || null,
size: backgroundImage.size,
createdAt: backgroundImage.created_at,
})),
);
return knex.raw(`
INSERT INTO storage_usage (id, total, user_avatars, background_images, attachments, created_at)
SELECT
1 AS id,
COALESCE(SUM(size), 0) AS total,
COALESCE(SUM(CASE WHEN type = 'userAvatar' THEN size ELSE 0 END), 0) AS user_avatars,
COALESCE(SUM(CASE WHEN type = 'backgroundImage' THEN size ELSE 0 END), 0) AS background_images,
COALESCE(SUM(CASE WHEN type = 'attachment' THEN size ELSE 0 END), 0) AS attachments,
timezone('UTC', now()) AS created_at
FROM uploaded_file;
`);
};
exports.down = async (knex) => {
await knex.schema.dropTable('storage_usage');
await knex('uploaded_file').delete().whereNot('type', 'attachment');
await knex.schema.alterTable('uploaded_file', (table) => {
table.dropPrimary();
table.dropIndex('references_total');
});
await knex.schema.renameTable('uploaded_file', 'file_reference');
await knex.schema.alterTable('file_reference', (table) => {
table.dropColumn('type');
table.dropColumn('mime_type');
table.dropColumn('size');
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')).alter();
table.renameColumn('references_total', 'total');
table.index('total');
});
await knex.raw(`
UPDATE user_account
SET avatar = avatar - 'uploadedFileId' - 'size' || jsonb_build_object('dirname', avatar->'uploadedFileId', 'sizeInBytes', avatar->'size')
WHERE avatar IS NOT NULL;
`);
await knex.schema.alterTable('background_image', (table) => {
table.renameColumn('uploaded_file_id', 'dirname');
table.renameColumn('size', 'size_in_bytes');
});
return knex.raw(`
UPDATE attachment
SET data = data - 'uploadedFileId' - 'size' || jsonb_build_object('fileReferenceId', data->'uploadedFileId', 'sizeInBytes', data->'size')
WHERE type = 'file';
`);
};
@@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('card', (table) => {
/* Columns */
table.boolean('is_due_completed');
});
return knex('card')
.update({
isDueCompleted: false,
})
.whereNotNull('due_date');
};
exports.down = (knex) =>
knex.schema.table('card', (table) => {
table.dropColumn('is_due_completed');
});
@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('board', (table) => {
/* Columns */
table.boolean('expand_task_lists_by_default').notNullable().defaultTo(false);
});
return knex.schema.alterTable('board', (table) => {
table.boolean('expand_task_lists_by_default').notNullable().alter();
});
};
exports.down = (knex) =>
knex.schema.table('board', (table) => {
table.dropColumn('expand_task_lists_by_default');
});
@@ -0,0 +1,36 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('config', (table) => {
/* Columns */
table.text('smtp_host');
table.integer('smtp_port');
table.text('smtp_name');
table.boolean('smtp_secure').notNullable().defaultTo(false);
table.boolean('smtp_tls_reject_unauthorized').notNullable().defaultTo(true);
table.text('smtp_user');
table.text('smtp_password');
table.text('smtp_from');
});
return knex.schema.alterTable('config', (table) => {
table.boolean('smtp_secure').notNullable().alter();
table.boolean('smtp_tls_reject_unauthorized').notNullable().alter();
});
};
exports.down = (knex) =>
knex.schema.alterTable('config', (table) => {
table.dropColumn('smtp_host');
table.dropColumn('smtp_port');
table.dropColumn('smtp_name');
table.dropColumn('smtp_secure');
table.dropColumn('smtp_tls_reject_unauthorized');
table.dropColumn('smtp_user');
table.dropColumn('smtp_password');
table.dropColumn('smtp_from');
});
@@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = (knex) =>
knex.schema.alterTable('user_account', (table) => {
table.text('api_key_prefix');
table.text('api_key_hash');
table.timestamp('api_key_created_at', true);
/* Indexes */
table.unique('api_key_hash');
});
exports.down = (knex) =>
knex.schema.alterTable('user_account', (table) => {
table.dropColumn('api_key_prefix');
table.dropColumn('api_key_hash');
table.dropColumn('api_key_created_at');
});
@@ -0,0 +1,16 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports.up = (knex) =>
knex.schema.raw(`
ALTER INDEX card_name_index RENAME TO card_name_gin_index;
ALTER INDEX card_description_index RENAME TO card_description_gin_index;
`);
module.exports.down = (knex) =>
knex.schema.raw(`
ALTER INDEX card_name_gin_index RENAME TO card_name_index;
ALTER INDEX card_description_gin_index RENAME TO card_description_index;
`);
@@ -0,0 +1,47 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.createTable('internal_config', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.text('storage_limit');
table.integer('active_users_limit');
table.boolean('is_initialized').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
});
const { is_initialized: isInitialized } = await knex('config').select('is_initialized').first();
await knex('internal_config').insert({
isInitialized,
id: 1,
createdAt: new Date().toISOString(),
});
return knex.schema.alterTable('config', (table) => {
table.dropColumn('is_initialized');
});
};
exports.down = async (knex) => {
const { is_initialized: isInitialized } = await knex('internal_config')
.select('is_initialized')
.first();
await knex.schema.alterTable('config', (table) => {
table.boolean('is_initialized').notNullable().default(isInitialized);
});
await knex.schema.alterTable('config', (table) => {
table.boolean('is_initialized').notNullable().alter();
});
return knex.schema.dropTable('internal_config');
};
@@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports.up = async (knex) => {
await knex.schema.alterTable('board', (table) => {
table.boolean('display_card_ages').notNullable().defaultTo(false);
});
return knex.schema.alterTable('board', (table) => {
table.boolean('display_card_ages').notNullable().alter();
});
};
module.exports.down = (knex) =>
knex.schema.alterTable('board', (table) => {
table.dropColumn('display_card_ages');
});
View File
+119
View File
@@ -0,0 +1,119 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const bcrypt = require('bcrypt');
const buildUserData = () => {
const data = {
role: 'admin',
isSsoUser: false,
isDeactivated: false,
};
if (process.env.DEFAULT_ADMIN_PASSWORD) {
data.password = bcrypt.hashSync(process.env.DEFAULT_ADMIN_PASSWORD, 10);
}
if (process.env.DEFAULT_ADMIN_NAME) {
data.name = process.env.DEFAULT_ADMIN_NAME;
}
if (process.env.DEFAULT_ADMIN_USERNAME) {
data.username = process.env.DEFAULT_ADMIN_USERNAME.toLowerCase();
}
return data;
};
const buildInternalConfigData = () => {
const data = {};
if (process.env.STORAGE_LIMIT) {
data.storageLimit = process.env.STORAGE_LIMIT;
}
if (process.env.ACTIVE_USERS_LIMIT) {
const activeUsersLimit = parseInt(process.env.ACTIVE_USERS_LIMIT, 10);
if (Number.isInteger(activeUsersLimit)) {
data.activeUsersLimit = activeUsersLimit;
}
}
return data;
};
exports.seed = async (knex) => {
const defaultAdminEmail =
process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase();
if (defaultAdminEmail) {
const userData = buildUserData();
let userId;
try {
[{ id: userId }] = await knex('user_account').insert(
{
...userData,
email: defaultAdminEmail,
subscribeToOwnCards: false,
subscribeToCardWhenCommenting: true,
turnOffRecentCardHighlighting: false,
enableFavoritesByDefault: true,
defaultEditorMode: 'wysiwyg',
defaultHomeView: 'groupedProjects',
defaultProjectsOrder: 'byDefault',
createdAt: new Date().toISOString(),
},
'id',
);
} catch (error) {
/* empty */
}
if (!userId) {
await knex('user_account').update(userData).where('email', defaultAdminEmail);
}
}
const internalConfigData = buildInternalConfigData();
let activeUsersLimit;
if (Object.keys(internalConfigData).length > 0) {
[{ active_users_limit: activeUsersLimit }] = await knex('internal_config')
.update(internalConfigData)
.returning('active_users_limit');
} else {
({ active_users_limit: activeUsersLimit } = await knex('internal_config')
.select('active_users_limit')
.first());
}
if (Number.isInteger(activeUsersLimit)) {
let orderByQuery;
let orderByQueryValues;
if (defaultAdminEmail) {
orderByQuery = 'CASE WHEN email = ? THEN 0 WHEN role = ? THEN 1 ELSE 2 END';
orderByQueryValues = [defaultAdminEmail, 'admin'];
} else {
orderByQuery = 'CASE WHEN role = ? THEN 0 ELSE 1 END';
orderByQueryValues = 'admin';
}
const users = await knex('user_account')
.select('id')
.where('is_deactivated', false)
.orderByRaw(orderByQuery, orderByQueryValues)
.orderBy('id')
.offset(activeUsersLimit);
if (users.length > 0) {
const userIds = users.map(({ id }) => id);
await knex('user_account')
.update({
isDeactivated: true,
})
.whereIn('id', userIds);
}
}
};
+969
View File
@@ -0,0 +1,969 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/* eslint-disable no-await-in-loop */
/* eslint-disable no-console */
/* eslint-disable no-continue */
/* eslint-disable no-restricted-syntax */
const { getEncoding } = require('istextorbinary');
const mime = require('mime-types');
const uuid = require('uuid');
const sharp = require('sharp');
const initKnex = require('knex');
const sails = require('sails');
const rc = require('sails/accessible/rc');
const _ = require('lodash');
const knexfile = require('./knexfile');
const { MAX_SIZE_TO_GET_ENCODING, POSITION_GAP } = require('../constants');
const PrevActionTypes = {
COMMENT_CARD: 'commentCard',
};
const PrevPathSegments = {
PROJECT_BACKGROUND_IMAGES: 'public/project-background-images',
FAVICONS: 'public/favicons',
USER_AVATARS: 'public/user-avatars',
BACKGROUND_IMAGES: 'public/background-images',
ATTACHMENTS: 'private/attachments',
};
const readStreamToBuffer = (readStream) =>
new Promise((resolve, reject) => {
const chunks = [];
readStream.on('data', (chunk) => {
chunks.push(chunk);
});
readStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
readStream.on('error', (error) => {
reject(error);
});
});
const loadSails = () =>
new Promise((resolve) => {
sails.load(
{
...rc('sails'),
log: {
level: 'error',
},
},
resolve,
);
});
const runStep = async (label, func) => {
process.stdout.write(`${label}... `);
try {
await func();
console.log('[OK]');
} catch (error) {
console.log('[FAIL]');
throw error;
}
};
const knex = initKnex(knexfile);
const upgradeDatabase = async () => {
await knex.transaction(async (trx) => {
await trx.raw(`
CREATE SCHEMA v1;
ALTER SEQUENCE next_id_seq SET SCHEMA v1;
ALTER FUNCTION next_id(OUT id BIGINT) SET SCHEMA v1;
ALTER TABLE migration SET SCHEMA v1;
ALTER TABLE migration_lock SET SCHEMA v1;
ALTER TABLE archive SET SCHEMA v1;
ALTER TABLE user_account SET SCHEMA v1;
ALTER TABLE identity_provider_user SET SCHEMA v1;
ALTER TABLE session SET SCHEMA v1;
ALTER TABLE project SET SCHEMA v1;
ALTER TABLE project_manager SET SCHEMA v1;
ALTER TABLE board SET SCHEMA v1;
ALTER TABLE board_membership SET SCHEMA v1;
ALTER TABLE label SET SCHEMA v1;
ALTER TABLE list SET SCHEMA v1;
ALTER TABLE card SET SCHEMA v1;
ALTER TABLE card_subscription SET SCHEMA v1;
ALTER TABLE card_membership SET SCHEMA v1;
ALTER TABLE card_label SET SCHEMA v1;
ALTER TABLE task SET SCHEMA v1;
ALTER TABLE attachment SET SCHEMA v1;
ALTER TABLE action SET SCHEMA v1;
ALTER TABLE notification SET SCHEMA v1;
`);
await trx.migrate.up({
name: '20250228000022_version_2.js',
});
const users = await trx('user_account').withSchema('v1').whereNull('deleted_at');
const userIds = users.map(({ id }) => id);
const userIdsSet = new Set(userIds);
const whereInUserIds = ['0', ...userIds];
if (users.length > 0) {
await knex
.batchInsert(
'user_account',
users.map((user) => ({
..._.pick(user, [
'id',
'email',
'password',
'name',
'username',
'phone',
'organization',
'language',
'subscribe_to_own_cards',
'created_at',
'updated_at',
'password_changed_at',
]),
role: user.is_admin ? User.Roles.ADMIN : User.Roles.BOARD_USER,
avatar: user.avatar && {
...user.avatar,
sizeInBytes: 0,
},
subscribe_to_card_when_commenting: true,
turn_off_recent_card_highlighting: false,
enable_favorites_by_default: true,
default_editor_mode: User.EditorModes.WYSIWYG,
default_home_view: User.HomeViews.GROUPED_PROJECTS,
default_projects_order: User.ProjectOrders.BY_DEFAULT,
is_sso_user: user.is_sso,
is_deactivated: false,
})),
)
.transacting(trx);
const identityProviderUsers = await trx('identity_provider_user')
.withSchema('v1')
.whereRaw('user_id = ANY (?)', [whereInUserIds]);
if (identityProviderUsers.length > 0) {
await knex.batchInsert('identity_provider_user', identityProviderUsers).transacting(trx);
}
const sessions = await trx('session')
.withSchema('v1')
.whereRaw('user_id = ANY (?)', [whereInUserIds]);
if (sessions.length > 0) {
await knex.batchInsert('session', sessions).transacting(trx);
}
}
const projects = await trx('project').withSchema('v1');
const whereInProjectIds = ['0', ...projects.map(({ id }) => id)];
if (projects.length > 0) {
const projectsWithBackgroundImage = projects.filter((project) => project.background_image);
const backgroundImageIdByProjectId = {};
if (projectsWithBackgroundImage.length > 0) {
const createdAt = new Date().toISOString();
const backgroundImages = await knex
.batchInsert(
'background_image',
projectsWithBackgroundImage.map((project) => ({
...project.background_image,
project_id: project.id,
size_in_bytes: 0,
created_at: createdAt,
})),
)
.returning(['id', 'project_id'])
.transacting(trx);
backgroundImages.forEach((backgroundImage) => {
backgroundImageIdByProjectId[backgroundImage.project_id] = backgroundImage.id;
});
}
await knex
.batchInsert(
'project',
projects.map((project) => {
const data = {
..._.pick(project, ['id', 'name', 'created_at', 'updated_at']),
is_hidden: false,
};
if (project.background) {
data.background_type = project.background.type;
switch (project.background.type) {
case Project.BackgroundTypes.GRADIENT:
data.background_gradient = project.background.name;
break;
case Project.BackgroundTypes.IMAGE:
data.background_image_id = backgroundImageIdByProjectId[project.id];
break;
default:
}
}
return data;
}),
)
.transacting(trx);
}
const projectManagers = await trx('project_manager')
.withSchema('v1')
.whereRaw('project_id = ANY (?)', [whereInProjectIds]);
if (projectManagers.length > 0) {
await knex.batchInsert('project_manager', projectManagers).transacting(trx);
}
const boards = await trx('board')
.withSchema('v1')
.whereRaw('project_id = ANY (?)', [whereInProjectIds]);
const projectIdByBoardId = boards.reduce(
(result, board) => ({
...result,
[board.id]: board.project_id,
}),
{},
);
const whereInBoardIds = ['0', ...Object.keys(projectIdByBoardId)];
if (boards.length > 0) {
await knex
.batchInsert(
'board',
boards.map((board) => ({
..._.pick(board, ['id', 'project_id', 'position', 'name', 'created_at', 'updated_at']),
default_view: Board.Views.KANBAN,
default_card_type: Card.Types.PROJECT,
limit_card_types_to_default_one: false,
always_display_card_creator: false,
})),
)
.transacting(trx);
const createdAt = new Date().toISOString();
await knex
.batchInsert(
'list',
boards.flatMap((board) =>
[List.Types.ARCHIVE, List.Types.TRASH].map((type) => ({
type,
board_id: board.id,
created_at: createdAt,
})),
),
)
.transacting(trx);
}
const boardMemberships = await trx('board_membership')
.withSchema('v1')
.whereRaw('board_id = ANY (?)', [whereInBoardIds]);
if (boardMemberships.length > 0) {
await knex
.batchInsert(
'board_membership',
boardMemberships.map((boardMembership) => ({
..._.pick(boardMembership, [
'id',
'board_id',
'user_id',
'role',
'can_comment',
'created_at',
'updated_at',
]),
project_id: projectIdByBoardId[boardMembership.board_id],
})),
)
.transacting(trx);
}
const labels = await trx('label')
.withSchema('v1')
.whereRaw('board_id = ANY (?)', [whereInBoardIds]);
if (labels.length > 0) {
await knex.batchInsert('label', labels).transacting(trx);
}
const lists = await trx('list')
.withSchema('v1')
.whereRaw('board_id = ANY (?)', [whereInBoardIds]);
const whereInListIds = ['0', ...lists.map(({ id }) => id)];
if (lists.length > 0) {
await knex
.batchInsert(
'list',
lists.map((list) => ({
..._.pick(list, [
'id',
'board_id',
'position',
'name',
'color',
'created_at',
'updated_at',
]),
type: List.Types.ACTIVE,
})),
)
.transacting(trx);
}
const cards = await trx('card')
.withSchema('v1')
.whereRaw('board_id = ANY (?)', [whereInBoardIds])
.whereRaw('list_id = ANY (?)', [whereInListIds]);
const cardById = _.keyBy(cards, 'id');
const whereInCardIds = ['0', ...Object.keys(cardById)];
if (cards.length > 0) {
await knex
.batchInsert(
'card',
cards.map((card) => ({
..._.pick(card, [
'id',
'board_id',
'list_id',
'cover_attachment_id',
'position',
'name',
'description',
'due_date',
'stopwatch',
'created_at',
'updated_at',
]),
creator_user_id: userIdsSet.has(card.creator_user_id) ? card.creator_user_id : null,
type: Card.Types.PROJECT,
list_changed_at: card.created_at,
})),
)
.transacting(trx);
}
const cardSubscriptions = await trx('card_subscription')
.withSchema('v1')
.whereRaw('card_id = ANY (?)', [whereInCardIds]);
if (cardSubscriptions.length > 0) {
await knex.batchInsert('card_subscription', cardSubscriptions).transacting(trx);
}
const cardMemberships = await trx('card_membership')
.withSchema('v1')
.whereRaw('card_id = ANY (?)', [whereInCardIds]);
if (cardMemberships.length > 0) {
await knex.batchInsert('card_membership', cardMemberships).transacting(trx);
}
const cardLabels = await trx('card_label')
.withSchema('v1')
.whereRaw('card_id = ANY (?)', [whereInCardIds]);
if (cardLabels.length > 0) {
await knex.batchInsert('card_label', cardLabels).transacting(trx);
}
const tasks = await trx('task')
.withSchema('v1')
.whereRaw('card_id = ANY (?)', [whereInCardIds]);
const tasksByCardId = _.groupBy(tasks, 'card_id');
const taskCardIds = Object.keys(tasksByCardId);
if (taskCardIds.length > 0) {
const createdAt = new Date().toISOString();
const taskLists = await knex
.batchInsert(
'task_list',
taskCardIds.map((cardId) => ({
card_id: cardId,
position: POSITION_GAP,
name: 'Task List',
show_on_front_of_card: true,
created_at: createdAt,
})),
)
.returning(['id', 'card_id'])
.transacting(trx);
await knex
.batchInsert(
'task',
taskLists.flatMap((taskList) =>
tasksByCardId[taskList.card_id].map((task) => ({
..._.pick(task, [
'id',
'position',
'name',
'is_completed',
'created_at',
'updated_at',
]),
task_list_id: taskList.id,
})),
),
)
.transacting(trx);
}
const attachments = await trx('attachment')
.withSchema('v1')
.whereRaw('card_id = ANY (?)', [whereInCardIds]);
if (attachments.length > 0) {
await knex
.batchInsert(
'attachment',
attachments.map((attachment) => ({
..._.pick(attachment, ['id', 'card_id', 'name', 'created_at', 'updated_at']),
creator_user_id: userIdsSet.has(attachment.creator_user_id)
? attachment.creator_user_id
: null,
type: Attachment.Types.FILE,
data: {
fileReferenceId: attachment.dirname,
filename: attachment.filename,
mimeType: mime.lookup(attachment.filename) || null,
sizeInBytes: 0,
encoding: null,
image: attachment.image,
},
})),
)
.transacting(trx);
}
const actions = await trx('action')
.withSchema('v1')
.whereRaw('card_id = ANY (?)', [whereInCardIds]);
const actionById = _.keyBy(actions, 'id');
const whereInActionIds = ['0', ...Object.keys(actionById)];
const commentActions = [];
const otherActions = [];
actions.forEach((action) => {
if (action.type === PrevActionTypes.COMMENT_CARD) {
commentActions.push(action);
} else {
otherActions.push(action);
}
});
if (commentActions.length > 0) {
await knex
.batchInsert(
'comment',
commentActions.map((action) => ({
..._.pick(action, ['id', 'card_id', 'created_at', 'updated_at']),
user_id: userIdsSet.has(action.user_id) ? action.user_id : null,
text: action.data.text,
})),
)
.transacting(trx);
}
if (otherActions.length > 0) {
await knex
.batchInsert(
'action',
otherActions.map((action) => {
const data = {
..._.pick(action, ['id', 'card_id', 'type', 'created_at', 'updated_at']),
user_id: userIdsSet.has(action.user_id) ? action.user_id : null,
};
switch (action.type) {
case Action.Types.CREATE_CARD:
data.data = {
list: {
...action.data.list,
type: List.Types.ACTIVE,
},
};
break;
case Action.Types.MOVE_CARD:
data.data = {
fromList: {
...action.data.fromList,
type: List.Types.ACTIVE,
},
toList: {
...action.data.toList,
type: List.Types.ACTIVE,
},
};
break;
default:
}
return data;
}),
)
.transacting(trx);
}
const notifications = await trx('notification')
.withSchema('v1')
.whereRaw('user_id = ANY (?)', [whereInUserIds])
.whereRaw('action_id = ANY (?)', [whereInActionIds])
.whereRaw('card_id = ANY (?)', [whereInCardIds]);
if (notifications.length > 0) {
await knex
.batchInsert(
'notification',
notifications.map((notification) => {
const card = cardById[notification.card_id];
const action = actionById[notification.action_id];
const data = {
..._.pick(notification, [
'id',
'user_id',
'card_id',
'is_read',
'created_at',
'updated_at',
]),
creator_user_id: userIdsSet.has(action.user_id) ? action.user_id : null,
board_id: card.board_id,
type: action.type,
};
if (action.type === PrevActionTypes.COMMENT_CARD) {
Object.assign(data, {
comment_id: action.id,
data: {
card: _.pick(card, ['name']),
text: action.data.text,
},
});
} else {
Object.assign(data, {
action_id: action.id,
data: {
fromList: {
...action.data.fromList,
type: List.Types.ACTIVE,
},
toList: {
...action.data.toList,
type: List.Types.ACTIVE,
},
card: _.pick(card, ['name']),
},
});
}
return data;
}),
)
.transacting(trx);
}
await trx.schema.dropSchema('v1', true);
});
};
const upgradeUserAvatars = async () => {
const fileManager = sails.hooks['file-manager'].getInstance();
const dirnames = await fileManager.listDir(PrevPathSegments.USER_AVATARS);
const users = await knex('user_account').whereNotNull('avatar');
if (dirnames) {
const userByDirname = _.keyBy(users, 'avatar.dirname');
for (const dirname of dirnames) {
const user = userByDirname[dirname];
const dirPathSegment = `${PrevPathSegments.USER_AVATARS}/${dirname}`;
if (user) {
const size = await fileManager.getSize(
`${dirPathSegment}/original.${user.avatar.extension}`,
);
if (size) {
await knex('user_account')
.update({
avatar: knex.raw("?? || jsonb_build_object('sizeInBytes', ?::bigint)", [
'avatar',
size,
]),
})
.where('id', user.id);
}
} else {
await fileManager.deleteDir(dirPathSegment);
}
}
}
for (const { avatar } of users) {
const dirPathSegment = `${PrevPathSegments.USER_AVATARS}/${avatar.dirname}`;
const isExists = await fileManager.isExists(`${dirPathSegment}/cover-180.${avatar.extension}`);
if (isExists) {
continue;
}
await fileManager.delete(`${dirPathSegment}/square-100.${avatar.extension}`);
let readStream;
try {
readStream = await fileManager.read(`${dirPathSegment}/original.${avatar.extension}`);
} catch (error) {
continue;
}
const originalBuffer = await readStreamToBuffer(readStream);
const image = sharp(originalBuffer, {
animated: true,
});
const cover180 = image
.clone()
.resize(180, 180, {
withoutEnlargement: true,
})
.png({
quality: 75,
force: false,
});
await fileManager.save(
`${dirPathSegment}/cover-180.${avatar.extension}`,
cover180,
mime.lookup(avatar.extension) || null,
);
}
};
const upgradeBackgroundImages = async () => {
const fileManager = sails.hooks['file-manager'].getInstance();
await fileManager.renameDir(
PrevPathSegments.PROJECT_BACKGROUND_IMAGES,
PrevPathSegments.BACKGROUND_IMAGES,
);
const dirnames = await fileManager.listDir(PrevPathSegments.BACKGROUND_IMAGES);
const backgroundImages = await knex('background_image');
if (dirnames) {
const backgroundImageByDirname = _.keyBy(backgroundImages, 'dirname');
for (const dirname of dirnames) {
const backgroundImage = backgroundImageByDirname[dirname];
const dirPathSegment = `${PrevPathSegments.BACKGROUND_IMAGES}/${dirname}`;
if (backgroundImage) {
const size = await fileManager.getSize(
`${dirPathSegment}/original.${backgroundImage.extension}`,
);
if (size) {
await knex('background_image')
.update({
size_in_bytes: size,
})
.where('id', backgroundImage.id);
}
} else {
await fileManager.deleteDir(dirPathSegment);
}
}
}
for (const backgroundImage of backgroundImages) {
const dirPathSegment = `${PrevPathSegments.BACKGROUND_IMAGES}/${backgroundImage.dirname}`;
const isExists = await fileManager.isExists(
`${dirPathSegment}/outside-360.${backgroundImage.extension}`,
);
if (isExists) {
continue;
}
await fileManager.delete(`${dirPathSegment}/cover-336.${backgroundImage.extension}`);
let readStream;
try {
readStream = await fileManager.read(
`${dirPathSegment}/original.${backgroundImage.extension}`,
);
} catch (error) {
continue;
}
const originalBuffer = await readStreamToBuffer(readStream);
const image = sharp(originalBuffer, {
animated: true,
});
const outside360 = image
.clone()
.resize(360, 360, {
fit: 'outside',
withoutEnlargement: true,
})
.png({
quality: 75,
force: false,
});
await fileManager.save(
`${dirPathSegment}/outside-360.${backgroundImage.extension}`,
outside360,
mime.lookup(backgroundImage.extension) || null,
);
}
};
const upgradeFileAttachments = async () => {
const fileManager = sails.hooks['file-manager'].getInstance();
const dirnames = await fileManager.listDir(PrevPathSegments.ATTACHMENTS);
const attachments = await knex('attachment').where('type', Attachment.Types.FILE);
const fileReferenceIds = [];
if (dirnames) {
const attachmentByDirname = _.keyBy(attachments, 'data.fileReferenceId');
for (const dirname of dirnames) {
const attachment = attachmentByDirname[dirname];
const dirPathSegment = `${PrevPathSegments.ATTACHMENTS}/${dirname}`;
if (attachment) {
if (uuid.validate(dirname)) {
const fileReferenceId = await knex.transaction(async (trx) => {
const [{ id }] = await trx('file_reference').insert(
{
total: 1,
created_at: new Date().toISOString(),
},
'id',
);
const size = await fileManager.getSize(`${dirPathSegment}/${attachment.data.filename}`);
let encoding = null;
if (size && size <= MAX_SIZE_TO_GET_ENCODING) {
const readStream = await fileManager.read(
`${dirPathSegment}/${attachment.data.filename}`,
);
const buffer = await readStreamToBuffer(readStream);
encoding = getEncoding(buffer);
}
await trx('attachment')
.update({
data: trx.raw(
"?? || jsonb_build_object('fileReferenceId', ?::text, 'sizeInBytes', ?::bigint, 'encoding', ?::text)",
['data', id, size, encoding],
),
})
.where('id', attachment.id);
await fileManager.renameDir(
`${dirPathSegment}`,
`${PrevPathSegments.ATTACHMENTS}/${id}`,
);
return id;
});
fileReferenceIds.push(fileReferenceId);
} else {
fileReferenceIds.push(dirname);
}
} else {
await fileManager.deleteDir(dirPathSegment);
}
}
}
if (fileReferenceIds.length > 0) {
await knex('attachment')
.update({
data: knex.raw('?? || \'{"fileReferenceId":null}\'', 'data'),
})
.where('type', Attachment.Types.FILE)
.whereRaw(`??->>'fileReferenceId' NOT IN (${fileReferenceIds.map(() => '?').join(', ')})`, [
'data',
...fileReferenceIds,
]);
}
const imageAttachments = await knex('attachment')
.where('type', Attachment.Types.FILE)
.whereRaw("??->>'image' IS NOT NULL", 'data');
for (const { data } of imageAttachments) {
const dirPathSegment = `${PrevPathSegments.ATTACHMENTS}/${data.fileReferenceId}`;
const thumbnailsPathSegment = `${dirPathSegment}/thumbnails`;
const isExists = await fileManager.isExists(
`${thumbnailsPathSegment}/outside-720.${data.image.thumbnailsExtension}`,
);
if (isExists) {
continue;
}
await fileManager.deleteDir(thumbnailsPathSegment);
let readStream;
try {
readStream = await fileManager.read(`${dirPathSegment}/${data.filename}`);
} catch (error) {
continue;
}
const buffer = await readStreamToBuffer(readStream);
const image = sharp(buffer, {
animated: true,
});
const outside360 = image
.clone()
.resize(360, 360, {
fit: 'outside',
withoutEnlargement: true,
})
.png({
quality: 75,
force: false,
});
await fileManager.save(
`${thumbnailsPathSegment}/outside-360.${data.image.thumbnailsExtension}`,
outside360,
data.mimeType,
);
const outside720 = image
.clone()
.resize(720, 720, {
fit: 'outside',
withoutEnlargement: true,
})
.png({
quality: 75,
force: false,
});
await fileManager.save(
`${thumbnailsPathSegment}/outside-720.${data.image.thumbnailsExtension}`,
outside720,
data.mimeType,
);
}
};
const upgradeDataStructure = async () => {
if (!sails.hooks.s3.isEnabled()) {
return;
}
const fileManager = sails.hooks['file-manager'].getInstance();
await fileManager.renameDir(PrevPathSegments.FAVICONS, sails.config.custom.faviconsPathSegment);
await fileManager.renameDir(
PrevPathSegments.USER_AVATARS,
sails.config.custom.userAvatarsPathSegment,
);
await fileManager.renameDir(
PrevPathSegments.BACKGROUND_IMAGES,
sails.config.custom.backgroundImagesPathSegment,
);
};
(async () => {
try {
let migrations;
try {
migrations = await knex('migration').orderBy('id');
} catch (error) {
throw new Error('Nothing to upgrade');
}
const migrationNames = migrations.map(({ name }) => name);
const isV1 = migrationNames[0] === '20180721020022_create_next_id_function.js';
const isLatestV1 = migrationNames.at(-1) === '20250131202710_add_list_color.js';
const isInitialV2 = migrationNames.at(-1) === '20250228000022_version_2.js';
if (isV1 && !isLatestV1) {
throw new Error('Update to latest v1 first');
}
await loadSails();
sails.config.custom.uploadsBasePath = sails.config.appPath;
if (isV1) {
await runStep('Upgrading database', upgradeDatabase);
}
if (isV1 || isInitialV2) {
await runStep('Upgrading user avatars', upgradeUserAvatars);
await runStep('Upgrading background images', upgradeBackgroundImages);
await runStep('Upgrading file attachments', upgradeFileAttachments);
}
await runStep('Upgrading data structure', upgradeDataStructure);
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
knex.destroy();
process.exit();
}
})();