const crypto = require("crypto"); const functions = require("firebase-functions/v1"); const admin = require("firebase-admin"); const nodemailer = require("nodemailer"); const { extractUserFcmTokens } = require("./notification_tokens"); admin.initializeApp(); let mailTransporter; /** * Retourne un transporteur SMTP nodemailer initialisé avec les variables d'environnement. * * La configuration repose sur les variables du fichier `.env` dans le dossier `functions`. */ function getMailTransporter() { if (mailTransporter) { return mailTransporter; } mailTransporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), secure: String(process.env.SMTP_SECURE || "false") === "true", auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, requireTLS: String(process.env.SMTP_REQUIRE_TLS || "false") === "true", tls: { rejectUnauthorized: String(process.env.SMTP_TLS_REJECT_UNAUTHORIZED || "true") === "true", }, }); return mailTransporter; } /** * Normalise une adresse email pour les comparaisons et le stockage. */ function normalizeEmail(email) { return String(email || "").trim().toLowerCase(); } /** * Génère un code numérique de vérification sur 6 chiffres. */ function generateAuthCode() { return String(Math.floor(100000 + Math.random() * 900000)); } /** * Hash un code d'authentification avec SHA-256. */ function hashCode(code) { return crypto.createHash("sha256").update(String(code)).digest("hex"); } /** * Envoie une notification à une liste d'utilisateurs. */ async function sendNotificationToUsers(userIds, title, body, excludeUserId, data = {}) { const targets = (userIds || []).filter((id) => id && id !== excludeUserId); if (targets.length === 0) { return; } const userDocs = await Promise.all( targets.map((userId) => admin.firestore().collection("users").doc(userId).get()) ); const tokens = userDocs .filter((doc) => doc.exists) .flatMap((doc) => extractUserFcmTokens(doc.data() || {})); await sendPushToTokens(tokens, title, body, data); } /** * Envoie une notification à un utilisateur unique. */ async function sendNotificationToUser(userId, title, body, data = {}) { if (!userId) { return; } const userDoc = await admin.firestore().collection("users").doc(userId).get(); if (!userDoc.exists) { return; } const tokens = extractUserFcmTokens(userDoc.data() || {}); await sendPushToTokens(tokens, title, body, data); } /** * Envoie un push multicast à une liste de tokens FCM. */ async function sendPushToTokens(tokens, title, body, data = {}) { const uniqueTokens = [...new Set((tokens || []).filter(Boolean))]; if (uniqueTokens.length === 0) { console.log("No tokens found, skipping notification send."); return; } const normalizedData = Object.fromEntries( Object.entries(data).map(([key, value]) => [key, value == null ? "" : String(value)]) ); const message = { notification: { title, body }, tokens: uniqueTokens, data: { click_action: "FLUTTER_NOTIFICATION_CLICK", ...normalizedData, }, android: { priority: "high", notification: { channelId: "high_importance_channel", }, }, }; const response = await admin.messaging().sendEachForMulticast(message); console.log(`${response.successCount} messages were sent successfully`); if (response.failureCount > 0) { console.log("Failed notifications:", response.responses.filter((r) => !r.success)); } } /** * Envoie un code d'authentification par email via SMTP. */ exports.sendEmailAuthCode = functions.https.onCall(async (data) => { const email = normalizeEmail(data?.email); if (!email || !email.includes("@")) { throw new functions.https.HttpsError("invalid-argument", "Adresse email invalide."); } const now = Date.now(); const oneMinuteAgo = admin.firestore.Timestamp.fromMillis(now - 60 * 1000); const recentRequest = await admin .firestore() .collection("emailAuthCodes") .where("email", "==", email) .where("createdAt", ">=", oneMinuteAgo) .limit(1) .get(); if (!recentRequest.empty) { throw new functions.https.HttpsError( "resource-exhausted", "Un code a déjà été envoyé récemment. Réessayez dans une minute." ); } const code = generateAuthCode(); const codeHash = hashCode(code); const expiresAt = admin.firestore.Timestamp.fromMillis(now + 10 * 60 * 1000); await admin.firestore().collection("emailAuthCodes").add({ email, codeHash, createdAt: admin.firestore.FieldValue.serverTimestamp(), expiresAt, used: false, attempts: 0, }); const transporter = getMailTransporter(); await transporter.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: email, subject: "Votre code de connexion Travel Mate", text: `Votre code d'authentification est: ${code}. Il expire dans 10 minutes.`, html: `
Votre code d'authentification est: ${code}.
Il expire dans 10 minutes.
`, }); return { success: true }; }); /** * Vérifie un code d'authentification reçu par email. */ exports.verifyEmailAuthCode = functions.https.onCall(async (data) => { const email = normalizeEmail(data?.email); const code = String(data?.code || "").trim(); if (!email || !email.includes("@") || code.length !== 6) { throw new functions.https.HttpsError("invalid-argument", "Email ou code invalide."); } const now = admin.firestore.Timestamp.now(); const snapshot = await admin .firestore() .collection("emailAuthCodes") .where("email", "==", email) .where("used", "==", false) .orderBy("createdAt", "desc") .limit(1) .get(); if (snapshot.empty) { throw new functions.https.HttpsError("not-found", "Aucun code actif trouvé."); } const doc = snapshot.docs[0]; const payload = doc.data(); if (payload.expiresAt && payload.expiresAt.toMillis() < now.toMillis()) { throw new functions.https.HttpsError("deadline-exceeded", "Le code a expiré."); } if ((payload.attempts || 0) >= 5) { throw new functions.https.HttpsError("permission-denied", "Trop de tentatives."); } const providedHash = hashCode(code); if (providedHash !== payload.codeHash) { await doc.ref.update({ attempts: admin.firestore.FieldValue.increment(1) }); throw new functions.https.HttpsError("permission-denied", "Code incorrect."); } await doc.ref.update({ used: true, verifiedAt: admin.firestore.FieldValue.serverTimestamp(), }); return { verified: true }; }); exports.onActivityCreated = functions.firestore .document("activities/{activityId}") .onCreate(async (snapshot, context) => { const activity = snapshot.data(); const activityId = context.params.activityId; const tripId = activity.tripId; const createdBy = activity.createdBy || "Unknown"; if (!tripId) { return; } const tripDoc = await admin.firestore().collection("trips").doc(tripId).get(); if (!tripDoc.exists) { return; } const trip = tripDoc.data(); const participants = trip.participants || []; if (trip.createdBy && !participants.includes(trip.createdBy)) { participants.push(trip.createdBy); } let creatorName = "Quelqu'un"; if (createdBy !== "Unknown") { const userDoc = await admin.firestore().collection("users").doc(createdBy).get(); if (userDoc.exists) { creatorName = userDoc.data().prenom || "Quelqu'un"; } } await sendNotificationToUsers( participants, "Nouvelle activité", `${creatorName} a ajouté : ${activity.name || activity.title || "Activité"}`, createdBy, { type: "activity", tripId, activityId } ); }); exports.onMessageCreated = functions.firestore .document("groups/{groupId}/messages/{messageId}") .onCreate(async (snapshot, context) => { const message = snapshot.data(); const groupId = context.params.groupId; const senderId = message.senderId; const groupDoc = await admin.firestore().collection("groups").doc(groupId).get(); if (!groupDoc.exists) { return; } const group = groupDoc.data(); const memberIds = group.memberIds || []; const tripId = group.tripId || ""; const senderName = message.senderName || "Quelqu'un"; await sendNotificationToUsers( memberIds, "Nouveau message", `${senderName}: ${message.text}`, senderId, { type: "message", groupId, tripId } ); }); exports.onExpenseCreated = functions.firestore .document("expenses/{expenseId}") .onCreate(async (snapshot) => { const expense = snapshot.data(); const groupId = expense.groupId; const paidBy = expense.paidById || expense.paidBy; if (!groupId) { return; } const groupDoc = await admin.firestore().collection("groups").doc(groupId).get(); if (!groupDoc.exists) { return; } const group = groupDoc.data(); const memberIds = group.memberIds || []; const tripId = group.tripId || ""; const payerName = expense.paidByName || "Quelqu'un"; await sendNotificationToUsers( memberIds, "Nouvelle dépense", `${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || "€"}`, paidBy, { type: "expense", groupId, tripId } ); }); exports.onTripInvitationCreated = functions.firestore .document("tripInvitations/{invitationId}") .onCreate(async (snapshot, context) => { const invitationId = context.params.invitationId; const invitation = snapshot.data(); if (!invitation?.inviteeId) { return; } await sendNotificationToUser( invitation.inviteeId, "Invitation de voyage", `${invitation.inviterName || "Quelqu'un"} vous invite à rejoindre ${invitation.tripTitle || "un voyage"}`, { type: "trip_invitation", invitationId, tripId: invitation.tripId, inviterName: invitation.inviterName || "Quelqu'un", tripTitle: invitation.tripTitle || "Voyage", } ); }); exports.onTripInvitationUpdated = functions.firestore .document("tripInvitations/{invitationId}") .onUpdate(async (change) => { const before = change.before.data(); const after = change.after.data(); if (before.status === after.status || before.status !== "pending") { return; } const status = after.status; const tripId = after.tripId; const inviteeId = after.inviteeId; if (!tripId || !inviteeId) { return; } if (status === "accepted") { await admin.firestore().runTransaction(async (transaction) => { const userRef = admin.firestore().collection("users").doc(inviteeId); const userDoc = await transaction.get(userRef); if (!userDoc.exists) { throw new Error("Utilisateur invité introuvable"); } const user = userDoc.data(); const tripRef = admin.firestore().collection("trips").doc(tripId); transaction.update(tripRef, { participants: admin.firestore.FieldValue.arrayUnion(inviteeId), updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); const groupSnapshot = await admin .firestore() .collection("groups") .where("tripId", "==", tripId) .limit(1) .get(); if (!groupSnapshot.empty) { const groupRef = groupSnapshot.docs[0].ref; const memberRef = groupRef.collection("members").doc(inviteeId); transaction.set( memberRef, { userId: inviteeId, firstName: user.prenom || "", lastName: user.nom || "", pseudo: user.prenom || "Voyageur", profilePictureUrl: user.profilePictureUrl || null, joinedAt: Date.now(), }, { merge: true } ); transaction.update(groupRef, { memberIds: admin.firestore.FieldValue.arrayUnion(inviteeId), updatedAt: Date.now(), }); } const accountSnapshot = await admin .firestore() .collection("accounts") .where("tripId", "==", tripId) .limit(1) .get(); if (!accountSnapshot.empty) { const accountRef = accountSnapshot.docs[0].ref; const accountMemberRef = accountRef.collection("members").doc(inviteeId); transaction.set( accountMemberRef, { userId: inviteeId, firstName: user.prenom || "", lastName: user.nom || "", pseudo: user.prenom || "Voyageur", profilePictureUrl: user.profilePictureUrl || null, joinedAt: Date.now(), }, { merge: true } ); } }); await sendNotificationToUser( after.inviterId, "Invitation acceptée", `${after.inviteeEmail || "L'utilisateur"} a accepté votre invitation.`, { type: "trip_invitation_response", tripId } ); return; } if (status === "rejected") { await sendNotificationToUser( after.inviterId, "Invitation refusée", `${after.inviteeEmail || "L'utilisateur"} a refusé votre invitation.`, { type: "trip_invitation_response", tripId } ); } }); exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => { const code = req.body.code; const state = req.body.state; const id_token = req.body.id_token; const user = req.body.user; const params = new URLSearchParams(); if (code) params.append("code", code); if (state) params.append("state", state); if (id_token) params.append("id_token", id_token); if (user) params.append("user", user); const qs = params.toString(); const packageName = "be.devdayronvl.travel_mate"; const redirectUrl = `intent://callback?${qs}#Intent;package=${packageName};scheme=signinwithapple;end`; res.redirect(302, redirectUrl); });