TQ-Slator/assets/api/client.ts
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
Système de gestion de traductions pensé en CMS headless : un back-office
pour ceux qui traduisent, une API pour ce qui consomme.

Architecture
- Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en
  même origine — ce qui rend viable le cookie de session plutôt qu'un
  jeton en localStorage.
- Deux APIs séparées : Management (session ou clé) et Delivery
  (stateless, clé seule). Les fusionner ferait porter à chaque lecture de
  bundle le coût de la session.
- Stockage canonique en ICU MessageFormat, sérialisation par plateforme.
  Le format d'une plateforme ne contamine pas la base.
- Publication par releases immuables ; le déploiement est un déplacement
  de pointeur, donc le rollback aussi.
- Isolation multi-organisation par filtre Doctrine, avec un test
  d'architecture qui casse la CI si une entité échappe à l'invariant.

Éditeur, deux vues
- Par langue : source et cible, jamais douze colonnes. Grille virtualisée,
  saisie sans bouton « Enregistrer », panneau de contexte permanent.
- Par clé : une clé, toutes ses langues empilées et repliées. Répond à
  « ce libellé est-il prêt partout ? ».
- Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner.
- Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ
  par catégorie CLDR de la langue cible.

Administration
- Deux niveaux : projet (membres, clés API, plateformes) et organisation
  (annuaire des comptes, création de projets).
- Invitations par e-mail, jeton 256 bits stocké haché.
- Désactiver un compte coupe les sessions en cours, pas seulement les
  connexions suivantes.
- Les plateformes s'archivent ; ni elles ni les environnements ne se
  suppriment — la trace explique pourquoi telle clé existe.

CLI tqs
- PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend
  ni de Composer ni de la disponibilité de TQ-Slator.
- init / push / pull / status ; le sync est non destructif par défaut et
  son prune est scopé plateforme.

126 tests, PHPStan niveau 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:16:05 +02:00

417 lines
14 KiB
TypeScript

