fix and update

This commit is contained in:
lendry
2026-07-01 23:27:48 +03:00
parent 2b88e028c6
commit bcdfbc3861
36 changed files with 1504 additions and 320 deletions

View File

@@ -16,8 +16,9 @@ export class FamilyController {
private readonly jwt: JwtService private readonly jwt: JwtService
) {} ) {}
private async auth(authorization?: string) { private async auth(authorization?: string, passiveActivityHeader?: string) {
return getAuthorizedUserId(this.jwt, this.core, authorization); const touchActivity = passiveActivityHeader !== '1';
return getAuthorizedUserId(this.jwt, this.core, authorization, touchActivity);
} }
@Post('groups') @Post('groups')
@@ -33,8 +34,12 @@ export class FamilyController {
@Get('users/:userId/groups') @Get('users/:userId/groups')
@ApiOperation({ summary: 'Список семей пользователя' }) @ApiOperation({ summary: 'Список семей пользователя' })
async listGroups(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { async listGroups(
const requesterId = await this.auth(authorization); @Headers('authorization') authorization: string | undefined,
@Headers('x-id-passive-activity') passiveActivity: string | undefined,
@Param('userId') userId: string
) {
const requesterId = await this.auth(authorization, passiveActivity);
if (requesterId !== userId) { if (requesterId !== userId) {
throw new ForbiddenException('Можно просматривать только свои семьи'); throw new ForbiddenException('Можно просматривать только свои семьи');
} }
@@ -50,8 +55,12 @@ export class FamilyController {
@Get('groups/:groupId') @Get('groups/:groupId')
@ApiOperation({ summary: 'Получить семейную группу' }) @ApiOperation({ summary: 'Получить семейную группу' })
async getGroup(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { async getGroup(
const requesterId = await this.auth(authorization); @Headers('authorization') authorization: string | undefined,
@Headers('x-id-passive-activity') passiveActivity: string | undefined,
@Param('groupId') groupId: string
) {
const requesterId = await this.auth(authorization, passiveActivity);
return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId })); return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId }));
} }
@@ -181,8 +190,12 @@ export class FamilyController {
@Get('groups/:groupId/presence') @Get('groups/:groupId/presence')
@ApiOperation({ summary: 'Онлайн-статус участников семьи' }) @ApiOperation({ summary: 'Онлайн-статус участников семьи' })
async getPresence(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { async getPresence(
const requesterId = await this.auth(authorization); @Headers('authorization') authorization: string | undefined,
@Headers('x-id-passive-activity') passiveActivity: string | undefined,
@Param('groupId') groupId: string
) {
const requesterId = await this.auth(authorization, passiveActivity);
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId })); return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId }));
} }

View File

@@ -117,7 +117,7 @@ export class MediaController {
@Post('documents/:documentId/photo/upload-url') @Post('documents/:documentId/photo/upload-url')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' }) @ApiOperation({ summary: 'Получить URL для файла документа', description: 'Presigned URL для загрузки скана, фото или файла документа в MinIO.' })
@ApiBody({ type: DocumentPhotoUploadDto }) @ApiBody({ type: DocumentPhotoUploadDto })
async createDocumentPhotoUploadUrl( async createDocumentPhotoUploadUrl(
@Headers('authorization') authorization: string | undefined, @Headers('authorization') authorization: string | undefined,
@@ -125,22 +125,31 @@ export class MediaController {
@Body() dto: DocumentPhotoUploadDto @Body() dto: DocumentPhotoUploadDto
) { ) {
const userId = await this.authUserId(authorization); const userId = await this.authUserId(authorization);
return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType })); return firstValueFrom(
this.core.media.CreateDocumentPhotoUploadUrl({
userId,
documentId,
contentType: dto.contentType,
fileName: dto.fileName
})
);
} }
@Get('users/:userId/documents/photo-url') @Get('users/:userId/documents/photo-url')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' }) @ApiOperation({ summary: 'Получить ссылку на файл документа', description: 'Временная ссылка на просмотр файла документа (15 минут).' })
@ApiParam({ name: 'userId', description: 'ID владельца документа' }) @ApiParam({ name: 'userId', description: 'ID владельца документа' })
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' }) @ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
@ApiQuery({ name: 'fileName', required: false, description: 'Имя файла для скачивания' })
async getDocumentPhotoUrl( async getDocumentPhotoUrl(
@Headers('authorization') authorization: string | undefined, @Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string, @Param('userId') userId: string,
@Query('storageKey') storageKey: string @Query('storageKey') storageKey: string,
@Query('fileName') fileName?: string
) { ) {
const requesterId = await this.authUserId(authorization); const requesterId = await this.authUserId(authorization);
return firstValueFrom( return firstValueFrom(
this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey }) this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey, fileName })
); );
} }

View File

@@ -40,8 +40,12 @@ export class NotificationsController {
@Get('unread-count') @Get('unread-count')
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' }) @ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
async unreadCount(@Headers('authorization') authorization: string | undefined) { async unreadCount(
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization); @Headers('authorization') authorization: string | undefined,
@Headers('x-id-passive-activity') passiveActivity: string | undefined
) {
const touchActivity = passiveActivity !== '1';
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization, touchActivity);
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId })); return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
} }

View File

@@ -10,9 +10,14 @@ interface RequesterProfile {
canViewUserDocuments?: boolean; canViewUserDocuments?: boolean;
} }
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> { async function getRequesterProfile(
jwt: JwtService,
core: CoreGrpcService,
authorization?: string,
touchActivity = true
): Promise<RequesterProfile> {
const payload = await verifyAccessToken(jwt, authorization); const payload = await verifyAccessToken(jwt, authorization);
await assertSessionUnlocked(core, payload); await assertSessionUnlocked(core, payload, touchActivity);
if (payload.isSuperAdmin) { if (payload.isSuperAdmin) {
return { id: payload.sub, isSuperAdmin: true, canViewUserDocuments: true }; return { id: payload.sub, isSuperAdmin: true, canViewUserDocuments: true };
@@ -51,7 +56,12 @@ export async function assertDocumentsWriteAccess(
return requester.id; return requester.id;
} }
export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) { export async function getAuthorizedUserId(
const requester = await getRequesterProfile(jwt, core, authorization); jwt: JwtService,
core: CoreGrpcService,
authorization?: string,
touchActivity = true
) {
const requester = await getRequesterProfile(jwt, core, authorization, touchActivity);
return requester.id; return requester.id;
} }

View File

@@ -2,6 +2,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator'; import { IsIn, IsOptional, IsString } from 'class-validator';
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const; const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
const DOCUMENT_ATTACHMENT_TYPES = [
...IMAGE_TYPES,
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'application/rtf',
'application/vnd.oasis.opendocument.text'
] as const;
export class AvatarUploadDto { export class AvatarUploadDto {
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES }) @ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
@IsString({ message: 'Укажите MIME-тип изображения' }) @IsString({ message: 'Укажите MIME-тип изображения' })
@@ -16,10 +31,17 @@ export class ConfirmAvatarDto {
} }
export class DocumentPhotoUploadDto { export class DocumentPhotoUploadDto {
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES }) @ApiProperty({ description: 'MIME-тип файла', example: 'application/pdf', enum: DOCUMENT_ATTACHMENT_TYPES })
@IsString({ message: 'Укажите MIME-тип изображения' }) @IsString({ message: 'Укажите MIME-тип файла' })
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' }) @IsIn([...DOCUMENT_ATTACHMENT_TYPES], {
message: 'Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT'
})
contentType!: string; contentType!: string;
@ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'passport.pdf' })
@IsOptional()
@IsString({ message: 'Имя файла должно быть строкой' })
fileName?: string;
} }
export class ChatMediaUploadDto { export class ChatMediaUploadDto {

View File

@@ -28,7 +28,11 @@ export async function verifyAccessToken(jwt: JwtService, authorization?: string)
} }
} }
export async function assertSessionUnlocked(core: CoreGrpcService, payload: AccessTokenPayload) { export async function assertSessionUnlocked(
core: CoreGrpcService,
payload: AccessTokenPayload,
touchActivity = true
) {
if (!payload.sessionId) { if (!payload.sessionId) {
return; return;
} }
@@ -37,7 +41,7 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
core.auth.ValidateSession({ core.auth.ValidateSession({
userId: payload.sub, userId: payload.sub,
sessionId: payload.sessionId, sessionId: payload.sessionId,
touchActivity: true touchActivity
}) })
)) as { requiresPin: boolean; sessionId: string }; )) as { requiresPin: boolean; sessionId: string };
@@ -54,9 +58,10 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
export async function resolveAuthorizedPayload( export async function resolveAuthorizedPayload(
jwt: JwtService, jwt: JwtService,
core: CoreGrpcService, core: CoreGrpcService,
authorization?: string authorization?: string,
touchActivity = true
): Promise<AccessTokenPayload> { ): Promise<AccessTokenPayload> {
const payload = await verifyAccessToken(jwt, authorization); const payload = await verifyAccessToken(jwt, authorization);
await assertSessionUnlocked(core, payload); await assertSessionUnlocked(core, payload, touchActivity);
return payload; return payload;
} }

View File

