Skip to content
36 changes: 36 additions & 0 deletions migrations/202605081622.ts
Original file line number Diff line number Diff line change
@@ -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<Pool>) {
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<Pool>) {
const conn = await pool.getConnection();
try {
await conn.query("DROP TABLE IF EXISTS testimony");
} finally {
conn.release();
}
}
35 changes: 35 additions & 0 deletions migrations/202605101156.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Pool } from "mariadb/*";
import { MigrationParams } from "umzug";

/**
* Migration pour définir une valeur par défaut vide sur la colonne "name"
* de la table "testimony" tout en la conservant NOT NULL.
*/

export async function up({ context: pool }: MigrationParams<Pool>) {
const conn = await pool.getConnection();
try {
await conn.query(
`ALTER TABLE IF EXISTS testimony MODIFY COLUMN IF EXISTS name VARCHAR(255) NULL;`,
);
await conn.query(
`ALTER TABLE IF EXISTS testimony MODIFY COLUMN IF EXISTS created_at VARCHAR(255) NULL;`,
);
} finally {
conn.release();
}
}

export async function down({ context: pool }: MigrationParams<Pool>) {
const conn = await pool.getConnection();
try {
await conn.query(
`ALTER TABLE IF EXISTS testimony MODIFY COLUMN IF EXISTS name VARCHAR(255) NOT NULL;`,
);
await conn.query(
`ALTER TABLE IF EXISTS testimony MODIFY COLUMN IF EXISTS created_at VARCHAR(255) NOT NULL;`,
);
} finally {
conn.release();
}
}
29 changes: 29 additions & 0 deletions migrations/202605101159.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Pool } from "mariadb/*";
import { MigrationParams } from "umzug";

/**
* Migration pour définir une valeur par défaut vide sur la colonne "name"
* de la table "testimony" tout en la conservant NOT NULL.
*/

export async function up({ context: pool }: MigrationParams<Pool>) {
const conn = await pool.getConnection();
try {
await conn.query(
"ALTER TABLE IF EXISTS testimony ADD COLUMN IF NOT EXISTS `insert` BOOLEAN;",
);
} finally {
conn.release();
}
}

export async function down({ context: pool }: MigrationParams<Pool>) {
const conn = await pool.getConnection();
try {
await conn.query(
"ALTER TABLE IF EXISTS testimony DROP COLUMN IF EXISTS `insert`;",
);
} finally {
conn.release();
}
}
3 changes: 3 additions & 0 deletions src/graphql/graphqlContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -45,6 +46,7 @@ export function getGraphqlContext({
coworkerRepo: CoworkerRepository;
projectRepo: ProjectRepository;
mediaRepo: MediaRepository;
testimonyRepo: TestimonyRepository;
} {
return {
user,
Expand All @@ -56,5 +58,6 @@ export function getGraphqlContext({
coworkerRepo: new CoworkerRepository(pool),
projectRepo: new ProjectRepository(pool),
mediaRepo: new MediaRepository(pool),
testimonyRepo: new TestimonyRepository(pool),
};
}
12 changes: 12 additions & 0 deletions src/graphql/graphqlSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -80,6 +87,8 @@ export function getSchema() {
${projectInputs}
${settingsTypes}
${contactTypes}
${testimonyTypes}
${testimonyInputs}
type Query {
${accountQueries}
${categoryQueries}
Expand All @@ -89,6 +98,7 @@ export function getSchema() {
${projectQueries}
${mediaQueries}
${settingsQueries}
${testimonyQueries}
}
type Mutation {
${authMutations}
Expand All @@ -101,6 +111,7 @@ export function getSchema() {
${mediaMutations}
${settingsMutations}
${contactMutation}
${testimonyMutations}
}
`);
}
Expand All @@ -121,5 +132,6 @@ export function getRoot() {
...mediaResolver,
...settingsResolver,
...contactResolver,
...testimonyResolver,
};
}
115 changes: 115 additions & 0 deletions src/graphql/resolvers/testimonyResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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<testimony[]>} Un tableau de témoignages récupérés de la base de données.
*/
testimonies: async (
_args: Record<string, never>,
context: { testimonyRepo: TestimonyRepository },
): Promise<Testimony[]> => {
const testimonies = await context.testimonyRepo.getAll();
console.log("Retrieved testimonies:", testimonies);
return testimonies;
},

/**
* 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<boolean>} 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<Testimony, "id"> },
context: {
user: jwt.JwtPayload | null;
testimonyRepo: TestimonyRepository;
},
): Promise<boolean> => {
checkAuth(context);
const input = { ..._args.input };
if (isEmpty(input.content)) throw new Error("Content is required");
if (input.name) 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<boolean>} 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<Omit<Testimony, "id">> },
context: {
user: jwt.JwtPayload | null;
testimonyRepo: TestimonyRepository;
},
): Promise<boolean> => {
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<boolean>} 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<boolean> => {
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;
30 changes: 30 additions & 0 deletions src/graphql/schemas/testimonySchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Types GraphQL pour les témoignages
export const testimonyTypes = `
type Testimony {
id: ID!
name: String
company: String
content: String!
createdAt: String
insert: Boolean
}
`;
export const testimonyInputs = `
input TestimonyInput {
name: String
company: String
content: String!
createdAt: String
insert: Boolean
}
`;

// 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!
`;
72 changes: 72 additions & 0 deletions src/repositories/TestimonyRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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<Testimony[]>} 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<Testimony[]> {
return withConnection(this.pool, (conn) =>
conn.query(
`SELECT id, name, company, content, created_at AS createdAt, \`insert\` FROM testimony ORDER BY createdAt DESC, name DESC`,
),
);
}

/**
* 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, "id">} testimony Les propriétés du témoignage à créer, à l'exception de l'ID qui est généré automatiquement.
* @returns {Promise<boolean>} 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<Testimony, "id">): Promise<boolean> {
const id = this.generateId();
await withConnection(this.pool, (conn) =>
conn.query(
`INSERT INTO testimony (id, name, company, content, created_at, \`insert\`) VALUES (?, ?, ?, ?, ?, ?)`,
[
id,
testimony.name,
testimony.company || null,
testimony.content,
testimony.createdAt || new Date().toISOString(),
testimony.insert || false,
],
),
);
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>} testimony Les propriétés du témoignage à mettre à jour, qui doivent inclure l'ID du témoignage à mettre à jour.
* @returns {Promise<boolean>} 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<Testimony>): Promise<boolean> {
if (!testimony.id) throw new Error("ID is required for update");
// Utilise la clé '`insert`' pour éviter le conflit SQL
const updateData: Record<string, any> = {
name: testimony.name || undefined,
company: testimony.company || undefined,
content: testimony.content || undefined,
created_at: testimony.createdAt || undefined,
};
if (typeof testimony.insert !== "undefined") {
updateData["`insert`"] = testimony.insert;
}
return this.updateOne(testimony.id, updateData);
}
}
Loading
Loading