feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from the-luap/picpeak#354 plugged into the maintainer's new feature-flag infrastructure (PR #443) instead of a parallel toggle. * New `customerPortal` feature flag (foundation flag for the not-yet-built calendar/quotes/bills/messaging customer surfaces). Defaults FALSE on fresh installs, TRUE on existing installs (events > 0) via migration 095 so live customer accounts don't disappear mid-deployment. * Foundation schema: customer_accounts, customer_invitations, event_customer_assignments, customer_password_resets, plus RBAC permissions customers.view / .create / .delete granted to super_admin + admin system roles. * Backend: /api/admin/customers (invite, list, search, assign, deactivate, reset password) + /api/customer/auth/* + /api/customer/* (login, dashboard, accept-invite, reset). Customer JWT bypass minted via /api/customer/events/:slug/access-token so existing gallery middleware stays untouched. * Frontend: /customer/* route tree gated by RequireFeature flag customerPortal, with login / dashboard / accept-invite / reset pages and a customer-side sidebar layout. /admin/customers and /admin/customers/:id gated identically. * Settings → Features grows a "Customers" section with a Customer portal card. The maintainer's Features tab stays the single source of truth — no parallel Advanced features tab. * CustomerAccountPicker on event create/edit forms hides itself when the flag is off; backend ignores customer_account_ids in that case instead of erroring the whole event save. Translations: en + de hand-translated. nl/pt/ru fall through to en — flagged here as needing native review. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Migration: Add Customer Accounts (recurring user logins)
|
||||
*
|
||||
* Implements the customer tier from discussion the-luap/picpeak#354.
|
||||
*
|
||||
* Three new tables:
|
||||
* - customer_accounts : the user record (email + bcrypt password)
|
||||
* - customer_invitations : admin → customer invite handshake (mirrors admin_invitations)
|
||||
* - event_customer_assignments: many-to-many junction with events
|
||||
*
|
||||
* Three new RBAC permissions seeded so super_admin and admin roles can
|
||||
* manage customers immediately after migrate. Editor / viewer remain
|
||||
* locked out by design (matches the existing users.* permissions pattern).
|
||||
*
|
||||
* Migration is idempotent — every step checks for existing state so a
|
||||
* partial install can be resumed.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// ---- customer_accounts -----------------------------------------------
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) {
|
||||
await knex.schema.createTable('customer_accounts', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('email', 255).unique().notNullable();
|
||||
|
||||
// --- auth ---------------------------------------------------------
|
||||
// password_hash is nullable until the invitation is accepted —
|
||||
// an unaccepted account row exists only after acceptInvitation, so
|
||||
// in practice this is always set, but the column is nullable to
|
||||
// allow for future "admin creates pre-loaded account" flows.
|
||||
table.string('password_hash', 255);
|
||||
table.boolean('must_change_password').notNullable().defaultTo(false);
|
||||
table.boolean('is_active').notNullable().defaultTo(true);
|
||||
// Tracks password-change time so JWTs issued before a password
|
||||
// change are rejected by customerAuth middleware. Mirrors the
|
||||
// admin_users.password_changed_at column.
|
||||
table.timestamp('password_changed_at');
|
||||
table.timestamp('last_login');
|
||||
table.string('last_login_ip', 45);
|
||||
table.string('preferred_language', 8).defaultTo('en');
|
||||
|
||||
// --- contact ------------------------------------------------------
|
||||
// Salutation honorific (Herr / Frau / Mx / Dr / Other). Stored as
|
||||
// free text rather than an enum so future locales (German "Frau",
|
||||
// French "Mme", legal titles "Dr.", etc.) don't need a migration.
|
||||
table.string('salutation', 32);
|
||||
table.string('first_name', 80);
|
||||
table.string('last_name', 80);
|
||||
// Convenience display name kept separately so the dashboard can
|
||||
// greet customers without joining first/last (e.g. "Welcome, Luca").
|
||||
table.string('display_name', 120);
|
||||
table.string('phone', 40);
|
||||
table.string('company_name', 120);
|
||||
|
||||
// --- billing / address (for future quotes & invoicing) -----------
|
||||
table.string('billing_email', 255);
|
||||
table.string('vat_id', 40);
|
||||
table.string('address_line1', 255);
|
||||
table.string('address_line2', 255);
|
||||
table.string('postal_code', 20);
|
||||
table.string('city', 120);
|
||||
table.string('state', 120);
|
||||
table.string('country_code', 2); // ISO 3166-1 alpha-2
|
||||
|
||||
// --- audit --------------------------------------------------------
|
||||
table.text('notes'); // free-text admin notes, never shown to the customer
|
||||
table.integer('created_by_admin_id').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['email']);
|
||||
table.index(['is_active']);
|
||||
table.index(['last_name']);
|
||||
table.index(['company_name']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- customer_invitations --------------------------------------------
|
||||
if (!(await knex.schema.hasTable('customer_invitations'))) {
|
||||
await knex.schema.createTable('customer_invitations', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('email', 255).notNullable();
|
||||
// 64 chars = 32 bytes hex = 256 bits — same entropy as admin invites.
|
||||
table.string('token', 64).unique().notNullable();
|
||||
table.integer('invited_by').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
|
||||
table.timestamp('expires_at').notNullable();
|
||||
table.timestamp('accepted_at');
|
||||
table.integer('accepted_customer_id').unsigned()
|
||||
.references('id').inTable('customer_accounts').onDelete('SET NULL');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['token']);
|
||||
table.index(['email']);
|
||||
table.index(['expires_at']);
|
||||
table.index(['accepted_at']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- event_customer_assignments --------------------------------------
|
||||
if (!(await knex.schema.hasTable('event_customer_assignments'))) {
|
||||
await knex.schema.createTable('event_customer_assignments', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('event_id').unsigned().notNullable()
|
||||
.references('id').inTable('events').onDelete('CASCADE');
|
||||
table.integer('customer_account_id').unsigned().notNullable()
|
||||
.references('id').inTable('customer_accounts').onDelete('CASCADE');
|
||||
table.integer('assigned_by_admin_id').unsigned()
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.timestamp('assigned_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.unique(['event_id', 'customer_account_id']);
|
||||
table.index(['customer_account_id']);
|
||||
table.index(['event_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- RBAC permissions -------------------------------------------------
|
||||
// Insert the three customers.* permissions if they're not already present
|
||||
// (guards against re-running the migration in dev). The same idempotency
|
||||
// pattern as 055_add_permissions_table.js.
|
||||
const existingPermissions = await knex('permissions').select('name');
|
||||
const existingNames = new Set(existingPermissions.map((p) => p.name));
|
||||
const newPermissions = [
|
||||
{
|
||||
name: 'customers.view',
|
||||
display_name: 'View Customers',
|
||||
category: 'customers',
|
||||
description: 'View customer accounts and their event assignments',
|
||||
},
|
||||
{
|
||||
name: 'customers.create',
|
||||
display_name: 'Invite Customers',
|
||||
category: 'customers',
|
||||
description: 'Issue customer invitations and assign customers to events',
|
||||
},
|
||||
{
|
||||
name: 'customers.delete',
|
||||
display_name: 'Deactivate Customers',
|
||||
category: 'customers',
|
||||
description: 'Deactivate customer accounts and revoke their access',
|
||||
},
|
||||
].filter((p) => !existingNames.has(p.name));
|
||||
|
||||
if (newPermissions.length > 0) {
|
||||
await knex('permissions').insert(newPermissions);
|
||||
}
|
||||
|
||||
// Grant the three permissions to super_admin and admin so the feature
|
||||
// is usable immediately. We look up role / permission ids fresh because
|
||||
// the inserts above just landed.
|
||||
const roles = await knex('roles').select('id', 'name')
|
||||
.whereIn('name', ['super_admin', 'admin']);
|
||||
const perms = await knex('permissions').select('id', 'name')
|
||||
.whereIn('name', ['customers.view', 'customers.create', 'customers.delete']);
|
||||
|
||||
if (roles.length > 0 && perms.length > 0) {
|
||||
const existing = await knex('role_permissions').select('role_id', 'permission_id');
|
||||
const existingSet = new Set(existing.map((m) => `${m.role_id}-${m.permission_id}`));
|
||||
const inserts = [];
|
||||
for (const role of roles) {
|
||||
for (const perm of perms) {
|
||||
const key = `${role.id}-${perm.id}`;
|
||||
if (!existingSet.has(key)) {
|
||||
inserts.push({ role_id: role.id, permission_id: perm.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inserts.length > 0) {
|
||||
await knex('role_permissions').insert(inserts);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- email template seed ---------------------------------------------
|
||||
// Schema is the post-075 shape: a master `email_templates` row keyed by
|
||||
// template_key, plus one `email_template_translations` row per language.
|
||||
// The legacy subject/body_html/body_text columns on email_templates may
|
||||
// still exist for back-compat, so we populate both wherever the column
|
||||
// is present — defensive, since some installs may run mid-upgrade.
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
const existing = await knex('email_templates')
|
||||
.where('template_key', 'customer_invitation')
|
||||
.first();
|
||||
|
||||
let templateId = existing?.id;
|
||||
if (!existing) {
|
||||
// Build the master row by introspecting the columns that actually
|
||||
// exist on this install. The schema has drifted across migrations
|
||||
// (075 adds language-specific columns then 075 normalises into a
|
||||
// separate translations table; some installs lack `created_at` /
|
||||
// `updated_at` on the master row). Anything not present is skipped
|
||||
// silently rather than causing the whole migration to abort and
|
||||
// taking the backend down with it.
|
||||
const masterColumns = await knex('email_templates').columnInfo();
|
||||
const insertRow = {
|
||||
template_key: 'customer_invitation',
|
||||
};
|
||||
if (masterColumns.variables) {
|
||||
insertRow.variables = JSON.stringify(['invite_link', 'expires_at']);
|
||||
}
|
||||
if (masterColumns.created_at) insertRow.created_at = knex.fn.now();
|
||||
if (masterColumns.updated_at) insertRow.updated_at = knex.fn.now();
|
||||
// Populate legacy single-language columns when present so older
|
||||
// email service code paths still find a sensible default body.
|
||||
if (masterColumns.subject) insertRow.subject = 'You\'ve been invited to access your photo galleries';
|
||||
if (masterColumns.body_html) {
|
||||
insertRow.body_html = '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}">Set up your account</a> (expires {{expires_at}}).</p>';
|
||||
}
|
||||
if (masterColumns.body_text) {
|
||||
insertRow.body_text = 'Set up your customer account: {{invite_link}} (expires {{expires_at}}).';
|
||||
}
|
||||
// Some installs have language-specific master columns from migration 075.
|
||||
if (masterColumns.subject_en) insertRow.subject_en = insertRow.subject || 'You\'ve been invited to access your photo galleries';
|
||||
if (masterColumns.body_html_en) insertRow.body_html_en = insertRow.body_html || '';
|
||||
if (masterColumns.body_text_en) insertRow.body_text_en = insertRow.body_text || '';
|
||||
|
||||
const [insertedId] = await knex('email_templates').insert(insertRow).returning('id');
|
||||
templateId = insertedId?.id || insertedId;
|
||||
}
|
||||
|
||||
if (templateId && await knex.schema.hasTable('email_template_translations')) {
|
||||
const transColumns = await knex('email_template_translations').columnInfo();
|
||||
const buildTranslationRow = (language, subject, bodyHtml, bodyText) => {
|
||||
const row = { template_id: templateId, language };
|
||||
if (transColumns.subject) row.subject = subject;
|
||||
if (transColumns.body_html) row.body_html = bodyHtml;
|
||||
if (transColumns.body_text) row.body_text = bodyText;
|
||||
if (transColumns.created_at) row.created_at = new Date();
|
||||
if (transColumns.updated_at) row.updated_at = new Date();
|
||||
return row;
|
||||
};
|
||||
|
||||
// The button uses the wrapper's `.button` class instead of inline
|
||||
// styles, which inherits the admin-configured `email_primary_color`
|
||||
// (Settings → Branding → Email palette). Inline `background-color`
|
||||
// would override it and lock the button to the legacy green
|
||||
// regardless of branding — that's the bug shipped on the very
|
||||
// first cut of this template.
|
||||
const en = buildTranslationRow(
|
||||
'en',
|
||||
'You\'ve been invited to access your photo galleries',
|
||||
`
|
||||
<h2>Welcome to your photo galleries</h2>
|
||||
<p>You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Set up your account</a>
|
||||
</div>
|
||||
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>If you weren't expecting this email, you can safely ignore it.</p>`,
|
||||
`Welcome to your photo galleries
|
||||
|
||||
You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
|
||||
|
||||
Set up your account: {{invite_link}}
|
||||
|
||||
This invitation expires on {{expires_at}}.
|
||||
|
||||
If you weren't expecting this email, you can safely ignore it.`
|
||||
);
|
||||
|
||||
const de = buildTranslationRow(
|
||||
'de',
|
||||
'Sie wurden eingeladen, auf Ihre Fotogalerien zuzugreifen',
|
||||
`
|
||||
<h2>Willkommen bei Ihren Fotogalerien</h2>
|
||||
<p>Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Konto einrichten</a>
|
||||
</div>
|
||||
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`,
|
||||
`Willkommen bei Ihren Fotogalerien
|
||||
|
||||
Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
|
||||
|
||||
Konto einrichten: {{invite_link}}
|
||||
|
||||
Diese Einladung läuft am {{expires_at}} ab.
|
||||
|
||||
Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.`
|
||||
);
|
||||
|
||||
for (const row of [en, de]) {
|
||||
const exists = await knex('email_template_translations')
|
||||
.where({ template_id: templateId, language: row.language })
|
||||
.first();
|
||||
if (!exists) {
|
||||
await knex('email_template_translations').insert(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Drop in reverse dependency order. Permissions / role grants are left
|
||||
// alone — they're idempotent on re-run and cleaning them on rollback
|
||||
// would require deleting role_permissions rows we may not own.
|
||||
await knex.schema.dropTableIfExists('event_customer_assignments');
|
||||
await knex.schema.dropTableIfExists('customer_invitations');
|
||||
await knex.schema.dropTableIfExists('customer_accounts');
|
||||
|
||||
// Best-effort cleanup of the three customers.* permissions if no other
|
||||
// code path inserted them. role_permissions rows cascade via FK.
|
||||
await knex('permissions').whereIn('name', [
|
||||
'customers.view',
|
||||
'customers.create',
|
||||
'customers.delete',
|
||||
]).del();
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Migration: Allow admins to pre-fill customer profile fields on invite (#354 follow-up).
|
||||
*
|
||||
* Adds a single `prefill_data` JSON column to `customer_invitations`. When an
|
||||
* admin invites a customer they can optionally pass first/last name, company,
|
||||
* phone and a billing address — that data is stashed here and copied onto the
|
||||
* new customer_accounts row by acceptInvitation(). The customer can then
|
||||
* confirm or edit those values on the accept-invite form before submitting.
|
||||
*
|
||||
* JSON instead of one column per field because:
|
||||
* - the prefill set may grow (vat id, salutation, etc.) and we don't want a
|
||||
* migration for every UI tweak;
|
||||
* - the data is only ever read+copied wholesale at accept time, never
|
||||
* filtered/queried.
|
||||
*
|
||||
* Migration is idempotent — bails out if the column already exists.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
const hasTable = await knex.schema.hasTable('customer_invitations');
|
||||
if (!hasTable) {
|
||||
// Migration 087 not yet run — nothing to alter. Should never happen in
|
||||
// practice (knex runs migrations in order), but be defensive.
|
||||
return;
|
||||
}
|
||||
|
||||
const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
|
||||
if (hasColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
await knex.schema.alterTable('customer_invitations', (table) => {
|
||||
// Knex's `json` type maps to JSONB on Postgres and TEXT on SQLite, which
|
||||
// matches how we already store other free-form payloads in this codebase
|
||||
// (see app_settings.theme_config). Nullable: invitations sent the
|
||||
// old way (or via the API without a body) should still work.
|
||||
table.json('prefill_data');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasTable = await knex.schema.hasTable('customer_invitations');
|
||||
if (!hasTable) return;
|
||||
const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
|
||||
if (!hasColumn) return;
|
||||
await knex.schema.alterTable('customer_invitations', (table) => {
|
||||
table.dropColumn('prefill_data');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Migration: Customer-surface feature flags, branding toggles, and password resets (#354 follow-up).
|
||||
*
|
||||
* Three things in one migration so a single rollback returns the install
|
||||
* to the prior state:
|
||||
*
|
||||
* 1. Per-customer feature flags on customer_accounts:
|
||||
* feature_calendar / feature_quotes / feature_bills (BOOLEAN, default
|
||||
* false). Default false because the matching pages are still
|
||||
* coming-soon stubs — admin opts a customer in once the feature is
|
||||
* actually useful for them. Combined with the global toggles below
|
||||
* via AND-logic in the customer session response.
|
||||
*
|
||||
* 2. Global customer-surface toggles seeded into app_settings under
|
||||
* setting_type='customer_surface':
|
||||
* customer_feature_calendar_enabled (default false)
|
||||
* customer_feature_quotes_enabled (default false)
|
||||
* customer_feature_bills_enabled (default false)
|
||||
* customer_show_logo (default true — preserves
|
||||
* current visual behaviour)
|
||||
* customer_show_company_name (default true — preserves
|
||||
* current visual behaviour)
|
||||
*
|
||||
* 3. customer_password_resets table — admin-triggered password reset
|
||||
* flow. Distinct from customer_invitations (which is the "create
|
||||
* account" flow): a reset always points at an existing customer_id
|
||||
* and updates the existing password_hash on accept.
|
||||
*
|
||||
* Plus the customer_password_reset email template, idempotently seeded.
|
||||
*
|
||||
* All steps are idempotent — a partial rollout can be resumed by re-running
|
||||
* `knex migrate:latest`.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// ---- per-customer feature flags ---------------------------------------
|
||||
|
||||
if (await knex.schema.hasTable('customer_accounts')) {
|
||||
const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
|
||||
for (const col of cols) {
|
||||
const exists = await knex.schema.hasColumn('customer_accounts', col);
|
||||
if (!exists) {
|
||||
// Add as a separate alterTable per column so a half-applied
|
||||
// migration (column A added, B failing) leaves the table in a
|
||||
// consistent state on retry.
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean(col).notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- global customer-surface settings ---------------------------------
|
||||
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
const seeds = [
|
||||
// Features: default false. Admin must explicitly enable on the
|
||||
// settings page before the corresponding sidebar entry can show
|
||||
// for any customer.
|
||||
{ setting_key: 'customer_feature_calendar_enabled', setting_value: false, setting_type: 'customer_surface' },
|
||||
{ setting_key: 'customer_feature_quotes_enabled', setting_value: false, setting_type: 'customer_surface' },
|
||||
{ setting_key: 'customer_feature_bills_enabled', setting_value: false, setting_type: 'customer_surface' },
|
||||
// Branding: default true so existing installs keep their current
|
||||
// logo + company name in the customer header until the admin
|
||||
// opts to hide them.
|
||||
{ setting_key: 'customer_show_logo', setting_value: true, setting_type: 'customer_surface' },
|
||||
{ setting_key: 'customer_show_company_name', setting_value: true, setting_type: 'customer_surface' },
|
||||
];
|
||||
|
||||
for (const row of seeds) {
|
||||
const existing = await knex('app_settings').where('setting_key', row.setting_key).first();
|
||||
if (!existing) {
|
||||
// Postgres JSONB column accepts both a JSON literal and a
|
||||
// JSON-stringified value depending on driver version. Stringify
|
||||
// for SQLite compatibility; Postgres accepts the same shape.
|
||||
await knex('app_settings').insert({
|
||||
setting_key: row.setting_key,
|
||||
setting_value: JSON.stringify(row.setting_value),
|
||||
setting_type: row.setting_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- customer_password_resets table -----------------------------------
|
||||
|
||||
if (!(await knex.schema.hasTable('customer_password_resets'))) {
|
||||
await knex.schema.createTable('customer_password_resets', (table) => {
|
||||
table.increments('id').primary();
|
||||
// 64-char hex token, same shape as invitations and admin invites.
|
||||
table.string('token', 64).unique().notNullable();
|
||||
// Always points at an existing customer_account; if the account is
|
||||
// deleted, the reset disappears too.
|
||||
table.integer('customer_account_id').notNullable()
|
||||
.references('id').inTable('customer_accounts').onDelete('CASCADE');
|
||||
table.integer('requested_by_admin_id')
|
||||
.references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
table.timestamp('expires_at').notNullable();
|
||||
table.timestamp('used_at');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.index('token');
|
||||
table.index('customer_account_id');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- customer_password_reset email template ---------------------------
|
||||
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
const existing = await knex('email_templates').where('template_key', 'customer_password_reset').first();
|
||||
if (!existing) {
|
||||
// Two email_templates schema variants exist in the wild:
|
||||
//
|
||||
// (a) legacy single-locale: subject / body_html / body_text columns
|
||||
// (b) multi-locale: subject_en / subject_de / body_html_en /
|
||||
// body_text_en / ... — all NOT NULL on at least one install
|
||||
// (the maintainer's prod), where the previous version of this
|
||||
// migration silently produced a 23502 NOT NULL violation and
|
||||
// crash-looped the backend.
|
||||
//
|
||||
// Detect whichever variant is present and populate every matching
|
||||
// column. For non-en locale columns we fall back to the English
|
||||
// content so the install isn't left with NULL-violation rows;
|
||||
// proper translations can be filled in later via the admin UI.
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
const SUBJECT = 'Reset your password';
|
||||
const BODY_HTML = `<p>Hello,</p>
|
||||
<p>Your photographer has triggered a password reset for your customer account.</p>
|
||||
<p><a href="{{reset_link}}">Click here to set a new password</a>. This link expires on {{expires_at}}.</p>
|
||||
<p>If you didn't expect this, you can ignore the message — your current password will keep working until you click the link.</p>`;
|
||||
const BODY_TEXT = `Your photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`;
|
||||
|
||||
const row = {};
|
||||
if ('template_key' in cols) row.template_key = 'customer_password_reset';
|
||||
if ('language' in cols) row.language = 'en';
|
||||
if ('is_active' in cols) row.is_active = true;
|
||||
if ('created_at' in cols) row.created_at = new Date();
|
||||
if ('updated_at' in cols) row.updated_at = new Date();
|
||||
|
||||
// Populate every subject/body column that exists, regardless of
|
||||
// locale suffix. Fallback content == English; safe because email
|
||||
// templates are user-editable post-install.
|
||||
for (const colName of Object.keys(cols)) {
|
||||
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
|
||||
row[colName] = SUBJECT;
|
||||
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
|
||||
row[colName] = BODY_HTML;
|
||||
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
|
||||
row[colName] = BODY_TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
await knex('email_templates').insert(row);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// ---- table -----------------------------------------------------------
|
||||
if (await knex.schema.hasTable('customer_password_resets')) {
|
||||
await knex.schema.dropTable('customer_password_resets');
|
||||
}
|
||||
|
||||
// ---- per-customer flags ---------------------------------------------
|
||||
if (await knex.schema.hasTable('customer_accounts')) {
|
||||
const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
|
||||
for (const col of cols) {
|
||||
if (await knex.schema.hasColumn('customer_accounts', col)) {
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.dropColumn(col);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settings -------------------------------------------------------
|
||||
if (await knex.schema.hasTable('app_settings')) {
|
||||
await knex('app_settings').whereIn('setting_key', [
|
||||
'customer_feature_calendar_enabled',
|
||||
'customer_feature_quotes_enabled',
|
||||
'customer_feature_bills_enabled',
|
||||
'customer_show_logo',
|
||||
'customer_show_company_name',
|
||||
]).del();
|
||||
}
|
||||
|
||||
// ---- template --------------------------------------------------------
|
||||
if (await knex.schema.hasTable('email_templates')) {
|
||||
await knex('email_templates').where('template_key', 'customer_password_reset').del();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Migration: Flip the per-customer feature flag semantic to "opt-out".
|
||||
*
|
||||
* Original semantic (089): both global toggle AND per-customer flag had
|
||||
* to be ON for a customer to see Calendar/Quotes/Bills. Defaults: false.
|
||||
* Result: enabling a feature globally was a no-op until the admin clicked
|
||||
* through every customer detail page and toggled them on individually.
|
||||
*
|
||||
* New semantic (this migration): global toggle is the master; per-customer
|
||||
* flag defaults to TRUE and only acts as an override-to-hide. So:
|
||||
*
|
||||
* - Global ON, per-customer default (true) → visible
|
||||
* - Global ON, per-customer set to false → hidden for this customer
|
||||
* - Global OFF, per-customer anything → hidden (master wins)
|
||||
*
|
||||
* Migration steps:
|
||||
* 1. Update column defaults to true so new customer_accounts rows
|
||||
* auto-opt-in.
|
||||
* 2. Flip every existing row's feature_* columns from false → true.
|
||||
* Rows that were never touched (i.e. ALL of them at this stage in
|
||||
* dev) end up with the new default. If a maintainer had already
|
||||
* hand-toggled a customer to false to hide a feature, that's
|
||||
* indistinguishable from the seeded default at this layer — so
|
||||
* this migration deliberately overwrites. Acceptable because the
|
||||
* original semantic only shipped for one image and nobody is
|
||||
* relying on hand-set false values yet.
|
||||
*
|
||||
* Idempotent: re-running is a no-op (the UPDATE just confirms current
|
||||
* values).
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) return;
|
||||
|
||||
// Step 1 — change defaults. Knex's .alter() rewrites the column;
|
||||
// we keep notNullable to match 089.
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean('feature_calendar').notNullable().defaultTo(true).alter();
|
||||
table.boolean('feature_quotes').notNullable().defaultTo(true).alter();
|
||||
table.boolean('feature_bills').notNullable().defaultTo(true).alter();
|
||||
});
|
||||
|
||||
// Step 2 — flip existing rows so they pick up the new default. Without
|
||||
// this, customers created on the 089-shipped image stay invisible even
|
||||
// after the admin enables the feature globally.
|
||||
await knex('customer_accounts').update({
|
||||
feature_calendar: true,
|
||||
feature_quotes: true,
|
||||
feature_bills: true,
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('customer_accounts'))) return;
|
||||
// Restore the 089 default of false. Don't bulk-update existing rows
|
||||
// back to false: that would silently hide features for customers the
|
||||
// admin had explicitly enabled post-090.
|
||||
await knex.schema.alterTable('customer_accounts', (table) => {
|
||||
table.boolean('feature_calendar').notNullable().defaultTo(false).alter();
|
||||
table.boolean('feature_quotes').notNullable().defaultTo(false).alter();
|
||||
table.boolean('feature_bills').notNullable().defaultTo(false).alter();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Migration: Re-theme the customer_invitation email's CTA button.
|
||||
*
|
||||
* The original 087 seed inlined `background-color: #5C8762` on the
|
||||
* "Set up your account" anchor, which locked the button to the legacy
|
||||
* green regardless of the admin's `email_primary_color` setting
|
||||
* (Settings → Branding → Email palette). The wrapper template
|
||||
* (emailProcessor.wrapEmailHtml) already exposes a `.button` class
|
||||
* that inherits the configured palette — switching the anchor over
|
||||
* is a one-line change, but existing installs already have the bad
|
||||
* HTML in their email_template_translations rows. This migration
|
||||
* rewrites those rows so the next outbound invitation picks up the
|
||||
* brand colour.
|
||||
*
|
||||
* Idempotent: only updates rows whose stored body still contains the
|
||||
* old hardcoded anchor markup. If the admin has hand-edited the
|
||||
* template (typical for non-English locales they translated
|
||||
* themselves) the row is left alone.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
if (!(await knex.schema.hasTable('email_template_translations'))) return;
|
||||
|
||||
const master = await knex('email_templates')
|
||||
.where('template_key', 'customer_invitation')
|
||||
.first();
|
||||
if (!master) return; // 087 hasn't run on this install — nothing to fix.
|
||||
|
||||
// English translation
|
||||
const enRow = await knex('email_template_translations')
|
||||
.where({ template_id: master.id, language: 'en' })
|
||||
.first();
|
||||
if (enRow && typeof enRow.body_html === 'string'
|
||||
&& enRow.body_html.includes('background-color: #5C8762')
|
||||
&& enRow.body_html.includes('Set up your account')) {
|
||||
const newHtml = `
|
||||
<h2>Welcome to your photo galleries</h2>
|
||||
<p>You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Set up your account</a>
|
||||
</div>
|
||||
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>If you weren't expecting this email, you can safely ignore it.</p>`;
|
||||
await knex('email_template_translations')
|
||||
.where({ id: enRow.id })
|
||||
.update({ body_html: newHtml, updated_at: new Date() });
|
||||
}
|
||||
|
||||
// German translation
|
||||
const deRow = await knex('email_template_translations')
|
||||
.where({ template_id: master.id, language: 'de' })
|
||||
.first();
|
||||
if (deRow && typeof deRow.body_html === 'string'
|
||||
&& deRow.body_html.includes('background-color: #5C8762')
|
||||
&& deRow.body_html.includes('Konto einrichten')) {
|
||||
const newHtml = `
|
||||
<h2>Willkommen bei Ihren Fotogalerien</h2>
|
||||
<p>Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Konto einrichten</a>
|
||||
</div>
|
||||
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`;
|
||||
await knex('email_template_translations')
|
||||
.where({ id: deRow.id })
|
||||
.update({ body_html: newHtml, updated_at: new Date() });
|
||||
}
|
||||
|
||||
// Legacy single-language column on the master row, if present.
|
||||
// Older installs may also have a hardcoded body_html on email_templates
|
||||
// itself (pre-translations table). Same idempotent rewrite logic.
|
||||
if (typeof master.body_html === 'string'
|
||||
&& master.body_html.includes('background-color: #5C8762')) {
|
||||
await knex('email_templates')
|
||||
.where({ id: master.id })
|
||||
.update({
|
||||
body_html: '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}" class="button">Set up your account</a> (expires {{expires_at}}).</p>',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(/* knex */) {
|
||||
// No-op: rolling back the visual fix would intentionally restore the
|
||||
// bug. Admins who want the old green button can edit the template
|
||||
// from Settings → Email Templates.
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Migration 094: Add `customerPortal` to feature_flags.
|
||||
*
|
||||
* The customer portal (#354) is the foundation feature for the
|
||||
* customer-side UI surface — login, dashboard, profile, password reset,
|
||||
* and the admin Customers management page. Subordinate flags
|
||||
* (calendar, calendarBooking, quotes, bills, messaging) are already
|
||||
* present in the table from migration 088 and gate the customer-side
|
||||
* tabs that hang off the dashboard.
|
||||
*
|
||||
* Default seeding rule mirrors 088:
|
||||
* - Existing install (events table has rows) → customerPortal = TRUE.
|
||||
* The PR ships with the customer-portal foundation already wired,
|
||||
* so an admin who upgrades shouldn't see admin sidebar entries
|
||||
* vanish until they explicitly opt out from Settings → Features.
|
||||
* - Fresh install (no events) → customerPortal = FALSE. Picpeak still
|
||||
* ships as a focused gallery delivery tool by default; admins flip
|
||||
* this on when they want recurring-customer logins.
|
||||
*
|
||||
* Idempotent: skips the insert when the row already exists. Re-running
|
||||
* is a no-op.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
|
||||
const existing = await knex('feature_flags').where({ key: 'customerPortal' }).first();
|
||||
if (existing) return;
|
||||
|
||||
// Same existing-vs-fresh detection 088 uses — count events. The flag
|
||||
// table is shared with 088's seeded keys; re-running detection keeps
|
||||
// each new feature flag in lockstep with the install state instead
|
||||
// of guessing per migration.
|
||||
const eventCountRow = await knex('events').count({ count: '*' }).first();
|
||||
const eventCount = parseInt(eventCountRow?.count || 0, 10);
|
||||
const isExistingInstall = eventCount > 0;
|
||||
|
||||
await knex('feature_flags').insert({
|
||||
key: 'customerPortal',
|
||||
value: isExistingInstall,
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||
await knex('feature_flags').where({ key: 'customerPortal' }).del();
|
||||
};
|
||||
Reference in New Issue
Block a user