lilith-platform.live/codebase/@features/vip/frontend-client/src/api.ts
2026-05-17 06:41:50 -07:00

483 lines
14 KiB
TypeScript

import {
startRegistration,
startAuthentication,
platformAuthenticatorIsAvailable,
} from '@simplewebauthn/browser';
export { platformAuthenticatorIsAvailable };
export interface VipMessage {
id: number;
clientId: number;
sender: 'quinn' | 'client';
body: string;
createdAt: string;
readAt: string | null;
attachmentUrl: string | null;
attachmentType: string | null;
}
export type VipQuoteStatus = 'draft' | 'sent' | 'accepted' | 'expired';
export type VipQuoteAnimationId =
| 'decrypt'
| 'seal'
| 'rosette'
| 'monogram'
| 'geometric-flower';
/**
* Per-quote visual configuration. Each slot is a stack of animation ids
* rendered simultaneously during the matching state.
*/
export interface VipQuotePresentation {
readonly loadingAnimation?: readonly VipQuoteAnimationId[];
readonly unlockAnimation?: readonly VipQuoteAnimationId[];
}
export interface VipQuote {
id: number;
vipClientId: number;
slug: string;
title: string;
bodyMarkdown: string;
status: VipQuoteStatus;
version: number;
password: string | null;
presentation: VipQuotePresentation;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface VipQuoteSummary {
slug: string;
title: string;
status: VipQuoteStatus;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface VipMemory {
id: number;
clientId: number;
uploadedBy: 'quinn' | 'client';
objectKey: string;
contentType: string;
fileSizeBytes: number;
caption: string | null;
status: 'visible' | 'hidden';
createdAt: string;
url: string;
}
export type VipRarity = 'secret' | 'rare' | 'uncommon' | 'common';
export interface VipGalleryItem {
id: number;
attachmentUrl: string;
attachmentType: string;
sender: 'quinn' | 'client';
createdAt: string;
rarity: VipRarity;
}
export interface VipGift {
id: number;
clientId: number;
item: string;
gifterNote: string | null;
receivedAt: string;
estimatedValue: number | null;
}
export interface VipMeeting {
id: number;
clientId: number;
meetingDate: string;
durationMinutes: number | null;
locationNote: string | null;
notes: string | null;
hotelName: string | null;
hotelAddress: string | null;
hotelRevealAt: string | null;
}
export interface VipHotel {
hotelName: string;
hotelAddress: string | null;
}
export type VipTier = 'bronze' | 'silver' | 'gold' | 'diamond';
export interface VipStory {
text: string;
generatedAt: string | null;
}
export interface VipRelationship {
invite: {
id: number;
token: string;
label: string | null;
tier: VipTier | null;
lastAccessedAt: string | null;
expiresAt: string | null;
};
gifts: VipGift[];
meetings: VipMeeting[];
story?: VipStory | null;
hotel?: VipHotel;
}
export type VipBillingStatus = 'paid' | 'pending' | 'refunded';
export interface VipBillingEntry {
id: number;
clientId: number;
amountCents: number;
currency: string;
description: string;
status: VipBillingStatus;
billedAt: string;
createdAt: string;
}
export interface VipReferral {
id: number;
code: string;
sourceClientId: number;
targetClientId: number | null;
label: string | null;
createdAt: string;
usedAt: string | null;
}
export class VipApiError extends Error {
constructor(
readonly code: string,
message: string,
readonly meta: Record<string, unknown> = {},
) {
super(message);
this.name = 'VipApiError';
}
}
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3031';
function vipHeaders(token: string): HeadersInit {
return {
'X-VIP-Token': token,
'Content-Type': 'application/json',
};
}
async function handleResponse<T>(res: Response): Promise<T> {
if (res.status === 401) {throw new VipApiError('invalid_token', 'This invitation link is no longer valid.');}
if (!res.ok) {throw new VipApiError('request_failed', `Request failed: ${res.status}`);}
return res.json() as Promise<T>;
}
export async function fetchMessages(token: string): Promise<VipMessage[]> {
const res = await fetch(`${API_URL}/vip/messages/${token}`, {
headers: vipHeaders(token),
});
return handleResponse<VipMessage[]>(res);
}
export async function sendMessage(token: string, body: string): Promise<VipMessage> {
const res = await fetch(`${API_URL}/vip/messages/${token}`, {
method: 'POST',
headers: vipHeaders(token),
body: JSON.stringify({ sender: 'client', body }),
});
return handleResponse<VipMessage>(res);
}
export async function markRead(token: string): Promise<void> {
await fetch(`${API_URL}/vip/messages/${token}/read?side=client`, {
method: 'PUT',
headers: vipHeaders(token),
}).catch(() => undefined);
}
export async function getRelationship(token: string): Promise<VipRelationship> {
const res = await fetch(`${API_URL}/vip/relationship/${token}`, {
headers: vipHeaders(token),
});
return handleResponse<VipRelationship>(res);
}
export async function getGallery(token: string): Promise<VipGalleryItem[]> {
const res = await fetch(`${API_URL}/vip/messages/${token}/gallery`, {
headers: vipHeaders(token),
});
return handleResponse<VipGalleryItem[]>(res);
}
export async function getMyReferrals(token: string): Promise<VipReferral[]> {
const res = await fetch(`${API_URL}/vip/referrals/mine`, {
headers: vipHeaders(token),
});
return handleResponse<VipReferral[]>(res);
}
export async function redeemReferralCode(token: string, code: string): Promise<VipReferral> {
const res = await fetch(`${API_URL}/vip/referrals/use`, {
method: 'POST',
headers: vipHeaders(token),
body: JSON.stringify({ code }),
});
if (res.status === 409) {throw new VipApiError('referral_already_used', 'This referral code has already been used.');}
return handleResponse<VipReferral>(res);
}
export async function getBillingHistory(token: string): Promise<VipBillingEntry[]> {
const res = await fetch(`${API_URL}/vip/billing/${token}`, {
headers: vipHeaders(token),
});
return handleResponse<VipBillingEntry[]>(res);
}
// ── Auth ─────────────────────────────────────────────────────────────────────
export interface VipAuthStatus {
hasPassword: boolean;
hasWebAuthn: boolean;
hasEncryption: boolean;
}
export async function getAuthStatus(token: string): Promise<VipAuthStatus> {
const res = await fetch(`${API_URL}/vip/auth/status/${token}`, {
headers: vipHeaders(token),
});
return handleResponse<VipAuthStatus>(res);
}
export async function setupPassword(
token: string,
password: string,
): Promise<{ contentKey: string | null }> {
const res = await fetch(`${API_URL}/vip/auth/setup/${token}`, {
method: 'POST',
headers: vipHeaders(token),
body: JSON.stringify({ password }),
});
const data = await handleResponse<{ ok: boolean; contentKey?: string }>(res);
return { contentKey: data.contentKey ?? null };
}
export async function verifyPassword(
token: string,
password: string,
): Promise<{ ok: boolean; hasWebAuthn: boolean; contentKey: string | null }> {
const res = await fetch(`${API_URL}/vip/auth/verify/${token}`, {
method: 'POST',
headers: vipHeaders(token),
body: JSON.stringify({ password }),
});
if (res.status === 401) {throw new VipApiError('invalid_password', 'Incorrect password.');}
const data = await handleResponse<{ ok: boolean; hasWebAuthn: boolean; contentKey?: string | null }>(res);
return { ok: data.ok, hasWebAuthn: data.hasWebAuthn, contentKey: data.contentKey ?? null };
}
// ── Settings ──────────────────────────────────────────────────────────────────
export interface VipEncryptionSettings {
enabled: true;
algorithm: string;
kdf: string;
kdfIterations: number;
keyCreatedAt: string | null;
loginBenchmarkMs: number | null;
}
export interface VipEncryptionSettingsDisabled {
enabled: false;
}
export interface VipSettingsResponse {
encryption: VipEncryptionSettings | VipEncryptionSettingsDisabled;
records: {
conversations: number;
meetings: number;
gifts: number;
billing: number;
};
}
export async function getEncryptionSettings(token: string): Promise<VipSettingsResponse> {
const res = await fetch(`${API_URL}/vip/settings/${token}`, {
headers: vipHeaders(token),
});
return handleResponse<VipSettingsResponse>(res);
}
export async function registerWebAuthn(token: string): Promise<void> {
const startRes = await fetch(`${API_URL}/vip/auth/webauthn/register-start/${token}`, {
method: 'POST',
headers: vipHeaders(token),
});
const options = await handleResponse<Parameters<typeof startRegistration>[0]['optionsJSON']>(startRes);
const credential = await startRegistration({ optionsJSON: options });
const finishRes = await fetch(`${API_URL}/vip/auth/webauthn/register-finish/${token}`, {
method: 'POST',
headers: vipHeaders(token),
body: JSON.stringify(credential),
});
await handleResponse<{ ok: boolean }>(finishRes);
}
export async function removeWebAuthn(token: string): Promise<void> {
const res = await fetch(`${API_URL}/vip/auth/webauthn/remove/${token}`, {
method: 'POST',
headers: vipHeaders(token),
});
await handleResponse<{ ok: boolean }>(res);
}
export async function getMemories(token: string): Promise<VipMemory[]> {
const res = await fetch(`${API_URL}/vip/memories/${token}`, {
headers: vipHeaders(token),
});
return handleResponse<VipMemory[]>(res);
}
export async function uploadMemory(
token: string,
file: File,
caption?: string,
): Promise<VipMemory> {
const form = new FormData();
form.append('file', file);
if (caption?.trim()) {form.append('caption', caption.trim());}
const res = await fetch(`${API_URL}/vip/memories/${token}`, {
method: 'POST',
headers: { 'X-VIP-Token': token },
body: form,
});
return handleResponse<VipMemory>(res);
}
// ── Invitations ───────────────────────────────────────────────────────────────
export interface VipInvitation {
id: number;
clientId: number;
proposedDate: string;
durationHours: number | null;
locationHint: string | null;
message: string;
deadline: string | null;
status: 'pending' | 'accepted' | 'declined' | 'expired';
respondedAt: string | null;
responseNote: string | null;
createdAt: string;
updatedAt: string;
}
export interface VipPriorityRequest {
id: number;
clientId: number;
preferredDates: string;
service: string;
notes: string;
status: 'new' | 'reviewed' | 'accepted' | 'declined';
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
}
export async function getInvitations(token: string): Promise<VipInvitation[]> {
const res = await fetch(`${API_URL}/vip/${token}/invitations`, {
headers: vipHeaders(token),
});
return handleResponse<VipInvitation[]>(res);
}
export async function respondToInvitation(
token: string,
invitationId: number,
action: 'accept' | 'decline',
responseNote?: string,
): Promise<VipInvitation> {
const res = await fetch(`${API_URL}/vip/${token}/invitations/${invitationId}/respond`, {
method: 'PATCH',
headers: vipHeaders(token),
body: JSON.stringify({ action, ...(responseNote ? { responseNote } : {}) }),
});
return handleResponse<VipInvitation>(res);
}
export async function getPriorityRequests(token: string): Promise<VipPriorityRequest[]> {
const res = await fetch(`${API_URL}/vip/${token}/priority-requests`, {
headers: vipHeaders(token),
});
return handleResponse<VipPriorityRequest[]>(res);
}
export async function submitPriorityRequest(
token: string,
data: { preferredDates: string; service: string; notes?: string },
): Promise<VipPriorityRequest> {
const res = await fetch(`${API_URL}/vip/${token}/priority-requests`, {
method: 'POST',
headers: vipHeaders(token),
body: JSON.stringify(data),
});
return handleResponse<VipPriorityRequest>(res);
}
export async function authenticateWebAuthn(token: string): Promise<void> {
const startRes = await fetch(`${API_URL}/vip/auth/webauthn/auth-start/${token}`, {
method: 'POST',
headers: vipHeaders(token),
});
if (startRes.status === 400) {throw new VipApiError('no_credential', 'No biometric credential found.');}
const options = await handleResponse<Parameters<typeof startAuthentication>[0]['optionsJSON']>(startRes);
const assertion = await startAuthentication({ optionsJSON: options });
const finishRes = await fetch(`${API_URL}/vip/auth/webauthn/auth-finish/${token}`, {
method: 'POST',
headers: vipHeaders(token),
body: JSON.stringify(assertion),
});
if (finishRes.status === 401) {throw new VipApiError('webauthn_failed', 'Biometric authentication failed.');}
await handleResponse<{ ok: boolean }>(finishRes);
}
export async function fetchQuote(token: string, slug: string, password?: string): Promise<VipQuote> {
const headers: Record<string, string> = { ...(vipHeaders(token) as Record<string, string>) };
if (password) headers['X-Quote-Password'] = password;
const res = await fetch(`${API_URL}/vip/${token}/quotes/${slug}`, {
headers,
credentials: 'include',
});
if (res.status === 401) {
let body: { code?: string; title?: string } = {};
try { body = await res.json(); } catch { /* non-json 401 */ }
if (body.code === 'password_required') {
throw new VipApiError('password_required', body.title ?? 'This quote is password-protected.', { presentation: (body as { presentation?: unknown }).presentation });
}
throw new VipApiError('invalid_token', 'This invitation link is no longer valid.');
}
if (res.status === 404) throw new VipApiError('quote_not_found', 'That quote was not found.');
if (!res.ok) throw new VipApiError('request_failed', `Request failed: ${res.status}`);
return (await res.json()) as VipQuote;
}
export async function listQuotes(token: string): Promise<VipQuoteSummary[]> {
const res = await fetch(`${API_URL}/vip/${token}/quotes`, {
headers: vipHeaders(token),
credentials: 'include',
});
if (res.status === 401) throw new VipApiError('invalid_token', 'This invitation link is no longer valid.');
if (!res.ok) throw new VipApiError('request_failed', `Request failed: ${res.status}`);
return (await res.json()) as VipQuoteSummary[];
}