Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
245 lines
7.6 KiB
JavaScript
245 lines
7.6 KiB
JavaScript
/**
|
|
* Add description_rich_text columns to project_tasks and project_template_tasks.
|
|
*
|
|
* The existing `description` column is repurposed to store markdown,
|
|
* while the new `description_rich_text` column stores the BlockNote JSON.
|
|
*
|
|
* Backfill:
|
|
* 1. Copy current description → description_rich_text (it's already BlockNote JSON)
|
|
* 2. Convert description from BlockNote JSON → markdown
|
|
*/
|
|
|
|
/**
|
|
* BlockNote-JSON → markdown converter for migration backfill.
|
|
*
|
|
* Mirrors the runtime converter in @alga-psa/formatting/blocknoteUtils.ts.
|
|
* Since migrations are CJS and the runtime converter is TypeScript/ESM,
|
|
* we maintain a parallel implementation here. The description_rich_text
|
|
* column preserves the original BlockNote JSON, so any minor drift is
|
|
* self-healing on first editor save.
|
|
*/
|
|
function blockNoteJsonToMarkdown(json) {
|
|
if (!json || typeof json !== 'string') return json;
|
|
|
|
const trimmed = json.trim();
|
|
// Only attempt parse if it looks like a BlockNote JSON array
|
|
if (!trimmed.startsWith('[')) return json;
|
|
|
|
let blocks;
|
|
try {
|
|
blocks = JSON.parse(trimmed);
|
|
} catch {
|
|
return json; // plain text — leave as-is
|
|
}
|
|
|
|
if (!Array.isArray(blocks) || blocks.length === 0) return json;
|
|
|
|
return blocks
|
|
.map((block) => {
|
|
const text = extractText(block.content);
|
|
|
|
switch (block.type) {
|
|
case 'paragraph':
|
|
return text;
|
|
case 'heading': {
|
|
const level = block.props?.level || 1;
|
|
return '#'.repeat(level) + ' ' + text;
|
|
}
|
|
case 'bulletListItem':
|
|
return '* ' + text;
|
|
case 'numberedListItem':
|
|
return '1. ' + text;
|
|
case 'checkListItem': {
|
|
const checked = block.props?.checked ? 'x' : ' ';
|
|
return `- [${checked}] ${text}`;
|
|
}
|
|
case 'codeBlock': {
|
|
const lang = block.props?.language || '';
|
|
return '```' + lang + '\n' + text + '\n```';
|
|
}
|
|
case 'image': {
|
|
const url = block.props?.url || '';
|
|
const alt = block.props?.caption || block.props?.name || 'image';
|
|
return url ? `` : '';
|
|
}
|
|
case 'table':
|
|
return convertTableToMarkdown(block);
|
|
default:
|
|
return text;
|
|
}
|
|
})
|
|
.join('\n\n');
|
|
}
|
|
|
|
function extractText(content) {
|
|
if (!content || !Array.isArray(content)) return '';
|
|
return content
|
|
.map((item) => {
|
|
if (typeof item.text === 'string') {
|
|
let t = item.text;
|
|
if (item.styles) {
|
|
if (item.styles.bold) t = `**${t}**`;
|
|
if (item.styles.italic) t = `*${t}*`;
|
|
if (item.styles.code) t = '`' + t + '`';
|
|
if (item.styles.strikethrough) t = `~~${t}~~`;
|
|
if (item.styles.underline) t = `<u>${t}</u>`;
|
|
if (item.styles.textColor && item.styles.textColor !== 'default') {
|
|
t = `<span style="color:${item.styles.textColor}">${t}</span>`;
|
|
}
|
|
if (item.styles.backgroundColor && item.styles.backgroundColor !== 'default') {
|
|
t = `<span style="background-color:${item.styles.backgroundColor}">${t}</span>`;
|
|
}
|
|
}
|
|
return t;
|
|
}
|
|
if (item.type === 'mention') {
|
|
const name = item.props?.username || item.props?.displayName || '';
|
|
return name ? `@${name}` : '';
|
|
}
|
|
if (item.type === 'link' && Array.isArray(item.content)) {
|
|
const linkText = extractText(item.content);
|
|
const href = item.href || '';
|
|
return href ? `[${linkText}](${href})` : linkText;
|
|
}
|
|
return '';
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
function convertTableToMarkdown(block) {
|
|
const content = block.content;
|
|
if (!content || typeof content !== 'object' || !content.rows) return '';
|
|
|
|
const rows = content.rows || [];
|
|
if (rows.length === 0) return '';
|
|
|
|
const numCols = rows[0].cells ? rows[0].cells.length : 0;
|
|
if (numCols === 0) return '';
|
|
|
|
let md = '';
|
|
rows.forEach((row, rowIndex) => {
|
|
const cells = row.cells || [];
|
|
let rowMd = '|';
|
|
for (let c = 0; c < numCols; c++) {
|
|
const cell = cells[c] || [];
|
|
let cellText = Array.isArray(cell) ? extractText(cell) : ' ';
|
|
if (!cellText || !cellText.trim()) cellText = ' ';
|
|
rowMd += ` ${cellText} |`;
|
|
}
|
|
md += rowMd + '\n';
|
|
if (rowIndex === 0) {
|
|
md += '|' + ' --- |'.repeat(numCols) + '\n';
|
|
}
|
|
});
|
|
return md.trimEnd();
|
|
}
|
|
|
|
/**
|
|
* Check whether a column already exists on a table (idempotent guard).
|
|
*/
|
|
async function columnExists(knex, tableName, columnName) {
|
|
const result = await knex.raw(`
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_name = ? AND column_name = ?
|
|
LIMIT 1
|
|
`, [tableName, columnName]);
|
|
return result.rows.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Convert descriptions from BlockNote JSON → markdown for a single table,
|
|
* processing one tenant at a time to bound memory and isolate failures.
|
|
*/
|
|
async function convertDescriptions(knex, tableName, pkColumn) {
|
|
const tenants = await knex(tableName)
|
|
.distinct('tenant')
|
|
.whereNotNull('description');
|
|
|
|
for (const { tenant } of tenants) {
|
|
await knex.transaction(async (trx) => {
|
|
const rows = await trx(tableName)
|
|
.select(pkColumn, 'tenant', 'description')
|
|
.where({ tenant })
|
|
.whereNotNull('description');
|
|
|
|
for (const row of rows) {
|
|
const md = blockNoteJsonToMarkdown(row.description);
|
|
if (md !== row.description) {
|
|
await trx(tableName)
|
|
.where({ [pkColumn]: row[pkColumn], tenant: row.tenant })
|
|
.update({ description: md });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('knex').Knex} knex
|
|
*/
|
|
exports.up = async function (knex) {
|
|
// 1. Add columns (idempotent — skip if already present from a partial run)
|
|
if (!(await columnExists(knex, 'project_tasks', 'description_rich_text'))) {
|
|
await knex.schema.alterTable('project_tasks', (table) => {
|
|
table.text('description_rich_text').nullable();
|
|
});
|
|
}
|
|
|
|
if (!(await columnExists(knex, 'project_template_tasks', 'description_rich_text'))) {
|
|
await knex.schema.alterTable('project_template_tasks', (table) => {
|
|
table.text('description_rich_text').nullable();
|
|
});
|
|
}
|
|
|
|
// 2. Backfill: copy existing description (BlockNote JSON) → description_rich_text
|
|
// Only fill rows that haven't been backfilled yet (idempotent).
|
|
await knex.raw(`
|
|
UPDATE project_tasks
|
|
SET description_rich_text = description
|
|
WHERE description IS NOT NULL
|
|
AND description_rich_text IS NULL
|
|
`);
|
|
|
|
await knex.raw(`
|
|
UPDATE project_template_tasks
|
|
SET description_rich_text = description
|
|
WHERE description IS NOT NULL
|
|
AND description_rich_text IS NULL
|
|
`);
|
|
|
|
// 3. Convert description from BlockNote JSON → markdown, per tenant
|
|
await convertDescriptions(knex, 'project_tasks', 'task_id');
|
|
await convertDescriptions(knex, 'project_template_tasks', 'template_task_id');
|
|
};
|
|
|
|
/**
|
|
* @param {import('knex').Knex} knex
|
|
*/
|
|
exports.down = async function (knex) {
|
|
// Restore description from description_rich_text (BlockNote JSON)
|
|
await knex.raw(`
|
|
UPDATE project_tasks
|
|
SET description = description_rich_text
|
|
WHERE description_rich_text IS NOT NULL
|
|
`);
|
|
|
|
await knex.raw(`
|
|
UPDATE project_template_tasks
|
|
SET description = description_rich_text
|
|
WHERE description_rich_text IS NOT NULL
|
|
`);
|
|
|
|
if (await columnExists(knex, 'project_tasks', 'description_rich_text')) {
|
|
await knex.schema.alterTable('project_tasks', (table) => {
|
|
table.dropColumn('description_rich_text');
|
|
});
|
|
}
|
|
|
|
if (await columnExists(knex, 'project_template_tasks', 'description_rich_text')) {
|
|
await knex.schema.alterTable('project_template_tasks', (table) => {
|
|
table.dropColumn('description_rich_text');
|
|
});
|
|
}
|
|
};
|