diff --git a/package-lock.json b/package-lock.json index f01acef..49013a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -164,6 +164,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1774,6 +1775,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1822,6 +1824,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2569,6 +2572,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.11.tgz", "integrity": "sha512-yxADFW35LYkP8oSGobGsYIrI42I+GPCvKTNHx4meT9Yq3C950IVz1eANoBk822I9tbKv1wyv9P4Bv1G5TpucFw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/component": "0.7.2", "@firebase/logger": "0.5.0", @@ -2635,6 +2639,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.11.tgz", "integrity": "sha512-KaACDjXkK5VLpI01vEs592R7/8s5DjFdIXfKoR385ly1SmK3Tu+jMHCIB4MsiY5jsez6v7VlEX/3rJ90dVkHyA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/app": "0.14.11", "@firebase/component": "0.7.2", @@ -2651,6 +2656,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.4.tgz", "integrity": "sha512-crX9TA5SVYZwLPG7/R16IsH8FLlgkPXjJUVhsVpHVDSqJiq3D/NuFTM5ctxGTExXAOeIn//69tQw47CPerM8MQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/logger": "0.5.0" } @@ -3104,6 +3110,7 @@ "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -4367,6 +4374,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.100.14" }, @@ -4514,8 +4522,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4789,6 +4796,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4799,6 +4807,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4917,6 +4926,7 @@ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", @@ -5293,6 +5303,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5325,6 +5336,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5812,6 +5824,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6783,8 +6796,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "17.4.2", @@ -7165,6 +7177,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10523,7 +10536,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11258,6 +11270,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11319,7 +11332,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11335,7 +11347,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -11457,6 +11468,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11466,6 +11478,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11485,6 +11498,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11626,7 +11640,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -11859,6 +11874,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13023,6 +13039,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -13160,6 +13177,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13404,6 +13422,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14444,6 +14463,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -14729,6 +14749,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/features/discover/components/RoastModal.tsx b/src/features/discover/components/RoastModal.tsx index 824494a..f6a772e 100644 --- a/src/features/discover/components/RoastModal.tsx +++ b/src/features/discover/components/RoastModal.tsx @@ -8,18 +8,22 @@ interface RoastModalProps { profile: Profile; activePersonaView: RoastPersona | null; isGenerating: boolean; + streamingText?: string; onClose: () => void; onSelectPersona: (persona: RoastPersona) => void; onGenerateRoast: (profile: Profile, persona: RoastPersona) => void; + onDeleteRoast: () => void; } export function RoastModal({ profile, activePersonaView, isGenerating, + streamingText, onClose, onSelectPersona, onGenerateRoast, + onDeleteRoast, }: RoastModalProps) { return ( { onSelectPersona("brutal"); @@ -38,6 +43,7 @@ export function RoastModal({ onSelectPersona("mild"); void onGenerateRoast(profile, "mild"); }} + onDeleteRoast={onDeleteRoast} /> ); } diff --git a/src/features/discover/hooks/__tests__/useRoastProfile.test.tsx b/src/features/discover/hooks/__tests__/useRoastProfile.test.tsx new file mode 100644 index 0000000..f5e7b39 --- /dev/null +++ b/src/features/discover/hooks/__tests__/useRoastProfile.test.tsx @@ -0,0 +1,158 @@ +/** + * @vitest-environment jsdom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/shared/services/roast.service", () => ({ + requestRoast: vi.fn(), + deleteRoast: vi.fn(), +})); + +vi.mock("../../services/discover.repository", () => ({ + updateProfile: vi.fn(), +})); + +import type { Profile } from "../../model/discover.types"; +import { updateProfile } from "../../services/discover.repository"; +import { useRoastProfile } from "../useRoastProfile"; + +import { requestRoast, deleteRoast } from "@/shared/services/roast.service"; + +const mockProfile: Profile = { + id: "p1", + name: "Ada", + primaryRole: "Frontend", + skills: { frontend: 8, backend: 3, design: 5, data: 2, devops: 1, soft: 6 }, + canvas: { loves: [], comfort: [], veto: [] }, + status: "looking", +} as unknown as Profile; + +const showToast = vi.fn(); + +const createWrapper = () => { + const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("useRoastProfile streaming", () => { + it("accumulates streamingText as onChunk is called", async () => { + (requestRoast as ReturnType).mockImplementation( + async (_payload: unknown, onChunk?: (t: string) => void) => { + onChunk?.("Parte 1 "); + onChunk?.("Parte 2"); + return { roast: "Parte 1 Parte 2" }; + }, + ); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeRoast(mockProfile, "brutal")); + + await waitFor(() => expect(result.current.isGenerating).toBe(false)); + + // streamingText cleared after success + expect(result.current.streamingText).toBe(""); + }); + + it("does NOT call updateProfile after roast succeeds", async () => { + (requestRoast as ReturnType).mockResolvedValue({ roast: "Roast text" }); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeRoast(mockProfile, "brutal")); + + await waitFor(() => expect(result.current.isGenerating).toBe(false)); + + expect(updateProfile).not.toHaveBeenCalled(); + }); + + it("clears streamingText and shows toast on error", async () => { + (requestRoast as ReturnType).mockRejectedValue(new Error("IA offline")); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeRoast(mockProfile, "brutal")); + + await waitFor(() => expect(result.current.isGenerating).toBe(false)); + + expect(result.current.streamingText).toBe(""); + expect(showToast).toHaveBeenCalled(); + }); +}); + +describe("useRoastProfile - executeDeleteRoast", () => { + const profileWithBrutal: Profile = { + ...mockProfile, + roastBrutal: "Você é terrível!", + } as unknown as Profile; + + it("clears roastBrutal from selectedProfile on successful delete", async () => { + (deleteRoast as ReturnType).mockResolvedValue(undefined); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(profileWithBrutal)); + act(() => result.current.executeDeleteRoast("brutal")); + + await waitFor(() => expect(result.current.isDeleting).toBe(false)); + + expect(result.current.selectedProfile?.roastBrutal).toBeUndefined(); + }); + + it("shows toast on delete error", async () => { + (deleteRoast as ReturnType).mockRejectedValue(new Error("Server error")); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeDeleteRoast("brutal")); + + await waitFor(() => expect(result.current.isDeleting).toBe(false)); + + expect(showToast).toHaveBeenCalledWith("Erro ao apagar veredito. Tente novamente."); + }); + + it("calls onDeleteSuccess callback after successful delete", async () => { + (deleteRoast as ReturnType).mockResolvedValue(undefined); + const onDeleteSuccess = vi.fn(); + + const { result } = renderHook(() => useRoastProfile({ showToast, onDeleteSuccess }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(profileWithBrutal)); + act(() => result.current.executeDeleteRoast("brutal")); + + await waitFor(() => expect(result.current.isDeleting).toBe(false)); + + expect(onDeleteSuccess).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/discover/hooks/useProfilesRealtime.ts b/src/features/discover/hooks/useProfilesRealtime.ts index e2b3fbb..48224c8 100644 --- a/src/features/discover/hooks/useProfilesRealtime.ts +++ b/src/features/discover/hooks/useProfilesRealtime.ts @@ -11,10 +11,7 @@ export function useProfilesRealtime(currentUserId?: string) { }); const profiles = useMemo(() => { - console.log("useProfilesRealtime: raw data from Firestore:", data); - console.log("useProfilesRealtime: currentUserId:", currentUserId); const sorted = currentUserId ? sortProfiles(data, currentUserId) : []; - console.log("useProfilesRealtime: sorted profiles:", sorted); return sorted; }, [data, currentUserId]); diff --git a/src/features/discover/hooks/useRoastProfile.ts b/src/features/discover/hooks/useRoastProfile.ts index 136cf02..791306e 100644 --- a/src/features/discover/hooks/useRoastProfile.ts +++ b/src/features/discover/hooks/useRoastProfile.ts @@ -3,43 +3,50 @@ import { useState } from "react"; import { getInitialPersona } from "../model/discover.selectors"; import type { Profile, RoastPersona } from "../model/discover.types"; -import { updateProfile } from "../services/discover.repository"; -import { firestoreLog, apiLog } from "@/shared/lib/logger/logger"; -import { requestRoast } from "@/shared/services/roast.service"; +import { apiLog } from "@/shared/lib/logger/logger"; +import { deleteRoast, requestRoast } from "@/shared/services/roast.service"; interface UseRoastProfileParams { showToast: (message: string, type?: "error" | "info") => void; + onDeleteSuccess?: () => void; } -export function useRoastProfile({ showToast }: UseRoastProfileParams) { +export function useRoastProfile({ showToast, onDeleteSuccess }: UseRoastProfileParams) { const [selectedProfile, setSelectedProfile] = useState(null); const [activePersonaView, setActivePersonaView] = useState(null); + const [streamingText, setStreamingText] = useState(""); const roastMutation = useMutation({ mutationFn: ({ profile, persona }: { profile: Profile; persona: RoastPersona }) => - requestRoast({ memberId: profile.id, memberData: profile, persona }), - onSuccess: async (data, { profile, persona }) => { - if (!data.roast) { - showToast(`Erro ao gerar a Sina: ${data.error ?? "Resposta inesperada do servidor."}`); - return; - } - const updateData = - persona === "brutal" - ? { roastBrutal: data.roast, updatedAt: new Date() } - : { roastMild: data.roast, updatedAt: new Date() }; - - try { - await updateProfile(profile.id, updateData); - } catch (dbError) { - firestoreLog.error("Erro ao salvar sina no banco:", dbError); - } - - setSelectedProfile({ ...profile, ...updateData }); + requestRoast({ memberId: profile.id, memberData: profile, persona }, (chunk) => + setStreamingText((prev) => prev + chunk), + ), + onMutate: () => { + setStreamingText(""); + }, + onSuccess: (data, { profile, persona }) => { + const field = persona === "brutal" ? "roastBrutal" : "roastMild"; + setSelectedProfile({ ...profile, [field]: data.roast, updatedAt: new Date() }); + setStreamingText(""); }, onError: (error) => { apiLog.error("Erro ao chamar o roast:", error); showToast("Sem conexão com o servidor de IA. Verifique sua rede e tente novamente."); + setStreamingText(""); + }, + }); + + const deleteRoastMutation = useMutation({ + mutationFn: ({ memberId, persona }: { memberId: string; persona: RoastPersona }) => + deleteRoast(memberId, persona), + onSuccess: (_, { persona }) => { + const field = persona === "brutal" ? "roastBrutal" : "roastMild"; + setSelectedProfile((prev) => (prev ? { ...prev, [field]: undefined } : null)); + onDeleteSuccess?.(); + }, + onError: () => { + showToast("Erro ao apagar veredito. Tente novamente."); }, }); @@ -58,13 +65,21 @@ export function useRoastProfile({ showToast }: UseRoastProfileParams) { roastMutation.mutate({ profile, persona }); } + function executeDeleteRoast(persona: RoastPersona) { + if (!selectedProfile) return; + deleteRoastMutation.mutate({ memberId: selectedProfile.id, persona }); + } + return { selectedProfile, activePersonaView, + streamingText, isGenerating: roastMutation.isPending, + isDeleting: deleteRoastMutation.isPending, openProfile, closeProfile: () => setSelectedProfile(null), executeRoast, + executeDeleteRoast, setActivePersonaView, }; } diff --git a/src/features/discover/pages/DiscoverPage.tsx b/src/features/discover/pages/DiscoverPage.tsx index 9dd3d95..d5a7a50 100644 --- a/src/features/discover/pages/DiscoverPage.tsx +++ b/src/features/discover/pages/DiscoverPage.tsx @@ -14,6 +14,7 @@ import { useToast } from "../hooks/useToast"; import { useAuth } from "@/contexts/useAuth"; import { SendMessageModal } from "@/features/messages/components/SendMessageModal"; +import { DeleteRoastConfirmModal } from "@/shared/components/ui/DeleteRoastConfirmModal"; export default function DiscoverPage() { const { user } = useAuth(); @@ -23,7 +24,12 @@ export default function DiscoverPage() { const filters = useDiscoverFilters(profiles); - const roast = useRoastProfile({ showToast }); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + + const roast = useRoastProfile({ + showToast, + onDeleteSuccess: () => setConfirmDeleteOpen(false), + }); const [contactTarget, setContactTarget] = useState<{ id: string; name: string } | null>(null); @@ -68,9 +74,22 @@ export default function DiscoverPage() { profile={roast.selectedProfile} activePersonaView={roast.activePersonaView} isGenerating={roast.isGenerating} + streamingText={roast.streamingText} onClose={roast.closeProfile} onSelectPersona={roast.setActivePersonaView} onGenerateRoast={roast.executeRoast} + onDeleteRoast={() => setConfirmDeleteOpen(true)} + /> + )} + + + + {confirmDeleteOpen && roast.activePersonaView && ( + roast.executeDeleteRoast(roast.activePersonaView!)} + onCancel={() => setConfirmDeleteOpen(false)} /> )} diff --git a/src/features/onboarding/components/GuildPassport.tsx b/src/features/onboarding/components/GuildPassport.tsx index 4cec925..7d5a4aa 100644 --- a/src/features/onboarding/components/GuildPassport.tsx +++ b/src/features/onboarding/components/GuildPassport.tsx @@ -63,12 +63,15 @@ export function GuildPassport({ className="shadow-none border-4 w-full h-full" /> -
+

