inapp notification fix

This commit is contained in:
Yared Yemane 2026-02-13 06:59:14 -08:00
parent 0f44e63692
commit 7d626d059f
19 changed files with 580 additions and 146 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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'
)
);

View File

@ -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'
)
);

View File

@ -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;

View File

@ -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
}

View File

@ -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"

View File

@ -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
}

View File

@ -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,

View File

@ -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)
// }

View File

@ -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)
}

View File

@ -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),

View File

@ -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()

View File

@ -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)
}()
}

View File

@ -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,

View File

@ -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 {

View File

@ -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),

View File

@ -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)
}

View File

@ -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)