Merge origin/beta into feat/accounting-inbound-invoices

Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
This commit is contained in:
Luca
2026-06-15 16:37:23 +02:00
46 changed files with 2556 additions and 96 deletions
@@ -0,0 +1,90 @@
/**
* Migration: Projects — an admin-only grouping layer ABOVE events
* (Project Overview cockpit, Model A).
*
* A project groups one OR MORE events of (usually) one customer; all the
* money documents (quotes/contracts/invoices) stay attached to their EVENT
* and the project simply rolls them up. Customers never see projects.
*
* projects id, name, customer_account_id (nullable), status,
* timestamps.
* events.project_id FK → projects (nullable, SET NULL on project delete).
*
* Backfill: every existing event gets its OWN auto-created project (the
* 1:1 default) so nothing is unassigned; admins then relink freely (group
* several events under one project, move events between projects). The
* auto-project's customer = the event's single assigned customer when there
* is exactly one, else NULL (admin sets it later). Cardinality is 1:N — the
* per-event auto-project is only the starting point, never a hard rule.
*
* Idempotent: table + column guarded; backfill touches only events whose
* project_id is still NULL, so a re-run is a no-op.
*/
exports.up = async function (knex) {
// 1. projects table
if (!(await knex.schema.hasTable('projects'))) {
await knex.schema.createTable('projects', (table) => {
table.increments('id').primary();
table.string('name', 255).notNullable();
// Nullable: a multi-customer or not-yet-assigned project has no single
// customer. SET NULL so erasing a customer doesn't delete the project.
table.integer('customer_account_id').unsigned()
.references('id').inTable('customer_accounts').onDelete('SET NULL');
table.string('status', 24).notNullable().defaultTo('active');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index(['customer_account_id']);
});
}
// 2. events.project_id
if ((await knex.schema.hasTable('events')) && !(await knex.schema.hasColumn('events', 'project_id'))) {
await knex.schema.alterTable('events', (table) => {
table.integer('project_id').unsigned()
.references('id').inTable('projects').onDelete('SET NULL');
table.index(['project_id']);
});
}
// 3. Backfill one auto-project per still-unassigned event. Wrapped in a
// single transaction so a heavy install (10k+ events) can't be left
// half-assigned if the loop dies mid-way — it's all-or-nothing, and a
// re-run still no-ops (gated on whereNull('project_id')).
if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) {
const hasAssignments = await knex.schema.hasTable('event_customer_assignments');
await knex.transaction(async (trx) => {
const events = await trx('events').whereNull('project_id').select('id', 'event_name');
for (const ev of events) {
let customerId = null;
if (hasAssignments) {
const rows = await trx('event_customer_assignments')
.where({ event_id: ev.id })
.select('customer_account_id');
if (rows.length === 1) customerId = rows[0].customer_account_id;
}
const name = (ev.event_name && String(ev.event_name).trim()) || `Event ${ev.id}`;
const inserted = await trx('projects').insert({
name,
customer_account_id: customerId,
status: 'active',
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
}).returning('id');
const projectId = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
await trx('events').where({ id: ev.id }).update({ project_id: projectId });
}
});
}
};
exports.down = async function (knex) {
if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('project_id');
});
}
if (await knex.schema.hasTable('projects')) {
await knex.schema.dropTable('projects');
}
};
@@ -0,0 +1,30 @@
/**
* Migration: book logged hours to a project.
*
* Adds customer_hour_entries.project_id (nullable FK → projects, SET NULL).
* Hours stay primarily customer-scoped; the optional project link powers the
* "book to project" checkbox + the Project Overview hours roll-up. Null =
* not booked to a project (existing behaviour preserved).
*
* Idempotent: column guarded by hasColumn.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('customer_hour_entries'))) return;
if (!(await knex.schema.hasColumn('customer_hour_entries', 'project_id'))) {
await knex.schema.alterTable('customer_hour_entries', (table) => {
table.integer('project_id').unsigned()
.references('id').inTable('projects').onDelete('SET NULL');
table.index(['project_id']);
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('customer_hour_entries'))) return;
if (await knex.schema.hasColumn('customer_hour_entries', 'project_id')) {
await knex.schema.alterTable('customer_hour_entries', (table) => {
table.dropColumn('project_id');
});
}
};
@@ -0,0 +1,31 @@
/**
* Migration: store the rendered email HTML at send time.
*
* The Project Overview cockpit previews the ACTUAL email that was sent (not a
* re-render from the current template, which may have changed). email_queue
* only stored the template variables (email_data), so add a rendered_html
* column the sender populates with the final wrapped HTML on dispatch.
*
* Nullable: rows queued/sent before this column existed have no stored HTML —
* the cockpit reconstructs those from email_data with a "reconstructed" note.
*
* Idempotent: column guarded by hasColumn.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('email_queue'))) return;
if (!(await knex.schema.hasColumn('email_queue', 'rendered_html'))) {
await knex.schema.alterTable('email_queue', (table) => {
table.text('rendered_html');
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('email_queue'))) return;
if (await knex.schema.hasColumn('email_queue', 'rendered_html')) {
await knex.schema.alterTable('email_queue', (table) => {
table.dropColumn('rendered_html');
});
}
};
@@ -0,0 +1,24 @@
/**
* Migration: seed the `projects` feature flag (default OFF).
*
* Gates the admin-only Project Overview cockpit + the "book to project" hours
* control. Off by default so existing installs don't suddenly surface a new
* top-level CRM area — the admin opts in under Settings → Features, exactly
* like bills/quotes/contracts/hours.
*
* Idempotent: inserts only when the row is missing (migration 088 already
* seeded the original flag set on fresh installs and won't re-run).
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
const existing = await knex('feature_flags').where({ key: 'projects' }).first();
if (!existing) {
await knex('feature_flags').insert({ key: 'projects', value: false });
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
await knex('feature_flags').where({ key: 'projects' }).del();
};
@@ -0,0 +1,65 @@
/**
* Migration: link quotes + contracts to a project.
*
* Quotes and contracts carry no event_id (see migration 107), so the Project
* Overview cockpit originally rolled them up by the project's customer — which
* is imprecise once a customer has more than one project. This adds an explicit
* `project_id` FK to both tables so the rollup is exact, and the quote/contract
* editors get a project picker.
*
* quotes.project_id FK → projects (nullable, SET NULL on project delete).
* contracts.project_id FK → projects (nullable, SET NULL on project delete).
*
* Backfill: only the unambiguous case. For every customer that owns EXACTLY ONE
* project, link that customer's still-unassigned quotes/contracts to it. Customers
* with several projects stay unassigned — the admin links them via the picker
* (we can't guess which project a document belongs to).
*
* Idempotent: columns guarded; backfill touches only NULL project_id rows.
*/
exports.up = async function (knex) {
for (const tbl of ['quotes', 'contracts']) {
if ((await knex.schema.hasTable(tbl)) && !(await knex.schema.hasColumn(tbl, 'project_id'))) {
await knex.schema.alterTable(tbl, (table) => {
table.integer('project_id').unsigned()
.references('id').inTable('projects').onDelete('SET NULL');
table.index(['project_id']);
});
}
}
if (!(await knex.schema.hasTable('projects'))) return;
// Customers that own exactly one project → the unambiguous backfill target.
const projects = await knex('projects').whereNotNull('customer_account_id').select('id', 'customer_account_id');
const byCustomer = new Map();
for (const p of projects) {
const list = byCustomer.get(p.customer_account_id) || [];
list.push(p.id);
byCustomer.set(p.customer_account_id, list);
}
for (const [customerId, projectIds] of byCustomer.entries()) {
if (projectIds.length !== 1) continue;
const projectId = projectIds[0];
for (const tbl of ['quotes', 'contracts']) {
if (!(await knex.schema.hasTable(tbl))) continue;
if (!(await knex.schema.hasColumn(tbl, 'customer_account_id'))) continue;
await knex(tbl)
.where({ customer_account_id: customerId })
.whereNull('project_id')
.update({ project_id: projectId });
}
}
};
exports.down = async function (knex) {
for (const tbl of ['quotes', 'contracts']) {
if ((await knex.schema.hasTable(tbl)) && (await knex.schema.hasColumn(tbl, 'project_id'))) {
await knex.schema.alterTable(tbl, (table) => {
table.dropColumn('project_id');
});
}
}
};