Files
picpeak/backend/src/services/customerAccountsService.js
T
Luca c657892bc8 feat(gallery): publish notifies assigned customer accounts via the account email
Publishing a gallery with no inline customer_email but assigned customer
account(s) previously sent nothing (the dialog said "no notification"). Now the
publish route falls back to the existing customer_gallery_assigned "your
galleries" email (sent per assigned active account in their preferred language)
so registered customers learn the gallery is available. Inline-email path
(gallery_created) is unchanged.

The publish dialog now reflects this: with an inline email it notifies that
address; with only assigned accounts it says the account(s) will be notified;
with neither, the button is just "Publish" (no false notify promise). Exports
notifyCustomerOfNewAssignments; EN/DE strings added.
2026-06-26 15:26:28 +02:00

1472 lines
58 KiB
JavaScript

/**
* Customer Accounts Service
*
* Recurring user logins (the third user tier alongside admin and guest).
* See discussion the-luap/picpeak#354 and migration 087 for context.
*
* Mirrors userManagementService.js for invitation lifecycle but operates
* on customer_accounts / customer_invitations / event_customer_assignments
* — separate token type ('customer'), simpler permission model (a customer
* either has access to a given event or doesn't, no RBAC).
*/
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { queueEmail } = require('./emailProcessor');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const logger = require('../utils/logger');
const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors');
const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, matches admin invites
/**
* Fire customer.created for the workflow engine, from every creation path
* (direct add + invitation accept). Best-effort / fail-closed; never throws
* into the caller.
*/
async function emitCustomerCreated(id, email) {
try {
await require('./workflows').emitWorkflowEvent('customer.created', {
entityType: 'customer',
entityId: id,
payload: { customerAccountId: id, customerEmail: email || null },
});
} catch (err) {
logger.warn('Failed to emit customer.created workflow event', { customerId: id, error: err.message });
}
}
/**
* Whitelist of customer profile fields the admin is allowed to pre-fill on
* an invitation (and that the customer can then edit on accept). Centralised
* so both invite-create and accept paths agree on what survives the round
* trip.
*/
const PREFILLABLE_FIELDS = [
'salutation',
'first_name',
'last_name',
'display_name',
'phone',
'company_name',
'vat_id',
'address_line1',
'address_line2',
'postal_code',
'city',
'state',
'country_code',
'country_name',
// Locale used for portal UI AND for quote/invoice PDF rendering.
// Admin can pre-set this on the invitation so a German customer
// gets German documents from the very first invoice, without
// waiting for them to log in and pick their language.
'preferred_language',
];
/**
* Sanitise a free-form prefill payload coming from the admin UI. Trims
* whitespace, drops anything not in the whitelist, uppercases ISO country
* codes, and returns null if the result is effectively empty so we don't
* litter the DB with `{}` rows.
*/
function sanitisePrefill(input) {
if (!input || typeof input !== 'object') return null;
const out = {};
for (const field of PREFILLABLE_FIELDS) {
const raw = input[field];
if (raw === undefined || raw === null) continue;
const trimmed = String(raw).trim();
if (!trimmed) continue;
if (field === 'country_code') {
out[field] = trimmed.toUpperCase().slice(0, 2);
} else {
out[field] = trimmed;
}
}
return Object.keys(out).length > 0 ? out : null;
}
/**
* Decode the prefill_data column. Postgres returns it pre-parsed; SQLite /
* older drivers may hand back a JSON string. Tolerate both, fail soft.
*/
function decodePrefill(raw) {
if (!raw) return null;
if (typeof raw === 'object') return raw;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Create a new customer invitation.
*
* Idempotency: rejects if an active customer with this email already
* exists, OR if a non-expired pending invitation is already in flight.
* The latter is intentional — re-sending an invite while one is open
* would mint two valid tokens, doubling the attack surface. Admins must
* cancel the open invitation first if they want to re-send.
*
* @returns {Promise<{ id, email, token, expiresAt }>}
*/
async function createInvitation({ email, invitedById, prefill }) {
const normalisedEmail = String(email || '').trim().toLowerCase();
if (!normalisedEmail) {
throw new ValidationError('Email is required');
}
const existingCustomer = await db('customer_accounts')
.where('email', normalisedEmail)
.first();
if (existingCustomer && existingCustomer.password_hash) {
// Already-active customer with this email — duplicate, reject.
throw new ConflictError('A customer account with this email already exists', 'email');
}
// If the existing customer is PASSIVE (password_hash IS NULL), this
// is the "promote to active" path: the admin clicked "Send portal
// invitation" on a passive customer. Allow the invitation through —
// acceptInvitation handles the UPSERT into the existing row.
const pendingInvite = await db('customer_invitations')
.where('email', normalisedEmail)
.whereNull('accepted_at')
.where('expires_at', '>', new Date())
.first();
if (pendingInvite) {
throw new ConflictError('A pending invitation already exists for this email', 'email');
}
// 64-char hex = 32 bytes = 256 bits of entropy. Same as admin invites.
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + INVITATION_TTL_MS);
const sanitisedPrefill = sanitisePrefill(prefill);
const [insertedId] = await db('customer_invitations').insert({
email: normalisedEmail,
token,
invited_by: invitedById,
expires_at: expiresAt,
created_at: new Date(),
// Stringify so SQLite (TEXT-typed json column) and Postgres (JSONB)
// both store the same shape. Skip the column entirely on null so
// databases predating migration 088 don't reject the insert.
...(sanitisedPrefill ? { prefill_data: JSON.stringify(sanitisedPrefill) } : {}),
}).returning('id');
const id = insertedId?.id || insertedId;
// Queue invitation email. The customer-facing accept page lives at
// /customer/invite/:token (see CustomerAcceptInvitePage.tsx).
//
// Resolve the frontend base URL through the shared helper so the link
// honours Site Settings → Site URL (general_site_url) rather than the
// local FRONTEND_URL env var. The helper still falls back to
// FRONTEND_URL → 'http://localhost:3000' if the setting isn't set, but
// a configured deployment will always use its real domain. (Admin
// invites in userManagementService use the env-only fallback — that's
// a separate bug to be fixed alongside this PR or after.)
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
await queueEmail(null, normalisedEmail, 'customer_invitation', {
invite_link: `${frontendUrl}/customer/invite/${token}`,
expires_at: expiresAt.toISOString(),
});
await logActivity('customer_invitation_created',
{ email: normalisedEmail },
null,
{ type: 'admin', id: invitedById, name: 'system' }
);
logger.info('Customer invitation created', { email: normalisedEmail, invitedById });
return { id, email: normalisedEmail, token, expiresAt };
}
/**
* Create a "passive" customer directly — no invitation, no email.
*
* Used for two flows:
* 1. Admin opens the quote/invoice editor, clicks "+ Create new
* customer", fills out the form, hits "Save as passive customer".
* The customer becomes available immediately as the recipient of
* the document the admin is working on.
* 2. Admin opens the same form and hits "Save & send portal
* invitation". The editor calls createDirect first to mint the
* customer id, then calls the send-invite route to fire the
* onboarding email. (Two separate API calls — easier to reason
* about than an atomic endpoint.)
*
* A passive customer is identified by `password_hash IS NULL`. The
* customerAuth middleware already rejects login for those (bcrypt
* compare against null returns false), so we don't need a separate
* "is_passive" column or an extra gate.
*
* Race-guarded against duplicate emails the same way createInvitation
* is — a real duplicate throws ConflictError.
*
* @param {{ email, prefill, createdByAdminId }} args
* @returns {Promise<{ id }>} The new customer's id.
*/
async function createDirect({ email, prefill, createdByAdminId }) {
const normalisedEmail = String(email || '').trim().toLowerCase();
if (!normalisedEmail) throw new ValidationError('Email is required');
const existing = await db('customer_accounts')
.where('email', normalisedEmail)
.first();
if (existing) {
throw new ConflictError('A customer account with this email already exists', 'email');
}
// Same default-locale resolution as acceptInvitation so German
// shops get German customers automatically.
let defaultPreferredLanguage = 'en';
try {
// eslint-disable-next-line global-require
const businessProfileService = require('./businessProfileService');
const { profile: bp } = await businessProfileService.getProfile();
if (bp && bp.default_locale) defaultPreferredLanguage = bp.default_locale;
} catch (_) { /* keep 'en' fallback */ }
const sanitised = sanitisePrefill(prefill) || {};
const preferredLanguage = sanitised.preferred_language || defaultPreferredLanguage;
const [inserted] = await db('customer_accounts').insert({
email: normalisedEmail,
salutation: sanitised.salutation || null,
first_name: sanitised.first_name || null,
last_name: sanitised.last_name || null,
display_name: sanitised.display_name || null,
phone: sanitised.phone || null,
company_name: sanitised.company_name || null,
vat_id: sanitised.vat_id || null,
address_line1: sanitised.address_line1 || null,
address_line2: sanitised.address_line2 || null,
postal_code: sanitised.postal_code || null,
city: sanitised.city || null,
state: sanitised.state || null,
country_code: sanitised.country_code || null,
country_name: sanitised.country_name || null,
preferred_language: preferredLanguage,
password_hash: null,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
password_changed_at: null,
created_by_admin_id: createdByAdminId || null,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = inserted?.id || inserted;
await logActivity('customer_created_passive',
{ customerId: id, email: normalisedEmail },
null,
{ type: 'admin', id: createdByAdminId || null, name: 'system' }
);
logger.info('Passive customer created', { id, email: normalisedEmail, createdByAdminId });
await emitCustomerCreated(id, normalisedEmail);
return { id };
}
/**
* Accept an invitation. Creates the customer_accounts row in a transaction
* and marks the invitation accepted, so a partial failure can't leave a
* dangling account or a re-usable token.
*/
async function acceptInvitation({ token, name, password, profile }) {
const invitation = await db('customer_invitations')
.where('token', token)
.whereNull('accepted_at')
.where('expires_at', '>', new Date())
.first();
if (!invitation) {
throw new ValidationError('Invalid or expired invitation');
}
// Race-condition guard: an admin may have created the customer
// manually (passive customer flow, migration-119-era and later)
// between the invite link being generated and clicked.
//
// Two cases:
// - existing.password_hash IS NOT NULL → real duplicate, 409
// - existing.password_hash IS NULL → passive customer being
// promoted to active. Branch to the UPSERT path further down so
// the customer's id (and all the rows that reference it —
// invoices, quotes, gallery assignments) survive promotion.
const existing = await db('customer_accounts')
.where('email', invitation.email)
.first();
if (existing && existing.password_hash) {
throw new ConflictError('Email already registered', 'email');
}
const promoting = !!existing && !existing.password_hash;
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
// Merge the admin's prefill with whatever the customer typed on the accept
// form. Customer-supplied values win on every key — the accept page shows
// the prefill values pre-populated but the customer is allowed to correct
// anything (e.g. an admin's typo in the company name).
const adminPrefill = decodePrefill(invitation.prefill_data) || {};
const customerProfile = sanitisePrefill(profile) || {};
const merged = { ...adminPrefill, ...customerProfile };
// The legacy single-name field still wins over a separately-typed
// display_name only if the merged record didn't carry one. Keeps backwards
// compatibility with clients that haven't been updated to send the
// structured profile.
if (name && !merged.display_name) {
merged.display_name = String(name).trim();
}
// Default the customer's preferred_language to the business profile's
// default_locale. Migration 090 sets the schema default to 'en' which
// is a poor fit for a Swiss/DE business — by pulling from the
// configured profile we make sure German shops issue German quotes
// and invoices to their new customers automatically. Customer-typed
// value still wins (if the accept form ever exposes the picker), and
// the admin can always override later on the customer detail page.
// Lazy require to avoid a service-cycle with businessProfileService.
let defaultPreferredLanguage = 'en';
try {
// eslint-disable-next-line global-require
const businessProfileService = require('./businessProfileService');
const { profile: bp } = await businessProfileService.getProfile();
if (bp && bp.default_locale) defaultPreferredLanguage = bp.default_locale;
} catch (_) { /* keep 'en' fallback */ }
const preferredLanguage = merged.preferred_language || defaultPreferredLanguage;
const customerId = await db.transaction(async (trx) => {
let id;
if (promoting) {
// Promotion path: passive customer being claimed by the
// customer themselves via the invitation link. UPDATE the
// existing row (preserving id + all foreign-key relationships)
// instead of inserting. We merge the profile fields: anything
// the customer typed on the accept form wins; values they
// didn't touch leave the existing row untouched.
id = existing.id;
const updates = {
password_hash: passwordHash,
password_changed_at: null,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
updated_at: new Date(),
};
// Only overwrite profile fields when the merged payload
// actually carries a value — never blank out existing data
// (the customer might have left a field empty because the
// admin had pre-filled it correctly).
const overwriteIfSet = (key, col = key) => {
if (merged[key] != null && merged[key] !== '') updates[col] = merged[key];
};
overwriteIfSet('salutation');
overwriteIfSet('first_name');
overwriteIfSet('last_name');
overwriteIfSet('display_name');
overwriteIfSet('phone');
overwriteIfSet('company_name');
overwriteIfSet('vat_id');
overwriteIfSet('address_line1');
overwriteIfSet('address_line2');
overwriteIfSet('postal_code');
overwriteIfSet('city');
overwriteIfSet('state');
overwriteIfSet('country_code');
if (merged.preferred_language) updates.preferred_language = merged.preferred_language;
await trx('customer_accounts').where('id', id).update(updates);
} else {
const [inserted] = await trx('customer_accounts').insert({
email: invitation.email,
// Profile fields land directly on the customer row. Anything the user
// didn't set stays null.
salutation: merged.salutation || null,
first_name: merged.first_name || null,
last_name: merged.last_name || null,
display_name: merged.display_name || null,
phone: merged.phone || null,
company_name: merged.company_name || null,
vat_id: merged.vat_id || null,
address_line1: merged.address_line1 || null,
address_line2: merged.address_line2 || null,
postal_code: merged.postal_code || null,
city: merged.city || null,
state: merged.state || null,
country_code: merged.country_code || null,
preferred_language: preferredLanguage,
password_hash: passwordHash,
is_active: formatBoolean(true),
// must_change_password is decorative today — accept-invite always
// sets a customer-chosen password, so this flag is never true and
// customerAuth doesn't read it. TODO when we ship an "admin
// pre-loads a temporary password" flow: surface a code in the
// login response (mirroring adminAuth's MUST_CHANGE_PASSWORD) and
// add a /change-password gate to customerAuth.
must_change_password: formatBoolean(false),
// Leave password_changed_at NULL on initial accept. Setting it here
// creates a millisecond/second-rounding race with the JWT issued
// by the immediate /login call: stored timestamp X.500ms can floor
// to X+1 in postgres while the JWT's iat lands at X, causing the
// customerAuth middleware's `iat < password_changed_at` check to
// reject perfectly valid tokens on the very next page reload. We
// populate password_changed_at only when an actual password change
// happens later (deactivate / reset flows).
password_changed_at: null,
created_by_admin_id: invitation.invited_by,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
id = inserted?.id || inserted;
}
await trx('customer_invitations')
.where('id', invitation.id)
.update({ accepted_at: new Date(), accepted_customer_id: id });
return id;
});
await logActivity('customer_invitation_accepted',
{ customerId, email: invitation.email, invitationId: invitation.id },
null,
{ type: 'system', id: null, name: 'system' }
);
logger.info('Customer invitation accepted', { customerId, email: invitation.email });
await emitCustomerCreated(customerId, invitation.email);
return { customerId, email: invitation.email };
}
/**
* Look up an invitation token without consuming it. Used by the accept
* page so it can render the email + expiry before the user submits.
*/
async function validateInvitationToken(token) {
const invitation = await db('customer_invitations')
.leftJoin('admin_users', 'admin_users.id', 'customer_invitations.invited_by')
.where('customer_invitations.token', token)
.whereNull('customer_invitations.accepted_at')
.where('customer_invitations.expires_at', '>', new Date())
.select(
'customer_invitations.email',
'customer_invitations.expires_at',
'customer_invitations.prefill_data',
'admin_users.username as invited_by_username'
)
.first();
if (!invitation) return null;
return {
...invitation,
prefill: decodePrefill(invitation.prefill_data),
};
}
/**
* Customer roster for the admin Customers page. Includes a count of how
* many events each customer has access to, so the admin can spot orphaned
* accounts at a glance.
*/
async function listCustomers({ search } = {}) {
let q = db('customer_accounts')
.leftJoin('event_customer_assignments', 'event_customer_assignments.customer_account_id', 'customer_accounts.id')
.groupBy('customer_accounts.id')
.select(
'customer_accounts.id',
'customer_accounts.email',
'customer_accounts.display_name',
'customer_accounts.first_name',
'customer_accounts.last_name',
'customer_accounts.salutation',
'customer_accounts.company_name',
'customer_accounts.is_active',
// Surfaced so the route's transformCustomer can compute the
// `isPassive` flag (passwordHash == null). The actual hash
// never leaves the API — transformCustomer drops it.
'customer_accounts.password_hash',
// Per-customer feature flags + hourly rate (migrations 092/129).
// Surfaced on the LIST endpoint so the standalone Hours-logging
// page can filter the customer dropdown to only customers with
// hours logging enabled, and read the default rate without an
// N+1 detail fetch. Without these in the SELECT,
// transformCustomer evaluates the four feature_* booleans as
// false (column absent → undefined → coerce to false).
'customer_accounts.feature_calendar',
'customer_accounts.feature_quotes',
'customer_accounts.feature_bills',
'customer_accounts.feature_hours_logging',
'customer_accounts.feature_contracts',
'customer_accounts.hourly_rate_minor',
'customer_accounts.last_login',
'customer_accounts.created_at',
db.raw('COUNT(event_customer_assignments.id) as event_count')
)
.orderBy('customer_accounts.created_at', 'desc');
if (search && String(search).trim()) {
const term = `%${String(search).trim().toLowerCase()}%`;
q = q.where(function () {
this.whereRaw('LOWER(customer_accounts.email) LIKE ?', [term])
.orWhereRaw('LOWER(COALESCE(customer_accounts.display_name, \'\')) LIKE ?', [term])
.orWhereRaw('LOWER(COALESCE(customer_accounts.last_name, \'\')) LIKE ?', [term])
.orWhereRaw('LOWER(COALESCE(customer_accounts.company_name, \'\')) LIKE ?', [term]);
});
}
return q;
}
/**
* Single customer record + their event assignments. Used by the admin
* detail view; the customer's own dashboard uses listEventsForCustomer.
*/
async function getCustomerById(id) {
const customer = await db('customer_accounts').where('id', id).first();
if (!customer) {
throw new NotFoundError('Customer', id);
}
const events = await db('event_customer_assignments')
.join('events', 'events.id', 'event_customer_assignments.event_id')
.where('event_customer_assignments.customer_account_id', id)
.select(
'events.id',
'events.slug',
'events.event_name',
'events.event_date',
'events.expires_at',
'events.is_archived',
'event_customer_assignments.assigned_at'
)
.orderBy('event_customer_assignments.assigned_at', 'desc');
return { ...customer, events };
}
/**
* Update customer profile. Admins can edit any field except auth-related
* columns (password_hash, password_changed_at, must_change_password) which
* are mutated by deactivate / reset / accept paths only.
*
* email changes deliberately allowed — the admin may need to correct a
* typo before the customer accepts. Uniqueness is enforced.
*/
async function updateCustomer(id, updates, updatedByAdminId) {
const customer = await db('customer_accounts').where('id', id).first();
if (!customer) {
throw new NotFoundError('Customer', id);
}
const allowed = {};
const fields = [
'email', 'salutation', 'first_name', 'last_name', 'display_name',
'phone', 'company_name', 'billing_email', 'vat_id',
'address_line1', 'address_line2', 'postal_code', 'city', 'state',
'country_code', 'country_name', 'preferred_language', 'notes',
// Per-customer feature flags (#354 follow-up). Booleans below are
// coerced via formatBoolean for SQLite compatibility.
'feature_calendar', 'feature_quotes', 'feature_bills', 'feature_hours_logging',
// Per-customer contracts override (migration 131). Defaults TRUE so
// existing customers keep their Contracts tab.
'feature_contracts',
// CRM billing cadence (migration 102). 'per_event' (default) keeps
// each invoice firing on its own schedule; monthly/quarterly snap
// every scheduled invoice to billing_cycle_day of the next period.
'billing_cadence', 'billing_cycle_day',
// Hour-logging default rate (migration 129). Minor units; null
// means admin must enter a per-entry override on every entry.
'hourly_rate_minor',
// Per-customer Skonto opt-out (migration 112). Boolean, coerced
// via formatBoolean below for SQLite compatibility.
'skonto_disabled',
];
for (const f of fields) {
if (updates[f] !== undefined) {
// Trim+lowercase email; everything else passes through. country_code
// is uppercased to match ISO 3166-1 alpha-2 convention.
if (f === 'email') {
allowed[f] = String(updates[f] || '').trim().toLowerCase();
} else if (f === 'country_code' && updates[f]) {
allowed[f] = String(updates[f]).trim().toUpperCase().slice(0, 2);
} else if (
f === 'feature_calendar' || f === 'feature_quotes'
|| f === 'feature_bills' || f === 'feature_hours_logging'
|| f === 'feature_contracts'
|| f === 'skonto_disabled'
) {
allowed[f] = formatBoolean(updates[f]);
} else if (f === 'hourly_rate_minor') {
// Default hourly rate. Null clears it (forces per-entry
// overrides); otherwise coerce to a non-negative bigint-safe
// integer. Anything funky → null.
if (updates[f] === null || updates[f] === '') {
allowed[f] = null;
} else {
const v = parseInt(updates[f], 10);
allowed[f] = Number.isFinite(v) && v >= 0 ? v : null;
}
} else if (f === 'billing_cadence') {
// Whitelist enum. Anything else flips to 'per_event' so we
// never persist garbage that the scheduler can't interpret.
const v = String(updates[f] || '').toLowerCase();
allowed[f] = ['per_event', 'monthly', 'quarterly'].includes(v) ? v : 'per_event';
} else if (f === 'billing_cycle_day') {
// Sign carries the interpretation:
// positive 1..28 → day-of-month (clamped to month length at
// schedule time, so cycleDay=28 stays valid
// in February)
// negative -1..-15 → that many days before end of month
// (cycleDay=-3 on a 31-day month fires on
// the 28th; on a 28-day February fires on
// the 25th)
// Zero is meaningless and clamps to 1 so the column never
// stores "the 0th of the month".
const v = parseInt(updates[f], 10);
if (!Number.isFinite(v) || v === 0) {
allowed[f] = 1;
} else if (v > 0) {
allowed[f] = Math.min(28, v);
} else {
allowed[f] = Math.max(-15, v);
}
} else {
allowed[f] = updates[f];
}
}
}
if (allowed.email && allowed.email !== customer.email) {
const conflict = await db('customer_accounts')
.where('email', allowed.email)
.whereNot('id', id)
.first();
if (conflict) {
throw new ConflictError('Email already in use', 'email');
}
}
if (updates.is_active !== undefined) {
allowed.is_active = formatBoolean(updates.is_active);
}
allowed.updated_at = new Date();
await db('customer_accounts').where('id', id).update(allowed);
await logActivity('customer_updated',
{ customerId: id, fields: Object.keys(allowed) },
null,
{ type: 'admin', id: updatedByAdminId, name: 'system' }
);
return getCustomerById(id);
}
/**
* Soft-delete: is_active=false. Existing JWTs become invalid because the
* customerAuth middleware re-checks is_active on every request. Junction
* rows are kept for audit (event history shows who had access historically).
*/
async function deactivateCustomer(id, deactivatedByAdminId) {
const customer = await db('customer_accounts').where('id', id).first();
if (!customer) {
throw new NotFoundError('Customer', id);
}
await db('customer_accounts').where('id', id).update({
is_active: formatBoolean(false),
// Bumping password_changed_at invalidates any outstanding tokens
// immediately — same trick adminAuth uses.
password_changed_at: new Date(),
updated_at: new Date(),
});
await logActivity('customer_deactivated',
{ customerId: id, email: customer.email },
null,
{ type: 'admin', id: deactivatedByAdminId, name: 'system' }
);
logger.info('Customer deactivated', { customerId: id, deactivatedByAdminId });
}
/**
* Reactivate a previously-deactivated customer. Restores login (is_active
* back to true), but does NOT re-grant any historical assignments — those
* were never removed (deactivate keeps the junction rows for audit), so
* the customer immediately sees the same galleries they had before.
*/
async function reactivateCustomer(id, reactivatedByAdminId) {
const customer = await db('customer_accounts').where('id', id).first();
if (!customer) {
throw new NotFoundError('Customer', id);
}
if (customer.is_active) {
return; // already active, no-op
}
await db('customer_accounts').where('id', id).update({
is_active: formatBoolean(true),
// Don't touch password_changed_at — the customer's password (if set)
// remains valid. They log in with their existing credential.
updated_at: new Date(),
});
await logActivity('customer_reactivated',
{ customerId: id, email: customer.email },
null,
{ type: 'admin', id: reactivatedByAdminId, name: 'system' }
);
logger.info('Customer reactivated', { customerId: id, reactivatedByAdminId });
}
/**
* GDPR-style erasure: anonymize-in-place rather than hard delete.
*
* Why anonymize, not delete?
* - `customer_invitations.accepted_customer_id` has no ON DELETE CASCADE
* (Postgres default RESTRICT), so a hard delete would fail if the
* customer accepted any invitations. Anonymize sidesteps the FK
* constraint entirely.
* - `event_customer_assignments` and `activity_logs` rows referencing
* this customer are part of the gallery's access audit trail. Wiping
* them would leave gaps that "who had access to this event" queries
* can't recover from.
*
* What we do:
* - Replace the email with a sentinel `deleted-<id>-<random>@deleted.invalid`
* so unique-on-email holds and the address can never collide with a
* real customer the admin invites later.
* - NULL every PII column (name, phone, company, vat, full address, notes).
* - Wipe `password_hash` so credentials can't be reused.
* - Set `is_active=false` and bump `password_changed_at` so any
* outstanding tokens die immediately.
* - Delete pending invitations + reset tokens for this customer.
*
* What we keep:
* - The customer_accounts row itself (anonymized).
* - Their event_customer_assignments rows (with a now-anonymized FK).
* - All activity_logs / access_logs (audit trail).
*
* Wrapped in a transaction so a partial failure doesn't leave half-erased
* state.
*/
async function eraseCustomer(id, erasedByAdminId) {
const customer = await db('customer_accounts').where('id', id).first();
if (!customer) {
throw new NotFoundError('Customer', id);
}
// Sentinel email — uses the .invalid TLD (RFC 6761) so it can't be
// a valid deliverable address, even by accident. Includes the id +
// a random suffix so a re-erase of a different account doesn't
// collide on the unique index.
const sentinelEmail = `deleted-${id}-${crypto.randomBytes(4).toString('hex')}@deleted.invalid`;
await db.transaction(async (trx) => {
await trx('customer_accounts').where('id', id).update({
email: sentinelEmail,
salutation: null,
first_name: null,
last_name: null,
display_name: null,
phone: null,
company_name: null,
billing_email: null,
vat_id: null,
address_line1: null,
address_line2: null,
postal_code: null,
city: null,
state: null,
country_code: null,
notes: null,
password_hash: null,
is_active: formatBoolean(false),
must_change_password: formatBoolean(false),
password_changed_at: new Date(),
updated_at: new Date(),
});
// Drop pending invitations the customer hasn't accepted yet AND any
// that ARE pointed at this customer (accepted_customer_id) — keep the
// history columns (email + invited_by + accepted_at) on the
// already-accepted ones since those reference the now-sentinel email
// and serve as audit. We just clear the FK pointer.
await trx('customer_invitations').where('email', customer.email).whereNull('accepted_at').del();
await trx('customer_invitations').where('accepted_customer_id', id).update({
// Keep the row, drop the back-pointer so a future hard-delete (if
// ever added) doesn't FK-block.
accepted_customer_id: null,
});
// Active reset tokens for this customer should be invalidated.
await trx('customer_password_resets').where('customer_account_id', id).del();
// Pending re-bills (incoming invoices, migration 132) attached to this
// customer would otherwise stay billable to the now-anonymized account —
// return the not-yet-billed ones to the inbox for re-triage so they're not
// silently lost or billed to a ghost (PR #636 review #2). Guarded for
// schema drift on installs that predate migration 132.
if (await trx.schema.hasColumn('inbound_documents', 'customer_account_id')) {
await trx('inbound_documents')
.where({ customer_account_id: id })
.whereNull('billed_invoice_id')
.update({ customer_account_id: null, disposition: null, status: 'unsorted', updated_at: new Date() });
}
});
await logActivity('customer_erased',
{ customerId: id, originalEmail: customer.email },
null,
{ type: 'admin', id: erasedByAdminId, name: 'system' }
);
logger.info('Customer erased (anonymized in place)', { customerId: id, erasedByAdminId });
}
/**
* Autocomplete for the event-form picker. Returns up to `limit` rows
* matching the email/name prefix. Active customers only — deactivated
* accounts shouldn't show up as assignable options.
*/
async function searchCustomers(query, { limit = 10 } = {}) {
const term = `%${String(query || '').trim().toLowerCase()}%`;
if (!term || term === '%%') return [];
return db('customer_accounts')
.where('is_active', formatBoolean(true))
.andWhere(function () {
this.whereRaw('LOWER(email) LIKE ?', [term])
.orWhereRaw('LOWER(COALESCE(display_name, \'\')) LIKE ?', [term])
.orWhereRaw('LOWER(COALESCE(last_name, \'\')) LIKE ?', [term])
.orWhereRaw('LOWER(COALESCE(company_name, \'\')) LIKE ?', [term]);
})
// password_hash is required by transformCustomer to compute the
// isPassive flag (passwordHash == null = passive / admin-only).
// Omitting it caused every search result to render as "Passive —
// admin only" because `undefined == null` is true. The hash itself
// is dropped by the route's transformCustomer before leaving the API.
//
// G.2 — `feature_hours_logging` is required by the calendar's
// drag-create modal (F.6) so the CustomerPicker can render the
// "Hour logging disabled" badge. Omitting it from this SELECT
// caused the badge to appear on EVERY search result regardless
// of the actual per-customer flag, because transformCustomer
// coerces undefined → false.
.select(
'id', 'email', 'display_name', 'first_name', 'last_name', 'company_name',
'password_hash', 'feature_hours_logging',
)
.orderBy('email', 'asc')
.limit(limit);
}
// ---- assignments ---------------------------------------------------------
/**
* Replace the entire assignment set for one event. Used by the admin
* event create/update endpoints when they receive `customer_account_ids`
* — diff-and-apply inside one transaction so the event row and its
* assignments either both update or neither does.
*
* `targetCustomerIds` may be empty to clear all assignments.
*/
async function setAssignmentsForEvent(eventId, targetCustomerIds, adminId, trx = db) {
const wanted = new Set((targetCustomerIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0));
const existing = await trx('event_customer_assignments')
.where('event_id', eventId)
.select('id', 'customer_account_id');
const existingIds = new Set(existing.map((r) => r.customer_account_id));
const toAdd = [...wanted].filter((id) => !existingIds.has(id));
const toRemove = existing.filter((r) => !wanted.has(r.customer_account_id));
if (toRemove.length > 0) {
await trx('event_customer_assignments')
.whereIn('id', toRemove.map((r) => r.id))
.del();
}
if (toAdd.length > 0) {
// Validate the customers exist + are active before inserting. Cheaper
// than catching FK errors and gives the admin a clear error message.
const valid = await trx('customer_accounts')
.whereIn('id', toAdd)
.where('is_active', formatBoolean(true))
.pluck('id');
const validSet = new Set(valid);
const ignored = toAdd.filter((id) => !validSet.has(id));
if (ignored.length > 0) {
logger.warn('Ignoring inactive/missing customer ids in assignment', {
eventId, ignored,
});
}
const rows = [...validSet].map((customerId) => ({
event_id: eventId,
customer_account_id: customerId,
assigned_by_admin_id: adminId,
assigned_at: new Date(),
}));
if (rows.length > 0) {
await trx('event_customer_assignments').insert(rows);
}
}
return { added: toAdd.length, removed: toRemove.length };
}
/**
* Inverse of setAssignmentsForEvent: replace the full set of events a
* single customer is assigned to. Backs the "Manage galleries" dialog
* on the customer detail page — admins pick from every available
* event and we diff against the existing row set.
*
* Returns { added, removed } so the caller can surface a useful toast.
*
* `targetEventIds` may be empty to clear every assignment.
*
* Access revocation: removing a row from event_customer_assignments
* is enough on its own — gallery middleware (galleryMiddleware.js)
* checks for a live assignment whenever it decodes a JWT minted via
* the customer access-token endpoint (decoded.via === 'customer').
* No separate revoked_tokens write needed; the customer's next
* request 401s the moment this transaction commits.
*/
async function setAssignmentsForCustomer(customerId, targetEventIds, adminId, trx = db) {
const wanted = new Set((targetEventIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0));
const existing = await trx('event_customer_assignments')
.where('customer_account_id', customerId)
.select('id', 'event_id');
const existingIds = new Set(existing.map((r) => r.event_id));
const toAdd = [...wanted].filter((id) => !existingIds.has(id));
const toRemove = existing.filter((r) => !wanted.has(r.event_id));
if (toRemove.length > 0) {
await trx('event_customer_assignments')
.whereIn('id', toRemove.map((r) => r.id))
.del();
}
// Collect the event IDs that actually landed in the DB (i.e. survived
// the archived/missing filter) so the post-commit notifier knows
// exactly which galleries to mention in the email. Empty by default.
let addedEventIds = [];
if (toAdd.length > 0) {
// Validate the events exist + are not archived before inserting.
// Mirrors the customer-side check in setAssignmentsForEvent so an
// admin can't accidentally pin a customer to an archived event
// that they couldn't actually open anyway.
const valid = await trx('events')
.whereIn('id', toAdd)
.where('is_archived', formatBoolean(false))
.pluck('id');
const validSet = new Set(valid);
const ignored = toAdd.filter((id) => !validSet.has(id));
if (ignored.length > 0) {
logger.warn('Ignoring missing/archived event ids in customer assignment', {
customerId, ignored,
});
}
const rows = [...validSet].map((eventId) => ({
event_id: eventId,
customer_account_id: customerId,
assigned_by_admin_id: adminId,
assigned_at: new Date(),
}));
if (rows.length > 0) {
await trx('event_customer_assignments').insert(rows);
addedEventIds = rows.map((r) => r.event_id);
}
}
// Notify the customer about newly-accessible galleries. Best-effort
// — a failure here must not roll back the assignment write, so we
// fire-and-forget after the transactional work is done and swallow
// any throw with a warn log. Skipped when no new rows were added.
if (addedEventIds.length > 0) {
notifyCustomerOfNewAssignments(customerId, addedEventIds).catch((err) => {
logger.warn('Failed to queue customer_gallery_assigned email', {
customerId, addedEventIds, error: err?.message,
});
});
}
return { added: toAdd.length, removed: toRemove.length, addedEventIds };
}
/**
* Queue a `customer_gallery_assigned` email summarising newly-granted
* gallery access for one customer. Called by setAssignmentsForCustomer
* after the transaction commits.
*
* Rules:
* - One email per save (digest), not one per gallery.
* - Archived + expired events are filtered out — the customer would
* hit a "this gallery has expired" notice anyway, so naming them
* in the email just confuses people.
* - Deactivated customers (is_active=false) get no email — their
* login is off, so a "you have new access" message would be
* misleading.
* - Customers without an email on file are skipped (silently —
* should never happen for accepted accounts but defensive).
* - Email failures are logged but never bubble up; the caller's
* `.catch` handler logs again at a more specific call site.
*/
async function notifyCustomerOfNewAssignments(customerId, addedEventIds) {
if (!addedEventIds || addedEventIds.length === 0) return;
const customer = await db('customer_accounts')
.where({ id: customerId, is_active: formatBoolean(true) })
.select('id', 'email', 'display_name', 'first_name', 'preferred_language')
.first();
if (!customer || !customer.email) {
logger.info('Skip customer_gallery_assigned email: customer missing/inactive/no email', {
customerId,
});
return;
}
// Filter the added events to those the customer can actually open.
// Archived events are hard-skipped; expired ones (expires_at in the
// past) would render as "Expired DD MMM" in the dashboard and lead
// to a confusing "I clicked the link in the email and got a 410"
// experience — drop those too.
const now = new Date();
const events = await db('events')
.whereIn('id', addedEventIds)
.where('is_archived', formatBoolean(false))
.andWhere(function() {
this.whereNull('expires_at').orWhere('expires_at', '>', now);
})
.orderBy('event_date', 'desc')
.select('id', 'slug', 'event_name', 'event_date');
if (events.length === 0) {
logger.info('Skip customer_gallery_assigned email: all added events archived/expired', {
customerId, addedEventIds,
});
return;
}
// Build the gallery list block. HTML is whitelisted via
// HTML_PASSTHROUGH_KEYS in emailProcessor so the <ul> survives the
// body-html escaping pass. Names + dates come from admin-controlled
// DB rows; the date is server-rendered.
const { formatDate } = require('../utils/dateFormatter');
const { escapeHtml } = require('../utils/formatters');
const language = customer.preferred_language || 'en';
const formattedRows = await Promise.all(events.map(async (ev) => ({
name: ev.event_name || ev.slug,
date: ev.event_date ? await formatDate(ev.event_date, language) : '',
})));
const galleryListHtml = `<ul>\n${
formattedRows.map((r) => {
const safeName = escapeHtml(r.name);
const safeDate = r.date ? `${escapeHtml(r.date)}` : '';
return ` <li>${safeName}${safeDate}</li>`;
}).join('\n')
}\n</ul>`;
const galleryListText = formattedRows
.map((r) => r.date ? `- ${r.name} (${r.date})` : `- ${r.name}`)
.join('\n');
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
const customerName = customer.display_name?.trim()
|| customer.first_name?.trim()
|| (customer.email ? customer.email.split('@')[0] : '');
await queueEmail(null, customer.email, 'customer_gallery_assigned', {
customer_name: customerName,
gallery_count: String(events.length),
// `singular` / `multiple` drive the {{#if}} blocks in the
// template — safeTemplateReplace treats anything non-empty +
// non-false as truthy, so passing literal 'true' / '' works.
singular: events.length === 1 ? 'true' : '',
multiple: events.length > 1 ? 'true' : '',
gallery_list_html: galleryListHtml,
gallery_list_text: galleryListText,
dashboard_link: `${frontendUrl}/customer/dashboard`,
});
}
/**
* Fetch the customers currently assigned to an event. Returned by the
* admin event-detail endpoint so the picker can hydrate.
*/
async function getAssignmentsForEvent(eventId) {
return db('event_customer_assignments')
.join('customer_accounts', 'customer_accounts.id', 'event_customer_assignments.customer_account_id')
.where('event_customer_assignments.event_id', eventId)
.select(
'customer_accounts.id',
'customer_accounts.email',
'customer_accounts.display_name',
'customer_accounts.first_name',
'customer_accounts.last_name',
'customer_accounts.is_active'
)
.orderBy('customer_accounts.email', 'asc');
}
/**
* Events visible to a logged-in customer. Filters out archived events
* since those galleries are no longer browsable. Expired events are
* deliberately included so customers can see "your gallery has expired"
* messaging in the dashboard rather than just disappearing silently.
*
* is_draft filter intentionally NOT applied: a customer assigned to a
* draft gallery should still see it on their dashboard (the photographer
* may want them to preview before publish). is_archived stays as the
* single hard-exclude — those galleries are gone.
*
* is_archived has a NOT NULL DEFAULT false from migration 029, so a
* plain typed filter is safe — no need for a COALESCE-via-whereRaw
* dance (which itself caused a 500 on postgres because the parameter
* placeholders weren't accepting the boolean cleanly).
*/
async function listEventsForCustomer(customerId) {
return db('event_customer_assignments')
.join('events', 'events.id', 'event_customer_assignments.event_id')
.where('event_customer_assignments.customer_account_id', customerId)
.where('events.is_archived', formatBoolean(false))
.select(
'events.id',
'events.slug',
'events.event_name',
'events.event_type',
'events.event_date',
'events.expires_at',
'events.is_active',
'event_customer_assignments.assigned_at'
)
.orderBy('events.event_date', 'desc');
}
/**
* True iff this customer is assigned to this event. Used by the
* access-token exchange endpoint in customer.js to decide whether to
* mint a gallery token.
*/
async function customerHasAccessToEvent(customerId, eventId) {
const row = await db('event_customer_assignments')
.where('customer_account_id', customerId)
.where('event_id', eventId)
.first('id');
return !!row;
}
// ---- pending invitations -----------------------------------------------
async function getPendingInvitations() {
return db('customer_invitations')
.leftJoin('admin_users', 'admin_users.id', 'customer_invitations.invited_by')
.whereNull('customer_invitations.accepted_at')
.where('customer_invitations.expires_at', '>', new Date())
.select(
'customer_invitations.id',
'customer_invitations.email',
'customer_invitations.expires_at',
'customer_invitations.created_at',
'admin_users.username as invited_by'
)
.orderBy('customer_invitations.created_at', 'desc');
}
async function cancelInvitation(id, cancelledByAdminId) {
const invitation = await db('customer_invitations').where('id', id).first();
if (!invitation) {
throw new NotFoundError('Invitation', id);
}
await db('customer_invitations').where('id', id).del();
await logActivity('customer_invitation_cancelled',
{ invitationId: id, email: invitation.email },
null,
{ type: 'admin', id: cancelledByAdminId, name: 'system' }
);
logger.info('Customer invitation cancelled', { invitationId: id, cancelledByAdminId });
}
// =====================================================================
// Customer-surface global toggles + password resets (#354 follow-up)
// =====================================================================
const PASSWORD_RESET_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
/**
* Read the master "Customer portal" feature flag (#354). When false,
* every customer-side surface (login, dashboard, accept-invite, reset)
* returns 403/410 and the admin "Customers" sidebar entry is hidden.
*
* Reads from the maintainer's `feature_flags` table (migration 088).
* Defaults to false on installs missing the row (e.g. migration 095
* hasn't run yet).
*/
async function isCustomerPortalEnabled() {
try {
if (!(await db.schema.hasTable('feature_flags'))) return false;
const row = await db('feature_flags').where({ key: 'customerPortal' }).first();
if (!row) return false;
const v = row.value;
return v === true || v === 1 || v === '1' || v === 'true';
} catch (e) {
// Defensive: if feature_flags is briefly unavailable (early bootstrap,
// failover) treat as off rather than throwing a 500 from the gate.
return false;
}
}
/**
* Customer-surface global toggles. Branding visibility (logo /
* company name in the customer dashboard header) lives in
* app_settings under setting_type='customer_surface' and is edited
* from the Branding page (Customer dashboard card, gated by the
* customerPortal feature flag).
*
* Calendar / Quotes / Bills feature globals are intentionally OFF
* here — those surfaces are now governed by the maintainer's
* feature_flags table (Settings → Features), not by app_settings.
*
* Returns sane defaults when the keys aren't present so an install
* missing migration 092 doesn't crash — branding defaults ON to
* match the visual state before the toggle existed.
*/
async function getCustomerSurfaceGlobals() {
const rows = await db('app_settings').where('setting_type', 'customer_surface').select('setting_key', 'setting_value');
const map = {};
for (const r of rows) {
let v = r.setting_value;
if (typeof v === 'string') {
try { v = JSON.parse(v); } catch { /* leave as-is */ }
}
map[r.setting_key] = v;
}
// Feature globals:
// - quotes + bills default TRUE — the customer-facing pages are
// fully built and the AND-logic with the per-customer flag is
// the real gate. The earlier hardcoded `false` made it
// impossible to surface the tabs without code changes.
// - calendar defaults FALSE — the customer-side page is still a
// coming-soon stub.
// Each is overridable via app_settings (setting_type='customer_surface').
const readBool = (key, fallback) => {
const v = map[key];
if (v === undefined) return fallback;
if (v === true || v === 1 || v === '1' || v === 't') return true;
if (v === false || v === 0 || v === '0' || v === 'f') return false;
return fallback;
};
return {
calendarEnabled: readBool('customer_feature_calendar_enabled', false),
quotesEnabled: readBool('customer_feature_quotes_enabled', true),
billsEnabled: readBool('customer_feature_bills_enabled', true),
showLogo: map.customer_show_logo !== false, // default true
showCompanyName: map.customer_show_company_name !== false, // default true
};
}
/**
* Compute the effective feature-flag set for a single customer.
*
* AND-logic: a customer sees a feature iff the global toggle is on AND
* their per-customer flag is on. This gives the admin two independent
* levers — flip a feature on for the whole instance, then choose which
* customers actually see it.
*
* Pass either a numeric customerId (we'll fetch) or a row already loaded.
*/
async function getEffectiveFeaturesForCustomer(customerOrId) {
const customer = (typeof customerOrId === 'number')
? await db('customer_accounts').where('id', customerOrId).first()
: customerOrId;
if (!customer) {
return { calendar: false, quotes: false, bills: false, hoursLogging: false, contracts: false };
}
const globals = await getCustomerSurfaceGlobals();
// SQLite returns booleans as 0/1; Postgres returns true/false. The
// strict `=== true` check used to falsely return `false` on SQLite,
// hiding the sidebar entry even when admin had flipped the per-
// customer toggle on. Normalise both shapes here so the Quotes /
// Invoices tabs appear consistently.
const truthy = (v) => v === true || v === 1 || v === '1' || v === 't';
// Hours logging gates on the master feature_flags row (Settings →
// Features) AND the per-customer flag. The customer_surface
// app_settings layer is admin-side-only here — no portal surface
// for hours, so we skip the third gate the bills/quotes use.
const hoursMaster = await db('feature_flags').where({ key: 'hoursLogging' }).first();
const hoursLoggingMaster = hoursMaster ? Boolean(hoursMaster.value) : true;
// Contracts: global feature_flags row AND the per-customer override
// (migration 131). feature_contracts defaults TRUE, so existing customers
// keep their Contracts tab; an admin can hide it per customer.
const contractsMaster = await db('feature_flags').where({ key: 'contracts' }).first();
const contractsEnabled = contractsMaster ? Boolean(contractsMaster.value) : false;
return {
calendar: globals.calendarEnabled && truthy(customer.feature_calendar),
quotes: globals.quotesEnabled && truthy(customer.feature_quotes),
bills: globals.billsEnabled && truthy(customer.feature_bills),
hoursLogging: hoursLoggingMaster && truthy(customer.feature_hours_logging),
contracts: contractsEnabled && truthy(customer.feature_contracts),
};
}
/**
* Admin triggers a password reset for an existing customer.
*
* Behaviour:
* - Generates a 64-char hex token (matches invitation tokens).
* - Stores it in customer_password_resets with a 7-day expiry.
* - Queues a customer_password_reset email with the link.
* - Does NOT invalidate the existing password yet — we only flip
* password_changed_at on the actual reset (so a typo'd reset doesn't
* lock a customer out of an already-working session).
*
* Idempotency: if there's an unused, non-expired reset already in flight
* we delete it before creating the new one — admins re-clicking the
* "Send reset" button shouldn't fan out two valid links.
*/
async function createPasswordReset({ customerId, requestedByAdminId }) {
const customer = await db('customer_accounts').where('id', customerId).first();
if (!customer) {
throw new NotFoundError('Customer', customerId);
}
if (!customer.is_active) {
throw new ValidationError('Cannot reset password for an inactive customer');
}
// Clean up any previous unused reset for this customer.
await db('customer_password_resets')
.where('customer_account_id', customerId)
.whereNull('used_at')
.del();
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS);
await db('customer_password_resets').insert({
token,
customer_account_id: customerId,
requested_by_admin_id: requestedByAdminId || null,
expires_at: expiresAt,
created_at: new Date(),
});
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
await queueEmail(null, customer.email, 'customer_password_reset', {
reset_link: `${frontendUrl}/customer/reset-password/${token}`,
expires_at: expiresAt.toISOString(),
});
await logActivity('customer_password_reset_requested',
{ customerId, email: customer.email },
null,
{ type: 'admin', id: requestedByAdminId, name: 'system' }
);
logger.info('Customer password reset requested', { customerId, requestedByAdminId });
return { customerId, email: customer.email, expiresAt };
}
/**
* Validate a password-reset token (lookup-only — does NOT consume it).
* Used by the customer's reset page to render "you're resetting the
* password for X" before they submit.
*/
async function validatePasswordResetToken(token) {
const row = await db('customer_password_resets')
.join('customer_accounts', 'customer_accounts.id', 'customer_password_resets.customer_account_id')
.where('customer_password_resets.token', token)
.whereNull('customer_password_resets.used_at')
.where('customer_password_resets.expires_at', '>', new Date())
.where('customer_accounts.is_active', formatBoolean(true))
.select(
'customer_password_resets.id',
'customer_password_resets.customer_account_id',
'customer_password_resets.expires_at',
'customer_accounts.email',
)
.first();
return row || null;
}
/**
* Apply a password reset: hash the new password, write it onto the
* customer row, mark the reset row used, and bump password_changed_at
* so any tokens issued before the reset (e.g. an attacker's session)
* stop working on next request.
*
* Wrapped in a transaction so we can't end up with a used token but no
* password update, or vice versa.
*/
async function applyPasswordReset({ token, password }) {
const row = await db('customer_password_resets')
.where('token', token)
.whereNull('used_at')
.where('expires_at', '>', new Date())
.first();
if (!row) {
throw new ValidationError('Invalid or expired reset link');
}
const customer = await db('customer_accounts').where('id', row.customer_account_id).first();
if (!customer || !customer.is_active) {
throw new ValidationError('Invalid or expired reset link');
}
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
await db.transaction(async (trx) => {
await trx('customer_accounts').where('id', customer.id).update({
password_hash: passwordHash,
password_changed_at: new Date(),
must_change_password: formatBoolean(false),
updated_at: new Date(),
});
await trx('customer_password_resets').where('id', row.id).update({ used_at: new Date() });
});
await logActivity('customer_password_reset_applied',
{ customerId: customer.id, email: customer.email },
null,
{ type: 'system', id: null, name: 'system' }
);
logger.info('Customer password reset applied', { customerId: customer.id });
return { email: customer.email };
}
module.exports = {
createInvitation,
createDirect,
acceptInvitation,
validateInvitationToken,
listCustomers,
getCustomerById,
updateCustomer,
deactivateCustomer,
reactivateCustomer,
eraseCustomer,
searchCustomers,
setAssignmentsForEvent,
setAssignmentsForCustomer,
getAssignmentsForEvent,
notifyCustomerOfNewAssignments,
listEventsForCustomer,
customerHasAccessToEvent,
getPendingInvitations,
cancelInvitation,
// #354 follow-up
isCustomerPortalEnabled,
getCustomerSurfaceGlobals,
getEffectiveFeaturesForCustomer,
createPasswordReset,
validatePasswordResetToken,
applyPasswordReset,
};