diff --git a/lib/core/blocs/session/session_cubit.dart b/lib/core/blocs/session/session_cubit.dart index 4ff9974..e2b40b2 100644 --- a/lib/core/blocs/session/session_cubit.dart +++ b/lib/core/blocs/session/session_cubit.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:equatable/equatable.dart'; @@ -44,112 +45,109 @@ class SessionCubit extends Cubit { } try { - // 1. CHI È QUESTO UTENTE? (Vediamo se ha un profilo staff, che sia Invitato o Admin) - StaffMemberModel? staff = await _repository.getStaffMemberByUserId( - user.id, - ); - CompanyModel? company; - if (staff != null) { - // --- LA MAGIA DEL SENSORE --- - if (staff.hasJoined == false) { - // È la primissima volta che entra! Aggiorniamo il DB. - await _repository.updateStaffMember(staff.id!, {'has_joined': true}); - // Aggiorniamo anche il nostro modello in memoria per questa sessione - staff = staff.copyWith(hasJoined: true); + // Riportiamo lo stato su initial per far girare lo spinner se stiamo riprovando + emit(state.copyWith(status: SessionStatus.initial, errorMessage: null)); + + // WRAP DELLA LOGICA IN UN BLOCCO PROTETTO DA TIMEOUT (10 Secondi) + await Future(() async { + StaffMemberModel? staff = await _repository.getStaffMemberByUserId( + user.id, + ); + CompanyModel? company; + + if (staff != null) { + if (staff.hasJoined == false) { + await _repository.updateStaffMember(staff.id!, { + 'has_joined': true, + }); + staff = staff.copyWith(hasJoined: true); + } + company = await _repository.getCompanyById(staff.companyId); + } else { + company = await _repository.getCompanyByOwnerId(user.id); } - company = await _repository.getCompanyById(staff.companyId); - } else { - // È l'Admin in onboarding - company = await _repository.getCompanyByOwnerId(user.id); - } - // 1. Controllo Azienda + if (company == null) { + return emit( + state.copyWith( + status: SessionStatus.onboardingRequired, + user: user, + onboardingStep: OnboardingStep.company, + ), + ); + } else { + emit(state.copyWith(company: company)); + } - if (staff != null) { - // L'utente esiste già nel sistema! Carichiamo l'azienda per cui lavora - company = await _repository.getCompanyById(staff.companyId); - } else { - // L'utente non ha profilo. Probabilmente è l'Admin che ha appena - // fatto Sign Up e sta iniziando l'Onboarding - company = await _repository.getCompanyByOwnerId(user.id); - } - if (company == null) { - return emit( - state.copyWith( - status: SessionStatus.onboardingRequired, - user: user, - onboardingStep: OnboardingStep.company, - ), - ); - } else { - emit(state.copyWith(company: company)); - } + final stores = await _repository.getStoresByCompanyId(company.id!); + if (stores.isEmpty) { + return emit( + state.copyWith( + status: SessionStatus.onboardingRequired, + user: user, + company: company, + onboardingStep: OnboardingStep.store, + ), + ); + } else { + emit(state.copyWith(currentStore: stores.first)); + } - // 2. Controllo Negozi - final stores = await _repository.getStoresByCompanyId(company.id!); - if (stores.isEmpty) { - return emit( + if (staff == null) { + return emit( + state.copyWith( + status: SessionStatus.onboardingRequired, + user: user, + company: company, + onboardingStep: OnboardingStep.staff, + ), + ); + } + + final lastStoreId = _prefs.getString(_lastStoreKey); + final activeStore = + stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first; + + if (lastStoreId != activeStore.id && activeStore.id != null) { + await _prefs.setString(_lastStoreKey, activeStore.id!); + } + + setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false); + + emit( state.copyWith( - status: SessionStatus.onboardingRequired, + status: SessionStatus.authenticated, user: user, company: company, - onboardingStep: OnboardingStep.store, + currentStore: activeStore, + currentStaffMember: staff, + onboardingStep: OnboardingStep.none, ), ); - } else { - emit(state.copyWith(currentStore: stores.first)); - } - // 3. Controllo Staff (Paziente Zero) - if (staff == null) { - return emit( - state.copyWith( - status: SessionStatus.onboardingRequired, - user: user, - company: company, - onboardingStep: OnboardingStep.staff, - ), - ); - } - - // --- TUTTO COMPLETATO: LOGICA DEL NEGOZIO DI DEFAULT --- - - // Leggiamo l'ultimo negozio dalle SharedPreferences - final lastStoreId = _prefs.getString(_lastStoreKey); - - // Cerchiamo quel negozio nella lista. Se non c'è (magari è stato eliminato), prendiamo il primo. - final activeStore = - stores.firstWhereOrNull((s) => s.id == lastStoreId) ?? stores.first; - - // Se non avevamo il lastStoreId salvato, salviamolo ora - if (lastStoreId != activeStore.id && activeStore.id != null) { - await _prefs.setString(_lastStoreKey, activeStore.id!); - } - - setIsSingleUserMode(_prefs.getBool('isSingleUserMode') ?? false); - - // 4. BENVENUTO A BORDO - emit( - state.copyWith( - status: SessionStatus.authenticated, - user: user, - company: company, - currentStore: activeStore, - currentStaffMember: staff, - onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding - ), + // FCM è fuori dall'await principale, quindi va bene così + _registerFcmToken(companyId: company.id!, staffId: staff.id!); + }).timeout( + const Duration(seconds: 10), // Tempo massimo concesso al server + onTimeout: () { + throw TimeoutException( + 'Il server di FLUX non risponde. Controlla la connessione.', + ); + }, + ); + } on TimeoutException catch (e) { + // 🎯 BINGO! IL TIMEOUT È SCATTATO + debugPrint("Timeout Inizializzazione: ${e.message}"); + emit( + state.copyWith(status: SessionStatus.error, errorMessage: e.message), ); - // --- REGISTRAZIONE DISPOSITIVO PER NOTIFICHE PUSH --- - // Lo chiamiamo SENZA 'await' in modo che il caricamento dell'app non si blocchi. - // L'utente entrerà subito nell'app e poi vedrà comparire il popup di sistema - // per accettare i permessi delle notifiche. - _registerFcmToken(companyId: company.id!, staffId: staff.id!); } catch (e) { - // Se esplode il database, non lasciamo l'app freezata in 'initial' + // Altri errori generici del DB o di rete + debugPrint("Errore Inizializzazione: $e"); emit( state.copyWith( - status: SessionStatus - .unauthenticated, // O un nuovo stato SessionStatus.error + status: SessionStatus.error, + errorMessage: "Si è verificato un errore di connessione imprevisto.", ), ); } diff --git a/lib/core/blocs/session/session_state.dart b/lib/core/blocs/session/session_state.dart index 0a95dd3..c36c861 100644 --- a/lib/core/blocs/session/session_state.dart +++ b/lib/core/blocs/session/session_state.dart @@ -6,6 +6,7 @@ enum SessionStatus { unauthenticated, onboardingRequired, authenticated, + error, } /// Definisce lo step esatto dell'onboarding (Paranoia Mode) @@ -26,6 +27,7 @@ class SessionState extends Equatable { final OnboardingStep onboardingStep; final bool isMobileDevice; final bool isSingleUserMode; + final String? errorMessage; const SessionState({ this.status = SessionStatus.initial, @@ -36,6 +38,7 @@ class SessionState extends Equatable { this.onboardingStep = OnboardingStep.none, this.isMobileDevice = false, this.isSingleUserMode = false, + this.errorMessage, }); /// Metodo per creare una copia dello stato modificando solo i campi necessari @@ -48,6 +51,7 @@ class SessionState extends Equatable { OnboardingStep? onboardingStep, bool? isMobileDevice, bool? isSingleUserMode, + String? errorMessage, }) { return SessionState( status: status ?? this.status, @@ -58,6 +62,7 @@ class SessionState extends Equatable { onboardingStep: onboardingStep ?? this.onboardingStep, isMobileDevice: isMobileDevice ?? this.isMobileDevice, isSingleUserMode: isSingleUserMode ?? this.isSingleUserMode, + errorMessage: errorMessage ?? this.errorMessage, ); } @@ -71,6 +76,7 @@ class SessionState extends Equatable { onboardingStep, isMobileDevice, isSingleUserMode, + errorMessage, ]; // Helper rapidi per la UI diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 04d097f..c5a7fc5 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -66,10 +66,15 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; class AppRouter { + // 1. CREIAMO LA CHIAVE GLOBALE DEL NAVIGATORE + static final GlobalKey rootNavigatorKey = + GlobalKey(); static GoRouter createRouter(SessionCubit sessionCubit) { return GoRouter( + navigatorKey: rootNavigatorKey, initialLocation: '/', refreshListenable: GoRouterRefreshStream(sessionCubit.stream), + redirect: (context, state) { final sessionState = sessionCubit.state; final isGoingToLogin = state.matchedLocation == '/login'; diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart new file mode 100644 index 0000000..cba82c6 --- /dev/null +++ b/lib/core/services/notification_service.dart @@ -0,0 +1,36 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flux/core/routes/app_router.dart'; +import 'package:go_router/go_router.dart'; + +// Chiamala dopo l'autenticazione o nel main() +Future setupInteractedMessage() async { + // CASO A: L'app era completamente CHIUSA e viene aperta tappando la notifica + RemoteMessage? initialMessage = await FirebaseMessaging.instance + .getInitialMessage(); + if (initialMessage != null) { + _handleNotificationTap(initialMessage); + } + + // CASO B: L'app era in BACKGROUND (minimizzata) e l'utente tappa la notifica + FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap); +} + +void _handleNotificationTap(RemoteMessage message) { + // Verifichiamo che tipo di notifica è e prendiamo l'ID + final eventType = message.data['eventType']; + final referenceId = message.data['referenceId']; + + if (eventType == 'task_assigned' && referenceId != null) { + // Navighiamo verso il form del Task usando la GlobalKey! + // Assicuriamoci che il context sia disponibile + final context = AppRouter.rootNavigatorKey.currentContext; + if (context != null) { + // Usiamo .push perché è una rotta di dettaglio fuori dalla shell + // Il path è /tasks/form/:id (vedi il tuo AppRouter) + context.push('/tasks/form/$referenceId'); + } else { + debugPrint("Attenzione: Context non trovato per il Deep Link!"); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index ad766d3..48e0788 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flux/core/services/notification_service.dart'; import 'package:flux/core/utils/version_check_service.dart'; import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/auth/bloc/auth_cubit.dart'; @@ -61,6 +62,7 @@ void main() async { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); + await setupInteractedMessage(); } catch (e) { debugPrint('Errore inizializzazione Firebase: $e'); } @@ -194,6 +196,13 @@ class _FluxAppState extends State { return _buildLoadingScreen(); } + if (sessionState.status == SessionStatus.error) { + return _buildSessionErrorScreen( + state: sessionState, + context: context, + ); + } + return BlocBuilder( builder: (context, themeState) { return MaterialApp.router( @@ -245,6 +254,42 @@ class _FluxAppState extends State { } } +Widget _buildSessionErrorScreen({ + required SessionState state, + required BuildContext context, +}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.wifi_off_rounded, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + state.errorMessage ?? 'Errore nella connessione', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + // Il ninja riprova a lanciare l'inizializzazione + context.read().initializeSession(); + }, + icon: const Icon(Icons.refresh), + label: const Text("Riprova a connetterti"), + ), + ], + ), + ), + ), + ), + ); +} + // --- IL WIDGET GUARDIANO DEGLI AGGIORNAMENTI --- class GlobalUpdateChecker extends StatefulWidget { final Widget child; diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..3ed4c94 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 2 +} diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..3ed4c94 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 2 +} diff --git a/pubspec.yaml b/pubspec.yaml index d124b35..d0a498a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flux description: "Gestione attività negozio di telefonia" publish_to: 'none' -version: 1.1.5+23 +version: 1.1.6+24 environment: sdk: ^3.11.3 diff --git a/supabase/config.toml b/supabase/config.toml index 4a4654e..dda9c39 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -422,3 +422,14 @@ entrypoint = "./functions/send-reminders/index.ts" # Specifies static files to be bundled with the function. Supports glob patterns. # For example, if you want to serve static HTML pages in your function: # static_files = [ "./functions/send-reminders/*.html" ] + +[functions.instant-notifier] +enabled = true +verify_jwt = false +import_map = "./functions/instant-notifier/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/instant-notifier/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/instant-notifier/*.html" ] diff --git a/supabase/functions/instant-notifier/.npmrc b/supabase/functions/instant-notifier/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/instant-notifier/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/instant-notifier/deno.json b/supabase/functions/instant-notifier/deno.json new file mode 100644 index 0000000..db206e8 --- /dev/null +++ b/supabase/functions/instant-notifier/deno.json @@ -0,0 +1,6 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2", + "@supabase/server": "npm:@supabase/server@^1" + } +} diff --git a/supabase/functions/instant-notifier/index.ts b/supabase/functions/instant-notifier/index.ts new file mode 100644 index 0000000..37b6b2a --- /dev/null +++ b/supabase/functions/instant-notifier/index.ts @@ -0,0 +1,206 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2" +import { JWT } from "https://esm.sh/google-auth-library@8.9.0" + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apiKey, content-type', +} + +serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) + + try { + const bodyText = await req.text(); + const payload = JSON.parse(bodyText); + + // Estraggo i dati dal payload standard di Supabase + const tableName = payload.table; + const record = payload.record; + + if (!tableName || !record) { + throw new Error("Payload non valido, manca table o record."); + } + + let event_type = ''; + let target_staff_id = ''; + let title = ''; + let description = ''; + let reference_id = ''; + + // Inizializziamo il client Supabase subito, ci serve per le query + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ) + + // SMISTAMENTO IN BASE ALLA TABELLA + if (tableName === 'task_assignments') { + event_type = 'task_assigned'; + target_staff_id = record.staff_id; + reference_id = record.task_id; + title = 'Nuovo Task Assegnato'; + + // 1. Peschiamo i dettagli completi del task + const { data: taskData } = await supabaseClient + .from('tasks') + .select('*') + .eq('id', reference_id) + .single(); + + // 2. Peschiamo il nome del creatore + let creatorName = "Admin"; + if (taskData?.created_by_id) { + const { data: creatorData } = await supabaseClient + .from('staff_members') + .select('first_name, last_name') + .eq('id', taskData.created_by_id) + .single(); + + if (creatorData) { + creatorName = `${creatorData.first_name} ${creatorData.last_name}`.trim(); + } + } + + // 3. Formattiamo la data (se esiste) + let dueDateStr = 'Nessuna scadenza'; + if (taskData?.due_date) { + const d = new Date(taskData.due_date); + dueDateStr = d.toLocaleDateString('it-IT'); + } + + // 4. Costruiamo il Body multilinea per Android + const taskTitle = taskData?.title || 'Senza titolo'; + const taskDesc = taskData?.description || 'Nessuna descrizione fornita.'; + + description = `${taskTitle}\n\nCreato da: ${creatorName}\nScadenza: ${dueDateStr}\nDettagli: ${taskDesc}`; + } + + // 1. Leggiamo le preferenze specifiche di questo dipendente + const { data: settings, error: settingsError } = await supabaseClient + .from('staff_notification_settings') + .select('*') + .eq('staff_id', target_staff_id) + .single() + + if (settingsError || !settings) throw new Error('Preferenze utente non trovate') + + // 2. Determiniamo QUALI canali usare in base all'evento e agli switch dell'utente + let sendPush = false + let sendEmail = false + + switch (event_type) { + case 'task_assigned': + sendPush = settings.task_assigned_push + sendEmail = settings.task_assigned_email + break + case 'note_invited': + sendPush = settings.note_invited_push + sendEmail = settings.note_invited_email + break + case 'new_operation': + sendPush = settings.new_operation_push + sendEmail = settings.new_operation_email + break + case 'new_ticket': + sendPush = settings.new_ticket_push + sendEmail = settings.new_ticket_email + break + default: + throw new Error('Tipo evento non riconosciuto') + } + + // Se l'utente ha spento tutto, interrompiamo subito risparmiando risorse + if (!sendPush && !sendEmail) { + return new Response(JSON.stringify({ message: 'L\'utente ha disattivato le notifiche per questo evento.' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 + }) + } + + // Se arriviamo qui, dobbiamo inviare qualcosa. Prepariamo i dati dell'utente. + const { data: staffMember } = await supabaseClient + .from('staff_members') + .select('email, first_name') + .eq('id', target_staff_id) + .single() + + // 3. LOGICA PUSH (FCM) + if (sendPush) { + const firebaseSecret = Deno.env.get('FIREBASE_SERVICE_ACCOUNT'); + + if (!firebaseSecret) { + console.error("ERRORE: Secret FIREBASE_SERVICE_ACCOUNT mancante nel progetto!"); + } else { + const credentials = JSON.parse(firebaseSecret); + const jwtClient = new JWT({ + email: credentials.client_email, + key: credentials.private_key, + scopes: ['https://www.googleapis.com/auth/firebase.messaging'], + }); + const fcmAccessToken = (await jwtClient.getAccessToken()).token; + + const { data: devices } = await supabaseClient + .from('staff_devices') + .select('fcm_token') + .eq('staff_id', target_staff_id); + + if (devices && devices.length > 0) { + for (const device of devices) { + try { + const res = await fetch(`https://fcm.googleapis.com/v1/projects/${credentials.project_id}/messages:send`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${fcmAccessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: { + token: device.fcm_token, + notification: { title, body: description }, + data: { click_action: 'FLUTTER_NOTIFICATION_CLICK', eventType: event_type, referenceId: reference_id }, + }, + }), + }); + + // QUI È DOVE CATTURIAMO LA RISPOSTA DI GOOGLE + const fcmResponseData = await res.json(); + + if (!res.ok) { + console.error("FCM HA RIFIUTATO LA NOTIFICA:", fcmResponseData); + } + + } catch (err) { + console.error('Errore di rete durante invio Push:', err); + } + } + } + } + } + + // 4. LOGICA EMAIL (Resend) + if (sendEmail && staffMember?.email) { + const resendApiKey = Deno.env.get('RESEND_API_KEY') + if (resendApiKey) { + try { + await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { 'Authorization': `Bearer ${resendApiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from: 'FLUX Notifiche ', + to: staffMember.email, + subject: title, + html: `

Ciao ${staffMember.first_name},

${description}


Il team FLUX

`, + }), + }) + } catch (err) { console.error('Errore invio Email:', err) } + } + } + + return new Response(JSON.stringify({ success: true, push_sent: sendPush, email_sent: sendEmail }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 + }) + + } catch (error) { + console.error("ERRORE FATALE NELLA FUNZIONE:", error); + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 + }) + } +}) \ No newline at end of file