clear last tickets
This commit is contained in:
+231
-241
@@ -2,25 +2,46 @@ const { onDocumentCreated } = require("firebase-functions/v2/firestore");
|
||||
const admin = require("firebase-admin");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const { generateImageV2 } = require("../helpers/gemini");
|
||||
const { generatePicturePrompt } = require("../helpers/prompts");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const axios = require("axios");
|
||||
const sharp = require("sharp");
|
||||
const crypto = require("crypto");
|
||||
|
||||
// Imports internes
|
||||
const { generateImageV2 } = require("../helpers/gemini");
|
||||
const { generatePicturePrompt } = require("../helpers/prompts");
|
||||
const { ALERT_TYPE, refList } = require("../index");
|
||||
const { sendNotification } = require("./notifications");
|
||||
|
||||
// Configuration
|
||||
const bucket = admin.storage().bucket();
|
||||
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
|
||||
|
||||
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
|
||||
let _cachedLogoBuffer = null;
|
||||
|
||||
/**
|
||||
* Récupère le buffer du logo depuis le cache ou le disque
|
||||
*/
|
||||
const getLogoBuffer = async () => {
|
||||
if (_cachedLogoBuffer) return _cachedLogoBuffer;
|
||||
try {
|
||||
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
|
||||
return _cachedLogoBuffer;
|
||||
} catch (error) {
|
||||
logger.error("❌ [Cover] Impossible de lire le fichier logo", error);
|
||||
throw new Error("Asset Logo manquant sur le serveur");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utilitaires Strings
|
||||
*/
|
||||
const pickFirstNonEmpty = (...values) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
@@ -33,354 +54,323 @@ const combineNames = (...parts) =>
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Résolution intelligente du nom d'artiste
|
||||
*/
|
||||
async function resolveArtistName(project = {}) {
|
||||
// 1. Vérification directe sur le projet ou le snapshot "owner"
|
||||
const owner = project?.owner || {};
|
||||
const direct = pickFirstNonEmpty(
|
||||
project?.artistName,
|
||||
project?.userName,
|
||||
owner?.artistName,
|
||||
owner?.userName,
|
||||
owner?.displayName
|
||||
owner?.displayName,
|
||||
);
|
||||
if (direct) return direct;
|
||||
|
||||
// 2. Fallback : Récupération depuis la collection Users
|
||||
const userId =
|
||||
typeof project?.userId === "string" && project.userId.trim()
|
||||
? project.userId.trim()
|
||||
: "";
|
||||
typeof project?.userId === "string" ? project.userId.trim() : "";
|
||||
if (!userId) return "";
|
||||
|
||||
try {
|
||||
const userSnapshot = await refList.users.doc(userId).get();
|
||||
if (!userSnapshot?.exists) return "";
|
||||
|
||||
const userData = userSnapshot.data() || {};
|
||||
const fullName = combineNames(userData.firstName, userData.lastName);
|
||||
return (
|
||||
pickFirstNonEmpty(
|
||||
userData.artistName,
|
||||
userData.userName,
|
||||
userData.displayName,
|
||||
fullName
|
||||
combineNames(userData.firstName, userData.lastName),
|
||||
) || ""
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn("⚠️ [Cover] Unable to resolve artist name", {
|
||||
projectId: project?.id || null,
|
||||
userId,
|
||||
error: error?.message || String(error),
|
||||
logger.warn("⚠️ [Cover] Artist name resolution failed", {
|
||||
projectId: project?.id,
|
||||
error: error.message,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le logo en filigrane sur l'image générée
|
||||
*/
|
||||
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||
logger.info("🖼️ [Cover] Adding logo to generated background");
|
||||
const [{ data: backgroundBuffer }, logoBuffer] = await Promise.all([
|
||||
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
|
||||
fs.promises.readFile(LOGO_PATH),
|
||||
]);
|
||||
logger.info("🖼️ [Cover] Compositing logo...");
|
||||
|
||||
const baseImage = sharp(backgroundBuffer);
|
||||
const { width = 1024, height = 1024 } = await baseImage.metadata();
|
||||
try {
|
||||
// Téléchargement background + Lecture Logo (parallèle)
|
||||
const [bgResponse, logoBuffer] = await Promise.all([
|
||||
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
|
||||
getLogoBuffer(),
|
||||
]);
|
||||
|
||||
const desiredWidth = Math.round(width * 0.32);
|
||||
const margin = Math.round(width * 0.04);
|
||||
const baseImage = sharp(bgResponse.data);
|
||||
const metadata = await baseImage.metadata();
|
||||
const width = metadata.width || 1024;
|
||||
const height = metadata.height || 1024;
|
||||
|
||||
const { data: resizedLogo, info: logoInfo } = await sharp(logoBuffer)
|
||||
.resize({ width: desiredWidth })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
// Calcul dynamique de la taille du logo (32% de la largeur)
|
||||
const desiredWidth = Math.round(width * 0.32);
|
||||
const margin = Math.round(width * 0.04);
|
||||
|
||||
const left = Math.max(width - logoInfo.width - margin, 0);
|
||||
const top = Math.max(height - logoInfo.height - margin, 0);
|
||||
// Redimensionnement du logo
|
||||
const resizedLogo = await sharp(logoBuffer)
|
||||
.resize({ width: desiredWidth })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const stampedBuffer = await baseImage
|
||||
.ensureAlpha()
|
||||
.composite([
|
||||
{
|
||||
input: resizedLogo,
|
||||
left,
|
||||
top,
|
||||
blend: "over",
|
||||
// Positionnement (Bas Droite)
|
||||
const logoMetadata = await sharp(resizedLogo).metadata();
|
||||
const left = Math.max(width - logoMetadata.width - margin, 0);
|
||||
const top = Math.max(height - logoMetadata.height - margin, 0);
|
||||
|
||||
// Composition
|
||||
const stampedBuffer = await baseImage
|
||||
.ensureAlpha()
|
||||
.composite([{ input: resizedLogo, left, top, blend: "over" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Upload vers Storage
|
||||
const token = crypto.randomUUID();
|
||||
const file = bucket.file(targetPath);
|
||||
|
||||
await file.save(stampedBuffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: "image/png",
|
||||
cacheControl: "public, max-age=31536000",
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
});
|
||||
|
||||
const token = require("crypto").randomUUID();
|
||||
await bucket.file(targetPath).save(stampedBuffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: "image/png",
|
||||
cacheControl: "public, max-age=31536000",
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
});
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||
targetPath
|
||||
)}?alt=media&token=${token}`;
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`;
|
||||
} catch (error) {
|
||||
logger.error("❌ [Cover] buildCoverWithLogo failed", error);
|
||||
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
|
||||
return backgroundUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared core for generating and saving the cover, and updating the project
|
||||
/**
|
||||
* Cœur de la logique de génération
|
||||
*/
|
||||
async function performCoverGeneration(project) {
|
||||
const t0 = Date.now();
|
||||
const artistName = await resolveArtistName(project);
|
||||
|
||||
// Génération du Prompt optimisé
|
||||
const prompt = generatePicturePrompt({
|
||||
...project,
|
||||
artistName,
|
||||
});
|
||||
// Utiliser generateImageV2 qui renvoie directement l'URL publique
|
||||
logger.info("🎨 [Cover] Calling model V2", {
|
||||
|
||||
logger.info("🎨 [Cover] Prompt generated", {
|
||||
projectId: project.id,
|
||||
promptPreview: String(prompt).slice(0, 160),
|
||||
artistName,
|
||||
promptPreview: prompt.slice(0, 100) + "...",
|
||||
});
|
||||
|
||||
const baseTimestamp = Date.now();
|
||||
const options = [];
|
||||
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const uniqueSuffix = `${baseTimestamp}-${index}`;
|
||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
|
||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
|
||||
let generatedUrl = "";
|
||||
|
||||
try {
|
||||
generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
|
||||
logger.info("🎨 [Cover] Candidate generated", {
|
||||
projectId: project.id,
|
||||
candidateIndex: index,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("❌ [Cover] generateImageV2 failed", {
|
||||
projectId: project.id,
|
||||
candidateIndex: index,
|
||||
error: e?.message || String(e),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!generatedUrl) {
|
||||
throw new Error("Génération d'image échouée (URL vide)");
|
||||
}
|
||||
|
||||
let finalCoverUrl = generatedUrl;
|
||||
try {
|
||||
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
|
||||
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(
|
||||
async (_, index) => {
|
||||
const uniqueSuffix = `${baseTimestamp}-${index}`;
|
||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`;
|
||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`;
|
||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`;
|
||||
finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath);
|
||||
} catch (e) {
|
||||
logger.error("❌ [Cover] Logo overlay failed", {
|
||||
projectId: project.id,
|
||||
candidateIndex: index,
|
||||
error: e?.message || String(e),
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
id: uniqueSuffix,
|
||||
generatedUrl,
|
||||
finalUrl: finalCoverUrl,
|
||||
});
|
||||
try {
|
||||
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
|
||||
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
|
||||
|
||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA");
|
||||
|
||||
// 2. Ajout du Logo
|
||||
const finalCoverUrl = await buildCoverWithLogo(
|
||||
generatedUrl,
|
||||
stampedPath,
|
||||
);
|
||||
|
||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
|
||||
|
||||
return {
|
||||
id: uniqueSuffix,
|
||||
generatedUrl,
|
||||
finalUrl: finalCoverUrl,
|
||||
promptUsed: prompt,
|
||||
};
|
||||
} catch (e) {
|
||||
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
|
||||
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
|
||||
error: e.message,
|
||||
});
|
||||
return null; // On retourne null pour filtrer plus tard
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Attente de la résolution de toutes les générations
|
||||
const results = await Promise.all(generationPromises);
|
||||
|
||||
// On garde uniquement les tentatives réussies (non null)
|
||||
const options = results.filter(Boolean);
|
||||
|
||||
if (options.length === 0) {
|
||||
throw new Error("Toutes les tentatives de génération ont échoué.");
|
||||
}
|
||||
|
||||
// Sauvegarde dans Firestore
|
||||
const [firstOption] = options;
|
||||
|
||||
await refList.projects.doc(project.id).set(
|
||||
{
|
||||
cover: {
|
||||
generatedBackground: firstOption?.generatedUrl || null,
|
||||
result: firstOption?.finalUrl || null,
|
||||
selectedOptionId: firstOption?.id || null,
|
||||
options,
|
||||
generatedBackground: firstOption.generatedUrl,
|
||||
result: firstOption.finalUrl,
|
||||
selectedOptionId: firstOption.id,
|
||||
options, // Sauvegarde de toutes les variantes réussies
|
||||
},
|
||||
coverStatus: "GENERATED",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
logger.info("✅ [Cover] Saved", {
|
||||
logger.info("🏁 [Cover] Process complete", {
|
||||
projectId: project.id,
|
||||
optionsCount: options.length,
|
||||
ms: Date.now() - t0,
|
||||
successCount: options.length,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return firstOption?.finalUrl || null;
|
||||
return firstOption.finalUrl;
|
||||
}
|
||||
|
||||
// Firestore trigger: création d'une tâche de génération de cover
|
||||
/**
|
||||
* TRIGGER FIRESTORE
|
||||
* Déclenché à la création d'un document dans 'tasks/{taskId}'
|
||||
*/
|
||||
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
{
|
||||
timeoutSeconds: 540,
|
||||
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
|
||||
memory: "1GiB",
|
||||
document: "tasks/{taskId}",
|
||||
},
|
||||
async (event) => {
|
||||
const data = event?.data?.data() || {};
|
||||
const type = data?.type || "";
|
||||
const projectId = data?.projectId || null;
|
||||
let coverUrl = null;
|
||||
let project = null;
|
||||
try {
|
||||
if (!projectId) {
|
||||
throw new Error("projectId manquant dans la task");
|
||||
}
|
||||
if (!["cover", "combine"]?.includes(type)) {
|
||||
throw new Error(`Type de tâche non supporté: ${type}`);
|
||||
}
|
||||
logger.info("🧵 [Task] Received", {
|
||||
taskId: event?.params?.taskId,
|
||||
type,
|
||||
projectId,
|
||||
});
|
||||
const data = event.data?.data() || {};
|
||||
const { type, projectId } = data;
|
||||
const taskId = event.params.taskId;
|
||||
|
||||
if (!projectId) return; // Ignorer les tâches mal formées
|
||||
if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches
|
||||
|
||||
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId });
|
||||
|
||||
try {
|
||||
// 1. Validation & Setup
|
||||
if (type === "combine") {
|
||||
logger.warn("⛔ [Task] Combine task ignored (feature disabled)", {
|
||||
projectId,
|
||||
taskId: event?.params?.taskId,
|
||||
});
|
||||
// Feature désactivée pour le moment
|
||||
await event.data.ref.update({
|
||||
status: "CANCELLED",
|
||||
error:
|
||||
"La personnalisation de la pochette avec une photo n'est plus disponible.",
|
||||
error: "La personnalisation photo n'est plus disponible.",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Mise à jour statut projet
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: "GENERATING",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
project = (await refList.projects.doc(projectId).get())?.data() || null;
|
||||
if (!project) {
|
||||
throw new Error("Projet non trouvé");
|
||||
}
|
||||
project.id = projectId;
|
||||
// 2. Chargement Projet
|
||||
const projectSnap = await refList.projects.doc(projectId).get();
|
||||
if (!projectSnap.exists) throw new Error("Projet introuvable");
|
||||
|
||||
const existingOptionsCount = Array.isArray(project?.cover?.options)
|
||||
? project.cover.options.length
|
||||
: 0;
|
||||
const project = { id: projectId, ...projectSnap.data() };
|
||||
|
||||
logger.info("🔎 [Task] Project loaded", {
|
||||
projectId,
|
||||
hasGeneratedBackground: !!project?.cover?.generatedBackground,
|
||||
hasUserForeground: !!project?.cover?.userForeground,
|
||||
existingOptionsCount,
|
||||
});
|
||||
|
||||
if (existingOptionsCount > 0) {
|
||||
logger.warn("⛔ [Task] Cover already generated, skipping", {
|
||||
projectId,
|
||||
existingOptionsCount,
|
||||
});
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: "GENERATED",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
// Idempotency check (si déjà généré, on ne refait pas)
|
||||
if (
|
||||
Array.isArray(project?.cover?.options) &&
|
||||
project.cover.options.length > 0
|
||||
) {
|
||||
logger.warn("⚠️ [Task] Cover already exists. Skipping.");
|
||||
await refList.projects
|
||||
.doc(projectId)
|
||||
.update({ coverStatus: "GENERATED" });
|
||||
await event.data.ref.update({
|
||||
status: "CANCELLED",
|
||||
error: "Cover already generated",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
status: "DONE",
|
||||
info: "Already generated",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "cover") {
|
||||
logger.info("🎨 [Task] Start cover generation", { projectId });
|
||||
coverUrl = await performCoverGeneration(project);
|
||||
logger.info("✅ [Task] Cover generated", { projectId });
|
||||
}
|
||||
// 3. Exécution Génération
|
||||
const coverUrl = await performCoverGeneration(project);
|
||||
|
||||
// 4. Finalisation Tâche
|
||||
await event.data.ref.update({
|
||||
status: "DONE",
|
||||
coverUrl,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
logger.info("📦 [Task] Marked DONE", {
|
||||
taskId: event?.params?.taskId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (
|
||||
project?.userId &&
|
||||
typeof project.userId === "string" &&
|
||||
project.userId.trim()
|
||||
) {
|
||||
const receiverId = project.userId.trim();
|
||||
const projectTitle =
|
||||
typeof project?.title === "string" && project.title.trim()
|
||||
? project.title.trim()
|
||||
: "ton projet";
|
||||
const message = `Ta nouvelle pochette pour "${projectTitle}" est prête.`;
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
receiver: receiverId,
|
||||
receiverCollection: "users",
|
||||
title: "Pochette générée",
|
||||
message,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||
projectId,
|
||||
projectTitle,
|
||||
coverUrl,
|
||||
},
|
||||
});
|
||||
} catch (notifError) {
|
||||
logger.error("❌ [Cover] Failed to send success notification", {
|
||||
// 5. Notification
|
||||
if (project.userId) {
|
||||
const projectTitle = project.title || "ton projet";
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
receiver: project.userId,
|
||||
receiverCollection: "users",
|
||||
title: "Pochette prête !",
|
||||
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||
projectId,
|
||||
error: notifError?.message || String(notifError),
|
||||
});
|
||||
}
|
||||
projectTitle,
|
||||
coverUrl,
|
||||
},
|
||||
}).catch((err) => logger.warn("Notification failed", err));
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.error("❌ [Task] Cover generation error", {
|
||||
taskId: event?.params?.taskId,
|
||||
projectId,
|
||||
type,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
logger.error(`🔥 [Task ${taskId}] Failed`, error);
|
||||
|
||||
// Mise à jour erreur Tâche
|
||||
await event.data.ref.set(
|
||||
{ status: "ERROR", error: error?.message || "Erreur" },
|
||||
{ merge: true }
|
||||
{ status: "ERROR", error: error.message },
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
// Mise à jour erreur Projet
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: "ERROR",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
if (
|
||||
project?.userId &&
|
||||
typeof project.userId === "string" &&
|
||||
project.userId.trim()
|
||||
) {
|
||||
const receiverId = project.userId.trim();
|
||||
const projectTitle =
|
||||
typeof project?.title === "string" && project.title.trim()
|
||||
? project.title.trim()
|
||||
: "ton projet";
|
||||
const message = `La génération de la pochette pour "${projectTitle}" a échoué.`;
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
receiver: receiverId,
|
||||
receiverCollection: "users",
|
||||
title: "Pochette indisponible",
|
||||
message,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||
projectId,
|
||||
projectTitle,
|
||||
error: error?.message || String(error),
|
||||
},
|
||||
});
|
||||
} catch (notifError) {
|
||||
logger.error("❌ [Cover] Failed to send error notification", {
|
||||
|
||||
// Notification Erreur
|
||||
const projectData = (await refList.projects.doc(projectId).get()).data();
|
||||
if (projectData?.userId) {
|
||||
await sendNotification({
|
||||
sender: "SYSTEM",
|
||||
receiver: projectData.userId,
|
||||
receiverCollection: "users",
|
||||
title: "Échec pochette",
|
||||
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||
projectId,
|
||||
error: notifError?.message || String(notifError),
|
||||
});
|
||||
}
|
||||
error: error.message,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user