import type {
AdminEnvironment,
AdminPlatform,
AdminProject,
AdminProjectList,
AdminUser,
AdminUserList,
ApiKeyList,
EnvironmentList,
PlatformList,
CreatedApiKey,
CurrentUser,
InvitationPreview,
MemberList,
ProjectRole,
GridRow,
KeyRow,
NamespaceTree,
Page,
Project,
ProjectStats,
TranslationEntry,
TranslationStatus,
} from './types';
/**
* Client HTTP de la Management API.
*
* Aucune bibliothèque : `fetch` suffit, et l'API étant servie en même origine,
* le cookie de session part tout seul. La seule règle à ne pas oublier est
* `credentials: 'same-origin'`, sans quoi le navigateur n'envoie rien.
*/
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly detail?: string,
) {
super(message);
this.name = 'ApiError';
}
/** L'utilisateur n'est plus authentifié : sa session a expiré. */
get isUnauthenticated(): boolean {
return this.status === 401;
}
/** Rejet de validation — le message est destiné à être lu tel quel. */
get isValidation(): boolean {
return this.status === 422;
}
/** Quelqu'un d'autre a modifié la traduction entre-temps. */
get isConflict(): boolean {
return this.status === 409;
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init,
credentials: 'same-origin',
headers: {
Accept: 'application/ld+json',
...init.headers,
},
});
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
const payload = text ? safeParse(text) : null;
if (!response.ok) {
// API Platform renvoie `detail` ; nos contrôleurs aussi. C'est le champ
// rédigé pour un humain, celui qu'on affiche sans le reformuler.
const detail =
(payload && typeof payload === 'object' && 'detail' in payload
? String((payload as { detail: unknown }).detail)
: undefined) ?? response.statusText;
throw new ApiError(response.status, detail, detail);
}
return payload as T;
}
function safeParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Déplie une collection Hydra.
*
* JSON-LD est retenu précisément pour `totalItems` : la grille virtualisée en a
* besoin pour dimensionner sa zone de défilement sans avoir tout chargé.
*/
function toPage<T>(payload: { member?: T[]; totalItems?: number }): Page<T> {
return { items: payload.member ?? [], total: payload.totalItems ?? 0 };
}
export interface GridQuery {
projectUuid: string;
locale: string;
platform?: string;
status?: TranslationStatus;
namespace?: string;
q?: string;
unassigned?: boolean;
page?: number;
itemsPerPage?: number;
}
export const api = {
async me(): Promise<CurrentUser> {
return request<CurrentUser>('/api/v1/auth/me', { headers: { Accept: 'application/json' } });
},
async login(email: string, password: string): Promise<CurrentUser> {
return request<CurrentUser>('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, password }),
});
},
async logout(): Promise<void> {
await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'same-origin' });
},
async projects(): Promise<Project[]> {
const payload = await request<{ member?: Project[] }>('/api/v1/projects');
return payload.member ?? [];
},
async stats(projectUuid: string): Promise<ProjectStats> {
return request<ProjectStats>(`/api/v1/projects/${projectUuid}/stats`, {
headers: { Accept: 'application/json' },
});
},
async namespaces(projectUuid: string, locale: string): Promise<NamespaceTree> {
return request<NamespaceTree>(
`/api/v1/projects/${projectUuid}/namespaces?locale=${encodeURIComponent(locale)}`,
{ headers: { Accept: 'application/json' } },
);
},
async grid(query: GridQuery): Promise<Page<GridRow>> {
const params = new URLSearchParams({ locale: query.locale });
if (query.platform) params.set('platform', query.platform);
if (query.status) params.set('status', query.status);
if (query.namespace) params.set('namespace', query.namespace);
if (query.q) params.set('q', query.q);
if (query.unassigned) params.set('unassigned', '1');
params.set('page', String(query.page ?? 1));
params.set('itemsPerPage', String(query.itemsPerPage ?? 100));
const payload = await request<{ member?: GridRow[]; totalItems?: number }>(
`/api/v1/projects/${query.projectUuid}/grid?${params}`,
);
return toPage(payload);
},
// ── Administration ───────────────────────────────────────────────────
async members(project: string): Promise<MemberList> {
return request<MemberList>(`/api/v1/projects/${project}/members`, {
headers: { Accept: 'application/json' },
});
},
async invite(
project: string,
body: { email: string; role: ProjectRole; locales: string[]; isExternal: boolean },
): Promise<{ email: string; expiresAt: string; message: string }> {
return request(`/api/v1/projects/${project}/members/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async updateMember(
project: string,
memberUuid: string,
body: { role?: ProjectRole; locales?: string[] },
): Promise<void> {
await request(`/api/v1/projects/${project}/members/${memberUuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async removeMember(project: string, memberUuid: string): Promise<void> {
await request(`/api/v1/projects/${project}/members/${memberUuid}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
},
async apiKeys(project: string): Promise<ApiKeyList> {
return request<ApiKeyList>(`/api/v1/projects/${project}/api-keys`, {
headers: { Accept: 'application/json' },
});
},
async createApiKey(
project: string,
body: { name: string; environment: string; scopes: string[] },
): Promise<CreatedApiKey> {
return request<CreatedApiKey>(`/api/v1/projects/${project}/api-keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async revokeApiKey(project: string, keyUuid: string): Promise<void> {
await request(`/api/v1/projects/${project}/api-keys/${keyUuid}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
},
/**
* Vue par clé : une clé, toutes ses langues.
*
* Pas de paramètre `locale` — la réponse les contient toutes. `status`
* change de sens au passage : « au moins une langue cible est dans cet
* état », et non « la langue affichée l'est ».
*/
async keys(query: Omit<GridQuery, 'locale'> & { focus?: boolean }): Promise<Page<KeyRow>> {
const params = new URLSearchParams();
if (query.platform) params.set('platform', query.platform);
if (query.status) params.set('status', query.status);
if (query.namespace) params.set('namespace', query.namespace);
if (query.q) params.set('q', query.q);
if (query.unassigned) params.set('unassigned', '1');
// Le serveur déduit LUI-MÊME les langues concernées de l'identité
// courante : ce drapeau demande une file, il ne la décrit pas.
if (query.focus) params.set('focus', '1');
params.set('page', String(query.page ?? 1));
params.set('itemsPerPage', String(query.itemsPerPage ?? 50));
const payload = await request<{ member?: KeyRow[]; totalItems?: number }>(
`/api/v1/projects/${query.projectUuid}/keys?${params}`,
);
return toPage(payload);
},
// ── Plateformes et environnements ────────────────────────────────────
async platforms(project: string): Promise<PlatformList> {
return request<PlatformList>(`/api/v1/projects/${project}/platforms-admin`, {
headers: { Accept: 'application/json' },
});
},
async createPlatform(
project: string,
body: {
name: string;
slug: string;
kind: string;
messageFormat: string;
exportLayout: string;
defaultMaxLength: number | null;
},
): Promise<AdminPlatform> {
return request<AdminPlatform>(`/api/v1/projects/${project}/platforms-admin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async updatePlatform(
project: string,
platformUuid: string,
body: {
name?: string;
messageFormat?: string;
exportLayout?: string;
defaultMaxLength?: number | null;
isArchived?: boolean;
},
): Promise<AdminPlatform> {
return request<AdminPlatform>(`/api/v1/projects/${project}/platforms-admin/${platformUuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async environments(project: string): Promise<EnvironmentList> {
return request<EnvironmentList>(`/api/v1/projects/${project}/environments`, {
headers: { Accept: 'application/json' },
});
},
async createEnvironment(
project: string,
body: { name: string; slug: string },
): Promise<AdminEnvironment> {
return request<AdminEnvironment>(`/api/v1/projects/${project}/environments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async updateEnvironment(
project: string,
environmentUuid: string,
body: { name?: string },
): Promise<AdminEnvironment> {
return request<AdminEnvironment>(
`/api/v1/projects/${project}/environments/${environmentUuid}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
},
);
},
// ── Administration globale (super-admin) ─────────────────────────────
async adminUsers(search?: string): Promise<AdminUserList> {
const query = search ? `?q=${encodeURIComponent(search)}` : '';
return request<AdminUserList>(`/api/v1/admin/users${query}`, {
headers: { Accept: 'application/json' },
});
},
async updateAdminUser(
uuid: string,
body: { isActive?: boolean; isSuperAdmin?: boolean },
): Promise<AdminUser> {
return request<AdminUser>(`/api/v1/admin/users/${uuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async adminProjects(): Promise<AdminProjectList> {
return request<AdminProjectList>('/api/v1/admin/projects', {
headers: { Accept: 'application/json' },
});
},
async createProject(body: {
name: string;
slug: string;
description: string;
sourceLocale: string;
targetLocales: string[];
}): Promise<AdminProject> {
return request<AdminProject>('/api/v1/admin/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
// ── Invitation (endpoints publics) ───────────────────────────────────
async invitationPreview(token: string): Promise<InvitationPreview> {
return request<InvitationPreview>(`/api/v1/invitations/${encodeURIComponent(token)}`, {
headers: { Accept: 'application/json' },
});
},
async acceptInvitation(
token: string,
body: { name: string; password: string },
): Promise<{ email: string; name: string; message: string }> {
return request(`/api/v1/invitations/${encodeURIComponent(token)}/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async writeTranslation(
keyUuid: string,
locale: string,
body: { value: string | null; status?: TranslationStatus; version?: number },
): Promise<TranslationEntry> {
return request<TranslationEntry>(
`/api/v1/keys/${keyUuid}/translations/${encodeURIComponent(locale)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
Accept: 'application/json',
},
body: JSON.stringify(body),
},
);
},
};