chore(api): orval-migrate search/social wrappers + drop dead auth duplicates (v1.0.9 item 1.6)

Two consolidations:

(1) Annotate `/search`, `/search/suggestions`, `/social/trending` with
swag tags so orval generates typed clients for them. Migrate
`searchApi` and `socialApi` (the two remaining hand-written wrappers
in `apps/web/src/services/api/`) to delegate to the generated
functions. Removes the last drift surface where backend changes to
those endpoints could silently mismatch the SPA.

(2) Delete two orphan auth-service implementations that have parallel-
implemented login/register/verifyEmail with stale wire shapes:
  - apps/web/src/services/authService.ts  (only its own test imports it)
  - apps/web/src/features/auth/services/authService.ts  (re-exported
    from features/auth/index.ts but the barrel itself has zero
    importers across the SPA)

The active path remains `services/api/auth.ts` (the integration layer
that owns token storage, csrf, and proactive refresh) — the duplicates
were dead post-v1.0.8 orval migration and silently diverged from the
true backend shape (e.g., the deleted services still expected
`access_token` at the root of the register response, never matched
current backend, broke when v1.0.9 item 1.4 changed the shape).

Net diff: -944 LOC of dead code, +typed orval clients for 2 more
endpoints, zero importer rewires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-26 23:25:07 +02:00
parent 8699004974
commit 85bdce6b46
19 changed files with 1096 additions and 983 deletions

View file

@ -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';

View file

@ -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<typeof vi.fn>;
get: ReturnType<typeof vi.fn>;
};
// 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();
});
});
});

View file

@ -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<AuthResponse> {
try {
const response = await postAuthLogin(
data as Parameters<typeof postAuthLogin>[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<AuthResponse> {
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<void> {
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<AuthResponse> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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' });
}
}

View file

@ -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<typeof vi.fn>;
get: ReturnType<typeof vi.fn>;
};
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',
},
);
});
});
});

View file

