feat: Implement trip invitation functionality and notification handling

- Added TripInvitationRepository for managing trip invitations.
- Created TripInvitation model with serialization methods.
- Implemented notification payload parser for handling FCM notifications.
- Enhanced NotificationService to manage trip invitations and related actions.
- Updated UserRepository to include user search functionality.
- Modified AuthRepository to store multiple FCM tokens.
- Added tests for trip invitation logic and notification payload parsing.
- Updated pubspec.yaml and pubspec.lock for dependency management.
This commit is contained in:
Van Leemput Dayron
2026-03-13 13:54:47 +01:00
parent e665dea82a
commit 3215a990d1
27 changed files with 1961 additions and 321 deletions

View File

@@ -1,87 +1,252 @@
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();
// Helper function to send notifications to a list of users
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 = {}) {
console.log(`Starting sendNotificationToUsers. Total users: ${userIds.length}, Exclude: ${excludeUserId}`);
try {
const tokens = [];
const targets = (userIds || []).filter((id) => id && id !== excludeUserId);
if (targets.length === 0) {
return;
}
for (const userId of userIds) {
if (userId === excludeUserId) {
console.log(`Skipping user ${userId} (sender)`);
continue;
}
const userDocs = await Promise.all(
targets.map((userId) => admin.firestore().collection("users").doc(userId).get())
);
const userDoc = await admin.firestore().collection("users").doc(userId).get();
if (userDoc.exists) {
const userData = userDoc.data();
if (userData.fcmToken) {
console.log(`Found token for user ${userId}`);
tokens.push(userData.fcmToken);
} else {
console.log(`No FCM token found for user ${userId}`);
}
} else {
console.log(`User document not found for ${userId}`);
}
}
const tokens = userDocs
.filter((doc) => doc.exists)
.flatMap((doc) => extractUserFcmTokens(doc.data() || {}));
// De-duplicate tokens
const uniqueTokens = [...new Set(tokens)];
console.log(`Total unique tokens to send: ${uniqueTokens.length} (from ${tokens.length} found)`);
await sendPushToTokens(tokens, title, body, data);
}
if (uniqueTokens.length > 0) {
const message = {
notification: {
title: title,
body: body,
},
tokens: uniqueTokens,
data: {
click_action: "FLUTTER_NOTIFICATION_CLICK",
...data
},
android: {
priority: "high",
notification: {
channelId: "high_importance_channel",
}
}
};
/**
* Envoie une notification à un utilisateur unique.
*/
async function sendNotificationToUser(userId, title, body, data = {}) {
if (!userId) {
return;
}
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));
}
} else {
console.log("No tokens found, skipping notification send.");
}
} catch (error) {
console.error("Error sending notification:", error);
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: `<p>Votre code d'authentification est: <strong>${code}</strong>.</p><p>Il expire dans 10 minutes.</p>`,
});
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) => {
console.log(`onActivityCreated triggered for ${context.params.activityId}`);
const activity = snapshot.data();
const activityId = context.params.activityId;
const tripId = activity.tripId;
const createdBy = activity.createdBy || "Unknown";
if (!tripId) {
console.log("No tripId found in activity");
return;
}
// Fetch trip to get participants
const tripDoc = await admin.firestore().collection("trips").doc(tripId).get();
if (!tripDoc.exists) {
console.log(`Trip ${tripId} not found`);
return;
}
@@ -91,9 +256,6 @@ exports.onActivityCreated = functions.firestore
participants.push(trip.createdBy);
}
console.log(`Found trip participants: ${JSON.stringify(participants)}`);
// Fetch creator name
let creatorName = "Quelqu'un";
if (createdBy !== "Unknown") {
const userDoc = await admin.firestore().collection("users").doc(createdBy).get();
@@ -104,78 +266,200 @@ exports.onActivityCreated = functions.firestore
await sendNotificationToUsers(
participants,
"Nouvelle activité !",
`${creatorName} a ajouté une nouvelle activité : ${activity.name || activity.title}`,
"Nouvelle activité",
`${creatorName} a ajouté : ${activity.name || activity.title || "Activité"}`,
createdBy,
{ tripId: tripId }
{ type: "activity", tripId, activityId }
);
});
exports.onMessageCreated = functions.firestore
.document("groups/{groupId}/messages/{messageId}")
.onCreate(async (snapshot, context) => {
console.log(`onMessageCreated triggered for ${context.params.messageId} in group ${context.params.groupId}`);
const message = snapshot.data();
const groupId = context.params.groupId;
const senderId = message.senderId;
// Fetch group to get members
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
if (!groupDoc.exists) {
console.log(`Group ${groupId} not found`);
return;
}
const group = groupDoc.data();
const memberIds = group.memberIds || [];
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
let senderName = message.senderName || "Quelqu'un";
const tripId = group.tripId || "";
const senderName = message.senderName || "Quelqu'un";
await sendNotificationToUsers(
memberIds,
"Nouveau message",
`${senderName} : ${message.text}`,
`${senderName}: ${message.text}`,
senderId,
{ groupId: groupId }
{ type: "message", groupId, tripId }
);
});
exports.onExpenseCreated = functions.firestore
.document("expenses/{expenseId}")
.onCreate(async (snapshot, context) => {
console.log(`onExpenseCreated triggered for ${context.params.expenseId}`);
.onCreate(async (snapshot) => {
const expense = snapshot.data();
const groupId = expense.groupId;
const paidBy = expense.paidById || expense.paidBy;
if (!groupId) {
console.log("No groupId found in expense");
return;
}
// Fetch group to get members
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
if (!groupDoc.exists) {
console.log(`Group ${groupId} not found`);
return;
}
const group = groupDoc.data();
const memberIds = group.memberIds || [];
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
let payerName = expense.paidByName || "Quelqu'un";
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 || '€'}`,
`${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || "€"}`,
paidBy,
{ groupId: groupId }
{ 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;
@@ -183,13 +467,13 @@ exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => {
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);
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 packageName = "be.devdayronvl.travel_mate";
const redirectUrl = `intent://callback?${qs}#Intent;package=${packageName};scheme=signinwithapple;end`;
res.redirect(302, redirectUrl);