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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user