feat(crm): Project Overview phase 1 — projects schema
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.
This commit is contained in:
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user