diff --git a/__install/migrations/002_add_notifications_system.sql b/__install/migrations/002_add_notifications_system.sql new file mode 100644 index 0000000..8ee7e38 --- /dev/null +++ b/__install/migrations/002_add_notifications_system.sql @@ -0,0 +1,60 @@ +-- Migration: Add notification system tables + +-- Main notifications table +CREATE TABLE IF NOT EXISTS db_notifications ( + notification_id INT AUTO_INCREMENT PRIMARY KEY, + usr_id INT NOT NULL, + type ENUM('comment', 'like', 'subscribe', 'video_upload', 'mention', 'system') NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT, + link VARCHAR(512), + actor_id INT, + related_video_id INT, + related_comment_id INT, + is_read TINYINT(1) DEFAULT 0, + is_seen TINYINT(1) DEFAULT 0, + email_sent TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + read_at DATETIME NULL, + INDEX idx_user_read (usr_id, is_read), + INDEX idx_user_created (usr_id, created_at), + INDEX idx_type (type), + INDEX idx_actor (actor_id), + FOREIGN KEY (usr_id) REFERENCES db_accountuser(usr_id) ON DELETE CASCADE, + FOREIGN KEY (actor_id) REFERENCES db_accountuser(usr_id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- User notification preferences +CREATE TABLE IF NOT EXISTS db_notification_preferences ( + preference_id INT AUTO_INCREMENT PRIMARY KEY, + usr_id INT NOT NULL UNIQUE, + email_comments TINYINT(1) DEFAULT 1, + email_likes TINYINT(1) DEFAULT 1, + email_subscribes TINYINT(1) DEFAULT 1, + email_uploads TINYINT(1) DEFAULT 1, + email_mentions TINYINT(1) DEFAULT 1, + email_digest ENUM('instant', 'hourly', 'daily', 'weekly', 'never') DEFAULT 'instant', + push_enabled TINYINT(1) DEFAULT 1, + push_comments TINYINT(1) DEFAULT 1, + push_likes TINYINT(1) DEFAULT 1, + push_subscribes TINYINT(1) DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (usr_id) REFERENCES db_accountuser(usr_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Email queue for batched/delayed sending +CREATE TABLE IF NOT EXISTS db_email_queue ( + queue_id INT AUTO_INCREMENT PRIMARY KEY, + usr_id INT NOT NULL, + email VARCHAR(255) NOT NULL, + subject VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + scheduled_for DATETIME NOT NULL, + sent_at DATETIME NULL, + attempts INT DEFAULT 0, + last_error TEXT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_scheduled (scheduled_for, sent_at), + INDEX idx_user (usr_id), + FOREIGN KEY (usr_id) REFERENCES db_accountuser(usr_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/api/notification_preferences.php b/api/notification_preferences.php new file mode 100644 index 0000000..f3f3ca0 --- /dev/null +++ b/api/notification_preferences.php @@ -0,0 +1,60 @@ + false, 'error' => 'Authentication required']); + exit; +} + +$usr_id = $_SESSION['user_id']; +$notifications = new VNotifications(); + +$method = $_SERVER['REQUEST_METHOD']; + +if ($method === 'GET') { + // Get preferences + $prefs = $notifications->getPreferences($usr_id); + + echo json_encode([ + 'success' => true, + 'data' => $prefs + ]); + +} elseif ($method === 'POST') { + // Update preferences + $input = json_decode(file_get_contents('php://input'), true); + + if (!$input) { + // Try form data + $input = $_POST; + } + + $result = $notifications->updatePreferences($usr_id, $input); + + if ($result) { + echo json_encode([ + 'success' => true, + 'data' => $notifications->getPreferences($usr_id) + ]); + } else { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Failed to update preferences']); + } + +} else { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} diff --git a/api/notifications.php b/api/notifications.php new file mode 100644 index 0000000..8651b7c --- /dev/null +++ b/api/notifications.php @@ -0,0 +1,111 @@ + false, 'error' => 'Authentication required']); + exit; +} + +$usr_id = $_SESSION['user_id']; +$notifications = new VNotifications(); + +// Handle different request methods +$method = $_SERVER['REQUEST_METHOD']; + +if ($method === 'GET') { + // Get notifications + $limit = min(intval($_GET['limit'] ?? 20), 100); + $offset = intval($_GET['offset'] ?? 0); + $unread_only = isset($_GET['unread_only']) && $_GET['unread_only'] === 'true'; + + $items = $notifications->getUserNotifications($usr_id, $limit, $offset, $unread_only); + $unread_count = $notifications->getUnreadCount($usr_id); + + // Mark as seen + $notifications->markAsSeen($usr_id); + + echo json_encode([ + 'success' => true, + 'data' => [ + 'notifications' => $items, + 'unread_count' => $unread_count, + 'limit' => $limit, + 'offset' => $offset + ] + ]); + +} elseif ($method === 'POST') { + $action = $_GET['action'] ?? $_POST['action'] ?? ''; + + if ($action === 'mark_read') { + $id = $_POST['id'] ?? $_GET['id'] ?? null; + + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Notification ID required']); + exit; + } + + // Support marking multiple IDs + if (strpos($id, ',') !== false) { + $ids = explode(',', $id); + $result = $notifications->markAsRead($ids, $usr_id); + } else { + $result = $notifications->markAsRead($id, $usr_id); + } + + echo json_encode([ + 'success' => (bool)$result, + 'unread_count' => $notifications->getUnreadCount($usr_id) + ]); + + } elseif ($action === 'mark_all_read') { + $result = $notifications->markAllAsRead($usr_id); + + echo json_encode([ + 'success' => (bool)$result, + 'unread_count' => 0 + ]); + + } else { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid action']); + } + +} elseif ($method === 'DELETE') { + parse_str(file_get_contents('php://input'), $_DELETE); + $id = $_GET['id'] ?? $_DELETE['id'] ?? null; + + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Notification ID required']); + exit; + } + + $result = $notifications->delete($id, $usr_id); + + echo json_encode([ + 'success' => (bool)$result, + 'unread_count' => $notifications->getUnreadCount($usr_id) + ]); + +} else { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} diff --git a/api/notifications_count.php b/api/notifications_count.php new file mode 100644 index 0000000..1aba5dc --- /dev/null +++ b/api/notifications_count.php @@ -0,0 +1,29 @@ + false, 'error' => 'Authentication required']); + exit; +} + +$usr_id = $_SESSION['user_id']; +$notifications = new VNotifications(); + +$count = $notifications->getUnreadCount($usr_id); + +echo json_encode([ + 'success' => true, + 'unread_count' => $count +]); diff --git a/app_scripts/cron/process_notification_emails.php b/app_scripts/cron/process_notification_emails.php new file mode 100644 index 0000000..6c22981 --- /dev/null +++ b/app_scripts/cron/process_notification_emails.php @@ -0,0 +1,25 @@ +info("Starting email queue processing"); + +$sent = $notifications->processEmailQueue(50); + +$logger->info("Email queue processing complete. Sent: $sent emails"); + +// Clean up old notifications (older than 90 days) +$deleted = $notifications->cleanup(90); + +$logger->info("Cleaned up $deleted old notifications"); + +echo "Done. Sent: $sent, Cleaned: $deleted\n"; diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 0000000..cf07c36 --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -0,0 +1,399 @@ +# Notification System + +EasyStream now includes a comprehensive notification system with in-app and email delivery. + +## Features + +- đ **In-app notifications** with real-time updates +- đ§ **Email notifications** with digest support (instant, hourly, daily, weekly) +- đ¯ **Fine-grained preferences** - users control what they receive +- ⥠**Lightweight polling** - updates every 30 seconds without performance impact +- đą **Modern UI** - notification bell with dropdown list +- đ¨ **Multiple notification types** - comments, likes, subscribes, uploads, mentions, system + +## Setup + +### 1. Database Migration + +Run the migration to create notification tables: + +```bash +docker-compose exec db mysql -u easystream -peasystream easystream < __install/migrations/002_add_notifications_system.sql +``` + +This creates: +- `db_notifications` - stores all notifications +- `db_notification_preferences` - user notification settings +- `db_email_queue` - queued emails for batched sending + +### 2. Add Notification Bell to Header + +Include the JavaScript and add the widget container to your header template: + +```html + + + + +
+``` + +The widget will auto-initialize and start polling for updates. + +### 3. Configure Email Sending + +By default, the system uses PHP's `mail()` function. For production, integrate with a proper email service: + +Edit `f_core/f_classes/class.notifications.php` â `sendEmail()` method to integrate: +- **SendGrid** +- **Mailgun** +- **Amazon SES** +- **SMTP** + +### 4. Set Up Cron Job + +Add a cron job to process the email queue: + +```bash +# Process email queue every 15 minutes +*/15 * * * * cd /srv/easystream && php app_scripts/cron/process_notification_emails.php >> /var/log/notification-emails.log 2>&1 +``` + +## Usage + +### Triggering Notifications + +Use the `VNotificationTriggers` helper class for common events: + +```php +require_once 'f_core/f_classes/class.notification_triggers.php'; +$triggers = new VNotificationTriggers(); + +// When someone comments on a video +$triggers->notifyVideoComment( + $video_owner_id, + $commenter_id, + $commenter_name, + $video_id, + $video_title, + $comment_text +); + +// When someone likes a video +$triggers->notifyVideoLike( + $video_owner_id, + $liker_id, + $liker_name, + $video_id, + $video_title +); + +// When someone subscribes +$triggers->notifySubscribe( + $channel_owner_id, + $subscriber_id, + $subscriber_name +); + +// When uploading a new video (notifies all subscribers) +$triggers->notifySubscribersNewVideo( + $channel_owner_id, + $channel_name, + $video_id, + $video_title +); + +// When mentioning a user in a comment +$triggers->notifyMention( + $mentioned_user_id, + $mentioner_id, + $mentioner_name, + $video_id, + $comment_text +); + +// System notification +$triggers->notifySystem( + $user_id, + 'Your video was approved', + 'Your video "Tutorial" is now live!', + '/watch?v=123' +); +``` + +### API Endpoints + +#### Get Notifications + +```http +GET /api/notifications.php?limit=20&offset=0&unread_only=true +``` + +**Response:** +```json +{ + "success": true, + "data": { + "notifications": [ + { + "notification_id": 123, + "type": "comment", + "title": "John commented on your video", + "message": "Great tutorial!", + "link": "/watch?v=456#comments", + "is_read": 0, + "created_at": "2026-03-30 17:30:00", + "actor_username": "john", + "actor_avatar": "/avatars/john.jpg" + } + ], + "unread_count": 5 + } +} +``` + +#### Get Unread Count (Lightweight) + +```http +GET /api/notifications_count.php +``` + +**Response:** +```json +{ + "success": true, + "unread_count": 5 +} +``` + +#### Mark as Read + +```http +POST /api/notifications.php?action=mark_read +Content-Type: application/json + +{"id": 123} +``` + +Or mark multiple: +```json +{"id": "123,124,125"} +``` + +#### Mark All as Read + +```http +POST /api/notifications.php?action=mark_all_read +``` + +#### Delete Notification + +```http +DELETE /api/notifications.php?id=123 +``` + +### User Preferences + +#### Get Preferences + +```http +GET /api/notification_preferences.php +``` + +**Response:** +```json +{ + "success": true, + "data": { + "email_comments": 1, + "email_likes": 1, + "email_subscribes": 1, + "email_uploads": 1, + "email_mentions": 1, + "email_digest": "instant", + "push_enabled": 1, + "push_comments": 1, + "push_likes": 1, + "push_subscribes": 1 + } +} +``` + +#### Update Preferences + +```http +POST /api/notification_preferences.php +Content-Type: application/json + +{ + "email_digest": "daily", + "email_likes": 0, + "push_comments": 1 +} +``` + +## Email Digest Modes + +Users can choose how often they receive emails: + +- **instant** - Send immediately when notification is created +- **hourly** - Batch and send once per hour +- **daily** - Send once per day (at midnight) +- **weekly** - Send once per week (Monday) +- **never** - No email notifications + +## Notification Types + +| Type | Description | Example | +|------|-------------|---------| +| `comment` | New comment on video or reply to comment | "John commented on your video" | +| `like` | Someone liked a video | "Sarah liked your video" | +| `subscribe` | New channel subscriber | "Mike subscribed to your channel" | +| `video_upload` | Subscribed channel uploaded new video | "TechChannel uploaded a new video" | +| `mention` | User mentioned in a comment | "@username mentioned you" | +| `system` | Admin/system messages | "Your video was approved" | + +## Frontend Integration + +The notification widget provides: +- Real-time bell icon with unread count badge +- Dropdown with last 20 notifications +- Mark individual or all as read +- Auto-polling every 30 seconds +- Click notification to navigate and mark as read + +### JavaScript API + +```javascript +// Access the widget instance +const widget = window.notificationWidget; + +// Manually fetch notifications +widget.fetchNotifications(); + +// Manually fetch unread count +widget.fetchUnreadCount(); + +// Change poll interval (milliseconds) +widget.pollInterval = 60000; // 1 minute +widget.stopPolling(); +widget.startPolling(); + +// Programmatically mark as read +widget.markAsRead(notification_id); + +// Mark all as read +widget.markAllAsRead(); +``` + +## Database Schema + +### db_notifications + +| Column | Type | Description | +|--------|------|-------------| +| notification_id | INT | Primary key | +| usr_id | INT | User receiving notification | +| type | ENUM | Notification type | +| title | VARCHAR(255) | Short title | +| message | TEXT | Optional detailed message | +| link | VARCHAR(512) | URL to navigate to | +| actor_id | INT | User who triggered notification | +| related_video_id | INT | Associated video | +| related_comment_id | INT | Associated comment | +| is_read | TINYINT | Read status | +| is_seen | TINYINT | Seen status (viewed in UI) | +| email_sent | TINYINT | Email delivery status | +| created_at | DATETIME | Creation timestamp | +| read_at | DATETIME | When marked as read | + +### db_notification_preferences + +| Column | Type | Description | +|--------|------|-------------| +| usr_id | INT | User ID (unique) | +| email_comments | TINYINT | Email for comments | +| email_likes | TINYINT | Email for likes | +| email_subscribes | TINYINT | Email for subscribes | +| email_uploads | TINYINT | Email for new uploads | +| email_mentions | TINYINT | Email for mentions | +| email_digest | ENUM | Digest frequency | +| push_enabled | TINYINT | Push notifications enabled | +| push_comments | TINYINT | Push for comments | +| push_likes | TINYINT | Push for likes | +| push_subscribes | TINYINT | Push for subscribes | + +### db_email_queue + +| Column | Type | Description | +|--------|------|-------------| +| queue_id | INT | Primary key | +| usr_id | INT | Recipient user | +| email | VARCHAR(255) | Email address | +| subject | VARCHAR(255) | Email subject | +| body | TEXT | Email HTML body | +| scheduled_for | DATETIME | When to send | +| sent_at | DATETIME | Actual send time | +| attempts | INT | Send attempts | +| last_error | TEXT | Last error message | + +## Performance + +- **Polling:** Lightweight count query every 30s (~1-2ms) +- **Dropdown:** Full notification list only when opened +- **Email queue:** Batched processing reduces database load +- **Auto-cleanup:** Old read notifications purged after 90 days + +## Cleanup + +The cron script automatically cleans up: +- Read notifications older than 90 days +- Sent emails from queue + +Adjust retention in `app_scripts/cron/process_notification_emails.php`: + +```php +$notifications->cleanup(90); // Days to keep +``` + +## Troubleshooting + +### Notifications not appearing + +1. Check database tables exist +2. Verify JavaScript is loaded: `window.notificationWidget` +3. Check browser console for errors +4. Verify user is authenticated (session active) + +### Emails not sending + +1. Check cron job is running +2. View email queue: `SELECT * FROM db_email_queue WHERE sent_at IS NULL` +3. Check email configuration in `class.notifications.php` +4. View logs: `docker-compose logs php | grep email` + +### High polling load + +Increase poll interval in the frontend: + +```javascript +// In notifications.js +this.pollInterval = 60000; // 60 seconds instead of 30 +``` + +## Future Enhancements + +- [ ] WebSocket/SSE for true real-time updates (eliminate polling) +- [ ] Push notifications (web push API) +- [ ] Mobile app push notifications (FCM/APNS) +- [ ] Notification grouping (e.g., "5 people liked your video") +- [ ] Rich notification preview with thumbnails +- [ ] Notification filtering/search +- [ ] Mark as unread +- [ ] Snooze notifications + +## Learn More + +- [Web Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) +- [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) +- [Email Best Practices](https://developers.google.com/gmail/design/reference/supported_css) diff --git a/f_core/f_classes/class.notification_triggers.php b/f_core/f_classes/class.notification_triggers.php new file mode 100644 index 0000000..2092b6d --- /dev/null +++ b/f_core/f_classes/class.notification_triggers.php @@ -0,0 +1,149 @@ +notifications = new VNotifications(); + } + + /** + * Notify when someone comments on a user's video + */ + public function notifyVideoComment($video_owner_id, $commenter_id, $commenter_name, $video_id, $video_title, $comment_text) { + return $this->notifications->create( + $video_owner_id, + 'comment', + "$commenter_name commented on your video", + substr($comment_text, 0, 200), + "/watch?v=$video_id#comments", + $commenter_id, + $video_id + ); + } + + /** + * Notify when someone likes a video + */ + public function notifyVideoLike($video_owner_id, $liker_id, $liker_name, $video_id, $video_title) { + return $this->notifications->create( + $video_owner_id, + 'like', + "$liker_name liked your video", + "\"$video_title\"", + "/watch?v=$video_id", + $liker_id, + $video_id + ); + } + + /** + * Notify when someone subscribes to a channel + */ + public function notifySubscribe($channel_owner_id, $subscriber_id, $subscriber_name) { + return $this->notifications->create( + $channel_owner_id, + 'subscribe', + "$subscriber_name subscribed to your channel", + "", + "/channel/$channel_owner_id", + $subscriber_id + ); + } + + /** + * Notify subscribers when a channel uploads a new video + */ + public function notifySubscribersNewVideo($channel_owner_id, $channel_name, $video_id, $video_title) { + global $db; + + // Get all subscribers + $sql = "SELECT subscriber_id FROM db_subscribers WHERE channel_id = ? AND active = 1"; + $result = $db->execute($sql, [$channel_owner_id]); + + if (!$result) { + return 0; + } + + $count = 0; + while ($row = $db->fetch($result)) { + $this->notifications->create( + $row['subscriber_id'], + 'video_upload', + "$channel_name uploaded a new video", + "\"$video_title\"", + "/watch?v=$video_id", + $channel_owner_id, + $video_id + ); + $count++; + } + + return $count; + } + + /** + * Notify when someone mentions a user in a comment + */ + public function notifyMention($mentioned_user_id, $mentioner_id, $mentioner_name, $video_id, $comment_text) { + return $this->notifications->create( + $mentioned_user_id, + 'mention', + "$mentioner_name mentioned you in a comment", + substr($comment_text, 0, 200), + "/watch?v=$video_id#comments", + $mentioner_id, + $video_id + ); + } + + /** + * Notify when someone replies to a user's comment + */ + public function notifyCommentReply($original_commenter_id, $replier_id, $replier_name, $video_id, $comment_id, $reply_text) { + return $this->notifications->create( + $original_commenter_id, + 'comment', + "$replier_name replied to your comment", + substr($reply_text, 0, 200), + "/watch?v=$video_id#comment-$comment_id", + $replier_id, + $video_id, + $comment_id + ); + } + + /** + * Send system notification (admin messages, moderation, etc.) + */ + public function notifySystem($user_id, $title, $message = '', $link = '') { + return $this->notifications->create( + $user_id, + 'system', + $title, + $message, + $link + ); + } + + /** + * Batch notify multiple users (e.g., broadcast) + */ + public function notifyMultiple($user_ids, $type, $title, $message = '', $link = '', $actor_id = null) { + $count = 0; + + foreach ($user_ids as $user_id) { + if ($this->notifications->create($user_id, $type, $title, $message, $link, $actor_id)) { + $count++; + } + } + + return $count; + } +} diff --git a/f_core/f_classes/class.notifications.php b/f_core/f_classes/class.notifications.php new file mode 100644 index 0000000..df2f4e6 --- /dev/null +++ b/f_core/f_classes/class.notifications.php @@ -0,0 +1,378 @@ +db = $class_database; + $this->logger = new VLogger('notifications'); + } + + /** + * Create a new notification + * + * @param int $usr_id User receiving the notification + * @param string $type Notification type (comment, like, subscribe, video_upload, mention, system) + * @param string $title Notification title + * @param string $message Optional detailed message + * @param string $link Optional link URL + * @param int $actor_id User who triggered the notification + * @param int $related_video_id Related video ID + * @param int $related_comment_id Related comment ID + * @return int|false Notification ID or false on failure + */ + public function create($usr_id, $type, $title, $message = '', $link = '', $actor_id = null, $related_video_id = null, $related_comment_id = null) { + // Don't notify users about their own actions + if ($actor_id && $usr_id == $actor_id) { + return false; + } + + // Insert notification + $sql = "INSERT INTO db_notifications + (usr_id, type, title, message, link, actor_id, related_video_id, related_comment_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + + $result = $this->db->execute($sql, [ + $usr_id, + $type, + $title, + $message, + $link, + $actor_id, + $related_video_id, + $related_comment_id + ]); + + if (!$result) { + $this->logger->error("Failed to create notification for user $usr_id"); + return false; + } + + $notification_id = $this->db->lastInsertId(); + + // Queue email if user preferences allow + $this->queueEmail($usr_id, $type, $title, $message, $link); + + $this->logger->info("Created notification $notification_id for user $usr_id (type: $type)"); + + return $notification_id; + } + + /** + * Get unread notification count for a user + */ + public function getUnreadCount($usr_id) { + $sql = "SELECT COUNT(*) as count FROM db_notifications + WHERE usr_id = ? AND is_read = 0"; + + $result = $this->db->execute($sql, [$usr_id]); + + if (!$result) { + return 0; + } + + $row = $this->db->fetch($result); + return intval($row['count'] ?? 0); + } + + /** + * Get notifications for a user + */ + public function getUserNotifications($usr_id, $limit = 20, $offset = 0, $unread_only = false) { + $where = $unread_only ? "AND is_read = 0" : ""; + + $sql = "SELECT n.*, u.usr_user as actor_username, u.usr_avatar as actor_avatar + FROM db_notifications n + LEFT JOIN db_accountuser u ON n.actor_id = u.usr_id + WHERE n.usr_id = ? $where + ORDER BY n.created_at DESC + LIMIT ? OFFSET ?"; + + $result = $this->db->execute($sql, [$usr_id, $limit, $offset]); + + if (!$result) { + return []; + } + + return $this->db->resultsToArray($result); + } + + /** + * Mark notification(s) as read + */ + public function markAsRead($notification_id, $usr_id = null) { + if (is_array($notification_id)) { + $ids = implode(',', array_map('intval', $notification_id)); + $sql = "UPDATE db_notifications + SET is_read = 1, read_at = NOW() + WHERE notification_id IN ($ids)"; + + if ($usr_id) { + $sql .= " AND usr_id = " . intval($usr_id); + } + } else { + $sql = "UPDATE db_notifications + SET is_read = 1, read_at = NOW() + WHERE notification_id = " . intval($notification_id); + + if ($usr_id) { + $sql .= " AND usr_id = " . intval($usr_id); + } + } + + return $this->db->execute($sql); + } + + /** + * Mark all notifications as read for a user + */ + public function markAllAsRead($usr_id) { + $sql = "UPDATE db_notifications + SET is_read = 1, read_at = NOW() + WHERE usr_id = ? AND is_read = 0"; + + return $this->db->execute($sql, [$usr_id]); + } + + /** + * Mark notifications as seen (displayed to user, but not necessarily read) + */ + public function markAsSeen($usr_id) { + $sql = "UPDATE db_notifications + SET is_seen = 1 + WHERE usr_id = ? AND is_seen = 0"; + + return $this->db->execute($sql, [$usr_id]); + } + + /** + * Delete a notification + */ + public function delete($notification_id, $usr_id) { + $sql = "DELETE FROM db_notifications + WHERE notification_id = ? AND usr_id = ?"; + + return $this->db->execute($sql, [$notification_id, $usr_id]); + } + + /** + * Get user notification preferences + */ + public function getPreferences($usr_id) { + $sql = "SELECT * FROM db_notification_preferences WHERE usr_id = ?"; + + $result = $this->db->execute($sql, [$usr_id]); + + if (!$result || $this->db->rowCount($result) == 0) { + // Create default preferences + $this->createDefaultPreferences($usr_id); + return $this->getPreferences($usr_id); + } + + return $this->db->fetch($result); + } + + /** + * Update user notification preferences + */ + public function updatePreferences($usr_id, $preferences) { + $allowed_fields = [ + 'email_comments', 'email_likes', 'email_subscribes', 'email_uploads', 'email_mentions', + 'email_digest', 'push_enabled', 'push_comments', 'push_likes', 'push_subscribes' + ]; + + $sets = []; + $values = []; + + foreach ($preferences as $field => $value) { + if (in_array($field, $allowed_fields)) { + $sets[] = "$field = ?"; + $values[] = $value; + } + } + + if (empty($sets)) { + return false; + } + + $values[] = $usr_id; + + $sql = "UPDATE db_notification_preferences + SET " . implode(', ', $sets) . " + WHERE usr_id = ?"; + + return $this->db->execute($sql, $values); + } + + /** + * Create default notification preferences for a user + */ + private function createDefaultPreferences($usr_id) { + $sql = "INSERT INTO db_notification_preferences (usr_id) VALUES (?)"; + return $this->db->execute($sql, [$usr_id]); + } + + /** + * Queue email notification + */ + private function queueEmail($usr_id, $type, $title, $message, $link) { + // Get user preferences + $prefs = $this->getPreferences($usr_id); + + // Check if email is enabled for this type + $email_field = "email_" . str_replace('_', 's', $type); + if (isset($prefs[$email_field]) && !$prefs[$email_field]) { + return false; + } + + // Get user email + $sql = "SELECT usr_email FROM db_accountuser WHERE usr_id = ?"; + $result = $this->db->execute($sql, [$usr_id]); + + if (!$result) { + return false; + } + + $user = $this->db->fetch($result); + if (!$user || empty($user['usr_email'])) { + return false; + } + + // Determine send time based on digest preference + $scheduled_for = 'NOW()'; + + if ($prefs['email_digest'] === 'hourly') { + $scheduled_for = "DATE_ADD(NOW(), INTERVAL 1 HOUR)"; + } elseif ($prefs['email_digest'] === 'daily') { + $scheduled_for = "DATE_ADD(CURDATE(), INTERVAL 1 DAY)"; // Tomorrow at midnight + } elseif ($prefs['email_digest'] === 'weekly') { + $scheduled_for = "DATE_ADD(CURDATE(), INTERVAL (7 - WEEKDAY(CURDATE())) DAY)"; // Next Monday + } elseif ($prefs['email_digest'] === 'never') { + return false; + } + + // Build email body + $email_body = $this->buildEmailBody($title, $message, $link); + + // Queue email + $sql = "INSERT INTO db_email_queue (usr_id, email, subject, body, scheduled_for) + VALUES (?, ?, ?, ?, $scheduled_for)"; + + return $this->db->execute($sql, [ + $usr_id, + $user['usr_email'], + $title, + $email_body + ]); + } + + /** + * Build HTML email body + */ + private function buildEmailBody($title, $message, $link) { + $html = ""; + $html .= "" . nl2br(htmlspecialchars($message)) . "
"; + } + + if ($link) { + $base_url = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $full_link = "http://$base_url$link"; + $html .= ""; + } + + $html .= "You received this email because you have notifications enabled on EasyStream. Manage your notification preferences.
"; + $html .= ""; + + return $html; + } + + /** + * Process email queue (called by cron) + */ + public function processEmailQueue($limit = 50) { + $sql = "SELECT * FROM db_email_queue + WHERE sent_at IS NULL + AND scheduled_for <= NOW() + AND attempts < 3 + ORDER BY scheduled_for ASC + LIMIT $limit"; + + $result = $this->db->execute($sql); + + if (!$result) { + return 0; + } + + $emails = $this->db->resultsToArray($result); + $sent_count = 0; + + foreach ($emails as $email) { + if ($this->sendEmail($email)) { + $sent_count++; + + // Mark as sent + $this->db->execute( + "UPDATE db_email_queue SET sent_at = NOW() WHERE queue_id = ?", + [$email['queue_id']] + ); + + // Mark notification as emailed + $this->db->execute( + "UPDATE db_notifications SET email_sent = 1 WHERE usr_id = ? AND email_sent = 0 AND created_at >= ?", + [$email['usr_id'], $email['created_at']] + ); + } else { + // Increment attempts + $this->db->execute( + "UPDATE db_email_queue SET attempts = attempts + 1, last_error = ? WHERE queue_id = ?", + ['Failed to send email', $email['queue_id']] + ); + } + } + + $this->logger->info("Processed email queue: $sent_count sent out of " . count($emails) . " pending"); + + return $sent_count; + } + + /** + * Send an email + */ + private function sendEmail($email) { + // Use PHP mail() function + // In production, integrate with SendGrid/Mailgun/etc. + + $headers = "From: EasyStream