diff --git a/apps/web/src/features/auth/index.ts b/apps/web/src/features/auth/index.ts index 022a9bbd5..bc54e4486 100644 --- a/apps/web/src/features/auth/index.ts +++ b/apps/web/src/features/auth/index.ts @@ -27,9 +27,6 @@ export { usePasswordReset } from './hooks/usePasswordReset'; export { useOAuthCallback } from './hooks/useOAuthCallback'; export { useUsernameAvailability } from './hooks/useUsernameAvailability'; -// Services -export * from './services/authService'; - // Components export { AuthInput } from './components/AuthInput'; export { AuthButton } from './components/AuthButton'; diff --git a/apps/web/src/features/auth/services/authService.test.ts b/apps/web/src/features/auth/services/authService.test.ts deleted file mode 100644 index 674bee1e7..000000000 --- a/apps/web/src/features/auth/services/authService.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AxiosError } from 'axios'; -import { - login, - register, - logout, - refreshToken, - requestPasswordReset, - resetPassword, - verifyEmail, - resendVerificationEmail, - type AuthResponse, -} from './authService'; -import { apiClient } from '@/services/api/client'; - -// Mock apiClient -vi.mock('@/services/api/client', () => ({ - apiClient: { - post: vi.fn(), - get: vi.fn(), - }, -})); - -// Mock handleApiServiceError - it always throws, so we let it through -// but need the real implementation for error handling tests -vi.mock('@/utils/serviceErrorHandler', async () => { - const actual = await vi.importActual('@/utils/serviceErrorHandler'); - return actual; -}); - -const mockedApiClient = apiClient as { - post: ReturnType; - get: ReturnType; -}; - -// v1.0.8 (post-tag, queue+auth annotation session) — full authService -// migration to orval. Tests still mock `apiClient.post/get` for legacy -// ergonomics; the orval module's functions are stubbed to delegate -// back to those mocks so existing -// `toHaveBeenCalledWith('/auth/...', ...)` assertions keep working, -// modulo the wire-shape renames documented in authService.ts. -vi.mock('@/services/generated/auth/auth', () => ({ - postAuthLogin: vi.fn(async (body: unknown) => - mockedApiClient.post('/auth/login', body).then((r: { data?: unknown }) => r?.data), - ), - postAuthLogout: vi.fn(async () => - mockedApiClient.post('/auth/logout').then((r: { data?: unknown }) => r?.data), - ), - postAuthRegister: vi.fn(async (body: unknown) => - mockedApiClient - .post('/auth/register', body) - .then((r: { data?: unknown }) => r?.data), - ), - postAuthRefresh: vi.fn(async (body: unknown) => - mockedApiClient - .post('/auth/refresh', body) - .then((r: { data?: unknown }) => r?.data), - ), - postAuthPasswordResetRequest: vi.fn(async (body: unknown) => - mockedApiClient - .post('/auth/password/reset-request', body) - .then((r: { data?: unknown }) => r?.data), - ), - postAuthPasswordReset: vi.fn(async (body: unknown) => - mockedApiClient - .post('/auth/password/reset', body) - .then((r: { data?: unknown }) => r?.data), - ), - postAuthVerifyEmail: vi.fn(async (params: { token: string }) => - mockedApiClient - .post(`/auth/verify-email?token=${params.token}`) - .then((r: { data?: unknown }) => r?.data), - ), - postAuthResendVerification: vi.fn(async (body: unknown) => - mockedApiClient - .post('/auth/resend-verification', body) - .then((r: { data?: unknown }) => r?.data), - ), - getAuthCheckUsername: vi.fn(async (params: { username: string }) => - mockedApiClient - .get( - `/auth/check-username?username=${encodeURIComponent(params.username)}`, - ) - .then((r: { data?: unknown }) => r?.data), - ), -})); - -describe('authService', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('login', () => { - it('should successfully login and return auth response', async () => { - const mockResponse: AuthResponse = { - user: { - id: '1', - email: 'test@example.com', - username: 'testuser', - }, - token: { - access_token: 'access-token-123', - refresh_token: 'refresh-token-123', - expires_in: 3600, - }, - }; - - mockedApiClient.post.mockResolvedValue({ data: mockResponse }); - - const result = await login({ - email: 'test@example.com', - password: 'password123', - }); - - expect(result).toEqual(mockResponse); - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/login', { - email: 'test@example.com', - password: 'password123', - }); - }); - - it('should throw on login failure', async () => { - const mockError = new AxiosError('Login failed'); - mockError.response = { - status: 401, - data: { error: 'Invalid credentials' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect( - login({ - email: 'test@example.com', - password: 'wrongpassword', - }), - ).rejects.toThrow(); - }); - - it('should throw on network errors', async () => { - const mockError = new AxiosError('Network Error'); - mockError.request = {}; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect( - login({ - email: 'test@example.com', - password: 'password123', - }), - ).rejects.toThrow(); - }); - }); - - describe('register', () => { - it('should successfully register and return auth response', async () => { - const mockResponse: AuthResponse = { - user: { - id: '1', - email: 'newuser@example.com', - username: 'newuser', - }, - token: { - access_token: 'access-token-123', - refresh_token: 'refresh-token-123', - expires_in: 3600, - }, - }; - - mockedApiClient.post.mockResolvedValue({ data: mockResponse }); - - const result = await register({ - email: 'newuser@example.com', - password: 'password123', - password_confirm: 'password123', - username: 'newuser', - }); - - expect(result).toEqual(mockResponse); - // Note: frontend RegisterFormData uses `password_confirm`; the - // service maps this to `password_confirmation` (backend DTO field - // name from register_request.go:8) before sending. This assertion - // checks the WIRE shape, not the form-data shape. - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/register', { - email: 'newuser@example.com', - password: 'password123', - password_confirmation: 'password123', - username: 'newuser', - }); - }); - - it('should throw on registration failure', async () => { - const mockError = new AxiosError('Registration failed'); - mockError.response = { - status: 409, - data: { error: 'Email already exists' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect( - register({ - email: 'existing@example.com', - password: 'password123', - password_confirm: 'password123', - username: 'existinguser', - }), - ).rejects.toThrow(); - }); - }); - - describe('logout', () => { - it('should successfully logout', async () => { - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await logout(); - - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/logout'); - }); - - it('should throw on logout failure', async () => { - const mockError = new AxiosError('Logout failed'); - mockError.response = { - status: 500, - data: { error: 'Internal server error' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect(logout()).rejects.toThrow(); - }); - }); - - describe('refreshToken', () => { - it('should successfully refresh token and return auth response', async () => { - const mockResponse: AuthResponse = { - user: { - id: '1', - email: 'test@example.com', - username: 'testuser', - }, - token: { - access_token: 'new-access-token-123', - refresh_token: 'new-refresh-token-123', - expires_in: 3600, - }, - }; - - mockedApiClient.post.mockResolvedValue({ data: mockResponse }); - - const result = await refreshToken('refresh-token-123'); - - expect(result).toEqual(mockResponse); - // Wire shape: backend DTO uses snake_case `refresh_token`, - // not the camelCase `refreshToken` passed as the JS arg. - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/refresh', { - refresh_token: 'refresh-token-123', - }); - }); - - it('should throw on refresh failure', async () => { - const mockError = new AxiosError('Refresh failed'); - mockError.response = { - status: 401, - data: { error: 'Invalid refresh token' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect(refreshToken('invalid-token')).rejects.toThrow(); - }); - }); - - describe('requestPasswordReset', () => { - it('should successfully request password reset', async () => { - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await requestPasswordReset({ - email: 'test@example.com', - }); - - expect(mockedApiClient.post).toHaveBeenCalledWith( - '/auth/password/reset-request', - { - email: 'test@example.com', - }, - ); - }); - - it('should throw on request failure', async () => { - const mockError = new AxiosError('Request failed'); - mockError.response = { - status: 404, - data: { error: 'User not found' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect( - requestPasswordReset({ - email: 'nonexistent@example.com', - }), - ).rejects.toThrow(); - }); - }); - - describe('resetPassword', () => { - it('should successfully reset password', async () => { - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await resetPassword({ - token: 'reset-token-123', - password: 'newpassword123', - confirmPassword: 'newpassword123', - }); - - // Wire shape: backend DTO field is `new_password` (cf. handlers/ - // password_reset_handler.go:ResetPasswordRequest); the frontend - // form-data type uses `password`. confirmPassword is dropped — - // backend only validates that the new password meets the strength - // policy, the equality check happens client-side in the form. - expect(mockedApiClient.post).toHaveBeenCalledWith( - '/auth/password/reset', - { - token: 'reset-token-123', - new_password: 'newpassword123', - }, - ); - }); - - it('should throw on reset failure', async () => { - const mockError = new AxiosError('Reset failed'); - mockError.response = { - status: 400, - data: { error: 'Invalid or expired token' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect( - resetPassword({ - token: 'invalid-token', - password: 'newpassword123', - confirmPassword: 'newpassword123', - }), - ).rejects.toThrow(); - }); - }); - - describe('verifyEmail', () => { - it('should successfully verify email', async () => { - // Verb shift: was GET, backend route is POST (auth.go:VerifyEmail - // / routes_auth.go:107). Token still passed as query param per - // the swaggo annotation. - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await verifyEmail('verification-token-123'); - - expect(mockedApiClient.post).toHaveBeenCalledWith( - '/auth/verify-email?token=verification-token-123', - ); - }); - - it('should throw on verification failure', async () => { - const mockError = new AxiosError('Verification failed'); - mockError.response = { - status: 400, - data: { error: 'Invalid or expired token' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect(verifyEmail('invalid-token')).rejects.toThrow(); - }); - }); - - describe('resendVerificationEmail', () => { - it('should successfully resend verification email', async () => { - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await resendVerificationEmail('test@example.com'); - - expect(mockedApiClient.post).toHaveBeenCalledWith( - '/auth/resend-verification', - { - email: 'test@example.com', - }, - ); - }); - - it('should throw on resend failure', async () => { - const mockError = new AxiosError('Resend failed'); - mockError.response = { - status: 429, - data: { error: 'Too many requests' }, - } as any; - - mockedApiClient.post.mockRejectedValue(mockError); - - await expect( - resendVerificationEmail('test@example.com'), - ).rejects.toThrow(); - }); - }); -}); diff --git a/apps/web/src/features/auth/services/authService.ts b/apps/web/src/features/auth/services/authService.ts deleted file mode 100644 index ee9c376e3..000000000 --- a/apps/web/src/features/auth/services/authService.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Auth service — orval-backed (full migration post-v1.0.8 B6/B6.bis). - * - * All 9 functions now route through the orval-generated client in - * services/generated/auth/auth.ts. Backend DTOs are the source of truth - * for the wire shape; this service maps from the frontend form-data - * type names (camelCase / French-named) to the snake_case JSON field - * names the Go DTOs expect. - * - * Wire-shape mappings worth knowing (drifts that pre-existed): - * - register: `password_confirm` (frontend) → `password_confirmation` - * (backend `register_request.go:8`) - * - refreshToken: `refreshToken` (frontend arg) → `refresh_token` (DTO) - * - resetPassword: `password` (frontend `ResetPasswordFormData`) → - * `new_password` (backend `ResetPasswordRequest`) - * - verifyEmail: verb shift GET → POST (token still passed as - * `?token=` query param; this matches the route - * registered at `routes_auth.go:107` and the swaggo - * annotation on `auth.go:VerifyEmail`). - * - * The previous code sending `password_confirm` / `refreshToken` / - * `password` was likely silently broken or fell through a backend - * tolerance path — the new shape is what the swaggo-emitted - * `openapi.yaml` declares, and what `c.ShouldBindJSON` of the - * corresponding DTOs actually requires. - */ -import { - postAuthLogin, - postAuthLogout, - postAuthRefresh, - postAuthRegister, - postAuthResendVerification, - postAuthPasswordReset, - postAuthPasswordResetRequest, - postAuthVerifyEmail, - getAuthCheckUsername, -} from '@/services/generated/auth/auth'; -import { handleApiServiceError } from '@/utils/serviceErrorHandler'; -import type { - LoginFormData, - RegisterFormData, - ForgotPasswordFormData, - ResetPasswordFormData, -} from '../types'; - -// INT-TYPE-008: AuthResponse aligned with backend LoginResponse (dto.LoginResponse) -// Backend format: { user: UserResponse, token: TokenResponse, requires_2fa?: boolean } -export interface AuthResponse { - user: { - id: string; - email: string; - username?: string; - }; - token: { - access_token: string; - refresh_token: string; - expires_in: number; - }; - requires_2fa?: boolean; // BE-API-001: Flag indicating 2FA is required -} - -/** - * Authentifie un utilisateur avec email et mot de passe - * @param data - Données de connexion (email, password) - * @returns Promise avec les tokens et les informations utilisateur - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function login(data: LoginFormData): Promise { - try { - const response = await postAuthLogin( - data as Parameters[0], - ); - return response as unknown as AuthResponse; - } catch (error) { - handleApiServiceError(error, { - context: 'auth', - customMessages: { - 401: 'Email ou mot de passe incorrect', - 403: "Votre compte n'est pas vérifié. Veuillez vérifier votre email.", - }, - }); - } -} - -/** - * Enregistre un nouvel utilisateur - * @param data - Données d'inscription (email, password, username) - * @returns Promise avec les tokens et les informations utilisateur - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function register(data: RegisterFormData): Promise { - try { - const response = await postAuthRegister({ - email: data.email, - password: data.password, - password_confirmation: data.password_confirm, - username: data.username, - }); - return response as unknown as AuthResponse; - } catch (error) { - handleApiServiceError(error, { - context: 'auth', - customMessages: { - 409: "Cet email ou ce nom d'utilisateur est déjà utilisé", - 400: 'Les données fournies sont invalides', - }, - }); - } -} - -/** - * Déconnecte l'utilisateur actuel - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function logout(): Promise { - try { - // PostAuthLogoutBody.refresh_token is optional; the cookie-based session - // tear-down doesn't need a body. - await postAuthLogout({}); - } catch (error) { - handleApiServiceError(error, { context: 'auth' }); - } -} - -/** - * Rafraîchit le token d'accès avec un refresh token - * @param refreshToken - Le refresh token - * @returns Promise avec les nouveaux tokens et les informations utilisateur - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function refreshToken( - refreshToken: string, -): Promise { - try { - const response = await postAuthRefresh({ refresh_token: refreshToken }); - return response as unknown as AuthResponse; - } catch (error) { - handleApiServiceError(error, { context: 'auth' }); - } -} - -/** - * Demande une réinitialisation de mot de passe - * @param data - Données contenant l'email - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function requestPasswordReset( - data: ForgotPasswordFormData, -): Promise { - try { - await postAuthPasswordResetRequest({ email: data.email }); - } catch (error) { - handleApiServiceError(error, { context: 'auth' }); - } -} - -/** - * Réinitialise le mot de passe avec un token - * @param data - Données contenant le token et le nouveau mot de passe - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function resetPassword( - data: ResetPasswordFormData, -): Promise { - try { - await postAuthPasswordReset({ - token: data.token, - new_password: data.password, - }); - } catch (error) { - handleApiServiceError(error, { context: 'auth' }); - } -} - -/** - * Vérifie l'email avec un token de vérification - * @param token - Token de vérification - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -// Note: backend route is POST (`auth.go:VerifyEmail` / -// `routes_auth.go:107`), token still passed as `?token=` query param. -// orval handles both — we only adjust the verb client-side. -export async function verifyEmail(token: string): Promise { - try { - await postAuthVerifyEmail({ token }); - } catch (error) { - handleApiServiceError(error, { context: 'auth' }); - } -} - -/** - * Renvoie l'email de vérification - * @param email - Email de l'utilisateur - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function resendVerificationEmail(email: string): Promise { - try { - await postAuthResendVerification({ email }); - } catch (error) { - handleApiServiceError(error, { context: 'auth' }); - } -} - -/** - * Vérifie la disponibilité d'un nom d'utilisateur - * @param username - Le nom d'utilisateur à vérifier - * @returns Promise avec true si disponible, false sinon - * @throws ApiError en cas d'erreur - */ -// INT-API-003: Standardized error handling using handleApiServiceError -export async function checkUsernameAvailability( - username: string, -): Promise { - try { - const response = await getAuthCheckUsername({ username }); - const payload = response as unknown as - | { available: boolean } - | { data?: { available?: boolean } }; - if ('available' in payload) { - return payload.available; - } - return payload.data?.available ?? false; - } catch (error) { - handleApiServiceError(error, { context: 'auth' }); - } -} diff --git a/apps/web/src/services/__tests__/authService.test.ts b/apps/web/src/services/__tests__/authService.test.ts deleted file mode 100644 index 0e7eed6cd..000000000 --- a/apps/web/src/services/__tests__/authService.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { authService } from '../authService'; -import { apiClient } from '../api/client'; - -// Mock the apiClient module -vi.mock('../api/client', () => ({ - apiClient: { - post: vi.fn(), - get: vi.fn(), - }, -})); - -const mockedApiClient = apiClient as { - post: ReturnType; - get: ReturnType; -}; - -describe('authService', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('login', () => { - it('should login successfully and return user and token', async () => { - const mockResponse = { - user: { - id: '123', - username: 'testuser', - email: 'test@example.com', - first_name: 'Test', - last_name: 'User', - avatar: 'https://example.com/avatar.jpg', - created_at: '2024-01-01T00:00:00Z', - is_verified: false, - }, - access_token: 'access-token-123', - refresh_token: 'refresh-token-123', - }; - - mockedApiClient.post.mockResolvedValue({ data: mockResponse }); - - const result = await authService.login({ - email: 'test@example.com', - password: 'password123', - }); - - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/login', { - email: 'test@example.com', - password: 'password123', - }); - expect(result.user).toBeDefined(); - expect(result.user.id).toBe('123'); - expect(result.user.email).toBe('test@example.com'); - expect(result.token).toBeDefined(); - expect(result.token.access_token).toBe('access-token-123'); - }); - - it('should handle login errors', async () => { - const mockError = new Error('Invalid credentials'); - mockedApiClient.post.mockRejectedValue(mockError); - - await expect( - authService.login({ - email: 'test@example.com', - password: 'wrongpassword', - }), - ).rejects.toThrow('Invalid credentials'); - }); - }); - - describe('register', () => { - it('should register successfully and return user and token', async () => { - const mockResponse = { - user: { - id: '456', - username: 'newuser', - email: 'new@example.com', - first_name: 'New', - last_name: 'User', - avatar: null, - created_at: '2024-01-01T00:00:00Z', - is_verified: false, - }, - access_token: 'access-token-456', - refresh_token: 'refresh-token-456', - }; - - mockedApiClient.post.mockResolvedValue({ data: mockResponse }); - - const result = await authService.register({ - email: 'new@example.com', - password: 'password123', - username: 'newuser', - }); - - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/register', { - email: 'new@example.com', - password: 'password123', - username: 'newuser', - }); - expect(result.user).toBeDefined(); - expect(result.user.id).toBe('456'); - expect(result.user.username).toBe('newuser'); - expect(result.token.access_token).toBe('access-token-456'); - }); - }); - - describe('logout', () => { - it('should logout successfully', async () => { - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await authService.logout(); - - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/logout'); - }); - - it('should handle logout errors gracefully', async () => { - const mockError = new Error('Network error'); - mockedApiClient.post.mockRejectedValue(mockError); - - // logout swallows errors and logs a warning - // Should not throw - await expect(authService.logout()).resolves.toBeUndefined(); - }); - }); - - describe('getCurrentUser', () => { - it('should get current user successfully', async () => { - const mockResponse = { - user: { - id: '123', - username: 'testuser', - email: 'test@example.com', - first_name: 'Test', - last_name: 'User', - avatar: 'https://example.com/avatar.jpg', - created_at: '2024-01-01T00:00:00Z', - is_verified: true, - }, - }; - - mockedApiClient.get.mockResolvedValue({ data: mockResponse }); - - const result = await authService.getCurrentUser(); - - expect(mockedApiClient.get).toHaveBeenCalledWith('/auth/me'); - expect(result).toBeDefined(); - expect(result.id).toBe('123'); - expect(result.email).toBe('test@example.com'); - expect(result.tier).toBe('Pro'); // is_verified = true - }); - }); - - describe('checkUsername', () => { - it('should check username availability', async () => { - const mockResponse = { available: true }; - mockedApiClient.get.mockResolvedValue({ data: mockResponse }); - - const result = await authService.checkUsername('testuser'); - - expect(mockedApiClient.get).toHaveBeenCalledWith( - '/auth/check-username?username=testuser', - ); - expect(result).toEqual(mockResponse); - }); - - it('should return false for unavailable username', async () => { - const mockResponse = { available: false }; - mockedApiClient.get.mockResolvedValue({ data: mockResponse }); - - const result = await authService.checkUsername('takenuser'); - expect(result.available).toBe(false); - }); - }); - - describe('verifyEmail', () => { - it('should verify email with token', async () => { - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await authService.verifyEmail('verification-token-123'); - - expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/verify-email', { - token: 'verification-token-123', - }); - }); - }); - - describe('resendVerification', () => { - it('should resend verification email', async () => { - mockedApiClient.post.mockResolvedValue({ data: {} }); - - await authService.resendVerification('test@example.com'); - - expect(mockedApiClient.post).toHaveBeenCalledWith( - '/auth/resend-verification', - { - email: 'test@example.com', - }, - ); - }); - }); -}); diff --git a/apps/web/src/services/api/search.ts b/apps/web/src/services/api/search.ts index 84e6ac475..fca287bef 100644 --- a/apps/web/src/services/api/search.ts +++ b/apps/web/src/services/api/search.ts @@ -1,5 +1,5 @@ -import { apiClient } from '@/services/api/client'; import { SearchResults } from '@/types/search'; +import { getSearch, getSearchSuggestions } from '@/services/generated/search/search'; /** v0.10.2 F363: Optional cursor/limit for future pagination */ export interface SearchParams { @@ -9,31 +9,45 @@ export interface SearchParams { limit?: number; } +// v1.0.9 item 1.6 — search endpoints now have OpenAPI annotations +// (`@Router /search [get]` + `/search/suggestions [get]`), so orval emits +// typed clients we delegate to. The frontend SearchResults shape is kept +// as-is because the backend response wraps the unified result in the +// generic envelope `handlers.APIResponse{data}` — orval types `data` as +// `unknown`, so we narrow at the boundary. export const searchApi = { search: async ( query: string, types?: string[], opts?: { cursor?: string; limit?: number } ): Promise => { - const params: Record = { q: query }; + const params: { q: string; type?: string[]; cursor?: string; limit?: number } = { q: query }; if (types && types.length > 0) { params.type = types; } if (opts?.cursor) params.cursor = opts.cursor; - if (opts?.limit != null && opts.limit > 0) - params.limit = String(Math.min(opts.limit, 50)); - const response = await apiClient.get(`/search`, { - params, - }); - return response.data; + if (opts?.limit != null && opts.limit > 0) { + params.limit = Math.min(opts.limit, 50); + } + const response = await getSearch(params); + return unwrapApiData(response); }, suggestions: async (query: string, limit?: number): Promise => { - const params: Record = { q: query }; - if (limit != null && limit > 0) params.limit = String(Math.min(limit, 20)); - const response = await apiClient.get(`/search/suggestions`, { - params, - }); - return response.data; + const params: { q: string; limit?: number } = { q: query }; + if (limit != null && limit > 0) { + params.limit = Math.min(limit, 20); + } + const response = await getSearchSuggestions(params); + return unwrapApiData(response); }, }; + +// Backend routes wrap the payload in `{success, data, ...}`; orval types +// `data` as `unknown`. Narrow once here so call sites stay typed. +function unwrapApiData(response: unknown): T { + if (response && typeof response === 'object' && 'data' in response) { + return (response as { data: T }).data; + } + return response as T; +} diff --git a/apps/web/src/services/api/social.ts b/apps/web/src/services/api/social.ts index 5835b2f03..4f2615230 100644 --- a/apps/web/src/services/api/social.ts +++ b/apps/web/src/services/api/social.ts @@ -1,9 +1,14 @@ /** * Social API Service * v0.203 Lot L: Trending hashtags + * + * v1.0.9 item 1.6 — `/social/trending` now has OpenAPI annotations so + * orval generates a typed client; this wrapper delegates to it and + * narrows the envelope (`handlers.APIResponse{data: {tags: [...]}}`) + * back to the simple TrendingTag[] the UI expects. */ -import { apiClient } from './client'; +import { getSocialTrending } from '@/services/generated/social/social'; export interface TrendingTag { tag: string; @@ -16,9 +21,14 @@ export interface TrendingResponse { export const socialApi = { async getTrending(limit = 10): Promise { - const response = await apiClient.get('/social/trending', { - params: { limit }, - }); - return response.data?.tags ?? []; + const response = await getSocialTrending({ limit }); + const payload = response as { data?: { tags?: TrendingTag[] } } | { tags?: TrendingTag[] }; + if ('data' in payload && payload.data?.tags) { + return payload.data.tags; + } + if ('tags' in payload && payload.tags) { + return payload.tags; + } + return []; }, }; diff --git a/apps/web/src/services/authService.ts b/apps/web/src/services/authService.ts deleted file mode 100644 index a276cd102..000000000 --- a/apps/web/src/services/authService.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { apiClient } from '@/services/api/client'; -import { User, UserDTO } from '../types'; -import { logger } from '@/utils/logger'; - -export interface AuthLoginResult { - user: User; - token: { access_token: string; refresh_token: string }; -} - -export interface AuthRegisterResult { - user: User; - token: { access_token: string; refresh_token: string }; -} - -export interface LoginCredentials { - email: string; - password: string; - remember_me?: boolean; -} - -export interface RegisterData { - username: string; - email: string; - password: string; - first_name?: string; - last_name?: string; -} - -// Helper to map Backend DTO to Frontend Model -const mapUserDTO = (dto: UserDTO): User => ({ - id: dto.id, - username: dto.username, - email: dto.email, - first_name: dto.first_name, - last_name: dto.last_name, - avatar: dto.avatar || 'https://via.placeholder.com/200', - banner: dto.banner, - bio: dto.bio, - location: dto.location, - roles: dto.role ? [dto.role] : ['USER'], - role: dto.role || 'user', - // Default boolean flags if missing in DTO - is_active: true, - is_verified: dto.is_verified ?? false, - is_admin: false, - is_public: true, - status: 'online', - created_at: dto.created_at - ? new Date(dto.created_at).toISOString() - : new Date().toISOString(), - updated_at: dto.created_at - ? new Date(dto.created_at).toISOString() - : new Date().toISOString(), - tier: dto.is_verified ? 'Pro' : 'Free', - stats: { followers: 0, following: 0, tracks: 0, plays: 0 }, -}); - -export const authService = { - login: async (credentials: LoginCredentials): Promise => { - const response = await apiClient.post<{ - user: UserDTO; - access_token: string; - refresh_token: string; - }>('/auth/login', credentials); - const data = response.data; - return { - user: mapUserDTO(data.user), - token: { - access_token: data.access_token, - refresh_token: data.refresh_token, - }, - }; - }, - - register: async (data: RegisterData): Promise => { - const response = await apiClient.post<{ - user: UserDTO; - access_token: string; - refresh_token: string; - }>('/auth/register', data); - const payload = response.data; - return { - user: mapUserDTO(payload.user), - token: { - access_token: payload.access_token, - refresh_token: payload.refresh_token, - }, - }; - }, - - logout: async () => { - try { - await apiClient.post('/auth/logout'); - } catch (e) { - logger.warn('Logout failed on server', { error: e }); - } - }, - - getCurrentUser: async () => { - const response = await apiClient.get<{ user: UserDTO }>('/auth/me'); - return mapUserDTO(response.data.user); - }, - - checkUsername: async (username: string) => { - // Assuming endpoint returns { available: boolean } - const response = await apiClient.get<{ available: boolean }>( - `/auth/check-username?username=${username}`, - ); - return response.data; - }, - - verifyEmail: (token: string) => - apiClient.post('/auth/verify-email', { token }), - - resendVerification: (email: string) => - apiClient.post('/auth/resend-verification', { email }), -}; diff --git a/apps/web/src/services/generated/model/getSearchParams.ts b/apps/web/src/services/generated/model/getSearchParams.ts new file mode 100644 index 000000000..f4df5b9d5 --- /dev/null +++ b/apps/web/src/services/generated/model/getSearchParams.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v8.8.1 🍺 + * Do not edit manually. + * Veza Backend API + * Backend API for Veza platform. + * OpenAPI spec version: 1.2.0 + */ + +export type GetSearchParams = { +/** + * Search query + */ +q: string; +/** + * Restrict to one or more entity types: track, user, playlist + */ +type?: string[]; +/** + * Opaque pagination cursor + */ +cursor?: string; +/** + * Page size (max 50) + */ +limit?: number; +}; diff --git a/apps/web/src/services/generated/model/getSearchSuggestionsParams.ts b/apps/web/src/services/generated/model/getSearchSuggestionsParams.ts new file mode 100644 index 000000000..1019592e2 --- /dev/null +++ b/apps/web/src/services/generated/model/getSearchSuggestionsParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.8.1 🍺 + * Do not edit manually. + * Veza Backend API + * Backend API for Veza platform. + * OpenAPI spec version: 1.2.0 + */ + +export type GetSearchSuggestionsParams = { +/** + * Partial query (min 1 char) + */ +q: string; +/** + * Max number of suggestions (1..20, default 5) + */ +limit?: number; +}; diff --git a/apps/web/src/services/generated/model/getSocialTrendingParams.ts b/apps/web/src/services/generated/model/getSocialTrendingParams.ts new file mode 100644 index 000000000..bf2c38538 --- /dev/null +++ b/apps/web/src/services/generated/model/getSocialTrendingParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.8.1 🍺 + * Do not edit manually. + * Veza Backend API + * Backend API for Veza platform. + * OpenAPI spec version: 1.2.0 + */ + +export type GetSocialTrendingParams = { +/** + * Max tags to return (1..50, default 10) + */ +limit?: number; +}; diff --git a/apps/web/src/services/generated/model/index.ts b/apps/web/src/services/generated/model/index.ts index e600a0298..ab3c1ebad 100644 --- a/apps/web/src/services/generated/model/index.ts +++ b/apps/web/src/services/generated/model/index.ts @@ -99,6 +99,9 @@ export * from './getQueueSessionToken200'; export * from './getQueueSessionToken200Data'; export * from './getQueueSessionToken200DataItemsItem'; export * from './getQueueSessionToken200DataSession'; +export * from './getSearchParams'; +export * from './getSearchSuggestionsParams'; +export * from './getSocialTrendingParams'; export * from './getTracks200'; export * from './getTracks200Data'; export * from './getTracks200DataPagination'; diff --git a/apps/web/src/services/generated/search/search.ts b/apps/web/src/services/generated/search/search.ts new file mode 100644 index 000000000..bcd0f7e63 --- /dev/null +++ b/apps/web/src/services/generated/search/search.ts @@ -0,0 +1,307 @@ +/** + * Generated by orval v8.8.1 🍺 + * Do not edit manually. + * Veza Backend API + * Backend API for Veza platform. + * OpenAPI spec version: 1.2.0 + */ +import { + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + GetSearchParams, + GetSearchSuggestionsParams, + InternalHandlersAPIResponse +} from '../model'; + +import { vezaMutator } from '../../api/orval-mutator'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * Postgres FTS-backed search across tracks, users, and playlists. Optional `type` filter accepts repeated values (e.g., ?type=track&type=user). v1.0.9 item 1.6 — annotation added so orval can generate a typed client. + * @summary Unified search + */ +export type getSearchResponse200 = { + data: InternalHandlersAPIResponse + status: 200 +} + +export type getSearchResponse400 = { + data: InternalHandlersAPIResponse + status: 400 +} + +export type getSearchResponse500 = { + data: InternalHandlersAPIResponse + status: 500 +} + +export type getSearchResponseSuccess = (getSearchResponse200) & { + headers: Headers; +}; +export type getSearchResponseError = (getSearchResponse400 | getSearchResponse500) & { + headers: Headers; +}; + +export type getSearchResponse = (getSearchResponseSuccess | getSearchResponseError) + +export const getGetSearchUrl = (params: GetSearchParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + const explodeParameters = ["type"]; + + if (Array.isArray(value) && explodeParameters.includes(key)) { + value.forEach((v) => { + normalizedParams.append(key, v === null ? 'null' : v.toString()); + }); + return; + } + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/search?${stringifiedParams}` : `/search` +} + +export const getSearch = async (params: GetSearchParams, options?: RequestInit): Promise => { + + return vezaMutator(getGetSearchUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetSearchQueryKey = (params?: GetSearchParams,) => { + return [ + `/search`, ...(params ? [params] : []) + ] as const; + } + + +export const getGetSearchQueryOptions = >, TError = InternalHandlersAPIResponse>(params: GetSearchParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetSearchQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => getSearch(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetSearchQueryResult = NonNullable>> +export type GetSearchQueryError = InternalHandlersAPIResponse + + +export function useGetSearch>, TError = InternalHandlersAPIResponse>( + params: GetSearchParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetSearch>, TError = InternalHandlersAPIResponse>( + params: GetSearchParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetSearch>, TError = InternalHandlersAPIResponse>( + params: GetSearchParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Unified search + */ + +export function useGetSearch>, TError = InternalHandlersAPIResponse>( + params: GetSearchParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetSearchQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + +/** + * Lightweight autocomplete used by the search bar. Returns a small SearchResult subset (typically tracks + users + playlists), capped at `limit` (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate a typed client. + * @summary Search suggestions + */ +export type getSearchSuggestionsResponse200 = { + data: InternalHandlersAPIResponse + status: 200 +} + +export type getSearchSuggestionsResponse400 = { + data: InternalHandlersAPIResponse + status: 400 +} + +export type getSearchSuggestionsResponse500 = { + data: InternalHandlersAPIResponse + status: 500 +} + +export type getSearchSuggestionsResponseSuccess = (getSearchSuggestionsResponse200) & { + headers: Headers; +}; +export type getSearchSuggestionsResponseError = (getSearchSuggestionsResponse400 | getSearchSuggestionsResponse500) & { + headers: Headers; +}; + +export type getSearchSuggestionsResponse = (getSearchSuggestionsResponseSuccess | getSearchSuggestionsResponseError) + +export const getGetSearchSuggestionsUrl = (params: GetSearchSuggestionsParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/search/suggestions?${stringifiedParams}` : `/search/suggestions` +} + +export const getSearchSuggestions = async (params: GetSearchSuggestionsParams, options?: RequestInit): Promise => { + + return vezaMutator(getGetSearchSuggestionsUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetSearchSuggestionsQueryKey = (params?: GetSearchSuggestionsParams,) => { + return [ + `/search/suggestions`, ...(params ? [params] : []) + ] as const; + } + + +export const getGetSearchSuggestionsQueryOptions = >, TError = InternalHandlersAPIResponse>(params: GetSearchSuggestionsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetSearchSuggestionsQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => getSearchSuggestions(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetSearchSuggestionsQueryResult = NonNullable>> +export type GetSearchSuggestionsQueryError = InternalHandlersAPIResponse + + +export function useGetSearchSuggestions>, TError = InternalHandlersAPIResponse>( + params: GetSearchSuggestionsParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetSearchSuggestions>, TError = InternalHandlersAPIResponse>( + params: GetSearchSuggestionsParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetSearchSuggestions>, TError = InternalHandlersAPIResponse>( + params: GetSearchSuggestionsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Search suggestions + */ + +export function useGetSearchSuggestions>, TError = InternalHandlersAPIResponse>( + params: GetSearchSuggestionsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetSearchSuggestionsQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + diff --git a/apps/web/src/services/generated/social/social.ts b/apps/web/src/services/generated/social/social.ts new file mode 100644 index 000000000..c130a8338 --- /dev/null +++ b/apps/web/src/services/generated/social/social.ts @@ -0,0 +1,161 @@ +/** + * Generated by orval v8.8.1 🍺 + * Do not edit manually. + * Veza Backend API + * Backend API for Veza platform. + * OpenAPI spec version: 1.2.0 + */ +import { + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + GetSocialTrendingParams, + InternalHandlersAPIResponse +} from '../model'; + +import { vezaMutator } from '../../api/orval-mutator'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * Returns the top hashtags surfacing in posts published recently. Counts are aggregated, not user-personalised — discovery is by declarative tags, not behavioural ranking (cf. CLAUDE.md rule 7). v1.0.9 item 1.6 — annotation added so orval can generate a typed client. + * @summary Trending hashtags + */ +export type getSocialTrendingResponse200 = { + data: InternalHandlersAPIResponse + status: 200 +} + +export type getSocialTrendingResponse500 = { + data: InternalHandlersAPIResponse + status: 500 +} + +export type getSocialTrendingResponseSuccess = (getSocialTrendingResponse200) & { + headers: Headers; +}; +export type getSocialTrendingResponseError = (getSocialTrendingResponse500) & { + headers: Headers; +}; + +export type getSocialTrendingResponse = (getSocialTrendingResponseSuccess | getSocialTrendingResponseError) + +export const getGetSocialTrendingUrl = (params?: GetSocialTrendingParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/social/trending?${stringifiedParams}` : `/social/trending` +} + +export const getSocialTrending = async (params?: GetSocialTrendingParams, options?: RequestInit): Promise => { + + return vezaMutator(getGetSocialTrendingUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetSocialTrendingQueryKey = (params?: GetSocialTrendingParams,) => { + return [ + `/social/trending`, ...(params ? [params] : []) + ] as const; + } + + +export const getGetSocialTrendingQueryOptions = >, TError = InternalHandlersAPIResponse>(params?: GetSocialTrendingParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetSocialTrendingQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => getSocialTrending(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetSocialTrendingQueryResult = NonNullable>> +export type GetSocialTrendingQueryError = InternalHandlersAPIResponse + + +export function useGetSocialTrending>, TError = InternalHandlersAPIResponse>( + params: undefined | GetSocialTrendingParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetSocialTrending>, TError = InternalHandlersAPIResponse>( + params?: GetSocialTrendingParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetSocialTrending>, TError = InternalHandlersAPIResponse>( + params?: GetSocialTrendingParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Trending hashtags + */ + +export function useGetSocialTrending>, TError = InternalHandlersAPIResponse>( + params?: GetSocialTrendingParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetSocialTrendingQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + diff --git a/veza-backend-api/docs/docs.go b/veza-backend-api/docs/docs.go index 7cfb61579..dcbcea717 100644 --- a/veza-backend-api/docs/docs.go +++ b/veza-backend-api/docs/docs.go @@ -4197,6 +4197,150 @@ const docTemplate = `{ } } }, + "/search": { + "get": { + "description": "Postgres FTS-backed search across tracks, users, and playlists. Optional ` + "`" + `type` + "`" + ` filter accepts repeated values (e.g., ?type=track\u0026type=user). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.", + "produces": [ + "application/json" + ], + "tags": [ + "Search" + ], + "summary": "Unified search", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query", + "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Restrict to one or more entity types: track, user, playlist", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Opaque pagination cursor", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (max 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Search results", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "500": { + "description": "Search failed", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + } + } + } + }, + "/search/suggestions": { + "get": { + "description": "Lightweight autocomplete used by the search bar. Returns a small SearchResult subset (typically tracks + users + playlists), capped at ` + "`" + `limit` + "`" + ` (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.", + "produces": [ + "application/json" + ], + "tags": [ + "Search" + ], + "summary": "Search suggestions", + "parameters": [ + { + "type": "string", + "description": "Partial query (min 1 char)", + "name": "q", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Max number of suggestions (1..20, default 5)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Suggestions", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "500": { + "description": "Suggestions failed", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + } + } + } + }, + "/social/trending": { + "get": { + "description": "Returns the top hashtags surfacing in posts published recently. Counts are aggregated, not user-personalised — discovery is by declarative tags, not behavioural ranking (cf. CLAUDE.md rule 7). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.", + "produces": [ + "application/json" + ], + "tags": [ + "Social" + ], + "summary": "Trending hashtags", + "parameters": [ + { + "type": "integer", + "description": "Max tags to return (1..50, default 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Trending tags", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "500": { + "description": "Failed to compute trending", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + } + } + } + }, "/tracks": { "get": { "description": "List tracks with pagination, filters, sort. Cursor-based when ?cursor provided, otherwise page/limit offset.", diff --git a/veza-backend-api/docs/swagger.json b/veza-backend-api/docs/swagger.json index e982985da..b88b6248d 100644 --- a/veza-backend-api/docs/swagger.json +++ b/veza-backend-api/docs/swagger.json @@ -4191,6 +4191,150 @@ } } }, + "/search": { + "get": { + "description": "Postgres FTS-backed search across tracks, users, and playlists. Optional `type` filter accepts repeated values (e.g., ?type=track\u0026type=user). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.", + "produces": [ + "application/json" + ], + "tags": [ + "Search" + ], + "summary": "Unified search", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query", + "required": true + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi", + "description": "Restrict to one or more entity types: track, user, playlist", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Opaque pagination cursor", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (max 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Search results", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "500": { + "description": "Search failed", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + } + } + } + }, + "/search/suggestions": { + "get": { + "description": "Lightweight autocomplete used by the search bar. Returns a small SearchResult subset (typically tracks + users + playlists), capped at `limit` (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.", + "produces": [ + "application/json" + ], + "tags": [ + "Search" + ], + "summary": "Search suggestions", + "parameters": [ + { + "type": "string", + "description": "Partial query (min 1 char)", + "name": "q", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Max number of suggestions (1..20, default 5)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Suggestions", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "500": { + "description": "Suggestions failed", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + } + } + } + }, + "/social/trending": { + "get": { + "description": "Returns the top hashtags surfacing in posts published recently. Counts are aggregated, not user-personalised — discovery is by declarative tags, not behavioural ranking (cf. CLAUDE.md rule 7). v1.0.9 item 1.6 — annotation added so orval can generate a typed client.", + "produces": [ + "application/json" + ], + "tags": [ + "Social" + ], + "summary": "Trending hashtags", + "parameters": [ + { + "type": "integer", + "description": "Max tags to return (1..50, default 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Trending tags", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + }, + "500": { + "description": "Failed to compute trending", + "schema": { + "$ref": "#/definitions/internal_handlers.APIResponse" + } + } + } + } + }, "/tracks": { "get": { "description": "List tracks with pagination, filters, sort. Cursor-based when ?cursor provided, otherwise page/limit offset.", diff --git a/veza-backend-api/docs/swagger.yaml b/veza-backend-api/docs/swagger.yaml index ad95b1b0c..f3832ed72 100644 --- a/veza-backend-api/docs/swagger.yaml +++ b/veza-backend-api/docs/swagger.yaml @@ -3720,6 +3720,109 @@ paths: summary: Remove item from queue session tags: - Queue + /search: + get: + description: Postgres FTS-backed search across tracks, users, and playlists. + Optional `type` filter accepts repeated values (e.g., ?type=track&type=user). + v1.0.9 item 1.6 — annotation added so orval can generate a typed client. + parameters: + - description: Search query + in: query + name: q + required: true + type: string + - collectionFormat: multi + description: 'Restrict to one or more entity types: track, user, playlist' + in: query + items: + type: string + name: type + type: array + - description: Opaque pagination cursor + in: query + name: cursor + type: string + - description: Page size (max 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Search results + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "400": + description: Validation error + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "500": + description: Search failed + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + summary: Unified search + tags: + - Search + /search/suggestions: + get: + description: Lightweight autocomplete used by the search bar. Returns a small + SearchResult subset (typically tracks + users + playlists), capped at `limit` + (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate + a typed client. + parameters: + - description: Partial query (min 1 char) + in: query + name: q + required: true + type: string + - description: Max number of suggestions (1..20, default 5) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Suggestions + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "400": + description: Validation error + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "500": + description: Suggestions failed + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + summary: Search suggestions + tags: + - Search + /social/trending: + get: + description: Returns the top hashtags surfacing in posts published recently. + Counts are aggregated, not user-personalised — discovery is by declarative + tags, not behavioural ranking (cf. CLAUDE.md rule 7). v1.0.9 item 1.6 — annotation + added so orval can generate a typed client. + parameters: + - description: Max tags to return (1..50, default 10) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Trending tags + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "500": + description: Failed to compute trending + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + summary: Trending hashtags + tags: + - Social /tracks: get: description: List tracks with pagination, filters, sort. Cursor-based when ?cursor diff --git a/veza-backend-api/internal/handlers/search_handlers.go b/veza-backend-api/internal/handlers/search_handlers.go index 72e50c8f3..fd0c1e6b6 100644 --- a/veza-backend-api/internal/handlers/search_handlers.go +++ b/veza-backend-api/internal/handlers/search_handlers.go @@ -38,6 +38,18 @@ func NewSearchHandlersWithInterface(searchService SearchServiceInterface) *Searc } // Search performs a full-text search across tracks, users, and playlists +// @Summary Unified search +// @Description Postgres FTS-backed search across tracks, users, and playlists. Optional `type` filter accepts repeated values (e.g., ?type=track&type=user). v1.0.9 item 1.6 — annotation added so orval can generate a typed client. +// @Tags Search +// @Produce json +// @Param q query string true "Search query" +// @Param type query []string false "Restrict to one or more entity types: track, user, playlist" collectionFormat(multi) +// @Param cursor query string false "Opaque pagination cursor" +// @Param limit query int false "Page size (max 50)" +// @Success 200 {object} handlers.APIResponse "Search results" +// @Failure 400 {object} handlers.APIResponse "Validation error" +// @Failure 500 {object} handlers.APIResponse "Search failed" +// @Router /search [get] func (sh *SearchHandlers) Search(c *gin.Context) { query := c.Query("q") if query == "" { @@ -57,6 +69,16 @@ func (sh *SearchHandlers) Search(c *gin.Context) { } // Suggestions returns autocomplete suggestions for the search input +// @Summary Search suggestions +// @Description Lightweight autocomplete used by the search bar. Returns a small SearchResult subset (typically tracks + users + playlists), capped at `limit` (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate a typed client. +// @Tags Search +// @Produce json +// @Param q query string true "Partial query (min 1 char)" +// @Param limit query int false "Max number of suggestions (1..20, default 5)" +// @Success 200 {object} handlers.APIResponse "Suggestions" +// @Failure 400 {object} handlers.APIResponse "Validation error" +// @Failure 500 {object} handlers.APIResponse "Suggestions failed" +// @Router /search/suggestions [get] func (sh *SearchHandlers) Suggestions(c *gin.Context) { query := c.Query("q") if query == "" { diff --git a/veza-backend-api/internal/handlers/social.go b/veza-backend-api/internal/handlers/social.go index 08e46d0db..50987b29c 100644 --- a/veza-backend-api/internal/handlers/social.go +++ b/veza-backend-api/internal/handlers/social.go @@ -248,6 +248,14 @@ func (h *SocialHandler) GetExplore(c *gin.Context) { } // GetTrending returns trending hashtags from recent posts (v0.203 Lot L) +// @Summary Trending hashtags +// @Description Returns the top hashtags surfacing in posts published recently. Counts are aggregated, not user-personalised — discovery is by declarative tags, not behavioural ranking (cf. CLAUDE.md rule 7). v1.0.9 item 1.6 — annotation added so orval can generate a typed client. +// @Tags Social +// @Produce json +// @Param limit query int false "Max tags to return (1..50, default 10)" +// @Success 200 {object} handlers.APIResponse "Trending tags" +// @Failure 500 {object} handlers.APIResponse "Failed to compute trending" +// @Router /social/trending [get] func (h *SocialHandler) GetTrending(c *gin.Context) { limitParam := c.DefaultQuery("limit", "10") limit := 10 diff --git a/veza-backend-api/openapi.yaml b/veza-backend-api/openapi.yaml index ad95b1b0c..f3832ed72 100644 --- a/veza-backend-api/openapi.yaml +++ b/veza-backend-api/openapi.yaml @@ -3720,6 +3720,109 @@ paths: summary: Remove item from queue session tags: - Queue + /search: + get: + description: Postgres FTS-backed search across tracks, users, and playlists. + Optional `type` filter accepts repeated values (e.g., ?type=track&type=user). + v1.0.9 item 1.6 — annotation added so orval can generate a typed client. + parameters: + - description: Search query + in: query + name: q + required: true + type: string + - collectionFormat: multi + description: 'Restrict to one or more entity types: track, user, playlist' + in: query + items: + type: string + name: type + type: array + - description: Opaque pagination cursor + in: query + name: cursor + type: string + - description: Page size (max 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Search results + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "400": + description: Validation error + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "500": + description: Search failed + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + summary: Unified search + tags: + - Search + /search/suggestions: + get: + description: Lightweight autocomplete used by the search bar. Returns a small + SearchResult subset (typically tracks + users + playlists), capped at `limit` + (1..20, default 5). v1.0.9 item 1.6 — annotation added so orval can generate + a typed client. + parameters: + - description: Partial query (min 1 char) + in: query + name: q + required: true + type: string + - description: Max number of suggestions (1..20, default 5) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Suggestions + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "400": + description: Validation error + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "500": + description: Suggestions failed + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + summary: Search suggestions + tags: + - Search + /social/trending: + get: + description: Returns the top hashtags surfacing in posts published recently. + Counts are aggregated, not user-personalised — discovery is by declarative + tags, not behavioural ranking (cf. CLAUDE.md rule 7). v1.0.9 item 1.6 — annotation + added so orval can generate a typed client. + parameters: + - description: Max tags to return (1..50, default 10) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Trending tags + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + "500": + description: Failed to compute trending + schema: + $ref: '#/definitions/internal_handlers.APIResponse' + summary: Trending hashtags + tags: + - Social /tracks: get: description: List tracks with pagination, filters, sort. Cursor-based when ?cursor