From efa47d697d4c007f27c12f4f33eb62ef64e09e78 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:06:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(crm):=20Project=20Overview=20phase=201=20?= =?UTF-8?q?=E2=80=94=20projects=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data model for the admin-only Project Overview cockpit (Model A — projects group events; money docs stay per-event and roll up). - migration 117: projects table (name, customer_account_id nullable, status) + events.project_id FK; backfill one auto-project per existing event (1:1 default, customer = the event's single assignment when unambiguous), admins relink freely afterward. 1 project : N events. - migration 118: customer_hour_entries.project_id (book hours to a project). - migration 119: email_queue.rendered_html (store actual sent HTML for the cockpit's email preview). All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full migration boot + backfill on a temp DB. --- backend/migrations/core/117_add_projects.js | 85 +++++++++++++++++++ .../118_add_project_id_to_hour_entries.js | 30 +++++++ .../119_add_rendered_html_to_email_queue.js | 31 +++++++ 3 files changed, 146 insertions(+) create mode 100644 backend/migrations/core/117_add_projects.js create mode 100644 backend/migrations/core/118_add_project_id_to_hour_entries.js create mode 100644 backend/migrations/core/119_add_rendered_html_to_email_queue.js diff --git a/backend/migrations/core/117_add_projects.js b/backend/migrations/core/117_add_projects.js new file mode 100644 index 00000000..98417e27 --- /dev/null +++ b/backend/migrations/core/117_add_projects.js @@ -0,0 +1,85 @@ +/** + * 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. + if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) { + const events = await knex('events').whereNull('project_id').select('id', 'event_name'); + const hasAssignments = await knex.schema.hasTable('event_customer_assignments'); + for (const ev of events) { + let customerId = null; + if (hasAssignments) { + const rows = await knex('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 knex('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 knex('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'); + } +}; diff --git a/backend/migrations/core/118_add_project_id_to_hour_entries.js b/backend/migrations/core/118_add_project_id_to_hour_entries.js new file mode 100644 index 00000000..457faea8 --- /dev/null +++ b/backend/migrations/core/118_add_project_id_to_hour_entries.js @@ -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'); + }); + } +}; diff --git a/backend/migrations/core/119_add_rendered_html_to_email_queue.js b/backend/migrations/core/119_add_rendered_html_to_email_queue.js new file mode 100644 index 00000000..73309d16 --- /dev/null +++ b/backend/migrations/core/119_add_rendered_html_to_email_queue.js @@ -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'); + }); + } +};