@@ -27,6 +27,31 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
scrollbar-width: thin;
scrollbar-color: rgb(168 173 188 / 55%) transparent;
}
* {
scrollbar-width: thin;
scrollbar-color: rgb(168 173 188 / 55%) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: rgb(168 173 188 / 45%);
border-radius: 999px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgb(102 112 133 / 65%);
} }
a { a {

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, MoreVertical, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
import { UserInspectorDialog } from '@/components/admin/user-inspector-dialog'; import { UserInspectorDialog } from '@/components/admin/user-inspector-dialog';
import { VerificationBadge } from '@/components/id/verification-badge'; import { VerificationBadge } from '@/components/id/verification-badge';
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog'; import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
@@ -10,10 +10,16 @@ import { AdminShell } from '@/components/id/admin-shell';
import { useAuth } from '@/components/id/auth-provider'; import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider'; import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } from '@/lib/api'; import { AdminPermission, AdminRole, AdminUser, PublicUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } from '@/lib/api';
import { getAdminLandingPath } from '@/lib/admin-access'; import { getAdminLandingPath } from '@/lib/admin-access';
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons'; import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
@@ -34,6 +40,113 @@ const roleLabels: Record<string, string> = {
const DEFAULT_USER_ROLE = 'user'; const DEFAULT_USER_ROLE = 'user';
function UserActionsMenu({
user,
currentUser,
actionLoading,
onOpenInspector,
onResetPassword,
onSuspend,
onUnsuspend,
onOpenDocuments,
onDisableTotp,
onOpenVerification,
onOpenRoles,
onToggleSuperAdmin
}: {
user: AdminUser;
currentUser: PublicUser | null;
actionLoading: boolean;
onOpenInspector: () => void;
onResetPassword: () => void;
onSuspend: () => void;
onUnsuspend: () => void;
onOpenDocuments: () => void;
onDisableTotp: () => void;
onOpenVerification: () => void;
onOpenRoles: () => void;
onToggleSuperAdmin: () => void;
}) {
const canInspect = (currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot;
const canManage = Boolean(currentUser?.canManageUsers);
const canDocuments = Boolean(currentUser?.canViewUserDocuments && !user.isBot);
const canDisableTotp = Boolean(currentUser?.isSuperAdmin && !user.isBot);
const canVerify = Boolean(currentUser?.canVerifyUsers);
const canRoles = Boolean(currentUser?.canManageRoles);
if (!canInspect && !canManage && !canDocuments && !canDisableTotp && !canVerify && !canRoles) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" aria-label="Действия с пользователем" disabled={actionLoading}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 rounded-xl">
{canInspect ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenInspector}>
<ScrollText className="h-4 w-4" />
Журнал и чаты
</DropdownMenuItem>
) : null}
{canManage ? (
<>
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onResetPassword}>
<KeyRound className="h-4 w-4" />
Сбросить пароль
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status === 'SUSPENDED'} onClick={onSuspend}>
<Ban className="h-4 w-4" />
Заблокировать
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status !== 'SUSPENDED'} onClick={onUnsuspend}>
<UserCheck className="h-4 w-4" />
Разблокировать
</DropdownMenuItem>
</>
) : null}
{canDocuments ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenDocuments}>
<FileText className="h-4 w-4" />
Документы
</DropdownMenuItem>
) : null}
{canDisableTotp ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onDisableTotp}>
<ShieldOff className="h-4 w-4" />
Отключить 2FA
</DropdownMenuItem>
) : null}
{canVerify ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenVerification}>
<BadgeCheck className="h-4 w-4" />
{user.isVerified ? 'Изменить верификацию' : 'Верифицировать'}
</DropdownMenuItem>
) : null}
{canRoles ? (
<>
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenRoles}>
<UserCog className="h-4 w-4" />
Роли и доступ
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 rounded-lg"
disabled={user.id === currentUser?.id}
onClick={onToggleSuperAdmin}
>
<Crown className="h-4 w-4" />
{user.isSuperAdmin ? 'Снять супер-админа' : 'Назначить супер-админом'}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function AdminUsersPage() { export default function AdminUsersPage() {
const router = useRouter(); const router = useRouter();
const { token, user: currentUser } = useAuth(); const { token, user: currentUser } = useAuth();
@@ -367,98 +480,28 @@ export default function AdminUsersPage() {
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span> <span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex justify-end gap-2" onClick={(event) => event.stopPropagation()}> <div className="flex justify-end" onClick={(event) => event.stopPropagation()}>
{(currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot ? ( <UserActionsMenu
<Button user={user}
variant="secondary" currentUser={currentUser}
size="icon" actionLoading={actionLoading}
aria-label="Журнал и чаты" onOpenInspector={() => setInspectorUser(user)}
onClick={() => setInspectorUser(user)} onResetPassword={() => {
> setSelectedUser(user);
<ScrollText className="h-4 w-4" /> setPassword('');
</Button> setDialog('password');
) : null} }}
{currentUser?.canManageUsers ? ( onSuspend={() => void handleSuspend(user)}
<> onUnsuspend={() => void handleUnsuspend(user)}
<Button onOpenDocuments={() => setDocumentsUser(user)}
variant="secondary" onDisableTotp={() => void handleAdminDisableTotp(user)}
size="icon" onOpenVerification={() => openVerificationDialog(user)}
aria-label="Сбросить пароль" onOpenRoles={() => {
disabled={actionLoading} setSelectedUser(user);
onClick={() => { setDialog('roles');
setSelectedUser(user); }}
setPassword(''); onToggleSuperAdmin={() => void handleToggleSuperAdmin(user)}
setDialog('password'); />
}}
>
<KeyRound className="h-4 w-4" />
</Button>
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
<Ban className="h-4 w-4" />
</Button>
<Button variant="secondary" size="icon" aria-label="Разблокировать" disabled={actionLoading || user.status !== 'SUSPENDED'} onClick={() => void handleUnsuspend(user)}>
<UserCheck className="h-4 w-4" />
</Button>
</>
) : null}
{currentUser?.canViewUserDocuments && !user.isBot ? (
<Button
variant="secondary"
size="icon"
aria-label="Документы пользователя"
disabled={actionLoading}
onClick={() => setDocumentsUser(user)}
>
<FileText className="h-4 w-4" />
</Button>
) : null}
{currentUser?.isSuperAdmin && !user.isBot ? (
<Button
variant="secondary"
size="icon"
aria-label="Отключить 2FA"
disabled={actionLoading}
onClick={() => void handleAdminDisableTotp(user)}
>
<ShieldOff className="h-4 w-4" />
</Button>
) : null}
{currentUser?.canVerifyUsers ? (
<Button
variant={user.isVerified ? 'default' : 'secondary'}
size="icon"
aria-label="Верификация"
disabled={actionLoading}
onClick={() => openVerificationDialog(user)}
>
<BadgeCheck className="h-4 w-4" />
</Button>
) : null}
{currentUser?.canManageRoles ? (
<>
<Button
variant="secondary"
size="icon"
aria-label="Управление ролями"
disabled={actionLoading}
onClick={() => {
setSelectedUser(user);
setDialog('roles');
}}
>
<UserCog className="h-4 w-4" />
</Button>
<Button
variant={user.isSuperAdmin ? 'default' : 'secondary'}
size="icon"
aria-label="Супер-админ"
disabled={actionLoading || user.id === currentUser?.id}
onClick={() => void handleToggleSuperAdmin(user)}
>
<Crown className="h-4 w-4" />
</Button>
</>
) : null}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react'; import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
import { AddressQuickSection } from '@/components/addresses/address-quick-section'; import { AddressQuickSection } from '@/components/addresses/address-quick-section';
import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; import { DocumentDialog } from '@/components/documents/document-dialog';
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery'; import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
import { ActionTile } from '@/components/id/action-tile'; import { ActionTile } from '@/components/id/action-tile';
import { useAuth } from '@/components/id/auth-provider'; import { useAuth } from '@/components/id/auth-provider';
@@ -20,6 +20,7 @@ import { useRequireAuth } from '@/hooks/use-require-auth';
import { import {
getDocumentType, getDocumentType,
indexDocumentsByType, indexDocumentsByType,
parseAttachmentMeta,
parseDocumentPhotos, parseDocumentPhotos,
parseMetadata, parseMetadata,
type DocumentTypeCode type DocumentTypeCode
@@ -324,6 +325,7 @@ export default function DataPage() {
documents.map((document) => { documents.map((document) => {
const config = getDocumentType(document.type); const config = getDocumentType(document.type);
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson)); const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
return ( return (
<div key={document.id}> <div key={document.id}>
<Row <Row
@@ -334,7 +336,7 @@ export default function DataPage() {
/> />
{photoKeys.length > 0 && userId && token ? ( {photoKeys.length > 0 && userId && token ? (
<div className="border-b border-[#eceef4] px-1 pb-4"> <div className="border-b border-[#eceef4] px-1 pb-4">
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} /> <DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -401,7 +403,7 @@ export default function DataPage() {
</section> </section>
{activeDocumentType && user ? ( {activeDocumentType && user ? (
<DocumentFormDialog <DocumentDialog
open={Boolean(activeDocumentType)} open={Boolean(activeDocumentType)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setActiveDocumentType(null); if (!open) setActiveDocumentType(null);

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ChevronRight, Plus } from 'lucide-react'; import { ChevronRight, Plus } from 'lucide-react';
import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; import { DocumentDialog } from '@/components/documents/document-dialog';
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery'; import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
import { useAuth } from '@/components/id/auth-provider'; import { useAuth } from '@/components/id/auth-provider';
import { IdShell } from '@/components/id/shell'; import { IdShell } from '@/components/id/shell';
@@ -12,6 +12,7 @@ import {
DOCUMENT_CATEGORIES, DOCUMENT_CATEGORIES,
DOCUMENT_TYPES, DOCUMENT_TYPES,
getDocumentType, getDocumentType,
parseAttachmentMeta,
parseDocumentPhotos, parseDocumentPhotos,
parseMetadata, parseMetadata,
QUICK_DOCUMENT_TYPES, QUICK_DOCUMENT_TYPES,
@@ -97,6 +98,7 @@ export default function DocumentsPage() {
const Icon = item.icon; const Icon = item.icon;
const existing = documentsByType.get(item.type); const existing = documentsByType.get(item.type);
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : []; const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
const attachmentMeta = existing ? parseAttachmentMeta(parseMetadata(existing.metadataJson)) : {};
return ( return (
<div key={item.type} className="border-b border-white/70 last:border-b-0"> <div key={item.type} className="border-b border-white/70 last:border-b-0">
<button <button
@@ -115,7 +117,7 @@ export default function DocumentsPage() {
</button> </button>
{existing && photoKeys.length > 0 && user && token ? ( {existing && photoKeys.length > 0 && user && token ? (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} /> <DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -126,7 +128,7 @@ export default function DocumentsPage() {
))} ))}
{activeType ? ( {activeType ? (
<DocumentFormDialog <DocumentDialog
open={Boolean(activeType)} open={Boolean(activeType)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setActiveType(null); if (!open) setActiveType(null);

View File

@@ -15,6 +15,29 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: rgb(168 173 188 / 55%) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: rgb(168 173 188 / 45%);
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: rgb(102 112 133 / 65%);
background-clip: padding-box;
} }
body { body {
@@ -29,6 +52,10 @@ html {
overflow-x: hidden; overflow-x: hidden;
} }
*::-webkit-scrollbar-corner {
background: transparent;
}
.rdp-root { .rdp-root {
--rdp-accent-color: #111827; --rdp-accent-color: #111827;
--rdp-accent-background-color: #f4f5f8; --rdp-accent-background-color: #f4f5f8;

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react'; import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react';
import { AddressQuickSection } from '@/components/addresses/address-quick-section'; import { AddressQuickSection } from '@/components/addresses/address-quick-section';
import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; import { DocumentDialog } from '@/components/documents/document-dialog';
import { ActionTile, SectionTitle } from '@/components/id/action-tile'; import { ActionTile, SectionTitle } from '@/components/id/action-tile';
import { AdminBadge } from '@/components/id/admin-badge'; import { AdminBadge } from '@/components/id/admin-badge';
import { AvatarDisplay } from '@/components/id/avatar-upload'; import { AvatarDisplay } from '@/components/id/avatar-upload';
@@ -124,7 +124,7 @@ export default function HomePage() {
</div> </div>
{activeDocumentType && user ? ( {activeDocumentType && user ? (
<DocumentFormDialog <DocumentDialog
open={Boolean(activeDocumentType)} open={Boolean(activeDocumentType)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setActiveDocumentType(null); if (!open) setActiveDocumentType(null);

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { CalendarRange, RotateCcw } from 'lucide-react'; import { useMemo, useState } from 'react';
import { CalendarRange, ChevronDown, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
export function toDatetimeLocalValue(date: Date) { export function toDatetimeLocalValue(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0'); const pad = (value: number) => String(value).padStart(2, '0');
@@ -36,6 +38,21 @@ function pluralDays(value: number) {
return 'дней'; return 'дней';
} }
function formatPeriodLabel(from: string, to: string) {
const format = (value: string) => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(parsed);
};
return `${format(from)}${format(to)}`;
}
export function AdminInsightsDateFilter({ export function AdminInsightsDateFilter({
from, from,
to, to,
@@ -51,45 +68,62 @@ export function AdminInsightsDateFilter({
onToChange: (value: string) => void; onToChange: (value: string) => void;
onReset: () => void; onReset: () => void;
}) { }) {
const [open, setOpen] = useState(false);
const periodLabel = useMemo(() => formatPeriodLabel(from, to), [from, to]);
return ( return (
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3"> <div className="rounded-2xl border border-[#eceef4] bg-[#fafbff]">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#667085]"> <button
<CalendarRange className="h-4 w-4" /> type="button"
Период просмотра className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition hover:bg-[#f4f5f8]"
</div> onClick={() => setOpen((current) => !current)}
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"> aria-expanded={open}
<div className="space-y-1"> >
<label className="text-xs text-[#667085]" htmlFor="insights-from"> <CalendarRange className="h-4 w-4 shrink-0 text-[#667085]" />
С даты и времени <div className="min-w-0 flex-1">
</label> <p className="text-xs font-medium text-[#667085]">Период просмотра</p>
<Input <p className="truncate text-sm text-[#1f2430]">{periodLabel}</p>
id="insights-from"
type="datetime-local"
value={from}
onChange={(event) => onFromChange(event.target.value)}
/>
</div> </div>
<div className="space-y-1"> <ChevronDown className={cn('h-4 w-4 shrink-0 text-[#667085] transition', open && 'rotate-180')} />
<label className="text-xs text-[#667085]" htmlFor="insights-to"> </button>
По дату и время
</label> {open ? (
<Input <div className="border-t border-[#eceef4] px-3 pb-3 pt-3">
id="insights-to" <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
type="datetime-local" <div className="space-y-1">
value={to} <label className="text-xs text-[#667085]" htmlFor="insights-from">
onChange={(event) => onToChange(event.target.value)} С даты и времени
/> </label>
<Input
id="insights-from"
type="datetime-local"
value={from}
onChange={(event) => onFromChange(event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-[#667085]" htmlFor="insights-to">
По дату и время
</label>
<Input
id="insights-to"
type="datetime-local"
value={to}
onChange={(event) => onToChange(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
<RotateCcw className="h-4 w-4" />
Сбросить
</Button>
</div>
</div>
<p className="mt-2 text-xs text-[#667085]">
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
</p>
</div> </div>
<div className="flex items-end"> ) : null}
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
<RotateCcw className="h-4 w-4" />
Сбросить
</Button>
</div>
</div>
<p className="mt-2 text-xs text-[#667085]">
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
</p>
</div> </div>
); );
} }

View File

@@ -0,0 +1,80 @@
'use client';
import { useEffect, useState } from 'react';
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
import { DocumentViewDialog } from '@/components/documents/document-view-dialog';
import { type DocumentTypeCode } from '@/lib/document-catalog';
import { UserDocument } from '@/lib/api';
export function DocumentDialog({
open,
onOpenChange,
documentType,
userId,
token,
existing,
onSaved,
readOnly
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentTypeCode;
userId: string;
token: string | null;
existing?: UserDocument | null;
onSaved: () => void | Promise<void>;
readOnly?: boolean;
}) {
const [mode, setMode] = useState<'view' | 'edit'>('edit');
useEffect(() => {
if (!open) return;
setMode(existing ? 'view' : 'edit');
}, [existing?.id, open]);
function handleClose(nextOpen: boolean) {
if (!nextOpen) {
onOpenChange(false);
return;
}
onOpenChange(true);
}
if (!open) return null;
if (mode === 'view' && existing) {
return (
<DocumentViewDialog
open={open}
onOpenChange={handleClose}
documentType={documentType}
userId={userId}
token={token}
document={existing}
readOnly={readOnly}
onEdit={readOnly ? undefined : () => setMode('edit')}
/>
);
}
return (
<DocumentFormDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && existing) {
setMode('view');
return;
}
handleClose(nextOpen);
}}
documentType={documentType}
userId={userId}
token={token}
existing={existing}
onSaved={onSaved}
readOnly={readOnly}
showBackToView={Boolean(existing) && !readOnly}
onBackToView={existing ? () => setMode('view') : undefined}
/>
);
}

View File

@@ -1,20 +1,23 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Camera, Loader2, X } from 'lucide-react'; import { ArrowLeft, Loader2, Paperclip, X } from 'lucide-react';
import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery'; import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useToast } from '@/components/id/toast-provider'; import { useToast } from '@/components/id/toast-provider';
import { DOCUMENT_FILE_ACCEPT, resolveDocumentFileContentType } from '@/lib/document-attachments';
import { import {
buildDocumentNumber, buildDocumentNumber,
DOCUMENT_TYPES, DOCUMENT_TYPES,
DocumentTypeDef, DocumentTypeDef,
LICENSE_CATEGORIES, LICENSE_CATEGORIES,
parseAttachmentMeta,
parseDocumentPhotos, parseDocumentPhotos,
parseMetadata, parseMetadata,
withDocumentPhotos, withDocumentPhotos,
type DocumentAttachmentMeta,
type DocumentTypeCode type DocumentTypeCode
} from '@/lib/document-catalog'; } from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api'; import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api';
@@ -106,7 +109,9 @@ export function DocumentFormDialog({
token, token,
existing, existing,
onSaved, onSaved,
readOnly readOnly,
showBackToView,
onBackToView
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -116,6 +121,8 @@ export function DocumentFormDialog({
existing?: UserDocument | null; existing?: UserDocument | null;
onSaved: () => void | Promise<void>; onSaved: () => void | Promise<void>;
readOnly?: boolean; readOnly?: boolean;
showBackToView?: boolean;
onBackToView?: () => void;
}) { }) {
const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]); const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]);
const { showToast } = useToast(); const { showToast } = useToast();
@@ -124,10 +131,12 @@ export function DocumentFormDialog({
const fieldValuesRef = useRef<Record<string, string>>({}); const fieldValuesRef = useRef<Record<string, string>>({});
const draftDocumentIdRef = useRef<string | undefined>(undefined); const draftDocumentIdRef = useRef<string | undefined>(undefined);
const photoStorageKeysRef = useRef<string[]>([]); const photoStorageKeysRef = useRef<string[]>([]);
const attachmentMetaRef = useRef<Record<string, DocumentAttachmentMeta>>({});
const wasOpenRef = useRef(false); const wasOpenRef = useRef(false);
const [formInstanceKey, setFormInstanceKey] = useState(0); const [formInstanceKey, setFormInstanceKey] = useState(0);
const [pickerValues, setPickerValues] = useState<Record<string, string>>({}); const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]); const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
const [attachmentMeta, setAttachmentMeta] = useState<Record<string, DocumentAttachmentMeta>>({});
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>(); const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isPhotoUploading, setIsPhotoUploading] = useState(false); const [isPhotoUploading, setIsPhotoUploading] = useState(false);
@@ -138,18 +147,23 @@ export function DocumentFormDialog({
fieldValuesRef.current = {}; fieldValuesRef.current = {};
draftDocumentIdRef.current = undefined; draftDocumentIdRef.current = undefined;
photoStorageKeysRef.current = []; photoStorageKeysRef.current = [];
attachmentMetaRef.current = {};
return; return;
} }
if (!config || wasOpenRef.current) return; if (!config || wasOpenRef.current) return;
wasOpenRef.current = true; wasOpenRef.current = true;
const initial = buildInitialValues(config, existing); const initial = buildInitialValues(config, existing);
const photos = parseDocumentPhotos(parseMetadata(existing?.metadataJson)); const metadata = parseMetadata(existing?.metadataJson);
const photos = parseDocumentPhotos(metadata);
const meta = parseAttachmentMeta(metadata);
fieldValuesRef.current = { ...initial }; fieldValuesRef.current = { ...initial };
photoStorageKeysRef.current = photos; photoStorageKeysRef.current = photos;
attachmentMetaRef.current = meta;
draftDocumentIdRef.current = existing?.id; draftDocumentIdRef.current = existing?.id;
setPickerValues(initial); setPickerValues(initial);
setPhotoStorageKeys(photos); setPhotoStorageKeys(photos);
setAttachmentMeta(meta);
setDraftDocumentId(existing?.id); setDraftDocumentId(existing?.id);
setFormInstanceKey((current) => current + 1); setFormInstanceKey((current) => current + 1);
}, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]); }, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]);
@@ -200,33 +214,50 @@ export function DocumentFormDialog({
}); });
} }
function buildMetadataPayload(source: Record<string, string>, photos: string[]) { function buildMetadataPayload(
source: Record<string, string>,
photos: string[],
meta: Record<string, DocumentAttachmentMeta>
) {
const metadata: Record<string, unknown> = {}; const metadata: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) { for (const [key, value] of Object.entries(source)) {
if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value; if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value;
} }
return JSON.stringify(withDocumentPhotos(metadata, photos)); return JSON.stringify(withDocumentPhotos(metadata, photos, meta));
} }
function buildSaveBody(currentValues: Record<string, string>, photos: string[]) { function buildSaveBody(
currentValues: Record<string, string>,
photos: string[],
meta: Record<string, DocumentAttachmentMeta>
) {
return { return {
number: buildDocumentNumber(config!, currentValues), number: buildDocumentNumber(config!, currentValues),
issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined, issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined,
expiresAt: currentValues.expiresAt ? new Date(currentValues.expiresAt).toISOString() : undefined, expiresAt: currentValues.expiresAt ? new Date(currentValues.expiresAt).toISOString() : undefined,
metadataJson: buildMetadataPayload(currentValues, photos) metadataJson: buildMetadataPayload(currentValues, photos, meta)
}; };
} }
async function patchDocument(documentId: string, currentValues: Record<string, string>, photos: string[]) { async function patchDocument(
documentId: string,
currentValues: Record<string, string>,
photos: string[],
meta: Record<string, DocumentAttachmentMeta>
) {
if (!token || !config) return; if (!token || !config) return;
await apiFetch( await apiFetch(
`/documents/users/${userId}/${documentId}`, `/documents/users/${userId}/${documentId}`,
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos)) }, { method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos, meta)) },
token token
); );
} }
async function ensureDocumentId(currentValues: Record<string, string>, photos: string[]) { async function ensureDocumentId(
currentValues: Record<string, string>,
photos: string[],
meta: Record<string, DocumentAttachmentMeta>
) {
if (draftDocumentIdRef.current) { if (draftDocumentIdRef.current) {
return { id: draftDocumentIdRef.current, created: false }; return { id: draftDocumentIdRef.current, created: false };
} }
@@ -240,7 +271,7 @@ export function DocumentFormDialog({
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
type: documentType, type: documentType,
...buildSaveBody(currentValues, photos) ...buildSaveBody(currentValues, photos, meta)
}) })
}, },
token token
@@ -250,36 +281,50 @@ export function DocumentFormDialog({
return { id: created.id, created: true }; return { id: created.id, created: true };
} }
async function uploadPhoto(file: File) { async function uploadAttachment(file: File) {
if (!token || readOnly) return; if (!token || readOnly) return;
setIsPhotoUploading(true); setIsPhotoUploading(true);
try { try {
await commitFocusedField(); await commitFocusedField();
const currentValues = collectFormValues(); const currentValues = collectFormValues();
const currentPhotos = photoStorageKeysRef.current; const currentPhotos = photoStorageKeysRef.current;
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos); const currentMeta = attachmentMetaRef.current;
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки фото'); const contentType = resolveDocumentFileContentType(file);
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos, currentMeta);
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки файла');
const presigned = await apiFetch<PresignedUploadResponse>( const presigned = await apiFetch<PresignedUploadResponse>(
`/media/documents/${targetDocumentId}/photo/upload-url`, `/media/documents/${targetDocumentId}/photo/upload-url`,
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) }, {
method: 'POST',
body: JSON.stringify({ contentType, fileName: file.name })
},
token token
); );
await uploadMediaObject(presigned, file, file.type); await uploadMediaObject(presigned, file, contentType);
const nextPhotos = [...currentPhotos, presigned.storageKey]; const nextPhotos = [...currentPhotos, presigned.storageKey];
const nextMeta = {
...currentMeta,
[presigned.storageKey]: {
fileName: file.name,
contentType
}
};
photoStorageKeysRef.current = nextPhotos; photoStorageKeysRef.current = nextPhotos;
attachmentMetaRef.current = nextMeta;
setPhotoStorageKeys(nextPhotos); setPhotoStorageKeys(nextPhotos);
await patchDocument(targetDocumentId, currentValues, nextPhotos); setAttachmentMeta(nextMeta);
await patchDocument(targetDocumentId, currentValues, nextPhotos, nextMeta);
if (created) { if (created) {
await onSaved(); await onSaved();
} }
showToast(ото добавлено'); showToast(айл добавлен');
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить фото'); const message = getApiErrorMessage(error, 'Не удалось загрузить файл');
if (message) showToast(message); if (message) showToast(message);
} finally { } finally {
setIsPhotoUploading(false); setIsPhotoUploading(false);
@@ -290,14 +335,18 @@ export function DocumentFormDialog({
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) { async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? []); const files = Array.from(event.target.files ?? []);
for (const file of files) { for (const file of files) {
await uploadPhoto(file); await uploadAttachment(file);
} }
} }
function removePhoto(storageKey: string) { function removePhoto(storageKey: string) {
const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey); const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey);
const nextMeta = { ...attachmentMetaRef.current };
delete nextMeta[storageKey];
photoStorageKeysRef.current = nextPhotos; photoStorageKeysRef.current = nextPhotos;
attachmentMetaRef.current = nextMeta;
setPhotoStorageKeys(nextPhotos); setPhotoStorageKeys(nextPhotos);
setAttachmentMeta(nextMeta);
} }
async function handleSave() { async function handleSave() {
@@ -307,15 +356,16 @@ export function DocumentFormDialog({
await commitFocusedField(); await commitFocusedField();
const currentValues = collectFormValues(); const currentValues = collectFormValues();
const photos = photoStorageKeysRef.current; const photos = photoStorageKeysRef.current;
const meta = attachmentMetaRef.current;
const documentId = draftDocumentIdRef.current ?? existing?.id; const documentId = draftDocumentIdRef.current ?? existing?.id;
if (documentId) { if (documentId) {
await patchDocument(documentId, currentValues, photos); await patchDocument(documentId, currentValues, photos, meta);
showToast('Документ обновлён'); showToast('Документ обновлён');
} else { } else {
const created = await apiFetch<UserDocument>( const created = await apiFetch<UserDocument>(
`/documents/users/${userId}`, `/documents/users/${userId}`,
{ method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos) }) }, { method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos, meta) }) },
token token
); );
draftDocumentIdRef.current = created.id; draftDocumentIdRef.current = created.id;
@@ -342,7 +392,14 @@ export function DocumentFormDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[420px]"> <DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[420px]">
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4"> <DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle> <div className="flex min-w-0 items-center gap-2">
{showBackToView && onBackToView ? (
<button type="button" onClick={onBackToView} className="rounded-full p-2 hover:bg-[#f4f5f8]" aria-label="Назад к просмотру">
<ArrowLeft className="h-4 w-4" />
</button>
) : null}
<DialogTitle className="truncate text-xl font-semibold">{config.label}</DialogTitle>
</div>
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]"> <button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@@ -355,6 +412,7 @@ export function DocumentFormDialog({
userId={userId} userId={userId}
token={token} token={token}
storageKeys={photoStorageKeys} storageKeys={photoStorageKeys}
attachmentMeta={attachmentMeta}
readOnly={readOnly} readOnly={readOnly}
onRemove={readOnly ? undefined : removePhoto} onRemove={readOnly ? undefined : removePhoto}
/> />
@@ -368,10 +426,17 @@ export function DocumentFormDialog({
disabled={isPhotoUploading} disabled={isPhotoUploading}
className="flex w-full items-center justify-center gap-2 rounded-[20px] bg-[#f4f5f8] px-4 py-4 text-sm font-medium transition hover:bg-[#eceef4]" className="flex w-full items-center justify-center gap-2 rounded-[20px] bg-[#f4f5f8] px-4 py-4 text-sm font-medium transition hover:bg-[#eceef4]"
> >
{isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />} {isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
{photoStorageKeys.length > 0 ? 'Добавить ещё фото' : 'Добавить фото'} {photoStorageKeys.length > 0 ? 'Добавить ещё файл' : 'Добавить фото или документ'}
</button> </button>
<input ref={photoInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoChange} /> <input
ref={photoInputRef}
type="file"
accept={DOCUMENT_FILE_ACCEPT}
multiple
className="hidden"
onChange={handlePhotoChange}
/>
</> </>
) : null} ) : null}

View File

@@ -1,17 +1,33 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { Loader2, Trash2 } from 'lucide-react'; import { Download, FileText, Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider'; import { useAuth } from '@/components/id/auth-provider';
import {
attachmentDisplayName,
attachmentFileExtension,
inferAttachmentContentType,
isImageAttachment,
isPdfAttachment,
type DocumentAttachmentMeta
} from '@/lib/document-attachments';
import { fetchDocumentPhotoUrl } from '@/lib/api'; import { fetchDocumentPhotoUrl } from '@/lib/api';
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch'; import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer'; import { DocumentAttachmentViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
export type ResolvedDocumentAttachment = {
storageKey: string;
url: string;
contentType: string;
fileName: string;
};
interface DocumentPhotoGalleryProps { interface DocumentPhotoGalleryProps {
userId: string; userId: string;
token: string | null; token: string | null;
storageKeys: string[]; storageKeys: string[];
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
readOnly?: boolean; readOnly?: boolean;
onRemove?: (storageKey: string) => void; onRemove?: (storageKey: string) => void;
layout?: 'grid' | 'strip'; layout?: 'grid' | 'strip';
@@ -22,20 +38,21 @@ export function DocumentPhotoGallery({
userId, userId,
token, token,
storageKeys, storageKeys,
attachmentMeta = {},
readOnly, readOnly,
onRemove, onRemove,
layout = 'grid', layout = 'grid',
className className
}: DocumentPhotoGalleryProps) { }: DocumentPhotoGalleryProps) {
const { isPinLocked } = useAuth(); const { isPinLocked } = useAuth();
const [urls, setUrls] = React.useState<Record<string, string>>({}); const [attachments, setAttachments] = React.useState<ResolvedDocumentAttachment[]>([]);
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set()); const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
const [viewerOpen, setViewerOpen] = React.useState(false); const [viewerOpen, setViewerOpen] = React.useState(false);
const [viewerIndex, setViewerIndex] = React.useState(0); const [viewerIndex, setViewerIndex] = React.useState(0);
React.useEffect(() => { React.useEffect(() => {
if (!token || storageKeys.length === 0 || isPinLocked) { if (!token || storageKeys.length === 0 || isPinLocked) {
setUrls({}); setAttachments([]);
return; return;
} }
@@ -43,34 +60,43 @@ export function DocumentPhotoGallery({
setLoadingKeys(new Set(storageKeys)); setLoadingKeys(new Set(storageKeys));
void Promise.all( void Promise.all(
storageKeys.map(async (storageKey) => { storageKeys.map(async (storageKey) => {
const meta = attachmentMeta[storageKey];
const contentType = inferAttachmentContentType(storageKey, meta);
const fileName = attachmentDisplayName(storageKey, meta);
try { try {
const response = await fetchDocumentPhotoUrl(userId, storageKey, token); const response = await fetchDocumentPhotoUrl(userId, storageKey, token, fileName);
return { storageKey, accessUrl: resolveBrowserMediaUrl(response.accessUrl) }; return {
storageKey,
accessUrl: resolveBrowserMediaUrl(response.accessUrl),
contentType,
fileName
};
} catch { } catch {
return { storageKey, accessUrl: '' }; return { storageKey, accessUrl: '', contentType, fileName };
} }
}) })
).then((results) => { ).then((results) => {
if (cancelled) return; if (cancelled) return;
const next: Record<string, string> = {}; setAttachments(
for (const item of results) { results
if (item.accessUrl) next[item.storageKey] = item.accessUrl; .filter((item) => Boolean(item.accessUrl))
} .map((item) => ({
setUrls(next); storageKey: item.storageKey,
url: item.accessUrl,
contentType: item.contentType,
fileName: item.fileName
}))
);
setLoadingKeys(new Set()); setLoadingKeys(new Set());
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isPinLocked, storageKeys.join('|'), token, userId]); }, [attachmentMeta, isPinLocked, storageKeys.join('|'), token, userId]);
const resolvedImages = storageKeys
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
.filter((item) => Boolean(item.url));
function openViewer(storageKey: string) { function openViewer(storageKey: string) {
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey); const resolvedIndex = attachments.findIndex((item) => item.storageKey === storageKey);
if (resolvedIndex >= 0) { if (resolvedIndex >= 0) {
setViewerIndex(resolvedIndex); setViewerIndex(resolvedIndex);
setViewerOpen(true); setViewerOpen(true);
@@ -84,67 +110,126 @@ export function DocumentPhotoGallery({
return ( return (
<> <>
<div <div
className={cn( className={cn(isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3', className)}
isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3',
className
)}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
{storageKeys.map((storageKey) => ( {storageKeys.map((storageKey) => {
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}> const meta = attachmentMeta[storageKey];
<DocumentPhotoThumbnail const contentType = inferAttachmentContentType(storageKey, meta);
url={urls[storageKey]} const fileName = attachmentDisplayName(storageKey, meta);
loading={loadingKeys.has(storageKey)} const resolved = attachments.find((item) => item.storageKey === storageKey);
className={isStrip ? 'aspect-square rounded-xl' : undefined} const loading = loadingKeys.has(storageKey);
onClick={() => openViewer(storageKey)}
/> if (isImageAttachment(contentType)) {
{!readOnly && onRemove ? ( return (
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
<DocumentPhotoThumbnail
url={resolved?.url}
loading={loading}
className={isStrip ? 'aspect-square rounded-xl' : undefined}
onClick={() => openViewer(storageKey)}
/>
{!readOnly && onRemove ? (
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} />
) : null}
</div>
);
}
return (
<div key={storageKey} className={cn('relative', isStrip && 'w-36 shrink-0')}>
<button <button
type="button" type="button"
onClick={(event) => { onClick={() => (resolved ? openViewer(storageKey) : undefined)}
event.stopPropagation(); className={cn(
onRemove(storageKey); 'flex h-full min-h-[96px] w-full flex-col items-start justify-between rounded-2xl border border-[#eceef4] bg-white p-3 text-left transition hover:border-[#20212b]',
}} isStrip && 'min-h-[80px]'
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80" )}
aria-label="Удалить фото"
> >
<Trash2 className="h-3.5 w-3.5" /> <div className="flex w-full items-start justify-between gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#f4f5f8] text-[#667085]">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-5 w-5" />}
</div>
<span className="rounded-lg bg-[#20212b] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white">
{attachmentFileExtension(contentType, fileName)}
</span>
</div>
<div className="mt-3 min-w-0">
<p className="truncate text-sm font-medium text-[#1f2430]">{fileName}</p>
<p className="mt-0.5 text-xs text-[#667085]">{isPdfAttachment(contentType) ? 'Открыть PDF' : 'Скачать файл'}</p>
</div>
</button> </button>
) : null} {resolved ? (
{loadingKeys.has(storageKey) ? ( <a
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-white/40"> href={`${resolved.url}${resolved.url.includes('?') ? '&' : '?'}download=1`}
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" /> target="_blank"
</div> rel="noreferrer"
) : null} className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
</div> aria-label="Скачать файл"
))} onClick={(event) => event.stopPropagation()}
>
<Download className="h-3.5 w-3.5" />
</a>
) : null}
{!readOnly && onRemove ? (
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} className="right-2 top-2" />
) : null}
</div>
);
})}
</div> </div>
<DocumentFullscreenViewer <DocumentAttachmentViewer
open={viewerOpen} open={viewerOpen}
onOpenChange={setViewerOpen} onOpenChange={setViewerOpen}
initialIndex={viewerIndex} initialIndex={viewerIndex}
images={resolvedImages} attachments={attachments}
/> />
</> </>
); );
} }
function RemoveAttachmentButton({
storageKey,
onRemove,
className
}: {
storageKey: string;
onRemove: (storageKey: string) => void;
className?: string;
}) {
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onRemove(storageKey);
}}
className={cn('absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80', className)}
aria-label="Удалить файл"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
);
}
interface DocumentPhotosInlineProps { interface DocumentPhotosInlineProps {
userId: string; userId: string;
token: string | null; token: string | null;
storageKeys: string[]; storageKeys: string[];
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
className?: string; className?: string;
} }
/** Горизонтальная полоска фото документа для списков (страница «Документы», «Данные»). */ /** Горизонтальная полоска файлов документа для списков. */
export function DocumentPhotosInline({ userId, token, storageKeys, className }: DocumentPhotosInlineProps) { export function DocumentPhotosInline({ userId, token, storageKeys, attachmentMeta, className }: DocumentPhotosInlineProps) {
if (!storageKeys.length) return null; if (!storageKeys.length) return null;
return ( return (
<DocumentPhotoGallery <DocumentPhotoGallery
userId={userId} userId={userId}
token={token} token={token}
storageKeys={storageKeys} storageKeys={storageKeys}
attachmentMeta={attachmentMeta}
readOnly readOnly
layout="strip" layout="strip"
className={className} className={className}

View File

@@ -2,19 +2,26 @@
import * as React from 'react'; import * as React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, X, ZoomIn } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton'; import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import { isImageAttachment, isPdfAttachment } from '@/lib/document-attachments';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ResolvedDocumentAttachment } from './document-photo-gallery';
interface DocumentFullscreenViewerProps { interface DocumentAttachmentViewerProps {
images: { storageKey: string; url: string }[]; attachments: ResolvedDocumentAttachment[];
initialIndex?: number; initialIndex?: number;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) { export function DocumentAttachmentViewer({
attachments,
initialIndex = 0,
open,
onOpenChange
}: DocumentAttachmentViewerProps) {
const [index, setIndex] = React.useState(initialIndex); const [index, setIndex] = React.useState(initialIndex);
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
@@ -31,11 +38,11 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onOpenChange(false); if (event.key === 'Escape') onOpenChange(false);
if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1)); if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1));
if (event.key === 'ArrowRight') setIndex((current) => Math.min(images.length - 1, current + 1)); if (event.key === 'ArrowRight') setIndex((current) => Math.min(attachments.length - 1, current + 1));
} }
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [images.length, onOpenChange, open]); }, [attachments.length, onOpenChange, open]);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
@@ -46,33 +53,48 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
}; };
}, [open]); }, [open]);
if (!open || images.length === 0 || !mounted) return null; if (!open || attachments.length === 0 || !mounted) return null;
const current = images[index]; const current = attachments[index];
const downloadUrl = `${current.url}${current.url.includes('?') ? '&' : '?'}download=1`;
return createPortal( return createPortal(
<div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96"> <div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96">
<div className="flex items-center justify-between px-4 py-3 text-white"> <div className="flex items-center justify-between px-4 py-3 text-white">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium">Фото документа</p> <p className="truncate text-sm font-medium">{current.fileName}</p>
<p className="text-xs text-white/60"> <p className="text-xs text-white/60">
{index + 1} из {images.length} {index + 1} из {attachments.length}
</p> </p>
</div> </div>
<Button <div className="flex items-center gap-1">
type="button" <Button
size="icon" type="button"
variant="ghost" size="icon"
className="text-white hover:bg-white/10" variant="ghost"
aria-label="Закрыть" className="text-white hover:bg-white/10"
onClick={() => onOpenChange(false)} aria-label="Скачать"
> asChild
<X className="h-5 w-5" /> >
</Button> <a href={downloadUrl} target="_blank" rel="noreferrer">
<Download className="h-5 w-5" />
</a>
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="text-white hover:bg-white/10"
aria-label="Закрыть"
onClick={() => onOpenChange(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
</div> </div>
<div className="relative flex flex-1 items-center justify-center px-3 pb-4"> <div className="relative flex flex-1 items-center justify-center px-3 pb-4">
{images.length > 1 ? ( {attachments.length > 1 ? (
<Button <Button
type="button" type="button"
size="icon" size="icon"
@@ -81,51 +103,73 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
'absolute left-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10', 'absolute left-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
index <= 0 && 'pointer-events-none opacity-30' index <= 0 && 'pointer-events-none opacity-30'
)} )}
aria-label="Предыдущее фото" aria-label="Предыдущий файл"
onClick={() => setIndex((value) => Math.max(0, value - 1))} onClick={() => setIndex((value) => Math.max(0, value - 1))}
> >
<ChevronLeft className="h-6 w-6" /> <ChevronLeft className="h-6 w-6" />
</Button> </Button>
) : null} ) : null}
{/* eslint-disable-next-line @next/next/no-img-element */} {isImageAttachment(current.contentType) ? (
<img // eslint-disable-next-line @next/next/no-img-element
src={current.url} <img
alt="Фото документа" src={current.url}
className="max-h-[calc(100vh-140px)] max-w-full object-contain" alt={current.fileName}
/> className="max-h-[calc(100vh-140px)] max-w-full object-contain"
/>
) : isPdfAttachment(current.contentType) ? (
<iframe
title={current.fileName}
src={current.url}
className="h-[calc(100vh-140px)] w-full max-w-5xl rounded-2xl bg-white"
/>
) : (
<div className="max-w-md rounded-[24px] bg-white/10 p-8 text-center text-white backdrop-blur-sm">
<p className="text-lg font-medium">{current.fileName}</p>
<p className="mt-2 text-sm text-white/70">Предпросмотр недоступен для этого типа файла</p>
<Button className="mt-6 rounded-xl" asChild>
<a href={downloadUrl} target="_blank" rel="noreferrer">
Скачать файл
</a>
</Button>
</div>
)}
{images.length > 1 ? ( {attachments.length > 1 ? (
<Button <Button
type="button" type="button"
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10', 'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
index >= images.length - 1 && 'pointer-events-none opacity-30' index >= attachments.length - 1 && 'pointer-events-none opacity-30'
)} )}
aria-label="Следующее фото" aria-label="Следующий файл"
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))} onClick={() => setIndex((value) => Math.min(attachments.length - 1, value + 1))}
> >
<ChevronRight className="h-6 w-6" /> <ChevronRight className="h-6 w-6" />
</Button> </Button>
) : null} ) : null}
</div> </div>
{images.length > 1 ? ( {attachments.length > 1 ? (
<div className="flex gap-2 overflow-x-auto px-4 pb-4"> <div className="flex gap-2 overflow-x-auto px-4 pb-4">
{images.map((image, imageIndex) => ( {attachments.map((attachment, attachmentIndex) => (
<button <button
key={image.storageKey} key={attachment.storageKey}
type="button" type="button"
className={cn( className={cn(
'h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2', 'h-14 min-w-[56px] shrink-0 overflow-hidden rounded-lg border-2 px-2 text-xs text-white',
imageIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70' attachmentIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
)} )}
onClick={() => setIndex(imageIndex)} onClick={() => setIndex(attachmentIndex)}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {isImageAttachment(attachment.contentType) ? (
<img src={image.url} alt="" className="h-full w-full object-cover" /> // eslint-disable-next-line @next/next/no-img-element
<img src={attachment.url} alt="" className="h-full w-full object-cover" />
) : (
<span className="flex h-full items-center justify-center">{attachment.fileName.slice(0, 8)}</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -135,6 +179,27 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
); );
} }
/** @deprecated Используйте DocumentAttachmentViewer */
export function DocumentFullscreenViewer({
images,
initialIndex = 0,
open,
onOpenChange
}: {
images: { storageKey: string; url: string }[];
initialIndex?: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const attachments: ResolvedDocumentAttachment[] = images.map((image) => ({
storageKey: image.storageKey,
url: image.url,
contentType: 'image/jpeg',
fileName: 'Фото документа'
}));
return <DocumentAttachmentViewer attachments={attachments} initialIndex={initialIndex} open={open} onOpenChange={onOpenChange} />;
}
interface DocumentPhotoThumbnailProps { interface DocumentPhotoThumbnailProps {
url?: string; url?: string;
loading?: boolean; loading?: boolean;
@@ -156,7 +221,7 @@ export function DocumentPhotoThumbnail({ url, loading, className, onClick }: Doc
{!loading && url ? ( {!loading && url ? (
<> <>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt=ото документа" className="h-full w-full object-cover" /> <img src={url} alt=айл документа" className="h-full w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition group-hover:bg-black/20"> <div className="absolute inset-0 flex items-center justify-center bg-black/0 transition group-hover:bg-black/20">
<ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" /> <ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" />
</div> </div>

View File

@@ -0,0 +1,270 @@
'use client';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import {
buildDocumentNumber,
DOCUMENT_TYPES,
getDocumentType,
LICENSE_CATEGORIES,
parseMetadata,
type DocumentTypeCode,
type DocumentTypeDef
} from '@/lib/document-catalog';
import { UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
function formatDateValue(value?: string) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return format(date, 'd MMMM yyyy', { locale: ru });
}
function formatFieldValue(fieldKind: string, value: string) {
if (!value) return '—';
if (fieldKind === 'date') return formatDateValue(value);
if (fieldKind === 'gender') return value === 'male' ? 'Мужской' : value === 'female' ? 'Женский' : value;
if (fieldKind === 'checkbox') return value === 'true' ? 'Да' : '—';
if (fieldKind === 'categories') {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.join(' · ');
}
return value;
}
function fullName(values: Record<string, string>) {
const parts = [values.lastName, values.firstName, values.middleName].filter(Boolean);
return parts.length ? parts.join(' ') : '—';
}
function latinFullName(values: Record<string, string>) {
const parts = [values.lastNameLatin, values.firstNameLatin, values.middleNameLatin].filter(Boolean);
return parts.length ? parts.join(' ') : '';
}
function DocumentField({ label, value, className }: { label: string; value: string; className?: string }) {
if (!value || value === '—') return null;
return (
<div className={className}>
<p className="text-[10px] uppercase tracking-[0.12em] opacity-70">{label}</p>
<p className="mt-0.5 text-sm font-medium leading-snug">{value}</p>
</div>
);
}
function PassportRfCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
return (
<div className="overflow-hidden rounded-[24px] border border-[#6b1d24]/20 bg-[linear-gradient(145deg,#7a2430_0%,#5c1820_55%,#3d1016_100%)] p-5 text-[#fff6eb] shadow-[0_24px_60px_rgba(92,24,32,0.35)]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#f6d9a8]">Российская Федерация</p>
<p className="mt-1 text-lg font-semibold">{config.label}</p>
</div>
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-[#f6d9a8]/40 bg-[#f6d9a8]/10 text-xs font-semibold text-[#f6d9a8]">
РФ
</div>
</div>
<div className="mt-5 rounded-[18px] bg-black/15 p-4 backdrop-blur-sm">
<p className="text-[10px] uppercase tracking-[0.14em] text-[#f6d9a8]/80">Серия и номер</p>
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || buildDocumentNumber(config, values)}</p>
</div>
<div className="mt-4 space-y-3">
<DocumentField label="ФИО" value={fullName(values)} />
<div className="grid grid-cols-2 gap-3">
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
<DocumentField label="Пол" value={formatFieldValue('gender', values.gender ?? '')} />
</div>
<DocumentField label="Место рождения" value={values.birthPlace ?? ''} />
<DocumentField label="Кем выдан" value={values.issuedBy ?? ''} />
<div className="grid grid-cols-2 gap-3">
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
<DocumentField label="Код подразделения" value={values.departmentCode ?? ''} />
</div>
<DocumentField label="Адрес регистрации" value={values.registrationAddress ?? ''} />
</div>
</div>
);
}
function ForeignPassportCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
const latin = latinFullName(values);
return (
<div className="overflow-hidden rounded-[24px] border border-[#5a1830]/20 bg-[linear-gradient(145deg,#6d2138_0%,#451222_100%)] p-5 text-[#fff4f0] shadow-[0_24px_60px_rgba(69,18,34,0.35)]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#ffd3bf]">Passport</p>
<p className="mt-1 text-lg font-semibold">{config.label}</p>
</div>
<div className="rounded-xl bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-widest text-[#ffd3bf]">P</div>
</div>
<div className="mt-5 rounded-[18px] bg-black/15 p-4">
<p className="text-[10px] uppercase tracking-[0.14em] opacity-70">No.</p>
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || values.number || '—'}</p>
</div>
<div className="mt-4 space-y-3">
<DocumentField label="ФИО" value={fullName(values)} />
{latin ? <DocumentField label="Name" value={latin} className="font-mono" /> : null}
<div className="grid grid-cols-2 gap-3">
<DocumentField label="Гражданство" value={values.citizenship ?? ''} />
<DocumentField label="Пол" value={formatFieldValue('gender', values.gender ?? '')} />
</div>
<div className="grid grid-cols-2 gap-3">
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
</div>
<DocumentField label="Действует до" value={formatFieldValue('date', values.expiresAt ?? document.expiresAt?.slice(0, 10) ?? '')} />
<DocumentField label="Место рождения" value={values.birthPlace ?? ''} />
</div>
</div>
);
}
function DriverLicenseCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
const categories = formatFieldValue('categories', values.categories ?? '');
return (
<div className="overflow-hidden rounded-[24px] border border-[#7c5cff]/20 bg-[linear-gradient(145deg,#f3ecff_0%,#ddd0ff_45%,#c8b6ff_100%)] p-5 text-[#2f2450] shadow-[0_24px_60px_rgba(124,92,255,0.18)]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6b4fd8]">Водительское удостоверение</p>
<p className="mt-1 text-lg font-semibold">{config.label}</p>
</div>
<div className="rounded-2xl bg-[#6b4fd8] px-3 py-2 text-xs font-bold uppercase tracking-widest text-white">RU</div>
</div>
<div className="mt-5 rounded-[18px] bg-white/70 p-4">
<p className="text-[10px] uppercase tracking-[0.14em] text-[#6b4fd8]">Номер</p>
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || values.number || '—'}</p>
</div>
<div className="mt-4 space-y-3">
<DocumentField label="ФИО" value={fullName(values)} />
<div className="grid grid-cols-2 gap-3">
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
</div>
<DocumentField label="Действует до" value={formatFieldValue('date', values.expiresAt ?? document.expiresAt?.slice(0, 10) ?? '')} />
<DocumentField label="Категории" value={categories} />
{values.specialMarks ? <DocumentField label="Особые отметки" value={values.specialMarks} /> : null}
</div>
</div>
);
}
function VehicleRegistrationCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
return (
<div className="overflow-hidden rounded-[24px] border border-[#3b82f6]/15 bg-[linear-gradient(145deg,#eff6ff_0%,#dbeafe_100%)] p-5 text-[#1e3a5f] shadow-[0_24px_60px_rgba(59,130,246,0.12)]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#2563eb]">Свидетельство о регистрации</p>
<p className="mt-1 text-lg font-semibold">{config.label}</p>
</div>
<div className="rounded-xl bg-[#2563eb] px-3 py-2 text-xs font-bold uppercase tracking-widest text-white">СТС</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<div className="rounded-[18px] bg-white/80 p-4">
<p className="text-[10px] uppercase tracking-[0.14em] text-[#2563eb]">Серия и номер</p>
<p className="mt-1 font-mono text-xl font-semibold">{document.number || values.seriesNumber || '—'}</p>
</div>
<div className="rounded-[18px] bg-white/80 p-4">
<p className="text-[10px] uppercase tracking-[0.14em] text-[#2563eb]">Госномер</p>
<p className="mt-1 font-mono text-xl font-semibold uppercase">{values.plateNumber || '—'}</p>
</div>
</div>
<div className="mt-4 space-y-3">
<DocumentField label="Марка, модель" value={values.makeModel ?? ''} />
<DocumentField label="VIN" value={values.vin ?? ''} className="font-mono" />
<div className="grid grid-cols-2 gap-3">
<DocumentField label="Год выпуска" value={values.year ?? ''} />
<DocumentField label="Цвет" value={values.color ?? ''} />
</div>
<DocumentField label="Владелец" value={fullName(values)} />
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
</div>
</div>
);
}
function GenericDocumentCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
const Icon = config.icon;
const visibleFields = config.fields
.map((field) => ({
label: field.label,
value: formatFieldValue(field.kind, values[field.key] ?? (field.key === 'issuedAt' ? document.issuedAt?.slice(0, 10) ?? '' : field.key === 'expiresAt' ? document.expiresAt?.slice(0, 10) ?? '' : ''))
}))
.filter((field) => field.value && field.value !== '—');
return (
<div className={cn('overflow-hidden rounded-[24px] border border-[#eceef4] bg-white p-5 shadow-[0_20px_50px_rgba(31,36,48,0.08)]')}>
<div className="flex items-start gap-4">
<div className={cn('flex h-14 w-14 items-center justify-center rounded-2xl', config.accent)}>
<Icon className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#667085]">Документ</p>
<p className="mt-1 text-xl font-semibold text-[#1f2430]">{config.label}</p>
<p className="mt-2 font-mono text-lg text-[#3390ec]">{document.number || buildDocumentNumber(config, values)}</p>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{visibleFields.map((field) => (
<DocumentField key={field.label} label={field.label} value={field.value} />
))}
</div>
</div>
);
}
export function DocumentViewCard({
documentType,
document
}: {
documentType: DocumentTypeCode;
document: UserDocument;
}) {
const config = getDocumentType(documentType) ?? DOCUMENT_TYPES.find((item) => item.type === documentType);
if (!config) return null;
const metadata = parseMetadata(document.metadataJson);
const values: Record<string, string> = {};
for (const field of config.fields) {
const raw = metadata[field.key];
values[field.key] = typeof raw === 'string' ? raw : '';
if (field.latinKey) {
const latinRaw = metadata[field.latinKey];
values[field.latinKey] = typeof latinRaw === 'string' ? latinRaw : '';
}
}
if (document.issuedAt) values.issuedAt = document.issuedAt.slice(0, 10);
if (document.expiresAt) values.expiresAt = document.expiresAt.slice(0, 10);
switch (documentType) {
case 'PASSPORT_RF':
return <PassportRfCard config={config} values={values} document={document} />;
case 'FOREIGN_PASSPORT':
return <ForeignPassportCard config={config} values={values} document={document} />;
case 'DRIVER_LICENSE':
return <DriverLicenseCard config={config} values={values} document={document} />;
case 'VEHICLE_REGISTRATION':
return <VehicleRegistrationCard config={config} values={values} document={document} />;
default:
return <GenericDocumentCard config={config} values={values} document={document} />;
}
}
export function DriverLicenseCategoryBadges({ value }: { value: string }) {
const selected = new Set(value.split(',').map((item) => item.trim()).filter(Boolean));
if (!selected.size) return null;
return (
<div className="flex flex-wrap gap-1.5">
{LICENSE_CATEGORIES.filter((category) => selected.has(category)).map((category) => (
<span key={category} className="rounded-lg bg-[#20212b] px-2 py-1 text-xs font-medium text-white">
{category}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { Pencil, X } from 'lucide-react';
import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
import { DocumentViewCard } from '@/components/documents/document-view-card';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { getDocumentType, parseAttachmentMeta, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
import { UserDocument } from '@/lib/api';
export function DocumentViewDialog({
open,
onOpenChange,
documentType,
userId,
token,
document,
readOnly,
onEdit
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentTypeCode;
userId: string;
token: string | null;
document: UserDocument;
readOnly?: boolean;
onEdit?: () => void;
}) {
const config = getDocumentType(documentType);
const metadata = parseMetadata(document.metadataJson);
const storageKeys = parseDocumentPhotos(metadata);
const attachmentMeta = parseAttachmentMeta(metadata);
if (!config) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[480px]">
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle>
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]" aria-label="Закрыть">
<X className="h-4 w-4" />
</button>
</div>
</DialogHeader>
<div className="space-y-5 px-5 pb-5 pt-4">
<DocumentViewCard documentType={documentType} document={document} />
{storageKeys.length > 0 ? (
<section>
<p className="mb-3 text-sm font-medium text-[#1f2430]">Прикреплённые файлы</p>
<DocumentPhotoGallery
userId={userId}
token={token}
storageKeys={storageKeys}
attachmentMeta={attachmentMeta}
readOnly
/>
</section>
) : null}
{!readOnly && onEdit ? (
<Button type="button" className="w-full rounded-[18px] py-6" onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
Редактировать
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,10 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { FileText } from 'lucide-react'; import { FileText } from 'lucide-react';
import { DocumentFormDialog } from '@/components/documents/document-form-dialog'; import { DocumentDialog } from '@/components/documents/document-dialog';
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery'; import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { getDocumentType, indexDocumentsByType, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog'; import { getDocumentType, indexDocumentsByType, parseAttachmentMeta, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
import { apiFetch, UserDocument } from '@/lib/api'; import { apiFetch, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -70,6 +70,7 @@ export function UserDocumentsDialog({
if (!config) return null; if (!config) return null;
const Icon = config.icon; const Icon = config.icon;
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson)); const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
return ( return (
<div key={document.id}> <div key={document.id}>
<button <button
@@ -88,7 +89,7 @@ export function UserDocumentsDialog({
</button> </button>
{photoKeys.length > 0 && token ? ( {photoKeys.length > 0 && token ? (
<div className="mt-2 px-1"> <div className="mt-2 px-1">
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} /> <DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -99,7 +100,7 @@ export function UserDocumentsDialog({
</Dialog> </Dialog>
{activeType ? ( {activeType ? (
<DocumentFormDialog <DocumentDialog
open={Boolean(activeType)} open={Boolean(activeType)}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { readStoredFamilyGroupId, writeStoredFamilyGroupId } from '@/lib/selected-family-storage';
const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
type FamilyOverlayContextValue = { type FamilyOverlayContextValue = {
openMiniChat: () => void; openMiniChat: () => void;
@@ -14,6 +13,7 @@ type FamilyOverlayContextValue = {
pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null; pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null;
clearPending: () => void; clearPending: () => void;
selectedGroupId: string | null; selectedGroupId: string | null;
isSelectionHydrated: boolean;
setSelectedGroupId: (groupId: string | null) => void; setSelectedGroupId: (groupId: string | null) => void;
}; };
@@ -24,23 +24,20 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null); const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null); const [pendingMember, setPendingMember] = useState<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null);
const [isSelectionHydrated, setIsSelectionHydrated] = useState(false);
const pendingMemberRef = useRef<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null); const pendingMemberRef = useRef<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
const selectionHydratedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (selectionHydratedRef.current) return; const stored = readStoredFamilyGroupId();
selectionHydratedRef.current = true; if (stored) {
const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY); setSelectedGroupIdState(stored);
if (stored) setSelectedGroupIdState(stored); }
setIsSelectionHydrated(true);
}, []); }, []);
const setSelectedGroupId = useCallback((groupId: string | null) => { const setSelectedGroupId = useCallback((groupId: string | null) => {
setSelectedGroupIdState(groupId); setSelectedGroupIdState(groupId);
if (groupId) { writeStoredFamilyGroupId(groupId);
window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId);
} else {
window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY);
}
}, []); }, []);
const openMiniChat = useCallback(() => { const openMiniChat = useCallback(() => {
@@ -86,11 +83,13 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
pendingMember, pendingMember,
clearPending, clearPending,
selectedGroupId, selectedGroupId,
isSelectionHydrated,
setSelectedGroupId setSelectedGroupId
}), }),
[ [
clearPending, clearPending,
closeMiniChat, closeMiniChat,
isSelectionHydrated,
miniChatOpen, miniChatOpen,
openChatRoom, openChatRoom,
openChatWithMember, openChatWithMember,

View File

@@ -456,6 +456,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
void refreshProfile(); void refreshProfile();
}, [refreshProfile]); }, [refreshProfile]);
React.useEffect(() => {
if (!isSessionReady || isPinLocked) return;
if (pathname.startsWith('/auth/')) return;
const accessToken = token ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
if (!accessToken) return;
void fetchAuthSession(accessToken)
.then((session) => {
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
})
.catch((error) => {
if (isPinRequiredError(error)) {
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
}
});
}, [activatePinLock, clearPinLock, isPinLocked, isSessionReady, pathname, token]);
React.useEffect(() => { React.useEffect(() => {
function handleVisibilityChange() { function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return; if (document.visibilityState !== 'visible') return;

View File

@@ -11,10 +11,11 @@ import {
fetchFamilyPresence, fetchFamilyPresence,
getAccessToken getAccessToken
} from '@/lib/api'; } from '@/lib/api';
import { resolveFamilyGroupId } from '@/lib/selected-family-storage';
export function useSelectedFamily(enabled = true) { export function useSelectedFamily(enabled = true) {
const { user, token, isPinLocked, isLoading, isContentReady } = useAuth(); const { user, token, isPinLocked, isLoading, isContentReady } = useAuth();
const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay(); const { selectedGroupId, setSelectedGroupId, isSelectionHydrated } = useFamilyOverlay();
const userId = user?.id; const userId = user?.id;
const [groups, setGroups] = useState<FamilyGroup[]>([]); const [groups, setGroups] = useState<FamilyGroup[]>([]);
const [group, setGroup] = useState<FamilyGroup | null>(null); const [group, setGroup] = useState<FamilyGroup | null>(null);
@@ -33,6 +34,8 @@ export function useSelectedFamily(enabled = true) {
return; return;
} }
let pendingHydration = false;
try { try {
const groupsResponse = await fetchFamilyGroups(userId, accessToken); const groupsResponse = await fetchFamilyGroups(userId, accessToken);
const list = groupsResponse.groups ?? []; const list = groupsResponse.groups ?? [];
@@ -46,8 +49,11 @@ export function useSelectedFamily(enabled = true) {
return; return;
} }
const effectiveId = const effectiveId = resolveFamilyGroupId(list, selectedGroupId, isSelectionHydrated);
selectedGroupId && list.some((item) => item.id === selectedGroupId) ? selectedGroupId : list[0]!.id; if (!effectiveId) {
pendingHydration = !isSelectionHydrated;
return;
}
if (effectiveId !== selectedGroupId) { if (effectiveId !== selectedGroupId) {
setSelectedGroupId(effectiveId); setSelectedGroupId(effectiveId);
@@ -63,9 +69,11 @@ export function useSelectedFamily(enabled = true) {
} catch { } catch {
// Фоновая ошибка не должна сбрасывать уже показанную семью в сайдбаре. // Фоновая ошибка не должна сбрасывать уже показанную семью в сайдбаре.
} finally { } finally {
setLoading(false); if (!pendingHydration) {
setLoading(false);
}
} }
}, [enabled, isContentReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, userId]); }, [enabled, isContentReady, isLoading, isPinLocked, isSelectionHydrated, selectedGroupId, setSelectedGroupId, token, userId]);
useEffect(() => { useEffect(() => {
if (!enabled || !userId || !token || isPinLocked || isLoading || !isContentReady) { if (!enabled || !userId || !token || isPinLocked || isLoading || !isContentReady) {
@@ -78,7 +86,7 @@ export function useSelectedFamily(enabled = true) {
} }
setLoading(!hasLoadedRef.current); setLoading(!hasLoadedRef.current);
void refresh(); void refresh();
}, [enabled, isContentReady, isLoading, isPinLocked, refresh, token, userId]); }, [enabled, isContentReady, isLoading, isPinLocked, isSelectionHydrated, refresh, token, userId]);
useEffect(() => { useEffect(() => {
const accessToken = getAccessToken() ?? token?.trim() ?? null; const accessToken = getAccessToken() ?? token?.trim() ?? null;

View File

@@ -1007,6 +1007,7 @@ export async function apiFetch<T>(
? { 'Content-Type': 'application/json' } ? { 'Content-Type': 'application/json' }
: {}), : {}),
...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}), ...(resolvedToken ? { Authorization: `Bearer ${resolvedToken}` } : {}),
...(behavior.silent ? { 'X-Id-Passive-Activity': '1' } : {}),
...(options.headers as Record<string, string> | undefined) ...(options.headers as Record<string, string> | undefined)
}; };
@@ -1075,8 +1076,14 @@ export interface MediaAccessResponse {
expiresAt: string; expiresAt: string;
} }
export async function fetchDocumentPhotoUrl(userId: string, storageKey: string, token?: string | null) { export async function fetchDocumentPhotoUrl(
userId: string,
storageKey: string,
token?: string | null,
fileName?: string
) {
const query = new URLSearchParams({ storageKey }); const query = new URLSearchParams({ storageKey });
if (fileName) query.set('fileName', fileName);
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token); return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
} }

View File

@@ -0,0 +1,76 @@
export type DocumentAttachmentMeta = {
fileName?: string;
contentType?: string;
};
export const DOCUMENT_FILE_ACCEPT =
'image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.odt,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const EXTENSION_TO_MIME: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
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',
rtf: 'application/rtf',
odt: 'application/vnd.oasis.opendocument.text'
};
export function resolveDocumentFileContentType(file: File) {
const fromType = file.type?.trim();
if (fromType && fromType !== 'application/octet-stream') {
return fromType;
}
const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
return EXTENSION_TO_MIME[extension] ?? 'application/octet-stream';
}
export function inferAttachmentContentType(storageKey: string, meta?: DocumentAttachmentMeta) {
if (meta?.contentType) return meta.contentType;
const extension = storageKey.split('.').pop()?.toLowerCase() ?? '';
return EXTENSION_TO_MIME[extension] ?? 'application/octet-stream';
}
export function isImageAttachment(contentType: string) {
return contentType.startsWith('image/');
}
export function isPdfAttachment(contentType: string) {
return contentType === 'application/pdf';
}
export function attachmentKindLabel(contentType: string) {
if (isImageAttachment(contentType)) return 'Изображение';
if (isPdfAttachment(contentType)) return 'PDF';
if (contentType.includes('word') || contentType === 'application/msword') return 'Word';
if (contentType.includes('excel') || contentType === 'application/vnd.ms-excel') return 'Excel';
if (contentType.includes('powerpoint') || contentType === 'application/vnd.ms-powerpoint') return 'PowerPoint';
if (contentType === 'text/plain') return 'Текст';
return 'Файл';
}
export function attachmentDisplayName(storageKey: string, meta?: DocumentAttachmentMeta) {
if (meta?.fileName?.trim()) return meta.fileName.trim();
const parts = storageKey.split('/');
return parts[parts.length - 1] ?? 'Файл';
}
export function attachmentFileExtension(contentType: string, fileName?: string) {
const fromName = fileName?.split('.').pop()?.toUpperCase();
if (fromName && fromName.length <= 5) return fromName;
if (isPdfAttachment(contentType)) return 'PDF';
if (contentType.includes('word')) return 'DOC';
if (contentType.includes('excel')) return 'XLS';
if (contentType.includes('powerpoint')) return 'PPT';
if (contentType === 'text/plain') return 'TXT';
if (isImageAttachment(contentType)) return 'IMG';
return 'FILE';
}

View File

@@ -290,9 +290,34 @@ export function parseDocumentPhotos(metadata: Record<string, unknown>): string[]
return []; return [];
} }
export function withDocumentPhotos(metadata: Record<string, unknown>, photoStorageKeys: string[]) { export type DocumentAttachmentMeta = {
fileName?: string;
contentType?: string;
};
export function parseAttachmentMeta(metadata: Record<string, unknown>): Record<string, DocumentAttachmentMeta> {
const raw = metadata.attachmentMeta;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return {};
}
const result: Record<string, DocumentAttachmentMeta> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const entry = value as Record<string, unknown>;
result[key] = {
fileName: typeof entry.fileName === 'string' ? entry.fileName : undefined,
contentType: typeof entry.contentType === 'string' ? entry.contentType : undefined
};
}
return result;
}
export function withDocumentPhotos(metadata: Record<string, unknown>, photoStorageKeys: string[], attachmentMeta?: Record<string, DocumentAttachmentMeta>) {
const next: Record<string, unknown> = { ...metadata, photoStorageKeys }; const next: Record<string, unknown> = { ...metadata, photoStorageKeys };
delete next.photoStorageKey; delete next.photoStorageKey;
if (attachmentMeta && Object.keys(attachmentMeta).length > 0) {
next.attachmentMeta = attachmentMeta;
}
return next; return next;
} }

View File

@@ -0,0 +1,43 @@
export const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
export function readStoredFamilyGroupId(): string | null {
if (typeof window === 'undefined') {
return null;
}
const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY);
return stored?.trim() ? stored : null;
}
export function writeStoredFamilyGroupId(groupId: string | null) {
if (typeof window === 'undefined') {
return;
}
if (groupId) {
window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId);
} else {
window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY);
}
}
export function resolveFamilyGroupId(
groups: Array<{ id: string }>,
selectedGroupId: string | null,
isSelectionHydrated: boolean
): string | null {
if (!groups.length) {
return null;
}
const candidates: Array<string | null> = [selectedGroupId];
if (!isSelectionHydrated) {
candidates.unshift(readStoredFamilyGroupId());
}
for (const candidate of candidates) {
if (candidate && groups.some((group) => group.id === candidate)) {
return candidate;
}
}
return isSelectionHydrated ? groups[0]!.id : null;
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE "Session" ADD COLUMN "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
UPDATE "Session" SET "lastActivityAt" = "updatedAt";

View File

@@ -132,6 +132,7 @@ model Session {
ipAddress String? ipAddress String?
userAgent String? userAgent String?
expiresAt DateTime expiresAt DateTime
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -800,13 +800,13 @@ export class AuthGrpcController {
} }
@GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl') @GrpcMethod('MediaService', 'CreateDocumentPhotoUploadUrl')
createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string }) { createDocumentPhotoUploadUrl(command: { userId: string; documentId: string; contentType: string; fileName?: string }) {
return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType); return this.media.createDocumentPhotoUploadUrl(command.userId, command.documentId, command.contentType, command.fileName);
} }
@GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl') @GrpcMethod('MediaService', 'GetDocumentPhotoAccessUrl')
getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string }) { getDocumentPhotoAccessUrl(command: { requesterId: string; targetUserId: string; storageKey: string; fileName?: string }) {
return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey); return this.media.getDocumentPhotoAccessUrl(command.requesterId, command.targetUserId, command.storageKey, command.fileName);
} }
@GrpcMethod('MediaService', 'ResolveStreamToken') @GrpcMethod('MediaService', 'ResolveStreamToken')

View File

@@ -115,9 +115,10 @@ export class MediaService {
}; };
} }
async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string) { async createDocumentPhotoUploadUrl(userId: string, documentId: string, contentType: string, fileName?: string) {
let normalizedContentType: string;
try { try {
this.minio.assertImageContentType(contentType); normalizedContentType = this.minio.assertDocumentAttachmentContentType(contentType, fileName);
} catch (error) { } catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла'); throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
} }
@@ -128,9 +129,9 @@ export class MediaService {
throw new NotFoundException('Документ не найден'); throw new NotFoundException('Документ не найден');
} }
const extension = this.minio.extensionForContentType(contentType); const extension = this.minio.extensionForDocumentContentType(normalizedContentType, fileName);
const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`; const storageKey = `documents/${userId}/${documentId}/${crypto.randomUUID()}.${extension}`;
return this.createUploadTarget(userId, storageKey, contentType); return this.createUploadTarget(userId, storageKey, normalizedContentType);
} }
async resolveStreamToken(token: string, requesterId?: string) { async resolveStreamToken(token: string, requesterId?: string) {
@@ -177,18 +178,19 @@ export class MediaService {
); );
} }
async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string) { async getDocumentPhotoAccessUrl(requesterId: string, targetUserId: string, storageKey: string, fileName?: string) {
await this.access.assertCanViewUserDocuments(requesterId, targetUserId); await this.access.assertCanViewUserDocuments(requesterId, targetUserId);
if (!storageKey.startsWith(`documents/${targetUserId}/`)) { if (!storageKey.startsWith(`documents/${targetUserId}/`)) {
throw new BadRequestException('Некорректный ключ фото документа'); throw new BadRequestException('Некорректный ключ файла документа');
} }
const token = await this.jwt.signAsync( const token = await this.jwt.signAsync(
{ {
purpose: 'media-stream', purpose: 'media-stream',
objectKey: storageKey, objectKey: storageKey,
ownerId: targetUserId ownerId: targetUserId,
fileName: fileName?.trim() || undefined
} satisfies MediaStreamPayload, } satisfies MediaStreamPayload,
{ {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),

View File

@@ -169,6 +169,7 @@ export class PinService {
data: { data: {
pinVerified: true, pinVerified: true,
status: SessionStatus.ACTIVE, status: SessionStatus.ACTIVE,
lastActivityAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
} }
}); });

View File

@@ -47,7 +47,8 @@ export class SessionService {
if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) { if (pinEnabled && pinVerified && status === SessionStatus.ACTIVE) {
const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15); const timeoutMinutes = await this.settings.getNumber('PIN_LOCK_TIMEOUT_MINUTES', 15);
const threshold = new Date(Date.now() - timeoutMinutes * 60_000); const threshold = new Date(Date.now() - timeoutMinutes * 60_000);
if (session.updatedAt < threshold) { const lastActivityAt = session.lastActivityAt ?? session.updatedAt;
if (lastActivityAt < threshold) {
await this.prisma.session.update({ await this.prisma.session.update({
where: { id: sessionId }, where: { id: sessionId },
data: { pinVerified: false, status: SessionStatus.LOCKED } data: { pinVerified: false, status: SessionStatus.LOCKED }
@@ -76,7 +77,7 @@ export class SessionService {
await this.prisma.session.update({ await this.prisma.session.update({
where: { id: sessionId }, where: { id: sessionId },
data: { updatedAt: new Date() } data: { lastActivityAt: new Date(), updatedAt: new Date() }
}); });
return state; return state;
@@ -160,7 +161,7 @@ export class SessionService {
return this.prisma.session.updateMany({ return this.prisma.session.updateMany({
where: { where: {
pinVerified: true, pinVerified: true,
updatedAt: { lt: threshold }, lastActivityAt: { lt: threshold },
status: SessionStatus.ACTIVE, status: SessionStatus.ACTIVE,
user: { pinCode: { isEnabled: true } } user: { pinCode: { isEnabled: true } }
}, },

View File

@@ -0,0 +1,141 @@
const BLOCKED_DOCUMENT_TYPES = new Set([
'application/javascript',
'application/x-javascript',
'text/javascript',
'text/html',
'application/xhtml+xml',
'application/x-httpd-php',
'application/x-sh'
]);
const EXTENSION_TO_MIME: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
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',
rtf: 'application/rtf',
odt: 'application/vnd.oasis.opendocument.text'
};
const ALLOWED_DOCUMENT_MIME = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'application/rtf',
'application/vnd.oasis.opendocument.text'
]);
const MIME_ALIASES: Record<string, string> = {
'image/jpg': 'image/jpeg',
'image/pjpeg': 'image/jpeg',
'application/x-pdf': 'application/pdf'
};
function extractFileExtension(fileName?: string | null) {
const trimmed = (fileName ?? '').trim().toLowerCase();
const dotIndex = trimmed.lastIndexOf('.');
if (dotIndex <= 0) return '';
return trimmed.slice(dotIndex + 1);
}
export function normalizeDocumentContentType(contentType?: string | null, fileName?: string | null) {
const raw = (contentType ?? '').trim().toLowerCase();
const base = raw.split(';')[0]?.trim() ?? '';
const aliased = MIME_ALIASES[base] ?? base;
if (aliased && aliased !== 'application/octet-stream' && ALLOWED_DOCUMENT_MIME.has(aliased)) {
return aliased;
}
const extension = extractFileExtension(fileName);
if (extension && EXTENSION_TO_MIME[extension]) {
return EXTENSION_TO_MIME[extension];
}
return aliased || 'application/octet-stream';
}
export function assertDocumentAttachmentContentType(contentType: string, fileName?: string) {
const normalized = normalizeDocumentContentType(contentType, fileName);
if (BLOCKED_DOCUMENT_TYPES.has(normalized)) {
throw new Error('Тип файла не поддерживается');
}
if (!ALLOWED_DOCUMENT_MIME.has(normalized)) {
throw new Error('Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT');
}
return normalized;
}
export function extensionForDocumentContentType(contentType: string, fileName?: string) {
const normalized = normalizeDocumentContentType(contentType, fileName);
switch (normalized) {
case 'image/jpeg':
return 'jpg';
case 'image/png':
return 'png';
case 'image/webp':
return 'webp';
case 'image/gif':
return 'gif';
case 'application/pdf':
return 'pdf';
case 'application/msword':
return 'doc';
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return 'docx';
case 'application/vnd.ms-excel':
return 'xls';
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return 'xlsx';
case 'application/vnd.ms-powerpoint':
return 'ppt';
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
return 'pptx';
case 'text/plain':
return 'txt';
case 'application/rtf':
return 'rtf';
case 'application/vnd.oasis.opendocument.text':
return 'odt';
default: {
const extension = extractFileExtension(fileName);
return extension || 'bin';
}
}
}
export function contentTypeForDocumentKey(storageKey: string) {
const lower = storageKey.toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.doc')) return 'application/msword';
if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
if (lower.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
if (lower.endsWith('.txt')) return 'text/plain';
if (lower.endsWith('.rtf')) return 'application/rtf';
if (lower.endsWith('.odt')) return 'application/vnd.oasis.opendocument.text';
return 'image/jpeg';
}

View File

@@ -15,6 +15,12 @@ import {
extensionForChatContentType, extensionForChatContentType,
normalizeChatContentType normalizeChatContentType
} from './chat-media.util'; } from './chat-media.util';
import {
assertDocumentAttachmentContentType as assertDocumentAttachment,
contentTypeForDocumentKey as resolveDocumentContentType,
extensionForDocumentContentType as mapDocumentExtension,
normalizeDocumentContentType
} from './document-media.util';
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
@@ -89,6 +95,18 @@ export class MinioService implements OnModuleInit {
} }
} }
assertDocumentAttachmentContentType(contentType: string, fileName?: string) {
return assertDocumentAttachment(contentType, fileName);
}
extensionForDocumentContentType(contentType: string, fileName?: string) {
return mapDocumentExtension(normalizeDocumentContentType(contentType, fileName), fileName);
}
contentTypeForDocumentKey(storageKey: string) {
return resolveDocumentContentType(storageKey);
}
assertImageContentType(contentType: string) { assertImageContentType(contentType: string) {
if (!ALLOWED_IMAGE_TYPES.has(contentType)) { if (!ALLOWED_IMAGE_TYPES.has(contentType)) {
throw new Error('Допустимы только изображения JPEG, PNG, WEBP или GIF'); throw new Error('Допустимы только изображения JPEG, PNG, WEBP или GIF');
@@ -133,6 +151,9 @@ export class MinioService implements OnModuleInit {
if (storageKey.startsWith('chat/')) { if (storageKey.startsWith('chat/')) {
return contentTypeForStorageKey(storageKey); return contentTypeForStorageKey(storageKey);
} }
if (storageKey.startsWith('documents/')) {
return this.contentTypeForDocumentKey(storageKey);
}
if (storageKey.endsWith('.png')) return 'image/png'; if (storageKey.endsWith('.png')) return 'image/png';
if (storageKey.endsWith('.webp')) return 'image/webp'; if (storageKey.endsWith('.webp')) return 'image/webp';
if (storageKey.endsWith('.gif')) return 'image/gif'; if (storageKey.endsWith('.gif')) return 'image/gif';

View File

@@ -38,12 +38,14 @@ message DocumentPhotoUploadRequest {
string userId = 1; string userId = 1;
string documentId = 2; string documentId = 2;
string contentType = 3; string contentType = 3;
optional string fileName = 4;
} }
message DocumentPhotoAccessRequest { message DocumentPhotoAccessRequest {
string requesterId = 1; string requesterId = 1;
string targetUserId = 2; string targetUserId = 2;
string storageKey = 3; string storageKey = 3;
optional string fileName = 4;
} }
message PresignedUploadResponse { message PresignedUploadResponse {