From 7d626d059f20a0f1a5808b6e0fde32b12de7dd4f Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 13 Feb 2026 06:59:14 -0800 Subject: [PATCH] inapp notification fix --- db/data/005_issue_reporting_seed.sql | 14 + db/data/006_notifications_seed.sql | 26 ++ .../000015_notification_types.down.sql | 19 + .../000015_notification_types.up.sql | 31 ++ db/query/notification.sql | 14 + gen/db/notification.sql.go | 40 ++ internal/domain/notification.go | 10 + internal/ports/notification.go | 10 +- internal/repository/notification.go | 22 +- internal/services/notification/service.go | 80 +++- internal/web_server/handlers/admin.go | 2 + internal/web_server/handlers/arifpay.go | 4 + .../web_server/handlers/course_management.go | 30 ++ internal/web_server/handlers/handlers.go | 21 + .../web_server/handlers/issue_reporting.go | 18 + .../handlers/notification_handler.go | 363 +++++++++++------- internal/web_server/handlers/team_handler.go | 2 + internal/web_server/handlers/user.go | 14 + internal/web_server/routes.go | 6 +- 19 files changed, 580 insertions(+), 146 deletions(-) create mode 100644 db/data/005_issue_reporting_seed.sql create mode 100644 db/data/006_notifications_seed.sql create mode 100644 db/migrations/000015_notification_types.down.sql create mode 100644 db/migrations/000015_notification_types.up.sql diff --git a/db/data/005_issue_reporting_seed.sql b/db/data/005_issue_reporting_seed.sql new file mode 100644 index 0000000..a17e8cf --- /dev/null +++ b/db/data/005_issue_reporting_seed.sql @@ -0,0 +1,14 @@ +INSERT INTO reported_issues (user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at) VALUES +(10, 'USER', 'Video not loading on mobile', 'When I try to play the Algebra Fundamentals introduction video on my phone, it shows a blank screen with a spinner that never stops.', 'video', 'pending', '{"course": "Algebra Fundamentals", "device": "iPhone 14", "browser": "Safari 17"}', now() - interval '14 days', now() - interval '14 days'), +(10, 'USER', 'Payment confirmation not received', 'I subscribed to the premium plan yesterday and the money was deducted from my account, but I have not received any confirmation email or SMS.', 'payment', 'in_progress', '{"plan": "Premium", "amount": 500, "payment_method": "telebirr"}', now() - interval '10 days', now() - interval '8 days'), +(10, 'USER', 'Cannot change profile picture', 'I am trying to upload a new profile picture but the upload button does not respond when I click it.', 'account', 'resolved', '{"browser": "Chrome 120", "file_type": "jpg", "file_size_kb": 2048}', now() - interval '20 days', now() - interval '15 days'), +(10, 'USER', 'Add dark mode support', 'It would be great if the platform had a dark mode option. Studying at night with the bright white background is hard on the eyes.', 'feature_request', 'pending', '{"platform": "web"}', now() - interval '7 days', now() - interval '7 days'), +(10, 'USER', 'Quiz results not saving', 'I completed the Biology 101 quiz but when I go back to check my results, it shows as incomplete.', 'bug', 'in_progress', '{"course": "Biology 101", "quiz_id": 5, "attempts": 3}', now() - interval '5 days', now() - interval '3 days'), +(12, 'SUPPORT', 'Course content displays incorrectly on tablets', 'Multiple users have reported that course text overlaps with images on tablet devices in landscape mode.', 'content', 'pending', '{"affected_devices": ["iPad Air", "Samsung Galaxy Tab S9"], "orientation": "landscape"}', now() - interval '12 days', now() - interval '12 days'), +(12, 'SUPPORT', 'Login fails after password reset', 'After resetting my password through the forgot password flow, the new password is not accepted for login.', 'login', 'resolved', '{"browser": "Firefox 121", "reset_method": "email"}', now() - interval '25 days', now() - interval '18 days'), +(12, 'SUPPORT', 'Slow page load times', 'The course listing page takes over 10 seconds to load, especially when filtering by category.', 'performance', 'in_progress', '{"page": "/courses", "avg_load_time_ms": 12500, "filter": "category=Science"}', now() - interval '9 days', now() - interval '6 days'), +(10, 'USER', 'Subscription auto-renewal not working', 'My monthly subscription expired even though I had auto-renewal enabled. I had to manually resubscribe.', 'subscription', 'rejected', '{"plan": "Monthly Basic", "expected_renewal": "2026-01-15"}', now() - interval '30 days', now() - interval '22 days'), +(12, 'SUPPORT', 'Screen reader cannot read course navigation', 'The course sidebar navigation is not accessible with screen readers. ARIA labels are missing on several interactive elements.', 'accessibility', 'pending', '{"screen_reader": "NVDA", "browser": "Chrome 120", "affected_elements": ["sidebar nav", "progress bar", "video controls"]}', now() - interval '4 days', now() - interval '4 days'), +(10, 'USER', 'Certificate download gives 404 error', 'After completing the English Grammar course, clicking the download certificate button returns a page not found error.', 'course', 'pending', '{"course": "English Grammar", "completion_date": "2026-01-28"}', now() - interval '2 days', now() - interval '2 days'), +(10, 'USER', 'Cannot access course after subscription renewal', 'I renewed my subscription but I still cannot access premium courses. It says my subscription is inactive.', 'subscription', 'in_progress', '{"plan": "Premium Annual", "renewal_date": "2026-02-01"}', now() - interval '1 day', now() - interval '12 hours') +ON CONFLICT DO NOTHING; diff --git a/db/data/006_notifications_seed.sql b/db/data/006_notifications_seed.sql new file mode 100644 index 0000000..a770ae5 --- /dev/null +++ b/db/data/006_notifications_seed.sql @@ -0,0 +1,26 @@ +INSERT INTO notifications (user_id, type, level, channel, title, message, payload, is_read, created_at) VALUES +-- Student (user_id=10) notifications +(10, 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "Algebra Fundamentals" has been added. Check it out!', '{"course_title": "Algebra Fundamentals", "category": "Mathematics"}', false, now() - interval '30 days'), +(10, 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "English Grammar 101" has been added. Check it out!', '{"course_title": "English Grammar 101", "category": "Language"}', false, now() - interval '25 days'), +(10, 'sub_course_created', 'info', 'in_app', 'New Content Available', 'A new sub-course "Linear Equations" has been added.', '{"sub_course_title": "Linear Equations", "course": "Algebra Fundamentals"}', false, now() - interval '24 days'), +(10, 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Introduction to Variables" has been added.', '{"video_title": "Introduction to Variables", "sub_course": "Linear Equations"}', false, now() - interval '23 days'), +(10, 'payment_verified', 'success', 'in_app', 'Payment Successful', 'Your payment has been verified successfully. Your subscription is now active.', '{"plan": "Premium Monthly", "amount": 500}', true, now() - interval '20 days'), +(10, 'subscription_activated', 'success', 'in_app', 'Subscription Activated', 'Your Premium Monthly subscription is now active until March 20, 2026.', '{"plan": "Premium Monthly", "expires": "2026-03-20"}', true, now() - interval '20 days'), +(10, 'knowledge_level_update', 'info', 'in_app', 'Knowledge Level Updated', 'Your knowledge level has been updated to: Intermediate', '{"previous_level": "Beginner", "new_level": "Intermediate"}', false, now() - interval '15 days'), +(10, 'issue_status_updated', 'info', 'in_app', 'Issue Status Updated', 'Your issue "Video not loading on mobile" has been updated to: in_progress', '{"issue_id": 1, "subject": "Video not loading on mobile", "status": "in_progress"}', true, now() - interval '12 days'), +(10, 'issue_status_updated', 'success', 'in_app', 'Issue Status Updated', 'Your issue "Cannot change profile picture" has been updated to: resolved', '{"issue_id": 3, "subject": "Cannot change profile picture", "status": "resolved"}', true, now() - interval '10 days'), +(10, 'course_enrolled', 'success', 'in_app', 'Course Enrolled', 'You have been enrolled in "Biology 101".', '{"course_title": "Biology 101"}', false, now() - interval '8 days'), +(10, 'assessment_assigned', 'info', 'in_app', 'New Assessment Available', 'A new assessment is available for "Algebra Fundamentals".', '{"course": "Algebra Fundamentals", "assessment_type": "quiz"}', false, now() - interval '5 days'), +(10, 'announcement', 'info', 'in_app', 'Platform Maintenance', 'Scheduled maintenance on Feb 15, 2026 from 2:00 AM - 4:00 AM EAT.', '{"scheduled_at": "2026-02-15T02:00:00+03:00", "duration_hours": 2}', false, now() - interval '2 days'), +(10, 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Solving Quadratic Equations" has been added.', '{"video_title": "Solving Quadratic Equations", "sub_course": "Quadratics"}', false, now() - interval '1 day'), + +-- Admin (user_id=12) notifications +(12, 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Video not loading on mobile" has been reported.', '{"issue_id": 1, "subject": "Video not loading on mobile", "reporter_id": 10}', false, now() - interval '14 days'), +(12, 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Payment confirmation not received" has been reported.', '{"issue_id": 2, "subject": "Payment confirmation not received", "reporter_id": 10}', false, now() - interval '10 days'), +(12, 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Quiz results not saving" has been reported.', '{"issue_id": 5, "subject": "Quiz results not saving", "reporter_id": 10}', false, now() - interval '5 days'), +(12, 'user_deleted', 'warning', 'in_app', 'User Deleted', 'User ID 99 has been deleted.', '{"deleted_user_id": 99, "deleted_by": 12}', true, now() - interval '18 days'), +(12, 'admin_created', 'info', 'in_app', 'New Admin Created', 'A new admin account has been created for admin@yimaru.com.', '{"admin_email": "admin@yimaru.com"}', true, now() - interval '28 days'), +(12, 'team_member_created', 'info', 'in_app', 'New Team Member', 'A new team member has been added.', '{"member_email": "support@yimaru.com", "role": "support"}', true, now() - interval '26 days'), +(12, 'system_alert', 'warning', 'in_app', 'High Error Rate Detected', 'The notification delivery failure rate exceeded 5% in the last hour.', '{"failure_rate": 5.2, "window": "1h"}', false, now() - interval '3 days'), +(12, 'announcement', 'info', 'in_app', 'New Student Registrations', '15 new students registered this week.', '{"count": 15, "period": "weekly"}', false, now() - interval '1 day') +ON CONFLICT DO NOTHING; diff --git a/db/migrations/000015_notification_types.down.sql b/db/migrations/000015_notification_types.down.sql new file mode 100644 index 0000000..9782f57 --- /dev/null +++ b/db/migrations/000015_notification_types.down.sql @@ -0,0 +1,19 @@ +ALTER TABLE notifications + DROP CONSTRAINT IF EXISTS notifications_type_check; + +ALTER TABLE notifications + ADD CONSTRAINT notifications_type_check CHECK ( + type IN ( + 'course_enrolled', + 'lesson_completed', + 'assessment_assigned', + 'assessment_submitted', + 'assessment_graded', + 'course_completed', + 'certificate_issued', + 'announcement', + 'otp_sent', + 'signup_welcome', + 'system_alert' + ) + ); diff --git a/db/migrations/000015_notification_types.up.sql b/db/migrations/000015_notification_types.up.sql new file mode 100644 index 0000000..9d6017c --- /dev/null +++ b/db/migrations/000015_notification_types.up.sql @@ -0,0 +1,31 @@ +-- Drop the existing CHECK constraint on notifications.type and replace with an expanded one +ALTER TABLE notifications + DROP CONSTRAINT IF EXISTS notifications_type_check; + +ALTER TABLE notifications + ADD CONSTRAINT notifications_type_check CHECK ( + type IN ( + 'course_enrolled', + 'lesson_completed', + 'assessment_assigned', + 'assessment_submitted', + 'assessment_graded', + 'course_completed', + 'certificate_issued', + 'announcement', + 'otp_sent', + 'signup_welcome', + 'system_alert', + 'knowledge_level_update', + 'payment_verified', + 'subscription_activated', + 'course_created', + 'sub_course_created', + 'video_added', + 'issue_status_updated', + 'issue_created', + 'admin_created', + 'team_member_created', + 'user_deleted' + ) + ); diff --git a/db/query/notification.sql b/db/query/notification.sql index 2c8b9b4..cdea088 100644 --- a/db/query/notification.sql +++ b/db/query/notification.sql @@ -67,6 +67,20 @@ SET is_read = TRUE, WHERE user_id = $1 AND is_read = FALSE; +-- name: MarkNotificationAsUnread :one +UPDATE notifications +SET is_read = FALSE, + read_at = NULL +WHERE id = $1 +RETURNING *; + +-- name: MarkAllUserNotificationsAsUnread :exec +UPDATE notifications +SET is_read = FALSE, + read_at = NULL +WHERE user_id = $1 + AND is_read = TRUE; + -- name: DeleteUserNotifications :exec DELETE FROM notifications WHERE user_id = $1; diff --git a/gen/db/notification.sql.go b/gen/db/notification.sql.go index c6a3a71..3534171 100644 --- a/gen/db/notification.sql.go +++ b/gen/db/notification.sql.go @@ -248,6 +248,19 @@ func (q *Queries) MarkAllUserNotificationsAsRead(ctx context.Context, userID int return err } +const MarkAllUserNotificationsAsUnread = `-- name: MarkAllUserNotificationsAsUnread :exec +UPDATE notifications +SET is_read = FALSE, + read_at = NULL +WHERE user_id = $1 + AND is_read = TRUE +` + +func (q *Queries) MarkAllUserNotificationsAsUnread(ctx context.Context, userID int64) error { + _, err := q.db.Exec(ctx, MarkAllUserNotificationsAsUnread, userID) + return err +} + const MarkNotificationAsRead = `-- name: MarkNotificationAsRead :one UPDATE notifications SET is_read = TRUE, @@ -274,3 +287,30 @@ func (q *Queries) MarkNotificationAsRead(ctx context.Context, id int64) (Notific ) return i, err } + +const MarkNotificationAsUnread = `-- name: MarkNotificationAsUnread :one +UPDATE notifications +SET is_read = FALSE, + read_at = NULL +WHERE id = $1 +RETURNING id, user_id, type, level, channel, title, message, payload, is_read, created_at, read_at +` + +func (q *Queries) MarkNotificationAsUnread(ctx context.Context, id int64) (Notification, error) { + row := q.db.QueryRow(ctx, MarkNotificationAsUnread, id) + var i Notification + err := row.Scan( + &i.ID, + &i.UserID, + &i.Type, + &i.Level, + &i.Channel, + &i.Title, + &i.Message, + &i.Payload, + &i.IsRead, + &i.CreatedAt, + &i.ReadAt, + ) + return i, err +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go index bba1c0a..d5a5a14 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -15,6 +15,16 @@ type DeliveryChannel string const ( NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE NotificationType = "knowledge_level_update" + NOTIFICATION_TYPE_PAYMENT_VERIFIED NotificationType = "payment_verified" + NOTIFICATION_TYPE_SUBSCRIPTION_ACTIVATED NotificationType = "subscription_activated" + NOTIFICATION_TYPE_COURSE_CREATED NotificationType = "course_created" + NOTIFICATION_TYPE_SUB_COURSE_CREATED NotificationType = "sub_course_created" + NOTIFICATION_TYPE_VIDEO_ADDED NotificationType = "video_added" + NOTIFICATION_TYPE_ISSUE_STATUS_UPDATED NotificationType = "issue_status_updated" + NOTIFICATION_TYPE_ISSUE_CREATED NotificationType = "issue_created" + NOTIFICATION_TYPE_ADMIN_CREATED NotificationType = "admin_created" + NOTIFICATION_TYPE_TEAM_MEMBER_CREATED NotificationType = "team_member_created" + NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted" NotificationRecieverSideAdmin NotificationRecieverSide = "admin" NotificationRecieverSideCustomer NotificationRecieverSide = "customer" diff --git a/internal/ports/notification.go b/internal/ports/notification.go index 80b8840..90c3b0d 100644 --- a/internal/ports/notification.go +++ b/internal/ports/notification.go @@ -8,13 +8,13 @@ import ( type NotificationStore interface { GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error) - // ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) // New method CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) - // GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error) CreateNotification(ctx context.Context, notification *domain.Notification) (*domain.Notification, error) - // UpdateNotificationStatus(ctx context.Context, id, status string, isRead bool, metadata []byte) (*domain.Notification, error) - // ListFailedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) - // DeleteOldNotifications(ctx context.Context) error + MarkNotificationAsRead(ctx context.Context, id int64) (*domain.Notification, error) + MarkAllUserNotificationsAsRead(ctx context.Context, userID int64) error + MarkNotificationAsUnread(ctx context.Context, id int64) (*domain.Notification, error) + MarkAllUserNotificationsAsUnread(ctx context.Context, userID int64) error + DeleteUserNotifications(ctx context.Context, userID int64) error } diff --git a/internal/repository/notification.go b/internal/repository/notification.go index 6e06c13..a83cf1a 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -129,6 +129,26 @@ func (r *Store) MarkAllUserNotificationsAsRead( return r.queries.MarkAllUserNotificationsAsRead(ctx, userID) } +func (r *Store) MarkNotificationAsUnread( + ctx context.Context, + id int64, +) (*domain.Notification, error) { + + dbNotif, err := r.queries.MarkNotificationAsUnread(ctx, id) + if err != nil { + return nil, err + } + + return mapDBToDomain(&dbNotif), nil +} + +func (r *Store) MarkAllUserNotificationsAsUnread( + ctx context.Context, + userID int64, +) error { + return r.queries.MarkAllUserNotificationsAsUnread(ctx, userID) +} + /* ========================= Delete ========================= */ @@ -161,7 +181,7 @@ func mapDBToDomain(db *dbgen.Notification) *domain.Notification { Type: domain.NotificationType(db.Type), Level: domain.NotificationLevel(db.Level), DeliveryChannel: channel, - DeliveryStatus: "PENDING", + DeliveryStatus: domain.DeliveryStatusPending, Payload: domain.NotificationPayload{ Headline: payload.Headline, Message: payload.Message, diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go index d48bc2a..d870798 100644 --- a/internal/services/notification/service.go +++ b/internal/services/notification/service.go @@ -457,14 +457,15 @@ func (s *Service) handleNotification(notification *domain.Notification) { notification.DeliveryStatus = domain.DeliveryStatusSent } + case domain.DeliveryChannelInApp: + notification.DeliveryStatus = domain.DeliveryStatusSent + default: - if notification.DeliveryChannel != domain.DeliveryChannelInApp { - s.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", - zap.String("channel", string(notification.DeliveryChannel)), - zap.Time("timestamp", time.Now()), - ) - notification.DeliveryStatus = domain.DeliveryStatusFailed - } + s.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", + zap.String("channel", string(notification.DeliveryChannel)), + zap.Time("timestamp", time.Now()), + ) + notification.DeliveryStatus = domain.DeliveryStatusFailed } // if _, err := s.store.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { @@ -697,6 +698,71 @@ func (s *Service) CountUnreadNotifications(ctx context.Context, recipient_id int return s.store.CountUnreadNotifications(ctx, recipient_id) } +func (s *Service) MarkNotificationAsRead(ctx context.Context, id int64) (*domain.Notification, error) { + notification, err := s.store.MarkNotificationAsRead(ctx, id) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.MarkNotificationAsRead] Failed to mark notification as read", + zap.Int64("notificationID", id), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return nil, err + } + return notification, nil +} + +func (s *Service) MarkAllUserNotificationsAsRead(ctx context.Context, userID int64) error { + err := s.store.MarkAllUserNotificationsAsRead(ctx, userID) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.MarkAllUserNotificationsAsRead] Failed to mark all notifications as read", + zap.Int64("userID", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + return nil +} + +func (s *Service) MarkNotificationAsUnread(ctx context.Context, id int64) (*domain.Notification, error) { + notification, err := s.store.MarkNotificationAsUnread(ctx, id) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.MarkNotificationAsUnread] Failed to mark notification as unread", + zap.Int64("notificationID", id), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return nil, err + } + return notification, nil +} + +func (s *Service) MarkAllUserNotificationsAsUnread(ctx context.Context, userID int64) error { + err := s.store.MarkAllUserNotificationsAsUnread(ctx, userID) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.MarkAllUserNotificationsAsUnread] Failed to mark all notifications as unread", + zap.Int64("userID", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + return nil +} + +func (s *Service) DeleteUserNotifications(ctx context.Context, userID int64) error { + err := s.store.DeleteUserNotifications(ctx, userID) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.DeleteUserNotifications] Failed to delete user notifications", + zap.Int64("userID", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + return nil +} + // func (s *Service) DeleteOldNotifications(ctx context.Context) error { // return s.store.DeleteOldNotifications(ctx) // } diff --git a/internal/web_server/handlers/admin.go b/internal/web_server/handlers/admin.go index e74d39b..b74bc11 100644 --- a/internal/web_server/handlers/admin.go +++ b/internal/web_server/handlers/admin.go @@ -136,6 +136,8 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"email": req.Email, "admin_id": newUser.ID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionAdminCreated, domain.ResourceAdmin, &newUser.ID, "Created admin: "+req.Email, meta, &ip, &ua) + h.sendInAppNotification(newUser.ID, domain.NOTIFICATION_TYPE_ADMIN_CREATED, "Welcome to Yimaru", "Your admin account has been created successfully.") + return response.WriteJSON(c, fiber.StatusOK, "Admin created successfully", nil, nil) } diff --git a/internal/web_server/handlers/arifpay.go b/internal/web_server/handlers/arifpay.go index 6a05c76..810e2d9 100644 --- a/internal/web_server/handlers/arifpay.go +++ b/internal/web_server/handlers/arifpay.go @@ -113,6 +113,10 @@ func (h *Handler) VerifyPayment(c *fiber.Ctx) error { }) } + if payment.Status == string(domain.PaymentStatusSuccess) { + h.sendInAppNotification(payment.UserID, domain.NOTIFICATION_TYPE_PAYMENT_VERIFIED, "Payment Successful", "Your payment has been verified successfully. Your subscription is now active.") + } + return c.JSON(domain.Response{ Message: "Payment status retrieved", Data: paymentToRes(payment), diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 82ed0a3..e63eca8 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -320,6 +320,16 @@ func (h *Handler) CreateCourse(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"title": course.Title, "category_id": course.CategoryID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseCreated, domain.ResourceCourse, &course.ID, "Created course: "+course.Title, meta, &ip, &ua) + go func() { + students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) + if err != nil { + return + } + for _, s := range students { + h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_COURSE_CREATED, "New Course Available", "A new course \""+course.Title+"\" has been added. Check it out!") + } + }() + return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Course created successfully", Data: courseRes{ @@ -604,6 +614,16 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"title": subCourse.Title, "course_id": subCourse.CourseID, "level": subCourse.Level}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseCreated, domain.ResourceSubCourse, &subCourse.ID, "Created sub-course: "+subCourse.Title, meta, &ip, &ua) + go func() { + students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) + if err != nil { + return + } + for _, s := range students { + h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_SUB_COURSE_CREATED, "New Content Available", "A new sub-course \""+subCourse.Title+"\" has been added.") + } + }() + return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Sub-course created successfully", Data: subCourseRes{ @@ -1038,6 +1058,16 @@ func (h *Handler) CreateSubCourseVideo(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": video.SubCourseID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Created video: "+video.Title, meta, &ip, &ua) + go func() { + students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) + if err != nil { + return + } + for _, s := range students { + h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_VIDEO_ADDED, "New Video Available", "A new video \""+req.Title+"\" has been added.") + } + }() + var publishDate *string if video.PublishDate != nil { pd := video.PublishDate.String() diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index aff4357..bdefc28 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -1,7 +1,10 @@ package handlers import ( + "context" + "Yimaru-Backend/internal/config" + "Yimaru-Backend/internal/domain" activitylogservice "Yimaru-Backend/internal/services/activity_log" "Yimaru-Backend/internal/services/arifpay" issuereporting "Yimaru-Backend/internal/services/issue_reporting" @@ -96,3 +99,21 @@ func New( mongoLoggerSvc: mongoLoggerSvc, } } + +func (h *Handler) sendInAppNotification(recipientID int64, notifType domain.NotificationType, headline, message string) { + go func() { + notification := &domain.Notification{ + RecipientID: recipientID, + Type: notifType, + Level: domain.NotificationLevelInfo, + DeliveryChannel: domain.DeliveryChannelInApp, + DeliveryStatus: domain.DeliveryStatusPending, + IsRead: false, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + } + _ = h.notificationSvc.SendNotification(context.Background(), notification) + }() +} diff --git a/internal/web_server/handlers/issue_reporting.go b/internal/web_server/handlers/issue_reporting.go index ea3924b..be761ad 100644 --- a/internal/web_server/handlers/issue_reporting.go +++ b/internal/web_server/handlers/issue_reporting.go @@ -111,6 +111,16 @@ func (h *Handler) CreateIssue(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"subject": issue.Subject, "issue_type": string(issue.IssueType)}) go h.activityLogSvc.RecordAction(context.Background(), &userID, &actorRole, domain.ActionIssueCreated, domain.ResourceIssue, &issue.ID, "Reported issue: "+issue.Subject, meta, &ip, &ua) + go func() { + admins, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleAdmin)}) + if err != nil { + return + } + for _, admin := range admins { + h.sendInAppNotification(admin.ID, domain.NOTIFICATION_TYPE_ISSUE_CREATED, "New Issue Reported", "A new issue \""+issue.Subject+"\" has been reported.") + } + }() + return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Issue reported successfully", Data: mapIssueToRes(issue), @@ -352,6 +362,14 @@ func (h *Handler) UpdateIssueStatus(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"issue_id": id, "new_status": req.Status}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionIssueStatusUpdated, domain.ResourceIssue, &id, fmt.Sprintf("Updated issue %d status to %s", id, req.Status), meta, &ip, &ua) + go func() { + issue, err := h.issueReportingSvc.GetIssueByID(context.Background(), id) + if err != nil { + return + } + h.sendInAppNotification(issue.UserID, domain.NOTIFICATION_TYPE_ISSUE_STATUS_UPDATED, "Issue Status Updated", fmt.Sprintf("Your issue \"%s\" has been updated to: %s", issue.Subject, req.Status)) + }() + return c.JSON(domain.Response{ Message: "Issue status updated successfully", Success: true, diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 78c8868..a1677a9 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -3,6 +3,7 @@ package handlers import ( "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/web_server/ws" + "bufio" "context" "encoding/json" "fmt" @@ -12,38 +13,10 @@ import ( "time" "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/adaptor" "github.com/gorilla/websocket" - "github.com/valyala/fasthttp/fasthttpadaptor" "go.uber.org/zap" ) -func hijackHTTP(c *fiber.Ctx) (net.Conn, http.ResponseWriter, error) { - var rw http.ResponseWriter - var conn net.Conn - - // This is a trick: fasthttpadaptor gives us the HTTP interfaces - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hj, ok := w.(http.Hijacker) - if !ok { - return - } - var err error - conn, _, err = hj.Hijack() - if err != nil { - return - } - rw = w - }) - - fasthttpadaptor.NewFastHTTPHandler(handler)(c.Context()) - - if conn == nil || rw == nil { - return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection") - } - return conn, rw, nil -} - func (h *Handler) ConnectSocket(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(int64) if !ok || userID == 0 { @@ -55,120 +28,246 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") } - // Convert *fiber.Ctx to *http.Request - req, err := adaptor.ConvertRequest(c, false) - if err != nil { - h.mongoLoggerSvc.Error("Failed to convert socket request", - zap.Int64("userID", userID), - zap.Int("status_code", fiber.StatusInternalServerError), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert request") - } + // Hijack the underlying net.Conn from fasthttp + done := make(chan struct{}) + c.Context().HijackSetNoResponse(true) + c.Context().Hijack(func(netConn net.Conn) { + defer close(done) - // Create a net.Conn hijacked from the fasthttp context - netConn, rw, err := hijackHTTP(c) - if err != nil { - h.mongoLoggerSvc.Error("Failed to hijack connection", - zap.Int64("userID", userID), - zap.Int("status_code", fiber.StatusInternalServerError), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection:"+err.Error()) - } - - // Upgrade the connection using Gorilla's Upgrader - conn, err := ws.Upgrader.Upgrade(rw, req, nil) - if err != nil { - h.mongoLoggerSvc.Error("WebSocket upgrade failed", - zap.Int64("userID", userID), - zap.Int("status_code", fiber.StatusInternalServerError), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - netConn.Close() - return fiber.NewError(fiber.StatusInternalServerError, "WebSocket upgrade failed:"+err.Error()) - } - - client := &ws.Client{ - Conn: conn, - RecipientID: userID, - } - - h.notificationSvc.Hub.Register <- client - // h.logger.Info("WebSocket connection established", "userID", userID) - - defer func() { - h.notificationSvc.Hub.Unregister <- client - conn.Close() - }() - - for { - _, _, err := conn.ReadMessage() - if err != nil { - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - h.mongoLoggerSvc.Info("WebSocket closed normally", - zap.Int64("userID", userID), - zap.Int("status_code", fiber.StatusBadRequest), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - } else { - h.mongoLoggerSvc.Info("Unexpected WebSocket closure", - zap.Int64("userID", userID), - zap.Int("status_code", fiber.StatusBadRequest), - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - } - break + // Build a minimal http.Request for gorilla's upgrader + stdReq := &http.Request{ + Method: http.MethodGet, + Header: make(http.Header), } - } + c.Context().Request.Header.VisitAll(func(key, value []byte) { + stdReq.Header.Set(string(key), string(value)) + }) + stdReq.Host = string(c.Context().Host()) + stdReq.RequestURI = string(c.Context().RequestURI()) + // Create a hijackable response writer around the raw connection + hjRW := &hijackResponseWriter{ + conn: netConn, + brw: bufio.NewReadWriter(bufio.NewReader(netConn), bufio.NewWriter(netConn)), + h: make(http.Header), + } + + wsConn, err := ws.Upgrader.Upgrade(hjRW, stdReq, nil) + if err != nil { + h.mongoLoggerSvc.Error("WebSocket upgrade failed", + zap.Int64("userID", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + netConn.Close() + return + } + + client := &ws.Client{ + Conn: wsConn, + RecipientID: userID, + } + + h.notificationSvc.Hub.Register <- client + defer func() { + h.notificationSvc.Hub.Unregister <- client + wsConn.Close() + }() + + h.mongoLoggerSvc.Info("WebSocket connection established", + zap.Int64("userID", userID), + zap.Time("timestamp", time.Now()), + ) + + for { + _, _, err := wsConn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + h.mongoLoggerSvc.Info("WebSocket closed normally", + zap.Int64("userID", userID), + zap.Time("timestamp", time.Now()), + ) + } else { + h.mongoLoggerSvc.Info("Unexpected WebSocket closure", + zap.Int64("userID", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + } + break + } + } + }) + + <-done return nil } -// func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { -// type Request struct { -// NotificationIDs []string `json:"notification_ids" validate:"required"` -// } +// hijackResponseWriter implements http.ResponseWriter and http.Hijacker +// so gorilla/websocket can upgrade over a raw net.Conn. +type hijackResponseWriter struct { + conn net.Conn + brw *bufio.ReadWriter + h http.Header +} -// var req Request -// if err := c.BodyParser(&req); err != nil { -// h.mongoLoggerSvc.Info("Failed to parse request body", -// zap.Int("status_code", fiber.StatusBadRequest), -// zap.Error(err), -// zap.Time("timestamp", time.Now()), -// ) -// return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") -// } +func (w *hijackResponseWriter) Header() http.Header { return w.h } +func (w *hijackResponseWriter) WriteHeader(statusCode int) {} +func (w *hijackResponseWriter) Write(b []byte) (int, error) { return w.conn.Write(b) } +func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return w.conn, w.brw, nil +} -// userID, ok := c.Locals("user_id").(int64) -// if !ok || userID == 0 { -// h.mongoLoggerSvc.Error("Invalid user ID in context", -// zap.Int64("userID", userID), -// zap.Int("status_code", fiber.StatusInternalServerError), -// zap.Time("timestamp", time.Now()), -// ) -// return fiber.NewError(fiber.StatusInternalServerError, "invalid user ID in context") -// } +func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid notification ID", + Error: err.Error(), + }) + } -// fmt.Printf("Notification IDs: %v \n", req.NotificationIDs) -// if err := h.notificationSvc.MarkAsRead(context.Background(), req.NotificationIDs, userID); err != nil { -// h.mongoLoggerSvc.Error("Failed to mark notifications as read", -// zap.String("notificationID", strings.Join(req.NotificationIDs, ",")), -// zap.Int64("userID", userID), -// zap.Int("status_code", fiber.StatusInternalServerError), -// zap.Error(err), -// zap.Time("timestamp", time.Now()), -// ) -// return fiber.NewError(fiber.StatusInternalServerError, "Failed to update notification status:", err.Error()) -// } + notification, err := h.notificationSvc.MarkNotificationAsRead(context.Background(), id) + if err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.MarkNotificationAsRead] Failed to mark notification as read", + zap.Int64("notificationID", id), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to mark notification as read", + Error: err.Error(), + }) + } -// return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Notification marked as read"}) -// } + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Notification marked as read", + Success: true, + StatusCode: fiber.StatusOK, + Data: notification, + }) +} + +func (h *Handler) MarkAllNotificationsAsRead(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Invalid user identification", + Error: "User ID not found in context", + }) + } + + if err := h.notificationSvc.MarkAllUserNotificationsAsRead(context.Background(), userID); err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.MarkAllNotificationsAsRead] Failed to mark all notifications as read", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to mark all notifications as read", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "All notifications marked as read", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +func (h *Handler) MarkNotificationAsUnread(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid notification ID", + Error: err.Error(), + }) + } + + notification, err := h.notificationSvc.MarkNotificationAsUnread(context.Background(), id) + if err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.MarkNotificationAsUnread] Failed to mark notification as unread", + zap.Int64("notificationID", id), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to mark notification as unread", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Notification marked as unread", + Success: true, + StatusCode: fiber.StatusOK, + Data: notification, + }) +} + +func (h *Handler) MarkAllNotificationsAsUnread(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Invalid user identification", + Error: "User ID not found in context", + }) + } + + if err := h.notificationSvc.MarkAllUserNotificationsAsUnread(context.Background(), userID); err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.MarkAllNotificationsAsUnread] Failed to mark all notifications as unread", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to mark all notifications as unread", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "All notifications marked as unread", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +func (h *Handler) DeleteUserNotifications(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Invalid user identification", + Error: "User ID not found in context", + }) + } + + if err := h.notificationSvc.DeleteUserNotifications(context.Background(), userID); err != nil { + h.mongoLoggerSvc.Error("[NotificationHandler.DeleteUserNotifications] Failed to delete notifications", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete notifications", + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + Message: "Notifications deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { type Request struct { diff --git a/internal/web_server/handlers/team_handler.go b/internal/web_server/handlers/team_handler.go index bbaa021..b61e6a4 100644 --- a/internal/web_server/handlers/team_handler.go +++ b/internal/web_server/handlers/team_handler.go @@ -245,6 +245,8 @@ func (h *Handler) CreateTeamMember(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"email": member.Email, "team_role": string(member.TeamRole)}) go h.activityLogSvc.RecordAction(context.Background(), &creatorID, &actorRole, domain.ActionTeamMemberCreated, domain.ResourceTeamMember, &member.ID, "Created team member: "+member.Email, meta, &ip, &ua) + h.sendInAppNotification(member.ID, domain.NOTIFICATION_TYPE_TEAM_MEMBER_CREATED, "Welcome to the Team", "You have been added as a team member.") + return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Team member created successfully", Data: toTeamMemberResponse(&member), diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index b479020..8abc9cb 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -197,6 +197,8 @@ func (h *Handler) UpdateUserKnowledgeLevel(c *fiber.Ctx) error { }) } + h.sendInAppNotification(userID, domain.NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE, "Knowledge Level Updated", "Your knowledge level has been updated to: "+req.KnowledgeLevel) + return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "User knowledge level updated successfully", }) @@ -1609,6 +1611,18 @@ func (h *Handler) DeleteUser(c *fiber.Ctx) error { meta, _ := json.Marshal(map[string]interface{}{"deleted_user_id": userID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionUserDeleted, domain.ResourceUser, &userID, fmt.Sprintf("Deleted user ID: %d", userID), meta, &ip, &ua) + go func() { + admins, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleAdmin)}) + if err != nil { + return + } + for _, admin := range admins { + if admin.ID != actorID { + h.sendInAppNotification(admin.ID, domain.NOTIFICATION_TYPE_USER_DELETED, "User Deleted", fmt.Sprintf("User ID %d has been deleted.", userID)) + } + } + }() + return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil) } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 1dc2dd4..0df25fc 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -330,7 +330,11 @@ func (a *App) initAppRoutes() { groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket) groupV1.Get("/notifications", a.authMiddleware, h.GetUserNotification) groupV1.Get("/notifications/all", a.authMiddleware, h.GetAllNotifications) - // groupV1.Post("/notifications/mark-as-read", a.authMiddleware, h.MarkNotificationAsRead) + groupV1.Patch("/notifications/:id/read", a.authMiddleware, h.MarkNotificationAsRead) + groupV1.Post("/notifications/mark-all-read", a.authMiddleware, h.MarkAllNotificationsAsRead) + groupV1.Patch("/notifications/:id/unread", a.authMiddleware, h.MarkNotificationAsUnread) + groupV1.Post("/notifications/mark-all-unread", a.authMiddleware, h.MarkAllNotificationsAsUnread) + groupV1.Delete("/notifications", a.authMiddleware, h.DeleteUserNotifications) groupV1.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications) groupV1.Post("/notifications/create", a.authMiddleware, h.CreateAndSendNotification)