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
+224
View File
@@ -0,0 +1,224 @@
/**
* Customer-side API client (#354).
*
* Strictly separate from authService.adminLogin / galleryService — uses
* the /api/customer/* surface and the customer_token cookie. Never falls
* back to admin endpoints.
*/
import { api } from '../config/api';
export interface CustomerProfile {
id: number;
email: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
preferredLanguage: string;
}
/**
* Full self-service profile shape — superset of CustomerProfile (which is
* the narrow auth-payload version). Used by the profile page and the
* accept-invite form.
*/
export interface CustomerProfileFull extends CustomerProfile {
salutation: string | null;
phone: string | null;
companyName: string | null;
vatId: string | null;
addressLine1: string | null;
addressLine2: string | null;
postalCode: string | null;
city: string | null;
state: string | null;
countryCode: string | null;
}
/** Subset of profile fields the admin can pre-fill on an invitation
* and that the customer can edit on accept. */
export interface CustomerProfilePrefill {
salutation?: string;
first_name?: string;
last_name?: string;
display_name?: string;
phone?: string;
company_name?: string;
vat_id?: string;
address_line1?: string;
address_line2?: string;
postal_code?: string;
city?: string;
state?: string;
country_code?: string;
}
export interface CustomerEvent {
id: number;
slug: string;
eventName: string;
eventType: string;
eventDate: string | null;
expiresAt: string | null;
isActive: boolean;
assignedAt: string;
}
export interface CustomerInvitationInfo {
email: string;
expiresAt: string;
invitedBy: string | null;
/** Admin-supplied prefill — populates the accept-invite profile form. */
prefill: CustomerProfilePrefill | null;
}
export interface CustomerProfileUpdate {
salutation?: string | null;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
phone?: string | null;
companyName?: string | null;
vatId?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
postalCode?: string | null;
city?: string | null;
state?: string | null;
countryCode?: string | null;
preferredLanguage?: string;
}
export interface CustomerAccessTokenResponse {
token: string;
event: { id: number; slug: string; eventName: string };
}
export const customerService = {
// ---- auth ----
async login(email: string, password: string, recaptchaToken?: string | null): Promise<{
customer: CustomerProfile;
features: { calendar: boolean; quotes: boolean; bills: boolean };
branding: { showLogo: boolean; showCompanyName: boolean };
}> {
const response = await api.post<{
customer: CustomerProfile;
features?: { calendar: boolean; quotes: boolean; bills: boolean };
branding?: { showLogo: boolean; showCompanyName: boolean };
}>(
'/customer/auth/login',
{ email, password, recaptchaToken }
);
// Backwards-compat fallbacks for older backends that haven't been
// upgraded yet — defaults match CustomerAuthContext's DEFAULT_*.
return {
customer: response.data.customer,
features: response.data.features || { calendar: false, quotes: false, bills: false },
branding: response.data.branding || { showLogo: true, showCompanyName: true },
};
},
async logout(): Promise<void> {
try {
await api.post('/customer/auth/logout');
} catch (e) {
// Logout is best-effort — the cookie clear is what matters and
// the backend always clears it even on error.
}
},
async session(): Promise<{
customer: CustomerProfile;
features: { calendar: boolean; quotes: boolean; bills: boolean };
branding: { showLogo: boolean; showCompanyName: boolean };
} | null> {
try {
const response = await api.get<{
customer: CustomerProfile;
features?: { calendar: boolean; quotes: boolean; bills: boolean };
branding?: { showLogo: boolean; showCompanyName: boolean };
}>('/customer/auth/session');
return {
customer: response.data.customer,
features: response.data.features || { calendar: false, quotes: false, bills: false },
branding: response.data.branding || { showLogo: true, showCompanyName: true },
};
} catch {
return null;
}
},
/**
* Look up a password-reset token without consuming it. Lets the reset
* page render "you're resetting the password for {{email}}" before
* the customer submits.
*/
async getPasswordReset(token: string): Promise<{ email: string; expiresAt: string }> {
const response = await api.get<{ reset: { email: string; expiresAt: string } }>(
`/customer/auth/password-reset/${encodeURIComponent(token)}`,
);
return response.data.reset;
},
/** Apply a password reset (token + new password). */
async applyPasswordReset(token: string, password: string): Promise<{ email: string }> {
const response = await api.post<{ email: string }>(
'/customer/auth/password-reset',
{ token, password },
);
return response.data;
},
// ---- invitations ----
async getInvitation(token: string): Promise<CustomerInvitationInfo> {
const response = await api.get<{ invitation: CustomerInvitationInfo }>(
`/customer/auth/invite/${encodeURIComponent(token)}`
);
return response.data.invitation;
},
async acceptInvitation(
token: string,
name: string,
password: string,
profile?: CustomerProfilePrefill,
): Promise<{ email: string }> {
const response = await api.post<{ email: string }>(
'/customer/auth/accept-invite',
{ token, name, password, profile },
);
return response.data;
},
// ---- profile (self-service) ----
async getProfile(): Promise<CustomerProfileFull> {
const response = await api.get<{ profile: CustomerProfileFull }>('/customer/profile');
return response.data.profile;
},
async updateProfile(payload: CustomerProfileUpdate): Promise<CustomerProfileFull> {
const response = await api.put<{ profile: CustomerProfileFull }>('/customer/profile', payload);
return response.data.profile;
},
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await api.post('/customer/profile/password', { currentPassword, newPassword });
},
// ---- dashboard ----
async listEvents(): Promise<CustomerEvent[]> {
const response = await api.get<{ events: CustomerEvent[] }>('/customer/events');
return response.data.events;
},
/**
* Exchange the customer JWT for a gallery JWT scoped to one event.
* The dashboard calls this on card-click and stores the resulting
* token in the slug-specific gallery cookie via storeGalleryToken().
*/
async getEventAccessToken(slug: string): Promise<CustomerAccessTokenResponse> {
const response = await api.get<CustomerAccessTokenResponse>(
`/customer/events/${encodeURIComponent(slug)}/access-token`
);
return response.data;
},
};
@@ -0,0 +1,188 @@
/**
* Admin → Customers API client (#354).
*
* Hits /api/admin/customers/* (admin auth). Distinct from customer.service.ts
* which is the customer's own /api/customer/* surface.
*/
import { api } from '../config/api';
export interface CustomerAccountSummary {
id: number;
email: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
salutation: string | null;
companyName: string | null;
isActive: boolean;
lastLogin: string | null;
createdAt: string;
eventCount?: number;
/** Per-customer feature flags (#354 follow-up). */
featureCalendar?: boolean;
featureQuotes?: boolean;
featureBills?: boolean;
}
export interface CustomerAccountDetail extends CustomerAccountSummary {
phone: string | null;
billingEmail: string | null;
vatId: string | null;
addressLine1: string | null;
addressLine2: string | null;
postalCode: string | null;
city: string | null;
state: string | null;
countryCode: string | null;
preferredLanguage: string;
notes: string | null;
events: Array<{
id: number;
slug: string;
eventName: string;
eventDate: string | null;
expiresAt: string | null;
isArchived: boolean;
assignedAt: string;
}>;
}
/** Optional admin-side prefill on invite — see /admin/customers/invite. */
export interface CustomerInvitePrefill {
salutation?: string;
first_name?: string;
last_name?: string;
display_name?: string;
phone?: string;
company_name?: string;
vat_id?: string;
address_line1?: string;
address_line2?: string;
postal_code?: string;
city?: string;
state?: string;
country_code?: string;
}
export interface CustomerInvitationSummary {
id: number;
email: string;
expiresAt: string;
createdAt: string;
invitedBy: string | null;
}
export const customerAdminService = {
async list(search?: string): Promise<CustomerAccountSummary[]> {
const response = await api.get<{ customers: CustomerAccountSummary[] }>(
'/admin/customers',
{ params: search ? { search } : undefined }
);
return response.data.customers;
},
async search(term: string): Promise<CustomerAccountSummary[]> {
if (!term || !term.trim()) return [];
const response = await api.get<{ customers: CustomerAccountSummary[] }>(
'/admin/customers/search',
{ params: { email: term } }
);
return response.data.customers;
},
async get(id: number): Promise<CustomerAccountDetail> {
const response = await api.get<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`);
return response.data.customer;
},
async update(id: number, payload: Partial<Omit<CustomerAccountDetail, 'id' | 'events' | 'eventCount'>>): Promise<CustomerAccountDetail> {
// Frontend sends camelCase, backend accepts snake_case — translate here
// so callers can stay in TS-land conventions.
const snake: Record<string, any> = {};
const map: Record<string, string> = {
email: 'email',
salutation: 'salutation',
firstName: 'first_name',
lastName: 'last_name',
displayName: 'display_name',
phone: 'phone',
companyName: 'company_name',
billingEmail: 'billing_email',
vatId: 'vat_id',
addressLine1: 'address_line1',
addressLine2: 'address_line2',
postalCode: 'postal_code',
city: 'city',
state: 'state',
countryCode: 'country_code',
preferredLanguage: 'preferred_language',
notes: 'notes',
isActive: 'is_active',
// Per-customer feature flags (#354 follow-up).
featureCalendar: 'feature_calendar',
featureQuotes: 'feature_quotes',
featureBills: 'feature_bills',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
}
const response = await api.put<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`, snake);
return response.data.customer;
},
async deactivate(id: number): Promise<void> {
await api.post(`/admin/customers/${id}/deactivate`);
},
/** Restore a deactivated customer (login re-enabled, assignments stay). */
async reactivate(id: number): Promise<void> {
await api.post(`/admin/customers/${id}/reactivate`);
},
/**
* Anonymize-in-place erasure (GDPR style). Customer row stays for
* audit FKs but every PII column is nulled and credentials are wiped.
* See backend service `eraseCustomer` for the full contract.
*/
async erase(id: number): Promise<void> {
await api.post(`/admin/customers/${id}/erase`);
},
/**
* Trigger a password reset for an existing customer. The backend
* generates a 7-day single-use token and emails the customer.
*/
async sendPasswordReset(id: number): Promise<{ email: string; expiresAt: string }> {
const response = await api.post<{ data: { email: string; expiresAt: string } } | { email: string; expiresAt: string }>(
`/admin/customers/${id}/password-reset`,
);
return ((response.data as any).data ?? response.data) as { email: string; expiresAt: string };
},
/**
* Invite a customer. `prefill` is an optional set of profile fields the
* admin can pre-populate on the invitation row — the customer sees them
* pre-filled (and editable) on the accept form. Saves the customer typing
* for the common case where the photographer already has the wedding
* couple's name + address from the booking form.
*/
async invite(
email: string,
prefill?: CustomerInvitePrefill,
): Promise<{ id: number; email: string; expiresAt: string }> {
const response = await api.post<{ data: { invitation: { id: number; email: string; expiresAt: string } } }>(
'/admin/customers/invite',
{ email, prefill },
);
return (response.data as any).data?.invitation ?? (response.data as any).invitation;
},
async listInvitations(): Promise<CustomerInvitationSummary[]> {
const response = await api.get<{ invitations: CustomerInvitationSummary[] }>('/admin/customers/invitations');
return response.data.invitations;
},
async cancelInvitation(id: number): Promise<void> {
await api.delete(`/admin/customers/invitations/${id}`);
},
};
+7
View File
@@ -41,6 +41,10 @@ interface CreateEventData {
show_feedback_to_guests?: boolean;
photo_cap?: number | null;
default_photo_sort?: string;
// Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id; backend service diffs against the existing
// assignments and applies inserts/deletes inside the same transaction.
customer_account_ids?: number[];
}
interface UpdateEventData {
@@ -62,6 +66,9 @@ interface UpdateEventData {
external_path?: string | null;
photo_cap?: number | null;
default_photo_sort?: string;
// Customer accounts (#354). Same semantics as on CreateEventData;
// omit the field to leave assignments untouched, send [] to clear.
customer_account_ids?: number[];
}
export type EventStatusFilter = 'active' | 'inactive' | 'archived' | 'draft' | 'expiring';
@@ -9,7 +9,14 @@ export type FeatureKey =
| 'bills'
| 'messaging'
| 'analytics'
| 'userManagement';
| 'userManagement'
// Foundation flag for the customer-side surface (#354). Gates the
// /customer/* routes (login, dashboard, profile, accept-invite,
// reset-password) and the admin Customers management page. The
// calendar / calendarBooking / quotes / bills / messaging flags
// above hang off this — they only appear in the customer dashboard
// when customerPortal is also ON.
| 'customerPortal';
export type FeatureFlags = Record<FeatureKey, boolean>;
+2 -1
View File
@@ -9,4 +9,5 @@ export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service';
export { feedbackService } from './feedback.service';
export { userManagementService } from './userManagement.service';
export { userManagementService } from './userManagement.service';
export { customerService } from './customer.service';