feat(ia): fait valider par un administrateur toute page ecrite par l'assistant
L'assistant publiait directement dans le wiki global. La politique de contenu
ecarte la faute franche — conseil FCL, cas client, hors perimetre — mais une
heuristique ne juge pas la justesse : une page fausse mais bien ecrite la
franchissait, et se retrouvait citee comme documentation Xpeditis aupres de
tous les clients.
Domaine
- `WikiContributionStatus` : pending / published / rejected. `create()` ne
prend pas le statut en parametre — rien ne nait publie.
- `publish(reviewerId, edits?)` valide, en acceptant une correction du
relecteur, qui repasse la meme politique de contenu.
- `reject(reviewerId, note?)` ecarte sans supprimer : la liste des refus montre
ou l'assistant se trompe.
- `revise()` remet une page validee en attente : sans cela, la validation
porterait sur un texte que l'assistant a remplace depuis.
Ce qui sort du depot
- `findPublished` pour la recherche et la page publique, `findForReview` pour
l'administration. Le statut est porte par la requete, pas filtre en memoire :
une page en attente ne peut pas sortir par le chemin des clients.
- `revision()` ne compte que le publie, donc l'index vectoriel ne se reconstruit
que sur une decision.
Relecture
- `GET /admin/wiki-contributions`, `POST :id/publish`, `POST :id/reject`, sous
JwtAuthGuard + RolesGuard et @Roles('admin').
- Chaque decision est journalisee (`WIKI_CONTRIBUTION_REVIEWED`) : une page
publiee engage la marque, on doit pouvoir dire qui l'a laissee passer.
- Ecran `/admin/wiki` : file par statut, corps complet affiche, correction et
motif de refus facultatifs.
L'assistant
- La capacite rend `pending_review` et un message d'attente, plus d'URL : le
modele annonce une proposition, jamais une publication. La consigne le lui
dit explicitement.
SQL de la migration verifie sur la base locale : l'upsert sur index
d'expression met bien a jour a la casse pres, la contrainte de statut refuse
une valeur inconnue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
402d5bcbbe
commit
c35f3d7bfb
@ -1,5 +1,8 @@
|
|||||||
import { TradePassage, TradeRetrievalPort } from '@domain/ports/out/trade-assistant.port';
|
import { TradePassage, TradeRetrievalPort } from '@domain/ports/out/trade-assistant.port';
|
||||||
import { WikiContribution } from '@domain/entities/wiki-contribution.entity';
|
import {
|
||||||
|
WikiContribution,
|
||||||
|
WikiContributionStatus,
|
||||||
|
} from '@domain/entities/wiki-contribution.entity';
|
||||||
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository';
|
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository';
|
||||||
import { CapabilityActor } from '@domain/services/capability-access';
|
import { CapabilityActor } from '@domain/services/capability-access';
|
||||||
import { Capability, CapabilityInputError, parseInput } from '../capability';
|
import { Capability, CapabilityInputError, parseInput } from '../capability';
|
||||||
@ -35,7 +38,9 @@ describe('knowledgeCapabilities', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
retrieval = { search: jest.fn().mockResolvedValue([]) };
|
retrieval = { search: jest.fn().mockResolvedValue([]) };
|
||||||
contributions = {
|
contributions = {
|
||||||
findByLocale: jest.fn().mockResolvedValue([]),
|
findPublished: jest.fn().mockResolvedValue([]),
|
||||||
|
findForReview: jest.fn().mockResolvedValue([]),
|
||||||
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
findByTitle: jest.fn().mockResolvedValue(null),
|
findByTitle: jest.fn().mockResolvedValue(null),
|
||||||
save: jest.fn().mockImplementation((c: WikiContribution) => Promise.resolve(c)),
|
save: jest.fn().mockImplementation((c: WikiContribution) => Promise.resolve(c)),
|
||||||
revision: jest.fn().mockResolvedValue('0:none'),
|
revision: jest.fn().mockResolvedValue('0:none'),
|
||||||
@ -52,7 +57,7 @@ describe('knowledgeCapabilities', () => {
|
|||||||
expect(contribute().policy).toEqual({ name: 'contribute_wiki_page', scope: 'write' });
|
expect(contribute().policy).toEqual({ name: 'contribute_wiki_page', scope: 'write' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('publishes a page the wiki does not cover', async () => {
|
it('proposes a page the wiki does not cover, without publishing it', async () => {
|
||||||
const result = await invoke(contribute(), page);
|
const result = await invoke(contribute(), page);
|
||||||
|
|
||||||
expect(contributions.save).toHaveBeenCalledTimes(1);
|
expect(contributions.save).toHaveBeenCalledTimes(1);
|
||||||
@ -60,7 +65,13 @@ describe('knowledgeCapabilities', () => {
|
|||||||
expect(saved.title).toBe(page.title);
|
expect(saved.title).toBe(page.title);
|
||||||
expect(saved.locale).toBe('fr');
|
expect(saved.locale).toBe('fr');
|
||||||
expect(saved.authorUserId).toBe('user');
|
expect(saved.authorUserId).toBe('user');
|
||||||
expect(result).toMatchObject({ status: 'created', url: saved.href });
|
|
||||||
|
// Rien n'entre dans le wiki sans relecture : ni le statut, ni le resultat
|
||||||
|
// rendu au modele ne doivent laisser croire le contraire.
|
||||||
|
expect(saved.status).toBe(WikiContributionStatus.PENDING);
|
||||||
|
expect(result).toMatchObject({ status: 'pending_review' });
|
||||||
|
expect(result).not.toHaveProperty('url');
|
||||||
|
expect((result as { message: string }).message).toMatch(/validation par un administrateur/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses a page the wiki already covers', async () => {
|
it('refuses a page the wiki already covers', async () => {
|
||||||
@ -97,7 +108,29 @@ describe('knowledgeCapabilities', () => {
|
|||||||
// remplace est justement celle que la recherche remonterait.
|
// remplace est justement celle que la recherche remonterait.
|
||||||
expect(retrieval.search).not.toHaveBeenCalled();
|
expect(retrieval.search).not.toHaveBeenCalled();
|
||||||
expect(contributions.save.mock.calls[0][0].id).toBe('w1');
|
expect(contributions.save.mock.calls[0][0].id).toBe('w1');
|
||||||
expect(result).toMatchObject({ status: 'updated' });
|
expect(result).toMatchObject({ status: 'pending_review' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a revised page back through review', async () => {
|
||||||
|
const published = WikiContribution.create({
|
||||||
|
id: 'w1',
|
||||||
|
locale: 'fr',
|
||||||
|
...page,
|
||||||
|
authorUserId: 'someone',
|
||||||
|
authorOrganizationId: 'org',
|
||||||
|
}).publish('admin');
|
||||||
|
contributions.findByTitle.mockResolvedValue(published);
|
||||||
|
|
||||||
|
await invoke(contribute(), {
|
||||||
|
...page,
|
||||||
|
body: `${BODY} Le manifeste est déposé par le transitaire.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sans cela, la validation d'un administrateur porterait sur un texte que
|
||||||
|
// l'assistant a remplace depuis.
|
||||||
|
const saved: WikiContribution = contributions.save.mock.calls[0][0];
|
||||||
|
expect(saved.status).toBe(WikiContributionStatus.PENDING);
|
||||||
|
expect(saved.reviewedByUserId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses content that recommends FCL, with a message the assistant can relay', async () => {
|
it('refuses content that recommends FCL, with a message the assistant can relay', async () => {
|
||||||
|
|||||||
@ -26,6 +26,12 @@ import { Capability, CapabilityInputError } from '../capability';
|
|||||||
* transport international, jamais pour un cas client. Les regles sont dans le
|
* transport international, jamais pour un cas client. Les regles sont dans le
|
||||||
* domaine (`wiki-contribution-policy`), pas dans la description ci-dessous :
|
* domaine (`wiki-contribution-policy`), pas dans la description ci-dessous :
|
||||||
* le modele lit la description, il ne franchit que la politique.
|
* le modele lit la description, il ne franchit que la politique.
|
||||||
|
*
|
||||||
|
* L'ecriture **propose**, elle ne publie pas. Une heuristique ecarte la faute
|
||||||
|
* franche, elle ne juge pas la justesse : la page part en relecture, et c'est
|
||||||
|
* un administrateur qui la fait entrer dans le wiki. Le nom de la capacite dit
|
||||||
|
* « contribuer », son resultat dit « en attente » — le modele doit annoncer une
|
||||||
|
* proposition, pas une publication.
|
||||||
*/
|
*/
|
||||||
export function knowledgeCapabilities(
|
export function knowledgeCapabilities(
|
||||||
retrieval: TradeRetrievalPort,
|
retrieval: TradeRetrievalPort,
|
||||||
@ -99,7 +105,7 @@ function contributeWikiPage(
|
|||||||
return {
|
return {
|
||||||
policy: { name: 'contribute_wiki_page', scope: 'write' },
|
policy: { name: 'contribute_wiki_page', scope: 'write' },
|
||||||
description:
|
description:
|
||||||
"Ajoute au wiki Xpeditis une page d'information générale sur le transport international, quand la documentation ne couvre pas le sujet. Réservé au savoir durable et valable pour tous les clients : jamais un cas client, un dossier, un tarif, un contenu recommandant le FCL, ni un sujet de transport national. Met à jour la page existante si le titre est déjà pris.",
|
"Propose au wiki Xpeditis une page d'information générale sur le transport international, quand la documentation ne couvre pas le sujet. La page part en relecture : elle n'est publiée qu'après validation par un administrateur. Réservé au savoir durable et valable pour tous les clients : jamais un cas client, un dossier, un tarif, un contenu recommandant le FCL, ni un sujet de transport national. Met à jour la proposition existante si le titre est déjà pris.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@ -174,10 +180,14 @@ function contributeWikiPage(
|
|||||||
|
|
||||||
const saved = await contributions.save(page);
|
const saved = await contributions.save(page);
|
||||||
return {
|
return {
|
||||||
status: existing ? ('updated' as const) : ('created' as const),
|
// Le resultat dit l'etat reel, pas l'intention : le modele annonce une
|
||||||
|
// proposition en attente, jamais une page publiee.
|
||||||
|
status: 'pending_review' as const,
|
||||||
title: saved.title,
|
title: saved.title,
|
||||||
section: saved.section,
|
section: saved.section,
|
||||||
url: saved.href,
|
message: existing
|
||||||
|
? 'Proposition mise à jour. Elle sera publiée après validation par un administrateur Xpeditis.'
|
||||||
|
: 'Proposition enregistrée. Elle sera publiée après validation par un administrateur Xpeditis.',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,18 +14,24 @@ import { WikiRetriever } from '@infrastructure/ai/wiki-retriever';
|
|||||||
import { TypeOrmTradeConversationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-trade-conversation.repository';
|
import { TypeOrmTradeConversationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-trade-conversation.repository';
|
||||||
import { TypeOrmTradeQuotaRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-trade-quota.repository';
|
import { TypeOrmTradeQuotaRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-trade-quota.repository';
|
||||||
import { TypeOrmWikiContributionRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-wiki-contribution.repository';
|
import { TypeOrmWikiContributionRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-wiki-contribution.repository';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { McpModule } from '../mcp/mcp.module';
|
import { McpModule } from '../mcp/mcp.module';
|
||||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { TradeAssistantController } from './trade-assistant.controller';
|
import { TradeAssistantController } from './trade-assistant.controller';
|
||||||
import { TradeAssistantService } from './trade-assistant.service';
|
import { TradeAssistantService } from './trade-assistant.service';
|
||||||
|
import { WikiReviewController } from './wiki-review.controller';
|
||||||
|
import { WikiReviewService } from './wiki-review.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
// `McpModule` fournit le registre de capacites : sans lui, l'assistant
|
// `McpModule` fournit le registre de capacites : sans lui, l'assistant
|
||||||
// repond mais n'agit jamais.
|
// repond mais n'agit jamais. `UsersModule` et `AuditModule` servent la
|
||||||
imports: [ConfigModule, SubscriptionsModule, McpModule],
|
// relecture : nommer le relecteur, et garder la trace de sa decision.
|
||||||
controllers: [TradeAssistantController],
|
imports: [ConfigModule, SubscriptionsModule, McpModule, UsersModule, AuditModule],
|
||||||
|
controllers: [TradeAssistantController, WikiReviewController],
|
||||||
providers: [
|
providers: [
|
||||||
TradeAssistantService,
|
TradeAssistantService,
|
||||||
|
WikiReviewService,
|
||||||
{ provide: TRADE_AI, useClass: OpenAiTradeAdapter },
|
{ provide: TRADE_AI, useClass: OpenAiTradeAdapter },
|
||||||
{ provide: TRADE_EMBEDDINGS, useClass: OpenAiEmbeddingAdapter },
|
{ provide: TRADE_EMBEDDINGS, useClass: OpenAiEmbeddingAdapter },
|
||||||
{ provide: TRADE_RETRIEVAL, useClass: WikiRetriever },
|
{ provide: TRADE_RETRIEVAL, useClass: WikiRetriever },
|
||||||
|
|||||||
@ -94,7 +94,9 @@ describe('TradeAssistantService', () => {
|
|||||||
remove: jest.fn().mockResolvedValue(undefined),
|
remove: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
wikiContributions = {
|
wikiContributions = {
|
||||||
findByLocale: jest.fn().mockResolvedValue([]),
|
findPublished: jest.fn().mockResolvedValue([]),
|
||||||
|
findForReview: jest.fn().mockResolvedValue([]),
|
||||||
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
findByTitle: jest.fn().mockResolvedValue(null),
|
findByTitle: jest.fn().mockResolvedValue(null),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
revision: jest.fn().mockResolvedValue('0:none'),
|
revision: jest.fn().mockResolvedValue('0:none'),
|
||||||
|
|||||||
@ -75,14 +75,15 @@ export class TradeAssistantService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complements du wiki, pour la page qui les affiche.
|
* Complements **valides** du wiki, pour la page qui les affiche.
|
||||||
*
|
*
|
||||||
* Ils sont publics au sein du produit, comme le reste du wiki : la page est
|
* Ils sont publics au sein du produit, comme le reste du wiki : la page est
|
||||||
* derriere l'authentification, mais son contenu ne depend ni du compte ni de
|
* derriere l'authentification, mais son contenu ne depend ni du compte ni de
|
||||||
* l'organisation — c'est ce qui en fait un wiki global.
|
* l'organisation — c'est ce qui en fait un wiki global. Une proposition en
|
||||||
|
* attente de relecture n'y figure pas.
|
||||||
*/
|
*/
|
||||||
async wiki(locale: string) {
|
async wiki(locale: string) {
|
||||||
const pages = await this.wikiContributions.findByLocale(locale === 'en' ? 'en' : 'fr');
|
const pages = await this.wikiContributions.findPublished(locale === 'en' ? 'en' : 'fr');
|
||||||
return pages.map(page => ({
|
return pages.map(page => ({
|
||||||
id: page.id,
|
id: page.id,
|
||||||
topic: page.topic,
|
topic: page.topic,
|
||||||
|
|||||||
@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
import { WikiReviewService, WikiReviewer } from './wiki-review.service';
|
||||||
|
|
||||||
|
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
|
||||||
|
|
||||||
|
export class PublishWikiContributionDto {
|
||||||
|
/** Correction de l'intitulé de section. Absent : celui proposé est conservé. */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 120)
|
||||||
|
section?: string;
|
||||||
|
|
||||||
|
/** Correction du corps. Absent : celui proposé est conservé. */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@Length(200, 6000)
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectWikiContributionDto {
|
||||||
|
/** Motif, conservé avec la page pour relire les refus. */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 500)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relecture des pages que l'assistant propose au wiki.
|
||||||
|
*
|
||||||
|
* Reserve a l'administration : le wiki est global, donc valider une page la
|
||||||
|
* rend visible a tous les clients et citable par l'assistant dans toutes ses
|
||||||
|
* reponses suivantes. Les deux gardes sont poses explicitement, comme sur les
|
||||||
|
* autres controleurs d'administration : le role est verifie dans le processus,
|
||||||
|
* jamais dans un prompt.
|
||||||
|
*/
|
||||||
|
@ApiTags('Trade assistant')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Controller('admin/wiki-contributions')
|
||||||
|
export class WikiReviewController {
|
||||||
|
constructor(private readonly service: WikiReviewService) {}
|
||||||
|
|
||||||
|
/** `status` absent : toute la file, refus compris. */
|
||||||
|
@Get()
|
||||||
|
list(@Query('status') status?: string) {
|
||||||
|
return this.service.list(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/publish')
|
||||||
|
@HttpCode(200)
|
||||||
|
publish(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: PublishWikiContributionDto
|
||||||
|
) {
|
||||||
|
return this.service.publish(reviewerOf(user), id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@HttpCode(200)
|
||||||
|
reject(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RejectWikiContributionDto
|
||||||
|
) {
|
||||||
|
return this.service.reject(reviewerOf(user), id, dto.note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewerOf = (user: UserPayload): WikiReviewer => ({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
});
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
WikiContribution,
|
||||||
|
WikiContributionRejected,
|
||||||
|
WikiContributionStatus,
|
||||||
|
} from '@domain/entities/wiki-contribution.entity';
|
||||||
|
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository';
|
||||||
|
import { UserRepository } from '@domain/ports/out/user.repository';
|
||||||
|
import { AuditAction } from '@domain/entities/audit-log.entity';
|
||||||
|
import { AuditService } from '../services/audit.service';
|
||||||
|
import { WikiReviewService } from './wiki-review.service';
|
||||||
|
|
||||||
|
const reviewer = { id: 'admin-1', email: 'admin@xpeditis.com', organizationId: 'org' };
|
||||||
|
|
||||||
|
const BODY = `La règle du 24 heures impose de transmettre le manifeste de cargaison aux douanes du pays de destination avant le chargement du navire au port d'embarquement. Elle s'applique au transport maritime international et conditionne l'autorisation de charger. Un dépôt tardif expose l'expéditeur à un refus d'embarquement et à une immobilisation du conteneur au terminal.`;
|
||||||
|
|
||||||
|
const proposal = () =>
|
||||||
|
WikiContribution.create({
|
||||||
|
id: 'w1',
|
||||||
|
locale: 'fr',
|
||||||
|
topic: 'douanes',
|
||||||
|
title: 'La règle des 24 heures',
|
||||||
|
section: 'Dépôt du manifeste',
|
||||||
|
body: BODY,
|
||||||
|
authorUserId: 'user',
|
||||||
|
authorOrganizationId: 'org',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WikiReviewService', () => {
|
||||||
|
let contributions: jest.Mocked<WikiContributionRepository>;
|
||||||
|
let users: jest.Mocked<Pick<UserRepository, 'findById'>>;
|
||||||
|
let audit: jest.Mocked<Pick<AuditService, 'log'>>;
|
||||||
|
let service: WikiReviewService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
contributions = {
|
||||||
|
findPublished: jest.fn().mockResolvedValue([]),
|
||||||
|
findForReview: jest.fn().mockResolvedValue([]),
|
||||||
|
findById: jest.fn().mockResolvedValue(proposal()),
|
||||||
|
findByTitle: jest.fn().mockResolvedValue(null),
|
||||||
|
save: jest.fn().mockImplementation((c: WikiContribution) => Promise.resolve(c)),
|
||||||
|
revision: jest.fn().mockResolvedValue('0:none'),
|
||||||
|
};
|
||||||
|
users = { findById: jest.fn().mockResolvedValue(null) };
|
||||||
|
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
service = new WikiReviewService(
|
||||||
|
contributions,
|
||||||
|
users as unknown as UserRepository,
|
||||||
|
audit as unknown as AuditService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* File de relecture */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
it('counts what is waiting, and passes an explicit filter through', async () => {
|
||||||
|
const pending = proposal();
|
||||||
|
const published = proposal().publish(reviewer.id);
|
||||||
|
contributions.findForReview.mockResolvedValue([pending, published]);
|
||||||
|
|
||||||
|
const result = await service.list();
|
||||||
|
|
||||||
|
expect(contributions.findForReview).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(result.pending).toBe(1);
|
||||||
|
expect(result.contributions).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a filter it does not know, rather than returning an empty list', async () => {
|
||||||
|
await service.list('whatever');
|
||||||
|
expect(contributions.findForReview).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the reviewer, and survives an account deleted since', async () => {
|
||||||
|
contributions.findForReview.mockResolvedValue([proposal().publish(reviewer.id)]);
|
||||||
|
users.findById.mockRejectedValue(new Error('db down'));
|
||||||
|
|
||||||
|
const [view] = (await service.list()).contributions;
|
||||||
|
|
||||||
|
expect(view.reviewedBy).toBeNull();
|
||||||
|
expect(view.status).toBe(WikiContributionStatus.PUBLISHED);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* Decisions */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
it('publishes the page and links to it', async () => {
|
||||||
|
const view = await service.publish(reviewer, 'w1');
|
||||||
|
|
||||||
|
const saved: WikiContribution = contributions.save.mock.calls[0][0];
|
||||||
|
expect(saved.status).toBe(WikiContributionStatus.PUBLISHED);
|
||||||
|
expect(saved.reviewedByUserId).toBe(reviewer.id);
|
||||||
|
expect(view.href).toBe('/dashboard/wiki/complements#w1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the reviewer edits instead of the proposed text', async () => {
|
||||||
|
const corrected = `${BODY} Le dépôt incombe au transitaire, jamais au destinataire.`;
|
||||||
|
|
||||||
|
await service.publish(reviewer, 'w1', { body: corrected });
|
||||||
|
|
||||||
|
expect(contributions.save.mock.calls[0][0].body).toBe(corrected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an edit that breaks the content policy', async () => {
|
||||||
|
const advocacy = `${BODY} Au-delà de 15 m³, nous recommandons le FCL.`;
|
||||||
|
|
||||||
|
await expect(service.publish(reviewer, 'w1', { body: advocacy })).rejects.toThrow(
|
||||||
|
WikiContributionRejected
|
||||||
|
);
|
||||||
|
expect(contributions.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a rejected page, with its reason', async () => {
|
||||||
|
const view = await service.reject(reviewer, 'w1', ' Source non vérifiée ');
|
||||||
|
|
||||||
|
const saved: WikiContribution = contributions.save.mock.calls[0][0];
|
||||||
|
expect(saved.status).toBe(WikiContributionStatus.REJECTED);
|
||||||
|
expect(saved.reviewNote).toBe('Source non vérifiée');
|
||||||
|
// Une page non publiee n'a pas de lien : il pointerait vers du vide.
|
||||||
|
expect(view.href).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records who decided what', async () => {
|
||||||
|
await service.publish(reviewer, 'w1');
|
||||||
|
|
||||||
|
expect(audit.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: AuditAction.WIKI_CONTRIBUTION_REVIEWED,
|
||||||
|
userEmail: reviewer.email,
|
||||||
|
resourceType: 'wiki_contribution',
|
||||||
|
resourceId: 'w1',
|
||||||
|
metadata: expect.objectContaining({ decision: 'published' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unknown page rather than failing silently', async () => {
|
||||||
|
contributions.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.publish(reviewer, 'w1')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,153 @@
|
|||||||
|
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
WikiContribution,
|
||||||
|
WikiContributionStatus,
|
||||||
|
} from '@domain/entities/wiki-contribution.entity';
|
||||||
|
import {
|
||||||
|
WIKI_CONTRIBUTION_REPOSITORY,
|
||||||
|
WikiContributionRepository,
|
||||||
|
} from '@domain/ports/out/wiki-contribution.repository';
|
||||||
|
import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
|
||||||
|
import { USER_REPOSITORY, UserRepository } from '@domain/ports/out/user.repository';
|
||||||
|
import { AuditService } from '../services/audit.service';
|
||||||
|
|
||||||
|
/** Qui relit. Vient de la session validee, jamais du corps de requete. */
|
||||||
|
export interface WikiReviewer {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relecture des pages proposees par l'assistant.
|
||||||
|
*
|
||||||
|
* Le contrat est court : rien n'entre dans le wiki sans qu'un administrateur
|
||||||
|
* l'ait lu. Le service ne re-verifie pas les droits — le controleur porte
|
||||||
|
* `@Roles('admin')` et le garde global valide la session — mais il journalise
|
||||||
|
* chaque decision, parce qu'une page publiee engage la marque aupres de tous
|
||||||
|
* les clients et qu'on doit pouvoir dire qui l'a laissee passer.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WikiReviewService {
|
||||||
|
private readonly logger = new Logger(WikiReviewService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(WIKI_CONTRIBUTION_REPOSITORY)
|
||||||
|
private readonly contributions: WikiContributionRepository,
|
||||||
|
@Inject(USER_REPOSITORY) private readonly users: UserRepository,
|
||||||
|
private readonly audit: AuditService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File de relecture.
|
||||||
|
*
|
||||||
|
* Sans filtre, elle montre tout — y compris les refus, qui sont ce qui
|
||||||
|
* revele ou l'assistant se trompe systematiquement.
|
||||||
|
*/
|
||||||
|
async list(status?: string) {
|
||||||
|
const filter = asStatus(status);
|
||||||
|
const pages = await this.contributions.findForReview(filter);
|
||||||
|
const reviewers = await this.reviewerNames(pages);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: pages.filter(page => page.status === WikiContributionStatus.PENDING).length,
|
||||||
|
contributions: pages.map(page => this.toView(page, reviewers)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(reviewer: WikiReviewer, id: string, edits?: { section?: string; body?: string }) {
|
||||||
|
const page = await this.find(id);
|
||||||
|
const published = await this.contributions.save(page.publish(reviewer.id, edits));
|
||||||
|
|
||||||
|
await this.record(reviewer, published, AuditStatus.SUCCESS, 'published');
|
||||||
|
this.logger.log(`Wiki contribution "${published.title}" published by ${reviewer.email}`);
|
||||||
|
|
||||||
|
return this.toView(published, { [reviewer.id]: reviewer.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(reviewer: WikiReviewer, id: string, note?: string) {
|
||||||
|
const page = await this.find(id);
|
||||||
|
const rejected = await this.contributions.save(page.reject(reviewer.id, note));
|
||||||
|
|
||||||
|
await this.record(reviewer, rejected, AuditStatus.SUCCESS, 'rejected');
|
||||||
|
|
||||||
|
return this.toView(rejected, { [reviewer.id]: reviewer.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async find(id: string): Promise<WikiContribution> {
|
||||||
|
const page = await this.contributions.findById(id);
|
||||||
|
if (!page) throw new NotFoundException('Contribution introuvable.');
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La decision est journalisee au meme titre qu'une action de capacite : c'est
|
||||||
|
* la seule trace qui relie une page du wiki a la personne qui l'a validee.
|
||||||
|
*/
|
||||||
|
private async record(
|
||||||
|
reviewer: WikiReviewer,
|
||||||
|
page: WikiContribution,
|
||||||
|
status: AuditStatus,
|
||||||
|
decision: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.audit.log({
|
||||||
|
action: AuditAction.WIKI_CONTRIBUTION_REVIEWED,
|
||||||
|
status,
|
||||||
|
userId: reviewer.id,
|
||||||
|
userEmail: reviewer.email,
|
||||||
|
organizationId: reviewer.organizationId,
|
||||||
|
resourceType: 'wiki_contribution',
|
||||||
|
resourceId: page.id,
|
||||||
|
resourceName: page.title,
|
||||||
|
metadata: { decision, topic: page.topic, locale: page.locale },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adresses des relecteurs, en une requete.
|
||||||
|
*
|
||||||
|
* La liste affiche « validee par », et un identifiant ne dit rien a la
|
||||||
|
* personne qui relit. Un compte supprime depuis laisse la case vide plutot
|
||||||
|
* que de faire echouer la liste.
|
||||||
|
*/
|
||||||
|
private async reviewerNames(pages: WikiContribution[]): Promise<Record<string, string>> {
|
||||||
|
const ids = [...new Set(pages.map(page => page.reviewedByUserId).filter(Boolean))] as string[];
|
||||||
|
if (!ids.length) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const found = await Promise.all(ids.map(id => this.users.findById(id)));
|
||||||
|
return Object.fromEntries(
|
||||||
|
found.filter(Boolean).map(user => [user!.id, user!.email.toString()])
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not resolve wiki reviewers: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toView(page: WikiContribution, reviewers: Record<string, string>) {
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
locale: page.locale,
|
||||||
|
topic: page.topic,
|
||||||
|
title: page.title,
|
||||||
|
section: page.section,
|
||||||
|
body: page.body,
|
||||||
|
status: page.status,
|
||||||
|
// Le lien n'a de sens qu'une fois la page publiee.
|
||||||
|
href: page.isPublished ? page.href : null,
|
||||||
|
reviewedBy: page.reviewedByUserId ? (reviewers[page.reviewedByUserId] ?? null) : null,
|
||||||
|
reviewedAt: page.reviewedAt?.toISOString() ?? null,
|
||||||
|
reviewNote: page.reviewNote ?? null,
|
||||||
|
createdAt: page.createdAt.toISOString(),
|
||||||
|
updatedAt: page.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un filtre inconnu vaut « pas de filtre », plutot qu'une liste vide muette. */
|
||||||
|
function asStatus(value?: string): WikiContributionStatus | undefined {
|
||||||
|
return Object.values(WikiContributionStatus).find(status => status === value);
|
||||||
|
}
|
||||||
@ -47,6 +47,11 @@ export enum AuditAction {
|
|||||||
// l'assistant integre. Le nom de la capacite est dans `resourceName`.
|
// l'assistant integre. Le nom de la capacite est dans `resourceName`.
|
||||||
AGENT_CAPABILITY_INVOKED = 'agent_capability_invoked',
|
AGENT_CAPABILITY_INVOKED = 'agent_capability_invoked',
|
||||||
|
|
||||||
|
// Relecture d'une page proposee au wiki par l'assistant. Une page publiee
|
||||||
|
// engage la marque aupres de tous les clients : la trace dit qui l'a laissee
|
||||||
|
// passer, et `metadata.decision` ce qui a ete decide.
|
||||||
|
WIKI_CONTRIBUTION_REVIEWED = 'wiki_contribution_reviewed',
|
||||||
|
|
||||||
// Droits des personnes (RGPD). L'article 5.2 impose de pouvoir demontrer
|
// Droits des personnes (RGPD). L'article 5.2 impose de pouvoir demontrer
|
||||||
// qu'une demande a ete traitee : sans trace, honorer un droit et l'ignorer
|
// qu'une demande a ete traitee : sans trace, honorer un droit et l'ignorer
|
||||||
// se ressemblent. La trace d'un effacement porte l'identifiant technique et
|
// se ressemblent. La trace d'un effacement porte l'identifiant technique et
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
} from '../services/wiki-contribution-policy';
|
} from '../services/wiki-contribution-policy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page ajoutee au wiki global par l'assistant.
|
* Page proposee au wiki global par l'assistant.
|
||||||
*
|
*
|
||||||
* Le wiki publie vit dans les fichiers de traduction du frontend : il est fige
|
* Le wiki publie vit dans les fichiers de traduction du frontend : il est fige
|
||||||
* au build et ne peut pas grandir pendant qu'un client pose une question. Cette
|
* au build et ne peut pas grandir pendant qu'un client pose une question. Cette
|
||||||
@ -12,14 +12,24 @@ import {
|
|||||||
* l'assistant rencontre un sujet d'information generale que la documentation ne
|
* l'assistant rencontre un sujet d'information generale que la documentation ne
|
||||||
* couvre pas encore.
|
* couvre pas encore.
|
||||||
*
|
*
|
||||||
* Elle n'est pas un brouillon : une fois creee, elle est lue par la recherche
|
* Elle nait **en attente**. `refuseWikiContribution` ecarte la faute franche a
|
||||||
* documentaire et citee sous les reponses, comme n'importe quelle page. C'est
|
* la construction, mais une heuristique ne juge pas la justesse d'un contenu :
|
||||||
* pour cela que sa validation (`refuseWikiContribution`) est faite ici, a la
|
* une page fausse mais bien ecrite la franchirait. Un administrateur tranche
|
||||||
* construction, et pas laissee au bon vouloir de l'appelant.
|
* donc avant publication, et rien n'est lu par la recherche ni affiche aux
|
||||||
|
* clients tant qu'il n'a pas tranche.
|
||||||
*
|
*
|
||||||
* Elle porte son auteur : la page est globale, mais on sait toujours quelle
|
* Elle porte son auteur : la page est globale, mais on sait toujours quelle
|
||||||
* question l'a fait naitre.
|
* question l'a fait naitre, et qui l'a validee.
|
||||||
*/
|
*/
|
||||||
|
export enum WikiContributionStatus {
|
||||||
|
/** Ecrite par l'assistant, pas encore relue. Invisible partout ailleurs. */
|
||||||
|
PENDING = 'pending',
|
||||||
|
/** Validee par un administrateur : elle fait partie du wiki. */
|
||||||
|
PUBLISHED = 'published',
|
||||||
|
/** Ecartee a la relecture. Conservee pour la trace, jamais affichee. */
|
||||||
|
REJECTED = 'rejected',
|
||||||
|
}
|
||||||
|
|
||||||
export interface WikiContributionProps {
|
export interface WikiContributionProps {
|
||||||
id: string;
|
id: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
@ -29,9 +39,15 @@ export interface WikiContributionProps {
|
|||||||
/** Intitule de la section, affiche sous le titre et indexe avec lui. */
|
/** Intitule de la section, affiche sous le titre et indexe avec lui. */
|
||||||
section: string;
|
section: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
status: WikiContributionStatus;
|
||||||
/** Compte dont la question a declenche la contribution. */
|
/** Compte dont la question a declenche la contribution. */
|
||||||
authorUserId: string;
|
authorUserId: string;
|
||||||
authorOrganizationId: string;
|
authorOrganizationId: string;
|
||||||
|
/** Administrateur qui a tranche, une fois la relecture faite. */
|
||||||
|
reviewedByUserId?: string;
|
||||||
|
reviewedAt?: Date;
|
||||||
|
/** Motif du refus, rendu a l'administrateur dans la liste. */
|
||||||
|
reviewNote?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@ -49,7 +65,7 @@ export class WikiContribution {
|
|||||||
* qu'il l'explique a l'utilisateur au lieu de reessayer.
|
* qu'il l'explique a l'utilisateur au lieu de reessayer.
|
||||||
*/
|
*/
|
||||||
static create(
|
static create(
|
||||||
props: Omit<WikiContributionProps, 'id' | 'createdAt' | 'updatedAt'> & { id: string }
|
props: Omit<WikiContributionProps, 'id' | 'status' | 'createdAt' | 'updatedAt'> & { id: string }
|
||||||
): WikiContribution {
|
): WikiContribution {
|
||||||
const draft: WikiContributionDraft = {
|
const draft: WikiContributionDraft = {
|
||||||
topic: props.topic,
|
topic: props.topic,
|
||||||
@ -62,7 +78,14 @@ export class WikiContribution {
|
|||||||
if (refusal) throw new WikiContributionRejected(refusal);
|
if (refusal) throw new WikiContributionRejected(refusal);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return new WikiContribution({ ...props, createdAt: now, updatedAt: now });
|
return new WikiContribution({
|
||||||
|
...props,
|
||||||
|
// Le statut n'est pas un parametre : rien ne nait publie, pas meme une
|
||||||
|
// page ecrite par un administrateur.
|
||||||
|
status: WikiContributionStatus.PENDING,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromPersistence(props: WikiContributionProps): WikiContribution {
|
static fromPersistence(props: WikiContributionProps): WikiContribution {
|
||||||
@ -85,7 +108,88 @@ export class WikiContribution {
|
|||||||
});
|
});
|
||||||
if (refusal) throw new WikiContributionRejected(refusal);
|
if (refusal) throw new WikiContributionRejected(refusal);
|
||||||
|
|
||||||
return new WikiContribution({ ...this.props, section, body, updatedAt: new Date() });
|
// Reviser, c'est reproposer : une page deja validee qui change de contenu
|
||||||
|
// repasse par la relecture, sinon la validation porterait sur un texte que
|
||||||
|
// plus personne n'a lu.
|
||||||
|
return new WikiContribution({
|
||||||
|
...this.props,
|
||||||
|
section,
|
||||||
|
body,
|
||||||
|
status: WikiContributionStatus.PENDING,
|
||||||
|
reviewedByUserId: undefined,
|
||||||
|
reviewedAt: undefined,
|
||||||
|
reviewNote: undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide la page : elle entre dans le wiki.
|
||||||
|
*
|
||||||
|
* L'administrateur peut corriger le texte au passage — c'est le cas courant,
|
||||||
|
* une page presque juste qu'il ne veut pas renvoyer a l'assistant. Sa version
|
||||||
|
* passe la meme politique de contenu que l'originale.
|
||||||
|
*/
|
||||||
|
publish(reviewerId: string, edits?: { section?: string; body?: string }): WikiContribution {
|
||||||
|
const section = edits?.section?.trim() || this.props.section;
|
||||||
|
const body = edits?.body?.trim() || this.props.body;
|
||||||
|
|
||||||
|
const refusal = refuseWikiContribution({
|
||||||
|
topic: this.props.topic,
|
||||||
|
title: this.props.title,
|
||||||
|
section,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (refusal) throw new WikiContributionRejected(refusal);
|
||||||
|
|
||||||
|
return new WikiContribution({
|
||||||
|
...this.props,
|
||||||
|
section,
|
||||||
|
body,
|
||||||
|
status: WikiContributionStatus.PUBLISHED,
|
||||||
|
reviewedByUserId: reviewerId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewNote: undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ecarte la page.
|
||||||
|
*
|
||||||
|
* Elle est conservee plutot que supprimee : la liste des refus est ce qui
|
||||||
|
* montre ou l'assistant se trompe, et elle evite de relire deux fois la meme
|
||||||
|
* proposition.
|
||||||
|
*/
|
||||||
|
reject(reviewerId: string, note?: string): WikiContribution {
|
||||||
|
return new WikiContribution({
|
||||||
|
...this.props,
|
||||||
|
status: WikiContributionStatus.REJECTED,
|
||||||
|
reviewedByUserId: reviewerId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewNote: note?.trim() || undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): WikiContributionStatus {
|
||||||
|
return this.props.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPublished(): boolean {
|
||||||
|
return this.props.status === WikiContributionStatus.PUBLISHED;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reviewedByUserId(): string | undefined {
|
||||||
|
return this.props.reviewedByUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reviewedAt(): Date | undefined {
|
||||||
|
return this.props.reviewedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get reviewNote(): string | undefined {
|
||||||
|
return this.props.reviewNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string {
|
get id(): string {
|
||||||
|
|||||||
@ -1,24 +1,41 @@
|
|||||||
import { WikiContribution } from '../../entities/wiki-contribution.entity';
|
import { WikiContribution, WikiContributionStatus } from '../../entities/wiki-contribution.entity';
|
||||||
|
|
||||||
export const WIKI_CONTRIBUTION_REPOSITORY = 'WikiContributionRepository';
|
export const WIKI_CONTRIBUTION_REPOSITORY = 'WikiContributionRepository';
|
||||||
|
|
||||||
export interface WikiContributionRepository {
|
export interface WikiContributionRepository {
|
||||||
/** Pages du complement pour cette langue, de la plus recente a la plus ancienne. */
|
/**
|
||||||
findByLocale(locale: string): Promise<WikiContribution[]>;
|
* Pages **publiees** pour cette langue, de la plus recente a la plus ancienne.
|
||||||
|
*
|
||||||
|
* C'est ce que lisent la recherche documentaire et la page de complements :
|
||||||
|
* une proposition en attente n'existe pour personne d'autre que le relecteur.
|
||||||
|
*/
|
||||||
|
findPublished(locale: string): Promise<WikiContribution[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page portant deja ce titre, s'il y en a une.
|
* File de relecture, tous statuts confondus ou filtree.
|
||||||
|
*
|
||||||
|
* Reservee a l'administration : c'est le seul endroit d'ou une page en
|
||||||
|
* attente est visible.
|
||||||
|
*/
|
||||||
|
findForReview(status?: WikiContributionStatus): Promise<WikiContribution[]>;
|
||||||
|
|
||||||
|
findById(id: string): Promise<WikiContribution | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page portant deja ce titre, quel que soit son statut.
|
||||||
*
|
*
|
||||||
* Le couple (langue, sujet, titre) est l'identite editoriale d'une page :
|
* Le couple (langue, sujet, titre) est l'identite editoriale d'une page :
|
||||||
* c'est ce qui permet de mettre a jour un complement plutot que d'en empiler
|
* c'est ce qui permet de mettre a jour une proposition plutot que d'en
|
||||||
* un second sur le meme sujet.
|
* empiler une seconde sur le meme sujet, y compris quand la premiere attend
|
||||||
|
* encore sa relecture.
|
||||||
*/
|
*/
|
||||||
findByTitle(locale: string, topic: string, title: string): Promise<WikiContribution | null>;
|
findByTitle(locale: string, topic: string, title: string): Promise<WikiContribution | null>;
|
||||||
|
|
||||||
save(contribution: WikiContribution): Promise<WikiContribution>;
|
save(contribution: WikiContribution): Promise<WikiContribution>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empreinte du jeu publie, qui change des qu'une page est ajoutee ou revisee.
|
* Empreinte du jeu **publie**, qui change des qu'une page est validee,
|
||||||
|
* revisee ou retiree.
|
||||||
*
|
*
|
||||||
* L'index vectoriel des complements est garde en memoire ; cette valeur est
|
* L'index vectoriel des complements est garde en memoire ; cette valeur est
|
||||||
* ce qui dit a la recherche qu'il est perime, sans relire tout le contenu a
|
* ce qui dit a la recherche qu'il est perime, sans relire tout le contenu a
|
||||||
|
|||||||
@ -58,11 +58,15 @@ const KNOWLEDGE_RULES = `\n\nExtraits de la documentation interne Xpeditis, sél
|
|||||||
* il vient de le rencontrer — donc il le comble, mais seulement la ou le wiki a
|
* il vient de le rencontrer — donc il le comble, mais seulement la ou le wiki a
|
||||||
* vocation a repondre : du savoir general sur le transport international.
|
* vocation a repondre : du savoir general sur le transport international.
|
||||||
*
|
*
|
||||||
* La consigne est deliberement restrictive. Ce qui passe reellement est decide
|
* Il *propose*, il ne publie pas : un administrateur valide avant que la page
|
||||||
* par `wiki-contribution-policy`, cote domaine : ce paragraphe evite les appels
|
* entre dans le wiki. La consigne le dit explicitement, sans quoi le modele
|
||||||
* inutiles, il ne protege rien.
|
* annoncerait a l'utilisateur une page « ajoutee » qu'il ne trouverait pas.
|
||||||
|
*
|
||||||
|
* La consigne est par ailleurs deliberement restrictive. Ce qui passe
|
||||||
|
* reellement est decide par `wiki-contribution-policy`, cote domaine : ce
|
||||||
|
* paragraphe evite les appels inutiles, il ne protege rien.
|
||||||
*/
|
*/
|
||||||
const WIKI_CONTRIBUTION_RULES = `\n\nEntretien de la documentation interne. Avant de répondre de mémoire sur une notion de fond, vérifie ce que le wiki contient avec search_documentation. Quand le wiki ne couvre pas un sujet d’information générale sur le transport international — une notion, une réglementation, une procédure, un document, un terme du métier — et que ce sujet servirait à n’importe quel client, complète le wiki global avec contribute_wiki_page, après avoir répondu à l’utilisateur. N’y verse que du savoir général et durable. N’y verse jamais : un cas client, un dossier, une réservation, un tarif ou un montant, une donnée de compte, un contenu qui recommande le FCL, un sujet de transport national. Dans le doute, ne contribue pas : une page inutile coûte plus cher qu’une page manquante. Une contribution ne remplace pas ta réponse, et ne la mentionne que si elle a réussi.`;
|
const WIKI_CONTRIBUTION_RULES = `\n\nEntretien de la documentation interne. Avant de répondre de mémoire sur une notion de fond, vérifie ce que le wiki contient avec search_documentation. Quand le wiki ne couvre pas un sujet d’information générale sur le transport international — une notion, une réglementation, une procédure, un document, un terme du métier — et que ce sujet servirait à n’importe quel client, propose une page au wiki global avec contribute_wiki_page, après avoir répondu à l’utilisateur. N’y verse que du savoir général et durable. N’y verse jamais : un cas client, un dossier, une réservation, un tarif ou un montant, une donnée de compte, un contenu qui recommande le FCL, un sujet de transport national. Dans le doute, ne propose rien : une page inutile coûte plus cher qu’une page manquante. Une proposition ne remplace pas ta réponse. Elle n’est pas publiée immédiatement : elle est relue puis validée par un administrateur Xpeditis. Ne dis donc jamais que le wiki a été mis à jour ni qu’une page est consultable — dis, seulement si l’appel a réussi, que tu as proposé une page à la documentation.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cadre d'usage des outils.
|
* Cadre d'usage des outils.
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { CachePort } from '@domain/ports/out/cache.port';
|
import { CachePort } from '@domain/ports/out/cache.port';
|
||||||
import { TradeEmbeddingPort } from '@domain/ports/out/trade-assistant.port';
|
import { TradeEmbeddingPort } from '@domain/ports/out/trade-assistant.port';
|
||||||
import { WikiContribution } from '@domain/entities/wiki-contribution.entity';
|
import {
|
||||||
|
WikiContribution,
|
||||||
|
WikiContributionStatus,
|
||||||
|
} from '@domain/entities/wiki-contribution.entity';
|
||||||
import { WikiRetriever, normalizeQuestion, pack, unpack } from './wiki-retriever';
|
import { WikiRetriever, normalizeQuestion, pack, unpack } from './wiki-retriever';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -193,6 +196,7 @@ describe('WikiRetriever', () => {
|
|||||||
section: 'Méthodes',
|
section: 'Méthodes',
|
||||||
// Les mots du vocabulaire de test portent tout le score.
|
// Les mots du vocabulaire de test portent tout le score.
|
||||||
body: 'vgm vgm vgm conteneur conteneurs',
|
body: 'vgm vgm vgm conteneur conteneurs',
|
||||||
|
status: WikiContributionStatus.PUBLISHED,
|
||||||
authorUserId: 'user',
|
authorUserId: 'user',
|
||||||
authorOrganizationId: 'org',
|
authorOrganizationId: 'org',
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@ -200,7 +204,9 @@ describe('WikiRetriever', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const repository = (pages: WikiContribution[]) => ({
|
const repository = (pages: WikiContribution[]) => ({
|
||||||
findByLocale: jest.fn().mockResolvedValue(pages),
|
findPublished: jest.fn().mockResolvedValue(pages),
|
||||||
|
findForReview: jest.fn().mockResolvedValue([]),
|
||||||
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
findByTitle: jest.fn().mockResolvedValue(null),
|
findByTitle: jest.fn().mockResolvedValue(null),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
revision: jest.fn().mockResolvedValue(`${pages.length}:r1`),
|
revision: jest.fn().mockResolvedValue(`${pages.length}:r1`),
|
||||||
@ -223,11 +229,11 @@ describe('WikiRetriever', () => {
|
|||||||
|
|
||||||
await retriever.search('vgm', 'fr');
|
await retriever.search('vgm', 'fr');
|
||||||
await retriever.search('vgm', 'fr');
|
await retriever.search('vgm', 'fr');
|
||||||
expect(contributions.findByLocale).toHaveBeenCalledTimes(1);
|
expect(contributions.findPublished).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
contributions.revision.mockResolvedValue('2:r2');
|
contributions.revision.mockResolvedValue('2:r2');
|
||||||
await retriever.search('vgm', 'fr');
|
await retriever.search('vgm', 'fr');
|
||||||
expect(contributions.findByLocale).toHaveBeenCalledTimes(2);
|
expect(contributions.findPublished).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('answers from the published wiki when the contributions are unreachable', async () => {
|
it('answers from the published wiki when the contributions are unreachable', async () => {
|
||||||
|
|||||||
Binary file not shown.
@ -9,8 +9,12 @@ export class CreateWikiContributions1789000000000 implements MigrationInterface
|
|||||||
title text NOT NULL,
|
title text NOT NULL,
|
||||||
section text NOT NULL,
|
section text NOT NULL,
|
||||||
body text NOT NULL,
|
body text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'published', 'rejected')),
|
||||||
author_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
author_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
author_organization_id uuid,
|
author_organization_id uuid,
|
||||||
|
reviewed_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
reviewed_at timestamptz,
|
||||||
|
review_note text,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
)`);
|
)`);
|
||||||
@ -21,9 +25,17 @@ export class CreateWikiContributions1789000000000 implements MigrationInterface
|
|||||||
'CREATE UNIQUE INDEX idx_wiki_contributions_identity ON wiki_contributions (locale, topic, lower(title))'
|
'CREATE UNIQUE INDEX idx_wiki_contributions_identity ON wiki_contributions (locale, topic, lower(title))'
|
||||||
);
|
);
|
||||||
|
|
||||||
// La page de complements liste par langue, du plus recent au plus ancien.
|
// La page de complements ne lit que le publie, par langue et du plus recent
|
||||||
|
// au plus ancien : l'index porte donc le statut en tete.
|
||||||
await queryRunner.query(
|
await queryRunner.query(
|
||||||
'CREATE INDEX idx_wiki_contributions_locale ON wiki_contributions (locale, updated_at DESC)'
|
'CREATE INDEX idx_wiki_contributions_published ON wiki_contributions (status, locale, updated_at DESC)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// La file de relecture, elle, part du statut et remonte les plus anciennes
|
||||||
|
// propositions en premier : ce sont celles qui attendent depuis le plus
|
||||||
|
// longtemps.
|
||||||
|
await queryRunner.query(
|
||||||
|
'CREATE INDEX idx_wiki_contributions_review ON wiki_contributions (status, created_at)'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,27 +1,52 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { WikiContribution } from '@domain/entities/wiki-contribution.entity';
|
import {
|
||||||
|
WikiContribution,
|
||||||
|
WikiContributionStatus,
|
||||||
|
} from '@domain/entities/wiki-contribution.entity';
|
||||||
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository';
|
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pages du wiki ecrites a l'execution.
|
* Pages du wiki ecrites a l'execution.
|
||||||
*
|
*
|
||||||
* Comme le reste de la feature assistant, les acces passent par du SQL
|
* Comme le reste de la feature assistant, les acces passent par du SQL
|
||||||
* parametre : ce sont quatre requetes simples, dont un `upsert` conditionnel
|
* parametre : ce sont quelques requetes simples, dont un `upsert` conditionnel
|
||||||
* que l'ORM rendrait plus long a lire sans le rendre plus sur.
|
* que l'ORM rendrait plus long a lire sans le rendre plus sur.
|
||||||
|
*
|
||||||
|
* Le statut est porte par chaque requete plutot que laisse au filtrage en
|
||||||
|
* memoire : une page en attente ne doit jamais sortir d'ici par le chemin que
|
||||||
|
* lisent la recherche ou les clients.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TypeOrmWikiContributionRepository implements WikiContributionRepository {
|
export class TypeOrmWikiContributionRepository implements WikiContributionRepository {
|
||||||
constructor(private readonly db: DataSource) {}
|
constructor(private readonly db: DataSource) {}
|
||||||
|
|
||||||
async findByLocale(locale: string): Promise<WikiContribution[]> {
|
async findPublished(locale: string): Promise<WikiContribution[]> {
|
||||||
const rows: Row[] = await this.db.query(
|
const rows: Row[] = await this.db.query(
|
||||||
`SELECT * FROM wiki_contributions WHERE locale = $1 ORDER BY updated_at DESC`,
|
`SELECT * FROM wiki_contributions
|
||||||
[locale]
|
WHERE locale = $1 AND status = $2
|
||||||
|
ORDER BY updated_at DESC`,
|
||||||
|
[locale, WikiContributionStatus.PUBLISHED]
|
||||||
);
|
);
|
||||||
return rows.map(toDomain);
|
return rows.map(toDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findForReview(status?: WikiContributionStatus): Promise<WikiContribution[]> {
|
||||||
|
// Les plus anciennes d'abord : une file de relecture se vide par le bas.
|
||||||
|
const rows: Row[] = status
|
||||||
|
? await this.db.query(
|
||||||
|
'SELECT * FROM wiki_contributions WHERE status = $1 ORDER BY created_at',
|
||||||
|
[status]
|
||||||
|
)
|
||||||
|
: await this.db.query('SELECT * FROM wiki_contributions ORDER BY created_at');
|
||||||
|
return rows.map(toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WikiContribution | null> {
|
||||||
|
const rows: Row[] = await this.db.query('SELECT * FROM wiki_contributions WHERE id = $1', [id]);
|
||||||
|
return rows.length ? toDomain(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
async findByTitle(
|
async findByTitle(
|
||||||
locale: string,
|
locale: string,
|
||||||
topic: string,
|
topic: string,
|
||||||
@ -46,10 +71,17 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
|
|||||||
const page = contribution.toObject();
|
const page = contribution.toObject();
|
||||||
const rows: Row[] = await this.db.query(
|
const rows: Row[] = await this.db.query(
|
||||||
`INSERT INTO wiki_contributions
|
`INSERT INTO wiki_contributions
|
||||||
(id, locale, topic, title, section, body, author_user_id, author_organization_id, created_at, updated_at)
|
(id, locale, topic, title, section, body, status, author_user_id, author_organization_id,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
reviewed_by_user_id, reviewed_at, review_note, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
ON CONFLICT (locale, topic, lower(title)) DO UPDATE
|
ON CONFLICT (locale, topic, lower(title)) DO UPDATE
|
||||||
SET section = EXCLUDED.section, body = EXCLUDED.body, updated_at = EXCLUDED.updated_at
|
SET section = EXCLUDED.section,
|
||||||
|
body = EXCLUDED.body,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
reviewed_by_user_id = EXCLUDED.reviewed_by_user_id,
|
||||||
|
reviewed_at = EXCLUDED.reviewed_at,
|
||||||
|
review_note = EXCLUDED.review_note,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
page.id,
|
page.id,
|
||||||
@ -58,8 +90,12 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
|
|||||||
page.title,
|
page.title,
|
||||||
page.section,
|
page.section,
|
||||||
page.body,
|
page.body,
|
||||||
|
page.status,
|
||||||
page.authorUserId,
|
page.authorUserId,
|
||||||
page.authorOrganizationId,
|
page.authorOrganizationId,
|
||||||
|
page.reviewedByUserId ?? null,
|
||||||
|
page.reviewedAt ?? null,
|
||||||
|
page.reviewNote ?? null,
|
||||||
page.createdAt,
|
page.createdAt,
|
||||||
page.updatedAt,
|
page.updatedAt,
|
||||||
]
|
]
|
||||||
@ -69,11 +105,12 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
|
|||||||
|
|
||||||
async revision(locale: string): Promise<string> {
|
async revision(locale: string): Promise<string> {
|
||||||
const rows: Array<{ count: string; last: string | null }> = await this.db.query(
|
const rows: Array<{ count: string; last: string | null }> = await this.db.query(
|
||||||
`SELECT COUNT(*) AS count, MAX(updated_at) AS last FROM wiki_contributions WHERE locale = $1`,
|
`SELECT COUNT(*) AS count, MAX(updated_at) AS last
|
||||||
[locale]
|
FROM wiki_contributions WHERE locale = $1 AND status = $2`,
|
||||||
|
[locale, WikiContributionStatus.PUBLISHED]
|
||||||
);
|
);
|
||||||
// Le couple (nombre, derniere ecriture) suffit : une page ajoutee change le
|
// Le couple (nombre, derniere ecriture) suffit : une page validee change le
|
||||||
// premier, une page revisee change le second, une page supprimee le premier.
|
// premier, une page revisee ou retiree change l'un ou l'autre.
|
||||||
return `${rows[0]?.count ?? '0'}:${rows[0]?.last ?? 'none'}`;
|
return `${rows[0]?.count ?? '0'}:${rows[0]?.last ?? 'none'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,8 +122,12 @@ interface Row {
|
|||||||
title: string;
|
title: string;
|
||||||
section: string;
|
section: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
status: string;
|
||||||
author_user_id: string | null;
|
author_user_id: string | null;
|
||||||
author_organization_id: string | null;
|
author_organization_id: string | null;
|
||||||
|
reviewed_by_user_id: string | null;
|
||||||
|
reviewed_at: Date | null;
|
||||||
|
review_note: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@ -103,8 +144,12 @@ function toDomain(row: Row): WikiContribution {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
section: row.section,
|
section: row.section,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
|
status: row.status as WikiContributionStatus,
|
||||||
authorUserId: row.author_user_id ?? '',
|
authorUserId: row.author_user_id ?? '',
|
||||||
authorOrganizationId: row.author_organization_id ?? '',
|
authorOrganizationId: row.author_organization_id ?? '',
|
||||||
|
reviewedByUserId: row.reviewed_by_user_id ?? undefined,
|
||||||
|
reviewedAt: row.reviewed_at ? new Date(row.reviewed_at) : undefined,
|
||||||
|
reviewNote: row.review_note ?? undefined,
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
updatedAt: new Date(row.updated_at),
|
updatedAt: new Date(row.updated_at),
|
||||||
});
|
});
|
||||||
|
|||||||
251
apps/frontend/app/[locale]/admin/wiki/page.tsx
Normal file
251
apps/frontend/app/[locale]/admin/wiki/page.tsx
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { BookOpen, Check, Pencil, RefreshCw, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getWikiReviewQueue,
|
||||||
|
publishWikiContribution,
|
||||||
|
rejectWikiContribution,
|
||||||
|
type WikiContributionReview,
|
||||||
|
type WikiContributionStatus,
|
||||||
|
} from '@/lib/api/trade-assistant';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Callout } from '@/components/ui/callout';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relecture des pages que l'assistant propose au wiki.
|
||||||
|
*
|
||||||
|
* Le wiki est global : une page validee devient visible pour tous les clients
|
||||||
|
* et citable par l'assistant dans toutes ses reponses suivantes. L'ecran est
|
||||||
|
* donc construit pour qu'on lise avant de decider — le corps complet est
|
||||||
|
* affiche, pas un extrait — et pour qu'on puisse corriger au passage plutot
|
||||||
|
* que de renvoyer une page presque juste.
|
||||||
|
*/
|
||||||
|
export default function WikiReviewPage() {
|
||||||
|
const t = useTranslations('admin.wikiReview');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [filter, setFilter] = React.useState<WikiContributionStatus>('pending');
|
||||||
|
|
||||||
|
const queue = useQuery({
|
||||||
|
queryKey: ['wiki-review', filter],
|
||||||
|
queryFn: () => getWikiReviewQueue(filter),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['wiki-review'] });
|
||||||
|
// La page publique et la recherche lisent le meme jeu : une decision les
|
||||||
|
// perime toutes les deux.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['wiki-complements'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body?: string }) =>
|
||||||
|
publishWikiContribution(id, body ? { body } : undefined),
|
||||||
|
onSuccess: page => {
|
||||||
|
toast.success(t('publishDone', { title: page.title }));
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error(t('publishFailed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reject = useMutation({
|
||||||
|
mutationFn: ({ id, note }: { id: string; note?: string }) => rejectWikiContribution(id, note),
|
||||||
|
onSuccess: page => {
|
||||||
|
toast.success(t('rejectDone', { title: page.title }));
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error(t('rejectFailed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queue.isPending) return <PageSpinner />;
|
||||||
|
if (queue.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title={t('errorTitle')}
|
||||||
|
description={t('errorBody')}
|
||||||
|
onRetry={() => queue.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = queue.data.contributions;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={t('title')}
|
||||||
|
description={t('description')}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={() => queue.refetch()}>
|
||||||
|
<RefreshCw />
|
||||||
|
{t('refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Callout variant="info" title={t('noticeTitle')}>
|
||||||
|
{t('noticeBody')}
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Tabs value={filter} onValueChange={value => setFilter(value as WikiContributionStatus)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="pending">{t('tabs.pending')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="published">{t('tabs.published')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="rejected">{t('tabs.rejected')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
<EmptyState icon={BookOpen} title={t(`empty.${filter}`)} />
|
||||||
|
) : (
|
||||||
|
pages.map(page => (
|
||||||
|
<ReviewCard
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
locale={locale}
|
||||||
|
busy={publish.isPending || reject.isPending}
|
||||||
|
onPublish={(id, body) => publish.mutate({ id, body })}
|
||||||
|
onReject={(id, note) => reject.mutate({ id, note })}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface ReviewCardProps {
|
||||||
|
page: WikiContributionReview;
|
||||||
|
locale: string;
|
||||||
|
busy: boolean;
|
||||||
|
onPublish: (id: string, body?: string) => void;
|
||||||
|
onReject: (id: string, note?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une proposition, lisible en entier.
|
||||||
|
*
|
||||||
|
* Deux champs facultatifs, jamais ouverts par defaut : la correction du corps
|
||||||
|
* et le motif du refus. Les afficher en permanence ferait de la relecture une
|
||||||
|
* saisie ; les cacher derriere un bouton garde l'ecran sur la lecture.
|
||||||
|
*/
|
||||||
|
function ReviewCard({ page, locale, busy, onPublish, onReject }: ReviewCardProps) {
|
||||||
|
const t = useTranslations('admin.wikiReview');
|
||||||
|
const [body, setBody] = React.useState<string | null>(null);
|
||||||
|
const [note, setNote] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const editing = body !== null;
|
||||||
|
const pending = page.status === 'pending';
|
||||||
|
|
||||||
|
const date = (value: string) =>
|
||||||
|
new Date(value).toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-GB', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-2 pb-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={STATUS_VARIANT[page.status]}>{t(`status.${page.status}`)}</Badge>
|
||||||
|
<Badge variant="neutral">{page.topic}</Badge>
|
||||||
|
<Badge variant="neutral">{page.locale.toUpperCase()}</Badge>
|
||||||
|
</div>
|
||||||
|
<CardTitle>{page.title}</CardTitle>
|
||||||
|
<p className="text-body-sm text-neutral-500">{page.section}</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{editing ? (
|
||||||
|
<Textarea
|
||||||
|
aria-label={t('editLabel')}
|
||||||
|
rows={12}
|
||||||
|
value={body}
|
||||||
|
onChange={event => setBody(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
page.body
|
||||||
|
.split(/\n+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((paragraph, index) => (
|
||||||
|
<p key={index} className="text-body-sm leading-6 text-neutral-700">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-body-xs text-neutral-500">
|
||||||
|
{t('proposedOn', { date: date(page.createdAt) })}
|
||||||
|
{page.reviewedBy
|
||||||
|
? ` — ${t('reviewedBy', { who: page.reviewedBy, date: date(page.reviewedAt ?? page.updatedAt) })}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{page.reviewNote && (
|
||||||
|
<p className="text-body-sm text-neutral-600">
|
||||||
|
{t('noteLabel')} {page.reviewNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{note !== null && (
|
||||||
|
<Textarea
|
||||||
|
aria-label={t('noteLabel')}
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('notePlaceholder')}
|
||||||
|
value={note}
|
||||||
|
onChange={event => setNote(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={busy} onClick={() => onPublish(page.id, editing ? body : undefined)}>
|
||||||
|
<Check />
|
||||||
|
{t('publish')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => (note === null ? setNote('') : onReject(page.id, note || undefined))}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
{note === null ? t('reject') : t('confirmReject')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setBody(editing ? null : page.body)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
{editing ? t('cancelEdit') : t('edit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<WikiContributionStatus, 'warning' | 'success' | 'danger'> = {
|
||||||
|
pending: 'warning',
|
||||||
|
published: 'success',
|
||||||
|
rejected: 'danger',
|
||||||
|
};
|
||||||
@ -56,6 +56,44 @@
|
|||||||
"error": "Sign in failed. Please check your credentials.",
|
"error": "Sign in failed. Please check your credentials.",
|
||||||
"backToApp": "Back to site"
|
"backToApp": "Back to site"
|
||||||
},
|
},
|
||||||
|
"wikiReview": {
|
||||||
|
"title": "Wiki review",
|
||||||
|
"description": "Pages the AI assistant proposes for the documentation. Nothing enters the wiki without your approval.",
|
||||||
|
"noticeTitle": "An approved page becomes public",
|
||||||
|
"noticeBody": "It is visible to every client and cited by the assistant in its later answers. Read the whole text before approving, and edit it if needed.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"errorTitle": "Review queue unavailable",
|
||||||
|
"errorBody": "The proposals could not be loaded.",
|
||||||
|
"tabs": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"published": "Published",
|
||||||
|
"rejected": "Rejected"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"pending": "Nothing waiting for review.",
|
||||||
|
"published": "No published page yet.",
|
||||||
|
"rejected": "No rejected proposal."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"published": "Published",
|
||||||
|
"rejected": "Rejected"
|
||||||
|
},
|
||||||
|
"proposedOn": "Proposed on {date}",
|
||||||
|
"reviewedBy": "reviewed by {who} on {date}",
|
||||||
|
"noteLabel": "Reason:",
|
||||||
|
"notePlaceholder": "Why is this page set aside? (optional)",
|
||||||
|
"publish": "Approve and publish",
|
||||||
|
"reject": "Reject",
|
||||||
|
"confirmReject": "Confirm rejection",
|
||||||
|
"edit": "Edit the text",
|
||||||
|
"cancelEdit": "Cancel the edit",
|
||||||
|
"editLabel": "Page body",
|
||||||
|
"publishDone": "“{title}” is now published in the wiki.",
|
||||||
|
"publishFailed": "The page could not be published.",
|
||||||
|
"rejectDone": "“{title}” has been set aside.",
|
||||||
|
"rejectFailed": "The rejection could not be saved."
|
||||||
|
},
|
||||||
"compliance": {
|
"compliance": {
|
||||||
"title": "Compliance",
|
"title": "Compliance",
|
||||||
"description": "What the product declares, what it keeps, and the record of rights requests.",
|
"description": "What the product declares, what it keeps, and the record of rights requests.",
|
||||||
@ -1839,13 +1877,13 @@
|
|||||||
},
|
},
|
||||||
"wikiComplements": {
|
"wikiComplements": {
|
||||||
"title": "Assistant additions",
|
"title": "Assistant additions",
|
||||||
"description": "Pages added to the wiki by the AI assistant, when a question revealed an international transport topic the documentation did not yet cover.",
|
"description": "Pages written by the AI assistant when a question revealed an international transport topic the documentation did not yet cover, then approved by the Xpeditis team.",
|
||||||
"cardTitle": "Assistant additions",
|
"cardTitle": "Assistant additions",
|
||||||
"cardDescription": "Topics added as questions came in",
|
"cardDescription": "Topics added as questions came in",
|
||||||
"loading": "Loading additions…",
|
"loading": "Loading additions…",
|
||||||
"error": "The additions could not be loaded.",
|
"error": "The additions could not be loaded.",
|
||||||
"empty": "No additions yet. The wiki covers the questions asked so far.",
|
"empty": "No additions yet. The wiki covers the questions asked so far.",
|
||||||
"writtenBy": "Written by the AI assistant — updated {date}"
|
"writtenBy": "Written by the AI assistant, approved by the team — updated {date}"
|
||||||
},
|
},
|
||||||
"wikiPages": {
|
"wikiPages": {
|
||||||
"backToWiki": "Back to Wiki",
|
"backToWiki": "Back to Wiki",
|
||||||
@ -3422,6 +3460,7 @@
|
|||||||
"logs": "System logs",
|
"logs": "System logs",
|
||||||
"logsDesc": "View and download application logs",
|
"logsDesc": "View and download application logs",
|
||||||
"assistant": "AI assistant",
|
"assistant": "AI assistant",
|
||||||
|
"wiki": "Wiki review",
|
||||||
"compliance": "Compliance"
|
"compliance": "Compliance"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -5094,7 +5133,7 @@
|
|||||||
"whoami": "Checked your permissions",
|
"whoami": "Checked your permissions",
|
||||||
"get_subscription": "Read your subscription",
|
"get_subscription": "Read your subscription",
|
||||||
"search_documentation": "Searched the wiki",
|
"search_documentation": "Searched the wiki",
|
||||||
"contribute_wiki_page": "Added a wiki page",
|
"contribute_wiki_page": "Proposed a wiki page",
|
||||||
"search_rates": "Searched rates",
|
"search_rates": "Searched rates",
|
||||||
"list_carriers": "Listed carriers",
|
"list_carriers": "Listed carriers",
|
||||||
"list_my_bookings": "Read your bookings",
|
"list_my_bookings": "Read your bookings",
|
||||||
|
|||||||
@ -56,6 +56,44 @@
|
|||||||
"error": "Échec de la connexion. Vérifiez vos identifiants.",
|
"error": "Échec de la connexion. Vérifiez vos identifiants.",
|
||||||
"backToApp": "Retour au site"
|
"backToApp": "Retour au site"
|
||||||
},
|
},
|
||||||
|
"wikiReview": {
|
||||||
|
"title": "Wiki à relire",
|
||||||
|
"description": "Les pages que l'assistant IA propose à la documentation. Rien n'entre dans le wiki sans votre validation.",
|
||||||
|
"noticeTitle": "Une page validée devient publique",
|
||||||
|
"noticeBody": "Elle est visible par tous les clients et citée par l'assistant dans ses réponses suivantes. Lisez le texte en entier avant de valider, et corrigez-le si besoin.",
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"errorTitle": "File de relecture indisponible",
|
||||||
|
"errorBody": "Les propositions n'ont pas pu être chargées.",
|
||||||
|
"tabs": {
|
||||||
|
"pending": "En attente",
|
||||||
|
"published": "Publiées",
|
||||||
|
"rejected": "Refusées"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"pending": "Aucune proposition en attente.",
|
||||||
|
"published": "Aucune page publiée pour le moment.",
|
||||||
|
"rejected": "Aucune proposition refusée."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"pending": "En attente",
|
||||||
|
"published": "Publiée",
|
||||||
|
"rejected": "Refusée"
|
||||||
|
},
|
||||||
|
"proposedOn": "Proposée le {date}",
|
||||||
|
"reviewedBy": "relue par {who} le {date}",
|
||||||
|
"noteLabel": "Motif :",
|
||||||
|
"notePlaceholder": "Pourquoi cette page est-elle écartée ? (facultatif)",
|
||||||
|
"publish": "Valider et publier",
|
||||||
|
"reject": "Refuser",
|
||||||
|
"confirmReject": "Confirmer le refus",
|
||||||
|
"edit": "Corriger le texte",
|
||||||
|
"cancelEdit": "Annuler la correction",
|
||||||
|
"editLabel": "Corps de la page",
|
||||||
|
"publishDone": "« {title} » est publiée dans le wiki.",
|
||||||
|
"publishFailed": "La page n'a pas pu être publiée.",
|
||||||
|
"rejectDone": "« {title} » a été écartée.",
|
||||||
|
"rejectFailed": "Le refus n'a pas pu être enregistré."
|
||||||
|
},
|
||||||
"compliance": {
|
"compliance": {
|
||||||
"title": "Conformité",
|
"title": "Conformité",
|
||||||
"description": "Ce que le produit déclare, ce qu'il conserve, et la trace des demandes de droits.",
|
"description": "Ce que le produit déclare, ce qu'il conserve, et la trace des demandes de droits.",
|
||||||
@ -1839,13 +1877,13 @@
|
|||||||
},
|
},
|
||||||
"wikiComplements": {
|
"wikiComplements": {
|
||||||
"title": "Compléments de l'assistant",
|
"title": "Compléments de l'assistant",
|
||||||
"description": "Pages ajoutées au wiki par l'assistant IA, quand une question a révélé un sujet du transport international que la documentation ne couvrait pas encore.",
|
"description": "Pages rédigées par l'assistant IA quand une question a révélé un sujet du transport international que la documentation ne couvrait pas encore, puis validées par l'équipe Xpeditis.",
|
||||||
"cardTitle": "Compléments de l'assistant",
|
"cardTitle": "Compléments de l'assistant",
|
||||||
"cardDescription": "Les sujets ajoutés au fil des questions",
|
"cardDescription": "Les sujets ajoutés au fil des questions",
|
||||||
"loading": "Chargement des compléments…",
|
"loading": "Chargement des compléments…",
|
||||||
"error": "Les compléments n'ont pas pu être chargés.",
|
"error": "Les compléments n'ont pas pu être chargés.",
|
||||||
"empty": "Aucun complément pour le moment. Le wiki couvre les questions posées jusqu'ici.",
|
"empty": "Aucun complément pour le moment. Le wiki couvre les questions posées jusqu'ici.",
|
||||||
"writtenBy": "Rédigé par l'assistant IA — mis à jour le {date}"
|
"writtenBy": "Rédigé par l'assistant IA, validé par l'équipe — mis à jour le {date}"
|
||||||
},
|
},
|
||||||
"wikiPages": {
|
"wikiPages": {
|
||||||
"backToWiki": "Retour au Wiki",
|
"backToWiki": "Retour au Wiki",
|
||||||
@ -3422,6 +3460,7 @@
|
|||||||
"logs": "Logs système",
|
"logs": "Logs système",
|
||||||
"logsDesc": "Visualiser et télécharger les logs applicatifs",
|
"logsDesc": "Visualiser et télécharger les logs applicatifs",
|
||||||
"assistant": "Assistant IA",
|
"assistant": "Assistant IA",
|
||||||
|
"wiki": "Wiki à relire",
|
||||||
"compliance": "Conformité"
|
"compliance": "Conformité"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -5094,7 +5133,7 @@
|
|||||||
"whoami": "Vérification de vos droits",
|
"whoami": "Vérification de vos droits",
|
||||||
"get_subscription": "Lecture de votre abonnement",
|
"get_subscription": "Lecture de votre abonnement",
|
||||||
"search_documentation": "Recherche dans le wiki",
|
"search_documentation": "Recherche dans le wiki",
|
||||||
"contribute_wiki_page": "Complément ajouté au wiki",
|
"contribute_wiki_page": "Page proposée au wiki",
|
||||||
"search_rates": "Recherche de tarifs",
|
"search_rates": "Recherche de tarifs",
|
||||||
"list_carriers": "Liste des transporteurs",
|
"list_carriers": "Liste des transporteurs",
|
||||||
"list_my_bookings": "Lecture de vos réservations",
|
"list_my_bookings": "Lecture de vos réservations",
|
||||||
|
|||||||
@ -66,7 +66,7 @@ describe('buildAppNav', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildAdminNav', () => {
|
describe('buildAdminNav', () => {
|
||||||
it('expose les neuf destinations d administration', () => {
|
it('expose les dix destinations d administration', () => {
|
||||||
const groups = buildAdminNav({ t, tShell });
|
const groups = buildAdminNav({ t, tShell });
|
||||||
const keys = groups.flatMap(g => g.items).map(i => i.key);
|
const keys = groups.flatMap(g => g.items).map(i => i.key);
|
||||||
expect(keys).toEqual([
|
expect(keys).toEqual([
|
||||||
@ -77,6 +77,7 @@ describe('buildAdminNav', () => {
|
|||||||
'csvRates',
|
'csvRates',
|
||||||
'blog',
|
'blog',
|
||||||
'assistant',
|
'assistant',
|
||||||
|
'wiki',
|
||||||
'compliance',
|
'compliance',
|
||||||
'logs',
|
'logs',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -185,6 +185,7 @@ export function buildAdminNav({ t, tShell }: AdminNavParams): NavGroup[] {
|
|||||||
label: tShell('groups.system'),
|
label: tShell('groups.system'),
|
||||||
items: [
|
items: [
|
||||||
{ key: 'assistant', label: t('items.assistant'), href: '/admin/assistant', icon: Bot },
|
{ key: 'assistant', label: t('items.assistant'), href: '/admin/assistant', icon: Bot },
|
||||||
|
{ key: 'wiki', label: t('items.wiki'), href: '/admin/wiki', icon: BookOpen },
|
||||||
{
|
{
|
||||||
key: 'compliance',
|
key: 'compliance',
|
||||||
label: t('items.compliance'),
|
label: t('items.compliance'),
|
||||||
|
|||||||
@ -56,11 +56,12 @@ export interface TradeReply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page ajoutée au wiki par l'assistant.
|
* Page proposée au wiki par l'assistant.
|
||||||
*
|
*
|
||||||
* Le wiki publié vit dans les fichiers de traduction et ne bouge qu'au build.
|
* Le wiki publié vit dans les fichiers de traduction et ne bouge qu'au build.
|
||||||
* Celles-ci s'écrivent en cours de route, quand une question révèle un trou
|
* Celles-ci s'écrivent en cours de route, quand une question révèle un trou
|
||||||
* dans la documentation : elles complètent le wiki, elles ne le remplacent pas.
|
* dans la documentation — mais elles n'entrent dans le wiki qu'après validation
|
||||||
|
* par un administrateur.
|
||||||
*/
|
*/
|
||||||
export interface WikiContribution {
|
export interface WikiContribution {
|
||||||
id: string;
|
id: string;
|
||||||
@ -73,11 +74,44 @@ export interface WikiContribution {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WikiContributionStatus = 'pending' | 'published' | 'rejected';
|
||||||
|
|
||||||
|
/** La même page, vue depuis la file de relecture : statut et décision compris. */
|
||||||
|
export interface WikiContributionReview extends Omit<WikiContribution, 'href'> {
|
||||||
|
locale: string;
|
||||||
|
status: WikiContributionStatus;
|
||||||
|
/** `null` tant que la page n'est pas publiée : le lien pointerait vers du vide. */
|
||||||
|
href: string | null;
|
||||||
|
reviewedBy: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewNote: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WikiReviewQueue {
|
||||||
|
/** Nombre de pages qui attendent une décision. */
|
||||||
|
pending: number;
|
||||||
|
contributions: WikiContributionReview[];
|
||||||
|
}
|
||||||
|
|
||||||
export const getTradeQuota = () => get<TradeQuota>('/api/v1/trade-assistant/quota');
|
export const getTradeQuota = () => get<TradeQuota>('/api/v1/trade-assistant/quota');
|
||||||
|
|
||||||
export const getWikiContributions = (language: string) =>
|
export const getWikiContributions = (language: string) =>
|
||||||
get<WikiContribution[]>(`/api/v1/trade-assistant/wiki?language=${language}`);
|
get<WikiContribution[]>(`/api/v1/trade-assistant/wiki?language=${language}`);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Relecture (administration) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const getWikiReviewQueue = (status?: WikiContributionStatus) =>
|
||||||
|
get<WikiReviewQueue>(`/api/v1/admin/wiki-contributions${status ? `?status=${status}` : ''}`);
|
||||||
|
|
||||||
|
export const publishWikiContribution = (id: string, edits?: { section?: string; body?: string }) =>
|
||||||
|
post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/publish`, edits ?? {});
|
||||||
|
|
||||||
|
export const rejectWikiContribution = (id: string, note?: string) =>
|
||||||
|
post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/reject`, { note });
|
||||||
|
|
||||||
export const getTradeConversations = () =>
|
export const getTradeConversations = () =>
|
||||||
get<TradeConversation[]>('/api/v1/trade-assistant/conversations');
|
get<TradeConversation[]>('/api/v1/trade-assistant/conversations');
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user