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);
|
||||
|
||||
27
functions/notification_tokens.js
Normal file
27
functions/notification_tokens.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Retourne tous les tokens FCM d'un profil utilisateur.
|
||||
*
|
||||
* La méthode supporte le format historique `fcmToken` (string) et le nouveau
|
||||
* format `fcmTokens` (array de strings) pour gérer le multi-appareils.
|
||||
*/
|
||||
function extractUserFcmTokens(userData) {
|
||||
const tokens = [];
|
||||
|
||||
if (typeof userData.fcmToken === "string" && userData.fcmToken.length > 0) {
|
||||
tokens.push(userData.fcmToken);
|
||||
}
|
||||
|
||||
if (Array.isArray(userData.fcmTokens)) {
|
||||
for (const token of userData.fcmTokens) {
|
||||
if (typeof token === "string" && token.length > 0) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractUserFcmTokens,
|
||||
};
|
||||
24
functions/notification_tokens.test.js
Normal file
24
functions/notification_tokens.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { extractUserFcmTokens } = require("./notification_tokens");
|
||||
|
||||
test("extractUserFcmTokens supports legacy single token", () => {
|
||||
const tokens = extractUserFcmTokens({ fcmToken: "token_1" });
|
||||
assert.deepEqual(tokens, ["token_1"]);
|
||||
});
|
||||
|
||||
test("extractUserFcmTokens merges single and multi token formats", () => {
|
||||
const tokens = extractUserFcmTokens({
|
||||
fcmToken: "token_legacy",
|
||||
fcmTokens: ["token_a", "token_b"],
|
||||
});
|
||||
assert.deepEqual(tokens, ["token_legacy", "token_a", "token_b"]);
|
||||
});
|
||||
|
||||
test("extractUserFcmTokens filters invalid values", () => {
|
||||
const tokens = extractUserFcmTokens({
|
||||
fcmToken: "",
|
||||
fcmTokens: ["valid", null, 42, ""],
|
||||
});
|
||||
assert.deepEqual(tokens, ["valid"]);
|
||||
});
|
||||
14
functions/package-lock.json
generated
14
functions/package-lock.json
generated
@@ -7,13 +7,14 @@
|
||||
"name": "functions",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.6.0",
|
||||
"firebase-functions": "^7.0.0"
|
||||
"firebase-functions": "^7.0.0",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"firebase-functions-test": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24"
|
||||
"node": "20"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -5209,6 +5210,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.6.0",
|
||||
"firebase-functions": "^7.0.0"
|
||||
"firebase-functions": "^7.0.0",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"firebase-functions-test": "^3.4.1"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user