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:
parent
8699004974
commit
85bdce6b46
19 changed files with 1096 additions and 983 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
26
apps/web/src/services/generated/model/getSearchParams.ts
Normal file
26
apps/web/src/services/generated/model/getSearchParams.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
307
apps/web/src/services/generated/search/search.ts
Normal file
307
apps/web/src/services/generated/search/search.ts
Normal 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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
161
apps/web/src/services/generated/social/social.ts
Normal file
161
apps/web/src/services/generated/social/social.ts
Normal 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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue