- 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.
481 lines
16 KiB
JavaScript
481 lines
16 KiB
JavaScript
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: `<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) => {
|
|
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);
|
|
});
|