483 lines
14 KiB
TypeScript
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[];
|
|
}
|
|
|