Skip to content

LetterMaker

Lettermaker.cc est une application web que j’ai développée pour permettre aux étudiants et jeunes professionnels de générer facilement des lettres de motivation personnalisées grâce à l’intelligence artificielle. Le site, réalisé avec Nuxt.js et TailwindCSS, intègre Stripe pour les paiements et Resend pour l’envoi automatisé des candidatures par email.

L’originalité du projet repose sur l’utilisation combinée de ChatGPT et Claude, deux IA complémentaires qui permettent d’obtenir des lettres plus riches, mieux structurées et adaptées aux besoins de chaque utilisateur. Le service est disponible en français, anglais et espagnol, et propose un tableau de bord pour suivre ses candidatures avec différents statuts (envoyé, en attente, refusé, entretien prévu, etc.).

Au cours du développement, j’ai dû relever plusieurs défis : apprendre à orchestrer deux IA pour améliorer la pertinence des résultats, concevoir un composant Form générique afin de simplifier la gestion des formulaires, mettre en place la logique de paiements sécurisés avec Stripe, et configurer correctement les emails transactionnels avec Resend pour assurer une bonne délivrabilité.

Ce projet m’a permis d’approfondir mes compétences en Nuxt.js, de perfectionner ma manière de penser des composants réutilisables et maintenables, et de travailler pour la première fois sur un véritable SaaS multilingue intégrant plusieurs services externes. Lettermaker.cc est aujourd’hui une vitrine de ma capacité à concevoir, développer et déployer une application web moderne, complète et orientée utilisateur.

Image de présentation

Route API Gestion du prompt

js
    const attempts = usage?.maxLetter - usage?.letterUsed;

    let currentIa;
    switch (body.ia) {
        case 'default':
            currentIa = 'gpt-4o';
            break;
        case 'deepkseek':
            currentIa = 'deepseek-chat';
            break;
        default:
            currentIa = 'gpt-4o';
            break;
    }

    let openai;

    if(currentIa === 'deepseek-chat') {
        openai = new OpenAI({
            baseURL: 'https://api.deepseek.com',
            apiKey: config.DEEPSEEK_API_KEY
        });
    } else {
        openai = new OpenAI();
    }

    const completion = await openai.chat.completions.create({
        model: currentIa,
        messages: [
            {
                role: "system",
                content: "Vous êtes un assistant expert en rédaction de lettres de motivation destinées à être converties en PDF/DocX. Dans le texte généré, il ne doit pas contenir l'entete du message avec le nom de l'entreprise sans adresse... Les sautes de ligne seront indiqués par des <br>"
            },
            {
                role: "user",
                content: `Génère une lettre de motivation sans en-tête et sans informations de contact personnelles ou de l'entreprise.

                          La lettre devra inclure les informations suivantes :

                          Prénom Nom : ${body.first_name} ${body.last_name}
                          Nom de l'entreprise : ${body.compagny_name}
                          Poste visé : ${body.compagny_post}
                          Formation : ${body.academic}
                          Experience pro : ${body.experience_pro}
                          Compétences: ${body.competences}
                          Informations: complémentaire : ${body.other_information}
                          La lettre doit être formelle, bien structurée et adaptée à une candidature spontanée. Elle doit inclure un objet et ne doit pas utiliser de markdown pour les liens ou tout autre élément.
                          La lettre doit commencé à : "Objet: "
                          Le format final devra être un PDF.
                          Les sautes de ligne seront indiqués par des <br>
                          Un plan en 3 parties pour structurer sa lettre de motivation : vous, moi, nous (sans les préciser)
                          Inclus des mots spécifiques du domaines pour montrer que tu t'y connais
                          Si l'information ne te semble pas pertinente ne la prend pas en compte.
                          `
            }
        ],
        store: true,
    });

Webhook Stripe permettant de gérer les achats

js
import {PrismaClient} from '@prisma/client'
import {stripe} from '~/server/utils/stripe'

const prisma = new PrismaClient()
const config = useRuntimeConfig()

export default defineEventHandler(async (event) => {

    const body = await readRawBody(event, false)
    let stripeEvent = body
    let subscription
    const signature = getHeader(event, 'stripe-signature');

    if (!body) {
        return {error: 'Invalid request body'}
    }

    if (!signature) {
        return {error: 'Invalid stripe-signature'}
    }

    try {
        // 3
        stripeEvent = stripe.webhooks.constructEvent(
            body,
            signature,
            config.STRIPE_WEBHOOK_SECRET_KEY
        )
    } catch (err) {
        const error = createError({
            statusCode: 400,
            statusMessage: `Webhook error: ${err}`,
        })
        return sendError(event, error)
    }

    switch (stripeEvent.type) {
        case 'customer.subscription.deleted':
            subscription = stripeEvent.data.object

            await prisma.user.update({
                where: {
                    stripe_customer_id: subscription.customer,
                },
                data: {
                    subscription: {
                        delete: true
                    }
                },
            })

            break
        case 'customer.subscription.updated':
        case 'customer.subscription.created': {
            subscription = stripeEvent.data.object

            console.log(subscription.items.data[0].price.lookup_key);

            const plan = await prisma.plan.findUnique({
                where: {
                    lookup_key: subscription.items.data[0].price.lookup_key
                }
            })

            await prisma.user.update({
                where: {
                    stripe_customer_id: subscription.customer,
                },
                data: {
                    subscription: {
                        upsert: {
                            update: {
                                status: 'ACTIVE',
                                startDate: new Date(subscription.start_date * 1000),
                                endDate: new Date(subscription.current_period_end * 1000),
                                planId: plan.id
                            },
                            create: {
                                status: 'ACTIVE',
                                startDate: new Date(subscription.start_date * 1000),
                                endDate: new Date(subscription.current_period_end * 1000),
                                planId: plan.id
                            }
                        }
                    }
                }
            });

            break;
        }
        default:

    }
    return {received: true}

})