diff --git a/.env.example b/.env.example index a6aeea4..2018a55 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/v1/ +NEXT_PUBLIC_APP_URL=http://localhost:3000 PORT=3000 # Next App FRONTEND Instrumentation NEXT_PUBLIC_FARO_URL=http://localhost:12347/collect diff --git a/.env.production b/.env.production index a8e5356..9e1156c 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/v1 +NEXT_PUBLIC_APP_URL=http://localhost:3000 PORT=3000 # Next App FRONTEND Instrumentation NEXT_PUBLIC_FARO_URL=http://localhost:12347/collect diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f7f2bbc..0e72d11 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,6 +71,7 @@ jobs: file: ./Dockerfile.prod build-args: | NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL }} + NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL }} NEXT_PUBLIC_FARO_URL=${{ vars.NEXT_PUBLIC_FARO_URL }} NEXT_PUBLIC_FARO_APP_VERSION=${{ vars.NEXT_PUBLIC_FARO_APP_VERSION }} NEXT_PUBLIC_APP_ENV=${{ vars.NEXT_PUBLIC_APP_ENV }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5259c41..83cc989 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,7 @@ jobs: SKIP_ENV_VALIDATION: true NODE_ENV: production NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} + NEXT_PUBLIC_APP_URL: ${{vars.NEXT_PUBLIC_APP_URL}} NEXT_PUBLIC_FARO_URL: ${{ vars.NEXT_PUBLIC_FARO_URL }} NEXT_PUBLIC_FARO_APP_VERSION: ${{ vars.NEXT_PUBLIC_FARO_APP_VERSION }} NEXT_PUBLIC_APP_ENV: ${{ vars.NEXT_PUBLIC_APP_ENV }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4d77bc0..f4007c1 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -64,6 +64,7 @@ jobs: file: ./Dockerfile.prod build-args: | NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL }} + NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL }} NEXT_PUBLIC_FARO_URL=${{ vars.NEXT_PUBLIC_FARO_URL }} NEXT_PUBLIC_FARO_APP_VERSION=${{ vars.NEXT_PUBLIC_FARO_APP_VERSION }} NEXT_PUBLIC_APP_ENV=${{ vars.NEXT_PUBLIC_APP_ENV }} diff --git a/Dockerfile.prod b/Dockerfile.prod index 1164134..a6657da 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -16,6 +16,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --offline --froze FROM base AS builder +ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_FARO_URL ARG NEXT_PUBLIC_FARO_APP_VERSION @@ -24,6 +25,7 @@ ARG NEXT_PUBLIC_METRICS_ENABLED ARG SKIP_ENV_VALIDATION=false ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ + NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \ NEXT_PUBLIC_FARO_URL=$NEXT_PUBLIC_FARO_URL \ NEXT_PUBLIC_FARO_APP_NAME="frontend" \ NEXT_PUBLIC_FARO_APP_NAMESPACE="frontend" \ diff --git a/app/(auth)/oauth/route.ts b/app/(auth)/oauth/route.ts index 9e3a7a1..c39967f 100644 --- a/app/(auth)/oauth/route.ts +++ b/app/(auth)/oauth/route.ts @@ -58,7 +58,7 @@ export async function GET(request: NextRequest) { ); } - const successUrl = new URL(routes.user.profile(), request.url); + const successUrl = new URL(routes.user.profile(), env.NEXT_PUBLIC_APP_URL); successUrl.searchParams.set('success', 'true'); successUrl.searchParams.set('message', 'Операция выполнена успешно'); @@ -76,7 +76,7 @@ export async function GET(request: NextRequest) { } } - const errorUrl = new URL(routes.auth.signin(), request.url); + const errorUrl = new URL(routes.auth.signin(), env.NEXT_PUBLIC_APP_URL); errorUrl.searchParams.set('success', 'false'); errorUrl.searchParams.set('message', ERROR_MESSAGE); diff --git a/eslint.config.mjs b/eslint.config.mjs index 96e8d1a..357a5cd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,6 +48,7 @@ const eslintConfig = defineConfig([ 'src/**/': 'KEBAB_CASE', }, ], + 'object-shorthand': ['warn', 'always'], }, }, globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']), diff --git a/src/entities/auth/api/http.ts b/src/entities/auth/api/http.ts index 724f39c..03406eb 100644 --- a/src/entities/auth/api/http.ts +++ b/src/entities/auth/api/http.ts @@ -7,7 +7,7 @@ export class AuthHttp { return api({ url: '/auth/sign-in', method: 'POST', - data: data, + data, skipAuthRefresh: true, contracts: { body: SAuth.SigninBody, @@ -30,7 +30,7 @@ export class AuthHttp { return api({ url: '/auth/sign-up', method: 'POST', - data: data, + data, contracts: { body: SAuth.SignupBody, response: SAuth.SignupResponse, @@ -42,7 +42,7 @@ export class AuthHttp { return api({ url: '/auth/sign-up/confirm', method: 'POST', - data: data, + data, skipAuthRefresh: true, contracts: { body: SAuth.SignupConfirmBody, @@ -55,7 +55,7 @@ export class AuthHttp { return api({ url: '/auth/password/reset', method: 'POST', - data: data, + data, contracts: { body: SAuth.ResetPasswordBody, response: SAuth.ResetPasswordResponse, @@ -67,7 +67,7 @@ export class AuthHttp { return api({ url: '/auth/password/reset/verify', method: 'POST', - data: data, + data, contracts: { body: SAuth.ResetPasswordVerifyBody, response: SAuth.ResetPasswordVerifyResponse, @@ -79,13 +79,24 @@ export class AuthHttp { return api({ url: '/auth/password/reset/confirm', method: 'POST', - data: data, + data, contracts: { body: SAuth.ResetPasswordConfirmBody, response: SAuth.ResetPasswordConfirmResponse, }, }); } + static resendCode(data: TAuth.ResendCodeBody): Promise { + return api({ + url: '/auth/resend', + method: 'POST', + data, + contracts: { + body: SAuth.ResendCodeBody, + response: SAuth.ResendCodeResponse, + }, + }); + } static oAuthProviders(signal: AbortSignal) { return api({ url: '/oauth/providers', @@ -124,22 +135,11 @@ export class AuthHttp { }, }); } - static resendCode(data: TAuth.ResendCodeBody): Promise { - return api({ - url: '/oauth/resend', - method: 'POST', - data: data, - contracts: { - body: SAuth.ResendCodeBody, - response: SAuth.ResendCodeResponse, - }, - }); - } static exchangeToken(data: TAuth.ExchangeTokenBody): Promise { return api({ url: '/oauth/exchange', method: 'POST', - data: data, + data, }); } } diff --git a/src/entities/board/model/mapper.ts b/src/entities/board/model/mapper.ts index 887786f..931fd3f 100644 --- a/src/entities/board/model/mapper.ts +++ b/src/entities/board/model/mapper.ts @@ -19,7 +19,7 @@ export class BoardMapper { columnList: BoardColumnResponse[], taskList: unknown[] ): KanbanBoardData { - const sortedColumns = [...columnList].sort((a, b) => a.orderIndex - b.orderIndex); + const sortedColumns = [...columnList].sort((a, b) => a.position - b.position); const tasksByColumn: Record = {}; const columns: Record = {}; diff --git a/src/entities/board/model/schemas.ts b/src/entities/board/model/schemas.ts index 952c64a..216e644 100644 --- a/src/entities/board/model/schemas.ts +++ b/src/entities/board/model/schemas.ts @@ -1,4 +1,4 @@ -import { createSortingSchema, DateTimeString, GlobalSuccess } from 'shared/api'; +import { createSortingSchema, CursorQuerySchema, DateTimeString, GlobalSuccess } from 'shared/api'; import { z } from 'zod/v4'; export const ActionResponse = GlobalSuccess; @@ -84,7 +84,7 @@ export const BoardColumn = z.object({ .nullable() .optional(), icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), - orderIndex: z + position: z .number() .int('Порядковый номер должен быть целым числом') .min(0, 'Порядковый номер не может быть отрицательным'), @@ -154,7 +154,7 @@ export const CreateBoardColumnBody = BoardColumn.omit({ autoTransitionTo: true, stateType: true, category: true, - orderIndex: true, + position: true, isVisible: true, notifyOnEnter: true, notifyOnExit: true, @@ -176,20 +176,9 @@ export const BoardColumnQueryParams = z my: z.boolean().optional(), category: z.string().optional(), overdue: z.boolean().optional(), - page: z.coerce.number().int().positive().optional(), - offset: z.coerce.number().int().min(0).optional(), - limit: z.coerce.number().int().min(0).max(100).optional(), }) - .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape) - .transform((data) => { - if (data.page && data.page > 1 && data.offset === 0) { - return { - ...data, - offset: (data.page - 1) * (data.limit || 20), - }; - } - return data; - }); + .extend(CursorQuerySchema.shape) + .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape); export const UpdateBoardColumnResponse = GlobalSuccess; export const CreateBoardColumnResponse = GlobalSuccess.extend({ diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts index 4ded86e..62f1917 100644 --- a/src/entities/board/model/types.ts +++ b/src/entities/board/model/types.ts @@ -3,6 +3,7 @@ import * as SBoard from './schemas'; export type BoardColumnStatus = z.infer; export type BoardViewType = z.infer; +export type BoardColumnQueryParams = z.infer; export type BoardColumnResponse = z.infer; export type BoardColumnListResponse = z.infer; diff --git a/src/entities/team/config/roles.ts b/src/entities/team/config/roles.ts index 8cbde32..1d64d4e 100644 --- a/src/entities/team/config/roles.ts +++ b/src/entities/team/config/roles.ts @@ -2,12 +2,10 @@ import type { TeamRole } from '../model/types'; export const ROLE_LABELS: Record, string> = { admin: 'Администратор', - lead: 'Лид', - moderator: 'Модератор', member: 'Участник', viewer: 'Гость', } as const; export const INVITATION_ROLES = [ - ...new Set(['admin', 'lead', 'moderator', 'member', 'viewer']), + ...new Set(['admin', 'member', 'viewer']), ] as const; diff --git a/src/entities/team/config/statuses.ts b/src/entities/team/config/statuses.ts index 3c15654..f27933b 100644 --- a/src/entities/team/config/statuses.ts +++ b/src/entities/team/config/statuses.ts @@ -1,11 +1,13 @@ import type { MemberStatus } from '../model/types'; -export const STATUS_LABELS: Record = { +export const STATUS_LABELS = { active: 'Активен', + banned: 'Заблокирован', blocked: 'Заблокирован', + inactive: 'Неактивен', pending: 'Неактивен', -} as const; +} as const satisfies Record; export const MEMBER_STATUSES = [ - ...new Set(['active', 'pending', 'blocked']), + ...new Set(['active', 'inactive', 'banned']), ] as const; diff --git a/src/entities/team/model/schemas.ts b/src/entities/team/model/schemas.ts index b2cf387..cd80e32 100644 --- a/src/entities/team/model/schemas.ts +++ b/src/entities/team/model/schemas.ts @@ -13,15 +13,15 @@ export const TeamAvatarSchema = z export const TeamRole = z.enum([ 'owner', 'admin', // управление юзерами, настройками - 'lead', // управление проектами - 'moderator', // чистка контента/сообщений 'member', // обычный работяга 'viewer', // просто смотрит ]); export const MemberStatus = z.enum([ 'active', // Полноценный участник - 'blocked', // Заблокирован не может вернуться по инвайту + 'banned', // Заблокирован не может вернуться по инвайту + 'inactive', + 'blocked', 'pending', ]); @@ -50,8 +50,8 @@ export const TeamDetailsResponse = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), - avatarUrl: z.string().nullable(), - coverUrl: z.string().nullable(), + avatar: TeamAvatarSchema, + cover: TeamAvatarSchema, ownerId: z.string().nullable(), createdAt: DateTimeString, updatedAt: DateTimeString, @@ -71,7 +71,7 @@ export const TeamInvitationResponse = z.object({ expiresAt: DateTimeString, }); -export const TeamInvitationListResponse = PaginatedResponseSchema(TeamInvitationResponse); +export const TeamInvitationListResponse = TeamInvitationResponse.array(); export const InviteMemberBody = z.object({ email: z.email(), diff --git a/src/features/boards/column/create/config/default-values.ts b/src/features/boards/column/create/config/default-values.ts index e645aee..d11fc3a 100644 --- a/src/features/boards/column/create/config/default-values.ts +++ b/src/features/boards/column/create/config/default-values.ts @@ -5,6 +5,6 @@ export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColu return { title: '', color: DEFAULT_COLUMN_COLOR, - orderIndex: position, + position, }; } diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts index 1a48587..0a4e5e0 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -37,7 +37,7 @@ export function useCreateBoardColumnForm( const onSubmit = (data: CreateBoardColumnFormValues) => { const body: TBoard.CreateBoardColumnBody = { title: data.title, - orderIndex: data.orderIndex, + position: data.position, ...(data.color ? { color: data.color } : {}), }; diff --git a/src/pages/invitations/ui/InvitationsPageContent.tsx b/src/pages/invitations/ui/InvitationsPageContent.tsx index 93d16b9..81f25eb 100644 --- a/src/pages/invitations/ui/InvitationsPageContent.tsx +++ b/src/pages/invitations/ui/InvitationsPageContent.tsx @@ -11,7 +11,7 @@ import { InvitationCard } from './InvitationCard'; export function InvitationsPageContent() { const invitationsQuery = useSuspenseQuery(UserQueries.getMyInvitations()); const invitations = invitationsQuery.data.items; - const invitationsCount = invitationsQuery.data.meta.total ?? invitations.length; + const invitationsCount = invitationsQuery.data.items.length ?? 0; return ( { const ids = Object.keys(value); const idsMap = new Map(ids.map((id, i) => [id, i])); - const columnIdsToUpdate: { columnId: string; orderIndex: number }[] = []; + const columnIdsToUpdate: { columnId: string; position: number }[] = []; const prevColumns = queryClient.getQueryData( boardFabricKeys.columns(data.board.slug) @@ -46,16 +46,16 @@ export const ProjectKanban = ({ data }: ProjectKanbanProps) => { if (!oldColumns) return oldColumns; return oldColumns.map((column) => { - const orderIndex = idsMap.get(column.id); - return orderIndex !== undefined ? { ...column, orderIndex } : column; + const position = idsMap.get(column.id); + return position !== undefined ? { ...column, position } : column; }); } ); prevColumns?.forEach((column) => { - const orderIndex = idsMap.get(column.id); - if (orderIndex !== undefined && column.orderIndex !== orderIndex) { - columnIdsToUpdate.push({ columnId: column.id, orderIndex }); + const position = idsMap.get(column.id); + if (position !== undefined && column.position !== position) { + columnIdsToUpdate.push({ columnId: column.id, position }); } }); diff --git a/src/pages/team/config/member.ts b/src/pages/team/config/member.ts index c636e76..457a9f5 100644 --- a/src/pages/team/config/member.ts +++ b/src/pages/team/config/member.ts @@ -3,24 +3,32 @@ import { ComponentProps } from 'react'; import { Badge } from 'shared/ui'; interface IMemberCardConfig { - ringColor: Record; - bgColor: Record; + ringColor: Record; + bgColor: Record; workloadColor: (w: number) => string; statusBadgeVariant: (s: TTeam.MemberStatus) => ComponentProps['variant']; workloadLabel: (w: number) => { text: string; color: string }; } +type EnsureAllStatuses = { + [K in TTeam.MemberStatus]: T; +}; + export const memberCardConfig: IMemberCardConfig = { ringColor: { - blocked: 'ring-destructive', active: 'ring-primary', + banned: 'ring-destructive', + blocked: 'ring-destructive', + inactive: 'ring-muted', pending: 'ring-muted', - }, + } satisfies EnsureAllStatuses, bgColor: { - blocked: 'bg-destructive/10', active: 'bg-card', + banned: 'bg-destructive/10', + blocked: 'bg-destructive/10', + inactive: 'bg-muted/90', pending: 'bg-muted/90', - }, + } satisfies EnsureAllStatuses, workloadColor: (w) => { if (w === 0) return 'bg-muted/90'; if (w <= 60) return 'bg-primary/40'; @@ -28,9 +36,9 @@ export const memberCardConfig: IMemberCardConfig = { return 'bg-orange-500'; }, statusBadgeVariant: (s) => { - if (s === 'blocked') return 'destructive'; + if (s === 'banned') return 'destructive'; if (s === 'active') return 'default'; - if (s === 'pending') return 'outline'; + if (s === 'inactive') return 'outline'; }, workloadLabel: (w) => { if (w === 0) return { text: 'Не загружен', color: 'text-muted-foreground' }; diff --git a/src/pages/team/ui/invitations/InvitationsPage.tsx b/src/pages/team/ui/invitations/InvitationsPage.tsx index b5c0c44..77016d6 100644 --- a/src/pages/team/ui/invitations/InvitationsPage.tsx +++ b/src/pages/team/ui/invitations/InvitationsPage.tsx @@ -8,7 +8,7 @@ import { InvitationsEmpty } from './InvitationsEmpty'; export function InvitationsPage() { const { data, isPending } = useQueryInvitations(); - if (!isPending && !data?.items?.length) { + if (!isPending && !data?.length) { return ; } @@ -16,7 +16,7 @@ export function InvitationsPage() {
{isPending ? Array.from({ length: 6 }).map((_, i) => ) - : data?.items.map((inv) => )} + : data?.map((inv) => )}
); } diff --git a/src/pages/team/ui/members/MemberCard.tsx b/src/pages/team/ui/members/MemberCard.tsx index 8a304ef..ce311df 100644 --- a/src/pages/team/ui/members/MemberCard.tsx +++ b/src/pages/team/ui/members/MemberCard.tsx @@ -37,7 +37,7 @@ export function MemberCard({ className, member, ...props }: MemberCardProps) { className={classNames( 'border-border bg-card rounded-xl border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.18)]', { - 'opacity-50 grayscale': member.status === 'pending', + 'opacity-50 grayscale': member.status === 'inactive', }, [cfg.bgColor[member.status], className] )} diff --git a/src/pages/team/ui/settings/TeamIdentity.tsx b/src/pages/team/ui/settings/TeamIdentity.tsx index 29c6ed7..183d40f 100644 --- a/src/pages/team/ui/settings/TeamIdentity.tsx +++ b/src/pages/team/ui/settings/TeamIdentity.tsx @@ -23,7 +23,7 @@ export function TeamIdentity({ team, ...props }: TeamIdentityProps) { description="Публичная информация о команде." >
- +
} diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index ac233f3..8c65263 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -11,6 +11,8 @@ export { DateTimeString, PaginatedResponseSchema, createSortingSchema, + SearchFilterSchema, + CursorQuerySchema, MetaSchema, } from './schemas'; export { AccessToken } from './token'; diff --git a/src/shared/api/schemas/index.ts b/src/shared/api/schemas/index.ts index b34cc97..fcee7d3 100644 --- a/src/shared/api/schemas/index.ts +++ b/src/shared/api/schemas/index.ts @@ -1,5 +1,6 @@ export { GlobalSuccess } from './global-success'; export { GlobalError } from './global-error'; export { DateTimeString } from './date-time-string'; -export { PaginatedResponseSchema, MetaSchema } from './pagination'; +export { PaginatedResponseSchema, MetaSchema, CursorQuerySchema } from './pagination'; +export { SearchFilterSchema } from './search'; export { createSortingSchema } from './sorting'; diff --git a/src/shared/api/schemas/pagination.ts b/src/shared/api/schemas/pagination.ts index 290dc16..4c9159a 100644 --- a/src/shared/api/schemas/pagination.ts +++ b/src/shared/api/schemas/pagination.ts @@ -1,12 +1,9 @@ import { z } from 'zod/v4'; export const MetaSchema = z.object({ - hasNextPage: z.boolean(), - hasPrevPage: z.boolean(), - total: z.number(), - totalPages: z.number(), - page: z.number(), - limit: z.number(), + next: z.boolean().nullable(), + hasNext: z.boolean(), + limit: z.number().int().positive(), }); export const PaginatedResponseSchema = (schema: T) => @@ -14,3 +11,15 @@ export const PaginatedResponseSchema = (schema: T) => items: z.array(schema), meta: MetaSchema, }); + +const LimitSchema = z.coerce + .number() + .int() + .min(1, 'Лимит должен быть не менее 1') + .max(100, 'Лимит не может превышать 100') + .optional(); + +export const CursorQuerySchema = z.object({ + cursor: z.string().optional(), + limit: LimitSchema, +}); diff --git a/src/shared/api/schemas/search.ts b/src/shared/api/schemas/search.ts new file mode 100644 index 0000000..8aef7d6 --- /dev/null +++ b/src/shared/api/schemas/search.ts @@ -0,0 +1,10 @@ +import { z } from 'zod/v4'; + +export const SearchFilterSchema = z.object({ + search: z + .string() + .trim() + .min(1, 'Поисковый запрос не может быть пустым') + .max(100, 'Поисковый запрос слишком длинный') + .optional(), +}); diff --git a/src/shared/api/schemas/sorting.ts b/src/shared/api/schemas/sorting.ts index 48aebed..6b920e6 100644 --- a/src/shared/api/schemas/sorting.ts +++ b/src/shared/api/schemas/sorting.ts @@ -1,28 +1,9 @@ import { z } from 'zod/v4'; export const createSortingSchema = ( - fields: T, - defaultField?: T[number], - defaultOrder: 'asc' | 'desc' = 'asc' + fields: T ) => z.object({ - sortBy: z - .enum(fields) - .optional() - /** - * Приведение as any обусловлено ограничением системы типов TypeScript: - * тип fields[0 выводится как string, а Zod ожидает конкретный литеральный тип - * из объединения T[number]. В рантайме значение гарантированно валидно, - * так как массив fields используется для создания enum. - * as any безопасно подавляет ошибку, не расширяя тип за пределы этой строки. - */ - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - .default(defaultField ?? (fields[0] as any)) - .describe(`Поле для сортировки. Доступно: ${fields.join(', ')}`), - - sortOrder: z - .enum(['asc', 'desc']) - .optional() - .default(() => defaultOrder) - .describe('Направление сортировки: asc - по возрастанию, desc - по убыванию'), + sortBy: z.enum(fields).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), }); diff --git a/src/shared/config/env.client.ts b/src/shared/config/env.client.ts index d819f5d..6ab3d5f 100644 --- a/src/shared/config/env.client.ts +++ b/src/shared/config/env.client.ts @@ -1,7 +1,14 @@ import { z } from 'zod/v4'; +const metricEnabledSchema = z.enum(['true', 'false'], { + error: 'NEXT_PUBLIC_METRICS_ENABLED - обязателен', +}); + +const isDevelopment = process.env.NODE_ENV === 'development'; + export const envSchemaClient = z.object({ NEXT_PUBLIC_API_BASE_URL: z.url('NEXT_PUBLIC_API_BASE_URL должен быть валидным URL'), + NEXT_PUBLIC_APP_URL: z.url('NEXT_PUBLIC_API_BASE_URL должен быть валидным URL'), NEXT_PUBLIC_FARO_URL: z.url( 'NEXT_PUBLIC_FARO_URL должен быть валидным URL (например, http://alloy:12347/collect)' ), @@ -21,15 +28,14 @@ export const envSchemaClient = z.object({ error: 'Окружение (APP_ENV) обязательно', }) .min(1, 'Окружение не может быть пустым'), - NEXT_PUBLIC_METRICS_ENABLED: z - .enum(['true', 'false'], { - error: 'NEXT_PUBLIC_METRICS_ENABLED - обязателен', - }) - .transform((v) => v === 'true'), + NEXT_PUBLIC_METRICS_ENABLED: isDevelopment + ? metricEnabledSchema.default('false').transform((v) => v === 'true') + : metricEnabledSchema.transform((v) => v === 'true'), }); const _env = envSchemaClient.safeParse({ NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_FARO_URL: process.env.NEXT_PUBLIC_FARO_URL, NEXT_PUBLIC_FARO_APP_NAME: process.env.NEXT_PUBLIC_FARO_APP_NAME, NEXT_PUBLIC_FARO_APP_NAMESPACE: process.env.NEXT_PUBLIC_FARO_APP_NAMESPACE, diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 112c9bc..d7d6634 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -1,12 +1,9 @@ import { z } from 'zod/v4'; +import { envSchemaClient } from './env.client'; const isServer = typeof window === 'undefined'; const isBuild = process.env.SKIP_ENV_VALIDATION === 'true'; -const metricEnabledSchema = z.enum(['true', 'false'], { - error: 'NEXT_PUBLIC_METRICS_ENABLED - обязателен', -}); - const envSchemaServer = z.object({ NODE_ENV: z .enum(['development', 'production', 'test'], { @@ -38,35 +35,6 @@ const envSchemaServer = z.object({ .includes('service.namespace=', { message: 'Атрибуты должны содержать service.namespace' }), }); -const envSchemaClient = z.object({ - NEXT_PUBLIC_API_BASE_URL: z.url('NEXT_PUBLIC_API_BASE_URL должен быть валидным URL'), - NEXT_PUBLIC_FARO_URL: z - .string({ - error: 'URL для Faro (Alloy) обязателен', - }) - .url('NEXT_PUBLIC_FARO_URL должен быть валидным URL (например, http://alloy:12347/collect)'), - NEXT_PUBLIC_FARO_APP_NAME: z - .string({ - error: 'Имя приложения для Faro обязательно', - }) - .min(1, 'Имя приложения не может быть пустым'), - NEXT_PUBLIC_FARO_APP_NAMESPACE: z - .string({ - error: 'Namespace приложения обязателен', - }) - .min(1, 'Namespace не может быть пустым'), - NEXT_PUBLIC_FARO_APP_VERSION: z.string().default('1.0.0'), - NEXT_PUBLIC_APP_ENV: z - .string({ - error: 'Окружение (APP_ENV) обязательно', - }) - .min(1, 'Окружение не может быть пустым'), - NEXT_PUBLIC_METRICS_ENABLED: - process.env.NODE_ENV === 'development' - ? metricEnabledSchema.default('false').transform((v) => v === 'true') - : metricEnabledSchema.transform((v) => v === 'true'), -}); - const envSchema = envSchemaClient.extend(envSchemaServer.shape); const getSchema = () => { diff --git a/src/widgets/app-sidebar/ui/MyTeams.tsx b/src/widgets/app-sidebar/ui/MyTeams.tsx index db4911a..9e3b2b2 100644 --- a/src/widgets/app-sidebar/ui/MyTeams.tsx +++ b/src/widgets/app-sidebar/ui/MyTeams.tsx @@ -10,8 +10,7 @@ import { SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from 'shared/ui' export function MyTeams() { const pathname = usePathname(); const invitationsQuery = useQuery(UserQueries.getMyInvitations()); - const invitationsCount = - invitationsQuery.data?.meta.total ?? invitationsQuery.data?.items.length ?? 0; + const invitationsCount = invitationsQuery.data?.items.length ?? 0; return (