first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

582
apps/frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,582 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
export interface PublicUser {
id: string;
email?: string | null;
phone?: string | null;
backupEmail?: string | null;
backupPhone?: string | null;
displayName: string;
username?: string | null;
avatarUrl?: string | null;
hasAvatar?: boolean;
isSuperAdmin: boolean;
status: string;
roles?: string[];
permissions?: string[];
canAccessAdmin?: boolean;
canManageRoles?: boolean;
canManageOAuth?: boolean;
canManageUsers?: boolean;
canManageSettings?: boolean;
canViewUsers?: boolean;
canViewUserDocuments?: boolean;
}
export interface AdminUser {
id: string;
email?: string | null;
phone?: string | null;
displayName: string;
username?: string | null;
isSuperAdmin: boolean;
status: string;
createdAt: string;
roles: string[];
}
export interface AdminRole {
id: string;
slug: string;
name: string;
description?: string;
permissions: { id: string; slug: string; name: string; description?: string }[];
}
export interface AdminPermission {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthScope {
id: string;
slug: string;
name: string;
description?: string;
}
export interface OAuthClient {
id: string;
name: string;
clientId: string;
redirectUris: string[];
scopes: string[];
isActive: boolean;
type: string;
clientSecret?: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: string;
pinVerified: boolean;
user: PublicUser;
sessionId: string;
}
export interface PasswordlessAuthResponse {
requiresPassword: boolean;
tempAuthToken?: string;
auth?: AuthTokens;
}
export interface LoginMethod {
kind: 'password' | 'otp';
channel: string;
masked: string;
}
export interface IdentifyResponse {
exists: boolean;
hasPassword: boolean;
isPinEnabled: boolean;
methods?: LoginMethod[];
}
export interface OtpSendResponse {
sent: boolean;
expiresAt: string;
maskedTarget: string;
}
export interface PinVerificationResponse {
accessToken: string;
pinVerified: boolean;
}
export interface RefreshSessionResponse {
requiresPin: boolean;
accessToken?: string;
sessionId?: string;
pinVerified: boolean;
user?: PublicUser;
}
export interface AuthSessionResponse {
requiresPin: boolean;
sessionId?: string | null;
pinVerified: boolean;
user: PublicUser;
}
export const AUTH_TOKEN_KEY = 'lendry_access_token';
export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
export const AUTH_SESSION_KEY = 'lendry_session_id';
export const AUTH_USER_CACHE_KEY = 'lendry_cached_user';
export class ApiError extends Error {
status: number;
code?: string;
sessionId?: string;
constructor(message: string, status: number, code?: string, sessionId?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.sessionId = sessionId;
}
}
type PinRequiredHandler = (sessionId: string) => void;
export function setPinRequiredHandler(handler: PinRequiredHandler | null) {
pinRequiredHandler = handler;
if (!handler) {
pinNotificationSent = false;
}
}
interface ErrorBody {
message?: string | string[];
error?: string;
code?: string;
sessionId?: string;
}
async function parseErrorResponse(response: Response): Promise<ApiError> {
try {
const body = (await response.clone().json()) as ErrorBody;
const message = Array.isArray(body.message)
? body.message.join('\n')
: (body.message ?? body.error ?? 'Не удалось выполнить запрос');
const pinRequired =
body.code === 'PIN_REQUIRED' ||
(response.status === 403 && typeof message === 'string' && message.toLowerCase().includes('pin'));
return new ApiError(message, response.status, pinRequired ? 'PIN_REQUIRED' : body.code, body.sessionId);
} catch {
return new ApiError('Не удалось выполнить запрос', response.status);
}
}
export function isPinRequiredError(error: unknown): error is ApiError {
return error instanceof ApiError && error.code === 'PIN_REQUIRED';
}
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
if (isPinRequiredError(error)) return null;
if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) {
return null;
}
return error instanceof Error ? error.message : fallback;
}
let pinRequiredHandler: PinRequiredHandler | null = null;
let pinNotificationSent = false;
export function resetPinRequiredNotification() {
pinNotificationSent = false;
}
function notifyPinRequired(sessionId: string) {
if (!pinRequiredHandler || pinNotificationSent) return;
pinNotificationSent = true;
pinRequiredHandler(sessionId);
}
export async function fetchAuthSession(token?: string | null): Promise<AuthSessionResponse> {
return apiFetch<AuthSessionResponse>('/auth/session', {}, token);
}
export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
if (typeof window === 'undefined') {
throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH');
}
const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY);
const sessionId = window.localStorage.getItem(AUTH_SESSION_KEY);
if (!refreshToken) {
throw new ApiError('Refresh token отсутствует', 401, 'NO_REFRESH');
}
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken, sessionId: sessionId ?? undefined })
});
if (!response.ok) {
throw await parseErrorResponse(response);
}
return response.json() as Promise<RefreshSessionResponse>;
}
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
try {
const refreshed = await refreshAuthSession();
if (refreshed.requiresPin) {
notifyPinRequired(refreshed.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
return refreshed;
}
if (refreshed.accessToken) {
window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken);
if (refreshed.sessionId) {
window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId);
}
if (refreshed.user) {
window.dispatchEvent(new CustomEvent('lendry:auth-refreshed', { detail: refreshed }));
}
}
return refreshed;
} catch {
return null;
}
}
export interface UserDocument {
id: string;
userId: string;
type: string;
number: string;
issuedAt?: string;
expiresAt?: string;
metadataJson?: string;
}
export interface SystemSetting {
id: string;
key: string;
value: string;
description?: string | null;
isSecret: boolean;
}
export interface SocialProvider {
id: string;
providerName: string;
clientId: string;
isEnabled: boolean;
}
export interface ActiveDevice {
id: string;
name: string;
type: string;
lastIp?: string | null;
lastSeenAt: string;
trusted: boolean;
}
export interface SignInEvent {
id: string;
success: boolean;
reason?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
createdAt: string;
}
export interface ActiveSession {
id: string;
userId: string;
deviceId?: string | null;
pinVerified: boolean;
status: string;
ipAddress?: string | null;
userAgent?: string | null;
expiresAt: string;
createdAt: string;
updatedAt: string;
}
async function parseError(response: Response): Promise<string> {
const error = await parseErrorResponse(response);
return error.message;
}
export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | 'value' | 'description' | 'isSecret'>) {
return {
key: setting.key,
value: setting.value,
description: setting.description ?? undefined,
isSecret: setting.isSecret
};
}
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null);
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
...options.headers
}
});
if (!response.ok) {
const error = await parseErrorResponse(response);
if (error.code === 'PIN_REQUIRED') {
notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
throw error;
}
if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') {
const refreshed = await trySilentTokenRefresh();
if (refreshed?.accessToken) {
return apiFetch<T>(path, options, refreshed.accessToken, false);
}
if (refreshed?.requiresPin) {
throw error;
}
}
throw error;
}
return response.json() as Promise<T>;
}
export function getDeviceFingerprint(): string {
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export interface MediaAccessResponse {
accessUrl: string;
expiresAt: string;
}
export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) {
const query = new URLSearchParams({ storageKey });
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
}
export async function softDeleteProfile(userId: string, token?: string | null) {
return apiFetch<{ success: boolean }>(`/profile/users/${userId}/self-delete`, { method: 'POST' }, token);
}
export interface UserAddress {
id: string;
userId: string;
label: 'HOME' | 'WORK' | 'OTHER';
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
createdAt: string;
updatedAt: string;
}
export async function fetchUserAddresses(userId: string, token?: string | null) {
return apiFetch<{ addresses?: UserAddress[] }>(`/addresses/users/${userId}`, {}, token);
}
export async function upsertUserAddress(
userId: string,
body: {
addressId?: string;
label: UserAddress['label'];
city: string;
street: string;
house: string;
apartment?: string;
comment?: string;
latitude?: number;
longitude?: number;
fullAddress?: string;
},
token?: string | null
) {
return apiFetch<UserAddress>(`/addresses/users/${userId}`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function deleteUserAddress(userId: string, addressId: string, token?: string | null) {
return apiFetch<{ count: number }>(`/addresses/users/${userId}/${addressId}`, { method: 'DELETE' }, token);
}
export interface FamilyMember {
id: string;
userId: string;
role: string;
displayName: string;
hasAvatar: boolean;
}
export interface FamilyGroup {
id: string;
ownerId: string;
name: string;
hasAvatar: boolean;
members?: FamilyMember[];
}
export interface FamilyInvite {
id: string;
groupId: string;
groupName: string;
inviterId: string;
inviterName: string;
inviteeUserId?: string;
targetEmail?: string;
targetPhone?: string;
status: string;
expiresAt: string;
}
export interface AppNotification {
id: string;
type: string;
title: string;
message: string;
payloadJson?: string;
isRead: boolean;
createdAt: string;
}
export interface ChatPollOption {
id: string;
text: string;
voteCount: number;
votedByMe: boolean;
}
export interface ChatMessage {
id: string;
roomId: string;
senderId: string;
senderName: string;
senderHasAvatar: boolean;
type: string;
content?: string;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
createdAt: string;
poll?: {
id: string;
question: string;
allowsMultiple: boolean;
isAnonymous: boolean;
options: ChatPollOption[];
};
}
export interface ChatRoom {
id: string;
groupId: string;
type: string;
name: string;
hasAvatar: boolean;
updatedAt: string;
members: Array<{ id: string; userId: string; displayName: string; hasAvatar: boolean; notificationsMuted: boolean }>;
lastMessage?: ChatMessage;
}
export async function fetchFamilyGroups(userId: string, token?: string | null) {
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token);
}
export async function fetchFamilyGroup(groupId: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, {}, token);
}
export async function updateFamilyGroup(groupId: string, name: string, token?: string | null) {
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token);
}
export async function sendFamilyInvite(groupId: string, target: string, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify({ target }) }, token);
}
export async function fetchFamilyInvites(token?: string | null) {
return apiFetch<{ invites?: FamilyInvite[] }>('/family/invites', {}, token);
}
export async function respondFamilyInvite(inviteId: string, accept: boolean, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/invites/${inviteId}/respond`, { method: 'POST', body: JSON.stringify({ accept }) }, token);
}
export async function fetchNotifications(token?: string | null, unreadOnly = false) {
return apiFetch<{ notifications?: AppNotification[] }>(`/notifications?unreadOnly=${unreadOnly}`, {}, token);
}
export async function fetchUnreadNotificationCount(token?: string | null) {
return apiFetch<{ count: number }>('/notifications/unread-count', {}, token);
}
export async function markNotificationRead(notificationId: string, token?: string | null) {
return apiFetch(`/notifications/${notificationId}/read`, { method: 'PATCH', body: '{}' }, token);
}
export async function markAllNotificationsRead(token?: string | null) {
return apiFetch('/notifications/read-all', { method: 'POST' }, token);
}
export async function fetchChatRooms(groupId: string, token?: string | null) {
return apiFetch<{ rooms?: ChatRoom[] }>(`/chat/groups/${groupId}/rooms`, {}, token);
}
export async function createChatRoom(groupId: string, name: string, memberUserIds: string[], token?: string | null) {
return apiFetch<ChatRoom>(`/chat/groups/${groupId}/rooms`, {
method: 'POST',
body: JSON.stringify({ name, memberUserIds })
}, token);
}
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);
}
export async function sendChatMessage(
roomId: string,
body: {
type: string;
content?: string;
replyToId?: string;
storageKey?: string;
mimeType?: string;
metadataJson?: string;
poll?: { question: string; options: string[]; allowsMultiple?: boolean; isAnonymous?: boolean };
},
token?: string | null
) {
return apiFetch<ChatMessage>(`/chat/rooms/${roomId}/messages`, { method: 'POST', body: JSON.stringify(body) }, token);
}
export async function voteChatPoll(messageId: string, optionIds: string[], token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}/vote`, { method: 'POST', body: JSON.stringify({ optionIds }) }, token);
}
export async function setChatRoomMuted(roomId: string, muted: boolean, token?: string | null) {
return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token);
}
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:8085/ws';