first commit
This commit is contained in:
28
apps/frontend/lib/address-catalog.ts
Normal file
28
apps/frontend/lib/address-catalog.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type AddressLabel = 'HOME' | 'WORK' | 'OTHER';
|
||||
|
||||
export const ADDRESS_LABELS: Record<AddressLabel, { title: string; shortTitle: string }> = {
|
||||
HOME: { title: 'Дом', shortTitle: 'Дом' },
|
||||
WORK: { title: 'Работа', shortTitle: 'Работа' },
|
||||
OTHER: { title: 'Другой адрес', shortTitle: 'Другие' }
|
||||
};
|
||||
|
||||
export function formatAddressLine(address: {
|
||||
fullAddress?: string | null;
|
||||
city?: string;
|
||||
street?: string;
|
||||
house?: string;
|
||||
apartment?: string | null;
|
||||
}) {
|
||||
if (address.fullAddress?.trim()) return address.fullAddress.trim();
|
||||
const parts = [`г. ${address.city}`, `${address.street}, ${address.house}`];
|
||||
if (address.apartment?.trim()) parts.push(`кв. ${address.apartment.trim()}`);
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
export function getPrimaryAddress(addresses: { label: string }[], label: AddressLabel) {
|
||||
return addresses.find((item) => item.label === label) ?? null;
|
||||
}
|
||||
|
||||
export function getOtherAddresses<T extends { label: string }>(addresses: T[]) {
|
||||
return addresses.filter((item) => item.label === 'OTHER');
|
||||
}
|
||||
582
apps/frontend/lib/api.ts
Normal file
582
apps/frontend/lib/api.ts
Normal 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';
|
||||
41
apps/frontend/lib/authenticated-media.ts
Normal file
41
apps/frontend/lib/authenticated-media.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string) {
|
||||
const response = await fetch(accessUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить файл');
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export function createBlobObjectUrl(blob: Blob) {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
export function revokeBlobObjectUrl(url?: string) {
|
||||
if (url?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerBlobDownload(blob: Blob, fileName: string) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName;
|
||||
anchor.rel = 'noopener';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function mediaExpiresAtMs(expiresAt?: string) {
|
||||
if (!expiresAt) return Date.now() + 14 * 60_000;
|
||||
const parsed = Date.parse(expiresAt);
|
||||
return Number.isFinite(parsed) ? parsed : Date.now() + 14 * 60_000;
|
||||
}
|
||||
|
||||
export function shouldRefreshMedia(expiresAtMs: number, bufferMs = 60_000) {
|
||||
return expiresAtMs - Date.now() <= bufferMs;
|
||||
}
|
||||
115
apps/frontend/lib/chat-media.ts
Normal file
115
apps/frontend/lib/chat-media.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE';
|
||||
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
heic: 'image/heic',
|
||||
heif: 'image/heif',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
webm: 'audio/webm',
|
||||
wav: 'audio/wav',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
zip: 'application/zip',
|
||||
rar: 'application/vnd.rar',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml'
|
||||
};
|
||||
|
||||
const MIME_ALIASES: Record<string, string> = {
|
||||
'audio/x-m4a': 'audio/mp4',
|
||||
'audio/x-aac': 'audio/aac',
|
||||
'audio/x-wav': 'audio/wav',
|
||||
'audio/x-flac': 'audio/flac',
|
||||
'audio/x-mpeg': 'audio/mpeg',
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'image/jpg': 'image/jpeg',
|
||||
'image/pjpeg': 'image/jpeg',
|
||||
'application/x-pdf': 'application/pdf',
|
||||
'application/x-zip-compressed': 'application/zip'
|
||||
};
|
||||
|
||||
function extractFileExtension(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const clean = value.split('?')[0]?.split('#')[0] ?? value;
|
||||
const parts = clean.split('.');
|
||||
if (parts.length < 2) return '';
|
||||
return parts.pop()?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
export function resolveChatContentType(file: Pick<File, 'type' | 'name'>) {
|
||||
const raw = (file.type ?? '').trim().toLowerCase();
|
||||
const base = raw.split(';')[0]?.trim() ?? '';
|
||||
const aliased = MIME_ALIASES[base] ?? base;
|
||||
|
||||
if (aliased && aliased !== 'application/octet-stream') {
|
||||
return aliased;
|
||||
}
|
||||
|
||||
const extension = extractFileExtension(file.name);
|
||||
if (extension && EXTENSION_TO_MIME[extension]) {
|
||||
return EXTENSION_TO_MIME[extension];
|
||||
}
|
||||
|
||||
return aliased || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function inferChatMessageType(contentType: string, options?: { voice?: boolean }): ChatAttachmentKind {
|
||||
const normalized = resolveChatContentType({ type: contentType, name: '' });
|
||||
if (normalized.startsWith('image/')) return 'IMAGE';
|
||||
if (normalized.startsWith('video/')) return 'FILE';
|
||||
if (normalized.startsWith('audio/')) {
|
||||
return options?.voice ? 'VOICE' : 'AUDIO';
|
||||
}
|
||||
return 'FILE';
|
||||
}
|
||||
|
||||
export function detectChatMessageType(file: File, options?: { voice?: boolean }) {
|
||||
const contentType = resolveChatContentType(file);
|
||||
return inferChatMessageType(contentType, options);
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes?: number) {
|
||||
if (!bytes || bytes <= 0) return '';
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
export function parseMessageMetadata(metadataJson?: string) {
|
||||
if (!metadataJson) return {} as { fileName?: string; fileSize?: number; durationMs?: number };
|
||||
try {
|
||||
return JSON.parse(metadataJson) as { fileName?: string; fileSize?: number; durationMs?: number };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function fileIconLabel(fileName?: string, mimeType?: string) {
|
||||
const extension = extractFileExtension(fileName);
|
||||
if (extension === 'pdf' || mimeType === 'application/pdf') return 'PDF';
|
||||
if (['doc', 'docx'].includes(extension)) return 'DOC';
|
||||
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'XLS';
|
||||
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
|
||||
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
|
||||
if (extension) return extension.toUpperCase().slice(0, 4);
|
||||
return 'FILE';
|
||||
}
|
||||
308
apps/frontend/lib/document-catalog.ts
Normal file
308
apps/frontend/lib/document-catalog.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Baby,
|
||||
Building2,
|
||||
Car,
|
||||
CreditCard,
|
||||
FileText,
|
||||
HeartPulse,
|
||||
Plane,
|
||||
Stethoscope,
|
||||
Wallet
|
||||
} from 'lucide-react';
|
||||
|
||||
export type DocumentTypeCode =
|
||||
| 'PASSPORT_RF'
|
||||
| 'FOREIGN_PASSPORT'
|
||||
| 'BIRTH_CERTIFICATE'
|
||||
| 'DRIVER_LICENSE'
|
||||
| 'VEHICLE_REGISTRATION'
|
||||
| 'OMS'
|
||||
| 'DMS'
|
||||
| 'INN'
|
||||
| 'SNILS';
|
||||
|
||||
export type FieldKind = 'text' | 'date' | 'gender' | 'checkbox' | 'textarea' | 'categories';
|
||||
|
||||
export interface DocumentFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: FieldKind;
|
||||
placeholder?: string;
|
||||
latinKey?: string;
|
||||
latinLabel?: string;
|
||||
}
|
||||
|
||||
export interface DocumentTypeDef {
|
||||
type: DocumentTypeCode;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
category: 'personal' | 'transport' | 'health' | 'finance';
|
||||
icon: LucideIcon;
|
||||
accent: string;
|
||||
numberKey: string;
|
||||
fields: DocumentFieldDef[];
|
||||
}
|
||||
|
||||
export const DOCUMENT_CATEGORIES = [
|
||||
{ id: 'personal' as const, title: 'Личные документы' },
|
||||
{ id: 'transport' as const, title: 'Транспорт' },
|
||||
{ id: 'health' as const, title: 'Здоровье' },
|
||||
{ id: 'finance' as const, title: 'Финансы' }
|
||||
];
|
||||
|
||||
const baseNameFields: DocumentFieldDef[] = [
|
||||
{ key: 'lastName', label: 'Фамилия', kind: 'text', placeholder: 'Фамилия' },
|
||||
{ key: 'firstName', label: 'Имя', kind: 'text', placeholder: 'Имя' },
|
||||
{ key: 'middleName', label: 'Отчество', kind: 'text', placeholder: 'Отчество' },
|
||||
{ key: 'noMiddleName', label: 'Нет отчества', kind: 'checkbox' }
|
||||
];
|
||||
|
||||
const latinNameFields: DocumentFieldDef[] = [
|
||||
{ key: 'lastName', label: 'Фамилия', kind: 'text', placeholder: 'Фамилия', latinKey: 'lastNameLatin', latinLabel: 'Фамилия латинскими буквами' },
|
||||
{ key: 'firstName', label: 'Имя', kind: 'text', placeholder: 'Имя', latinKey: 'firstNameLatin', latinLabel: 'Имя латинскими буквами' },
|
||||
{ key: 'middleName', label: 'Отчество', kind: 'text', placeholder: 'Отчество', latinKey: 'middleNameLatin', latinLabel: 'Отчество латинскими буквами' },
|
||||
{ key: 'noMiddleName', label: 'Нет отчества', kind: 'checkbox' }
|
||||
];
|
||||
|
||||
export const DOCUMENT_TYPES: DocumentTypeDef[] = [
|
||||
{
|
||||
type: 'PASSPORT_RF',
|
||||
label: 'Паспорт РФ',
|
||||
shortLabel: 'Паспорт РФ',
|
||||
category: 'personal',
|
||||
icon: FileText,
|
||||
accent: 'text-red-500 bg-red-50',
|
||||
numberKey: 'seriesNumber',
|
||||
fields: [
|
||||
{ key: 'seriesNumber', label: 'Серия и номер', kind: 'text', placeholder: '0000 000000' },
|
||||
...baseNameFields,
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', placeholder: 'Место рождения' },
|
||||
{ key: 'gender', label: 'Пол', kind: 'gender' },
|
||||
{ key: 'issuedBy', label: 'Кем выдан', kind: 'text', placeholder: 'Кем выдан' },
|
||||
{ key: 'departmentCode', label: 'Код подразделения', kind: 'text', placeholder: '000-000' },
|
||||
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
|
||||
{ key: 'registrationAddress', label: 'Адрес регистрации', kind: 'text', placeholder: 'Адрес регистрации' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'FOREIGN_PASSPORT',
|
||||
label: 'Загран',
|
||||
shortLabel: 'Загран',
|
||||
category: 'personal',
|
||||
icon: Plane,
|
||||
accent: 'text-red-500 bg-red-50',
|
||||
numberKey: 'number',
|
||||
fields: [
|
||||
{ key: 'number', label: 'Номер', kind: 'text', placeholder: 'Номер' },
|
||||
...latinNameFields,
|
||||
{ key: 'citizenship', label: 'Гражданство', kind: 'text', placeholder: 'Гражданство', latinKey: 'citizenshipLatin', latinLabel: 'Гражданство латинскими буквами' },
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', placeholder: 'Место рождения', latinKey: 'birthPlaceLatin', latinLabel: 'Место рождения латинскими буквами' },
|
||||
{ key: 'gender', label: 'Пол', kind: 'gender' },
|
||||
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
|
||||
{ key: 'expiresAt', label: 'Дата окончания срока действия', kind: 'date' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'BIRTH_CERTIFICATE',
|
||||
label: 'Свидетельство о рождении',
|
||||
shortLabel: 'Св-во о рождении',
|
||||
category: 'personal',
|
||||
icon: Baby,
|
||||
accent: 'text-red-500 bg-red-50',
|
||||
numberKey: 'seriesNumber',
|
||||
fields: [
|
||||
{ key: 'series', label: 'Серия', kind: 'text', placeholder: 'Серия' },
|
||||
{ key: 'number', label: 'Номер', kind: 'text', placeholder: 'Номер' },
|
||||
...baseNameFields,
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
|
||||
{ key: 'recordNumber', label: 'Номер записи акта о рождении', kind: 'text' },
|
||||
{ key: 'fatherLastName', label: 'Фамилия отца', kind: 'text', placeholder: 'Фамилия' },
|
||||
{ key: 'fatherFirstName', label: 'Имя отца', kind: 'text', placeholder: 'Имя' },
|
||||
{ key: 'fatherMiddleName', label: 'Отчество отца', kind: 'text', placeholder: 'Отчество' },
|
||||
{ key: 'fatherNoMiddleName', label: 'Нет отчества (отец)', kind: 'checkbox' },
|
||||
{ key: 'fatherCitizenship', label: 'Гражданство отца', kind: 'text' },
|
||||
{ key: 'fatherBirthDate', label: 'Дата рождения отца', kind: 'date' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'DRIVER_LICENSE',
|
||||
label: 'ВУ',
|
||||
shortLabel: 'ВУ',
|
||||
category: 'transport',
|
||||
icon: CreditCard,
|
||||
accent: 'text-violet-500 bg-violet-50',
|
||||
numberKey: 'number',
|
||||
fields: [
|
||||
{ key: 'number', label: 'Номер', kind: 'text' },
|
||||
...latinNameFields,
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'birthPlace', label: 'Место рождения', kind: 'text', latinKey: 'birthPlaceLatin', latinLabel: 'Место рождения латинскими буквами' },
|
||||
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
|
||||
{ key: 'expiresAt', label: 'Действует до', kind: 'date' },
|
||||
{ key: 'issuedBy', label: 'Кем выдано', kind: 'text', latinKey: 'issuedByLatin', latinLabel: 'Кем выдано латинскими буквами' },
|
||||
{ key: 'issuePlace', label: 'Место выдачи', kind: 'text', latinKey: 'issuePlaceLatin', latinLabel: 'Место выдачи латинскими буквами' },
|
||||
{ key: 'categories', label: 'Категории', kind: 'categories' },
|
||||
{ key: 'specialMarks', label: 'Особые отметки', kind: 'textarea' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'VEHICLE_REGISTRATION',
|
||||
label: 'СТС / СРТС',
|
||||
shortLabel: 'СТС / СРТС',
|
||||
category: 'transport',
|
||||
icon: Car,
|
||||
accent: 'text-violet-500 bg-violet-50',
|
||||
numberKey: 'seriesNumber',
|
||||
fields: [
|
||||
{ key: 'seriesNumber', label: 'Серия и номер', kind: 'text' },
|
||||
{ key: 'plateNumber', label: 'Регистрационный знак', kind: 'text', placeholder: 'X000XX00' },
|
||||
{ key: 'vin', label: 'Идентификационный номер (VIN)', kind: 'text' },
|
||||
{ key: 'makeModel', label: 'Марка, модель', kind: 'text', latinKey: 'makeModelLatin', latinLabel: 'Марка, модель латинскими буквами' },
|
||||
{ key: 'vehicleType', label: 'Тип ТС', kind: 'text' },
|
||||
{ key: 'vehicleCategory', label: 'Категория ТС', kind: 'text' },
|
||||
{ key: 'year', label: 'Год выпуска', kind: 'text' },
|
||||
{ key: 'chassisNumber', label: 'Номер шасси (рамы)', kind: 'text' },
|
||||
{ key: 'bodyNumber', label: 'Номер кузова (кабины, прицепа)', kind: 'text' },
|
||||
{ key: 'color', label: 'Цвет', kind: 'text' },
|
||||
{ key: 'enginePower', label: 'Мощность двигателя, кВт/л.с.', kind: 'text' },
|
||||
{ key: 'ecoClass', label: 'Экологический класс', kind: 'text' },
|
||||
{ key: 'maxMass', label: 'Разрешенная максимальная масса, кг', kind: 'text' },
|
||||
{ key: 'curbWeight', label: 'Масса в снаряженном состоянии, кг', kind: 'text' },
|
||||
{ key: 'tempRegistration', label: 'Срок временной регистрации', kind: 'text' },
|
||||
{ key: 'pts', label: 'ПТС', kind: 'text', placeholder: '77TX000006 или 123456789012345' },
|
||||
...latinNameFields.slice(0, 3),
|
||||
{ key: 'registrationAddress', label: 'Адрес регистрации', kind: 'text' },
|
||||
{ key: 'departmentCode', label: 'Код подразделения (выдано ГИБДД)', kind: 'text' },
|
||||
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' },
|
||||
{ key: 'specialMarks', label: 'Особые отметки', kind: 'textarea' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'OMS',
|
||||
label: 'ОМС',
|
||||
shortLabel: 'ОМС',
|
||||
category: 'health',
|
||||
icon: Stethoscope,
|
||||
accent: 'text-sky-500 bg-sky-50',
|
||||
numberKey: 'number',
|
||||
fields: [
|
||||
{ key: 'number', label: 'Номер', kind: 'text' },
|
||||
...baseNameFields,
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'gender', label: 'Пол', kind: 'gender' },
|
||||
{ key: 'blankSeriesNumber', label: 'Серия и номер бланка', kind: 'text' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'DMS',
|
||||
label: 'ДМС',
|
||||
shortLabel: 'ДМС',
|
||||
category: 'health',
|
||||
icon: HeartPulse,
|
||||
accent: 'text-sky-500 bg-sky-50',
|
||||
numberKey: 'number',
|
||||
fields: [
|
||||
{ key: 'number', label: 'Номер', kind: 'text' },
|
||||
...baseNameFields,
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'gender', label: 'Пол', kind: 'gender' },
|
||||
{ key: 'validFrom', label: 'Действует с', kind: 'date' },
|
||||
{ key: 'validTo', label: 'Действует до', kind: 'date' },
|
||||
{ key: 'insurer', label: 'Страхователь', kind: 'text' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'INN',
|
||||
label: 'ИНН',
|
||||
shortLabel: 'ИНН',
|
||||
category: 'finance',
|
||||
icon: Building2,
|
||||
accent: 'text-emerald-500 bg-emerald-50',
|
||||
numberKey: 'number',
|
||||
fields: [
|
||||
{ key: 'number', label: 'ИНН', kind: 'text' },
|
||||
...baseNameFields,
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
|
||||
{ key: 'gender', label: 'Пол', kind: 'gender' },
|
||||
{ key: 'issuedBy', label: 'Орган, выдавший документ', kind: 'text' },
|
||||
{ key: 'issuedAt', label: 'Дата выдачи', kind: 'date' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'SNILS',
|
||||
label: 'СНИЛС',
|
||||
shortLabel: 'СНИЛС',
|
||||
category: 'finance',
|
||||
icon: Wallet,
|
||||
accent: 'text-emerald-500 bg-emerald-50',
|
||||
numberKey: 'number',
|
||||
fields: [
|
||||
{ key: 'number', label: 'СНИЛС', kind: 'text' },
|
||||
...baseNameFields,
|
||||
{ key: 'birthDate', label: 'Дата рождения', kind: 'date' },
|
||||
{ key: 'birthPlace', label: 'Место рождения', kind: 'text' },
|
||||
{ key: 'gender', label: 'Пол', kind: 'gender' },
|
||||
{ key: 'registeredAt', label: 'Дата регистрации', kind: 'date' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const QUICK_DOCUMENT_TYPES = ['PASSPORT_RF', 'DRIVER_LICENSE', 'FOREIGN_PASSPORT'] as const;
|
||||
|
||||
export const LICENSE_CATEGORIES = ['A', 'B', 'C', 'D', 'A1', 'B1', 'C1', 'D1', 'M', 'BE', 'CE', 'DE', 'Tm', 'Tb', 'C1E', 'D1E'];
|
||||
|
||||
export function getDocumentType(type: string) {
|
||||
return DOCUMENT_TYPES.find((item) => item.type === type);
|
||||
}
|
||||
|
||||
export function buildDocumentNumber(type: DocumentTypeDef, values: Record<string, string>) {
|
||||
if (type.type === 'BIRTH_CERTIFICATE') {
|
||||
return [values.series, values.number].filter(Boolean).join(' ');
|
||||
}
|
||||
if (type.type === 'PASSPORT_RF' || type.type === 'VEHICLE_REGISTRATION') {
|
||||
return values.seriesNumber || values.number || '—';
|
||||
}
|
||||
return values[type.numberKey] || '—';
|
||||
}
|
||||
|
||||
export function parseMetadata(metadataJson?: string) {
|
||||
if (!metadataJson) return {} as Record<string, unknown>;
|
||||
try {
|
||||
return JSON.parse(metadataJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {} as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDocumentPhotos(metadata: Record<string, unknown>): string[] {
|
||||
const keys = metadata.photoStorageKeys;
|
||||
if (Array.isArray(keys)) {
|
||||
return keys.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||
}
|
||||
if (typeof metadata.photoStorageKey === 'string' && metadata.photoStorageKey) {
|
||||
return [metadata.photoStorageKey];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function withDocumentPhotos(metadata: Record<string, unknown>, photoStorageKeys: string[]) {
|
||||
const next: Record<string, unknown> = { ...metadata, photoStorageKeys };
|
||||
delete next.photoStorageKey;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Берёт самый свежий документ каждого типа (список с бэкенда отсортирован по updatedAt desc). */
|
||||
export function indexDocumentsByType<T extends { type: string }>(documents: T[]): Map<string, T> {
|
||||
const map = new Map<string, T>();
|
||||
for (const document of documents) {
|
||||
if (!map.has(document.type)) {
|
||||
map.set(document.type, document);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
76
apps/frontend/lib/geocoding.ts
Normal file
76
apps/frontend/lib/geocoding.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface ParsedAddress {
|
||||
city: string;
|
||||
street: string;
|
||||
house: string;
|
||||
fullAddress: string;
|
||||
}
|
||||
|
||||
const NOMINATIM_HEADERS = {
|
||||
'User-Agent': 'LendryID/1.0 (contact@lendry.ru)',
|
||||
Accept: 'application/json'
|
||||
};
|
||||
|
||||
export async function reverseGeocode(lat: number, lng: number): Promise<ParsedAddress | null> {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(String(lat))}&lon=${encodeURIComponent(String(lng))}&accept-language=ru`;
|
||||
const response = await fetch(url, { headers: NOMINATIM_HEADERS });
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as {
|
||||
display_name?: string;
|
||||
address?: Record<string, string | undefined>;
|
||||
};
|
||||
const addr = data.address ?? {};
|
||||
const city = addr.city || addr.town || addr.village || addr.municipality || addr.state || '';
|
||||
const street = addr.road || addr.pedestrian || addr.footway || addr.neighbourhood || '';
|
||||
const house = addr.house_number || '';
|
||||
return {
|
||||
city,
|
||||
street,
|
||||
house,
|
||||
fullAddress: data.display_name ?? formatParsed(city, street, house)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function forwardGeocode(query: string): Promise<{ lat: number; lng: number; parsed: ParsedAddress } | null> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&q=${encodeURIComponent(trimmed)}&limit=1&accept-language=ru`;
|
||||
const response = await fetch(url, { headers: NOMINATIM_HEADERS });
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as Array<{
|
||||
lat: string;
|
||||
lon: string;
|
||||
display_name?: string;
|
||||
address?: Record<string, string | undefined>;
|
||||
}>;
|
||||
const first = data[0];
|
||||
if (!first) return null;
|
||||
const addr = first.address ?? {};
|
||||
const city = addr.city || addr.town || addr.village || addr.municipality || '';
|
||||
const street = addr.road || addr.pedestrian || '';
|
||||
const house = addr.house_number || '';
|
||||
return {
|
||||
lat: Number(first.lat),
|
||||
lng: Number(first.lon),
|
||||
parsed: {
|
||||
city,
|
||||
street,
|
||||
house,
|
||||
fullAddress: first.display_name ?? formatParsed(city, street, house)
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatParsed(city: string, street: string, house: string) {
|
||||
const parts = [];
|
||||
if (city) parts.push(`г. ${city}`);
|
||||
if (street || house) parts.push([street, house].filter(Boolean).join(', '));
|
||||
return parts.join(', ');
|
||||
}
|
||||
62
apps/frontend/lib/system-settings-catalog.ts
Normal file
62
apps/frontend/lib/system-settings-catalog.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type SystemSettingFieldType = 'text' | 'number' | 'boolean';
|
||||
|
||||
export interface SystemSettingMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
type: SystemSettingFieldType;
|
||||
unit?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
||||
general: 'Проект и брендинг',
|
||||
pin: 'PIN-код и блокировка сессии',
|
||||
auth: 'Аутентификация и регистрация',
|
||||
ldap: 'LDAP / Active Directory',
|
||||
limits: 'Лимиты и ограничения',
|
||||
media: 'Медиа и файлы'
|
||||
};
|
||||
|
||||
export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
|
||||
{ key: 'PROJECT_NAME', label: 'Название проекта', group: 'general', type: 'text', hint: 'Отображается в меню, заголовке и боковой панели' },
|
||||
{ key: 'PROJECT_TAGLINE', label: 'Слоган проекта', group: 'general', type: 'text' },
|
||||
{ key: 'PROJECT_DOMAIN', label: 'Домен IdP', group: 'general', type: 'text' },
|
||||
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', label: 'Таймаут блокировки PIN', group: 'pin', type: 'number', unit: 'мин' },
|
||||
{ key: 'PIN_DELETE_GRACE_MINUTES', label: 'Задержка удаления PIN', group: 'pin', type: 'number', unit: 'мин', hint: 'Сколько ждать после запроса удаления PIN-кода' },
|
||||
{ key: 'PIN_REQUIRE_ON_DELETE', label: 'Требовать PIN при удалении', group: 'pin', type: 'boolean' },
|
||||
{ key: 'PIN_MIN_LENGTH', label: 'Мин. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
|
||||
{ key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
|
||||
{ key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' },
|
||||
{ key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' },
|
||||
{ key: 'SESSION_REFRESH_DAYS', label: 'Срок refresh-токена', group: 'auth', type: 'number', unit: 'дн' },
|
||||
{ key: 'PASSWORD_MIN_LENGTH', label: 'Мин. длина пароля', group: 'auth', type: 'number', unit: 'симв' },
|
||||
{ key: 'REGISTRATION_ENABLED', label: 'Регистрация включена', group: 'auth', type: 'boolean' },
|
||||
{ key: 'MAINTENANCE_MODE', label: 'Режим обслуживания', group: 'auth', type: 'boolean', hint: 'Блокирует вход для обычных пользователей' },
|
||||
{ key: 'LDAP_ENABLED', label: 'LDAP включён', group: 'ldap', type: 'boolean', hint: 'Показывает кнопку входа через LDAP на странице авторизации' },
|
||||
{ key: 'LDAP_USE_LDAPS', label: 'Использовать LDAPS', group: 'ldap', type: 'boolean', hint: 'Шифрованное подключение (порт 636)' },
|
||||
{ key: 'LDAP_HOST', label: 'Хост LDAP', group: 'ldap', type: 'text', hint: 'DC-1.local,DC-2.local или ldaps://DC-1.local:636' },
|
||||
{ key: 'LDAP_PORT', label: 'Порт LDAP', group: 'ldap', type: 'number', hint: '636 для LDAPS, 389 для LDAP. Не используйте 640.' },
|
||||
{ key: 'LDAP_DOMAIN', label: 'Домен AD', group: 'ldap', type: 'text', hint: 'mvkug.local — для bind вида user@domain' },
|
||||
{ key: 'LDAP_SKIP_TLS_VERIFY', label: 'Не проверять TLS', group: 'ldap', type: 'boolean', hint: 'Только для тестовых окружений' },
|
||||
{ key: 'LDAP_BASE_DN', label: 'Base DN (поиск пользователей)', group: 'ldap', type: 'text', hint: 'OU или CN контейнера с пользователями. Напр. OU=Lugansk,DC=mvkug,DC=local. Если CN не работает — попробуйте OU' },
|
||||
{ key: 'LDAP_BIND_USERNAME', label: 'Логин сервисной УЗ', group: 'ldap', type: 'text', hint: 'mvkadmin или mvkug\\mvkadmin — учётная запись для подключения к AD' },
|
||||
{ key: 'LDAP_BIND_DN', label: 'Bind DN (опционально)', group: 'ldap', type: 'text', hint: 'Полный DN сервисной УЗ. Оставьте пустым, если указан логин выше' },
|
||||
{ key: 'LDAP_BIND_PASSWORD', label: 'Пароль сервисной УЗ', group: 'ldap', type: 'text', hint: 'Секретное значение' },
|
||||
{ key: 'LDAP_USER_FILTER', label: 'Фильтр пользователя', group: 'ldap', type: 'text', hint: 'Placeholder {username}' },
|
||||
{ key: 'LDAP_USERNAME_ATTR', label: 'Атрибут логина', group: 'ldap', type: 'text' },
|
||||
{ key: 'LDAP_EMAIL_ATTR', label: 'Атрибут почты', group: 'ldap', type: 'text' },
|
||||
{ key: 'LDAP_DISPLAY_NAME_ATTR', label: 'Атрибут имени', group: 'ldap', type: 'text' },
|
||||
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
|
||||
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
|
||||
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
|
||||
];
|
||||
|
||||
export function getSettingMeta(key: string) {
|
||||
return SYSTEM_SETTING_CATALOG.find((item) => item.key === key);
|
||||
}
|
||||
|
||||
export function sortSettingsByCatalog<T extends { key: string }>(settings: T[]) {
|
||||
const order = new Map(SYSTEM_SETTING_CATALOG.map((item, index) => [item.key, index]));
|
||||
return [...settings].sort((a, b) => (order.get(a.key) ?? 999) - (order.get(b.key) ?? 999));
|
||||
}
|
||||
6
apps/frontend/lib/utils.ts
Normal file
6
apps/frontend/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user