NOME_OPERADOR:

-

+

{form.name || "Aguardando..."}

diff --git a/src/server/features/roast/__tests__/roast.controller.test.ts b/src/server/features/roast/__tests__/roast.controller.test.ts new file mode 100644 index 0000000..9730a19 --- /dev/null +++ b/src/server/features/roast/__tests__/roast.controller.test.ts @@ -0,0 +1,81 @@ +import type { Request, Response } from "express"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../roast.service", () => ({ + generateRoastStream: vi.fn(), +})); + +import { postRoast } from "../roast.controller"; +import { generateRoastStream } from "../roast.service"; + +async function* chunks(...texts: string[]) { + for (const t of texts) yield t; +} + +function mockRes() { + const written: string[] = []; + return { + setHeader: vi.fn(), + flushHeaders: vi.fn(), + write: vi.fn((s: string) => { + written.push(s); + }), + end: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn(), + _written: written, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("postRoast (SSE)", () => { + it("sets SSE headers", async () => { + (generateRoastStream as ReturnType).mockReturnValue(chunks()); + const res = mockRes(); + await postRoast( + { body: { memberId: "u1", memberData: {} } } as Request, + res as unknown as Response, + ); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream"); + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(res.setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + }); + + it("writes each chunk as SSE data and ends with [DONE]", async () => { + (generateRoastStream as ReturnType).mockReturnValue(chunks("Olá ", "mundo")); + const res = mockRes(); + await postRoast( + { body: { memberId: "u1", memberData: {} } } as Request, + res as unknown as Response, + ); + expect(res._written).toContain('data: {"chunk":"Olá "}\n\n'); + expect(res._written).toContain('data: {"chunk":"mundo"}\n\n'); + expect(res._written).toContain("data: [DONE]\n\n"); + expect(res.end).toHaveBeenCalled(); + }); + + it("writes error event when stream throws", async () => { + (generateRoastStream as ReturnType).mockReturnValue( + (async function* () { + throw new Error("Gemini down"); + })(), + ); + const res = mockRes(); + await postRoast( + { body: { memberId: "u1", memberData: {} } } as Request, + res as unknown as Response, + ); + expect(res._written.some((s) => s.includes('"error"'))).toBe(true); + expect(res.end).toHaveBeenCalled(); + }); + + it("returns 400 json when memberId or memberData are missing", async () => { + const res = mockRes(); + await postRoast({ body: {} } as Request, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Missing memberId or memberData" }); + }); +}); diff --git a/src/server/features/roast/__tests__/roast.service.test.ts b/src/server/features/roast/__tests__/roast.service.test.ts new file mode 100644 index 0000000..301d25c --- /dev/null +++ b/src/server/features/roast/__tests__/roast.service.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/server/shared/lib/gemini.server", () => ({ + getGeminiClient: vi.fn(), +})); + +vi.mock("../roast.repository", () => ({ + saveProfileRoast: vi.fn().mockResolvedValue(undefined), +})); + +import { saveProfileRoast } from "../roast.repository"; +import { generateRoastStream } from "../roast.service"; +import type { RoastRequestBody } from "../roast.types"; + +import { getGeminiClient } from "@/server/shared/lib/gemini.server"; + +const mockBody: RoastRequestBody = { + memberId: "user-1", + memberData: { name: "Ada", primaryRole: "Frontend" }, + persona: "brutal", +}; + +function makeAsyncIterable(chunks: { text: string }[]) { + return { + [Symbol.asyncIterator]() { + let i = 0; + return { + async next() { + if (i >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: chunks[i++] }; + }, + }; + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + (getGeminiClient as ReturnType).mockReturnValue({ + models: { + generateContentStream: vi + .fn() + .mockResolvedValue(makeAsyncIterable([{ text: "Você é " }, { text: "terrível." }])), + }, + }); +}); + +describe("generateRoastStream", () => { + it("yields text chunks from the Gemini stream", async () => { + const chunks: string[] = []; + for await (const chunk of generateRoastStream(mockBody)) { + chunks.push(chunk); + } + expect(chunks).toEqual(["Você é ", "terrível."]); + }); + + it("calls saveProfileRoast with the full accumulated text after streaming", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of generateRoastStream(mockBody)) { + /* drain */ + } + expect(saveProfileRoast).toHaveBeenCalledWith("user-1", "Você é terrível.", "brutal"); + }); + + it("passes thinkingBudget: 0 in the Gemini config", async () => { + const mockClient = (getGeminiClient as ReturnType)(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of generateRoastStream(mockBody)) { + /* drain */ + } + expect(mockClient.models.generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { thinkingBudget: 0 }, + }), + }), + ); + }); + + it("skips empty chunks", async () => { + (getGeminiClient as ReturnType).mockReturnValue({ + models: { + generateContentStream: vi + .fn() + .mockResolvedValue( + makeAsyncIterable([{ text: "hello" }, { text: "" }, { text: " world" }]), + ), + }, + }); + const chunks: string[] = []; + for await (const chunk of generateRoastStream(mockBody)) { + chunks.push(chunk); + } + expect(chunks).toEqual(["hello", " world"]); + }); +}); diff --git a/src/server/features/roast/roast.controller.ts b/src/server/features/roast/roast.controller.ts index 88a86a4..a734516 100644 --- a/src/server/features/roast/roast.controller.ts +++ b/src/server/features/roast/roast.controller.ts @@ -1,27 +1,48 @@ import type { Request, Response } from "express"; -import { generateRoast } from "./roast.service"; -import type { RoastRequestBody } from "./roast.types"; +import { deleteProfileRoast } from "./roast.repository"; +import { generateRoastStream } from "./roast.service"; +import type { RoastPersona, RoastRequestBody } from "./roast.types"; + +export async function deleteRoast(req: Request, res: Response) { + const { memberId } = req.params; + const persona = req.query.persona as RoastPersona | undefined; + + if (!memberId) { + res.status(400).json({ error: "Missing memberId" }); + return; + } + + await deleteProfileRoast(memberId, persona); + res.status(200).json({ success: true }); +} export async function postRoast(req: Request, res: Response) { - try { - const body = req.body as RoastRequestBody; - const { memberId, memberData } = body; + const body = req.body as RoastRequestBody; + const { memberId, memberData } = body; - if (!memberId || !memberData) { - return res.status(400).json({ error: "Missing memberId or memberData" }); - } + if (!memberId || !memberData) { + res.status(400).json({ error: "Missing memberId or memberData" }); + return; + } - const roast = await generateRoast(body); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); - return res.json({ roast }); + try { + for await (const chunk of generateRoastStream(body)) { + res.write(`data: ${JSON.stringify({ chunk })}\n\n`); + } + res.write("data: [DONE]\n\n"); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected server error"; - const errorMessage = message.includes("API key not valid") ? "Chave da API do Gemini inválida ou não configurada. Por favor, adicione uma chave válida no painel de configurações." : message; - - return res.status(500).json({ error: errorMessage }); + res.write(`data: ${JSON.stringify({ error: errorMessage })}\n\n`); + } finally { + res.end(); } } diff --git a/src/server/features/roast/roast.repository.ts b/src/server/features/roast/roast.repository.ts index 0053f30..c39a178 100644 --- a/src/server/features/roast/roast.repository.ts +++ b/src/server/features/roast/roast.repository.ts @@ -1,7 +1,25 @@ +import { FieldValue } from "firebase-admin/firestore"; + import { adminDb } from "../../shared/lib/firebase-admin.server"; import type { RoastPersona } from "./roast.types"; +export async function deleteProfileRoast(memberId: string, persona?: RoastPersona) { + const updateData: Record = { + updatedAt: new Date(), + }; + + if (persona === "brutal") { + updateData.roastBrutal = FieldValue.delete(); + } else if (persona === "mild") { + updateData.roastMild = FieldValue.delete(); + } else { + updateData.roast = FieldValue.delete(); + } + + await adminDb.collection("profiles").doc(memberId).update(updateData); +} + export async function saveProfileRoast( memberId: string, roastText: string, diff --git a/src/server/features/roast/roast.routes.ts b/src/server/features/roast/roast.routes.ts index 5affe7b..06f86af 100644 --- a/src/server/features/roast/roast.routes.ts +++ b/src/server/features/roast/roast.routes.ts @@ -3,7 +3,7 @@ import rateLimit from "express-rate-limit"; import { asyncHandler } from "../../shared/utils/async-handler"; -import { postRoast } from "./roast.controller"; +import { deleteRoast, postRoast } from "./roast.controller"; // 5 roast requests per user per minute — Gemini calls are expensive const roastLimiter = rateLimit({ @@ -17,3 +17,4 @@ const roastLimiter = rateLimit({ export const roastRouter = Router(); roastRouter.post("/", roastLimiter, asyncHandler(postRoast)); +roastRouter.delete("/:memberId", asyncHandler(deleteRoast)); diff --git a/src/server/features/roast/roast.service.ts b/src/server/features/roast/roast.service.ts index f1686b6..8ed3245 100644 --- a/src/server/features/roast/roast.service.ts +++ b/src/server/features/roast/roast.service.ts @@ -4,24 +4,31 @@ import type { RoastPersona, RoastRequestBody } from "./roast.types"; import { getGeminiClient } from "@/server/shared/lib/gemini.server"; -export async function generateRoast({ memberId, memberData, persona }: RoastRequestBody) { +export async function* generateRoastStream({ + memberId, + memberData, + persona, +}: RoastRequestBody): AsyncGenerator { const ai = getGeminiClient(); - const response = await ai.models.generateContent({ + const result = await ai.models.generateContentStream({ model: "gemini-2.5-flash", contents: `Analise este membro. DADOS DO MEMBRO:\n${JSON.stringify(memberData, null, 2)}`, config: { + thinkingConfig: { thinkingBudget: 0 }, systemInstruction: getRoastSystemInstruction(persona as RoastPersona), }, }); - const roastText = response.text ?? ""; + let fullText = ""; - if (!roastText) { - throw new Error("A IA não retornou conteúdo para o roast."); + for await (const chunk of result) { + const text = chunk.text ?? ""; + if (text) { + fullText += text; + yield text; + } } - await saveProfileRoast(memberId, roastText, persona); - - return roastText; + await saveProfileRoast(memberId, fullText, persona); } diff --git a/src/server/shared/lib/firebase-admin.server.ts b/src/server/shared/lib/firebase-admin.server.ts index c722ddc..a00abd4 100644 --- a/src/server/shared/lib/firebase-admin.server.ts +++ b/src/server/shared/lib/firebase-admin.server.ts @@ -68,5 +68,7 @@ function getFirebaseAdminApp() { const adminApp = getFirebaseAdminApp(); -export const adminDb = getFirestore(adminApp); +const firestoreDatabaseId = process.env.VITE_FIREBASE_FIRESTORE_DATABASE_ID || "(default)"; + +export const adminDb = getFirestore(adminApp, firestoreDatabaseId); export const adminAuth = getAuth(adminApp); diff --git a/src/shared/components/ui/DeleteRoastConfirmModal.tsx b/src/shared/components/ui/DeleteRoastConfirmModal.tsx new file mode 100644 index 0000000..8e08f09 --- /dev/null +++ b/src/shared/components/ui/DeleteRoastConfirmModal.tsx @@ -0,0 +1,101 @@ +import { Trash2 } from "lucide-react"; +import { motion } from "motion/react"; + +import type { RoastPersona } from "@/domain/entities/Shared"; + +interface DeleteRoastConfirmModalProps { + persona: RoastPersona; + isDeleting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function DeleteRoastConfirmModal({ + persona, + isDeleting, + onConfirm, + onCancel, +}: DeleteRoastConfirmModalProps) { + const personaLabel = persona === "brutal" ? "TECH LEAD (BRUTAL)" : "MENTOR (SUAVE)"; + + return ( +
+ + +
+ +
+ +
+ +
+
+
+ +
+

+ APAGAR VEREDITO_ +

+

ALVO: {personaLabel}

+
+
+ +
+
+

+ ⚠ ESTA AÇÃO É IRREVERSÍVEL +

+

+ O veredito {personaLabel} será apagado + permanentemente do banco de dados. +

+
+ +
+ + +
+
+ +
+ [SISTEMA ROASTED & TOASTED] © 2026 + + PERIGO + +
+
+ +
+
+ ); +} diff --git a/src/shared/components/ui/ProfileCard.tsx b/src/shared/components/ui/ProfileCard.tsx index 7f0e53b..3e9615b 100644 --- a/src/shared/components/ui/ProfileCard.tsx +++ b/src/shared/components/ui/ProfileCard.tsx @@ -102,7 +102,10 @@ export default function ProfileCard({ className="group-hover:scale-105 transition-transform" />
-

+

{name || "NOME_NULO"}

@@ -158,7 +161,10 @@ export default function ProfileCard({ />
-

+

{name || "NOME_NULO"}

diff --git a/src/shared/components/ui/RoastModal.tsx b/src/shared/components/ui/RoastModal.tsx index 8e8525b..83bfd05 100644 --- a/src/shared/components/ui/RoastModal.tsx +++ b/src/shared/components/ui/RoastModal.tsx @@ -1,4 +1,4 @@ -import { Terminal } from "lucide-react"; +import { Terminal, Trash2 } from "lucide-react"; import { motion } from "motion/react"; import type { RoastPersona } from "@/domain/entities/Shared"; @@ -10,9 +10,11 @@ interface RoastModalProps { roastMild?: string; activePersonaView: RoastPersona | null; isGenerating?: boolean; + streamingText?: string; onClose: () => void; onGenerateBrutal: () => void; onGenerateMild: () => void; + onDeleteRoast?: () => void; } export function RoastModal({ @@ -22,9 +24,11 @@ export function RoastModal({ roastMild, activePersonaView, isGenerating = false, + streamingText, onClose, onGenerateBrutal, onGenerateMild, + onDeleteRoast, }: RoastModalProps) { const isBrutalActive = activePersonaView === "brutal"; const isMildActive = activePersonaView === "mild"; @@ -73,7 +77,20 @@ export function RoastModal({
- {isGenerating ? "Gerando veredito..." : roastText} + {isGenerating ? ( + <> + {streamingText || "Gerando veredito..."} + + | + + + ) : ( + roastText + )}
@@ -100,6 +117,17 @@ export function RoastModal({ > {roastMild ? "VER MENTOR (SUAVE)" : "GERAR MENTOR (SUAVE)"} + + {onDeleteRoast && roastText && ( + + )}
diff --git a/src/shared/hooks/useFirestoreSubscription.ts b/src/shared/hooks/useFirestoreSubscription.ts index 2d9a409..3c746fc 100644 --- a/src/shared/hooks/useFirestoreSubscription.ts +++ b/src/shared/hooks/useFirestoreSubscription.ts @@ -1,9 +1,13 @@ -import { collection, query, onSnapshot, QueryConstraint } from "firebase/firestore"; +import { collection, query, onSnapshot, type QueryConstraint } from "firebase/firestore"; import { useEffect, useState } from "react"; import { AppError } from "@/shared/lib/AppError"; import { db } from "@/shared/lib/firebase/firebase.client"; +// Module-level constant avoids creating a new [] reference on every render, +// which would otherwise cause useEffect to re-subscribe in an infinite loop. +const EMPTY_CONSTRAINTS: QueryConstraint[] = []; + interface UseFirestoreSubscriptionOptions { collectionName: string; constraints?: QueryConstraint[]; @@ -32,7 +36,7 @@ interface UseFirestoreSubscriptionReturn { */ export function useFirestoreSubscription({ collectionName, - constraints = [], + constraints = EMPTY_CONSTRAINTS, sortFn, }: UseFirestoreSubscriptionOptions): UseFirestoreSubscriptionReturn { const [data, setData] = useState([]); @@ -41,7 +45,6 @@ export function useFirestoreSubscription({ useEffect(() => { setLoading(true); - setError(null); try { @@ -51,10 +54,13 @@ export function useFirestoreSubscription({ q, (snapshot) => { try { - const docs = snapshot.docs.map((doc) => ({ - id: doc.id, - ...(doc.data() as object), - } as T)); + const docs = snapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...(doc.data() as object), + }) as T, + ); const sorted = sortFn ? docs.sort(sortFn) : docs; setData(sorted); setError(null); @@ -72,7 +78,6 @@ export function useFirestoreSubscription({ return unsubscribe; } catch { - // Error will be handled by onSnapshot error callback return undefined; } }, [collectionName, constraints, sortFn]); diff --git a/src/shared/services/__tests__/roast.service.test.ts b/src/shared/services/__tests__/roast.service.test.ts new file mode 100644 index 0000000..e62fe2d --- /dev/null +++ b/src/shared/services/__tests__/roast.service.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { requestRoast, deleteRoast } from "../roast.service"; + +function sseStream(...events: string[]) { + const data = events.map((e) => `data: ${e}\n\n`).join(""); + const encoder = new TextEncoder(); + const bytes = encoder.encode(data); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("requestRoast (SSE client)", () => { + it("resolves with full roast text when [DONE] is received", async () => { + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream('{"chunk":"Olá "}', '{"chunk":"mundo"}', "[DONE]"), + }); + const result = await requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }); + expect(result.roast).toBe("Olá mundo"); + }); + + it("calls onChunk for each received chunk in order", async () => { + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream('{"chunk":"A"}', '{"chunk":"B"}', "[DONE]"), + }); + const calls: string[] = []; + await requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }, (t) => calls.push(t)); + expect(calls).toEqual(["A", "B"]); + }); + + it("throws when the stream contains an error event", async () => { + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream('{"error":"Gemini down"}'), + }); + await expect( + requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }), + ).rejects.toThrow("Gemini down"); + }); + + it("throws when response is not ok", async () => { + mockFetch.mockResolvedValue({ + ok: false, + body: null, + json: async () => ({ error: "Rate limit" }), + }); + await expect( + requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }), + ).rejects.toThrow("Rate limit"); + }); +}); + +describe("deleteRoast", () => { + it("calls DELETE /api/roast/:memberId?persona=brutal", async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + await deleteRoast("u1", "brutal"); + expect(mockFetch).toHaveBeenCalledWith("/api/roast/u1?persona=brutal", { method: "DELETE" }); + }); + + it("calls DELETE /api/roast/:memberId without persona query when persona is omitted", async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + await deleteRoast("u1"); + expect(mockFetch).toHaveBeenCalledWith("/api/roast/u1", { method: "DELETE" }); + }); + + it("throws with server error message when response is not ok", async () => { + mockFetch.mockResolvedValue({ ok: false, json: async () => ({ error: "Not found" }) }); + await expect(deleteRoast("u1", "brutal")).rejects.toThrow("Not found"); + }); +}); diff --git a/src/shared/services/roast.service.ts b/src/shared/services/roast.service.ts index dd056e9..84d2fdc 100644 --- a/src/shared/services/roast.service.ts +++ b/src/shared/services/roast.service.ts @@ -11,20 +11,64 @@ export interface RoastApiResponse { error?: string; } -export async function requestRoast(payload: RoastRequestPayload): Promise { +export async function requestRoast( + payload: RoastRequestPayload, + onChunk?: (text: string) => void, +): Promise { const response = await fetch("/api/roast", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - const data = (await response.json()) as RoastApiResponse; - - if (!response.ok) { + if (!response.ok || !response.body) { + const data = (await response.json()) as RoastApiResponse; throw new Error(data.error || "Erro ao gerar roast."); } - return data; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + + for (const part of parts) { + if (!part.startsWith("data: ")) continue; + const raw = part.slice(6).trim(); + + if (raw === "[DONE]") return { roast: fullText }; + + let parsed: { chunk?: string; error?: string }; + try { + parsed = JSON.parse(raw) as { chunk?: string; error?: string }; + } catch { + continue; + } + + if (parsed.error) throw new Error(parsed.error); + + if (parsed.chunk) { + fullText += parsed.chunk; + onChunk?.(parsed.chunk); + } + } + } + + return { roast: fullText }; +} + +export async function deleteRoast(memberId: string, persona?: RoastPersona): Promise { + const url = persona ? `/api/roast/${memberId}?persona=${persona}` : `/api/roast/${memberId}`; + const response = await fetch(url, { method: "DELETE" }); + if (!response.ok) { + const data = (await response.json()) as { error?: string }; + throw new Error(data.error ?? "Erro ao apagar roast."); + } }