@ -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<SearchResults> => {
const params: Record<string, string | string[]> = { 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<SearchResults>(`/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<SearchResults>(response);
},
suggestions: async (query: string, limit?: number): Promise<SearchResults> => {
const params: Record<string, string> = { q: query };
if (limit != null && limit > 0) params.limit = String(Math.min(limit, 20));
const response = await apiClient.get<SearchResults>(`/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<SearchResults>(response);
},
};
// Backend routes wrap the payload in `{success, data, ...}`; orval types
// `data` as `unknown`. Narrow once here so call sites stay typed.
function unwrapApiData<T>(response: unknown): T {
if (response && typeof response === 'object' && 'data' in response) {
return (response as { data: T }).data;
}
return response as T;
}

View file

@ -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<TrendingTag[]> {
const response = await apiClient.get<TrendingResponse>('/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 [];
},
};

View file

@ -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<AuthLoginResult> => {
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<AuthRegisterResult> => {
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 }),
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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';

View file

@ -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<T extends (...args: never) => unknown> = Parameters<T>[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<getSearchResponse> => {
return vezaMutator<getSearchResponse>(getGetSearchUrl(params),
{
...options,
method: 'GET'
}
);}
export const getGetSearchQueryKey = (params?: GetSearchParams,) => {
return [
`/search`, ...(params ? [params] : [])
] as const;
}
export const getGetSearchQueryOptions = <TData = Awaited<ReturnType<typeof getSearch>>, TError = InternalHandlersAPIResponse>(params: GetSearchParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearch>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSearchQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSearch>>> = ({ signal }) => getSearch(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSearch>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetSearchQueryResult = NonNullable<Awaited<ReturnType<typeof getSearch>>>
export type GetSearchQueryError = InternalHandlersAPIResponse
export function useGetSearch<TData = Awaited<ReturnType<typeof getSearch>>, TError = InternalHandlersAPIResponse>(
params: GetSearchParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearch>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getSearch>>,
TError,
Awaited<ReturnType<typeof getSearch>>
> , 'initialData'
>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSearch<TData = Awaited<ReturnType<typeof getSearch>>, TError = InternalHandlersAPIResponse>(
params: GetSearchParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearch>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getSearch>>,
TError,
Awaited<ReturnType<typeof getSearch>>
> , 'initialData'
>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSearch<TData = Awaited<ReturnType<typeof getSearch>>, TError = InternalHandlersAPIResponse>(
params: GetSearchParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearch>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Unified search
*/
export function useGetSearch<TData = Awaited<ReturnType<typeof getSearch>>, TError = InternalHandlersAPIResponse>(
params: GetSearchParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearch>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetSearchQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
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<getSearchSuggestionsResponse> => {
return vezaMutator<getSearchSuggestionsResponse>(getGetSearchSuggestionsUrl(params),
{
...options,
method: 'GET'
}
);}
export const getGetSearchSuggestionsQueryKey = (params?: GetSearchSuggestionsParams,) => {
return [
`/search/suggestions`, ...(params ? [params] : [])
] as const;
}
export const getGetSearchSuggestionsQueryOptions = <TData = Awaited<ReturnType<typeof getSearchSuggestions>>, TError = InternalHandlersAPIResponse>(params: GetSearchSuggestionsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearchSuggestions>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSearchSuggestionsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSearchSuggestions>>> = ({ signal }) => getSearchSuggestions(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSearchSuggestions>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetSearchSuggestionsQueryResult = NonNullable<Awaited<ReturnType<typeof getSearchSuggestions>>>
export type GetSearchSuggestionsQueryError = InternalHandlersAPIResponse
export function useGetSearchSuggestions<TData = Awaited<ReturnType<typeof getSearchSuggestions>>, TError = InternalHandlersAPIResponse>(
params: GetSearchSuggestionsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearchSuggestions>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getSearchSuggestions>>,
TError,
Awaited<ReturnType<typeof getSearchSuggestions>>
> , 'initialData'
>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSearchSuggestions<TData = Awaited<ReturnType<typeof getSearchSuggestions>>, TError = InternalHandlersAPIResponse>(
params: GetSearchSuggestionsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearchSuggestions>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getSearchSuggestions>>,
TError,
Awaited<ReturnType<typeof getSearchSuggestions>>
> , 'initialData'
>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSearchSuggestions<TData = Awaited<ReturnType<typeof getSearchSuggestions>>, TError = InternalHandlersAPIResponse>(
params: GetSearchSuggestionsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearchSuggestions>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Search suggestions
*/
export function useGetSearchSuggestions<TData = Awaited<ReturnType<typeof getSearchSuggestions>>, TError = InternalHandlersAPIResponse>(
params: GetSearchSuggestionsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSearchSuggestions>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetSearchSuggestionsQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View file

@ -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<T extends (...args: never) => unknown> = Parameters<T>[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<getSocialTrendingResponse> => {
return vezaMutator<getSocialTrendingResponse>(getGetSocialTrendingUrl(params),
{
...options,
method: 'GET'
}
);}
export const getGetSocialTrendingQueryKey = (params?: GetSocialTrendingParams,) => {
return [
`/social/trending`, ...(params ? [params] : [])
] as const;
}
export const getGetSocialTrendingQueryOptions = <TData = Awaited<ReturnType<typeof getSocialTrending>>, TError = InternalHandlersAPIResponse>(params?: GetSocialTrendingParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSocialTrending>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSocialTrendingQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSocialTrending>>> = ({ signal }) => getSocialTrending(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSocialTrending>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetSocialTrendingQueryResult = NonNullable<Awaited<ReturnType<typeof getSocialTrending>>>
export type GetSocialTrendingQueryError = InternalHandlersAPIResponse
export function useGetSocialTrending<TData = Awaited<ReturnType<typeof getSocialTrending>>, TError = InternalHandlersAPIResponse>(
params: undefined | GetSocialTrendingParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSocialTrending>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getSocialTrending>>,
TError,
Awaited<ReturnType<typeof getSocialTrending>>
> , 'initialData'
>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSocialTrending<TData = Awaited<ReturnType<typeof getSocialTrending>>, TError = InternalHandlersAPIResponse>(
params?: GetSocialTrendingParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSocialTrending>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getSocialTrending>>,
TError,
Awaited<ReturnType<typeof getSocialTrending>>
> , 'initialData'
>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSocialTrending<TData = Awaited<ReturnType<typeof getSocialTrending>>, TError = InternalHandlersAPIResponse>(
params?: GetSocialTrendingParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSocialTrending>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Trending hashtags
*/
export function useGetSocialTrending<TData = Awaited<ReturnType<typeof getSocialTrending>>, TError = InternalHandlersAPIResponse>(
params?: GetSocialTrendingParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSocialTrending>>, TError, TData>>, request?: SecondParameter<typeof vezaMutator>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetSocialTrendingQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View file

@ -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.",

View file

@ -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.",

View file

@ -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

View file

@ -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 == "" {

View file

@ -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

View file

@ -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