feat: Add comprehensive notification system with email support
- Create notification management system with VNotifications class - Build notification API endpoints (get, mark read, delete, preferences) - Implement notification bell UI widget with real-time polling - Add email queue system with digest support (instant/hourly/daily/weekly) - Create notification trigger helpers for common events - Add fine-grained user preference controls - Implement cron script for email processing and cleanup - Document setup and usage in docs/NOTIFICATIONS.md Features: - In-app notifications with dropdown - Email delivery with batching - 6 notification types (comment, like, subscribe, upload, mention, system) - User preference controls per notification type - Lightweight polling (30s interval) - Auto-cleanup of old notifications (90 days) - Production-ready with proper error handling
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
/**
|
||||
* Notification Trigger Helpers
|
||||
* Convenience functions for triggering notifications from app events
|
||||
*/
|
||||
|
||||
require_once 'class.notifications.php';
|
||||
|
||||
class VNotificationTriggers {
|
||||
private $notifications;
|
||||
|
||||
public function __construct() {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
/**
|
||||
* Notification Management Class
|
||||
* Handles creation, retrieval, and delivery of user notifications
|
||||
*/
|
||||
|
||||
class VNotifications {
|
||||
private $db;
|
||||
private $logger;
|
||||
|
||||
public function __construct() {
|
||||
global $class_database;
|
||||
$this->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><body style='font-family: Arial, sans-serif; line-height: 1.6;'>";
|
||||
$html .= "<h2 style='color: #007bff;'>$title</h2>";
|
||||
|
||||
if ($message) {
|
||||
$html .= "<p>" . nl2br(htmlspecialchars($message)) . "</p>";
|
||||
}
|
||||
|
||||
if ($link) {
|
||||
$base_url = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$full_link = "http://$base_url$link";
|
||||
$html .= "<p><a href='$full_link' style='color: #007bff; text-decoration: none; font-weight: bold;'>View on EasyStream →</a></p>";
|
||||
}
|
||||
|
||||
$html .= "<hr style='border: none; border-top: 1px solid #ddd; margin: 20px 0;'>";
|
||||
$html .= "<p style='font-size: 12px; color: #666;'>You received this email because you have notifications enabled on EasyStream. <a href='http://$base_url/settings#notifications'>Manage your notification preferences</a>.</p>";
|
||||
$html .= "</body></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 <noreply@easystream.com>\r\n";
|
||||
$headers .= "Reply-To: noreply@easystream.com\r\n";
|
||||
$headers .= "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||
|
||||
return mail($email['email'], $email['subject'], $email['body'], $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old notifications (called by cron)
|
||||
*/
|
||||
public function cleanup($days = 90) {
|
||||
$sql = "DELETE FROM db_notifications
|
||||
WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)
|
||||
AND is_read = 1";
|
||||
|
||||
$result = $this->db->execute($sql, [$days]);
|
||||
|
||||
$deleted = $this->db->affectedRows();
|
||||
$this->logger->info("Cleaned up $deleted old read notifications (older than $days days)");
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user