feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from the-luap/picpeak#354 plugged into the maintainer's new feature-flag infrastructure (PR #443) instead of a parallel toggle. * New `customerPortal` feature flag (foundation flag for the not-yet-built calendar/quotes/bills/messaging customer surfaces). Defaults FALSE on fresh installs, TRUE on existing installs (events > 0) via migration 095 so live customer accounts don't disappear mid-deployment. * Foundation schema: customer_accounts, customer_invitations, event_customer_assignments, customer_password_resets, plus RBAC permissions customers.view / .create / .delete granted to super_admin + admin system roles. * Backend: /api/admin/customers (invite, list, search, assign, deactivate, reset password) + /api/customer/auth/* + /api/customer/* (login, dashboard, accept-invite, reset). Customer JWT bypass minted via /api/customer/events/:slug/access-token so existing gallery middleware stays untouched. * Frontend: /customer/* route tree gated by RequireFeature flag customerPortal, with login / dashboard / accept-invite / reset pages and a customer-side sidebar layout. /admin/customers and /admin/customers/:id gated identically. * Settings → Features grows a "Customers" section with a Customer portal card. The maintainer's Features tab stays the single source of truth — no parallel Advanced features tab. * CustomerAccountPicker on event create/edit forms hides itself when the flag is off; backend ignores customer_account_ids in that case instead of erroring the whole event save. Translations: en + de hand-translated. nl/pt/ru fall through to en — flagged here as needing native review. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,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}`);
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user