From 2f45834b7ea33374ef5d0ecda98ef303a3f771d1 Mon Sep 17 00:00:00 2001 From: maksym Date: Mon, 20 Jul 2026 22:30:17 +0200 Subject: [PATCH 01/32] feat(chat): implement chat service and UI components - Introduced a new chat slice with a comprehensive service layer, including `ChatGateway` and `ChatService` for managing chat sessions, messages, and feedback. - Added UI components for chat history, message bubbles, and chat detail views, enhancing user interaction with chat transcripts. - Implemented functionality for loading chat messages, providing feedback, and exporting chat transcripts in various formats (JSON, Markdown, CSV). - Established a dependency injection plugin to provide the chat service throughout the application. This update lays the groundwork for a robust chat feature, allowing users to view, interact with, and manage their chat history effectively. --- .../{chatDetail => chat/detail}/Provider.vue | 0 .../{chatList => chat/list}/Card.vue | 0 .../{chatList => chat/list}/Provider.vue | 0 .../{chatMessage => chat/message}/Bubble.vue | 0 app/slices/chat/data/chat.gateway.ts | 106 +++++++++ app/slices/chat/data/chat.mapper.ts | 135 +++++++++++ app/slices/chat/data/index.ts | 3 + app/slices/chat/data/unwrapEnvelope.ts | 13 ++ app/slices/chat/domain/chat.gateway.ts | 32 +++ app/slices/chat/domain/chat.service.ts | 58 +++++ app/slices/chat/domain/chat.types.ts | 97 ++++++++ app/slices/chat/domain/index.ts | 3 + app/slices/chat/index.d.ts | 9 + app/slices/chat/pages/chats/index.vue | 2 - app/slices/chat/plugins/di.ts | 19 ++ app/slices/chat/stores/chat.ts | 209 +++++------------- .../common/composables/createServiceGetter.ts | 37 ++++ app/slices/common/data/BaseGateway.ts | 50 +++++ 18 files changed, 614 insertions(+), 159 deletions(-) rename app/slices/chat/components/{chatDetail => chat/detail}/Provider.vue (100%) rename app/slices/chat/components/{chatList => chat/list}/Card.vue (100%) rename app/slices/chat/components/{chatList => chat/list}/Provider.vue (100%) rename app/slices/chat/components/{chatMessage => chat/message}/Bubble.vue (100%) create mode 100644 app/slices/chat/data/chat.gateway.ts create mode 100644 app/slices/chat/data/chat.mapper.ts create mode 100644 app/slices/chat/data/index.ts create mode 100644 app/slices/chat/data/unwrapEnvelope.ts create mode 100644 app/slices/chat/domain/chat.gateway.ts create mode 100644 app/slices/chat/domain/chat.service.ts create mode 100644 app/slices/chat/domain/chat.types.ts create mode 100644 app/slices/chat/domain/index.ts create mode 100644 app/slices/chat/index.d.ts create mode 100644 app/slices/chat/plugins/di.ts create mode 100644 app/slices/common/composables/createServiceGetter.ts create mode 100644 app/slices/common/data/BaseGateway.ts diff --git a/app/slices/chat/components/chatDetail/Provider.vue b/app/slices/chat/components/chat/detail/Provider.vue similarity index 100% rename from app/slices/chat/components/chatDetail/Provider.vue rename to app/slices/chat/components/chat/detail/Provider.vue diff --git a/app/slices/chat/components/chatList/Card.vue b/app/slices/chat/components/chat/list/Card.vue similarity index 100% rename from app/slices/chat/components/chatList/Card.vue rename to app/slices/chat/components/chat/list/Card.vue diff --git a/app/slices/chat/components/chatList/Provider.vue b/app/slices/chat/components/chat/list/Provider.vue similarity index 100% rename from app/slices/chat/components/chatList/Provider.vue rename to app/slices/chat/components/chat/list/Provider.vue diff --git a/app/slices/chat/components/chatMessage/Bubble.vue b/app/slices/chat/components/chat/message/Bubble.vue similarity index 100% rename from app/slices/chat/components/chatMessage/Bubble.vue rename to app/slices/chat/components/chat/message/Bubble.vue diff --git a/app/slices/chat/data/chat.gateway.ts b/app/slices/chat/data/chat.gateway.ts new file mode 100644 index 0000000..baa00ce --- /dev/null +++ b/app/slices/chat/data/chat.gateway.ts @@ -0,0 +1,106 @@ +import { ChatsService } from '#api'; +import { client as apiClient } from '#api/data/repositories/api/client.gen'; +import type { + ChatFeedbackDto, + ChatListResponseDto, + ChatMessagesResponseDto, + ChatSessionDto, + SyncChatsResponseDto, +} from '#api/data/repositories/api/types.gen'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { IChatGateway } from '../domain/chat.gateway'; +import { + emptyChatList, + emptyChatMessages, + type ChatExportFormat, + type IChatExportFile, + type IChatFeedback, + type IChatListResult, + type IChatMessagesQuery, + type IChatMessagesResult, + type IChatSession, + type IChatSyncResult, +} from '../domain/chat.types'; +import { ChatMapper } from './chat.mapper'; +import { unwrapEnvelope } from './unwrapEnvelope'; + +export class ChatGateway extends BaseGateway implements IChatGateway { + private mapper = new ChatMapper(); + + listMine(page: number, perPage: number): Promise { + return this.execute(async () => { + const res = await ChatsService.getMyChats({ query: { page, perPage } }); + const dto = unwrapEnvelope(res.data); + return dto ? this.mapper.toList(dto) : emptyChatList(page, perPage); + }); + } + + getMine(id: string): Promise { + return this.execute(async () => { + const res = await ChatsService.getMyChat({ path: { id } }); + const dto = unwrapEnvelope(res.data); + return dto ? this.mapper.toSession(dto) : null; + }); + } + + messages( + id: string, + query: IChatMessagesQuery, + ): Promise { + return this.execute(async () => { + const res = await ChatsService.getMyChatMessages({ path: { id }, query }); + const dto = unwrapEnvelope(res.data); + return dto ? this.mapper.toMessages(dto) : emptyChatMessages(); + }); + } + + syncMine(agentId?: string): Promise { + return this.execute(async () => { + const res = await ChatsService.syncMyChats({ + body: agentId ? { agentId } : {}, + }); + const dto = unwrapEnvelope(res.data); + return dto ? this.mapper.toSync(dto) : null; + }); + } + + feedback(id: string): Promise { + return this.execute(async () => { + const res = await ChatsService.listMyChatFeedback({ path: { id } }); + const dtos = unwrapEnvelope(res.data); + return dtos ? this.mapper.toFeedbackList(dtos) : []; + }); + } + + rate(id: string, messageId: string, rating: 1 | -1): Promise { + return this.execute(async () => { + await ChatsService.createMyChatFeedback({ + path: { id }, + body: { messageId, rating }, + }); + }); + } + + unrate(id: string, messageId: string): Promise { + return this.execute(async () => { + await ChatsService.deleteMyChatFeedback({ path: { id, messageId } }); + }); + } + + // Export is a file download — use the raw axios client (carries the base URL + // + Bearer header from the shared client config) with a blob response, then + // resolve the bytes + filename. The store performs the browser download. + exportChat(id: string, format: ChatExportFormat): Promise { + return this.execute(async () => { + const res = await apiClient.instance.get(`/me/chats/${id}/export`, { + params: { format }, + responseType: 'blob', + }); + const dispo = (res.headers['content-disposition'] as string) ?? ''; + const filename = + dispo.match(/filename="?([^"]+)"?/)?.[1] ?? + `chat-${id}.${format === 'markdown' ? 'md' : format}`; + return { blob: res.data as Blob, filename }; + }); + } +} diff --git a/app/slices/chat/data/chat.mapper.ts b/app/slices/chat/data/chat.mapper.ts new file mode 100644 index 0000000..0592f9a --- /dev/null +++ b/app/slices/chat/data/chat.mapper.ts @@ -0,0 +1,135 @@ +import type { + ChatFeedbackDto, + ChatListResponseDto, + ChatMessageDto, + ChatMessagesResponseDto, + ChatSessionDto, + SyncChatsResponseDto, +} from '#api/data/repositories/api/types.gen'; +import type { + ChatMessageRole, + IChatFeedback, + IChatInsights, + IChatListResult, + IChatMessage, + IChatMessagesResult, + IChatSession, + IChatSyncResult, +} from '../domain/chat.types'; + +/** + * The OpenAPI generator types several free-form nullable string fields + * (`title`, `preview`, `summary`, `nextCursor`, `comment`, `authorId`) as + * `{ [key: string]: unknown } | null`. Coerce them to a clean `string | null` + * at the boundary so the domain never sees the generator's noise. + */ +function str(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +const KNOWN_SENTIMENTS = new Set([ + 'positive', + 'neutral', + 'negative', + 'mixed', +]); + +/** + * Maps generated chat DTOs onto the slice's domain shapes. The one place in the + * chat slice that imports from `#api`. + */ +export class ChatMapper { + toList(dto: ChatListResponseDto): IChatListResult { + return { + items: dto.items.map((s) => this.toSession(s)), + total: dto.total, + page: dto.page, + perPage: dto.perPage, + }; + } + + toSession(dto: ChatSessionDto): IChatSession { + return { + id: dto.id, + channel: dto.channel, + externalUserId: dto.externalUserId, + sessionKey: dto.sessionKey, + title: str(dto.title), + preview: str(dto.preview), + lastRole: dto.lastRole ?? null, + lastMessageAt: dto.lastMessageAt, + messageCount: dto.messageCount, + userMessageCount: dto.userMessageCount, + summary: str(dto.summary), + insights: this.toInsights(dto.insights), + archived: dto.archived, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + }; + } + + toMessages(dto: ChatMessagesResponseDto): IChatMessagesResult { + return { + messages: dto.messages.map((m) => this.toMessage(m)), + nextCursor: str(dto.nextCursor), + hasMore: dto.hasMore, + }; + } + + toSync(dto: SyncChatsResponseDto): IChatSyncResult { + return { + scannedAgents: dto.scannedAgents, + scannedFiles: dto.scannedFiles, + upserted: dto.upserted, + skipped: dto.skipped, + }; + } + + toFeedbackList(dtos: ChatFeedbackDto[]): IChatFeedback[] { + return dtos.map((f) => this.toFeedback(f)); + } + + // ── internals ────────────────────────────────────────────────────────── + + private toInsights(raw: unknown): IChatInsights | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + const sentiment = o.sentiment as IChatInsights['sentiment']; + return { + topics: Array.isArray(o.topics) + ? o.topics.filter((t): t is string => typeof t === 'string') + : [], + sentiment: KNOWN_SENTIMENTS.has(sentiment) ? sentiment : 'neutral', + resolved: o.resolved === true, + language: typeof o.language === 'string' ? o.language : 'unknown', + }; + } + + private toMessage(dto: ChatMessageDto): IChatMessage { + return { + id: dto.id, + role: this.toRole(dto.role), + text: dto.text, + ts: dto.ts, + }; + } + + // The `/me` endpoints only ever emit conversational roles; anything else + // (tool/system) is filtered server-side. Pass the three through and fold the + // impossible rest into a plain assistant bubble rather than a summary marker. + private toRole(role: ChatMessageDto['role']): ChatMessageRole { + return role === 'user' || role === 'summary' ? role : 'assistant'; + } + + private toFeedback(dto: ChatFeedbackDto): IChatFeedback { + return { + id: dto.id, + messageId: dto.messageId, + rating: dto.rating, + comment: str(dto.comment), + source: dto.source, + authorId: str(dto.authorId), + createdAt: dto.createdAt, + }; + } +} diff --git a/app/slices/chat/data/index.ts b/app/slices/chat/data/index.ts new file mode 100644 index 0000000..f61dc07 --- /dev/null +++ b/app/slices/chat/data/index.ts @@ -0,0 +1,3 @@ +export * from './chat.gateway'; +export * from './chat.mapper'; +export * from './unwrapEnvelope'; diff --git a/app/slices/chat/data/unwrapEnvelope.ts b/app/slices/chat/data/unwrapEnvelope.ts new file mode 100644 index 0000000..355878e --- /dev/null +++ b/app/slices/chat/data/unwrapEnvelope.ts @@ -0,0 +1,13 @@ +/** + * The API wraps every 2xx body in `{ success, data }`, but the generated + * client types each response as the *inner* payload — so `res.data` is typed as + * the DTO while at runtime it is the envelope. Peel it here, at the single data + * boundary, so mappers receive the DTO the types already promise and no layer + * above ever deals with the envelope. + */ +export function unwrapEnvelope(body: unknown): T | null { + if (body && typeof body === 'object' && 'data' in (body as { data?: unknown })) { + return ((body as { data?: unknown }).data ?? null) as T | null; + } + return (body ?? null) as T | null; +} diff --git a/app/slices/chat/domain/chat.gateway.ts b/app/slices/chat/domain/chat.gateway.ts new file mode 100644 index 0000000..3298bbd --- /dev/null +++ b/app/slices/chat/domain/chat.gateway.ts @@ -0,0 +1,32 @@ +import type { + ChatExportFormat, + IChatExportFile, + IChatFeedback, + IChatListResult, + IChatMessagesQuery, + IChatMessagesResult, + IChatSession, + IChatSyncResult, +} from './chat.types'; + +/** + * The contract the domain depends on. The data layer provides the concrete + * implementation (`ChatGateway`); the service and store know only this + * abstraction, so the SDK/transport stays swappable and mockable. + */ +export abstract class IChatGateway { + abstract listMine(page: number, perPage: number): Promise; + abstract getMine(id: string): Promise; + abstract messages( + id: string, + query: IChatMessagesQuery, + ): Promise; + abstract syncMine(agentId?: string): Promise; + abstract feedback(id: string): Promise; + abstract rate(id: string, messageId: string, rating: 1 | -1): Promise; + abstract unrate(id: string, messageId: string): Promise; + abstract exportChat( + id: string, + format: ChatExportFormat, + ): Promise; +} diff --git a/app/slices/chat/domain/chat.service.ts b/app/slices/chat/domain/chat.service.ts new file mode 100644 index 0000000..cb201b7 --- /dev/null +++ b/app/slices/chat/domain/chat.service.ts @@ -0,0 +1,58 @@ +import type { IChatGateway } from './chat.gateway'; +import type { + ChatExportFormat, + IChatExportFile, + IChatFeedback, + IChatListResult, + IChatMessagesQuery, + IChatMessagesResult, + IChatSession, + IChatSyncResult, +} from './chat.types'; + +/** + * Domain service for the current user's chats. Holds the use-case surface the + * store consumes and owns default query params. Today every method delegates + * straight to the gateway; cross-cutting rules (caching, event emission, + * multi-gateway orchestration) would land here without touching callers. + */ +export class ChatService { + constructor(private gateway: IChatGateway) {} + + listMine(page = 1, perPage = 50): Promise { + return this.gateway.listMine(page, perPage); + } + + getMine(id: string): Promise { + return this.gateway.getMine(id); + } + + messages( + id: string, + query: IChatMessagesQuery = {}, + ): Promise { + return this.gateway.messages(id, query); + } + + // Self-service reconcile — pull the caller's own chats from S3 into the index + // when realtime indexing hasn't caught up. Server-scoped to the current user. + syncMine(agentId?: string): Promise { + return this.gateway.syncMine(agentId); + } + + feedback(id: string): Promise { + return this.gateway.feedback(id); + } + + rate(id: string, messageId: string, rating: 1 | -1): Promise { + return this.gateway.rate(id, messageId, rating); + } + + unrate(id: string, messageId: string): Promise { + return this.gateway.unrate(id, messageId); + } + + exportChat(id: string, format: ChatExportFormat): Promise { + return this.gateway.exportChat(id, format); + } +} diff --git a/app/slices/chat/domain/chat.types.ts b/app/slices/chat/domain/chat.types.ts new file mode 100644 index 0000000..2cf45b2 --- /dev/null +++ b/app/slices/chat/domain/chat.types.ts @@ -0,0 +1,97 @@ +// Domain types for the chat slice — the clean, envelope-free shapes the app +// works with. The data layer maps SDK DTOs onto these; nothing above the data +// layer touches the generated `#api` types. + +export type ChatExportFormat = 'json' | 'markdown' | 'csv'; + +// LLM-derived structured insights (null until the cron-batch computes them). +export interface IChatInsights { + topics: string[]; + sentiment: 'positive' | 'neutral' | 'negative' | 'mixed'; + resolved: boolean; + language: string; +} + +// The current user's 👍/👎 on an assistant message. +export interface IChatFeedback { + id: string; + messageId: string; + rating: number; // 1 | -1 + comment: string | null; + source: string; + authorId: string | null; + createdAt: string; +} + +// One of the current user's own chat sessions (index metadata). Server-scoped +// to the caller's JWT — the app never passes a user id. +export interface IChatSession { + id: string; + channel: string; + externalUserId: string; + sessionKey: string; + title: string | null; + preview: string | null; + lastRole: string | null; + lastMessageAt: string; + messageCount: number; + userMessageCount: number; + summary: string | null; + insights: IChatInsights | null; + archived: boolean; + createdAt: string; + updatedAt: string; +} + +// End users only ever see conversational roles — tool events are filtered +// server-side; `summary` marks where compaction folded older turns. +export type ChatMessageRole = 'user' | 'assistant' | 'summary'; + +export interface IChatMessage { + id: string; + role: ChatMessageRole; + text: string; + ts: number; +} + +export interface IChatListResult { + items: IChatSession[]; + total: number; + page: number; + perPage: number; +} + +export interface IChatMessagesResult { + messages: IChatMessage[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface IChatMessagesQuery { + limit?: number; + cursor?: string; +} + +export interface IChatSyncResult { + scannedAgents: number; + scannedFiles: number; + upserted: number; + skipped: number; +} + +// A downloaded transcript, ready for the browser to save. The data layer +// resolves the bytes + filename; the store performs the actual download. +export interface IChatExportFile { + blob: Blob; + filename: string; +} + +/** Defensive empty list — used when the server returns an empty envelope. */ +export function emptyChatList(page = 1, perPage = 50): IChatListResult { + return { items: [], total: 0, page, perPage }; +} + +/** Defensive empty message page. */ +export function emptyChatMessages(): IChatMessagesResult { + return { messages: [], nextCursor: null, hasMore: false }; +} diff --git a/app/slices/chat/domain/index.ts b/app/slices/chat/domain/index.ts new file mode 100644 index 0000000..276a42b --- /dev/null +++ b/app/slices/chat/domain/index.ts @@ -0,0 +1,3 @@ +export * from './chat.gateway'; +export * from './chat.service'; +export * from './chat.types'; diff --git a/app/slices/chat/index.d.ts b/app/slices/chat/index.d.ts new file mode 100644 index 0000000..e670984 --- /dev/null +++ b/app/slices/chat/index.d.ts @@ -0,0 +1,9 @@ +import type { ChatService } from './domain/chat.service'; + +declare module '#app' { + interface NuxtApp { + $chatService: ChatService; + } +} + +export {}; diff --git a/app/slices/chat/pages/chats/index.vue b/app/slices/chat/pages/chats/index.vue index 5596bef..f72f733 100644 --- a/app/slices/chat/pages/chats/index.vue +++ b/app/slices/chat/pages/chats/index.vue @@ -1,5 +1,3 @@ diff --git a/app/slices/chat/plugins/di.ts b/app/slices/chat/plugins/di.ts new file mode 100644 index 0000000..80acb07 --- /dev/null +++ b/app/slices/chat/plugins/di.ts @@ -0,0 +1,19 @@ +import { ChatGateway } from '../data/chat.gateway'; +import { ChatService } from '../domain/chat.service'; + +/** + * Composition root for the chat slice. Builds the service graph + * (service → gateway → mapper) once and provides it as `$chatService`. The + * store resolves it lazily via `createServiceGetter`. + */ +export default defineNuxtPlugin({ + name: 'chat-di', + setup() { + const service = new ChatService(new ChatGateway()); + return { + provide: { + chatService: service, + }, + }; + }, +}); diff --git a/app/slices/chat/stores/chat.ts b/app/slices/chat/stores/chat.ts index 3b3da68..edab175 100644 --- a/app/slices/chat/stores/chat.ts +++ b/app/slices/chat/stores/chat.ts @@ -1,177 +1,72 @@ -import { ChatsService } from '#api'; -import { client as apiClient } from '#api/data/repositories/api/client.gen'; - -export type ChatExportFormat = 'json' | 'markdown' | 'csv'; - -// The API wraps every response in { success, data }; unwrap to the payload. -interface IEnvelope { - success?: boolean; - data?: T; -} -function unwrap(body: unknown): T | null { - if (body && typeof body === 'object' && 'data' in (body as IEnvelope)) { - return ((body as IEnvelope).data ?? null) as T | null; - } - return (body ?? null) as T | null; -} - -// LLM-derived structured insights (null until the cron-batch computes them). -export interface IChatInsights { - topics: string[]; - sentiment: 'positive' | 'neutral' | 'negative' | 'mixed'; - resolved: boolean; - language: string; -} - -// The current user's 👍/👎 on an assistant message. -export interface IChatFeedback { - id: string; - messageId: string; - rating: number; // 1 | -1 - comment: string | null; - source: string; - authorId: string | null; - createdAt: string; -} - -// One of the current user's own chat sessions (index metadata). Server-scoped -// to the caller's JWT — the app never passes a user id. -export interface IChatSession { - id: string; - channel: string; - externalUserId: string; - sessionKey: string; - title: string | null; - preview: string | null; - lastRole: string | null; - lastMessageAt: string; - messageCount: number; - userMessageCount: number; - summary: string | null; - insights: IChatInsights | null; - archived: boolean; - createdAt: string; - updatedAt: string; -} - -// End users only ever see conversational roles — tool events are filtered -// server-side; `summary` marks where compaction folded older turns. -export type ChatMessageRole = 'user' | 'assistant' | 'summary'; - -export interface IChatMessage { - id: string; - role: ChatMessageRole; - text: string; - ts: number; -} - -export interface IChatListResult { - items: IChatSession[]; - total: number; - page: number; - perPage: number; -} - -export interface IChatMessagesResult { - messages: IChatMessage[]; - nextCursor: string | null; - hasMore: boolean; -} - -export interface IChatMessagesQuery { - limit?: number; - cursor?: string; -} - -export interface IChatSyncResult { - scannedAgents: number; - scannedFiles: number; - upserted: number; - skipped: number; +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import type { ChatService } from '#chat/domain'; +import type { ChatExportFormat, IChatMessagesQuery } from '#chat/domain'; + +// Re-export the domain types so existing consumers that import them from +// `#chat/stores/chat` (Bubble, Card, chatDetail/Provider) keep working. +export type { + ChatExportFormat, + ChatMessageRole, + IChatExportFile, + IChatFeedback, + IChatInsights, + IChatListResult, + IChatMessage, + IChatMessagesQuery, + IChatMessagesResult, + IChatSession, + IChatSyncResult, +} from '#chat/domain'; + +const getService = createServiceGetter('$chatService'); + +/** Save an already-fetched blob to disk (client-only DOM side effect). */ +function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); } +/** + * UI-facing facade over `ChatService`. Keeps the same method surface the chat + * components already call; the transport, DTO mapping and envelope-unwrapping + * now live in the data layer behind the service. + */ export const useChatStore = defineStore('chat', () => { - async function listMine(page = 1, perPage = 50): Promise { - const res = await ChatsService.getMyChats({ query: { page, perPage } }); - return ( - unwrap(res.data) ?? { - items: [], - total: 0, - page, - perPage, - } - ); + function listMine(page = 1, perPage = 50) { + return getService().listMine(page, perPage); } - async function getMine(id: string): Promise { - const res = await ChatsService.getMyChat({ path: { id } }); - return unwrap(res.data); + function getMine(id: string) { + return getService().getMine(id); } - async function messages( - id: string, - query: IChatMessagesQuery = {}, - ): Promise { - const res = await ChatsService.getMyChatMessages({ path: { id }, query }); - return ( - unwrap(res.data) ?? { - messages: [], - nextCursor: null, - hasMore: false, - } - ); + function messages(id: string, query: IChatMessagesQuery = {}) { + return getService().messages(id, query); } - // Self-service reconcile — pull the caller's own chats from S3 into the index - // when realtime indexing hasn't caught up. Server-scoped to the current user. - async function syncMine(agentId?: string): Promise { - const res = await ChatsService.syncMyChats({ - body: agentId ? { agentId } : {}, - }); - return unwrap(res.data); + function syncMine(agentId?: string) { + return getService().syncMine(agentId); } - async function feedback(id: string): Promise { - const res = await ChatsService.listMyChatFeedback({ path: { id } }); - return unwrap(res.data) ?? []; + function feedback(id: string) { + return getService().feedback(id); } - async function rate( - id: string, - messageId: string, - rating: 1 | -1, - ): Promise { - await ChatsService.createMyChatFeedback({ - path: { id }, - body: { messageId, rating }, - }); + function rate(id: string, messageId: string, rating: 1 | -1) { + return getService().rate(id, messageId, rating); } - async function unrate(id: string, messageId: string): Promise { - await ChatsService.deleteMyChatFeedback({ path: { id, messageId } }); + function unrate(id: string, messageId: string) { + return getService().unrate(id, messageId); } - // Export is a file download — use the raw axios client (carries the base URL - // + Bearer header from the shared client config) with a blob response, then - // trigger a browser download. - async function exportChat( - id: string, - format: ChatExportFormat, - ): Promise { - const res = await apiClient.instance.get(`/me/chats/${id}/export`, { - params: { format }, - responseType: 'blob', - }); - const dispo = (res.headers['content-disposition'] as string) ?? ''; - const filename = - dispo.match(/filename="?([^"]+)"?/)?.[1] ?? - `chat-${id}.${format === 'markdown' ? 'md' : format}`; - const url = URL.createObjectURL(res.data as Blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + async function exportChat(id: string, format: ChatExportFormat) { + const { blob, filename } = await getService().exportChat(id, format); + triggerDownload(blob, filename); } return { diff --git a/app/slices/common/composables/createServiceGetter.ts b/app/slices/common/composables/createServiceGetter.ts new file mode 100644 index 0000000..1402e9f --- /dev/null +++ b/app/slices/common/composables/createServiceGetter.ts @@ -0,0 +1,37 @@ +/** + * Factory that returns a memoized getter for a Nuxt-provided service. + * + * Replaces the per-store boilerplate of reaching into `useNuxtApp()`, caching + * the instance, and `markRaw`-ing it. + * + * Usage (at module scope, above `defineStore`): + * const getService = createServiceGetter('$chatService'); + * // then inside the store: `getService().listMine()` + * + * Why module-scope instead of Pinia state: + * Class instances can't be serialized during SSR hydration, so they must live + * outside Pinia state. The closure gives request-isolated lazy resolution + * without touching state. + * + * Why markRaw: + * Prevents Vue from making the service deeply reactive — services hold their + * own state (SDK clients, mappers) that should not be tracked. + */ +export function createServiceGetter( + injectionKey: string, +): () => T { + let cached: T | undefined; + return (): T => { + if (cached) return cached; + const app = useNuxtApp() as unknown as Record; + const service = app[injectionKey] as T | undefined; + if (!service) { + throw new Error( + `createServiceGetter: '${injectionKey}' is not provided. ` + + `Ensure the DI plugin runs before this getter is called.`, + ); + } + cached = markRaw(service); + return cached; + }; +} diff --git a/app/slices/common/data/BaseGateway.ts b/app/slices/common/data/BaseGateway.ts new file mode 100644 index 0000000..3347b56 --- /dev/null +++ b/app/slices/common/data/BaseGateway.ts @@ -0,0 +1,50 @@ +/** + * Optional hook to translate raw errors (network / SDK failures) into domain + * errors before they leave the data layer. Slices that need provider-specific + * error handling implement this and pass it to `BaseGateway`'s constructor. + */ +export interface IErrorMapper { + toErrorEntity(error: unknown): unknown; +} + +/** Default mapper — re-throws the original error unchanged. */ +const passthroughErrorMapper: IErrorMapper = { + toErrorEntity: (error) => error, +}; + +/** + * Base class for data-layer gateways. Centralizes the + * `try { ... } catch { throw errorMapper.toErrorEntity(error) }` boilerplate so + * concrete gateways focus on SDK calls + mapper invocation. + * + * Usage: + * export class ChatGateway extends BaseGateway implements IChatGateway { + * private mapper = new ChatMapper(); + * + * listMine(page: number, perPage: number): Promise { + * return this.execute(async () => { + * const res = await ChatsService.getMyChats({ query: { page, perPage } }); + * return this.mapper.toList(unwrapEnvelope(res.data)); + * }); + * } + * } + * + * Wrapping the whole method body (rather than a narrower `executeAndMap`) lets + * gateways branch, pre-compute query objects, and post-process freely without + * contorting the signature. + */ +export abstract class BaseGateway { + protected errorMapper: IErrorMapper; + + constructor(errorMapper?: IErrorMapper) { + this.errorMapper = errorMapper ?? passthroughErrorMapper; + } + + protected async execute(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + throw this.errorMapper.toErrorEntity(error); + } + } +} From 1fbdc9e857d3050a63c867c44dbdfb181ae3efb9 Mon Sep 17 00:00:00 2001 From: maksym Date: Mon, 20 Jul 2026 22:34:58 +0200 Subject: [PATCH 02/32] refactor(bridle): adopt domain/data layered architecture Mirror the chat slice's layering: extract the agent-send into a domain service + data gateway/mapper behind IBridleGateway, provided via a $bridleService DI plugin. The store keeps its reactive conversation state and localStorage persistence but no longer touches #api or unwrap. Also relocate unwrapEnvelope from #chat/data to #common/data so every slice's gateways share one envelope-peeling boundary (chat updated to import it from #common). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/slices/bridle/data/bridle.gateway.ts | 22 ++++++++ app/slices/bridle/data/bridle.mapper.ts | 19 +++++++ app/slices/bridle/data/index.ts | 2 + app/slices/bridle/domain/bridle.gateway.ts | 9 ++++ app/slices/bridle/domain/bridle.service.ts | 16 ++++++ app/slices/bridle/domain/bridle.types.ts | 26 ++++++++++ app/slices/bridle/domain/index.ts | 3 ++ app/slices/bridle/index.d.ts | 9 ++++ app/slices/bridle/plugins/di.ts | 18 +++++++ app/slices/bridle/stores/bridle.ts | 50 +++++-------------- app/slices/chat/data/chat.gateway.ts | 2 +- app/slices/chat/data/index.ts | 1 - .../{chat => common}/data/unwrapEnvelope.ts | 4 +- 13 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 app/slices/bridle/data/bridle.gateway.ts create mode 100644 app/slices/bridle/data/bridle.mapper.ts create mode 100644 app/slices/bridle/data/index.ts create mode 100644 app/slices/bridle/domain/bridle.gateway.ts create mode 100644 app/slices/bridle/domain/bridle.service.ts create mode 100644 app/slices/bridle/domain/bridle.types.ts create mode 100644 app/slices/bridle/domain/index.ts create mode 100644 app/slices/bridle/index.d.ts create mode 100644 app/slices/bridle/plugins/di.ts rename app/slices/{chat => common}/data/unwrapEnvelope.ts (76%) diff --git a/app/slices/bridle/data/bridle.gateway.ts b/app/slices/bridle/data/bridle.gateway.ts new file mode 100644 index 0000000..6b6a6ea --- /dev/null +++ b/app/slices/bridle/data/bridle.gateway.ts @@ -0,0 +1,22 @@ +// The generated SDK class is also named `BridleService`; alias it to `BridleApi` +// so it doesn't collide with the domain service of the same name. +import { BridleService as BridleApi } from '#api'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; +import { IBridleGateway } from '../domain/bridle.gateway'; +import type { IBridleReply } from '../domain/bridle.types'; +import { BridleMapper } from './bridle.mapper'; + +export class BridleGateway extends BaseGateway implements IBridleGateway { + private mapper = new BridleMapper(); + + sendMessage(agentId: string, text: string): Promise { + return this.execute(async () => { + const res = await BridleApi.sendBridleMessageSync({ + path: { agentId }, + body: { text }, + }); + return this.mapper.toReply(unwrapEnvelope(res.data)); + }); + } +} diff --git a/app/slices/bridle/data/bridle.mapper.ts b/app/slices/bridle/data/bridle.mapper.ts new file mode 100644 index 0000000..fdec032 --- /dev/null +++ b/app/slices/bridle/data/bridle.mapper.ts @@ -0,0 +1,19 @@ +import type { IBridleReply } from '../domain/bridle.types'; + +/** + * Maps the agent runtime's sync response onto the domain reply. The SDK types + * this endpoint's body as `unknown`, so we defensively read the three fields + * we care about and normalize `text` to a string. Time/id fallbacks are left to + * the store (they depend on "now"). + */ +export class BridleMapper { + toReply(raw: unknown): IBridleReply { + const o = + raw && typeof raw === 'object' ? (raw as Record) : {}; + return { + messageId: typeof o.messageId === 'string' ? o.messageId : null, + text: typeof o.text === 'string' ? o.text : '', + ts: typeof o.ts === 'number' ? o.ts : null, + }; + } +} diff --git a/app/slices/bridle/data/index.ts b/app/slices/bridle/data/index.ts new file mode 100644 index 0000000..b4b9766 --- /dev/null +++ b/app/slices/bridle/data/index.ts @@ -0,0 +1,2 @@ +export * from './bridle.gateway'; +export * from './bridle.mapper'; diff --git a/app/slices/bridle/domain/bridle.gateway.ts b/app/slices/bridle/domain/bridle.gateway.ts new file mode 100644 index 0000000..40df091 --- /dev/null +++ b/app/slices/bridle/domain/bridle.gateway.ts @@ -0,0 +1,9 @@ +import type { IBridleReply } from './bridle.types'; + +/** + * Contract for talking to the agent runtime. The data layer implements it + * (`BridleGateway`); the service and store depend only on this abstraction. + */ +export abstract class IBridleGateway { + abstract sendMessage(agentId: string, text: string): Promise; +} diff --git a/app/slices/bridle/domain/bridle.service.ts b/app/slices/bridle/domain/bridle.service.ts new file mode 100644 index 0000000..d5f5e4a --- /dev/null +++ b/app/slices/bridle/domain/bridle.service.ts @@ -0,0 +1,16 @@ +import type { IBridleGateway } from './bridle.gateway'; +import type { IBridleReply } from './bridle.types'; + +/** + * Domain service for the live agent chat. Exposes the send use-case; the store + * layers conversation state, optimistic updates and persistence on top. Named + * after the slice — the generated `#api` SDK class of the same name is imported + * under an alias in the data gateway to avoid the collision. + */ +export class BridleService { + constructor(private gateway: IBridleGateway) {} + + sendMessage(agentId: string, text: string): Promise { + return this.gateway.sendMessage(agentId, text); + } +} diff --git a/app/slices/bridle/domain/bridle.types.ts b/app/slices/bridle/domain/bridle.types.ts new file mode 100644 index 0000000..a4d117e --- /dev/null +++ b/app/slices/bridle/domain/bridle.types.ts @@ -0,0 +1,26 @@ +// Domain types for the bridle slice — the live agent chat. Envelope-free; the +// data layer maps the SDK response onto these and the store owns the reactive +// conversation state. + +export enum BridleRoleTypes { + User = 'user', + Agent = 'agent', +} + +export interface IBridleMessage { + id: string; + role: BridleRoleTypes; + text: string; + ts: number; +} + +/** + * The agent's reply to a synchronous send. `messageId`/`ts` are null when the + * agent omits them — the store fills client-side fallbacks (generated id, + * `Date.now()`) since those are time/UI concerns, not domain data. + */ +export interface IBridleReply { + messageId: string | null; + text: string; + ts: number | null; +} diff --git a/app/slices/bridle/domain/index.ts b/app/slices/bridle/domain/index.ts new file mode 100644 index 0000000..f1739e1 --- /dev/null +++ b/app/slices/bridle/domain/index.ts @@ -0,0 +1,3 @@ +export * from './bridle.gateway'; +export * from './bridle.service'; +export * from './bridle.types'; diff --git a/app/slices/bridle/index.d.ts b/app/slices/bridle/index.d.ts new file mode 100644 index 0000000..b6db9ed --- /dev/null +++ b/app/slices/bridle/index.d.ts @@ -0,0 +1,9 @@ +import type { BridleService } from './domain/bridle.service'; + +declare module '#app' { + interface NuxtApp { + $bridleService: BridleService; + } +} + +export {}; diff --git a/app/slices/bridle/plugins/di.ts b/app/slices/bridle/plugins/di.ts new file mode 100644 index 0000000..459c0e8 --- /dev/null +++ b/app/slices/bridle/plugins/di.ts @@ -0,0 +1,18 @@ +import { BridleGateway } from '../data/bridle.gateway'; +import { BridleService } from '../domain/bridle.service'; + +/** + * Composition root for the bridle slice. Builds the service graph + * (service → gateway → mapper) once and provides it as `$bridleService`. + */ +export default defineNuxtPlugin({ + name: 'bridle-di', + setup() { + const service = new BridleService(new BridleGateway()); + return { + provide: { + bridleService: service, + }, + }; + }, +}); diff --git a/app/slices/bridle/stores/bridle.ts b/app/slices/bridle/stores/bridle.ts index 0aa3a0c..9c76df9 100644 --- a/app/slices/bridle/stores/bridle.ts +++ b/app/slices/bridle/stores/bridle.ts @@ -1,34 +1,14 @@ -import { BridleService } from '#api'; +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import { BridleRoleTypes } from '#bridle/domain'; +import type { BridleService, IBridleMessage } from '#bridle/domain'; -export enum BridleRoleTypes { - User = 'user', - Agent = 'agent', -} - -export interface IBridleMessage { - id: string; - role: BridleRoleTypes; - text: string; - ts: number; -} - -interface ISyncResponse { - text?: string; - messageId?: string; - ts?: number; -} +// Re-export the domain enum/types so consumers importing them from +// `#bridle/stores/bridle` (Message.vue) keep working. `BridleRoleTypes` is used +// as a runtime value, so it's a value re-export (not `export type`). +export { BridleRoleTypes } from '#bridle/domain'; +export type { IBridleMessage, IBridleReply } from '#bridle/domain'; -interface IEnvelope { - success?: boolean; - data?: T; -} - -function unwrap(body: unknown): T | null { - if (body && typeof body === 'object' && 'data' in (body as IEnvelope)) { - return ((body as IEnvelope).data ?? null) as T | null; - } - return (body ?? null) as T | null; -} +const getService = createServiceGetter('$bridleService'); // localStorage persistence for chat conversations — survives page refresh. // Scoped per agentId so switching agents doesn't bleed history. Mirrors the @@ -125,16 +105,12 @@ export const useBridleStore = defineStore('bridle', () => { errors.value[agentId] = null; try { - const res = await BridleService.sendBridleMessageSync({ - path: { agentId }, - body: { text: trimmed }, - }); - const data = unwrap(res.data) ?? {}; + const reply = await getService().sendMessage(agentId, trimmed); appendMessage(agentId, { - id: data.messageId || `a-${Date.now()}`, + id: reply.messageId || `a-${Date.now()}`, role: BridleRoleTypes.Agent, - text: data.text ?? '', - ts: data.ts ?? Date.now(), + text: reply.text, + ts: reply.ts ?? Date.now(), }); } catch (err) { errors.value[agentId] = diff --git a/app/slices/chat/data/chat.gateway.ts b/app/slices/chat/data/chat.gateway.ts index baa00ce..ce2ebdf 100644 --- a/app/slices/chat/data/chat.gateway.ts +++ b/app/slices/chat/data/chat.gateway.ts @@ -8,6 +8,7 @@ import type { SyncChatsResponseDto, } from '#api/data/repositories/api/types.gen'; import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; import { IChatGateway } from '../domain/chat.gateway'; import { emptyChatList, @@ -22,7 +23,6 @@ import { type IChatSyncResult, } from '../domain/chat.types'; import { ChatMapper } from './chat.mapper'; -import { unwrapEnvelope } from './unwrapEnvelope'; export class ChatGateway extends BaseGateway implements IChatGateway { private mapper = new ChatMapper(); diff --git a/app/slices/chat/data/index.ts b/app/slices/chat/data/index.ts index f61dc07..5247b54 100644 --- a/app/slices/chat/data/index.ts +++ b/app/slices/chat/data/index.ts @@ -1,3 +1,2 @@ export * from './chat.gateway'; export * from './chat.mapper'; -export * from './unwrapEnvelope'; diff --git a/app/slices/chat/data/unwrapEnvelope.ts b/app/slices/common/data/unwrapEnvelope.ts similarity index 76% rename from app/slices/chat/data/unwrapEnvelope.ts rename to app/slices/common/data/unwrapEnvelope.ts index 355878e..48b2593 100644 --- a/app/slices/chat/data/unwrapEnvelope.ts +++ b/app/slices/common/data/unwrapEnvelope.ts @@ -2,8 +2,8 @@ * The API wraps every 2xx body in `{ success, data }`, but the generated * client types each response as the *inner* payload — so `res.data` is typed as * the DTO while at runtime it is the envelope. Peel it here, at the single data - * boundary, so mappers receive the DTO the types already promise and no layer - * above ever deals with the envelope. + * boundary shared by every slice's gateways, so mappers receive the DTO the + * types already promise and no layer above ever deals with the envelope. */ export function unwrapEnvelope(body: unknown): T | null { if (body && typeof body === 'object' && 'data' in (body as { data?: unknown })) { From 7a16915eb5463fb73931fb810906684167193d7e Mon Sep 17 00:00:00 2001 From: maksym Date: Mon, 20 Jul 2026 22:48:27 +0200 Subject: [PATCH 03/32] refactor(agent,auth): adopt domain/data layered architecture Apply the same layering to the agent and auth slices: extract network calls into domain services + data gateways/mappers behind IAgentGateway / IAuthGateway, each provided via a DI plugin ($agentService / $authService). Stores keep their reactive state (agent lists/current + loading/error; auth token/user/hydration + localStorage + bearer wiring) but no longer touch #api or unwrap. Notes: - agent remove(): pass the SDK-required query { wipeS3: 'false' }, which also fixes a pre-existing type error and keeps deletes non-destructive. - SDK/domain name collision (both AuthService): the data gateway imports the SDK aliased as AuthApi. - auth-init plugin now dependsOn 'auth-di' so $authService is provided before init() -> me() runs at setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/slices/agent/data/agent.gateway.ts | 72 +++++++++++++++++++++ app/slices/agent/data/agent.mapper.ts | 61 +++++++++++++++++ app/slices/agent/data/index.ts | 2 + app/slices/agent/domain/agent.gateway.ts | 22 +++++++ app/slices/agent/domain/agent.service.ts | 42 ++++++++++++ app/slices/agent/domain/agent.types.ts | 35 ++++++++++ app/slices/agent/domain/index.ts | 3 + app/slices/agent/index.d.ts | 9 +++ app/slices/agent/plugins/di.ts | 18 ++++++ app/slices/agent/stores/agent.ts | 72 +++++++-------------- app/slices/user/auth/data/auth.gateway.ts | 41 ++++++++++++ app/slices/user/auth/data/auth.mapper.ts | 51 +++++++++++++++ app/slices/user/auth/data/index.ts | 2 + app/slices/user/auth/domain/auth.gateway.ts | 16 +++++ app/slices/user/auth/domain/auth.service.ts | 28 ++++++++ app/slices/user/auth/domain/auth.types.ts | 22 +++++++ app/slices/user/auth/domain/index.ts | 3 + app/slices/user/auth/index.d.ts | 9 +++ app/slices/user/auth/plugins/auth.ts | 3 +- app/slices/user/auth/plugins/di.ts | 20 ++++++ app/slices/user/auth/stores/auth.ts | 57 ++++++---------- 21 files changed, 501 insertions(+), 87 deletions(-) create mode 100644 app/slices/agent/data/agent.gateway.ts create mode 100644 app/slices/agent/data/agent.mapper.ts create mode 100644 app/slices/agent/data/index.ts create mode 100644 app/slices/agent/domain/agent.gateway.ts create mode 100644 app/slices/agent/domain/agent.service.ts create mode 100644 app/slices/agent/domain/agent.types.ts create mode 100644 app/slices/agent/domain/index.ts create mode 100644 app/slices/agent/index.d.ts create mode 100644 app/slices/agent/plugins/di.ts create mode 100644 app/slices/user/auth/data/auth.gateway.ts create mode 100644 app/slices/user/auth/data/auth.mapper.ts create mode 100644 app/slices/user/auth/data/index.ts create mode 100644 app/slices/user/auth/domain/auth.gateway.ts create mode 100644 app/slices/user/auth/domain/auth.service.ts create mode 100644 app/slices/user/auth/domain/auth.types.ts create mode 100644 app/slices/user/auth/domain/index.ts create mode 100644 app/slices/user/auth/index.d.ts create mode 100644 app/slices/user/auth/plugins/di.ts diff --git a/app/slices/agent/data/agent.gateway.ts b/app/slices/agent/data/agent.gateway.ts new file mode 100644 index 0000000..8232ebe --- /dev/null +++ b/app/slices/agent/data/agent.gateway.ts @@ -0,0 +1,72 @@ +import { AgentsService } from '#api'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; +import { IAgentGateway } from '../domain/agent.gateway'; +import type { + IAgentCreateInput, + IAgentData, + IAgentUpdateInput, +} from '../domain/agent.types'; +import { AgentMapper } from './agent.mapper'; + +export class AgentGateway extends BaseGateway implements IAgentGateway { + private mapper = new AgentMapper(); + + findAll(): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerFindAll(); + return this.mapper.toList(unwrapEnvelope(res.data)); + }); + } + + findPublic(): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerFindPublic(); + return this.mapper.toList(unwrapEnvelope(res.data)); + }); + } + + findById(id: string): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerFindById({ path: { id } }); + return this.mapper.toEntity(unwrapEnvelope(res.data)); + }); + } + + create(input: IAgentCreateInput): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerCreate({ + body: this.mapper.toCreateDto(input), + }); + return this.mapper.toEntity(unwrapEnvelope(res.data)); + }); + } + + update(id: string, input: IAgentUpdateInput): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerUpdate({ + path: { id }, + body: this.mapper.toUpdateDto(input), + }); + return this.mapper.toEntity(unwrapEnvelope(res.data)); + }); + } + + remove(id: string): Promise { + return this.execute(async () => { + // `wipeS3` is required by the SDK type; default to non-destructive so a + // plain delete never wipes the agent's S3 data. + await AgentsService.agentControllerRemove({ + path: { id }, + query: { wipeS3: 'false' }, + }); + }); + } + + restart(id: string): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerRestart({ path: { id } }); + return this.mapper.toEntity(unwrapEnvelope(res.data)); + }); + } +} diff --git a/app/slices/agent/data/agent.mapper.ts b/app/slices/agent/data/agent.mapper.ts new file mode 100644 index 0000000..783e925 --- /dev/null +++ b/app/slices/agent/data/agent.mapper.ts @@ -0,0 +1,61 @@ +import type { + CreateAgentDto, + UpdateAgentDto, +} from '#api/data/repositories/api/types.gen'; +import type { + IAgentCreateInput, + IAgentData, + IAgentUpdateInput, +} from '../domain/agent.types'; + +/** + * Maps the agents API onto domain shapes. The endpoints are typed as `unknown` + * (no response DTO), so `toEntity` reads defensively and drops anything without + * a string `id`. Input payloads are structurally identical to the wire DTOs, so + * the `to*Dto` converters are thin — they exist to keep `#api` types out of the + * domain and to be the one place that breaks if the DTOs drift. + */ +export class AgentMapper { + toEntity(raw: unknown): IAgentData | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.id !== 'string') return null; + const resources = + o.resources && typeof o.resources === 'object' + ? (o.resources as Record) + : {}; + return { + id: o.id, + name: typeof o.name === 'string' ? o.name : '', + status: typeof o.status === 'string' ? o.status : '', + templateId: typeof o.templateId === 'string' ? o.templateId : '', + workflowId: typeof o.workflowId === 'string' ? o.workflowId : null, + config: + o.config && typeof o.config === 'object' + ? (o.config as Record) + : {}, + resources: { + cpu: typeof resources.cpu === 'string' ? resources.cpu : '', + memory: typeof resources.memory === 'string' ? resources.memory : '', + }, + isPublic: o.isPublic === true, + createdAt: typeof o.createdAt === 'string' ? o.createdAt : '', + updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : '', + }; + } + + toList(raw: unknown): IAgentData[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => this.toEntity(item)) + .filter((a): a is IAgentData => a !== null); + } + + toCreateDto(input: IAgentCreateInput): CreateAgentDto { + return { ...input }; + } + + toUpdateDto(input: IAgentUpdateInput): UpdateAgentDto { + return { ...input }; + } +} diff --git a/app/slices/agent/data/index.ts b/app/slices/agent/data/index.ts new file mode 100644 index 0000000..d6b2a9a --- /dev/null +++ b/app/slices/agent/data/index.ts @@ -0,0 +1,2 @@ +export * from './agent.gateway'; +export * from './agent.mapper'; diff --git a/app/slices/agent/domain/agent.gateway.ts b/app/slices/agent/domain/agent.gateway.ts new file mode 100644 index 0000000..c08708f --- /dev/null +++ b/app/slices/agent/domain/agent.gateway.ts @@ -0,0 +1,22 @@ +import type { + IAgentCreateInput, + IAgentData, + IAgentUpdateInput, +} from './agent.types'; + +/** + * Contract for the agents API. Implemented by `AgentGateway` in the data layer; + * the service and store depend only on this abstraction. + */ +export abstract class IAgentGateway { + abstract findAll(): Promise; + abstract findPublic(): Promise; + abstract findById(id: string): Promise; + abstract create(input: IAgentCreateInput): Promise; + abstract update( + id: string, + input: IAgentUpdateInput, + ): Promise; + abstract remove(id: string): Promise; + abstract restart(id: string): Promise; +} diff --git a/app/slices/agent/domain/agent.service.ts b/app/slices/agent/domain/agent.service.ts new file mode 100644 index 0000000..e328b59 --- /dev/null +++ b/app/slices/agent/domain/agent.service.ts @@ -0,0 +1,42 @@ +import type { IAgentGateway } from './agent.gateway'; +import type { + IAgentCreateInput, + IAgentData, + IAgentUpdateInput, +} from './agent.types'; + +/** + * Domain service for agents. Exposes the CRUD + restart use-cases; the store + * layers reactive list/current state and loading/error flags on top. + */ +export class AgentService { + constructor(private gateway: IAgentGateway) {} + + findAll(): Promise { + return this.gateway.findAll(); + } + + findPublic(): Promise { + return this.gateway.findPublic(); + } + + findById(id: string): Promise { + return this.gateway.findById(id); + } + + create(input: IAgentCreateInput): Promise { + return this.gateway.create(input); + } + + update(id: string, input: IAgentUpdateInput): Promise { + return this.gateway.update(id, input); + } + + remove(id: string): Promise { + return this.gateway.remove(id); + } + + restart(id: string): Promise { + return this.gateway.restart(id); + } +} diff --git a/app/slices/agent/domain/agent.types.ts b/app/slices/agent/domain/agent.types.ts new file mode 100644 index 0000000..18b9b26 --- /dev/null +++ b/app/slices/agent/domain/agent.types.ts @@ -0,0 +1,35 @@ +// Domain types for the agent slice. Envelope-free; the data layer maps the +// SDK's (loosely-typed `unknown`) responses onto these and converts domain +// input payloads to the wire DTOs. + +export interface IAgentData { + id: string; + name: string; + status: string; + templateId: string; + workflowId: string | null; + config: Record; + resources: { cpu: string; memory: string }; + isPublic: boolean; + createdAt: string; + updatedAt: string; +} + +/** Payload to create an agent. Mirrors the fields the app sends; the mapper + * converts it to the generated `CreateAgentDto`. */ +export interface IAgentCreateInput { + name: string; + templateId: string; + llmCredentialId?: string; + config?: Record; + resources?: { cpu: string; memory: string }; + isPublic?: boolean; + allowedOrigins?: string[]; + knowledgeIds?: string[]; + isAdmin?: boolean; +} + +/** Payload to update an agent — every create field optional, plus debug. */ +export interface IAgentUpdateInput extends Partial { + debugEnabled?: boolean; +} diff --git a/app/slices/agent/domain/index.ts b/app/slices/agent/domain/index.ts new file mode 100644 index 0000000..6e24de0 --- /dev/null +++ b/app/slices/agent/domain/index.ts @@ -0,0 +1,3 @@ +export * from './agent.gateway'; +export * from './agent.service'; +export * from './agent.types'; diff --git a/app/slices/agent/index.d.ts b/app/slices/agent/index.d.ts new file mode 100644 index 0000000..7f325cd --- /dev/null +++ b/app/slices/agent/index.d.ts @@ -0,0 +1,9 @@ +import type { AgentService } from './domain/agent.service'; + +declare module '#app' { + interface NuxtApp { + $agentService: AgentService; + } +} + +export {}; diff --git a/app/slices/agent/plugins/di.ts b/app/slices/agent/plugins/di.ts new file mode 100644 index 0000000..58abd0a --- /dev/null +++ b/app/slices/agent/plugins/di.ts @@ -0,0 +1,18 @@ +import { AgentGateway } from '../data/agent.gateway'; +import { AgentService } from '../domain/agent.service'; + +/** + * Composition root for the agent slice. Builds the service graph + * (service → gateway → mapper) once and provides it as `$agentService`. + */ +export default defineNuxtPlugin({ + name: 'agent-di', + setup() { + const service = new AgentService(new AgentGateway()); + return { + provide: { + agentService: service, + }, + }; + }, +}); diff --git a/app/slices/agent/stores/agent.ts b/app/slices/agent/stores/agent.ts index e747b4a..ecadb05 100644 --- a/app/slices/agent/stores/agent.ts +++ b/app/slices/agent/stores/agent.ts @@ -1,33 +1,20 @@ -import { - AgentsService, - type CreateAgentDto, - type UpdateAgentDto, -} from '#api'; +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import type { + AgentService, + IAgentCreateInput, + IAgentData, + IAgentUpdateInput, +} from '#agent/domain'; -export interface IAgentData { - id: string; - name: string; - status: string; - templateId: string; - workflowId: string | null; - config: Record; - resources: { cpu: string; memory: string }; - isPublic: boolean; - createdAt: string; - updatedAt: string; -} +// Re-export domain types so consumers importing them from `#agent/stores/agent` +// (landingHero AgentCard/Provider) keep working. +export type { + IAgentCreateInput, + IAgentData, + IAgentUpdateInput, +} from '#agent/domain'; -interface IEnvelope { - success?: boolean; - data?: T; -} - -function unwrap(body: unknown): T | null { - if (body && typeof body === 'object' && 'data' in (body as IEnvelope)) { - return ((body as IEnvelope).data ?? null) as T | null; - } - return (body ?? null) as T | null; -} +const getService = createServiceGetter('$agentService'); export const useAgentStore = defineStore('agent', () => { const agents = ref([]); @@ -40,9 +27,7 @@ export const useAgentStore = defineStore('agent', () => { loading.value = true; error.value = null; try { - const res = await AgentsService.agentControllerFindAll(); - const list = unwrap(res.data); - agents.value = Array.isArray(list) ? list : []; + agents.value = await getService().findAll(); } catch (err) { error.value = (err as Error).message; agents.value = []; @@ -56,9 +41,7 @@ export const useAgentStore = defineStore('agent', () => { loading.value = true; error.value = null; try { - const res = await AgentsService.agentControllerFindPublic(); - const list = unwrap(res.data); - publicAgents.value = Array.isArray(list) ? list : []; + publicAgents.value = await getService().findPublic(); } catch (err) { error.value = (err as Error).message; publicAgents.value = []; @@ -72,8 +55,7 @@ export const useAgentStore = defineStore('agent', () => { loading.value = true; error.value = null; try { - const res = await AgentsService.agentControllerFindById({ path: { id } }); - current.value = unwrap(res.data); + current.value = await getService().findById(id); } catch (err) { error.value = (err as Error).message; current.value = null; @@ -83,19 +65,14 @@ export const useAgentStore = defineStore('agent', () => { return current.value; } - async function create(body: CreateAgentDto) { - const res = await AgentsService.agentControllerCreate({ body }); - const created = unwrap(res.data); + async function create(input: IAgentCreateInput) { + const created = await getService().create(input); if (created) agents.value.push(created); return created; } - async function update(id: string, body: UpdateAgentDto) { - const res = await AgentsService.agentControllerUpdate({ - path: { id }, - body, - }); - const updated = unwrap(res.data); + async function update(id: string, input: IAgentUpdateInput) { + const updated = await getService().update(id, input); if (updated) { const idx = agents.value.findIndex((a) => a.id === id); if (idx >= 0) agents.value[idx] = updated; @@ -104,13 +81,12 @@ export const useAgentStore = defineStore('agent', () => { } async function remove(id: string) { - await AgentsService.agentControllerRemove({ path: { id } }); + await getService().remove(id); agents.value = agents.value.filter((a) => a.id !== id); } async function restart(id: string) { - const res = await AgentsService.agentControllerRestart({ path: { id } }); - const updated = unwrap(res.data); + const updated = await getService().restart(id); if (updated) { const idx = agents.value.findIndex((a) => a.id === id); if (idx >= 0) agents.value[idx] = updated; diff --git a/app/slices/user/auth/data/auth.gateway.ts b/app/slices/user/auth/data/auth.gateway.ts new file mode 100644 index 0000000..d8c33ec --- /dev/null +++ b/app/slices/user/auth/data/auth.gateway.ts @@ -0,0 +1,41 @@ +// The generated SDK class is also named `AuthService`; alias it to `AuthApi` +// so it doesn't collide with the domain service of the same name. +import { AuthService as AuthApi } from '#api'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; +import { IAuthGateway } from '../domain/auth.gateway'; +import type { IAuthSession, IAuthUser } from '../domain/auth.types'; +import { AuthMapper } from './auth.mapper'; + +export class AuthGateway extends BaseGateway implements IAuthGateway { + private mapper = new AuthMapper(); + + me(): Promise { + return this.execute(async () => { + const res = await AuthApi.authControllerMe(); + return this.mapper.toUser(unwrapEnvelope(res.data)); + }); + } + + login(email: string, password: string): Promise { + return this.execute(async () => { + const res = await AuthApi.authControllerLogin({ + body: { email, password }, + }); + return this.mapper.toSession(unwrapEnvelope(res.data)); + }); + } + + register( + name: string, + email: string, + password: string, + ): Promise { + return this.execute(async () => { + const res = await AuthApi.authControllerRegister({ + body: { name, email, password }, + }); + return this.mapper.toSession(unwrapEnvelope(res.data)); + }); + } +} diff --git a/app/slices/user/auth/data/auth.mapper.ts b/app/slices/user/auth/data/auth.mapper.ts new file mode 100644 index 0000000..f3ce060 --- /dev/null +++ b/app/slices/user/auth/data/auth.mapper.ts @@ -0,0 +1,51 @@ +import { + UserRoleTypes, + type IAuthSession, + type IAuthUser, +} from '../domain/auth.types'; + +const EMPTY_USER: IAuthUser = { + id: '', + name: '', + email: '', + roles: [], + status: '', +}; + +const VALID_ROLES = new Set(Object.values(UserRoleTypes)); + +/** + * Maps the (untyped) auth responses onto domain shapes. Reads defensively and + * keeps only recognized roles so an unexpected backend value can't crash the + * role checks. + */ +export class AuthMapper { + toUser(raw: unknown): IAuthUser | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.id !== 'string') return null; + return { + id: o.id, + name: typeof o.name === 'string' ? o.name : '', + email: typeof o.email === 'string' ? o.email : '', + roles: this.toRoles(o.roles), + status: typeof o.status === 'string' ? o.status : '', + }; + } + + toSession(raw: unknown): IAuthSession { + const o = + raw && typeof raw === 'object' ? (raw as Record) : {}; + return { + accessToken: typeof o.accessToken === 'string' ? o.accessToken : '', + user: this.toUser(o.user) ?? { ...EMPTY_USER }, + }; + } + + private toRoles(raw: unknown): UserRoleTypes[] { + if (!Array.isArray(raw)) return []; + return raw.filter( + (r): r is UserRoleTypes => typeof r === 'string' && VALID_ROLES.has(r), + ); + } +} diff --git a/app/slices/user/auth/data/index.ts b/app/slices/user/auth/data/index.ts new file mode 100644 index 0000000..f9b4218 --- /dev/null +++ b/app/slices/user/auth/data/index.ts @@ -0,0 +1,2 @@ +export * from './auth.gateway'; +export * from './auth.mapper'; diff --git a/app/slices/user/auth/domain/auth.gateway.ts b/app/slices/user/auth/domain/auth.gateway.ts new file mode 100644 index 0000000..c800dd0 --- /dev/null +++ b/app/slices/user/auth/domain/auth.gateway.ts @@ -0,0 +1,16 @@ +import type { IAuthSession, IAuthUser } from './auth.types'; + +/** + * Contract for the auth API. Implemented by `AuthGateway` in the data layer. + * Only the pure network round-trips live here — token persistence and the + * bearer-header wiring stay in the store. + */ +export abstract class IAuthGateway { + abstract me(): Promise; + abstract login(email: string, password: string): Promise; + abstract register( + name: string, + email: string, + password: string, + ): Promise; +} diff --git a/app/slices/user/auth/domain/auth.service.ts b/app/slices/user/auth/domain/auth.service.ts new file mode 100644 index 0000000..24971d5 --- /dev/null +++ b/app/slices/user/auth/domain/auth.service.ts @@ -0,0 +1,28 @@ +import type { IAuthGateway } from './auth.gateway'; +import type { IAuthSession, IAuthUser } from './auth.types'; + +/** + * Domain service for auth. Exposes me/login/register; the store layers token + * storage, the bearer-header side effect, and hydration on top. Named after the + * slice — the generated `#api` `AuthService` is imported under an alias in the + * data gateway to avoid the collision. + */ +export class AuthService { + constructor(private gateway: IAuthGateway) {} + + me(): Promise { + return this.gateway.me(); + } + + login(email: string, password: string): Promise { + return this.gateway.login(email, password); + } + + register( + name: string, + email: string, + password: string, + ): Promise { + return this.gateway.register(name, email, password); + } +} diff --git a/app/slices/user/auth/domain/auth.types.ts b/app/slices/user/auth/domain/auth.types.ts new file mode 100644 index 0000000..cef4a42 --- /dev/null +++ b/app/slices/user/auth/domain/auth.types.ts @@ -0,0 +1,22 @@ +// Domain types for the auth slice. The data layer maps the (loosely-typed +// `unknown`) auth responses onto these; the store owns token/session state. + +export enum UserRoleTypes { + Owner = 'Owner', + Admin = 'Admin', + User = 'User', +} + +export interface IAuthUser { + id: string; + name: string; + email: string; + roles: UserRoleTypes[]; + status: string; +} + +/** A successful login/register result: bearer token + the authenticated user. */ +export interface IAuthSession { + accessToken: string; + user: IAuthUser; +} diff --git a/app/slices/user/auth/domain/index.ts b/app/slices/user/auth/domain/index.ts new file mode 100644 index 0000000..0de4e60 --- /dev/null +++ b/app/slices/user/auth/domain/index.ts @@ -0,0 +1,3 @@ +export * from './auth.gateway'; +export * from './auth.service'; +export * from './auth.types'; diff --git a/app/slices/user/auth/index.d.ts b/app/slices/user/auth/index.d.ts new file mode 100644 index 0000000..5d95b91 --- /dev/null +++ b/app/slices/user/auth/index.d.ts @@ -0,0 +1,9 @@ +import type { AuthService } from './domain/auth.service'; + +declare module '#app' { + interface NuxtApp { + $authService: AuthService; + } +} + +export {}; diff --git a/app/slices/user/auth/plugins/auth.ts b/app/slices/user/auth/plugins/auth.ts index 3bb8f1c..4603c97 100644 --- a/app/slices/user/auth/plugins/auth.ts +++ b/app/slices/user/auth/plugins/auth.ts @@ -6,7 +6,8 @@ export default defineNuxtPlugin({ name: 'auth-init', parallel: false, - dependsOn: ['api-base-url'], + // `auth-di` must provide $authService before init() → me() runs here. + dependsOn: ['api-base-url', 'auth-di'], async setup() { if (!import.meta.client) return; const authStore = useAuthStore(); diff --git a/app/slices/user/auth/plugins/di.ts b/app/slices/user/auth/plugins/di.ts new file mode 100644 index 0000000..6ad2fe5 --- /dev/null +++ b/app/slices/user/auth/plugins/di.ts @@ -0,0 +1,20 @@ +import { AuthGateway } from '../data/auth.gateway'; +import { AuthService } from '../domain/auth.service'; + +/** + * Composition root for the auth slice. Builds the service graph + * (service → gateway → mapper) and provides it as `$authService`. Must run + * before the `auth-init` plugin, which calls the store's `init()` → `me()` at + * setup time (see auth-init's `dependsOn`). + */ +export default defineNuxtPlugin({ + name: 'auth-di', + setup() { + const service = new AuthService(new AuthGateway()); + return { + provide: { + authService: service, + }, + }; + }, +}); diff --git a/app/slices/user/auth/stores/auth.ts b/app/slices/user/auth/stores/auth.ts index fbd9857..69d86ec 100644 --- a/app/slices/user/auth/stores/auth.ts +++ b/app/slices/user/auth/stores/auth.ts @@ -1,21 +1,16 @@ -import { AuthService } from '#api/data'; +import { createServiceGetter } from '#common/composables/createServiceGetter'; import { handleApiAuthentication } from '#api/utils/handleApiAuthentication'; +import { UserRoleTypes } from '#auth/domain'; +import type { AuthService, IAuthUser } from '#auth/domain'; -type ApiEnvelope = { success: boolean; data: T }; +// Re-export the domain enum/types so consumers that import them from +// `#auth/stores/auth` — and the auto-import (imports.dirs) that exposes +// `UserRoleTypes` globally — keep working. `UserRoleTypes` is used as a runtime +// value, so it's a value re-export. +export { UserRoleTypes } from '#auth/domain'; +export type { IAuthUser, IAuthSession } from '#auth/domain'; -export enum UserRoleTypes { - Owner = 'Owner', - Admin = 'Admin', - User = 'User', -} - -export interface IAuthUser { - id: string; - name: string; - email: string; - roles: UserRoleTypes[]; - status: string; -} +const getService = createServiceGetter('$authService'); const TOKEN_STORAGE_KEY = 'ranch.access_token'; @@ -59,36 +54,22 @@ export const useAuthStore = defineStore('auth', () => { } async function fetchMe() { - const res = await AuthService.authControllerMe(); - const env = res.data as ApiEnvelope | undefined; - user.value = env?.data ?? null; + user.value = await getService().me(); return user.value; } async function login(email: string, password: string) { - const res = await AuthService.authControllerLogin({ - body: { email, password }, - }); - const env = res.data as ApiEnvelope<{ - accessToken: string; - user: IAuthUser; - }>; - applyToken(env.data.accessToken); - user.value = env.data.user; - return env.data; + const session = await getService().login(email, password); + applyToken(session.accessToken); + user.value = session.user; + return session; } async function register(name: string, email: string, password: string) { - const res = await AuthService.authControllerRegister({ - body: { name, email, password }, - }); - const env = res.data as ApiEnvelope<{ - accessToken: string; - user: IAuthUser; - }>; - applyToken(env.data.accessToken); - user.value = env.data.user; - return env.data; + const session = await getService().register(name, email, password); + applyToken(session.accessToken); + user.value = session.user; + return session; } function logout() { From 37661cea9a1aca7c3671a4986d5d665cb8c8b2eb Mon Sep 17 00:00:00 2001 From: maksym Date: Tue, 21 Jul 2026 11:59:57 +0200 Subject: [PATCH 04/32] feat(error): implement error handling architecture with domain and composable layers - Introduced a new error handling structure, including `ErrorEntity`, `ErrorMapper`, and `handleError` service for managing application errors. - Added utility functions for error handling and toast notifications. - Created composables for using error states in Vue components. - Updated Nuxt configuration to support the new error slice. - Established a clear separation of concerns between error data, domain logic, and UI components. This update enhances the application's error management capabilities, providing a more robust and user-friendly experience. --- app/slices/setup/error/composable/index.ts | 1 + app/slices/setup/error/composable/useError.ts | 17 ++++ app/slices/setup/error/data/error.mapper.ts | 83 +++++++++++++++++++ app/slices/setup/error/data/index.ts | 1 + app/slices/setup/error/domain/error.entity.ts | 29 +++++++ .../setup/error/domain/error.service.ts | 57 +++++++++++++ app/slices/setup/error/domain/error.types.ts | 47 +++++++++++ .../setup/error/domain/errors/global.error.ts | 13 +++ app/slices/setup/error/domain/errors/index.ts | 1 + app/slices/setup/error/domain/index.ts | 5 ++ app/slices/setup/error/index.ts | 4 + app/slices/setup/error/nuxt.config.ts | 6 +- .../setup/error/plugins/error.global.ts | 13 +++ app/slices/setup/error/utils/error-handle.ts | 27 ++++++ app/slices/setup/error/utils/index.ts | 1 + .../theme/components/{ => ui}/badge/Badge.vue | 0 .../theme/components/{ => ui}/badge/index.ts | 0 app/slices/user/auth/data/auth.gateway.ts | 14 +++- app/slices/user/auth/data/authError.mapper.ts | 67 +++++++++++++++ app/slices/user/auth/data/index.ts | 1 + .../domain/errors/badCredentials.error.ts | 13 +++ .../user/auth/domain/errors/error.types.ts | 6 ++ .../auth/domain/errors/forbidden.error.ts | 13 +++ app/slices/user/auth/domain/errors/index.ts | 5 ++ .../domain/errors/tooManyAttempts.error.ts | 13 +++ .../user/auth/domain/errors/unknown.error.ts | 13 +++ 26 files changed, 446 insertions(+), 4 deletions(-) create mode 100644 app/slices/setup/error/composable/index.ts create mode 100644 app/slices/setup/error/composable/useError.ts create mode 100644 app/slices/setup/error/data/error.mapper.ts create mode 100644 app/slices/setup/error/data/index.ts create mode 100644 app/slices/setup/error/domain/error.entity.ts create mode 100644 app/slices/setup/error/domain/error.service.ts create mode 100644 app/slices/setup/error/domain/error.types.ts create mode 100644 app/slices/setup/error/domain/errors/global.error.ts create mode 100644 app/slices/setup/error/domain/errors/index.ts create mode 100644 app/slices/setup/error/domain/index.ts create mode 100644 app/slices/setup/error/index.ts create mode 100644 app/slices/setup/error/plugins/error.global.ts create mode 100644 app/slices/setup/error/utils/error-handle.ts create mode 100644 app/slices/setup/error/utils/index.ts rename app/slices/setup/theme/components/{ => ui}/badge/Badge.vue (100%) rename app/slices/setup/theme/components/{ => ui}/badge/index.ts (100%) create mode 100644 app/slices/user/auth/data/authError.mapper.ts create mode 100644 app/slices/user/auth/domain/errors/badCredentials.error.ts create mode 100644 app/slices/user/auth/domain/errors/error.types.ts create mode 100644 app/slices/user/auth/domain/errors/forbidden.error.ts create mode 100644 app/slices/user/auth/domain/errors/index.ts create mode 100644 app/slices/user/auth/domain/errors/tooManyAttempts.error.ts create mode 100644 app/slices/user/auth/domain/errors/unknown.error.ts diff --git a/app/slices/setup/error/composable/index.ts b/app/slices/setup/error/composable/index.ts new file mode 100644 index 0000000..bd12f73 --- /dev/null +++ b/app/slices/setup/error/composable/index.ts @@ -0,0 +1 @@ +export * from "./useError"; diff --git a/app/slices/setup/error/composable/useError.ts b/app/slices/setup/error/composable/useError.ts new file mode 100644 index 0000000..93a8502 --- /dev/null +++ b/app/slices/setup/error/composable/useError.ts @@ -0,0 +1,17 @@ +// composables/useAppError.ts +import { ref } from "vue"; +import { ErrorEntity } from "../domain/error.entity"; + +const error = ref(null); + +export function useAppError() { + const show = (err: ErrorEntity) => { + error.value = err; //reactive for watch in component + }; + + const clear = () => { + error.value = null; + }; + + return { error, show, clear }; +} diff --git a/app/slices/setup/error/data/error.mapper.ts b/app/slices/setup/error/data/error.mapper.ts new file mode 100644 index 0000000..04aef95 --- /dev/null +++ b/app/slices/setup/error/data/error.mapper.ts @@ -0,0 +1,83 @@ +import { ErrorEntity } from "../domain/error.entity"; +import { ErrorCodes } from "../domain"; +import type { ErrorDetailResponse, PlatformStatusResponse } from "../domain/error.types"; + +type ErrorResponseBody = ErrorDetailResponse & PlatformStatusResponse; + +export class ErrorMapper { + toErrorEntity(error: unknown): ErrorEntity { + const fetchError = error as { response?: { status?: number; _data?: ErrorResponseBody }; statusCode?: number; data?: ErrorResponseBody }; + const status = fetchError.response?.status ?? fetchError.statusCode ?? 500; + const responseData = fetchError.response?._data ?? fetchError.data; + + // Platform status (6000 geo-block / 6001 maintenance): every boot request + // fails with the same body, and the api.config onResponse interceptor + // already handles the redirect — surface a silent entity (no toast). + if (responseData?.error === 6000 || responseData?.error === 6001) { + return new ErrorEntity(responseData.message || "platform_status", { + statusCode: status, + isToast: false, + name: "platform_status", + code: responseData.error === 6000 ? ErrorCodes.GEO_BLOCKED : ErrorCodes.MAINTENANCE, + }); + } + + const { text: detail, fromStructuredDetail } = this.extractDetail(responseData?.detail); + const errorCode = detail ? detail : "UNEXPECTED_ERROR"; + const name = this.toGetErrorName(errorCode); + const message = detail + ? fromStructuredDetail + ? detail + : this.toGetDetail(detail) + : name; + + return new ErrorEntity(message, { + statusCode: status, + name, + isToast: true, + }); + } + + private extractDetail(detail: ErrorDetailResponse["detail"]): { + text: string; + fromStructuredDetail: boolean; + } { + if (!detail) return { text: "", fromStructuredDetail: false }; + if (typeof detail === "string") { + return { text: detail, fromStructuredDetail: false }; + } + if (Array.isArray(detail)) { + return { + text: detail + .map((d) => { + const field = d.loc?.filter((l) => l !== "body").join(".") ?? ""; + const msg = d.msg ?? d.ctx?.error ?? ""; + return field ? `${field}: ${msg}` : msg; + }) + .filter(Boolean) + .join(" | "), + fromStructuredDetail: true, + }; + } + return { + text: detail.msg ?? detail.ctx?.error ?? "", + fromStructuredDetail: true, + }; + } + + private toGetErrorName(error: string): string { + const errorTypeMap: Record = { + UNEXPECTED_ERROR: ErrorCodes.UNEXPECTED_ERROR, + UNAUTHORIZED: ErrorCodes.UNAUTHORIZED, + FORBIDDEN: ErrorCodes.FORBIDDEN, + NOT_FOUND: ErrorCodes.NOT_FOUND, + BAD_REQUEST: ErrorCodes.BAD_REQUEST, + ERR_NETWORK: ErrorCodes.ERR_NETWORK, + }; + return (errorTypeMap[error.toUpperCase()] || ErrorCodes.UNEXPECTED_ERROR).toLowerCase(); + } + + private toGetDetail(detail: string): string { + return detail.toLowerCase().replace(/\s+/g, "_"); + } +} diff --git a/app/slices/setup/error/data/index.ts b/app/slices/setup/error/data/index.ts new file mode 100644 index 0000000..b9aff69 --- /dev/null +++ b/app/slices/setup/error/data/index.ts @@ -0,0 +1 @@ +export * from "./error.mapper"; diff --git a/app/slices/setup/error/domain/error.entity.ts b/app/slices/setup/error/domain/error.entity.ts new file mode 100644 index 0000000..268b6c0 --- /dev/null +++ b/app/slices/setup/error/domain/error.entity.ts @@ -0,0 +1,29 @@ +export class ErrorEntity extends Error { + public statusCode: number = 500; + public isToast = false; + public title: string = ""; + public description: string = ""; + public code: string = ""; + + constructor( + message?: string, + options?: { + statusCode?: number; + isToast?: boolean; + name?: string; + code?: string; + } + ) { + super(message); + this.title = `${message}_title`; + this.description = `${message}_description`; + this.statusCode = options?.statusCode ?? 500; + this.isToast = options?.isToast ?? false; + this.name = options?.name ?? this.constructor.name; + this.code = options?.code ?? ""; + } + + getStatus() { + return this.statusCode; + } +} diff --git a/app/slices/setup/error/domain/error.service.ts b/app/slices/setup/error/domain/error.service.ts new file mode 100644 index 0000000..d3e7496 --- /dev/null +++ b/app/slices/setup/error/domain/error.service.ts @@ -0,0 +1,57 @@ +// setup/domain/service/errorHandler.ts +import { ErrorEntity } from "#error"; +import { handleAppError } from "../utils/error-handle"; +import { useAppError } from "../composable/useError"; +import { ErrorCodes } from "./error.types"; + +export async function handleError(err: unknown) { + // Geo-block (6000) / maintenance (6001): the api.config onResponse + // interceptor already navigates to /blocked or /maintenance, and the whole + // parallel boot wave fails with the same body — no toast, no error state, + // no Sentry noise. + if ( + err instanceof ErrorEntity && + (err.code === ErrorCodes.GEO_BLOCKED || err.code === ErrorCodes.MAINTENANCE) + ) { + return; + } + + const { show, clear } = useAppError(); + const localePath = useLocalePath(); + clear(); + + if (err instanceof ErrorEntity) { + show(err); + + if (err.isToast) { + handleAppError(err); + } + + if (err.statusCode === 401 && err.code === ErrorCodes.UNAUTHORIZED) { + navigateTo(localePath("/login")); + return; + } + + if (import.meta.dev) { + switch (err.statusCode) { + case 401: + console.warn(`[DEV] Unauthorized: ${err.statusCode} ${err.message}`); + break; + case 403: + console.error(`[DEV] Access Forbidden: ${err.statusCode}`, err.message); + break; + case 404: + console.log(`[DEV] Resource not found: ${err.statusCode}`, err.message); + break; + case 500: + console.error(`[DEV] Server Error: ${err.statusCode}`, err.message); + break; + default: + console.error(`[DEV] Unhandled Status Code: ${err.statusCode}`, err.message); + } + } + } else if (import.meta.dev) { + console.error(`[DEV] No Error Entity: ${err}`); + } + +} diff --git a/app/slices/setup/error/domain/error.types.ts b/app/slices/setup/error/domain/error.types.ts new file mode 100644 index 0000000..bd8b4f4 --- /dev/null +++ b/app/slices/setup/error/domain/error.types.ts @@ -0,0 +1,47 @@ +export interface ErrorTypes { + UNEXPECTED_ERROR: string; + UNAUTHORIZED: string; + FORBIDDEN: string; + NOT_FOUND: string; + BAD_REQUEST: string; + INTERNAL_SERVER_ERROR: string; +} +export interface IErrorData { + title: string; + description: string; + code: string; + statusCode: number; + isToast: boolean; +} +export enum ErrorCodes { + UNEXPECTED_ERROR = "UNEXPECTED_ERROR", + UNAUTHORIZED = "UNAUTHORIZED", + FORBIDDEN = "FORBIDDEN", + NOT_FOUND = "NOT_FOUND", + BAD_REQUEST = "BAD_REQUEST", + ERR_NETWORK = "ERR_NETWORK", + GEO_BLOCKED = "GEO_BLOCKED", + MAINTENANCE = "MAINTENANCE", +} +type ErrorDetail = { + type?: string; + msg?: string; + loc?: string[]; + input?: unknown; + ctx?: { + error: string; + }; +} +export type ErrorDetailResponse = { + detail?: string | ErrorDetail | ErrorDetail[]; +} +/** + * Platform status body (may arrive with any HTTP status): + * `{ error: 6000, message: "Forbidden geo", redirect_url: "…/blocked" }`. + * 6000 = geo-block, 6001 = maintenance. + */ +export type PlatformStatusResponse = { + error?: number; + message?: string; + redirect_url?: string; +} diff --git a/app/slices/setup/error/domain/errors/global.error.ts b/app/slices/setup/error/domain/errors/global.error.ts new file mode 100644 index 0000000..1a5bacc --- /dev/null +++ b/app/slices/setup/error/domain/errors/global.error.ts @@ -0,0 +1,13 @@ +import { ErrorEntity } from "../error.entity"; + +export class GlobalError extends ErrorEntity { + constructor( + message: string, + options: { statusCode: number; name: string; isToast: boolean } + ) { + super(message); + this.name = options.name; + this.statusCode = options.statusCode; + this.isToast = options.isToast; + } +} diff --git a/app/slices/setup/error/domain/errors/index.ts b/app/slices/setup/error/domain/errors/index.ts new file mode 100644 index 0000000..0ab4e8f --- /dev/null +++ b/app/slices/setup/error/domain/errors/index.ts @@ -0,0 +1 @@ +export * from "./global.error"; diff --git a/app/slices/setup/error/domain/index.ts b/app/slices/setup/error/domain/index.ts new file mode 100644 index 0000000..906b4fe --- /dev/null +++ b/app/slices/setup/error/domain/index.ts @@ -0,0 +1,5 @@ +export * from "./error.entity"; +export * from "./error.types"; +// Note: error.service is NOT exported here to avoid circular dependency +// Import it directly: import { handleError } from "#error/domain/error.service" +export * as GlobalError from "./errors"; diff --git a/app/slices/setup/error/index.ts b/app/slices/setup/error/index.ts new file mode 100644 index 0000000..9cb4f8c --- /dev/null +++ b/app/slices/setup/error/index.ts @@ -0,0 +1,4 @@ +export * from "./utils"; +export * from "./domain"; +export * from "./composable"; +export * from "./data"; diff --git a/app/slices/setup/error/nuxt.config.ts b/app/slices/setup/error/nuxt.config.ts index b71b455..e2b59dc 100644 --- a/app/slices/setup/error/nuxt.config.ts +++ b/app/slices/setup/error/nuxt.config.ts @@ -1,10 +1,10 @@ -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { fileURLToPath } from "url"; +import { dirname } from "path"; const currentDir = dirname(fileURLToPath(import.meta.url)); export default defineNuxtConfig({ alias: { - '#error': currentDir, + "#error": currentDir, }, }); diff --git a/app/slices/setup/error/plugins/error.global.ts b/app/slices/setup/error/plugins/error.global.ts new file mode 100644 index 0000000..86477a7 --- /dev/null +++ b/app/slices/setup/error/plugins/error.global.ts @@ -0,0 +1,13 @@ +import { handleError } from "../domain/error.service"; +export default defineNuxtPlugin((nuxtApp) => { + // Vue error handler + nuxtApp.vueApp.config.errorHandler = (error) => { + console.error("Vue errorHandler:", error); + handleError(error); + }; + + // Vue error hook + //nuxtApp.hook("vue:error", (error, instance, info) => { + // console.error("Vue error hook:", error); + //}); +}); diff --git a/app/slices/setup/error/utils/error-handle.ts b/app/slices/setup/error/utils/error-handle.ts new file mode 100644 index 0000000..1d2a396 --- /dev/null +++ b/app/slices/setup/error/utils/error-handle.ts @@ -0,0 +1,27 @@ +import { toast } from "vue-sonner"; +import { ErrorEntity } from "../domain/error.entity"; + +// Error titles/descriptions are i18n keys generated at runtime (e.g. +// "unexpected_error_title") — translate before showing, otherwise the raw key +// leaks into the toast. Falls back to the key itself when no translation +// exists or when called outside the Nuxt context. +const translate = (key: string): string => { + try { + const { $i18n } = useNuxtApp(); + return $i18n.te(key) ? $i18n.t(key) : key; + } catch { + return key; + } +}; + +export const handleAppError = ( + error: ErrorEntity, + showToast = true +): string => { + if (showToast) { + toast.error(translate(error.title), { + description: translate(error.description), + }); + } + return error.description; +}; diff --git a/app/slices/setup/error/utils/index.ts b/app/slices/setup/error/utils/index.ts new file mode 100644 index 0000000..2a2048b --- /dev/null +++ b/app/slices/setup/error/utils/index.ts @@ -0,0 +1 @@ +export * from "./error-handle"; diff --git a/app/slices/setup/theme/components/badge/Badge.vue b/app/slices/setup/theme/components/ui/badge/Badge.vue similarity index 100% rename from app/slices/setup/theme/components/badge/Badge.vue rename to app/slices/setup/theme/components/ui/badge/Badge.vue diff --git a/app/slices/setup/theme/components/badge/index.ts b/app/slices/setup/theme/components/ui/badge/index.ts similarity index 100% rename from app/slices/setup/theme/components/badge/index.ts rename to app/slices/setup/theme/components/ui/badge/index.ts diff --git a/app/slices/user/auth/data/auth.gateway.ts b/app/slices/user/auth/data/auth.gateway.ts index d8c33ec..4db16c2 100644 --- a/app/slices/user/auth/data/auth.gateway.ts +++ b/app/slices/user/auth/data/auth.gateway.ts @@ -5,14 +5,24 @@ import { BaseGateway } from '#common/data/BaseGateway'; import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; import { IAuthGateway } from '../domain/auth.gateway'; import type { IAuthSession, IAuthUser } from '../domain/auth.types'; +import { AuthErrorMapper } from './authError.mapper'; import { AuthMapper } from './auth.mapper'; export class AuthGateway extends BaseGateway implements IAuthGateway { private mapper = new AuthMapper(); + constructor() { + // Route raw axios failures through the auth error mapper so callers get a + // typed domain error with a user-facing message instead of a silent + // empty session. + super(new AuthErrorMapper()); + } + + // `throwOnError: true` — the axios client returns the error object instead of + // throwing by default; opt in so `execute()`'s catch fires and maps it. me(): Promise { return this.execute(async () => { - const res = await AuthApi.authControllerMe(); + const res = await AuthApi.authControllerMe({ throwOnError: true }); return this.mapper.toUser(unwrapEnvelope(res.data)); }); } @@ -21,6 +31,7 @@ export class AuthGateway extends BaseGateway implements IAuthGateway { return this.execute(async () => { const res = await AuthApi.authControllerLogin({ body: { email, password }, + throwOnError: true, }); return this.mapper.toSession(unwrapEnvelope(res.data)); }); @@ -34,6 +45,7 @@ export class AuthGateway extends BaseGateway implements IAuthGateway { return this.execute(async () => { const res = await AuthApi.authControllerRegister({ body: { name, email, password }, + throwOnError: true, }); return this.mapper.toSession(unwrapEnvelope(res.data)); }); diff --git a/app/slices/user/auth/data/authError.mapper.ts b/app/slices/user/auth/data/authError.mapper.ts new file mode 100644 index 0000000..b575f57 --- /dev/null +++ b/app/slices/user/auth/data/authError.mapper.ts @@ -0,0 +1,67 @@ +import type { IErrorMapper } from '#common/data/BaseGateway'; +import type { ErrorEntity } from '#error/domain/error.entity'; +import { BadCredentialsError } from '../domain/errors/badCredentials.error'; +import { ForbiddenError } from '../domain/errors/forbidden.error'; +import { TooManyAttemptsError } from '../domain/errors/tooManyAttempts.error'; +import { UnknownAuthError } from '../domain/errors/unknown.error'; + +interface AxiosLikeError { + code?: string; + message?: string; + response?: { status?: number; data?: unknown }; + /** `@hey-api/client-axios` copies `response.data` here on a non-throwing error. */ + error?: unknown; +} + +/** + * Translates a raw auth API failure into a typed domain error with a + * user-facing message. The `#api` client is axios-based, so error bodies live + * on `error.response.data` (main-site's ofetch client uses `_data` instead). + * + * Wired into `AuthGateway` via `BaseGateway`'s constructor — every gateway + * method that throws is funneled through here. + */ +export class AuthErrorMapper implements IErrorMapper { + toErrorEntity(error: unknown): ErrorEntity { + const e = (error ?? {}) as AxiosLikeError; + const status = e.response?.status ?? 0; + const serverMessage = pickMessage(e.response?.data ?? e.error); + + if (e.code === 'ERR_NETWORK' || status === 0) { + return new UnknownAuthError( + 'Network error — check your connection and try again.', + { statusCode: 0 }, + ); + } + if (status === 401) { + return new BadCredentialsError('Incorrect email or password.'); + } + if (status === 429) { + return new TooManyAttemptsError( + 'Too many attempts. Please wait a moment and try again.', + ); + } + if (status === 403) { + return new ForbiddenError( + serverMessage ?? 'You don’t have access to do that.', + ); + } + return new UnknownAuthError( + serverMessage ?? 'Something went wrong. Please try again.', + { statusCode: status || 500 }, + ); + } +} + +/** Pull a human message out of the API error body (`message` / `detail` / `error`). */ +function pickMessage(body: unknown): string | null { + if (!body || typeof body !== 'object') return null; + const b = body as Record; + const raw = b.message ?? b.detail ?? b.error; + if (typeof raw === 'string') return raw; + if (Array.isArray(raw)) { + const parts = raw.filter((x): x is string => typeof x === 'string'); + return parts.length ? parts.join(' ') : null; + } + return null; +} diff --git a/app/slices/user/auth/data/index.ts b/app/slices/user/auth/data/index.ts index f9b4218..f8bcd0a 100644 --- a/app/slices/user/auth/data/index.ts +++ b/app/slices/user/auth/data/index.ts @@ -1,2 +1,3 @@ export * from './auth.gateway'; export * from './auth.mapper'; +export * from './authError.mapper'; diff --git a/app/slices/user/auth/domain/errors/badCredentials.error.ts b/app/slices/user/auth/domain/errors/badCredentials.error.ts new file mode 100644 index 0000000..1d0fcc7 --- /dev/null +++ b/app/slices/user/auth/domain/errors/badCredentials.error.ts @@ -0,0 +1,13 @@ +import { ErrorEntity } from '#error/domain/error.entity'; +import { AuthErrorType } from './error.types'; + +/** 401 on login/register — wrong email or password. */ +export class BadCredentialsError extends ErrorEntity { + constructor(message: string, options?: { statusCode?: number; isToast?: boolean }) { + super(message, { + statusCode: options?.statusCode ?? 401, + isToast: options?.isToast ?? false, + name: AuthErrorType.BAD_CREDENTIALS, + }); + } +} diff --git a/app/slices/user/auth/domain/errors/error.types.ts b/app/slices/user/auth/domain/errors/error.types.ts new file mode 100644 index 0000000..45f2db9 --- /dev/null +++ b/app/slices/user/auth/domain/errors/error.types.ts @@ -0,0 +1,6 @@ +export enum AuthErrorType { + BAD_CREDENTIALS = 'bad_credentials', + TOO_MANY_ATTEMPTS = 'too_many_attempts', + FORBIDDEN = 'forbidden', + UNKNOWN_AUTH_ERROR = 'unknown_auth_error', +} diff --git a/app/slices/user/auth/domain/errors/forbidden.error.ts b/app/slices/user/auth/domain/errors/forbidden.error.ts new file mode 100644 index 0000000..2814aab --- /dev/null +++ b/app/slices/user/auth/domain/errors/forbidden.error.ts @@ -0,0 +1,13 @@ +import { ErrorEntity } from '#error/domain/error.entity'; +import { AuthErrorType } from './error.types'; + +/** 403 — action not allowed (e.g. self-service registration disabled). */ +export class ForbiddenError extends ErrorEntity { + constructor(message: string, options?: { statusCode?: number; isToast?: boolean }) { + super(message, { + statusCode: options?.statusCode ?? 403, + isToast: options?.isToast ?? false, + name: AuthErrorType.FORBIDDEN, + }); + } +} diff --git a/app/slices/user/auth/domain/errors/index.ts b/app/slices/user/auth/domain/errors/index.ts new file mode 100644 index 0000000..a810b80 --- /dev/null +++ b/app/slices/user/auth/domain/errors/index.ts @@ -0,0 +1,5 @@ +export * from './error.types'; +export * from './badCredentials.error'; +export * from './tooManyAttempts.error'; +export * from './forbidden.error'; +export * from './unknown.error'; diff --git a/app/slices/user/auth/domain/errors/tooManyAttempts.error.ts b/app/slices/user/auth/domain/errors/tooManyAttempts.error.ts new file mode 100644 index 0000000..1f09489 --- /dev/null +++ b/app/slices/user/auth/domain/errors/tooManyAttempts.error.ts @@ -0,0 +1,13 @@ +import { ErrorEntity } from '#error/domain/error.entity'; +import { AuthErrorType } from './error.types'; + +/** 429 — rate-limited after too many failed attempts. */ +export class TooManyAttemptsError extends ErrorEntity { + constructor(message: string, options?: { statusCode?: number; isToast?: boolean }) { + super(message, { + statusCode: options?.statusCode ?? 429, + isToast: options?.isToast ?? false, + name: AuthErrorType.TOO_MANY_ATTEMPTS, + }); + } +} diff --git a/app/slices/user/auth/domain/errors/unknown.error.ts b/app/slices/user/auth/domain/errors/unknown.error.ts new file mode 100644 index 0000000..880904d --- /dev/null +++ b/app/slices/user/auth/domain/errors/unknown.error.ts @@ -0,0 +1,13 @@ +import { ErrorEntity } from '#error/domain/error.entity'; +import { AuthErrorType } from './error.types'; + +/** Fallback — network failures and unrecognized server errors. */ +export class UnknownAuthError extends ErrorEntity { + constructor(message: string, options?: { statusCode?: number; isToast?: boolean }) { + super(message, { + statusCode: options?.statusCode ?? 500, + isToast: options?.isToast ?? false, + name: AuthErrorType.UNKNOWN_AUTH_ERROR, + }); + } +} From 8dac2aa8dfe91533fe5fb75131d0a4ba942c2501 Mon Sep 17 00:00:00 2001 From: maksym Date: Tue, 21 Jul 2026 12:10:46 +0200 Subject: [PATCH 05/32] feat(auth): add common authentication form and provider components - Introduced `AuthCommonForm` component for handling both login and registration forms, including validation and error handling. - Created `Provider.vue` components for login and registration, integrating the common form and managing submission logic. - Enhanced user experience with dynamic feedback during form submission and error display. This update streamlines the authentication process, providing a unified interface for user login and registration. --- app/slices/setup/theme/components/ui/badge/index.ts | 2 ++ app/slices/user/auth/components/auth/{ => common}/Form.vue | 0 .../user/auth/components/{authLogin => auth/login}/Provider.vue | 2 +- .../components/{authRegister => auth/register}/Provider.vue | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) rename app/slices/user/auth/components/auth/{ => common}/Form.vue (100%) rename app/slices/user/auth/components/{authLogin => auth/login}/Provider.vue (98%) rename app/slices/user/auth/components/{authRegister => auth/register}/Provider.vue (99%) diff --git a/app/slices/setup/theme/components/ui/badge/index.ts b/app/slices/setup/theme/components/ui/badge/index.ts index 0cc92cc..a3f50cd 100644 --- a/app/slices/setup/theme/components/ui/badge/index.ts +++ b/app/slices/setup/theme/components/ui/badge/index.ts @@ -1,6 +1,8 @@ import type { VariantProps } from "class-variance-authority" import { cva } from "class-variance-authority" +export { default as Badge } from "./Badge.vue"; + export const badgeVariants = cva( "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { diff --git a/app/slices/user/auth/components/auth/Form.vue b/app/slices/user/auth/components/auth/common/Form.vue similarity index 100% rename from app/slices/user/auth/components/auth/Form.vue rename to app/slices/user/auth/components/auth/common/Form.vue diff --git a/app/slices/user/auth/components/authLogin/Provider.vue b/app/slices/user/auth/components/auth/login/Provider.vue similarity index 98% rename from app/slices/user/auth/components/authLogin/Provider.vue rename to app/slices/user/auth/components/auth/login/Provider.vue index 3e3c2e1..68d1899 100644 --- a/app/slices/user/auth/components/authLogin/Provider.vue +++ b/app/slices/user/auth/components/auth/login/Provider.vue @@ -35,7 +35,7 @@ async function onSubmit(values: { email: string; password: string }) {