From ddf5026e50daa0fff82de9efd951f1bca37face6 Mon Sep 17 00:00:00 2001 From: NKoelblen Date: Fri, 8 May 2026 16:38:52 +0200 Subject: [PATCH 1/2] feat: implement testimony feature with GraphQL schema, resolver, and database migration Co-authored-by: Copilot --- migrations/202605081622.ts | 36 +++++++ src/graphql/graphqlContext.ts | 3 + src/graphql/graphqlSchema.ts | 12 +++ src/graphql/resolvers/testimonyResolver.ts | 114 +++++++++++++++++++++ src/graphql/schemas/testimonySchema.ts | 28 +++++ src/repositories/TestimonyRepository.ts | 65 ++++++++++++ src/types/testimonyTypes.ts | 8 ++ 7 files changed, 266 insertions(+) create mode 100644 migrations/202605081622.ts create mode 100644 src/graphql/resolvers/testimonyResolver.ts create mode 100644 src/graphql/schemas/testimonySchema.ts create mode 100644 src/repositories/TestimonyRepository.ts create mode 100644 src/types/testimonyTypes.ts diff --git a/migrations/202605081622.ts b/migrations/202605081622.ts new file mode 100644 index 0000000..97a19ac --- /dev/null +++ b/migrations/202605081622.ts @@ -0,0 +1,36 @@ +import { MigrationParams } from "umzug"; +import { Pool } from "mariadb"; + +/** + * Migration pour créer la table "testimony" dans la base de données. + * La table "testimony" est utilisée pour stocker les témoignages des utilisateurs. + * La migration crée la table avec quatre colonnes : "id" (identifiant unique), "name" (nom de l'utilisateur), "company" (nom de l'entreprise) et "content" (contenu du témoignage). + * La colonne "id" est définie comme clé primaire pour garantir l'unicité des identifiants. + * La fonction "up" est exécutée lors de l'application de la migration, tandis que la fonction "down" est exécutée lors du rollback de la migration. + */ + +export async function up({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query(` + CREATE TABLE IF NOT EXISTS testimony ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + company VARCHAR(255) NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + } finally { + conn.release(); + } +} + +export async function down({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query("DROP TABLE IF EXISTS testimony"); + } finally { + conn.release(); + } +} diff --git a/src/graphql/graphqlContext.ts b/src/graphql/graphqlContext.ts index 28cbd30..b60ad80 100644 --- a/src/graphql/graphqlContext.ts +++ b/src/graphql/graphqlContext.ts @@ -7,6 +7,7 @@ import { SettingsRepository } from "../repositories/SettingsRepository"; import { Pool } from "mariadb/*"; import jwt from "jsonwebtoken"; import { MediaRepository } from "../repositories/MediaRepository"; +import TestimonyRepository from "../repositories/TestimonyRepository"; /** * Fonction pour créer le contexte GraphQL, qui sera passé à tous les résolveurs. @@ -45,6 +46,7 @@ export function getGraphqlContext({ coworkerRepo: CoworkerRepository; projectRepo: ProjectRepository; mediaRepo: MediaRepository; + testimonyRepo: TestimonyRepository; } { return { user, @@ -56,5 +58,6 @@ export function getGraphqlContext({ coworkerRepo: new CoworkerRepository(pool), projectRepo: new ProjectRepository(pool), mediaRepo: new MediaRepository(pool), + testimonyRepo: new TestimonyRepository(pool), }; } diff --git a/src/graphql/graphqlSchema.ts b/src/graphql/graphqlSchema.ts index 8248a3d..73a3c4c 100644 --- a/src/graphql/graphqlSchema.ts +++ b/src/graphql/graphqlSchema.ts @@ -57,6 +57,13 @@ import { import settingsResolver from "./resolvers/settingsResolver"; import { contactMutation, contactTypes } from "./schemas/contactSchema"; import contactResolver from "./resolvers/contactResolver"; +import { + testimonyInputs, + testimonyMutations, + testimonyQueries, + testimonyTypes, +} from "./schemas/testimonySchema"; +import testimonyResolver from "./resolvers/testimonyResolver"; /** * Construit le schéma GraphQL en combinant les types, requêtes et mutations de tous les modules. @@ -80,6 +87,8 @@ export function getSchema() { ${projectInputs} ${settingsTypes} ${contactTypes} + ${testimonyTypes} + ${testimonyInputs} type Query { ${accountQueries} ${categoryQueries} @@ -89,6 +98,7 @@ export function getSchema() { ${projectQueries} ${mediaQueries} ${settingsQueries} + ${testimonyQueries} } type Mutation { ${authMutations} @@ -101,6 +111,7 @@ export function getSchema() { ${mediaMutations} ${settingsMutations} ${contactMutation} + ${testimonyMutations} } `); } @@ -121,5 +132,6 @@ export function getRoot() { ...mediaResolver, ...settingsResolver, ...contactResolver, + ...testimonyResolver, }; } diff --git a/src/graphql/resolvers/testimonyResolver.ts b/src/graphql/resolvers/testimonyResolver.ts new file mode 100644 index 0000000..cf85e19 --- /dev/null +++ b/src/graphql/resolvers/testimonyResolver.ts @@ -0,0 +1,114 @@ +import jwt from "jsonwebtoken"; +import { + isEmpty, + checkAuth, + validateId, + isValidDate, +} from "../../utils/validationUtils"; +import { sanitizeString, sanitizeWysiwyg } from "../../utils/stringUtils"; +import { Testimony } from "../../types/testimonyTypes"; +import TestimonyRepository from "../../repositories/TestimonyRepository"; + +// Résolveur GraphQL pour les opérations liées aux témoignages +const testimonyResolver = { + /** + * Récupère tous les témoignages + * Appelle la méthode getAll du repository des témoignages pour récupérer tous les témoignages de la base de données. + * @param {Object} _args Les arguments de la requête, qui ne sont pas utilisés dans cette opération. + * @param {Object} context Le contexte de la requête, contenant le repository des témoignages. + * @returns {Promise} Un tableau de témoignages récupérés de la base de données. + */ + testimonies: async ( + _args: Record, + context: { testimonyRepo: TestimonyRepository }, + ): Promise => { + return await context.testimonyRepo.getAll(); + }, + + /** + * Crée un nouveau témoignage + * Vérifie que l'utilisateur est authentifié, puis appelle la méthode create du repository des témoignages pour créer un nouveau témoignage dans la base de données. + * Après la création, récupère et retourne le témoignage créé. + * @param {Object} _args Les arguments de la mutation, contenant les propriétés du témoignage à créer (sauf l'ID). + * @param {Object} context Le contexte de la requête, contenant les informations de l'utilisateur et le repository des témoignages. + * @returns {Promise} Indique si la création du témoignage a réussi. + * @throws {Error} Une erreur si l'utilisateur n'est pas authentifié ou si le témoignage ne peut pas être trouvé après la création. + */ + createTestimony: async ( + _args: { input: Omit }, + context: { + user: jwt.JwtPayload | null; + testimonyRepo: TestimonyRepository; + }, + ): Promise => { + checkAuth(context); + const input = { ..._args.input }; + if (isEmpty(input.name)) throw new Error("Name is required"); + if (isEmpty(input.content)) throw new Error("Content is required"); + input.name = sanitizeString(input.name); + if (input.company) input.company = sanitizeString(input.company); + input.content = sanitizeWysiwyg(input.content); + input.createdAt = new Date(input.createdAt || Date.now()); + if (!isValidDate(input.createdAt?.toISOString() ?? "")) + throw new Error("Invalid start date"); + const result = await context.testimonyRepo.create(input); + if (!result) throw new Error("Failed to create testimony"); + return result; + }, + + /** + * Met à jour un témoignage existant + * Vérifie que l'utilisateur est authentifié, puis appelle la méthode update du repository des témoignages pour mettre à jour les propriétés d'un témoignage existant dans la base de données. + * Après la mise à jour, récupère et retourne le témoignage mis à jour. + * @param {Object} _args Les arguments de la mutation, contenant les propriétés du témoignage à mettre à jour (doit inclure l'ID). + * @param {Object} context Le contexte de la requête, contenant les informations de l'utilisateur et le repository des témoignages. + * @returns {Promise} Indique si la mise à jour du témoignage a réussi. + * @throws {Error} Une erreur si l'utilisateur n'est pas authentifié ou si le témoignage ne peut pas être trouvé après la mise à jour. + */ + updateTestimony: async ( + _args: { id: string; input: Partial> }, + context: { + user: jwt.JwtPayload | null; + testimonyRepo: TestimonyRepository; + }, + ): Promise => { + checkAuth(context); + validateId(_args.id); + const input = { ..._args.input, id: _args.id }; + if (input.name) input.name = sanitizeString(input.name); + if (input.company) input.company = sanitizeString(input.company); + if (input.content) input.content = sanitizeWysiwyg(input.content); + if (input.createdAt) { + input.createdAt = new Date(input.createdAt); + if (!isValidDate(input.createdAt.toISOString())) + throw new Error("Invalid start date"); + } + const result = await context.testimonyRepo.update(input); + if (!result) throw new Error("Failed to update testimony"); + return result; + }, + + /** + * Supprime un témoignage existant + * Vérifie que l'utilisateur est authentifié, puis appelle la méthode delete du repository des témoignages pour supprimer un témoignage existant de la base de données. + * @param {Object} _args Les arguments de la mutation, contenant l'ID du témoignage à supprimer. + * @param {Object} context Le contexte de la requête, contenant les informations de l'utilisateur et le repository des témoignages. + * @returns {Promise} Indique si la suppression du témoignage a réussi. + * @throws {Error} Une erreur si l'utilisateur n'est pas authentifié ou si le témoignage ne peut pas être trouvé pour la suppression. + */ + deleteTestimony: async ( + _args: { id: string }, + context: { + user: jwt.JwtPayload | null; + testimonyRepo: TestimonyRepository; + }, + ): Promise => { + checkAuth(context); + validateId(_args.id); + const result = await context.testimonyRepo.delete(_args.id); + if (!result) throw new Error("Failed to delete testimony"); + return result; + }, +}; + +export default testimonyResolver; diff --git a/src/graphql/schemas/testimonySchema.ts b/src/graphql/schemas/testimonySchema.ts new file mode 100644 index 0000000..4c252fc --- /dev/null +++ b/src/graphql/schemas/testimonySchema.ts @@ -0,0 +1,28 @@ +// Types GraphQL pour les témoignages +export const testimonyTypes = ` + type Testimony { + id: ID! + name: String! + company: String + content: String! + createdAt: String! + } +`; +export const testimonyInputs = ` + input TestimonyInput { + name: String! + company: String + content: String! + createdAt: String + } +`; + +// Requête GraphQL pour les témoignages +export const testimonyQueries = `testimonies: [Testimony!]!`; + +// Mutations GraphQL pour les témoignages +export const testimonyMutations = ` + createTestimony(input: TestimonyInput): Boolean! + updateTestimony(id: ID!, input: TestimonyInput): Boolean! + deleteTestimony(id: ID!): Boolean! +`; diff --git a/src/repositories/TestimonyRepository.ts b/src/repositories/TestimonyRepository.ts new file mode 100644 index 0000000..e51de6d --- /dev/null +++ b/src/repositories/TestimonyRepository.ts @@ -0,0 +1,65 @@ +import { Testimony } from "../types/testimonyTypes"; +import { withConnection } from "../database/dbHelpers"; +import { BaseRepository } from "./BaseRepository"; + +// Repository pour les opérations liées aux témoignages dans la base de données +export default class TestimonyRepository extends BaseRepository { + protected readonly tableName = "testimony"; + + /** + * Récupère tous les témoignages de la base de données. + * La méthode exécute une requête SQL pour sélectionner tous les témoignages de la table "testimony" de la base de données, en récupérant tous les champs disponibles. + * Les résultats sont retournés sous forme d'un tableau d'objets Testimony, où chaque objet représente un témoignage avec ses propriétés correspondantes. + * @returns {Promise} Un tableau de témoignages récupérés de la base de données, avec toutes leurs propriétés. + * @throws {Error} Une erreur si la récupération des témoignages échoue pour une raison quelconque. + */ + async getAll(): Promise { + return withConnection(this.pool, (conn) => + conn.query( + `SELECT id, name, company, content, created_at AS createdAt FROM testimony ORDER BY created_at ASC`, + ), + ); + } + + /** + * Crée un nouveau témoignage dans la base de données en utilisant les propriétés fournies. + * La méthode génère un ID unique pour le nouveau témoignage, puis insère les données dans la table "testimony" de la base de données. + * Après l'insertion, la méthode retourne un booléen indiquant si la création a réussi. + * @param {Omit} testimony Les propriétés du témoignage à créer, à l'exception de l'ID qui est généré automatiquement. + * @returns {Promise} Une promesse qui se résout avec true lorsque la création est terminée, ou rejette une erreur si la création échoue. + * @throws {Error} Une erreur si la création échoue pour une raison quelconque. + */ + async create(testimony: Omit): Promise { + const id = this.generateId(); + await withConnection(this.pool, (conn) => + conn.query( + `INSERT INTO testimony (id, name, company, content, created_at) VALUES (?, ?, ?, ?, ?)`, + [ + id, + testimony.name, + testimony.company || null, + testimony.content, + testimony.createdAt || new Date().toISOString(), + ], + ), + ); + return true; + } + + /** + * Met à jour un témoignage existant dans la base de données en fonction des propriétés fournies. + * La méthode vérifie que l'ID du témoignage est fourni, puis construit dynamiquement la requête SQL pour mettre à jour les champs spécifiés. + * Après l'exécution de la requête de mise à jour, la méthode retourne un booléen indiquant si la mise à jour a réussi. + * @param {Partial} testimony Les propriétés du témoignage à mettre à jour, qui doivent inclure l'ID du témoignage à mettre à jour. + * @returns {Promise} Une promesse qui se résout avec true lorsque la mise à jour est terminée, ou rejette une erreur si l'ID n'est pas fourni ou si la mise à jour échoue. + * @throws {Error} Une erreur si l'ID du témoignage n'est pas fourni, ou si la mise à jour échoue pour une raison quelconque. + */ + async update(testimony: Partial): Promise { + if (!testimony.id) throw new Error("ID is required for update"); + return this.updateOne(testimony.id, { + name: testimony.name || undefined, + company: testimony.company || undefined, + content: testimony.content || undefined, + }); + } +} diff --git a/src/types/testimonyTypes.ts b/src/types/testimonyTypes.ts new file mode 100644 index 0000000..a3c0aee --- /dev/null +++ b/src/types/testimonyTypes.ts @@ -0,0 +1,8 @@ +// Interface représentant un témoignage +export interface Testimony { + id: string; + name: string; + company?: string; + content: string; + createdAt: Date; +} From 46916934e38279a56d62dc4561f118c086a1cad6 Mon Sep 17 00:00:00 2001 From: NKoelblen Date: Fri, 8 May 2026 16:46:29 +0200 Subject: [PATCH 2/2] fix: change order of testimonies retrieval to descending by creation date Co-authored-by: Copilot --- src/repositories/TestimonyRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/TestimonyRepository.ts b/src/repositories/TestimonyRepository.ts index e51de6d..1ebfeae 100644 --- a/src/repositories/TestimonyRepository.ts +++ b/src/repositories/TestimonyRepository.ts @@ -16,7 +16,7 @@ export default class TestimonyRepository extends BaseRepository { async getAll(): Promise { return withConnection(this.pool, (conn) => conn.query( - `SELECT id, name, company, content, created_at AS createdAt FROM testimony ORDER BY created_at ASC`, + `SELECT id, name, company, content, created_at AS createdAt FROM testimony ORDER BY created_at DESC`, ), ); }