mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.4): corrections de recette (8 écarts) + recherche globale ⌘K
Arbitrage du référent sur la revue pixel : tout corriger, activer la recherche. - Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock » depuis la liste ; fiche pièce : fournisseur → lien Tiers. - Statistiques : période 3/6/12 mois (paramètre months au contrat). - Tiers : rattachements syndic→site (migration r3_recette_fixes, Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé. - Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale préremplie, rattachement toujours requis). - Recherche globale : GET /search (73 opérations) — familles OT/ ascenseurs/sites filtrées par la matrice, « voir autre » respecté ; topbar ⌘K, debounce, résultats groupés, navigation clavier. 74 tests API (8 nouveaux sur le scoping de la recherche), 14/14 Playwright dont un parcours « recette corrigée », 18/18 contrôles en navigateur réel, zéro erreur console. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
105
apps/api/src/search/search.service.ts
Normal file
105
apps/api/src/search/search.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
|
||||
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
|
||||
* l'invariant « voir autre » (même règle que la liste des OT). */
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
|
||||
const terme = q.trim();
|
||||
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
|
||||
if (terme.length < SEARCH_MIN_CHARS) return vide;
|
||||
|
||||
const [voitOT, voitParc, voitSites] = await Promise.all([
|
||||
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
|
||||
this.permissions.can(user.roleId, 'ASSETS', 'view'),
|
||||
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
|
||||
]);
|
||||
const contient = (champ: string): Prisma.StringFilter => ({
|
||||
contains: champ,
|
||||
mode: 'insensitive',
|
||||
});
|
||||
|
||||
const [workOrders, assets, sites] = await Promise.all([
|
||||
voitOT
|
||||
? this.prisma.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
|
||||
await this.scopeOT(user),
|
||||
],
|
||||
},
|
||||
select: { id: true, reference: true, title: true, status: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitParc
|
||||
? this.prisma.asset.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ reference: contient(terme) },
|
||||
{ brand: contient(terme) },
|
||||
{ model: contient(terme) },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
brand: true,
|
||||
model: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: { reference: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitSites
|
||||
? this.prisma.location.findMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
OR: [{ name: contient(terme) }, { city: contient(terme) }],
|
||||
},
|
||||
select: { id: true, name: true, city: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
return {
|
||||
workOrders,
|
||||
assets: assets.map((a) => ({
|
||||
id: a.id,
|
||||
reference: a.reference,
|
||||
brand: a.brand,
|
||||
model: a.model,
|
||||
siteName: a.location.parent?.name ?? a.location.name,
|
||||
})),
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
|
||||
* autre », on ne trouve que SES OT. */
|
||||
private async scopeOT(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||
if (viewOther) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ assignees: { some: { id: user.userId } } },
|
||||
{ createdById: user.userId },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user