inapp notification fix
This commit is contained in:
parent
0f44e63692
commit
7d626d059f
14
db/data/005_issue_reporting_seed.sql
Normal file
14
db/data/005_issue_reporting_seed.sql
Normal 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;
|
||||||
26
db/data/006_notifications_seed.sql
Normal file
26
db/data/006_notifications_seed.sql
Normal 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;
|
||||||
19
db/migrations/000015_notification_types.down.sql
Normal file
19
db/migrations/000015_notification_types.down.sql
Normal 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'
|
||||||
|
)
|
||||||
|
);
|
||||||
31
db/migrations/000015_notification_types.up.sql
Normal file
31
db/migrations/000015_notification_types.up.sql
Normal 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'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -67,6 +67,20 @@ SET is_read = TRUE,
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND is_read = FALSE;
|
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
|
-- name: DeleteUserNotifications :exec
|
||||||
DELETE FROM notifications
|
DELETE FROM notifications
|
||||||
WHERE user_id = $1;
|
WHERE user_id = $1;
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,19 @@ func (q *Queries) MarkAllUserNotificationsAsRead(ctx context.Context, userID int
|
||||||
return err
|
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
|
const MarkNotificationAsRead = `-- name: MarkNotificationAsRead :one
|
||||||
UPDATE notifications
|
UPDATE notifications
|
||||||
SET is_read = TRUE,
|
SET is_read = TRUE,
|
||||||
|
|
@ -274,3 +287,30 @@ func (q *Queries) MarkNotificationAsRead(ctx context.Context, id int64) (Notific
|
||||||
)
|
)
|
||||||
return i, err
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,16 @@ type DeliveryChannel string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NOTIFICATION_TYPE_KNOWLEDGE_LEVEL_UPDATE NotificationType = "knowledge_level_update"
|
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"
|
NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
|
||||||
NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
|
NotificationRecieverSideCustomer NotificationRecieverSide = "customer"
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import (
|
||||||
|
|
||||||
type NotificationStore interface {
|
type NotificationStore interface {
|
||||||
GetUserNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, int64, error)
|
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)
|
CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error)
|
||||||
GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, 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)
|
CreateNotification(ctx context.Context, notification *domain.Notification) (*domain.Notification, error)
|
||||||
// UpdateNotificationStatus(ctx context.Context, id, status string, isRead bool, metadata []byte) (*domain.Notification, error)
|
MarkNotificationAsRead(ctx context.Context, id int64) (*domain.Notification, error)
|
||||||
// ListFailedNotifications(ctx context.Context, limit int) ([]domain.Notification, error)
|
MarkAllUserNotificationsAsRead(ctx context.Context, userID int64) error
|
||||||
// DeleteOldNotifications(ctx context.Context) error
|
MarkNotificationAsUnread(ctx context.Context, id int64) (*domain.Notification, error)
|
||||||
|
MarkAllUserNotificationsAsUnread(ctx context.Context, userID int64) error
|
||||||
|
DeleteUserNotifications(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,26 @@ func (r *Store) MarkAllUserNotificationsAsRead(
|
||||||
return r.queries.MarkAllUserNotificationsAsRead(ctx, userID)
|
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
|
Delete
|
||||||
========================= */
|
========================= */
|
||||||
|
|
@ -161,7 +181,7 @@ func mapDBToDomain(db *dbgen.Notification) *domain.Notification {
|
||||||
Type: domain.NotificationType(db.Type),
|
Type: domain.NotificationType(db.Type),
|
||||||
Level: domain.NotificationLevel(db.Level),
|
Level: domain.NotificationLevel(db.Level),
|
||||||
DeliveryChannel: channel,
|
DeliveryChannel: channel,
|
||||||
DeliveryStatus: "PENDING",
|
DeliveryStatus: domain.DeliveryStatusPending,
|
||||||
Payload: domain.NotificationPayload{
|
Payload: domain.NotificationPayload{
|
||||||
Headline: payload.Headline,
|
Headline: payload.Headline,
|
||||||
Message: payload.Message,
|
Message: payload.Message,
|
||||||
|
|
|
||||||
|
|
@ -457,14 +457,15 @@ func (s *Service) handleNotification(notification *domain.Notification) {
|
||||||
notification.DeliveryStatus = domain.DeliveryStatusSent
|
notification.DeliveryStatus = domain.DeliveryStatusSent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case domain.DeliveryChannelInApp:
|
||||||
|
notification.DeliveryStatus = domain.DeliveryStatusSent
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if notification.DeliveryChannel != domain.DeliveryChannelInApp {
|
s.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel",
|
||||||
s.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel",
|
zap.String("channel", string(notification.DeliveryChannel)),
|
||||||
zap.String("channel", string(notification.DeliveryChannel)),
|
zap.Time("timestamp", time.Now()),
|
||||||
zap.Time("timestamp", time.Now()),
|
)
|
||||||
)
|
notification.DeliveryStatus = domain.DeliveryStatusFailed
|
||||||
notification.DeliveryStatus = domain.DeliveryStatusFailed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if _, err := s.store.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil {
|
// 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)
|
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 {
|
// func (s *Service) DeleteOldNotifications(ctx context.Context) error {
|
||||||
// return s.store.DeleteOldNotifications(ctx)
|
// return s.store.DeleteOldNotifications(ctx)
|
||||||
// }
|
// }
|
||||||
|
|
|
||||||
|
|
@ -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})
|
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)
|
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)
|
return response.WriteJSON(c, fiber.StatusOK, "Admin created successfully", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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{
|
return c.JSON(domain.Response{
|
||||||
Message: "Payment status retrieved",
|
Message: "Payment status retrieved",
|
||||||
Data: paymentToRes(payment),
|
Data: paymentToRes(payment),
|
||||||
|
|
|
||||||
|
|
@ -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})
|
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 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{
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||||
Message: "Course created successfully",
|
Message: "Course created successfully",
|
||||||
Data: courseRes{
|
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})
|
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 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{
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||||
Message: "Sub-course created successfully",
|
Message: "Sub-course created successfully",
|
||||||
Data: subCourseRes{
|
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})
|
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 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
|
var publishDate *string
|
||||||
if video.PublishDate != nil {
|
if video.PublishDate != nil {
|
||||||
pd := video.PublishDate.String()
|
pd := video.PublishDate.String()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"Yimaru-Backend/internal/config"
|
"Yimaru-Backend/internal/config"
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
activitylogservice "Yimaru-Backend/internal/services/activity_log"
|
||||||
"Yimaru-Backend/internal/services/arifpay"
|
"Yimaru-Backend/internal/services/arifpay"
|
||||||
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
|
||||||
|
|
@ -96,3 +99,21 @@ func New(
|
||||||
mongoLoggerSvc: mongoLoggerSvc,
|
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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)})
|
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 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{
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||||
Message: "Issue reported successfully",
|
Message: "Issue reported successfully",
|
||||||
Data: mapIssueToRes(issue),
|
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})
|
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 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{
|
return c.JSON(domain.Response{
|
||||||
Message: "Issue status updated successfully",
|
Message: "Issue status updated successfully",
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"Yimaru-Backend/internal/web_server/ws"
|
"Yimaru-Backend/internal/web_server/ws"
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -12,38 +13,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/valyala/fasthttp/fasthttpadaptor"
|
|
||||||
"go.uber.org/zap"
|
"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 {
|
func (h *Handler) ConnectSocket(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("userID").(int64)
|
userID, ok := c.Locals("userID").(int64)
|
||||||
if !ok || userID == 0 {
|
if !ok || userID == 0 {
|
||||||
|
|
@ -55,120 +28,246 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error {
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
|
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert *fiber.Ctx to *http.Request
|
// Hijack the underlying net.Conn from fasthttp
|
||||||
req, err := adaptor.ConvertRequest(c, false)
|
done := make(chan struct{})
|
||||||
if err != nil {
|
c.Context().HijackSetNoResponse(true)
|
||||||
h.mongoLoggerSvc.Error("Failed to convert socket request",
|
c.Context().Hijack(func(netConn net.Conn) {
|
||||||
zap.Int64("userID", userID),
|
defer close(done)
|
||||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
||||||
zap.Error(err),
|
|
||||||
zap.Time("timestamp", time.Now()),
|
|
||||||
)
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert request")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a net.Conn hijacked from the fasthttp context
|
// Build a minimal http.Request for gorilla's upgrader
|
||||||
netConn, rw, err := hijackHTTP(c)
|
stdReq := &http.Request{
|
||||||
if err != nil {
|
Method: http.MethodGet,
|
||||||
h.mongoLoggerSvc.Error("Failed to hijack connection",
|
Header: make(http.Header),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error {
|
// hijackResponseWriter implements http.ResponseWriter and http.Hijacker
|
||||||
// type Request struct {
|
// so gorilla/websocket can upgrade over a raw net.Conn.
|
||||||
// NotificationIDs []string `json:"notification_ids" validate:"required"`
|
type hijackResponseWriter struct {
|
||||||
// }
|
conn net.Conn
|
||||||
|
brw *bufio.ReadWriter
|
||||||
|
h http.Header
|
||||||
|
}
|
||||||
|
|
||||||
// var req Request
|
func (w *hijackResponseWriter) Header() http.Header { return w.h }
|
||||||
// if err := c.BodyParser(&req); err != nil {
|
func (w *hijackResponseWriter) WriteHeader(statusCode int) {}
|
||||||
// h.mongoLoggerSvc.Info("Failed to parse request body",
|
func (w *hijackResponseWriter) Write(b []byte) (int, error) { return w.conn.Write(b) }
|
||||||
// zap.Int("status_code", fiber.StatusBadRequest),
|
func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
// zap.Error(err),
|
return w.conn, w.brw, nil
|
||||||
// zap.Time("timestamp", time.Now()),
|
}
|
||||||
// )
|
|
||||||
// return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// userID, ok := c.Locals("user_id").(int64)
|
func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error {
|
||||||
// if !ok || userID == 0 {
|
idStr := c.Params("id")
|
||||||
// h.mongoLoggerSvc.Error("Invalid user ID in context",
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
// zap.Int64("userID", userID),
|
if err != nil {
|
||||||
// zap.Int("status_code", fiber.StatusInternalServerError),
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
// zap.Time("timestamp", time.Now()),
|
Message: "Invalid notification ID",
|
||||||
// )
|
Error: err.Error(),
|
||||||
// return fiber.NewError(fiber.StatusInternalServerError, "invalid user ID in context")
|
})
|
||||||
// }
|
}
|
||||||
|
|
||||||
// fmt.Printf("Notification IDs: %v \n", req.NotificationIDs)
|
notification, err := h.notificationSvc.MarkNotificationAsRead(context.Background(), id)
|
||||||
// if err := h.notificationSvc.MarkAsRead(context.Background(), req.NotificationIDs, userID); err != nil {
|
if err != nil {
|
||||||
// h.mongoLoggerSvc.Error("Failed to mark notifications as read",
|
h.mongoLoggerSvc.Error("[NotificationHandler.MarkNotificationAsRead] Failed to mark notification as read",
|
||||||
// zap.String("notificationID", strings.Join(req.NotificationIDs, ",")),
|
zap.Int64("notificationID", id),
|
||||||
// zap.Int64("userID", userID),
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||||
// zap.Int("status_code", fiber.StatusInternalServerError),
|
zap.Error(err),
|
||||||
// zap.Error(err),
|
zap.Time("timestamp", time.Now()),
|
||||||
// zap.Time("timestamp", time.Now()),
|
)
|
||||||
// )
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
// return fiber.NewError(fiber.StatusInternalServerError, "Failed to update notification status:", err.Error())
|
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 {
|
func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error {
|
||||||
type Request struct {
|
type Request struct {
|
||||||
|
|
|
||||||
|
|
@ -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)})
|
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)
|
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{
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||||
Message: "Team member created successfully",
|
Message: "Team member created successfully",
|
||||||
Data: toTeamMemberResponse(&member),
|
Data: toTeamMemberResponse(&member),
|
||||||
|
|
|
||||||
|
|
@ -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{
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
Message: "User knowledge level updated successfully",
|
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})
|
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 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)
|
return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,11 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket)
|
groupV1.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket)
|
||||||
groupV1.Get("/notifications", a.authMiddleware, h.GetUserNotification)
|
groupV1.Get("/notifications", a.authMiddleware, h.GetUserNotification)
|
||||||
groupV1.Get("/notifications/all", a.authMiddleware, h.GetAllNotifications)
|
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.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications)
|
||||||
groupV1.Post("/notifications/create", a.authMiddleware, h.CreateAndSendNotification)
|
groupV1.Post("/notifications/create", a.authMiddleware, h.CreateAndSendNotification)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user