- {statutFiches &&
- Object.entries(statutFiches).map(([statut, { valeur }], index) =>
+ {countByStatut &&
+ Object.entries(countByStatut).map(([statut, { valeur }], index) =>
statut === 'Sans statut' ? (
-
+
) : (
-
+
)
)}
@@ -161,7 +163,7 @@ const ModuleAvancementFichesAction = ({ module }: Props) => {
export default ModuleAvancementFichesAction;
type CardProps = {
- statut: Statut;
+ statut: Statut | 'Sans statut';
count: number;
};
diff --git a/app.territoiresentransitions.react/src/app/pages/collectivite/TableauDeBord/Collectivite/ModuleAvancementFichesAction/useFichesActionStatuts.ts b/app.territoiresentransitions.react/src/app/pages/collectivite/TableauDeBord/Collectivite/ModuleAvancementFichesAction/useFichesActionStatuts.ts
index 0f9e55510a..7c838ea4b5 100644
--- a/app.territoiresentransitions.react/src/app/pages/collectivite/TableauDeBord/Collectivite/ModuleAvancementFichesAction/useFichesActionStatuts.ts
+++ b/app.territoiresentransitions.react/src/app/pages/collectivite/TableauDeBord/Collectivite/ModuleAvancementFichesAction/useFichesActionStatuts.ts
@@ -1,44 +1,15 @@
-import { useQuery } from 'react-query';
-
-import { useApiClient } from 'core-logic/api/useApiClient';
import { useCollectiviteId } from 'core-logic/hooks/params';
-import { Statut } from '@tet/api/plan-actions';
+import { RouterInput, trpc } from '@tet/api/utils/trpc/client';
-export type GetFichesActionStatutsParams = {
- cibles?: string;
- partenaire_tag_ids?: string;
- pilote_tag_ids?: string;
- pilote_user_ids?: string;
- service_tag_ids?: string;
- plan_ids?: string;
- modified_since?: string;
-};
-
-type GetIndicateursValeursResponse = {
- par_statut: {
- [key: string]: {
- count: number;
- valeur: Statut;
- };
- };
-};
+type CountByStatutFilter =
+ RouterInput['plans']['fiches']['countByStatut']['filter'];
/** Charge toutes les valeurs associées à un indicateur id (ou à un ou plusieurs identifiants d'indicateurs prédéfinis) */
-export const useFichesActionStatuts = (
- params: GetFichesActionStatutsParams
-) => {
- const collectivite_id = useCollectiviteId();
- const api = useApiClient();
-
- return useQuery(
- ['fiches_action_statuts', collectivite_id, params],
- async () => {
- if (!collectivite_id) return;
+export const useFichesActionStatuts = (params: CountByStatutFilter) => {
+ const collectiviteId = useCollectiviteId()!;
- return api.get
({
- route: `/collectivites/${collectivite_id}/fiches-action/synthese`,
- params: { collectivite_id, ...params },
- });
- }
- );
+ return trpc.plans.fiches.countByStatut.useQuery({
+ collectiviteId,
+ filter: params,
+ });
};
diff --git a/app.territoiresentransitions.react/src/core-logic/api/auth/AuthProvider.tsx b/app.territoiresentransitions.react/src/core-logic/api/auth/AuthProvider.tsx
index a2bb2acf97..f3d4370580 100644
--- a/app.territoiresentransitions.react/src/core-logic/api/auth/AuthProvider.tsx
+++ b/app.territoiresentransitions.react/src/core-logic/api/auth/AuthProvider.tsx
@@ -6,8 +6,8 @@ import {
import {
clearAuthTokens,
getRootDomain,
+ getSession,
MaCollectivite,
- restoreSessionFromAuthTokens,
setAuthTokens,
} from '@tet/api';
import { Tables } from '@tet/api/database.types';
@@ -227,24 +227,6 @@ const clearCrispUserData = () => {
}
};
-export async function getSession() {
- const { data, error } = await supabaseClient.auth.getSession();
- if (data?.session) {
- return data.session;
- }
- if (error) throw error;
-
- // restaure une éventuelle session précédente
- const ret = await restoreSessionFromAuthTokens(supabaseClient);
- if (ret) {
- const { data, error } = ret;
- if (data?.session) {
- return data.session;
- }
- if (error) throw error;
- }
-}
-
const useCurrentSession = () => {
const { data, error } = useQuery(['session'], async () => {
return getSession();
@@ -256,13 +238,3 @@ const useCurrentSession = () => {
return data;
};
-
-export async function getAuthHeaders() {
- const session = await getSession();
- return session?.access_token
- ? {
- authorization: `Bearer ${session.access_token}`,
- apikey: `${ENV.supabase_anon_key}`,
- }
- : null;
-}
diff --git a/app.territoiresentransitions.react/src/core-logic/api/supabase.ts b/app.territoiresentransitions.react/src/core-logic/api/supabase.ts
index c584a16aab..24deed25c1 100644
--- a/app.territoiresentransitions.react/src/core-logic/api/supabase.ts
+++ b/app.territoiresentransitions.react/src/core-logic/api/supabase.ts
@@ -1,42 +1,6 @@
-import { createClient } from '@supabase/supabase-js';
-import * as Sentry from '@sentry/nextjs';
-import { ENV } from 'environmentVariables';
-import { Database } from '@tet/api';
+import { supabaseClient } from '@tet/api/utils/supabase-client';
-/**
- * Supabase client
- */
-export const supabaseClient = createClient(
- ENV.supabase_url!,
- ENV.supabase_anon_key!,
- {
- global: {
- // intercepte les requêtes pour traiter les erreurs globalement
- fetch: (input, init) => {
- return fetch(input as RequestInfo | URL, init).then((res) => {
- // en cas d'erreur
- if (res.status >= 400) {
- res
- // clone la réponse avant de la consommer
- .clone()
- .json()
- // et log l'erreur dans sentry
- .then(({ code, message }) => {
- Sentry.captureException(
- new Error(`Supabase error ${code}: ${message}`)
- );
- });
- }
- // renvoi la réponse originale
- return res;
- });
- },
- },
- db: {
- schema: 'public',
- },
- }
-);
+export { supabaseClient };
// options pour `useQuery` lorsqu'il s'agit de données qui ne changent pas trop
// souvent (définitions du référentiel etc.)
diff --git a/app.territoiresentransitions.react/src/utils/trpc.ts b/app.territoiresentransitions.react/src/utils/trpc.ts
deleted file mode 100644
index 4f23f6ab6b..0000000000
--- a/app.territoiresentransitions.react/src/utils/trpc.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { createTRPCReact, httpBatchLink } from '@trpc/react-query';
-import { getAuthHeaders } from 'core-logic/api/auth/AuthProvider';
-
-// By using `import type` you ensure that the reference will be stripped at compile-time, meaning you don't inadvertently import server-side code into your client.
-// For more information, see the Typescript docs: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
-// eslint-disable-next-line @nx/enforce-module-boundaries
-import type { AppRouter } from './../../../backend/src/trpc.router';
-
-const BASE_URL = `${process.env.NEXT_PUBLIC_BACKEND_URL}/trpc`;
-
-export const trpc = createTRPCReact();
-
-export const trpcClient = trpc.createClient({
- links: [
- httpBatchLink({
- url: BASE_URL,
- async headers() {
- const authHeaders = await getAuthHeaders();
- return {
- ...(authHeaders ?? {}),
- };
- },
- }),
- ],
-});
diff --git a/app.territoiresentransitions.react/tsconfig.json b/app.territoiresentransitions.react/tsconfig.json
index 02bc6e75f5..1914839a58 100644
--- a/app.territoiresentransitions.react/tsconfig.json
+++ b/app.territoiresentransitions.react/tsconfig.json
@@ -3,11 +3,9 @@
"compilerOptions": {
"jsx": "preserve",
"allowJs": false,
- "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"resolveJsonModule": true,
- "incremental": true,
"isolatedModules": true,
"plugins": [
{
diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts
index ae28732ef4..f2b0133351 100644
--- a/backend/src/app.module.ts
+++ b/backend/src/app.module.ts
@@ -10,7 +10,7 @@ import { PersonnalisationsModule } from './personnalisations/personnalisations.m
import { SheetModule } from './spreadsheets/sheet.module';
import { TrpcModule } from './trpc/trpc.module';
import { ConfigurationModule } from './config/configuration.module';
-import { TrpcRouter } from './trpc.router';
+import { TrpcRouter } from './trpc/trpc.router';
import { ReferentielsModule } from './referentiels/referentiels.module';
@Module({
diff --git a/backend/src/auth/decorators/token-info.decorators.ts b/backend/src/auth/decorators/token-info.decorators.ts
index dab9d416cf..73317d96fa 100644
--- a/backend/src/auth/decorators/token-info.decorators.ts
+++ b/backend/src/auth/decorators/token-info.decorators.ts
@@ -1,8 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+import { REQUEST_JWT_PAYLOAD_PARAM } from '../guards/auth.guard';
export const TokenInfo = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
- return request.tokenInfo;
+ return request[REQUEST_JWT_PAYLOAD_PARAM];
}
);
diff --git a/backend/src/auth/guards/auth.guard.ts b/backend/src/auth/guards/auth.guard.ts
index 3e4d09a61f..3ae9e9d25f 100644
--- a/backend/src/auth/guards/auth.guard.ts
+++ b/backend/src/auth/guards/auth.guard.ts
@@ -12,9 +12,17 @@ import { getErrorMessage } from '../../common/services/errors.helper';
import BackendConfigurationService from '../../config/configuration.service';
import { AllowAnonymousAccess } from '../decorators/allow-anonymous-access.decorator';
import { AllowPublicAccess } from '../decorators/allow-public-access.decorator';
-import { SupabaseJwtPayload } from '../models/supabase-jwt.models';
+import {
+ AuthJwtPayload,
+ AuthUser,
+ isAnonymousUser,
+ isAuthenticatedUser,
+ isServiceRoleUser,
+ jwtToUser,
+} from '../models/auth.models';
export const TOKEN_QUERY_PARAM = 'token';
+export const REQUEST_JWT_PAYLOAD_PARAM = 'jwt-payload';
@Injectable()
export class AuthGuard implements CanActivate {
@@ -39,40 +47,62 @@ export class AuthGuard implements CanActivate {
return true;
}
- const token = this.extractTokenFromRequest(request);
- if (!token) {
+ const jwtToken = this.extractTokenFromRequest(request);
+ if (!jwtToken) {
throw new UnauthorizedException();
}
+
+ // Validate JWT token and extract payload
+ let jwtPayload: AuthJwtPayload;
try {
- const payload: SupabaseJwtPayload = await this.jwtService.verifyAsync(
- token,
- {
- secret: this.backendConfigurationService.get('SUPABASE_JWT_SECRET'),
- }
- );
- // 💡 We're assigning the payload to the request object here
- // so that we can access it in our route handlers
- // @ts-expect-error tokenInfo doesn't exist on Request, but we want to attach it
- request['tokenInfo'] = payload;
- this.logger.log(`Token validated for user ${payload.sub}`);
- if (payload.is_anonymous) {
- const allowAnonymousAccess = this.reflector.get(
- AllowAnonymousAccess,
- context.getHandler()
- );
- if (allowAnonymousAccess) {
- this.logger.log(`Anonymous user is allowed`);
- return true;
- } else {
- this.logger.error(`Anonymous user is not allowed`);
- throw new UnauthorizedException();
- }
- }
+ jwtPayload = await this.jwtService.verifyAsync(jwtToken, {
+ secret: this.backendConfigurationService.get('SUPABASE_JWT_SECRET'),
+ });
} catch (err) {
this.logger.error(`Failed to validate token: ${getErrorMessage(err)}`);
throw new UnauthorizedException();
}
- return true;
+
+ // Convert JWT payload to user
+ let user: AuthUser;
+ try {
+ user = jwtToUser(jwtPayload);
+ } catch (err) {
+ this.logger.error(`Failed to convert token: ${getErrorMessage(err)}`);
+ throw new UnauthorizedException();
+ }
+
+ // 💡 We're assigning the user to the request object here so that we can access it in our route handlers
+ // @ts-expect-error force attach a new property to the request object
+ request[REQUEST_JWT_PAYLOAD_PARAM] = user;
+
+ if (isAuthenticatedUser(user)) {
+ this.logger.log(`Authenticated user is allowed`);
+ return true;
+ }
+
+ if (isServiceRoleUser(user)) {
+ this.logger.log(`Service role user is allowed`);
+ return true;
+ }
+
+ if (isAnonymousUser(user)) {
+ const allowAnonymousAccess = this.reflector.get(
+ AllowAnonymousAccess,
+ context.getHandler()
+ );
+
+ if (allowAnonymousAccess) {
+ this.logger.log(`Anonymous user is allowed`);
+ return true;
+ }
+
+ this.logger.error(`Anonymous user is not allowed`);
+ throw new UnauthorizedException();
+ }
+
+ this.logger.error(`Unknown user is not allowed`);
+ throw new UnauthorizedException();
}
private extractTokenFromRequest(request: Request): string | undefined {
diff --git a/backend/src/auth/models/auth.models.ts b/backend/src/auth/models/auth.models.ts
new file mode 100644
index 0000000000..3c2b8788e1
--- /dev/null
+++ b/backend/src/auth/models/auth.models.ts
@@ -0,0 +1,72 @@
+import { JwtPayload } from 'jsonwebtoken';
+
+export enum AuthRole {
+ AUTHENTICATED = 'authenticated',
+ SERVICE_ROLE = 'service_role',
+ ANON = 'anon', // Anonymous
+}
+
+// export type User = Pick;
+
+export interface AuthUser {
+ id: Role extends AuthRole.AUTHENTICATED ? string : null;
+ role: Role;
+ isAnonymous: Role extends AuthRole.AUTHENTICATED ? false : true;
+}
+
+export type AnonymousUser = AuthUser;
+export type AuthenticatedUser = AuthUser;
+export type ServiceRoleUser = AuthUser;
+
+export function isAnonymousUser(user: AuthUser | null): user is AnonymousUser {
+ return user?.role === AuthRole.ANON && user.isAnonymous === true;
+}
+
+export function isServiceRoleUser(
+ user: AuthUser | null
+): user is AnonymousUser {
+ return user?.role === AuthRole.SERVICE_ROLE && user.isAnonymous === true;
+}
+
+export function isAuthenticatedUser(
+ user: AuthUser | null
+): user is AuthenticatedUser {
+ return user?.role === AuthRole.AUTHENTICATED && user.isAnonymous === false;
+}
+
+export interface AuthJwtPayload
+ extends JwtPayload {
+ role: Role;
+}
+
+export function jwtToUser(jwt: AuthJwtPayload): AuthUser {
+ if (jwt.role === AuthRole.AUTHENTICATED) {
+ if (jwt.sub === undefined) {
+ throw new Error(`JWT sub claim is missing: ${JSON.stringify(jwt)}`);
+ }
+
+ return {
+ id: jwt.sub,
+ role: AuthRole.AUTHENTICATED,
+ isAnonymous: false,
+ };
+ }
+
+ if (jwt.role === AuthRole.ANON) {
+ return {
+ id: null,
+ role: AuthRole.ANON,
+ isAnonymous: true,
+ };
+ }
+
+ if (jwt.role === AuthRole.SERVICE_ROLE) {
+ return {
+ id: null,
+ role: AuthRole.SERVICE_ROLE,
+ isAnonymous: true,
+ };
+ }
+
+ throw new Error(`JWT role is invalid: ${JSON.stringify(jwt)}`);
+}
diff --git a/backend/src/auth/models/supabase-jwt.models.ts b/backend/src/auth/models/supabase-jwt.models.ts
deleted file mode 100644
index f0fdb7f5c7..0000000000
--- a/backend/src/auth/models/supabase-jwt.models.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as jwt from 'jsonwebtoken';
-
-export enum SupabaseRole {
- AUTHENTICATED = 'authenticated',
- SERVICE_ROLE = 'service_role',
- ANON = 'anon', // Anonymous
-}
-
-export interface SupabaseJwtPayload extends jwt.JwtPayload {
- email?: string;
- phone?: string;
- app_metadata?: {
- provider: string;
- providers: string[];
- };
- session_id: string;
- role: SupabaseRole;
- is_anonymous: boolean;
-}
diff --git a/backend/src/auth/services/auth.service.spec.ts b/backend/src/auth/services/auth.service.spec.ts
index 4da99c47f6..88d900b03e 100644
--- a/backend/src/auth/services/auth.service.spec.ts
+++ b/backend/src/auth/services/auth.service.spec.ts
@@ -5,8 +5,8 @@ import {
UtilisateurDroitType,
} from '../models/private-utilisateur-droit.table';
import { AuthService } from './auth.service';
-import { SupabaseRole } from '../models/supabase-jwt.models';
import CollectivitesService from '../../collectivites/services/collectivites.service';
+import { AuthRole } from '../models/auth.models';
describe('AuthService', () => {
let authService: AuthService;
@@ -29,7 +29,7 @@ describe('AuthService', () => {
it("Utilisateur qui n'a aucun droit", async () => {
expect(
authService.aDroitsSuffisants(
- SupabaseRole.AUTHENTICATED,
+ AuthRole.AUTHENTICATED,
[],
[1],
NiveauAcces.LECTURE
@@ -40,7 +40,7 @@ describe('AuthService', () => {
it('Utilisateur anonyme', async () => {
expect(
authService.aDroitsSuffisants(
- SupabaseRole.ANON,
+ AuthRole.ANON,
[],
[],
NiveauAcces.LECTURE
@@ -51,7 +51,7 @@ describe('AuthService', () => {
it('Service role', async () => {
expect(
authService.aDroitsSuffisants(
- SupabaseRole.SERVICE_ROLE,
+ AuthRole.SERVICE_ROLE,
[],
[1, 2, 3],
NiveauAcces.ADMIN
@@ -68,7 +68,7 @@ describe('AuthService', () => {
niveauAcces: NiveauAcces.EDITION,
active: true,
createdAt: new Date(),
- modifiedAt: null,
+ modifiedAt: new Date(),
invitationId: null,
},
{
@@ -78,14 +78,14 @@ describe('AuthService', () => {
niveauAcces: NiveauAcces.ADMIN,
active: true,
createdAt: new Date(),
- modifiedAt: null,
+ modifiedAt: new Date(),
invitationId: null,
},
];
expect(
authService.aDroitsSuffisants(
- SupabaseRole.AUTHENTICATED,
+ AuthRole.AUTHENTICATED,
droits,
[1, 2],
NiveauAcces.EDITION
@@ -102,7 +102,7 @@ describe('AuthService', () => {
niveauAcces: NiveauAcces.EDITION,
active: true,
createdAt: new Date(),
- modifiedAt: null,
+ modifiedAt: new Date(),
invitationId: null,
},
{
@@ -112,14 +112,14 @@ describe('AuthService', () => {
niveauAcces: NiveauAcces.ADMIN,
active: false,
createdAt: new Date(),
- modifiedAt: null,
+ modifiedAt: new Date(),
invitationId: null,
},
];
expect(
authService.aDroitsSuffisants(
- SupabaseRole.AUTHENTICATED,
+ AuthRole.AUTHENTICATED,
droits,
[1, 2],
NiveauAcces.EDITION
@@ -136,7 +136,7 @@ describe('AuthService', () => {
niveauAcces: NiveauAcces.LECTURE,
active: true,
createdAt: new Date(),
- modifiedAt: null,
+ modifiedAt: new Date(),
invitationId: null,
},
{
@@ -146,14 +146,14 @@ describe('AuthService', () => {
niveauAcces: NiveauAcces.ADMIN,
active: true,
createdAt: new Date(),
- modifiedAt: null,
+ modifiedAt: new Date(),
invitationId: null,
},
];
expect(
authService.aDroitsSuffisants(
- SupabaseRole.AUTHENTICATED,
+ AuthRole.AUTHENTICATED,
droits,
[1, 2],
NiveauAcces.EDITION
@@ -170,14 +170,14 @@ describe('AuthService', () => {
niveauAcces: NiveauAcces.EDITION,
active: true,
createdAt: new Date(),
- modifiedAt: null,
+ modifiedAt: new Date(),
invitationId: null,
},
];
expect(
authService.aDroitsSuffisants(
- SupabaseRole.AUTHENTICATED,
+ AuthRole.AUTHENTICATED,
droits,
[1, 2],
NiveauAcces.EDITION
diff --git a/backend/src/auth/services/auth.service.ts b/backend/src/auth/services/auth.service.ts
index 5a09d0a47f..5427d04f5f 100644
--- a/backend/src/auth/services/auth.service.ts
+++ b/backend/src/auth/services/auth.service.ts
@@ -1,17 +1,20 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { and, eq, inArray, sql, SQL, SQLWrapper } from 'drizzle-orm';
+import CollectivitesService from '../../collectivites/services/collectivites.service';
import DatabaseService from '../../common/services/database.service';
+import {
+ AuthenticatedUser,
+ AuthRole,
+ AuthUser,
+ isAuthenticatedUser,
+ isServiceRoleUser,
+} from '../models/auth.models';
import {
NiveauAcces,
niveauAccessOrdonne,
utilisateurDroitTable,
UtilisateurDroitType,
} from '../models/private-utilisateur-droit.table';
-import {
- SupabaseJwtPayload,
- SupabaseRole,
-} from '../models/supabase-jwt.models';
-import CollectivitesService from '../../collectivites/services/collectivites.service';
import { utilisateurSupportTable } from '../models/utilisateur-support.table';
import { utilisateurVerifieTable } from '../models/utilisateur-verifie.table';
@@ -25,17 +28,17 @@ export class AuthService {
) {}
aDroitsSuffisants(
- tokenRole: SupabaseRole,
+ tokenRole: AuthRole,
droits: UtilisateurDroitType[],
collectiviteIds: number[],
niveauAccessMinimum: NiveauAcces
): boolean {
- if (tokenRole === SupabaseRole.SERVICE_ROLE) {
+ if (tokenRole === AuthRole.SERVICE_ROLE) {
this.logger.log(
`Rôle de service détecté, accès autorisé à toutes les collectivités`
);
return true;
- } else if (tokenRole === SupabaseRole.AUTHENTICATED) {
+ } else if (tokenRole === AuthRole.AUTHENTICATED) {
const niveauAccessMinimumIndex =
niveauAccessOrdonne.indexOf(niveauAccessMinimum);
@@ -94,85 +97,86 @@ export class AuthService {
.select()
.from(utilisateurDroitTable)
.where(and(...conditions));
+
this.logger.log(`${droits.length} droits récupérés`);
return droits;
}
async verifieAccesAuxCollectivites(
- tokenInfo: SupabaseJwtPayload,
+ user: AuthUser,
collectiviteIds: number[],
niveauAccessMinimum: NiveauAcces,
doNotThrow?: boolean
): Promise {
let droits: UtilisateurDroitType[] = [];
- const userId = tokenInfo?.sub;
- if (tokenInfo?.role === SupabaseRole.AUTHENTICATED && userId) {
- droits = await this.getDroitsUtilisateur(userId, collectiviteIds);
+
+ if (isAuthenticatedUser(user)) {
+ droits = await this.getDroitsUtilisateur(user.id, collectiviteIds);
}
+
const authorise = this.aDroitsSuffisants(
- tokenInfo?.role,
+ user.role,
droits,
collectiviteIds,
niveauAccessMinimum
);
+
if (!authorise && !doNotThrow) {
throw new UnauthorizedException(`Droits insuffisants`);
}
+
return authorise;
}
/**
* Vérifie si l'utilisateur a un rôle support
- * @param tokenInfo token de l'utilisateur
+ * @param user token de l'utilisateur
* @return vrai si l'utilisateur a un rôle support
*/
- async estSupport(tokenInfo: SupabaseJwtPayload): Promise {
- const userId = tokenInfo.sub;
- if (tokenInfo.role === SupabaseRole.AUTHENTICATED && userId) {
+ async estSupport(user: AuthUser): Promise {
+ if (isAuthenticatedUser(user)) {
const result = await this.databaseService.db
.select()
.from(utilisateurSupportTable)
- .where(eq(utilisateurSupportTable.userId, userId));
+ .where(eq(utilisateurSupportTable.userId, user.id));
return result[0].support || false;
}
+
return false;
}
/**
* Vérifie si l'utilisateur est vérifié
- * @param tokenInfo token de l'utilisateur
+ * @param tokenInfo utilisateur authentifié
* @return vrai si l'utilisateur est vérifié
*/
- async estVerifie(tokenInfo: SupabaseJwtPayload): Promise {
- const userId = tokenInfo.sub;
- if (tokenInfo.role === SupabaseRole.AUTHENTICATED && userId) {
- const result = await this.databaseService.db
- .select()
- .from(utilisateurVerifieTable)
- .where(eq(utilisateurVerifieTable.userId, userId));
- return result[0].verifie || false;
- }
- return false;
+ async estVerifie(user: AuthenticatedUser): Promise {
+ const result = await this.databaseService.db
+ .select()
+ .from(utilisateurVerifieTable)
+ .where(eq(utilisateurVerifieTable.userId, user.id));
+
+ return result[0].verifie || false;
}
/**
* Vérifie si un utilisateur a accès en lecture à une collectivité
* en prenant en compte la restriction possible de la collectivité
- * @param tokenInfo token de l'utilisateur
+ * @param user token de l'utilisateur
* @param collectiviteId identifiant de la collectivité
* @param doNotThrow vrai pour ne pas générer une exception
*/
async verifieAccesRestreintCollectivite(
- tokenInfo: SupabaseJwtPayload,
+ user: AuthUser,
collectiviteId: number,
doNotThrow?: boolean
): Promise {
- if (tokenInfo.role === SupabaseRole.SERVICE_ROLE) {
+ if (isServiceRoleUser(user)) {
this.logger.log(
`Rôle de service détecté, accès autorisé à toutes les collectivités`
);
return true;
- } else if (tokenInfo.role === SupabaseRole.AUTHENTICATED) {
+ } else if (isAuthenticatedUser(user)) {
let authorise = false;
const collectivite = await this.collectiviteService.getCollectivite(
collectiviteId
@@ -183,20 +187,20 @@ export class AuthService {
// ou être un auditeur d'un audit courant de la collectivité.
authorise =
(await this.verifieAccesAuxCollectivites(
- tokenInfo,
+ user,
[collectiviteId],
NiveauAcces.LECTURE,
true
)) ||
- (await this.estSupport(tokenInfo)) ||
- (await this.estAuditeur(tokenInfo, collectiviteId));
+ (await this.estSupport(user)) ||
+ (await this.estAuditeur(user, collectiviteId));
} else {
// Si la collectivité n'est pas en accès restreint, l'utilisateur doit :
// être vérifié, ou s'il ne l'est pas, avoir un droit en lecture sur la collectivité.
authorise =
- (await this.estVerifie(tokenInfo)) ||
+ (await this.estVerifie(user)) ||
(await this.verifieAccesAuxCollectivites(
- tokenInfo,
+ user,
[collectiviteId],
NiveauAcces.LECTURE,
true
@@ -213,27 +217,23 @@ export class AuthService {
/**
* Vérifie que l'utilisateur est un auditeur de la collectivité
* TODO à modifier avec les tables liées aux labellisations
- * @param tokenInfo token de l'utilisateur
+ * @param user utilisateur authentifié
* @param collectiviteId identifiant de la collectivité
*/
async estAuditeur(
- tokenInfo: SupabaseJwtPayload,
+ user: AuthenticatedUser,
collectiviteId: number
): Promise {
- const userId = tokenInfo.sub;
- if (tokenInfo.role === SupabaseRole.AUTHENTICATED && userId) {
- const result = await this.databaseService.db.execute(
- sql
- `SELECT *
- FROM audit_auditeur aa
- JOIN labellisation.audit a ON aa.audit_id = a.id
- WHERE a.date_debut IS NOT NULL
- AND a.clos IS FALSE
- AND a.collectivite_id = ${collectiviteId}
- AND aa.auditeur = ${userId}`
- );
- return result?.length > 0 || false;
- }
- return false;
+ const result = await this.databaseService.db.execute(
+ sql`SELECT *
+ FROM audit_auditeur aa
+ JOIN labellisation.audit a ON aa.audit_id = a.id
+ WHERE a.date_debut IS NOT NULL
+ AND a.clos IS FALSE
+ AND a.collectivite_id = ${collectiviteId}
+ AND aa.auditeur = ${user.id}`
+ );
+
+ return result?.length > 0 || false;
}
}
diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts
index cb241faefe..5ab45c3f96 100644
--- a/backend/src/common/common.module.ts
+++ b/backend/src/common/common.module.ts
@@ -3,11 +3,12 @@ import { ConfigurationModule } from '../config/configuration.module';
import { VersionController } from './controllers/version.controller';
import DatabaseService from './services/database.service';
import MattermostNotificationService from './services/mattermost-notification.service';
+import SupabaseService from './services/supabase.service';
@Module({
imports: [ConfigurationModule],
- providers: [DatabaseService, MattermostNotificationService],
- exports: [DatabaseService, MattermostNotificationService],
+ providers: [DatabaseService, MattermostNotificationService, SupabaseService],
+ exports: [DatabaseService, MattermostNotificationService, SupabaseService],
controllers: [VersionController],
})
export class CommonModule {}
diff --git a/backend/src/common/models/count-synthese.dto.ts b/backend/src/common/models/count-synthese.dto.ts
index 7eff1063fc..221641039b 100644
--- a/backend/src/common/models/count-synthese.dto.ts
+++ b/backend/src/common/models/count-synthese.dto.ts
@@ -1,17 +1,18 @@
-import { extendApi, extendZodWithOpenApi } from '@anatine/zod-openapi';
import { z } from 'zod';
-extendZodWithOpenApi(z);
+export const countSyntheseValeurSchema = z.object({
+ count: z.number().int(),
+ valeur: z.union([z.string(), z.number(), z.boolean(), z.null()]),
+});
-export const countSyntheseValeurSchema = extendApi(
- z.object({
- count: z.number().int(),
- valeur: z.union([z.string(), z.number(), z.boolean(), z.null()]),
- })
-);
export type CountSyntheseValeurType = z.infer;
-export const countSyntheseSchema = extendApi(
- z.record(z.string(), countSyntheseValeurSchema)
+export const countByRecordSchema = z.record(
+ z.string(),
+ countSyntheseValeurSchema
);
-export type CountSyntheseType = z.infer;
+
+export type CountByRecordType = Record<
+ string,
+ { count: number; valeur: Value }
+>;
diff --git a/backend/src/fiches/controllers/fiches-action.controller.ts b/backend/src/fiches/controllers/fiches-action.controller.ts
index 6a505da1d0..b4cb6e8d3b 100644
--- a/backend/src/fiches/controllers/fiches-action.controller.ts
+++ b/backend/src/fiches/controllers/fiches-action.controller.ts
@@ -10,11 +10,11 @@ import {
} from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { TokenInfo } from '../../auth/decorators/token-info.decorators';
-import type { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import type { AuthenticatedUser } from '../../auth/models/auth.models';
import { getFichesActionSyntheseSchema } from '../models/get-fiches-action-synthese.response';
import { getFichesActionFilterRequestSchema } from '../models/get-fiches-actions-filter.request';
import { updateFicheActionRequestSchema } from '../models/update-fiche-action.request';
-import FichesActionSyntheseService from '../services/fiches-action-synthese.service';
+import { CountByStatutService } from '../count-by-statut/count-by-statut.service';
import FichesActionUpdateService from '../services/fiches-action-update.service';
import {
deleteFicheActionNotesRequestSchema,
@@ -53,7 +53,7 @@ export class DeleteFicheActionNotesRequestClass extends createZodDto(
export class FichesActionController {
constructor(
private readonly ficheService: FicheService,
- private readonly fichesActionSyntheseService: FichesActionSyntheseService,
+ private readonly fichesActionSyntheseService: CountByStatutService,
private readonly fichesActionUpdateService: FichesActionUpdateService
) {}
@@ -66,12 +66,11 @@ export class FichesActionController {
async getFichesActionSynthese(
@Param('collectivite_id') collectiviteId: number,
@Query() request: GetFichesActionFilterRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
) {
- return this.fichesActionSyntheseService.getFichesActionSynthese(
+ return this.fichesActionSyntheseService.countByStatut(
collectiviteId,
- request,
- tokenInfo
+ request
);
}
@@ -83,12 +82,11 @@ export class FichesActionController {
async getFichesAction(
@Param('collectivite_id') collectiviteId: number,
@Query() request: GetFichesActionFilterRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
) {
return this.fichesActionSyntheseService.getFichesAction(
collectiviteId,
- request,
- tokenInfo
+ request
);
}
@@ -101,7 +99,7 @@ export class FichesActionController {
@Param('id') id: number,
@Body()
body: UpdateFicheActionRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
) {
return await this.fichesActionUpdateService.updateFicheAction(
id,
@@ -117,7 +115,7 @@ export class FichesActionController {
})
async selectFicheActionNotes(
@Param('id') ficheId: number,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
) {
return this.ficheService.getNotes(ficheId, tokenInfo);
}
@@ -130,7 +128,7 @@ export class FichesActionController {
async upsertFicheActionNotes(
@Param('id') ficheId: number,
@Body() body: UpsertFicheActionNotesRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
) {
return this.fichesActionUpdateService.upsertNotes(
ficheId,
@@ -147,7 +145,7 @@ export class FichesActionController {
async deleteFicheActionNotes(
@Param('id') ficheId: number,
@Body() body: DeleteFicheActionNotesRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
) {
return this.fichesActionUpdateService.deleteNote(
ficheId,
diff --git a/backend/src/fiches/count-by-statut/count-by-statut.router.e2e-spec.ts b/backend/src/fiches/count-by-statut/count-by-statut.router.e2e-spec.ts
new file mode 100644
index 0000000000..29fc93d596
--- /dev/null
+++ b/backend/src/fiches/count-by-statut/count-by-statut.router.e2e-spec.ts
@@ -0,0 +1,52 @@
+import { AuthenticatedUser } from './../../auth/models/auth.models';
+import { inferProcedureInput } from '@trpc/server';
+import { getYoloDodoUser } from '../../../test/auth/auth-utils';
+import { getTestRouter } from '../../../test/common/app-utils';
+import { AppRouter, TrpcRouter } from '../../trpc/trpc.router';
+import { FicheActionStatutsEnumType } from '../models/fiche-action.table';
+
+type Input = inferProcedureInput;
+
+describe('CountByStatutRouter', () => {
+ let router: TrpcRouter;
+ let yoloDodoUser: AuthenticatedUser;
+
+ beforeAll(async () => {
+ router = await getTestRouter();
+ yoloDodoUser = await getYoloDodoUser();
+ });
+
+ test('authenticated, with empty filter', async () => {
+ const caller = router.createCaller({ user: yoloDodoUser });
+
+ const input: Input = {
+ collectiviteId: 1,
+ filter: {},
+ };
+
+ const result = await caller.plans.fiches.countByStatut(input);
+ expect(result).toMatchObject({});
+
+ for (const statut of Object.values(FicheActionStatutsEnumType)) {
+ expect(result[statut]).toMatchObject({
+ valeur: expect.any(String),
+ count: expect.any(Number),
+ });
+ }
+ });
+
+ test('not authenticated', async () => {
+ const caller = router.createCaller({ user: null });
+
+ const input: Input = {
+ collectiviteId: 1,
+ filter: {},
+ };
+
+ // `rejects` is necessary to handle exception in async function
+ // See https://vitest.dev/api/expect.html#tothrowerror
+ await expect(() =>
+ caller.plans.fiches.countByStatut(input)
+ ).rejects.toThrowError(/not authenticated/i);
+ });
+});
diff --git a/backend/src/fiches/count-by-statut/count-by-statut.router.ts b/backend/src/fiches/count-by-statut/count-by-statut.router.ts
new file mode 100644
index 0000000000..95e9aea06c
--- /dev/null
+++ b/backend/src/fiches/count-by-statut/count-by-statut.router.ts
@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import { z } from 'zod';
+import { getFichesActionFilterRequestSchema } from '../models/get-fiches-actions-filter.request';
+import { TrpcService } from '../../trpc/trpc.service';
+import { CountByStatutService } from './count-by-statut.service';
+
+const inputSchema = z.object({
+ collectiviteId: z.number(),
+ filter: getFichesActionFilterRequestSchema,
+});
+
+@Injectable()
+export class CountByStatutRouter {
+ constructor(
+ private readonly trpc: TrpcService,
+ private readonly service: CountByStatutService
+ ) {}
+
+ router = this.trpc.router({
+ countByStatut: this.trpc.authedProcedure
+ .input(inputSchema)
+ .query(({ input }) => {
+ const { collectiviteId, filter } = input;
+ return this.service.countByStatut(collectiviteId, filter);
+ }),
+ });
+}
diff --git a/backend/src/fiches/services/fiches-action-synthese.service.ts b/backend/src/fiches/count-by-statut/count-by-statut.service.ts
similarity index 87%
rename from backend/src/fiches/services/fiches-action-synthese.service.ts
rename to backend/src/fiches/count-by-statut/count-by-statut.service.ts
index 345291b7c4..99cb400334 100644
--- a/backend/src/fiches/services/fiches-action-synthese.service.ts
+++ b/backend/src/fiches/count-by-statut/count-by-statut.service.ts
@@ -13,7 +13,7 @@ import {
} from 'drizzle-orm';
import { PgColumn } from 'drizzle-orm/pg-core';
import { AuthService } from '../../auth/services/auth.service';
-import { CountSyntheseType } from '../../common/models/count-synthese.dto';
+import { CountByRecordType } from '../../common/models/count-synthese.dto';
import { getModifiedSinceDate } from '../../common/models/modified-since.enum';
import DatabaseService from '../../common/services/database.service';
import { axeTable } from '../models/axe.table';
@@ -26,13 +26,11 @@ import {
ficheActionTable,
SANS_STATUT_FICHE_ACTION_SYNTHESE_KEY,
} from '../models/fiche-action.table';
-import { GetFichesActionSyntheseResponseType } from '../models/get-fiches-action-synthese.response';
import { GetFichesActionFilterRequestType } from '../models/get-fiches-actions-filter.request';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
@Injectable()
-export default class FichesActionSyntheseService {
- private readonly logger = new Logger(FichesActionSyntheseService.name);
+export class CountByStatutService {
+ private readonly logger = new Logger(CountByStatutService.name);
private readonly FICHE_ACTION_PARTENAIRE_TAGS_QUERY_ALIAS =
'ficheActionPartenaireTags';
@@ -44,33 +42,30 @@ export default class FichesActionSyntheseService {
private readonly authService: AuthService
) {}
- async getFichesActionSynthese(
+ async countByStatut(
collectiviteId: number,
- filter: GetFichesActionFilterRequestType,
- tokenInfo: SupabaseJwtPayload
- ): Promise {
+ filter: GetFichesActionFilterRequestType
+ ) {
this.logger.log(
`Récupération de la synthese des fiches action pour la collectivité ${collectiviteId}: filtre ${JSON.stringify(
filter
)}`
);
- const listeValeurs = Object.values(FicheActionStatutsEnumType) as string[];
+ const listeValeurs = Object.values(FicheActionStatutsEnumType);
const conditions = this.getConditions(collectiviteId, filter);
- const statutSynthese = await this.getSynthesePourPropriete(
+
+ const result = await this.countBy(
ficheActionTable.statut,
conditions,
listeValeurs,
SANS_STATUT_FICHE_ACTION_SYNTHESE_KEY
);
- const synthese: GetFichesActionSyntheseResponseType = {
- par_statut: statutSynthese,
- };
- return synthese;
+ return result;
}
- getFicheActionPartenaireTagsQuery() {
+ private getFicheActionPartenaireTagsQuery() {
return this.databaseService.db
.select({
fiche_id: ficheActionPartenaireTagTable.ficheId,
@@ -84,7 +79,7 @@ export default class FichesActionSyntheseService {
.as(this.FICHE_ACTION_PARTENAIRE_TAGS_QUERY_ALIAS);
}
- getFicheActionAxesQuery() {
+ private getFicheActionAxesQuery() {
return this.databaseService.db
.select({
fiche_id: ficheActionAxeTable.ficheId,
@@ -97,7 +92,7 @@ export default class FichesActionSyntheseService {
.as('ficheActionAxes');
}
- getFicheActionServiceTagsQuery() {
+ private getFicheActionServiceTagsQuery() {
return this.databaseService.db
.select({
fiche_id: ficheActionServiceTagTable.ficheId,
@@ -111,7 +106,7 @@ export default class FichesActionSyntheseService {
.as('ficheActionServiceTag');
}
- getFicheActionPilotesQuery() {
+ private getFicheActionPilotesQuery() {
return this.databaseService.db
.select({
fiche_id: ficheActionPiloteTable.ficheId,
@@ -131,9 +126,8 @@ export default class FichesActionSyntheseService {
async getFichesAction(
collectiviteId: number,
- filter: GetFichesActionFilterRequestType,
- tokenInfo: SupabaseJwtPayload
- ): Promise {
+ filter: GetFichesActionFilterRequestType
+ ) {
this.logger.log(
`Récupération des fiches action pour la collectivité ${collectiviteId}: filtre ${JSON.stringify(
filter
@@ -179,7 +173,7 @@ export default class FichesActionSyntheseService {
return await fichesActionQuery;
}
- getConditions(
+ private getConditions(
collectiviteId: number,
filter: GetFichesActionFilterRequestType
): (SQLWrapper | SQL)[] {
@@ -242,20 +236,20 @@ export default class FichesActionSyntheseService {
return conditions;
}
- async getSynthesePourPropriete(
+ private async countBy(
propriete: PgColumn,
conditions: (SQLWrapper | SQL)[],
- listeValeurs?: string[],
- nullValue?: string
- ): Promise {
+ values: Value[],
+ nullValue?: NullValue
+ ) {
const ficheActionPartenaireTags = this.getFicheActionPartenaireTagsQuery();
const ficheActionPilotes = this.getFicheActionPilotesQuery();
const ficheActionServiceTags = this.getFicheActionServiceTagsQuery();
const ficheActionAxes = this.getFicheActionAxesQuery();
- if (listeValeurs && nullValue && !listeValeurs.includes(nullValue)) {
- listeValeurs.push(nullValue);
- }
+ const listeValeurs: Array = [
+ ...new Set([...values, ...(nullValue ? [nullValue] : [])]),
+ ];
const fichesActionSyntheseQuery = this.databaseService.db
.select({
@@ -283,7 +277,7 @@ export default class FichesActionSyntheseService {
.groupBy(propriete);
const fichesActionSynthese = await fichesActionSyntheseQuery;
- const synthese: CountSyntheseType = {};
+ const synthese = {} as CountByRecordType;
if (listeValeurs) {
listeValeurs.forEach((valeur) => {
synthese[valeur] = {
diff --git a/backend/src/fiches/fiches-action.module.ts b/backend/src/fiches/fiches-action.module.ts
index 8ecb3344b2..24cce9deee 100644
--- a/backend/src/fiches/fiches-action.module.ts
+++ b/backend/src/fiches/fiches-action.module.ts
@@ -3,20 +3,22 @@ import { AuthModule } from '../auth/auth.module';
import { CollectivitesModule } from '../collectivites/collectivites.module';
import { CommonModule } from '../common/common.module';
import { FichesActionController } from './controllers/fiches-action.controller';
-import FichesActionSyntheseService from './services/fiches-action-synthese.service';
+import { CountByStatutService } from './count-by-statut/count-by-statut.service';
import FichesActionUpdateService from './services/fiches-action-update.service';
import FicheService from './services/fiche.service';
import TagService from '../taxonomie/services/tag.service';
+import { CountByStatutRouter } from './count-by-statut/count-by-statut.router';
@Module({
imports: [CommonModule, AuthModule, CollectivitesModule],
providers: [
FicheService,
- FichesActionSyntheseService,
+ CountByStatutService,
FichesActionUpdateService,
TagService,
+ CountByStatutRouter,
],
- exports: [FichesActionSyntheseService],
+ exports: [CountByStatutService, CountByStatutRouter],
controllers: [FichesActionController],
})
export class FichesActionModule {}
diff --git a/backend/src/fiches/models/fiche-action-libre-tag.table.ts b/backend/src/fiches/models/fiche-action-libre-tag.table.ts
index 67c47c10d8..f0b718a485 100644
--- a/backend/src/fiches/models/fiche-action-libre-tag.table.ts
+++ b/backend/src/fiches/models/fiche-action-libre-tag.table.ts
@@ -1,5 +1,5 @@
-import { libreTagTable } from 'backend/src/taxonomie/models/libre-tag.table';
import { integer, pgTable, primaryKey } from 'drizzle-orm/pg-core';
+import { libreTagTable } from '../../taxonomie/models/libre-tag.table';
import { ficheActionTable } from './fiche-action.table';
export const ficheActionLibreTagTable = pgTable(
diff --git a/backend/src/fiches/models/get-fiches-action-synthese.response.ts b/backend/src/fiches/models/get-fiches-action-synthese.response.ts
index 4e30188d60..374251dbe4 100644
--- a/backend/src/fiches/models/get-fiches-action-synthese.response.ts
+++ b/backend/src/fiches/models/get-fiches-action-synthese.response.ts
@@ -1,12 +1,12 @@
import { extendApi, extendZodWithOpenApi } from '@anatine/zod-openapi';
import { z } from 'zod';
-import { countSyntheseSchema } from '../../common/models/count-synthese.dto';
+import { countSyntheseValeurSchema } from '../../common/models/count-synthese.dto';
extendZodWithOpenApi(z);
export const getFichesActionSyntheseSchema = extendApi(
z.object({
- par_statut: countSyntheseSchema,
+ par_statut: countSyntheseValeurSchema,
})
);
diff --git a/backend/src/fiches/models/get-fiches-actions-filter.request.ts b/backend/src/fiches/models/get-fiches-actions-filter.request.ts
index a12f748b10..c71d1dc1c2 100644
--- a/backend/src/fiches/models/get-fiches-actions-filter.request.ts
+++ b/backend/src/fiches/models/get-fiches-actions-filter.request.ts
@@ -1,77 +1,68 @@
-import { extendApi } from '@anatine/zod-openapi';
import { z } from 'zod';
import { modifiedSinceSchema } from '../../common/models/modified-since.enum';
import { ficheActionCiblesEnumSchema } from './fiche-action.table';
-export const getFichesActionFilterRequestSchema = extendApi(
- z
- .object({
- cibles: z
- .string()
- .transform((value) => value.split(','))
- .pipe(ficheActionCiblesEnumSchema.array())
- .optional()
- .openapi({
- description: 'Liste des cibles séparées par des virgules',
- }),
+export const getFichesActionFilterRequestSchema = z
+ .object({
+ cibles: z
+ .string()
+ .transform((value) => value.split(','))
+ .pipe(ficheActionCiblesEnumSchema.array())
+ .optional()
+ .describe('Liste des cibles séparées par des virgules'),
+ partenaire_tag_ids: z
+ .string()
+ .transform((value) => value.split(','))
+ .pipe(z.coerce.number().array())
+ .optional()
+ .describe(
+ 'Liste des identifiants de tags de partenaires séparés par des virgules ouaich'
+ ),
+ pilote_tag_ids: z
+ .string()
+ .transform((value) => value.split(','))
+ .pipe(z.coerce.number().array())
+ .optional()
+ .describe(
+ 'Liste des identifiants de tags des personnes pilote séparées par des virgules'
+ ),
+ pilote_user_ids: z
+ .string()
+ .transform((value) => value.split(','))
+ .pipe(z.string().array())
+ .optional()
+ .describe(
+ 'Liste des identifiants des utilisateurs pilote séparées par des virgules'
+ ),
+ service_tag_ids: z
+ .string()
+ .transform((value) => value.split(','))
+ .pipe(z.coerce.number().array())
+ .optional()
+ .describe(
+ 'Liste des identifiants de tags de services séparés par des virgules'
+ ),
+ plan_ids: z
+ .string()
+ .transform((value) => value.split(','))
+ .pipe(z.coerce.number().array())
+ .optional()
+ .describe(
+ "Liste des identifiants des plans d'action séparés par des virgules"
+ ),
+ modified_after: z
+ .string()
+ .datetime()
+ .optional()
+ .describe('Uniquement les fiches modifiées après cette date'),
+ modified_since: modifiedSinceSchema
+ .optional()
+ .describe(
+ 'Filtre sur la date de modification en utilisant des valeurs prédéfinies'
+ ),
+ })
+ .describe('Filtre de récupération des fiches action');
- partenaire_tag_ids: z
- .string()
- .transform((value) => value.split(','))
- .pipe(z.coerce.number().array())
- .optional()
- .openapi({
- description:
- 'Liste des identifiants de tags de partenaires séparés par des virgules',
- }),
- pilote_tag_ids: z
- .string()
- .transform((value) => value.split(','))
- .pipe(z.coerce.number().array())
- .optional()
- .openapi({
- description:
- 'Liste des identifiants de tags des personnes pilote séparées par des virgules',
- }),
- pilote_user_ids: z
- .string()
- .transform((value) => value.split(','))
- .pipe(z.string().array())
- .optional()
- .openapi({
- description:
- 'Liste des identifiants des utilisateurs pilote séparées par des virgules',
- }),
- service_tag_ids: z
- .string()
- .transform((value) => value.split(','))
- .pipe(z.coerce.number().array())
- .optional()
- .openapi({
- description:
- 'Liste des identifiants de tags de services séparés par des virgules',
- }),
- plan_ids: z
- .string()
- .transform((value) => value.split(','))
- .pipe(z.coerce.number().array())
- .optional()
- .openapi({
- description:
- "Liste des identifiants des plans d'action séparés par des virgules",
- }),
- modified_after: z.string().datetime().optional().openapi({
- description: 'Uniquement les fiches modifiées après cette date',
- }),
- modified_since: modifiedSinceSchema.optional().openapi({
- description:
- 'Filtre sur la date de modification en utilisant des valeurs prédéfinies',
- }),
- })
- .openapi({
- title: 'Filtre de récupération des fiches action',
- })
-);
export type GetFichesActionFilterRequestType = z.infer<
typeof getFichesActionFilterRequestSchema
>;
diff --git a/backend/src/fiches/services/fiche.service.ts b/backend/src/fiches/services/fiche.service.ts
index ba78437c26..2b14807d5a 100644
--- a/backend/src/fiches/services/fiche.service.ts
+++ b/backend/src/fiches/services/fiche.service.ts
@@ -15,7 +15,7 @@ import { ficheActionSousThematiqueTable } from '../models/fiche-action-sous-them
import { ficheActionThematiqueTable } from '../models/fiche-action-thematique.table';
import { ficheActionNoteTable } from '../models/fiche-action-note.table';
import { AuthService } from '../../auth/services/auth.service';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import { NiveauAcces } from '../../auth/models/private-utilisateur-droit.table';
import { dcpTable } from '../../auth/models/dcp.table';
@@ -41,7 +41,7 @@ export default class FicheService {
/** Détermine si un utilisateur peut lire une fiche */
async canReadFiche(
ficheId: number,
- tokenInfo: SupabaseJwtPayload
+ tokenInfo: AuthenticatedUser
): Promise {
const fiche = await this.getFicheFromId(ficheId);
if (fiche === null) return false;
@@ -62,7 +62,7 @@ export default class FicheService {
/** Détermine si un utilisateur peut modifier une fiche */
async canWriteFiche(
ficheId: number,
- tokenInfo: SupabaseJwtPayload
+ tokenInfo: AuthenticatedUser
): Promise {
const fiche = await this.getFicheFromId(ficheId);
if (fiche === null) return false;
@@ -199,7 +199,7 @@ export default class FicheService {
}
/** Lit les notes de suivi attachées à la fiche */
- async getNotes(ficheId: number, tokenInfo: SupabaseJwtPayload) {
+ async getNotes(ficheId: number, tokenInfo: AuthenticatedUser) {
const canRead = await this.canReadFiche(ficheId, tokenInfo);
if (!canRead) return false;
diff --git a/backend/src/fiches/services/fiches-action-update.service.ts b/backend/src/fiches/services/fiches-action-update.service.ts
index c8d04df620..7be0f6bdae 100644
--- a/backend/src/fiches/services/fiches-action-update.service.ts
+++ b/backend/src/fiches/services/fiches-action-update.service.ts
@@ -11,7 +11,7 @@ import {
import { PgTable, PgTransaction } from 'drizzle-orm/pg-core';
import { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js';
import { toCamel } from 'postgres';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import DatabaseService from '../../common/services/database.service';
import { buildConflictUpdateColumns } from '../../common/services/conflict.helper';
import FicheService from './fiche.service';
@@ -75,7 +75,7 @@ export default class FichesActionUpdateService {
async updateFicheAction(
ficheActionId: number,
body: UpdateFicheActionRequestType,
- tokenInfo: SupabaseJwtPayload
+ tokenInfo: AuthenticatedUser
) {
await this.ficheService.canWriteFiche(ficheActionId, tokenInfo);
@@ -415,7 +415,7 @@ export default class FichesActionUpdateService {
async upsertNotes(
ficheId: number,
notes: UpsertFicheActionNoteType[],
- tokenInfo: SupabaseJwtPayload
+ tokenInfo: AuthenticatedUser
) {
this.logger.log(
`Vérifie les droits avant de mettre à jour les notes de la fiche ${ficheId}`
@@ -431,8 +431,8 @@ export default class FichesActionUpdateService {
notes.map((note) => ({
...note,
ficheId,
- createdBy: tokenInfo.sub,
- modifiedBy: tokenInfo.sub,
+ createdBy: tokenInfo.id,
+ modifiedBy: tokenInfo.id,
}))
)
.onConflictDoUpdate({
@@ -449,7 +449,7 @@ export default class FichesActionUpdateService {
async deleteNote(
ficheId: number,
dateNote: string,
- tokenInfo: SupabaseJwtPayload
+ tokenInfo: AuthenticatedUser
) {
this.logger.log(
`Vérifie les droits avant de supprimer la note datée ${dateNote} de la fiche ${ficheId}`
diff --git a/backend/src/indicateurs/controllers/indicateurs.controller.ts b/backend/src/indicateurs/controllers/indicateurs.controller.ts
index fefbb0c159..147844a169 100644
--- a/backend/src/indicateurs/controllers/indicateurs.controller.ts
+++ b/backend/src/indicateurs/controllers/indicateurs.controller.ts
@@ -2,15 +2,13 @@ import { createZodDto } from '@anatine/zod-nestjs';
import { Body, Controller, Get, Logger, Post, Query } from '@nestjs/common';
import { ApiCreatedResponse, ApiResponse, ApiTags } from '@nestjs/swagger';
import { TokenInfo } from '../../auth/decorators/token-info.decorators';
-import {
- getIndicateursValeursResponseSchema,
-} from '../models/get-indicateurs.response';
+import { getIndicateursValeursResponseSchema } from '../models/get-indicateurs.response';
import {
UpsertIndicateursValeursRequest,
UpsertIndicateursValeursResponse,
} from '../models/upsert-indicateurs-valeurs.request';
import IndicateursService from '../services/indicateurs.service';
-import type { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import type { AuthenticatedUser } from '../../auth/models/auth.models';
import { getIndicateursValeursRequestSchema } from '../models/get-indicateurs.request';
/**
@@ -35,7 +33,7 @@ export class IndicateursController {
@ApiResponse({ type: GetIndicateursValeursResponseClass })
async getIndicateurValeurs(
@Query() request: GetIndicateursValeursRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return this.indicateurService.getIndicateurValeursGroupees(
request,
@@ -49,7 +47,7 @@ export class IndicateursController {
})
async upsertIndicateurValeurs(
@Body() request: UpsertIndicateursValeursRequest,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
const upsertedValeurs =
await this.indicateurService.upsertIndicateurValeurs(
diff --git a/backend/src/indicateurs/controllers/trajectoires.controller.ts b/backend/src/indicateurs/controllers/trajectoires.controller.ts
index c6f27719fc..ecc84cbd3b 100644
--- a/backend/src/indicateurs/controllers/trajectoires.controller.ts
+++ b/backend/src/indicateurs/controllers/trajectoires.controller.ts
@@ -17,7 +17,7 @@ import { calculTrajectoireRequestSchema } from '../models/calcul-trajectoire.req
import TrajectoiresDataService from '../services/trajectoires-data.service';
import TrajectoiresSpreadsheetService from '../services/trajectoires-spreadsheet.service';
import TrajectoiresXlsxService from '../services/trajectoires-xlsx.service';
-import type { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import type { AuthenticatedUser } from '../../auth/models/auth.models';
import { verificationTrajectoireResponseSchema } from '../models/verification-trajectoire.response';
import { modeleTrajectoireTelechargementRequestSchema } from '../models/modele-trajectoire-telechargement.request';
import { verificationTrajectoireRequestSchema } from '../models/verification-trajectoire.request';
@@ -61,7 +61,7 @@ export class TrajectoiresController {
@ApiResponse({ type: CalculTrajectoireResponseClass })
async calculeTrajectoireSnbc(
@Query() request: CalculTrajectoireRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
this.logger.log(
`Calcul de la trajectoire SNBC pour la collectivité ${request.collectiviteId}`
@@ -78,7 +78,7 @@ export class TrajectoiresController {
@Delete('')
async deleteTrajectoireSnbc(
@Query() request: CollectiviteRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
this.logger.log(
`Suppression de la trajectoire SNBC pour la collectivité ${request.collectiviteId}`
@@ -116,7 +116,7 @@ export class TrajectoiresController {
})
downloadDataSnbc(
@Query() request: CollectiviteRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload,
+ @TokenInfo() tokenInfo: AuthenticatedUser,
@Res() res: Response,
@Next() next: NextFunction
) {
@@ -139,7 +139,7 @@ export class TrajectoiresController {
})
async verificationDonneesSnbc(
@Query() request: VerificationTrajectoireRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
this.logger.log(
`Vérifie la possibilité de lancer le calcul de la trajectoire SNBC pour la collectivité ${request.collectiviteId}`
diff --git a/backend/src/indicateurs/routers/trajectoires.router.ts b/backend/src/indicateurs/routers/trajectoires.router.ts
index e750de45f6..12f16c2863 100644
--- a/backend/src/indicateurs/routers/trajectoires.router.ts
+++ b/backend/src/indicateurs/routers/trajectoires.router.ts
@@ -1,10 +1,6 @@
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
-import {
- SupabaseJwtPayload,
- SupabaseRole,
-} from '../../auth/models/supabase-jwt.models';
-import { TrpcService } from '../../trpc/services/trpc.service';
+import { TrpcService } from '../../trpc/trpc.service';
import TrajectoiresSpreadsheetService from '../services/trajectoires-spreadsheet.service';
@Injectable()
@@ -15,23 +11,17 @@ export class TrajectoiresRouter {
) {}
router = this.trpc.router({
- snbc: this.trpc.procedure
+ snbc: this.trpc.authedProcedure
.input(
z.object({
collectiviteId: z.number(),
conserve_fichier_temporaire: z.boolean().optional(),
})
)
- .query(({ input }) => {
- // TODO: token
- const tokenInfo: SupabaseJwtPayload = {
- session_id: '',
- role: SupabaseRole.AUTHENTICATED,
- is_anonymous: false,
- };
+ .query(({ input, ctx }) => {
return this.trajectoiresSpreadsheetService.calculeTrajectoireSnbc(
input,
- tokenInfo
+ ctx.user
);
}),
});
diff --git a/backend/src/indicateurs/services/indicateurs.service.ts b/backend/src/indicateurs/services/indicateurs.service.ts
index ef57ec0d72..aebd72970d 100644
--- a/backend/src/indicateurs/services/indicateurs.service.ts
+++ b/backend/src/indicateurs/services/indicateurs.service.ts
@@ -14,22 +14,13 @@ import {
} from 'drizzle-orm';
import { groupBy, partition } from 'es-toolkit';
import * as _ from 'lodash';
+import { AuthenticatedUser, AuthRole } from '../../auth/models/auth.models';
import { NiveauAcces } from '../../auth/models/private-utilisateur-droit.table';
import { AuthService } from '../../auth/services/auth.service';
import DatabaseService from '../../common/services/database.service';
+import { DeleteIndicateursValeursRequestType } from '../models/delete-indicateurs.request';
+import { GetIndicateursValeursRequestType } from '../models/get-indicateurs.request';
import { GetIndicateursValeursResponseType } from '../models/get-indicateurs.response';
-import { groupementTable } from '../../collectivites/models/groupement.table';
-import { groupementCollectiviteTable } from '../../collectivites/models/groupement-collectivite.table';
-import {
- CreateIndicateurValeurType,
- IndicateurAvecValeursParSource,
- IndicateurAvecValeursType,
- IndicateurValeurAvecMetadonnesDefinition,
- IndicateurValeurGroupee,
- IndicateurValeursGroupeeParSource,
- indicateurValeurTable,
- IndicateurValeurType,
-} from '../models/indicateur-valeur.table';
import {
indicateurDefinitionTable,
IndicateurDefinitionType,
@@ -40,11 +31,15 @@ import {
IndicateurSourceMetadonneeType,
} from '../models/indicateur-source-metadonnee.table';
import {
- SupabaseJwtPayload,
- SupabaseRole,
-} from '../../auth/models/supabase-jwt.models';
-import { DeleteIndicateursValeursRequestType } from '../models/delete-indicateurs.request';
-import { GetIndicateursValeursRequestType } from '../models/get-indicateurs.request';
+ CreateIndicateurValeurType,
+ IndicateurAvecValeursParSource,
+ IndicateurAvecValeursType,
+ IndicateurValeurAvecMetadonnesDefinition,
+ IndicateurValeurGroupee,
+ IndicateurValeursGroupeeParSource,
+ indicateurValeurTable,
+ IndicateurValeurType,
+} from '../models/indicateur-valeur.table';
@Injectable()
export default class IndicateursService {
@@ -195,7 +190,7 @@ export default class IndicateursService {
async getIndicateurValeursGroupees(
options: GetIndicateursValeursRequestType,
- tokenInfo: SupabaseJwtPayload
+ tokenInfo: AuthenticatedUser
): Promise {
await this.authService.verifieAccesAuxCollectivites(
tokenInfo,
@@ -295,7 +290,7 @@ export default class IndicateursService {
async upsertIndicateurValeurs(
indicateurValeurs: CreateIndicateurValeurType[],
- tokenInfo?: SupabaseJwtPayload
+ tokenInfo: AuthenticatedUser
): Promise {
if (tokenInfo) {
const collectiviteIds = [
@@ -307,16 +302,16 @@ export default class IndicateursService {
NiveauAcces.EDITION
);
- if (tokenInfo.role === SupabaseRole.AUTHENTICATED && tokenInfo.sub) {
+ if (tokenInfo.role === AuthRole.AUTHENTICATED && tokenInfo.id) {
indicateurValeurs.forEach((v) => {
- v.createdBy = tokenInfo.sub;
- v.modifiedBy = tokenInfo.sub;
+ v.createdBy = tokenInfo.id;
+ v.modifiedBy = tokenInfo.id;
});
}
}
this.logger.log(
- `Upsert des ${indicateurValeurs.length} valeurs des indicateurs pour l'utilisateur ${tokenInfo?.sub} (role ${tokenInfo?.role})`
+ `Upsert des ${indicateurValeurs.length} valeurs des indicateurs pour l'utilisateur ${tokenInfo.id} (role ${tokenInfo.role})`
);
// On doit distinguer les valeurs avec et sans métadonnées car la clause d'unicité est différente (onConflictDoUpdate)
const [indicateurValeursAvecMetadonnees, indicateurValeursSansMetadonnees] =
diff --git a/backend/src/indicateurs/services/trajectoires-data.service.ts b/backend/src/indicateurs/services/trajectoires-data.service.ts
index 1bdfb37916..59b719285f 100644
--- a/backend/src/indicateurs/services/trajectoires-data.service.ts
+++ b/backend/src/indicateurs/services/trajectoires-data.service.ts
@@ -21,7 +21,7 @@ import {
IndicateurValeurAvecMetadonnesDefinition,
IndicateurValeurType,
} from '../models/indicateur-valeur.table';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import {
VerificationTrajectoireResultType,
VerificationTrajectoireStatus,
@@ -618,7 +618,7 @@ export default class TrajectoiresDataService {
*/
async verificationDonneesSnbc(
request: VerificationTrajectoireRequestType,
- tokenInfo: SupabaseJwtPayload,
+ tokenInfo: AuthenticatedUser,
epci?: EpciType,
forceRecuperationDonnees = false
): Promise {
@@ -721,7 +721,7 @@ export default class TrajectoiresDataService {
async deleteTrajectoireSnbc(
collectiviteId: number,
snbcMetadonneesId?: number,
- tokenInfo?: SupabaseJwtPayload
+ tokenInfo?: AuthenticatedUser
): Promise {
if (!snbcMetadonneesId) {
const indicateurSourceMetadonnee =
diff --git a/backend/src/indicateurs/services/trajectoires-spreadsheet.service.ts b/backend/src/indicateurs/services/trajectoires-spreadsheet.service.ts
index fbf748bc96..0772d6117c 100644
--- a/backend/src/indicateurs/services/trajectoires-spreadsheet.service.ts
+++ b/backend/src/indicateurs/services/trajectoires-spreadsheet.service.ts
@@ -7,10 +7,9 @@ import {
import { isNil, partition } from 'es-toolkit';
import * as _ from 'lodash';
import slugify from 'slugify';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import { EpciType } from '../../collectivites/models/epci.table';
import GroupementsService from '../../collectivites/services/groupements.service';
-import { BackendConfigurationType } from '../../config/configuration.model';
import ConfigurationService from '../../config/configuration.service';
import SheetService from '../../spreadsheets/services/sheet.service';
import {
@@ -18,14 +17,14 @@ import {
CalculTrajectoireReset,
CalculTrajectoireResultatMode,
} from '../models/calcul-trajectoire.request';
-import { CreateIndicateurValeurType } from '../models/indicateur-valeur.table';
+import { CalculTrajectoireResultType } from '../models/calcul-trajectoire.response';
+import { DonneesCalculTrajectoireARemplirType } from '../models/donnees-calcul-trajectoire-a-remplir.dto';
import { IndicateurDefinitionType } from '../models/indicateur-definition.table';
+import { CreateIndicateurValeurType } from '../models/indicateur-valeur.table';
+import { VerificationTrajectoireStatus } from '../models/verification-trajectoire.response';
import IndicateursService from './indicateurs.service';
import IndicateurSourcesService from './indicateurSources.service';
import TrajectoiresDataService from './trajectoires-data.service';
-import { VerificationTrajectoireStatus } from '../models/verification-trajectoire.response';
-import { DonneesCalculTrajectoireARemplirType } from '../models/donnees-calcul-trajectoire-a-remplir.dto';
-import { CalculTrajectoireResultType } from '../models/calcul-trajectoire.response';
@Injectable()
export default class TrajectoiresSpreadsheetService {
@@ -58,7 +57,7 @@ export default class TrajectoiresSpreadsheetService {
async calculeTrajectoireSnbc(
request: CalculTrajectoireRequestType,
- tokenInfo: SupabaseJwtPayload,
+ tokenInfo: AuthenticatedUser,
epci?: EpciType
): Promise {
let mode: CalculTrajectoireResultatMode =
@@ -324,7 +323,8 @@ export default class TrajectoiresSpreadsheetService {
);
const upsertedTrajectoireIndicateurValeurs =
await this.indicateursService.upsertIndicateurValeurs(
- indicateurValeursTrajectoireResultat
+ indicateurValeursTrajectoireResultat,
+ tokenInfo
);
// Maintenant que les indicateurs ont été créés, on peut ajouter la collectivité au groupement
diff --git a/backend/src/indicateurs/services/trajectoires-xlsx.service.ts b/backend/src/indicateurs/services/trajectoires-xlsx.service.ts
index 3a690ce4b2..ab5933debc 100644
--- a/backend/src/indicateurs/services/trajectoires-xlsx.service.ts
+++ b/backend/src/indicateurs/services/trajectoires-xlsx.service.ts
@@ -5,17 +5,16 @@ import {
UnprocessableEntityException,
} from '@nestjs/common';
import { NextFunction, Response } from 'express';
-import { JSZipGeneratorOptions } from 'jszip';
import { default as XlsxTemplate } from 'xlsx-template';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
-import { EpciType } from '../../collectivites/models/epci.table';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import { CollectiviteRequestType } from '../../collectivites/models/collectivite.request';
+import { EpciType } from '../../collectivites/models/epci.table';
import BackendConfigurationService from '../../config/configuration.service';
import SheetService from '../../spreadsheets/services/sheet.service';
-import TrajectoiresDataService from './trajectoires-data.service';
-import { VerificationTrajectoireStatus } from '../models/verification-trajectoire.response';
-import { ModeleTrajectoireTelechargementRequestType } from '../models/modele-trajectoire-telechargement.request';
import { DonneesCalculTrajectoireARemplirType } from '../models/donnees-calcul-trajectoire-a-remplir.dto';
+import { ModeleTrajectoireTelechargementRequestType } from '../models/modele-trajectoire-telechargement.request';
+import { VerificationTrajectoireStatus } from '../models/verification-trajectoire.response';
+import TrajectoiresDataService from './trajectoires-data.service';
@Injectable()
export default class TrajectoiresXlsxService {
@@ -202,7 +201,7 @@ export default class TrajectoiresXlsxService {
async downloadTrajectoireSnbc(
request: CollectiviteRequestType,
- tokenInfo: SupabaseJwtPayload,
+ tokenInfo: AuthenticatedUser,
res: Response,
next: NextFunction
) {
@@ -223,7 +222,7 @@ export default class TrajectoiresXlsxService {
if (
resultatVerification.status ===
- VerificationTrajectoireStatus.COMMUNE_NON_SUPPORTEE ||
+ VerificationTrajectoireStatus.COMMUNE_NON_SUPPORTEE ||
!resultatVerification.epci
) {
throw new UnprocessableEntityException(
diff --git a/backend/src/main.ts b/backend/src/main.ts
index 14b36489ec..4c9f1b4d8b 100644
--- a/backend/src/main.ts
+++ b/backend/src/main.ts
@@ -12,7 +12,7 @@ import { AppModule } from './app.module';
import { initApplicationCredentials } from './common/services/gcloud.helper';
import './common/services/sentry.service';
import { SENTRY_DSN } from './common/services/sentry.service';
-import { TrpcRouter } from './trpc.router';
+import { TrpcRouter } from './trpc/trpc.router';
const logger = new Logger('main');
const port = process.env.PORT || 8080;
diff --git a/backend/src/personnalisations/controllers/personnalisations.controller.ts b/backend/src/personnalisations/controllers/personnalisations.controller.ts
index 86e499a2d6..e57bc20a49 100644
--- a/backend/src/personnalisations/controllers/personnalisations.controller.ts
+++ b/backend/src/personnalisations/controllers/personnalisations.controller.ts
@@ -11,7 +11,7 @@ import { getPersonnalisationReglesResponseSchema } from '../models/get-personnal
import { getPersonnalisationReponsesRequestSchema } from '../models/get-personnalisation-reponses.request';
import { getPersonnalitionReponsesResponseSchema } from '../models/get-personnalisation-reponses.response';
import PersonnalisationService from '../services/personnalisations-service';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
/**
* Création des classes de requête/réponse à partir du schema pour générer automatiquement la documentation OpenAPI et la validation des entrées
@@ -52,7 +52,7 @@ export class PersonnalisationsController {
@ApiResponse({ type: GetPersonnalisationReglesResponseClass })
async getPersonnalisationRegles(
@Query() request: GetPersonnalisationReglesRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return this.personnalisationsService.getPersonnalisationRegles(
request.referentiel
@@ -65,7 +65,7 @@ export class PersonnalisationsController {
async getPersonnalisationReponses(
@Param('collectivite_id') collectiviteId: number,
@Query() request: GetPersonnalitionReponsesQueryClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return this.personnalisationsService.getPersonnalisationReponses(
collectiviteId,
@@ -80,7 +80,7 @@ export class PersonnalisationsController {
async getPersonnalisationConsequences(
@Param('collectivite_id') collectiviteId: number,
@Query() request: GetPersonnalitionConsequencesQueryClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return this.personnalisationsService.getPersonnalisationConsequencesForCollectivite(
collectiviteId,
diff --git a/backend/src/personnalisations/services/personnalisations-service.ts b/backend/src/personnalisations/services/personnalisations-service.ts
index 7d493105c6..8810301144 100644
--- a/backend/src/personnalisations/services/personnalisations-service.ts
+++ b/backend/src/personnalisations/services/personnalisations-service.ts
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { and, asc, desc, eq, like, lte, SQL, SQLWrapper } from 'drizzle-orm';
import { NiveauAcces } from '../../auth/models/private-utilisateur-droit.table';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import { AuthService } from '../../auth/services/auth.service';
import {
CollectiviteAvecType,
@@ -88,7 +88,7 @@ export default class PersonnalisationsService {
async getPersonnalisationReponses(
collectiviteId: number,
reponsesDate?: string,
- tokenInfo?: SupabaseJwtPayload
+ tokenInfo?: AuthenticatedUser
): Promise {
const reponses: GetPersonnalisationReponsesResponseType = {};
@@ -158,7 +158,7 @@ export default class PersonnalisationsService {
async getPersonnalisationConsequencesForCollectivite(
collectiviteId: number,
request: GetPersonnalisationConsequencesRequestType,
- tokenInfo?: SupabaseJwtPayload,
+ tokenInfo?: AuthenticatedUser,
collectiviteInfo?: CollectiviteAvecType
): Promise {
// Seulement les personnes ayant l'accès en lecture à la collectivité peuvent voir les réponses historiques
diff --git a/backend/src/referentiels/controllers/referentiels-scoring.controller.ts b/backend/src/referentiels/controllers/referentiels-scoring.controller.ts
index d96076457e..0098a0622f 100644
--- a/backend/src/referentiels/controllers/referentiels-scoring.controller.ts
+++ b/backend/src/referentiels/controllers/referentiels-scoring.controller.ts
@@ -4,7 +4,7 @@ import { ApiExcludeEndpoint, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { AllowAnonymousAccess } from '../../auth/decorators/allow-anonymous-access.decorator';
import { TokenInfo } from '../../auth/decorators/token-info.decorators';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import { checkMultipleReferentielScoresRequestSchema } from '../models/check-multiple-referentiel-scores.request';
import { CheckReferentielScoresRequestType } from '../models/check-referentiel-scores.request';
import { getActionStatutsRequestSchema } from '../models/get-action-statuts.request';
@@ -63,7 +63,7 @@ export class ReferentielsScoringController {
@Param('collectivite_id') collectiviteId: number,
@Param('referentiel_id') referentielId: ReferentielType,
@Query() parameters: GetActionStatutsRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return this.referentielsScoringService.getReferentielActionStatuts(
referentielId,
@@ -79,7 +79,7 @@ export class ReferentielsScoringController {
async getReferentielMultipleScorings(
@Param('referentiel_id') referentielId: ReferentielType,
@Query() parameters: GetReferentielMultipleScoresRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return await this.referentielsScoringService.computeScoreForMultipleCollectivite(
referentielId,
@@ -95,7 +95,7 @@ export class ReferentielsScoringController {
@Param('collectivite_id') collectiviteId: number,
@Param('referentiel_id') referentielId: ReferentielType,
@Query() parameters: GetReferentielScoresRequestClass,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return await this.referentielsScoringService.computeScoreForCollectivite(
referentielId,
diff --git a/backend/src/referentiels/controllers/referentiels.controller.ts b/backend/src/referentiels/controllers/referentiels.controller.ts
index 33ec6d252f..e21b60166b 100644
--- a/backend/src/referentiels/controllers/referentiels.controller.ts
+++ b/backend/src/referentiels/controllers/referentiels.controller.ts
@@ -4,7 +4,7 @@ import { ApiResponse, ApiTags } from '@nestjs/swagger';
import { AllowAnonymousAccess } from '../../auth/decorators/allow-anonymous-access.decorator';
import { AllowPublicAccess } from '../../auth/decorators/allow-public-access.decorator';
import { TokenInfo } from '../../auth/decorators/token-info.decorators';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import { getReferentielResponseSchema } from '../models/get-referentiel.response';
import { ReferentielType } from '../models/referentiel.enum';
import ReferentielsService from '../services/referentiels.service';
@@ -25,7 +25,7 @@ export class ReferentielsController {
@ApiResponse({ type: GetReferentielResponseClass })
async getReferentiel(
@Param('referentiel_id') referentielId: ReferentielType,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return this.referentielsService.getReferentiel(referentielId, true);
}
@@ -35,7 +35,7 @@ export class ReferentielsController {
@ApiResponse({ type: GetReferentielResponseClass })
async importReferentiel(
@Param('referentiel_id') referentielId: ReferentielType,
- @TokenInfo() tokenInfo: SupabaseJwtPayload
+ @TokenInfo() tokenInfo: AuthenticatedUser
): Promise {
return this.referentielsService.importReferentiel(referentielId);
}
diff --git a/backend/src/referentiels/services/referentiels-scoring.service.ts b/backend/src/referentiels/services/referentiels-scoring.service.ts
index 37f2561396..c3003f45ba 100644
--- a/backend/src/referentiels/services/referentiels-scoring.service.ts
+++ b/backend/src/referentiels/services/referentiels-scoring.service.ts
@@ -20,7 +20,7 @@ import { chunk, isNil } from 'es-toolkit';
import * as _ from 'lodash';
import { DateTime } from 'luxon';
import { NiveauAcces } from '../../auth/models/private-utilisateur-droit.table';
-import { SupabaseJwtPayload } from '../../auth/models/supabase-jwt.models';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
import { AuthService } from '../../auth/services/auth.service';
import { CollectiviteAvecType } from '../../collectivites/models/identite-collectivite.dto';
import CollectivitesService from '../../collectivites/services/collectivites.service';
@@ -98,7 +98,7 @@ export default class ReferentielsScoringService {
collectiviteId: number,
referentielId: ReferentielType,
date?: string,
- tokenInfo?: SupabaseJwtPayload,
+ tokenInfo?: AuthenticatedUser,
niveauAccesMinimum = NiveauAcces.LECTURE
): Promise {
// Check read access if a date is given (historical data)
@@ -472,7 +472,7 @@ export default class ReferentielsScoringService {
referentielActionAvecScore.score.pasConcerneTachesAvancement =
tachesCount;
referentielActionAvecScore.score.renseigne = true;
- /* todo:
+ /* todo:
point_potentiel_perso=tache_points_personnalise
if is_personnalise and not is_desactive
else None,
@@ -776,7 +776,7 @@ export default class ReferentielsScoringService {
referentielId: ReferentielType,
collectiviteId: number,
date?: string,
- tokenInfo?: SupabaseJwtPayload,
+ tokenInfo?: AuthenticatedUser,
noCheck?: boolean
): Promise {
const getActionStatuts: GetActionStatutsResponseType = {};
@@ -836,7 +836,7 @@ export default class ReferentielsScoringService {
referentielId: ReferentielType,
collectiviteId: number,
date?: string,
- tokenInfo?: SupabaseJwtPayload,
+ tokenInfo?: AuthenticatedUser,
noCheck?: boolean
): Promise {
const getActionStatutsExplications: GetActionStatutExplicationsResponseType =
@@ -944,7 +944,7 @@ export default class ReferentielsScoringService {
async computeScoreForMultipleCollectivite(
referentielId: ReferentielType,
parameters: GetReferentielMultipleScoresRequestType,
- tokenInfo?: SupabaseJwtPayload
+ tokenInfo?: AuthenticatedUser
): Promise {
this.logger.log(
`Calcul des scores pour les collectivités ${parameters.collectiviteIds.join(
@@ -995,7 +995,7 @@ export default class ReferentielsScoringService {
referentielId: ReferentielType,
collectiviteId: number,
parameters: GetReferentielScoresRequestType,
- tokenInfo?: SupabaseJwtPayload,
+ tokenInfo?: AuthenticatedUser,
referentiel?: GetReferentielResponseType,
noCheck?: boolean
): Promise {
diff --git a/backend/src/trpc.router.ts b/backend/src/trpc.router.ts
deleted file mode 100644
index 37fa74f859..0000000000
--- a/backend/src/trpc.router.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { INestApplication, Injectable, Logger } from '@nestjs/common';
-import * as trpcExpress from '@trpc/server/adapters/express';
-import { TrajectoiresRouter } from './indicateurs/routers/trajectoires.router';
-import { TrpcService } from './trpc/services/trpc.service';
-
-@Injectable()
-export class TrpcRouter {
- private readonly logger = new Logger(TrpcRouter.name);
-
- constructor(
- private readonly trpc: TrpcService,
- private readonly trajectoiresRouter: TrajectoiresRouter
- ) {}
-
- appRouter = this.trpc.router({
- trajectoires: this.trajectoiresRouter.router,
- });
-
- async applyMiddleware(app: INestApplication) {
- this.logger.log(`Applying trpc middleware`);
- app.use(
- `/trpc`,
- trpcExpress.createExpressMiddleware({
- router: this.appRouter,
- })
- );
- }
-}
-
-export type AppRouter = TrpcRouter[`appRouter`];
diff --git a/backend/src/trpc/services/trpc.service.ts b/backend/src/trpc/services/trpc.service.ts
deleted file mode 100644
index 8f53a2136c..0000000000
--- a/backend/src/trpc/services/trpc.service.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Injectable } from '@nestjs/common';
-import { initTRPC } from '@trpc/server';
-
-@Injectable()
-export class TrpcService {
- trpc = initTRPC.create();
- procedure = this.trpc.procedure;
- router = this.trpc.router;
- mergeRouters = this.trpc.mergeRouters;
-}
diff --git a/backend/src/trpc/trpc.module.ts b/backend/src/trpc/trpc.module.ts
index 052cc4f033..800dd67ea7 100644
--- a/backend/src/trpc/trpc.module.ts
+++ b/backend/src/trpc/trpc.module.ts
@@ -1,6 +1,7 @@
-import { Module } from '@nestjs/common';
-import { TrpcService } from './services/trpc.service';
+import { Global, Module } from '@nestjs/common';
+import { TrpcService } from './trpc.service';
+@Global()
@Module({
imports: [],
controllers: [],
diff --git a/backend/src/trpc/trpc.router.ts b/backend/src/trpc/trpc.router.ts
new file mode 100644
index 0000000000..61b4d2bf45
--- /dev/null
+++ b/backend/src/trpc/trpc.router.ts
@@ -0,0 +1,44 @@
+import { INestApplication, Injectable, Logger } from '@nestjs/common';
+import { createExpressMiddleware } from '@trpc/server/adapters/express';
+import SupabaseService from '../common/services/supabase.service';
+import { TrajectoiresRouter } from '../indicateurs/routers/trajectoires.router';
+import { createContext, TrpcService } from './trpc.service';
+import { CountByStatutRouter } from '../fiches/count-by-statut/count-by-statut.router';
+
+@Injectable()
+export class TrpcRouter {
+ private readonly logger = new Logger(TrpcRouter.name);
+
+ constructor(
+ private readonly trpc: TrpcService,
+ private readonly supabase: SupabaseService,
+ private readonly trajectoiresRouter: TrajectoiresRouter,
+ private readonly countByStatutRouter: CountByStatutRouter
+ ) {}
+
+ appRouter = this.trpc.router({
+ indicateurs: {
+ trajectoires: this.trajectoiresRouter.router,
+ },
+ plans: {
+ fiches: {
+ ...this.countByStatutRouter.router,
+ },
+ },
+ });
+
+ createCaller = this.trpc.createCallerFactory(this.appRouter);
+
+ async applyMiddleware(app: INestApplication) {
+ this.logger.log(`Applying trpc middleware`);
+ app.use(
+ `/trpc`,
+ createExpressMiddleware({
+ router: this.appRouter,
+ createContext: (opts) => createContext(this.supabase.client, opts),
+ })
+ );
+ }
+}
+
+export type AppRouter = TrpcRouter[`appRouter`];
diff --git a/backend/src/trpc/trpc.service.ts b/backend/src/trpc/trpc.service.ts
new file mode 100644
index 0000000000..e6bf12b314
--- /dev/null
+++ b/backend/src/trpc/trpc.service.ts
@@ -0,0 +1,120 @@
+import { Injectable } from '@nestjs/common';
+import { SupabaseClient } from '@supabase/supabase-js';
+import { initTRPC, TRPCError } from '@trpc/server';
+import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
+import {
+ AuthUser,
+ isAnonymousUser,
+ isAuthenticatedUser,
+} from '../auth/models/auth.models';
+
+@Injectable()
+export class TrpcService {
+ trpc = initTRPC.context().create({
+ // transformer: superJson,
+ errorFormatter({ shape }) {
+ return shape;
+ },
+ });
+
+ /**
+ * Create an unprotected public procedure
+ * @see https://trpc.io/docs/v11/procedures
+ **/
+ publicProcedure = this.trpc.procedure;
+
+ /**
+ * Create an anonymous procedure
+ * @see https://trpc.io/docs/v11/procedures
+ **/
+ anonProcedure = this.trpc.procedure.use(
+ this.trpc.middleware(({ next, ctx }) => {
+ const anonUser = ctx.user;
+
+ if (!isAnonymousUser(anonUser)) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Not anonymous',
+ });
+ }
+
+ return next({ ctx: { user: anonUser } });
+ })
+ );
+
+ /**
+ * Create an authenticated procedure
+ * @see https://trpc.io/docs/v11/procedures
+ **/
+ authedProcedure = this.trpc.procedure.use(
+ this.trpc.middleware(({ next, ctx }) => {
+ const authUser = ctx.user;
+
+ if (!isAuthenticatedUser(authUser)) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Not authenticated',
+ });
+ }
+
+ return next({
+ ctx: {
+ user: authUser,
+ },
+ });
+ })
+ );
+
+ /**
+ * Create a router
+ * @see https://trpc.io/docs/v11/router
+ */
+ router = this.trpc.router;
+
+ /**
+ * Merge multiple routers together
+ * @see https://trpc.io/docs/v11/merging-routers
+ */
+ mergeRouters = this.trpc.mergeRouters;
+
+ /**
+ * Create a server-side caller
+ * @see https://trpc.io/docs/v11/server/server-side-calls
+ */
+ createCallerFactory = this.trpc.createCallerFactory;
+}
+
+/**
+ * Creates context for an incoming request
+ * @see https://trpc.io/docs/v11/context
+ */
+export async function createContext(
+ supabase: SupabaseClient,
+ { req }: CreateExpressContextOptions
+) {
+ // Extract Supabase session from cookies or headers
+ const supabaseToken = req.headers.authorization?.split('Bearer ')[1];
+
+ try {
+ // Verify the token from supabase
+ const {
+ data: { user },
+ } = await supabase.auth.getUser(supabaseToken);
+
+ if (!user) {
+ return { user: null };
+ }
+
+ return {
+ user: {
+ id: user.id ?? null,
+ role: user.role,
+ isAnonymous: user.is_anonymous,
+ } as AuthUser,
+ };
+ } catch (error) {
+ return { user: null };
+ }
+}
+
+export type Context = Awaited>;
diff --git a/backend/test/auth/auth-utils.ts b/backend/test/auth/auth-utils.ts
index 9ed0aa8dba..9bce5048e6 100644
--- a/backend/test/auth/auth-utils.ts
+++ b/backend/test/auth/auth-utils.ts
@@ -1,17 +1,48 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { YOLO_DODO_CREDENTIALS } from './test-users.samples';
+import {
+ AuthUser,
+ isAuthenticatedUser,
+} from '../../src/auth/models/auth.models';
+
let supabase: SupabaseClient;
-export const getYoloDodoToken = async (): Promise => {
+export async function getYoloDodoAuthResponse() {
if (!supabase) {
supabase = createClient(
- process.env.SUPABASE_URL!,
- process.env.SUPABASE_ANON_KEY!
+ process.env.SUPABASE_URL as string,
+ process.env.SUPABASE_ANON_KEY as string
);
}
- const signinResponse = await supabase.auth.signInWithPassword(
- YOLO_DODO_CREDENTIALS
- );
- const yoloDodoToken = signinResponse.data.session?.access_token || '';
+
+ return await supabase.auth.signInWithPassword(YOLO_DODO_CREDENTIALS);
+}
+
+export const getYoloDodoToken = async (): Promise => {
+ const response = await getYoloDodoAuthResponse();
+
+ const yoloDodoToken = response.data.session?.access_token || '';
return yoloDodoToken;
};
+
+export async function getYoloDodoUser() {
+ const {
+ data: { user },
+ } = await getYoloDodoAuthResponse();
+
+ if (!user) {
+ expect.fail('Could not authenticated user yolododo');
+ }
+
+ const authUser = {
+ id: user.id,
+ role: user.role,
+ isAnonymous: user.is_anonymous,
+ } as AuthUser;
+
+ if (!isAuthenticatedUser(authUser)) {
+ expect.fail('Could not authenticated user yolododo');
+ }
+
+ return authUser;
+}
diff --git a/backend/test/common/app-utils.ts b/backend/test/common/app-utils.ts
index 3601a32aeb..e296ae2679 100644
--- a/backend/test/common/app-utils.ts
+++ b/backend/test/common/app-utils.ts
@@ -2,6 +2,7 @@ import { ZodValidationPipe } from '@anatine/zod-nestjs/src/lib/zod-validation-pi
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../src/app.module';
+import { TrpcRouter } from '../../src/trpc/trpc.router';
export const getTestApp = async (): Promise => {
const moduleRef = await Test.createTestingModule({
@@ -10,6 +11,12 @@ export const getTestApp = async (): Promise => {
const app = moduleRef.createNestApplication();
app.useGlobalPipes(new ZodValidationPipe());
+
await app.init();
+
return app;
};
+
+export async function getTestRouter() {
+ return (await getTestApp()).get(TrpcRouter);
+}
diff --git a/backend/test/indicateurs/indicateurs.e2e-spec.ts b/backend/test/indicateurs/indicateurs.e2e-spec.ts
index df24839d35..52d07886b8 100644
--- a/backend/test/indicateurs/indicateurs.e2e-spec.ts
+++ b/backend/test/indicateurs/indicateurs.e2e-spec.ts
@@ -1,32 +1,16 @@
import { INestApplication } from '@nestjs/common';
-import { Test } from '@nestjs/testing';
-import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { default as request } from 'supertest';
-import { AppModule } from '../../src/app.module';
import { UpsertIndicateursValeursRequest } from '../../src/indicateurs/models/upsert-indicateurs-valeurs.request';
-import { YOLO_DODO_CREDENTIALS } from '../auth/test-users.samples';
+import { getYoloDodoToken } from '../auth/auth-utils';
+import { getTestApp } from '../common/app-utils';
describe('Route de lecture / ecriture des indicateurs', () => {
let app: INestApplication;
- let supabase: SupabaseClient;
let yoloDodoToken: string;
beforeAll(async () => {
- const moduleRef = await Test.createTestingModule({
- imports: [AppModule],
- }).compile();
-
- app = moduleRef.createNestApplication();
- await app.init();
-
- supabase = createClient(
- process.env.SUPABASE_URL!,
- process.env.SUPABASE_ANON_KEY!
- );
- const signinResponse = await supabase.auth.signInWithPassword(
- YOLO_DODO_CREDENTIALS
- );
- yoloDodoToken = signinResponse.data.session?.access_token || '';
+ app = await getTestApp();
+ yoloDodoToken = await getYoloDodoToken();
});
afterAll(async () => {
diff --git a/backend/test/indicateurs/trajectoire-snbc.e2e-spec.ts b/backend/test/indicateurs/trajectoire-snbc.e2e-spec.ts
index 012b038e06..cb29727a4e 100644
--- a/backend/test/indicateurs/trajectoire-snbc.e2e-spec.ts
+++ b/backend/test/indicateurs/trajectoire-snbc.e2e-spec.ts
@@ -1,17 +1,15 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
-import { default as _ } from 'lodash';
import { default as request } from 'supertest';
import { AppModule } from '../../src/app.module';
import { CalculTrajectoireResultatMode } from '../../src/indicateurs/models/calcul-trajectoire.request';
-import { YOLO_DODO_CREDENTIALS } from '../auth/test-users.samples';
-import { trajectoireSnbcCalculRetour } from './test-data/trajectoire-snbc-calcul-retour';
+import { CalculTrajectoireResponseType } from '../../src/indicateurs/models/calcul-trajectoire.response';
import {
VerificationTrajectoireResponseType,
VerificationTrajectoireStatus,
} from '../../src/indicateurs/models/verification-trajectoire.response';
-import { CalculTrajectoireResponseType } from '../../src/indicateurs/models/calcul-trajectoire.response';
+import { YOLO_DODO_CREDENTIALS } from '../auth/test-users.samples';
describe('Calcul de trajectoire SNBC', () => {
let app: INestApplication;
diff --git a/backend/test/referentiels/referentiels-scoring.e2e-spec.ts b/backend/test/referentiels/referentiels-scoring.e2e-spec.ts
index 3fa46e8c8a..5f0b9bdecf 100644
--- a/backend/test/referentiels/referentiels-scoring.e2e-spec.ts
+++ b/backend/test/referentiels/referentiels-scoring.e2e-spec.ts
@@ -2,9 +2,7 @@ import { INestApplication } from '@nestjs/common';
import { default as request } from 'supertest';
import { getYoloDodoToken } from '../auth/auth-utils';
import { getTestApp } from '../common/app-utils';
-import {
- GetActionStatutsResponseType,
-} from '../../src/referentiels/models/get-action-statuts.response';
+import { GetActionStatutsResponseType } from '../../src/referentiels/models/get-action-statuts.response';
import { ReferentielActionWithScoreType } from '../../src/referentiels/models/referentiel-action-avec-score.dto';
import { GetReferentielScoresResponseType } from '../../src/referentiels/models/get-referentiel-scores.response';
import { ActionType } from '../../src/referentiels/models/action-type.enum';
@@ -21,7 +19,7 @@ describe('Referentiels scoring routes', () => {
});
it(`Récupération des statuts des actions sans token non autorisée`, async () => {
- const response = await request(app.getHttpServer())
+ await request(app.getHttpServer())
.get('/collectivites/1/referentiels/cae/action-statuts')
.expect(401);
});
@@ -46,7 +44,7 @@ describe('Referentiels scoring routes', () => {
});
it(`Récupération anonyme des statuts des actions pour une collectivite inconnue`, async () => {
- const response = await request(app.getHttpServer())
+ await request(app.getHttpServer())
.get('/collectivites/10000000/referentiels/cae/action-statuts')
.set('Authorization', `Bearer ${process.env.SUPABASE_ANON_KEY}`)
.expect(404)
@@ -59,7 +57,7 @@ describe('Referentiels scoring routes', () => {
});
it(`Récupération anonyme des statuts des actions d'un référentiel inconnu`, async () => {
- const response = await request(app.getHttpServer())
+ await request(app.getHttpServer())
.get('/collectivites/1/referentiels/inconnu/action-statuts')
.set('Authorization', `Bearer ${process.env.SUPABASE_ANON_KEY}`)
.expect(404)
@@ -71,7 +69,7 @@ describe('Referentiels scoring routes', () => {
});
it(`Récupération anonyme des historiques de statuts des actions non autorisé`, async () => {
- const response = await request(app.getHttpServer())
+ await request(app.getHttpServer())
.get(
'/collectivites/1/referentiels/cae/action-statuts?date=2020-01-02T00:00:01Z'
)
@@ -105,9 +103,9 @@ describe('Referentiels scoring routes', () => {
};
expect(actionStatuts['cae_1.1.1.1.1']).toEqual(expectedActionStatut);
});
-
+
it(`Récupération du score d'un référentiel sans token non autorisée`, async () => {
- const response = await request(app.getHttpServer())
+ await request(app.getHttpServer())
.get('/collectivites/1/referentiels/cae/scores')
.expect(401);
});
@@ -154,13 +152,13 @@ describe('Referentiels scoring routes', () => {
},
actionsEnfant: [],
scoresTag: {},
- tags: []
+ tags: [],
};
expect(referentielScoreWithoutActionsEnfant).toEqual(expectedCaeRoot);
});
it(`Récupération anonyme du score d'un référentiel pour une collectivite inconnue`, async () => {
- const response = await request(app.getHttpServer())
+ await request(app.getHttpServer())
.get('/collectivites/10000000/referentiels/cae/scores')
.set('Authorization', `Bearer ${process.env.SUPABASE_ANON_KEY}`)
.expect(404)
@@ -173,7 +171,7 @@ describe('Referentiels scoring routes', () => {
});
it(`Récupération anonyme du score d'un référentiel inconnu`, async () => {
- const response = await request(app.getHttpServer())
+ await request(app.getHttpServer())
.get('/collectivites/1/referentiels/inconnu/scores')
.set('Authorization', `Bearer ${process.env.SUPABASE_ANON_KEY}`)
.expect(404)
@@ -228,7 +226,7 @@ describe('Referentiels scoring routes', () => {
},
actionsEnfant: [],
scoresTag: {},
- tags: []
+ tags: [],
};
expect(referentielScoreWithoutActionsEnfant).toEqual(expectedCaeRoot);
diff --git a/doc/adr/0004-trpc.md b/doc/adr/0004-trpc.md
new file mode 100644
index 0000000000..dd1d1c82c5
--- /dev/null
+++ b/doc/adr/0004-trpc.md
@@ -0,0 +1,163 @@
+# 4. Utiliser tRPC pour la communication entre nos apps client et notre serveur backend
+
+Date: 2024-11-15
+
+## Statut
+
+Accepté
+
+## Contexte
+
+Notre architecture système actuelle se compose de plusieurs frontend Next.js et d'un backend Nest.js. La communication entre ces services est actuellement gérée via des API REST traditionnelles. Bien que fonctionnelle, cette approche présente plusieurs inconvénients :
+
+1. La sécurité des types entre le frontend et le backend n'est pas garantie. Risques d'erreurs au runtime.
+2. Maintenance manuelle d'une documentation OpenAPI/Swagger séparée.
+3. Surcharge de développement dans la maintenance des DTOs et de la logique de validation
+
+## Décision
+
+Utilisation de tRPC comme couche de communication principale entre nos applications frontend Next.js et notre backend Nest.js.
+
+### Détails Techniques
+
+- Utilisation des packages `@trpc/server` et `@trpc/client`
+- Migration progressive des routes backend REST vers des procédures tRPC
+- Maintien d'un package `domain` séparé pour les schemas, types, et validations communs entre frontend et backend
+- Utilisation du support intégré de tRPC pour Next.js App Router (à détailler)
+
+### Stratégie de Migration
+
+1. Configuration initiale de tRPC
+2. Exécution parallèle des endpoints REST et tRPC pendant la migration
+3. Migration progressive des endpoints
+4. Suppression des endpoints REST après validation de la migration
+
+## Conséquences
+
+### Positives
+
+1. **Sécurité des types**
+
+ - Typage de bout en bout entre frontend and backend
+ - Inférence automatique des types pour les appels API
+ - Élimination des erreurs d'exécution liées aux types
+
+2. **Developer Experience**
+
+ - Autocomplétion dans l'IDE
+ - Moins de boilerplate
+ - Documentation automatique via les types
+ - Intégration intuitive avec React Query, déjà maitrisé par l'équipe
+
+3. **Performance**
+
+ - Optimisation des requêtes réseau grâce au batching automatique
+ - Mise en cache
+
+4. **Maintenance**
+
+ - Source unique de vérité pour les types API
+ - Pas de documentation API manuelle
+ - Refactoring simplifié à travers frontend et backend
+
+### Négatives
+
+1. **Courbe d'apprentissage**
+
+ - Montée en compétences nécessaire de l'équipe sur les concepts tRPC et les bonnes pratiques
+ - Complexité initiale de configuration
+ - Nouveaux patterns pour la gestion d'erreurs et middleware
+
+2. **Technical Considerations**
+
+ - Moins facile à tester ou débogguer qu'un simple appel REST
+
+## Alternatives Considérées
+
+1. **GraphQL**
+
+ - Plus complexe en setup et courbe apprentissage
+ - Parait excessif pour notre cas d'utilisation
+
+2. **OpenAPI avec typage fort**
+
+ - Maintenance manuelle des schémas
+ - Implémentation plus verbeuse
+ - Moins performant
+
+3. **gRPC**
+
+ - Plus adapté à une architecture microservices
+ - Configuration et outillage complexes
+ - Support navigateur limité
+
+## Notes d'implémentation
+
+**Côté front** : exemple d'usage avec typage des params via `RouterInput`
+
+```typescript
+import { RouterInput, trpc } from '@tet/api/utils/trpc/client';
+
+type CountByStatutParams =
+ RouterInput['plans']['fiches']['countByStatut']['body'];
+
+export function useFichesActionStatuts = (collectiviteId: number, params: CountByStatutParams) {
+
+ return trpc.plans.fiches.countByStatut.useQuery({
+ collectiviteId,
+ body: params,
+ });
+};
+
+```
+
+**Côté back** :
+
+- `publicProcedure` pour les routes publiques
+
+```typescript
+/* Dans le router */
+const inputSchema = z.object({
+ collectiviteId: z.number(),
+ body: getFichesActionFilterRequestSchema,
+});
+
+countByStatut: this.trpc.publicProcedure.input(inputSchema).query(({ input }) => {
+ const { collectiviteId, body } = input;
+ return this.service.countByStatut(collectiviteId, body);
+});
+```
+
+- `authedProcedure` pour les routes authentifiées et récupération du user via le `ctx` de la query.
+
+```typescript
+/* Dans le router */
+countByStatut: this.trpc.authedProcedure.input(inputSchema).query(({ input, ctx }) => {
+ const { collectiviteId, body } = input;
+ const authenticatedUser = ctx.user;
+ return this.service.countByStatut(collectiviteId, body, authenticatedUser);
+});
+
+/* Dans le service */
+const uuid = user.id;
+const role = user.role;
+```
+
+- `anonProcedure` pour les routes avec un token anonyme et récupération du user (anonyme) via le `ctx` de la query.
+
+```typescript
+/* Dans le router */
+countByStatut: this.trpc.anonProcedure.input(inputSchema).query(({ input, ctx }) => {
+ const { collectiviteId, body } = input;
+ const anonymousUser = ctx.user;
+ return this.service.countByStatut(collectiviteId, body, anonymousUser);
+});
+```
+
+## Améliorations possibles
+
+- Mise en place d'outils facilitant la DX.
+
+## Références
+
+- Documentation tRPC : https://trpc.io/docs
diff --git a/packages/api/src/environmentVariables.ts b/packages/api/src/environmentVariables.ts
index ebd68a9327..8a84629d72 100644
--- a/packages/api/src/environmentVariables.ts
+++ b/packages/api/src/environmentVariables.ts
@@ -1,7 +1,9 @@
export const ENV = {
node_env: process.env.NODE_ENV,
logActionsDuration: process.env.NEXT_PUBLIC_LOG_ACTION_DURATION === 'TRUE',
- supabase_anon_key: process.env.NEXT_PUBLIC_SUPABASE_KEY,
+ supabase_anon_key:
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ??
+ process.env.NEXT_PUBLIC_SUPABASE_KEY,
supabase_url: process.env.NEXT_PUBLIC_SUPABASE_URL,
sentry_dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
posthog: {
diff --git a/packages/api/src/utils/authTokens.ts b/packages/api/src/utils/authTokens.ts
index a4d52b4d9e..5f62d69a21 100644
--- a/packages/api/src/utils/authTokens.ts
+++ b/packages/api/src/utils/authTokens.ts
@@ -1,10 +1,12 @@
-/*
+/*
* Fonctions pour enlever ou ajouter les cookies qui permettent de partager
- * l'authentification entre les sous-domaines.
+ * l'authentification entre les sous-domaines.
* Ref: https://github.com/orgs/supabase/discussions/5742#discussioncomment-4050444
*/
-import {Session, SupabaseClient} from '@supabase/supabase-js';
+import { Session, SupabaseClient } from '@supabase/supabase-js';
+import { supabaseClient } from './supabase-client';
+import { ENV } from '../environmentVariables';
export const ACCESS_TOKEN = 'tet-access-token';
export const REFRESH_TOKEN = 'tet-refresh-token';
@@ -27,9 +29,9 @@ const SUPABASE_TOKEN = /^sb-.*-auth-token/;
const deleteSbCookies = (domain: string) =>
document.cookie
.split(/\s*;\s*/)
- .map(cookie => cookie.split('='))
- .filter(x => x[0].match(SUPABASE_TOKEN))
- .forEach(x => (document.cookie = formatExpiredToken(x[0], domain)));
+ .map((cookie) => cookie.split('='))
+ .filter((x) => x[0].match(SUPABASE_TOKEN))
+ .forEach((x) => (document.cookie = formatExpiredToken(x[0], domain)));
/** Crée les tokens à partir de la session */
const MAX_AGE = 60 * 60 * 24 * 365;
@@ -62,9 +64,9 @@ export const restoreSessionFromAuthTokens = async (
// recherche les cookies
const cookies = document.cookie
.split(/\s*;\s*/)
- .map(cookie => cookie.split('='));
- const accessTokenCookie = cookies.find(x => x[0] === ACCESS_TOKEN);
- const refreshTokenCookie = cookies.find(x => x[0] === REFRESH_TOKEN);
+ .map((cookie) => cookie.split('='));
+ const accessTokenCookie = cookies.find((x) => x[0] === ACCESS_TOKEN);
+ const refreshTokenCookie = cookies.find((x) => x[0] === REFRESH_TOKEN);
if (accessTokenCookie && refreshTokenCookie) {
return supabase.auth.setSession({
@@ -74,3 +76,31 @@ export const restoreSessionFromAuthTokens = async (
}
return null;
};
+
+export async function getSession() {
+ const { data, error } = await supabaseClient.auth.getSession();
+ if (data?.session) {
+ return data.session;
+ }
+ if (error) throw error;
+
+ // restaure une éventuelle session précédente
+ const ret = await restoreSessionFromAuthTokens(supabaseClient);
+ if (ret) {
+ const { data, error } = ret;
+ if (data?.session) {
+ return data.session;
+ }
+ if (error) throw error;
+ }
+}
+
+export async function getAuthHeaders() {
+ const session = await getSession();
+ return session?.access_token
+ ? {
+ authorization: `Bearer ${session.access_token}`,
+ apikey: `${ENV.supabase_anon_key}`,
+ }
+ : null;
+}
diff --git a/packages/api/src/utils/supabase-client.ts b/packages/api/src/utils/supabase-client.ts
new file mode 100644
index 0000000000..15a01b51d9
--- /dev/null
+++ b/packages/api/src/utils/supabase-client.ts
@@ -0,0 +1,13 @@
+import { createClient } from '@supabase/supabase-js';
+import { Database } from '@tet/api';
+import { ENV } from '../environmentVariables';
+
+export const supabaseClient = createClient(
+ ENV.supabase_url as string,
+ ENV.supabase_anon_key as string,
+ {
+ db: {
+ schema: 'public',
+ },
+ }
+);
diff --git a/packages/api/src/utils/trpc/client.tsx b/packages/api/src/utils/trpc/client.tsx
new file mode 100644
index 0000000000..6fd87d5caf
--- /dev/null
+++ b/packages/api/src/utils/trpc/client.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import type { QueryClient } from '@tanstack/react-query';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { httpBatchLink } from '@trpc/client';
+import { createTRPCReact } from '@trpc/react-query';
+import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
+import { useState } from 'react';
+import { makeQueryClient } from './query-client';
+
+// By using `import type` you ensure that the reference will be stripped at compile-time, meaning you don't inadvertently import server-side code into your client.
+// For more information, see the Typescript docs: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
+// eslint-disable-next-line @nx/enforce-module-boundaries
+import type { AppRouter } from '@tet/backend/trpc/trpc.router';
+import { getAuthHeaders } from '../authTokens';
+
+export type RouterInput = inferRouterInputs;
+export type RouterOutput = inferRouterOutputs;
+
+export const trpc = createTRPCReact();
+
+let clientQueryClientSingleton: QueryClient;
+
+function getQueryClient() {
+ if (typeof window === 'undefined') {
+ // Server: always make a new query client
+ return makeQueryClient();
+ }
+ // Browser: use singleton pattern to keep the same query client
+ return (clientQueryClientSingleton ??= makeQueryClient());
+}
+
+function getUrl() {
+ return `${
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:8080'
+ }/trpc`;
+}
+
+export function TRPCProvider(
+ props: Readonly<{
+ children: React.ReactNode;
+ }>
+) {
+ // NOTE:
+ // Avoid useState when initializing the query client if you don't
+ // have a suspense boundary between this and the code that may
+ // suspend because React will throw away the client on the initial
+ // render if it suspends and there is no boundary
+ const queryClient = getQueryClient();
+ const [trpcClient] = useState(() =>
+ trpc.createClient({
+ links: [
+ httpBatchLink({
+ // transformer: superjson, <-- if you use a data transformer
+ url: getUrl(),
+ async headers() {
+ const authHeaders = await getAuthHeaders();
+ return {
+ ...(authHeaders ?? {}),
+ };
+ },
+ }),
+ ],
+ })
+ );
+ return (
+
+
+ {props.children}
+
+
+ );
+}
diff --git a/packages/api/src/utils/trpc/query-client.ts b/packages/api/src/utils/trpc/query-client.ts
new file mode 100644
index 0000000000..c10417022c
--- /dev/null
+++ b/packages/api/src/utils/trpc/query-client.ts
@@ -0,0 +1,23 @@
+import {
+ defaultShouldDehydrateQuery,
+ QueryClient,
+} from '@tanstack/react-query';
+
+export function makeQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30 * 1000,
+ },
+ dehydrate: {
+ // serializeData: superjson.serialize,
+ shouldDehydrateQuery: (query) =>
+ defaultShouldDehydrateQuery(query) ||
+ query.state.status === 'pending',
+ },
+ hydrate: {
+ // deserializeData: superjson.deserialize,
+ },
+ },
+ });
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 2d0d39e669..c5160227d6 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -1,8 +1,8 @@
{
"compileOnSave": false,
"compilerOptions": {
- "rootDir": ".",
- "baseUrl": ".",
+ "baseUrl": "./",
+ "rootDir": "./",
"outDir": "./dist",
"sourceMap": true,
"strict": true,
@@ -24,6 +24,7 @@
"@tet/api/*": ["./packages/api/src/*"],
"@tet/app/*": ["./app.territoiresentransitions.react/src/app/*"],
"@tet/auth/*": ["./packages/auth/*"],
+ "@tet/backend/*": ["./backend/src/*"],
"@tet/panier/*": ["./packages/panier/*"],
"@tet/site/*": ["./packages/site/*"],
"@tet/ui": ["./packages/ui/src/index.ts"],