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:
Luca
2026-05-11 00:05:20 +02:00
co-authored by Claude Opus 4.6
parent f2f48f31b0
commit 087ef45942
54 changed files with 9816 additions and 54 deletions
@@ -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();
};