diff --git a/db/migrations/00019_event_notifications_log.sql b/db/migrations/00019_event_notifications_log.sql new file mode 100644 index 0000000..530faa3 --- /dev/null +++ b/db/migrations/00019_event_notifications_log.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS event_notifications_log +( + id_event_notifications_log SERIAL, + id_events INTEGER NOT NULL, + email VARCHAR(100) NOT NULL, + notification_type VARCHAR(50) NOT NULL, -- 'CREATED' | 'REMINDER_1H' + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id_event_notifications_log), + CONSTRAINT fk_event_notifications_event FOREIGN KEY (id_events) REFERENCES events (id_events) ON DELETE CASCADE, + CONSTRAINT fk_event_notifications_user FOREIGN KEY (email) REFERENCES newf (email) ON DELETE CASCADE, + CONSTRAINT uq_event_notification_once UNIQUE (id_events, email, notification_type) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS event_notifications_log; +-- +goose StatementEnd + + diff --git a/handlers/event/event_handler.go b/handlers/event/event_handler.go index 61f5b64..284b09a 100644 --- a/handlers/event/event_handler.go +++ b/handlers/event/event_handler.go @@ -9,16 +9,19 @@ import ( "github.com/gofiber/fiber/v2" "github.com/plugimt/transat-backend/models" + "github.com/plugimt/transat-backend/services" "github.com/plugimt/transat-backend/utils" ) type EventHandler struct { - db *sql.DB + db *sql.DB + notifier *services.EventNotificationService } -func NewEventHandler(db *sql.DB) *EventHandler { +func NewEventHandler(db *sql.DB, notifier *services.EventNotificationService) *EventHandler { return &EventHandler{ - db: db, + db: db, + notifier: notifier, } } @@ -778,6 +781,13 @@ func (h *EventHandler) CreateEvent(c *fiber.Ctx) error { utils.LogLineKeyValue(utils.LevelInfo, "Event ID", eventID) utils.LogFooter() + // Fire-and-forget creation notification (non-blocking) + if h.notifier != nil { + go func(id int) { + _ = h.notifier.SendEventCreated(id) + }(eventID) + } + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "message": "Event created successfully", "event": map[string]interface{}{ diff --git a/i18n/active.de.toml b/i18n/active.de.toml index 096d793..19057f9 100644 --- a/i18n/active.de.toml +++ b/i18n/active.de.toml @@ -1,3 +1,10 @@ +[event.created] +title = "📅 Neues Ereignis erstellt" +message = "Das Ereignis '{{.Name}}' wurde für {{.StartTime}} erstellt." + +[event.reminder] +title = "⏰ Ereignis beginnt in 1 Stunde" +message = "'{{.Name}}' beginnt in 1 Stunde." [restaurant_notification] title = "🍽️ NEUES MENÜ VERFÜGBAR!" message = "Ein köstliches neues Menü erwartet Sie im RU! Entdecken Sie die Tagesgerichte und hinterlassen Sie Ihre Kommentare. Guten Appetit!" diff --git a/i18n/active.en.toml b/i18n/active.en.toml index 33390c5..9e0c7d5 100644 --- a/i18n/active.en.toml +++ b/i18n/active.en.toml @@ -51,6 +51,14 @@ open_restaurant = "View menu" download_app = "Download app" open_profile = "Access my profile" +[event.created] +title = "📅 New event created" +message = "Event '{{.Name}}' created for {{.StartTime}}." + +[event.reminder] +title = "⏰ Event starts in 1 hour" +message = "'{{.Name}}' starts in 1 hour." + [weather] thunderstorm = "Thunderstorm" drizzle = "Drizzle" diff --git a/i18n/active.es.toml b/i18n/active.es.toml index 0a86d7d..c68b4e8 100644 --- a/i18n/active.es.toml +++ b/i18n/active.es.toml @@ -1,3 +1,10 @@ +[event.created] +title = "📅 Nuevo evento creado" +message = "El evento '{{.Name}}' se ha creado para {{.StartTime}}." + +[event.reminder] +title = "⏰ El evento comienza en 1 hora" +message = "'{{.Name}}' comienza en 1 hora." [email_verification] title = "Tu código de verificación Transat" subject = "🔐 Tu código de verificación Transat: {{.VerificationCode}}" diff --git a/i18n/active.fr.toml b/i18n/active.fr.toml index 9408bdc..ed20ed4 100644 --- a/i18n/active.fr.toml +++ b/i18n/active.fr.toml @@ -1,3 +1,10 @@ +[event.created] +title = "📅 Nouvel évènement créé" +message = "L'évènement '{{.Name}}' est créé pour le {{.StartTime}}." + +[event.reminder] +title = "⏰ L'évènement commence dans 1 heure" +message = "'{{.Name}}' commence dans 1 heure." [email_verification] title = "Ton code de vérification Transat" subject = "🔐 Ton code de vérification Transat : {{.VerificationCode}}" diff --git a/main.go b/main.go index e5a5d3f..d26d3f8 100644 --- a/main.go +++ b/main.go @@ -61,6 +61,7 @@ func main() { app.Use(utils.SentryHandler) notificationService := services.NewNotificationService(db) + eventNotifier := services.NewEventNotificationService(db, notificationService) translationService, err := services.NewTranslationService() if err != nil { log.Fatalf("💥 Failed to create Translation Service: %v", err) @@ -100,7 +101,7 @@ func main() { clubsHandler := club.NewclubHandler(db) - eventHandler := event.NewEventHandler(db) + eventHandler := event.NewEventHandler(db, eventNotifier) appScheduler := scheduler.NewScheduler(restHandler) appScheduler.StartAll() @@ -109,6 +110,10 @@ func main() { // Cron Jobs - Requires access to handlers/services c := cron.New() + // Add periodic job to send 1-hour event reminders every 5 minutes + _, _ = c.AddFunc("CRON_TZ=Europe/Paris */5 * * * *", func() { + _ = eventNotifier.SendDueOneHourReminders() + }) c.Start() defer c.Stop() diff --git a/services/event_notification.go b/services/event_notification.go new file mode 100644 index 0000000..4e5a6ef --- /dev/null +++ b/services/event_notification.go @@ -0,0 +1,314 @@ +package services + +import ( + "database/sql" + "fmt" + "time" + + "github.com/nicksnyder/go-i18n/v2/i18n" + appI18n "github.com/plugimt/transat-backend/i18n" + "github.com/plugimt/transat-backend/models" + "github.com/plugimt/transat-backend/utils" +) + +type EventNotificationType string + +const ( + EventNotificationCreated EventNotificationType = "CREATED" + EventNotificationReminder1h EventNotificationType = "REMINDER_1H" +) + +// EventNotificationService encapsulates business logic for event notifications +type EventNotificationService struct { + db *sql.DB + notificationService *NotificationService +} + +func NewEventNotificationService(db *sql.DB, notificationService *NotificationService) *EventNotificationService { + return &EventNotificationService{db: db, notificationService: notificationService} +} + +// LogNotification creates a deduped log entry for a sent notification +func (s *EventNotificationService) LogNotification(eventID int, email string, notifType EventNotificationType) error { + _, err := s.db.Exec(` + INSERT INTO event_notifications_log (id_events, email, notification_type) + VALUES ($1, $2, $3) + ON CONFLICT (id_events, email, notification_type) DO NOTHING + `, eventID, email, string(notifType)) + return err +} + +// AlreadySent checks whether a notification of a given type was already sent to a user for an event +func (s *EventNotificationService) AlreadySent(eventID int, email string, notifType EventNotificationType) (bool, error) { + var exists bool + err := s.db.QueryRow(` + SELECT EXISTS( + SELECT 1 FROM event_notifications_log + WHERE id_events = $1 AND email = $2 AND notification_type = $3 + ) + `, eventID, email, string(notifType)).Scan(&exists) + return exists, err +} + +// getEventCore fetches event core fields used for notifications +func (s *EventNotificationService) getEventCore(eventID int) (name string, clubID int, startDate time.Time, err error) { + err = s.db.QueryRow(` + SELECT name, id_club, start_date + FROM events WHERE id_events = $1 + `, eventID).Scan(&name, &clubID, &startDate) + return +} + +// getRecipients finds user emails interested in the club or the event itself +func (s *EventNotificationService) getRecipients(eventID int, clubID int) ([]string, error) { + // Users who are members of the club + clubQuery := `SELECT email FROM clubs_members WHERE id_clubs = $1` + rowsClub, err := s.db.Query(clubQuery, clubID) + if err != nil { + return nil, fmt.Errorf("failed to query club members: %w", err) + } + defer rowsClub.Close() + + emailSet := map[string]struct{}{} + for rowsClub.Next() { + var email string + if err := rowsClub.Scan(&email); err == nil { + emailSet[email] = struct{}{} + } + } + if err := rowsClub.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate club member rows: %w", err) + } + + // Users who marked interest in the event (attendees table) + evtQuery := `SELECT email FROM events_attendents WHERE id_events = $1` + rowsEvt, err := s.db.Query(evtQuery, eventID) + if err != nil { + return nil, fmt.Errorf("failed to query event interested users: %w", err) + } + defer rowsEvt.Close() + for rowsEvt.Next() { + var email string + if err := rowsEvt.Scan(&email); err == nil { + emailSet[email] = struct{}{} + } + } + if err := rowsEvt.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate event interested rows: %w", err) + } + + // materialize unique list + recipients := make([]string, 0, len(emailSet)) + for e := range emailSet { + recipients = append(recipients, e) + } + return recipients, nil +} + +// SendEventCreated sends creation notifications to all relevant recipients, deduped and localized +func (s *EventNotificationService) SendEventCreated(eventID int) error { + name, clubID, startDate, err := s.getEventCore(eventID) + if err != nil { + return err + } + recipients, err := s.getRecipients(eventID, clubID) + if err != nil { + return err + } + if len(recipients) == 0 { + return nil + } + + // fetch tokens+language + targets, err := s.notificationService.GetUsersWithLanguageByEmails(recipients) + if err != nil { + return err + } + if len(targets) == 0 { + return nil + } + + // group by language and filter out already-sent + languageToTokens := map[string][]string{} + languageToEmails := map[string][]string{} + for _, t := range targets { + sent, err := s.AlreadySent(eventID, t.Email, EventNotificationCreated) + if err != nil { + continue + } + if sent || t.NotificationToken == "" { + continue + } + languageToTokens[t.LanguageCode] = append(languageToTokens[t.LanguageCode], t.NotificationToken) + languageToEmails[t.LanguageCode] = append(languageToEmails[t.LanguageCode], t.Email) + } + + // send per language + total := 0 + for lang, tokens := range languageToTokens { + if len(tokens) == 0 { + continue + } + localizer := appI18n.GetLocalizer(lang) + + title := localizer.MustLocalize(&i18n.LocalizeConfig{ + MessageID: "event.created.title", + DefaultMessage: &i18n.Message{ID: "event.created.title", Other: "New event created"}, + }) + + // include event id and open screen + message := localizer.MustLocalize(&i18n.LocalizeConfig{ + MessageID: "event.created.message", + TemplateData: map[string]interface{}{ + "Name": name, + "StartTime": utils.FormatParis(startDate, "02/01 15:04"), + "EventID": eventID, + }, + DefaultMessage: &i18n.Message{ID: "event.created.message", Other: "Event '{{.Name}}' created for {{.StartTime}}."}, + }) + + payload := models.NotificationPayload{ + NotificationTokens: tokens, + Title: title, + Message: message, + Sound: "default", + ChannelID: "default", + Data: map[string]interface{}{ + "screen": "Event", + "eventId": eventID, + }, + } + + if err := s.notificationService.SendPushNotification(payload); err != nil { + // continue to next language group + continue + } + + // log all emails for which we sent + for _, email := range languageToEmails[lang] { + _ = s.LogNotification(eventID, email, EventNotificationCreated) + } + total += len(tokens) + } + + _ = total // kept for potential metrics/logging + return nil +} + +// SendDueOneHourReminders scans for events starting within the next hour and sends reminders if not already sent +func (s *EventNotificationService) SendDueOneHourReminders() error { + now := utils.Now() + lower := now.Add(1 * time.Hour) + upper := lower.Add(5 * time.Minute) + + rows, err := s.db.Query(` + SELECT id_events FROM events + WHERE start_date >= $1 AND start_date < $2 + `, lower, upper) + if err != nil { + return fmt.Errorf("failed to query upcoming events: %w", err) + } + defer rows.Close() + + var eventIDs []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err == nil { + eventIDs = append(eventIDs, id) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("failed to iterate events: %w", err) + } + + for _, eventID := range eventIDs { + if err := s.sendOneHourReminderForEvent(eventID); err != nil { + // continue other events + continue + } + } + return nil +} + +func (s *EventNotificationService) sendOneHourReminderForEvent(eventID int) error { + name, clubID, startDate, err := s.getEventCore(eventID) + if err != nil { + return err + } + + // If event was created less than 1 hour before start, skip reminder per requirements + // Note: creation_date is stored on events; use it if needed. We'll guard at send time too. + var creationDate time.Time + _ = s.db.QueryRow(`SELECT creation_date FROM events WHERE id_events = $1`, eventID).Scan(&creationDate) + if !creationDate.IsZero() && startDate.Sub(creationDate) < time.Hour { + return nil + } + + recipients, err := s.getRecipients(eventID, clubID) + if err != nil { + return err + } + if len(recipients) == 0 { + return nil + } + + targets, err := s.notificationService.GetUsersWithLanguageByEmails(recipients) + if err != nil { + return err + } + if len(targets) == 0 { + return nil + } + + languageToTokens := map[string][]string{} + languageToEmails := map[string][]string{} + for _, t := range targets { + sent, err := s.AlreadySent(eventID, t.Email, EventNotificationReminder1h) + if err != nil { + continue + } + if sent || t.NotificationToken == "" { + continue + } + languageToTokens[t.LanguageCode] = append(languageToTokens[t.LanguageCode], t.NotificationToken) + languageToEmails[t.LanguageCode] = append(languageToEmails[t.LanguageCode], t.Email) + } + + for lang, tokens := range languageToTokens { + if len(tokens) == 0 { + continue + } + localizer := appI18n.GetLocalizer(lang) + title := localizer.MustLocalize(&i18n.LocalizeConfig{ + MessageID: "event.reminder.title", + DefaultMessage: &i18n.Message{ID: "event.reminder.title", Other: "Event starts in 1 hour"}, + }) + message := localizer.MustLocalize(&i18n.LocalizeConfig{ + MessageID: "event.reminder.message", + TemplateData: map[string]interface{}{ + "Name": name, + "EventID": eventID, + }, + DefaultMessage: &i18n.Message{ID: "event.reminder.message", Other: "'{{.Name}}' starts in 1 hour."}, + }) + + payload := models.NotificationPayload{ + NotificationTokens: tokens, + Title: title, + Message: message, + Sound: "default", + ChannelID: "default", + Data: map[string]interface{}{ + "screen": "Event", + "eventId": eventID, + }, + } + if err := s.notificationService.SendPushNotification(payload); err != nil { + continue + } + for _, email := range languageToEmails[lang] { + _ = s.LogNotification(eventID, email, EventNotificationReminder1h) + } + } + return nil +} diff --git a/services/notification.go b/services/notification.go index 1e44bfc..94fbd29 100644 --- a/services/notification.go +++ b/services/notification.go @@ -198,6 +198,40 @@ func (ns *NotificationService) GetSubscribedUsersWithLanguage(serviceName string return targets, nil } +// GetUsersWithLanguageByEmails resolves a list of emails to their notification tokens and language codes. +// Users without a valid notification token are excluded. +func (ns *NotificationService) GetUsersWithLanguageByEmails(emails []string) ([]models.NotificationTargetWithLanguage, error) { + if len(emails) == 0 { + return []models.NotificationTargetWithLanguage{}, nil + } + + query := ` + SELECT n.email, COALESCE(n.notification_token, '') AS notification_token, l.code AS language_code + FROM newf n + JOIN languages l ON n.language = l.id_languages + WHERE n.email = ANY($1) AND n.notification_token IS NOT NULL AND n.notification_token != ''; + ` + rows, err := ns.db.Query(query, pq.Array(emails)) + if err != nil { + return nil, fmt.Errorf("failed to query users by emails for notifications: %w", err) + } + defer rows.Close() + + var targets []models.NotificationTargetWithLanguage + for rows.Next() { + var t models.NotificationTargetWithLanguage + if err := rows.Scan(&t.Email, &t.NotificationToken, &t.LanguageCode); err != nil { + log.Printf("Error scanning user language/notification token: %v", err) + continue + } + targets = append(targets, t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate users with language rows: %w", err) + } + return targets, nil +} + // SendPushNotification sends a push notification via Expo. func (ns *NotificationService) SendPushNotification(payload models.NotificationPayload) error { var tokens []string