Unverified Commit 77e6c855 authored by Kourser's avatar Kourser
Browse files

Inscription : le slug se déduit du nom au lieu d'être demandé

La suggestion ne captait que la PREMIÈRE lettre frappée : conditionnée
par « champ vide », elle s'arrêtait dès la deuxième. « RCA » donnait
« r », que l'API refusait ensuite pour longueur insuffisante.

Mais le champ lui-même n'avait rien à faire là. « Slug (identifiant
public) » ne veut rien dire pour qui découvre le produit, c'était le
seul champ du formulaire capable d'échouer sur une règle invisible, et
le demandeur n'a de toute façon pas le dernier mot puisqu'un
administrateur approuve. Pire : si l'identifiant demandé était pris
entre-temps, la modération renvoyait l'administrateur le négocier avec
un inconnu.

Il est donc dérivé du nom, côté serveur, et re-dérivé à l'approbation
s'il a été pris depuis : la modération porte sur la légitimité de la
demande, pas sur une chaîne de caractères.

Le champ reste dans la console d'instance — un exploitant sait ce que
c'est — avec la suggestion corrigée : elle suit le nom tant que
personne n'a saisi l'identifiant à la main.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent c6d6e908
Loading
Loading
Loading
Loading
+7 −10
Original line number Diff line number Diff line
import { IsEmail, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '../../auth/password-policy';

const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;

export class SignupDto {
  @IsString()
  @MinLength(2)
  @MaxLength(80)
  organisationName!: string;

  @IsString()
  @MinLength(2)
  @MaxLength(40)
  @Matches(SLUG, {
    message: 'Le slug ne peut contenir que des minuscules, des chiffres et des tirets',
  })
  slug!: string;
  /*
   * PAS de slug ici, a dessein. L'identifiant public est derive du nom par le
   * serveur : le demandeur n'a aucune raison de connaitre cette notion, et ce
   * champ etait le seul du formulaire a pouvoir echouer sur une regle qu'il ne
   * voyait pas. L'exploitant, lui, garde la main depuis la console d'instance.
   */

  @IsString()
  @MinLength(1)
+24 −10
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ import { hash } from 'bcryptjs';
import type { SignupMode } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../common/audit.service';
import { deriveSlug } from '../common/slug';
import { MailerService } from '../platform-mail/mailer.service';
import { AccountService } from './account.service';
import type { SignupDto } from './dto/signup.dto';
@@ -69,10 +70,9 @@ export class SignupService {
      throw new ForbiddenException("Les inscriptions sont fermées pour le moment");
    }

    const slug = dto.slug.trim().toLowerCase();
    if (await this.prisma.tenant.findUnique({ where: { slug } })) {
      throw new ConflictException('Ce slug est déjà utilisé');
    }
    // L'identifiant public se deduit du nom. Deux organisations homonymes ne se
    // marchent pas dessus : la seconde est numerotee.
    const slug = await this.slugLibre(dto.organisationName);

    if (mode === 'MODERATED') {
      const pending = await this.prisma.signupRequest.findFirst({
@@ -100,6 +100,15 @@ export class SignupService {
    return { status: 'CREATED' };
  }

  /** Slug derive du nom, garanti libre au moment de l'appel. */
  private slugLibre(organisationName: string): Promise<string> {
    return deriveSlug(
      organisationName,
      async (candidat) =>
        (await this.prisma.tenant.findUnique({ where: { slug: candidat } })) !== null,
    );
  }

  // --- Moderation (console d'instance) ---

  listRequests(status?: 'PENDING' | 'APPROVED' | 'REJECTED') {
@@ -112,14 +121,19 @@ export class SignupService {

  async approve(id: string, decidedByUserId: string): Promise<void> {
    const req = await this.requirePending(id);
    if (await this.prisma.tenant.findUnique({ where: { slug: req.slug } })) {
      throw new ConflictException(
        'Ce slug a été pris entre-temps : rejetez la demande ou modifiez le slug avec le demandeur',
      );
    }
    /*
     * Le slug reserve a la demande a pu etre pris depuis (une autre demande
     * approuvee, une organisation creee a la main). Il est alors re-derive, au
     * lieu de renvoyer l'administrateur negocier un identifiant avec le
     * demandeur : la moderation porte sur la legitimite de la demande, pas sur
     * une chaine de caracteres.
     */
    const slug = (await this.prisma.tenant.findUnique({ where: { slug: req.slug } }))
      ? await this.slugLibre(req.organisationName)
      : req.slug;
    const tenant = await this.provision(
      req.organisationName,
      req.slug,
      slug,
      req.contactName,
      req.contactEmail,
    );
+45 −0
Original line number Diff line number Diff line
import { deriveSlug, slugify } from './slug';

describe('slugify', () => {
  it('met en minuscules, retire les accents et la ponctuation', () => {
    expect(slugify('Groupe Méridien')).toBe('groupe-meridien');
    expect(slugify("Coopérative d'Hélios !")).toBe('cooperative-d-helios');
  });

  it('ne laisse jamais de tiret en bord', () => {
    expect(slugify('  — RCA — ')).toBe('rca');
  });

  it('tronque sans laisser de tiret final', () => {
    // La coupe tombe pile sur le tiret : sans nettoyage, le slug finirait par
    // « - » et ne passerait plus le motif attendu par l'API.
    expect(slugify('a'.repeat(39) + ' bcd')).toBe('a'.repeat(39));
    expect(slugify('a'.repeat(38) + ' bcd')).toBe('a'.repeat(38) + '-b');
  });
});

describe('deriveSlug', () => {
  const pris = (...deja: string[]) => (c: string) => Promise.resolve(deja.includes(c));

  it('rend le slug du nom quand il est libre', async () => {
    await expect(deriveSlug('RCA', pris())).resolves.toBe('rca');
  });

  it('numerote a partir de 2 quand le slug est occupe', async () => {
    await expect(deriveSlug('RCA', pris('rca'))).resolves.toBe('rca-2');
    await expect(deriveSlug('RCA', pris('rca', 'rca-2'))).resolves.toBe('rca-3');
  });

  it('garde le suffixe entier quand le nom atteint la longueur maximale', async () => {
    const long = 'b'.repeat(60);
    const slug = await deriveSlug(long, pris('b'.repeat(40)));
    // Sans reserve pour le suffixe, la troncature l'aurait mange et rendu un
    // doublon du slug deja pris.
    expect(slug).toBe('b'.repeat(38) + '-2');
    expect(slug.length).toBeLessThanOrEqual(40);
  });

  it('retombe sur un mot neutre quand le nom ne laisse aucun caractere', async () => {
    await expect(deriveSlug('', pris())).resolves.toBe('organisation');
  });
});
+50 −0
Original line number Diff line number Diff line
/**
 * Fabrication d'un identifiant public a partir d'un nom d'organisation.
 *
 * Il n'a jamais valu la peine d'etre demande a qui s'inscrit : « slug » ne veut
 * rien dire pour un futur client, et sa saisie etait le seul champ du formulaire
 * capable d'echouer sur une regle invisible. Le nom suffit a le produire.
 */

/** Longueur maximale, alignee sur la colonne et sur le DTO de la console. */
const MAX = 40;

/** Passe un texte libre en minuscules-tirets, sans accent ni ponctuation. */
export function slugify(value: string): string {
  return value
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, MAX)
    .replace(/-+$/g, '');
}

/**
 * Slug libre, derive du nom.
 *
 * `estPris` dit si un candidat existe deja — la recherche est confiee a l'appelant
 * pour que cette fonction reste pure et testable. Le suffixe numerique est ajoute
 * AVANT la troncature finale : sans cela, « -2 » pouvait etre coupe et rendre deux
 * organisations au meme slug.
 *
 * Un nom entierement non alphanumerique (ideogrammes, emoji) ne laisse rien : on
 * retombe alors sur « organisation », qui sera numerote comme les autres.
 */
export async function deriveSlug(
  name: string,
  estPris: (candidat: string) => Promise<boolean>,
): Promise<string> {
  const base = slugify(name) || 'organisation';
  if (!(await estPris(base))) return base;

  // La borne est haute a dessein : elle n'existe que pour ne pas boucler sans fin
  // si la fonction de recherche se met a toujours repondre « pris ».
  for (let n = 2; n <= 999; n += 1) {
    const suffixe = `-${n}`;
    const candidat = `${base.slice(0, MAX - suffixe.length).replace(/-+$/g, '')}${suffixe}`;
    if (!(await estPris(candidat))) return candidat;
  }
  throw new Error(`Impossible de deriver un slug libre depuis « ${name} »`);
}
+15 −3
Original line number Diff line number Diff line
@@ -863,13 +863,22 @@ function CreateTenantForm({ onCreated }: { onCreated: () => Promise<void> }): JS
  const { t } = useT();
  const [name, setName] = useState('');
  const [slug, setSlug] = useState('');
  /** Vrai des que l'exploitant a touche au champ : la suggestion s'efface devant lui. */
  const [slugSaisi, setSlugSaisi] = useState(false);
  const [adminEmail, setAdminEmail] = useState('');
  const [adminName, setAdminName] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [created, setCreated] = useState<{ email: string; password: string | null } | null>(null);

  /** Le slug est l'identifiant public : on le propose depuis le nom, modifiable. */
  /**
   * Le slug SUIT le nom tant que personne ne l'a saisi a la main.
   *
   * La condition precedente — proposer seulement si le champ etait vide —
   * n'attrapait que la PREMIERE lettre frappee : des la deuxieme, le slug n'etait
   * plus vide et la suggestion s'arretait. « RCA » donnait « r », que l'API
   * refusait ensuite pour longueur insuffisante.
   */
  function suggestSlug(value: string): string {
    return value
      .toLowerCase()
@@ -910,14 +919,17 @@ function CreateTenantForm({ onCreated }: { onCreated: () => Promise<void> }): JS
            value={name}
            onChange={(e) => {
              setName(e.target.value);
              if (!slug) setSlug(suggestSlug(e.target.value));
              if (!slugSaisi) setSlug(suggestSlug(e.target.value));
            }}
            required
          />
          <input
            placeholder={t('Slug (identifiant public)')}
            value={slug}
            onChange={(e) => setSlug(suggestSlug(e.target.value))}
            onChange={(e) => {
              setSlugSaisi(true);
              setSlug(suggestSlug(e.target.value));
            }}
            required
          />
        </div>
Loading