docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Channels;
use App\Framework\Notification\ValueObjects\NotificationChannel;
/**
* Result of a channel delivery attempt
*/
final readonly class ChannelResult
{
public function __construct(
public NotificationChannel $channel,
public bool $success,
public ?string $errorMessage = null,
public array $metadata = []
) {
}
public static function success(
NotificationChannel $channel,
array $metadata = []
): self {
return new self(
channel: $channel,
success: true,
metadata: $metadata
);
}
public static function failure(
NotificationChannel $channel,
string $errorMessage,
array $metadata = []
): self {
return new self(
channel: $channel,
success: false,
errorMessage: $errorMessage,
metadata: $metadata
);
}
public function isSuccess(): bool
{
return $this->success;
}
public function isFailure(): bool
{
return !$this->success;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Channels;
use App\Framework\Notification\Notification;
use App\Framework\Notification\Storage\NotificationRepository;
use App\Framework\Notification\ValueObjects\NotificationChannel;
/**
* Database notification channel for in-app notifications
*
* Stores notifications in database for user inbox/notification center
*/
final readonly class DatabaseChannel implements NotificationChannelInterface
{
public function __construct(
private NotificationRepository $repository
) {
}
public function send(Notification $notification): ChannelResult
{
try {
// Mark as delivered when storing in database
$deliveredNotification = $notification->markAsDelivered();
// Persist to database
$this->repository->save($deliveredNotification);
return ChannelResult::success(
channel: NotificationChannel::DATABASE,
metadata: [
'notification_id' => $deliveredNotification->id->toString(),
'stored_at' => time(),
]
);
} catch (\Throwable $e) {
return ChannelResult::failure(
channel: NotificationChannel::DATABASE,
errorMessage: $e->getMessage()
);
}
}
public function supports(Notification $notification): bool
{
return $notification->supportsChannel(NotificationChannel::DATABASE);
}
public function getChannel(): NotificationChannel
{
return NotificationChannel::DATABASE;
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Channels;
use App\Domain\Common\ValueObject\Email;
use App\Framework\Mail\EmailList;
use App\Framework\Mail\MailerInterface;
use App\Framework\Mail\Message;
use App\Framework\Mail\Priority;
use App\Framework\Notification\Notification;
use App\Framework\Notification\ValueObjects\NotificationChannel;
use App\Framework\Notification\ValueObjects\NotificationPriority;
/**
* Email notification channel
*
* Sends notifications via email using the framework's Mail module
*/
final readonly class EmailChannel implements NotificationChannelInterface
{
public function __construct(
private MailerInterface $mailer,
private Email $fromAddress,
private UserEmailResolver $userEmailResolver
) {
}
public function send(Notification $notification): ChannelResult
{
try {
// Resolve recipient email address
$recipientEmail = $this->userEmailResolver->resolveEmail($notification->recipientId);
if ($recipientEmail === null) {
return ChannelResult::failure(
channel: NotificationChannel::EMAIL,
errorMessage: "Could not resolve email for user: {$notification->recipientId}"
);
}
// Build email message
$message = new Message(
from: $this->fromAddress,
subject: $notification->title,
body: $this->formatBodyAsText($notification),
htmlBody: $this->formatBodyAsHtml($notification),
priority: $this->mapPriority($notification->priority),
to: new EmailList($recipientEmail)
);
// Send via mailer
$result = $this->mailer->send($message);
if ($result->success) {
return ChannelResult::success(
channel: NotificationChannel::EMAIL,
metadata: ['message_id' => $result->messageId ?? null]
);
}
return ChannelResult::failure(
channel: NotificationChannel::EMAIL,
errorMessage: $result->error ?? 'Unknown email delivery error'
);
} catch (\Throwable $e) {
return ChannelResult::failure(
channel: NotificationChannel::EMAIL,
errorMessage: $e->getMessage()
);
}
}
public function supports(Notification $notification): bool
{
return $notification->supportsChannel(NotificationChannel::EMAIL);
}
public function getChannel(): NotificationChannel
{
return NotificationChannel::EMAIL;
}
private function formatBodyAsText(Notification $notification): string
{
$text = $notification->body . "\n\n";
if ($notification->hasAction()) {
$text .= "{$notification->actionLabel}: {$notification->actionUrl}\n";
}
$text .= "\n---\n";
$text .= "This is an automated notification. Please do not reply to this email.\n";
return $text;
}
private function formatBodyAsHtml(Notification $notification): string
{
$html = '<html><body>';
$html .= '<h2>' . htmlspecialchars($notification->title) . '</h2>';
$html .= '<p>' . nl2br(htmlspecialchars($notification->body)) . '</p>';
if ($notification->hasAction()) {
$html .= sprintf(
'<p><a href="%s" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">%s</a></p>',
htmlspecialchars($notification->actionUrl),
htmlspecialchars($notification->actionLabel)
);
}
$html .= '<hr>';
$html .= '<p style="color: #666; font-size: 12px;">This is an automated notification. Please do not reply to this email.</p>';
$html .= '</body></html>';
return $html;
}
private function mapPriority(NotificationPriority $priority): Priority
{
return match ($priority) {
NotificationPriority::LOW => Priority::LOW,
NotificationPriority::NORMAL => Priority::NORMAL,
NotificationPriority::HIGH => Priority::HIGH,
NotificationPriority::URGENT => Priority::HIGH,
};
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Channels;
use App\Framework\Notification\Notification;
use App\Framework\Notification\ValueObjects\NotificationChannel;
/**
* Interface for notification delivery channels
*
* Each channel implementation handles delivery via a specific medium
*/
interface NotificationChannelInterface
{
/**
* Send notification via this channel
*
* @param Notification $notification The notification to send
* @return ChannelResult Result of the delivery attempt
*/
public function send(Notification $notification): ChannelResult;
/**
* Check if this channel supports the given notification
*
* @param Notification $notification The notification to check
* @return bool True if this channel can deliver the notification
*/
public function supports(Notification $notification): bool;
/**
* Get the channel type identifier
*
* @return NotificationChannel The channel type
*/
public function getChannel(): NotificationChannel;
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Channels;
use App\Domain\Common\ValueObject\Email;
/**
* Interface for resolving user email addresses
*
* Implementation should be provided by the application layer
* to map user IDs to email addresses
*/
interface UserEmailResolver
{
/**
* Resolve email address for a user ID
*
* @param string $userId The user identifier
* @return Email|null The user's email address or null if not found
*/
public function resolveEmail(string $userId): ?Email;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Events;
use App\Framework\Notification\Notification;
use App\Framework\Notification\NotificationResult;
/**
* Event dispatched when a notification fails to send on all channels
*/
final readonly class NotificationFailed
{
public function __construct(
public Notification $notification,
public NotificationResult $result
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Events;
use App\Framework\Notification\Notification;
use App\Framework\Notification\NotificationResult;
/**
* Event dispatched when a notification is successfully sent
*/
final readonly class NotificationSent
{
public function __construct(
public Notification $notification,
public NotificationResult $result
) {
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Jobs;
use App\Framework\Notification\Notification;
use App\Framework\Notification\NotificationDispatcher;
/**
* Queue job for asynchronous notification sending
*/
final readonly class SendNotificationJob
{
public function __construct(
public Notification $notification
) {
}
/**
* Execute the job
*
* @param NotificationDispatcher $dispatcher Injected by container
* @return array Result metadata
*/
public function handle(NotificationDispatcher $dispatcher): array
{
$result = $dispatcher->sendNow($this->notification);
return [
'notification_id' => $this->notification->id->toString(),
'recipient_id' => $this->notification->recipient_id,
'success' => $result->isSuccess(),
'channels_succeeded' => count($result->getSuccessful()),
'channels_failed' => count($result->getFailed()),
'errors' => $result->getErrors(),
];
}
public function getType(): string
{
return 'notification.send';
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Blueprint;
use App\Framework\Database\Schema\Schema;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Create notifications table for in-app notifications
*/
final readonly class CreateNotificationsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->createIfNotExists('notifications', function (Blueprint $table) {
$table->string('id', 26)->primary();
$table->string('recipient_id', 100);
$table->string('type', 50);
$table->string('title', 255);
$table->text('body');
$table->json('data')->nullable();
$table->json('channels');
$table->string('priority', 20)->default('normal');
$table->string('status', 20)->default('pending');
$table->timestamp('created_at');
$table->timestamp('sent_at')->nullable();
$table->timestamp('read_at')->nullable();
$table->string('action_url')->nullable();
$table->string('action_label', 100)->nullable();
// Indexes
$table->index(['recipient_id']);
$table->index(['type']);
$table->index(['status']);
$table->index(['created_at']);
$table->index(['read_at']);
});
$schema->execute();
// Composite indexes for common queries
$connection->execute(SqlQuery::create('CREATE INDEX idx_notifications_recipient_status_created ON notifications(recipient_id, status, created_at)'));
$connection->execute(SqlQuery::create('CREATE INDEX idx_notifications_recipient_type ON notifications(recipient_id, type)'));
}
public function down(ConnectionInterface $connection): void
{
$schema = new Schema($connection);
$schema->dropIfExists('notifications');
$schema->execute();
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp("2025_10_02_000001");
}
public function getDescription(): string
{
return 'Create notifications table for in-app notification system';
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification;
use App\Framework\Notification\ValueObjects\NotificationChannel;
use App\Framework\Notification\ValueObjects\NotificationId;
use App\Framework\Notification\ValueObjects\NotificationPriority;
use App\Framework\Notification\ValueObjects\NotificationStatus;
use App\Framework\Notification\ValueObjects\NotificationType;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Core notification entity
*
* Immutable value object representing a notification
*/
final readonly class Notification
{
/**
* @param NotificationId $id Unique notification identifier
* @param string $recipientId User/Entity receiving the notification
* @param NotificationType $type Notification category
* @param string $title Notification title
* @param string $body Notification message body
* @param Timestamp $createdAt Creation timestamp
* @param array<string, mixed> $data Additional structured data
* @param array<NotificationChannel> $channels Delivery channels
* @param NotificationPriority $priority Delivery priority
* @param NotificationStatus $status Current status
* @param Timestamp|null $sentAt Delivery timestamp
* @param Timestamp|null $readAt Read timestamp
* @param string|null $actionUrl Optional action URL
* @param string|null $actionLabel Optional action button label
*/
public function __construct(
public NotificationId $id,
public string $recipientId,
public NotificationType $type,
public string $title,
public string $body,
public Timestamp $createdAt,
public array $data = [],
public array $channels = [],
public NotificationPriority $priority = NotificationPriority::NORMAL,
public NotificationStatus $status = NotificationStatus::PENDING,
public ?Timestamp $sentAt = null,
public ?Timestamp $readAt = null,
public ?string $actionUrl = null,
public ?string $actionLabel = null
) {
if (empty($recipientId)) {
throw new \InvalidArgumentException('Recipient ID cannot be empty');
}
if (empty($title)) {
throw new \InvalidArgumentException('Title cannot be empty');
}
if (empty($body)) {
throw new \InvalidArgumentException('Body cannot be empty');
}
if (empty($channels)) {
throw new \InvalidArgumentException('At least one delivery channel is required');
}
}
public static function create(
string $recipientId,
NotificationType $type,
string $title,
string $body,
NotificationChannel ...$channels
): self {
return new self(
id: NotificationId::generate(),
recipientId: $recipientId,
type: $type,
title: $title,
body: $body,
createdAt: Timestamp::now(),
data: [],
channels: $channels,
priority: NotificationPriority::NORMAL,
status: NotificationStatus::PENDING
);
}
public function withData(array $data): self
{
return new self(
id: $this->id,
recipientId: $this->recipientId,
type: $this->type,
title: $this->title,
body: $this->body,
createdAt: $this->createdAt,
data: [...$this->data, ...$data],
channels: $this->channels,
priority: $this->priority,
status: $this->status,
sentAt: $this->sentAt,
readAt: $this->readAt,
actionUrl: $this->actionUrl,
actionLabel: $this->actionLabel
);
}
public function withPriority(NotificationPriority $priority): self
{
return new self(
id: $this->id,
recipientId: $this->recipientId,
type: $this->type,
title: $this->title,
body: $this->body,
createdAt: $this->createdAt,
data: $this->data,
channels: $this->channels,
priority: $priority,
status: $this->status,
sentAt: $this->sentAt,
readAt: $this->readAt,
actionUrl: $this->actionUrl,
actionLabel: $this->actionLabel
);
}
public function withAction(string $url, string $label): self
{
return new self(
id: $this->id,
recipientId: $this->recipientId,
type: $this->type,
title: $this->title,
body: $this->body,
createdAt: $this->createdAt,
data: $this->data,
channels: $this->channels,
priority: $this->priority,
status: $this->status,
sentAt: $this->sentAt,
readAt: $this->readAt,
actionUrl: $url,
actionLabel: $label
);
}
public function markAsSent(): self
{
return new self(
id: $this->id,
recipientId: $this->recipientId,
type: $this->type,
title: $this->title,
body: $this->body,
createdAt: $this->createdAt,
data: $this->data,
channels: $this->channels,
priority: $this->priority,
status: NotificationStatus::SENT,
sentAt: Timestamp::now(),
readAt: $this->readAt,
actionUrl: $this->actionUrl,
actionLabel: $this->actionLabel
);
}
public function markAsDelivered(): self
{
return new self(
id: $this->id,
recipientId: $this->recipientId,
type: $this->type,
title: $this->title,
body: $this->body,
createdAt: $this->createdAt,
data: $this->data,
channels: $this->channels,
priority: $this->priority,
status: NotificationStatus::DELIVERED,
sentAt: $this->sentAt ?? Timestamp::now(),
readAt: $this->readAt,
actionUrl: $this->actionUrl,
actionLabel: $this->actionLabel
);
}
public function markAsRead(): self
{
return new self(
id: $this->id,
recipientId: $this->recipientId,
type: $this->type,
title: $this->title,
body: $this->body,
createdAt: $this->createdAt,
data: $this->data,
channels: $this->channels,
priority: $this->priority,
status: NotificationStatus::READ,
sentAt: $this->sentAt,
readAt: Timestamp::now(),
actionUrl: $this->actionUrl,
actionLabel: $this->actionLabel
);
}
public function markAsFailed(): self
{
return new self(
id: $this->id,
recipientId: $this->recipientId,
type: $this->type,
title: $this->title,
body: $this->body,
createdAt: $this->createdAt,
data: $this->data,
channels: $this->channels,
priority: $this->priority,
status: NotificationStatus::FAILED,
sentAt: $this->sentAt,
readAt: $this->readAt,
actionUrl: $this->actionUrl,
actionLabel: $this->actionLabel
);
}
public function isRead(): bool
{
return $this->status === NotificationStatus::READ;
}
public function hasAction(): bool
{
return $this->actionUrl !== null;
}
public function supportsChannel(NotificationChannel $channel): bool
{
foreach ($this->channels as $supportedChannel) {
if ($supportedChannel === $channel) {
return true;
}
}
return false;
}
public function toArray(): array
{
return [
'id' => $this->id->toString(),
'recipient_id' => $this->recipientId,
'type' => $this->type->toString(),
'title' => $this->title,
'body' => $this->body,
'data' => $this->data,
'channels' => array_map(fn($c) => $c->value, $this->channels),
'priority' => $this->priority->value,
'status' => $this->status->value,
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
'sent_at' => $this->sentAt?->format('Y-m-d H:i:s'),
'read_at' => $this->readAt?->format('Y-m-d H:i:s'),
'action_url' => $this->actionUrl,
'action_label' => $this->actionLabel,
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification;
use App\Framework\EventBus\EventBus;
use App\Framework\Notification\Channels\NotificationChannelInterface;
use App\Framework\Notification\Events\NotificationFailed;
use App\Framework\Notification\Events\NotificationSent;
use App\Framework\Notification\Jobs\SendNotificationJob;
use App\Framework\Notification\ValueObjects\NotificationChannel;
use App\Framework\Queue\Queue;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Queue\ValueObjects\QueuePriority;
/**
* Central notification dispatcher
*
* Handles routing notifications to appropriate channels and manages delivery
*/
final readonly class NotificationDispatcher
{
/**
* @param array<NotificationChannelInterface> $channels
*/
public function __construct(
private array $channels,
private Queue $queue,
private EventBus $eventBus
) {
}
/**
* Send notification synchronously
*
* @param Notification $notification The notification to send
* @return NotificationResult Result of the send operation
*/
public function sendNow(Notification $notification): NotificationResult
{
$results = [];
foreach ($notification->channels as $channelType) {
$channel = $this->getChannel($channelType);
if ($channel === null) {
$results[] = \App\Framework\Notification\Channels\ChannelResult::failure(
channel: $channelType,
errorMessage: "Channel not configured: {$channelType->value}"
);
continue;
}
if (!$channel->supports($notification)) {
$results[] = \App\Framework\Notification\Channels\ChannelResult::failure(
channel: $channelType,
errorMessage: "Channel does not support this notification"
);
continue;
}
$results[] = $channel->send($notification);
}
$result = new NotificationResult($notification, $results);
// Dispatch events
if ($result->isSuccess()) {
$this->eventBus->dispatch(new NotificationSent($notification, $result));
} else {
$this->eventBus->dispatch(new NotificationFailed($notification, $result));
}
return $result;
}
/**
* Queue notification for asynchronous delivery
*
* @param Notification $notification The notification to send
* @return void
*/
public function sendLater(Notification $notification): void
{
$job = new SendNotificationJob($notification);
$priority = match ($notification->priority) {
\App\Framework\Notification\ValueObjects\NotificationPriority::URGENT => QueuePriority::critical(),
\App\Framework\Notification\ValueObjects\NotificationPriority::HIGH => QueuePriority::high(),
\App\Framework\Notification\ValueObjects\NotificationPriority::NORMAL => QueuePriority::normal(),
\App\Framework\Notification\ValueObjects\NotificationPriority::LOW => QueuePriority::low(),
};
$payload = JobPayload::create($job, $priority);
$this->queue->push($payload);
}
/**
* Send notification with automatic queue/immediate decision
*
* @param Notification $notification The notification to send
* @param bool $async Whether to send asynchronously (default: true)
* @return NotificationResult|null Result if sent immediately, null if queued
*/
public function send(Notification $notification, bool $async = true): ?NotificationResult
{
if ($async) {
$this->sendLater($notification);
return null;
}
return $this->sendNow($notification);
}
/**
* Get channel implementation by type
*
* @param NotificationChannel $channelType The channel type
* @return NotificationChannelInterface|null The channel or null if not found
*/
private function getChannel(NotificationChannel $channelType): ?NotificationChannelInterface
{
foreach ($this->channels as $channel) {
if ($channel->getChannel() === $channelType) {
return $channel;
}
}
return null;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification;
use App\Framework\Notification\Channels\ChannelResult;
/**
* Result of a notification send operation across all channels
*/
final readonly class NotificationResult
{
/**
* @param Notification $notification The notification that was sent
* @param array<ChannelResult> $channelResults Results from each channel
*/
public function __construct(
public Notification $notification,
public array $channelResults
) {
}
/**
* Check if notification was successfully sent via at least one channel
*
* @return bool True if at least one channel succeeded
*/
public function isSuccess(): bool
{
foreach ($this->channelResults as $result) {
if ($result->isSuccess()) {
return true;
}
}
return false;
}
/**
* Check if notification failed on all channels
*
* @return bool True if all channels failed
*/
public function isFailure(): bool
{
return !$this->isSuccess();
}
/**
* Get successful channel results
*
* @return array<ChannelResult>
*/
public function getSuccessful(): array
{
return array_filter(
$this->channelResults,
fn(ChannelResult $result) => $result->isSuccess()
);
}
/**
* Get failed channel results
*
* @return array<ChannelResult>
*/
public function getFailed(): array
{
return array_filter(
$this->channelResults,
fn(ChannelResult $result) => $result->isFailure()
);
}
/**
* Get error messages from failed channels
*
* @return array<string>
*/
public function getErrors(): array
{
$errors = [];
foreach ($this->getFailed() as $result) {
if ($result->errorMessage !== null) {
$errors[] = "{$result->channel->value}: {$result->errorMessage}";
}
}
return $errors;
}
}

View File

@@ -0,0 +1,440 @@
# Notification System
Multi-channel notification system with support for email, database, push, SMS, and webhooks.
## Features
- **Multi-Channel Delivery**: Email, Database (in-app), Push, SMS, Webhook
- **Queue Integration**: Async delivery via framework Queue system
- **Event System**: NotificationSent and NotificationFailed events
- **Priority Levels**: LOW, NORMAL, HIGH, URGENT
- **Read Tracking**: Mark notifications as read, count unread
- **Action Buttons**: Optional action URL and label
- **Type Safety**: Fully typed with Value Objects and Enums
- **Framework Integration**: Uses existing Mail, Queue, EventBus, Database modules
## Basic Usage
### Creating and Sending a Notification
```php
use App\Framework\Notification\Notification;
use App\Framework\Notification\NotificationDispatcher;
use App\Framework\Notification\ValueObjects\NotificationChannel;
use App\Framework\Notification\ValueObjects\NotificationPriority;
use App\Framework\Notification\ValueObjects\NotificationType;
// Create notification
$notification = Notification::create(
recipientId: 'user-123',
type: NotificationType::system(),
title: 'System Update',
body: 'Your system has been updated to version 2.0',
NotificationChannel::DATABASE,
NotificationChannel::EMAIL
);
// Add optional features
$notification = $notification
->withPriority(NotificationPriority::HIGH)
->withAction('/changelog', 'View Changelog')
->withData([
'version' => '2.0.0',
'features' => ['performance', 'security']
]);
// Send asynchronously (via Queue)
$dispatcher->sendLater($notification);
// Or send immediately
$result = $dispatcher->sendNow($notification);
```
### Common Notification Types
```php
// System notifications
NotificationType::system()
// Security alerts
NotificationType::security()
// Marketing messages
NotificationType::marketing()
// Social interactions
NotificationType::social()
// Transactional emails
NotificationType::transactional()
// Custom types
NotificationType::fromString('order-status')
```
### Retrieving Notifications
```php
use App\Framework\Notification\Storage\NotificationRepository;
// Get user's notifications
$notifications = $repository->findByUser('user-123', limit: 20);
// Get unread notifications
$unread = $repository->findUnreadByUser('user-123');
// Count unread
$count = $repository->countUnreadByUser('user-123');
// Mark as read
$repository->markAsRead($notificationId);
// Mark all as read
$repository->markAllAsReadForUser('user-123');
```
## Channels
### Database Channel (In-App Notifications)
Stores notifications in database for user inbox/notification center.
```php
NotificationChannel::DATABASE
```
**Features**:
- Persistent storage
- Read/unread tracking
- Pagination support
- Automatic cleanup of old notifications
### Email Channel
Sends notifications via email using framework's Mail module.
```php
NotificationChannel::EMAIL
```
**Features**:
- HTML and plain text emails
- Priority mapping
- Action buttons in email
- Automatic HTML formatting
**Requirements**:
- UserEmailResolver implementation to map user IDs to email addresses
```php
class DatabaseUserEmailResolver implements UserEmailResolver
{
public function resolveEmail(string $userId): ?Email
{
// Lookup user email from database
return $this->userRepository->findEmailById($userId);
}
}
```
### Push Channel (Placeholder)
For web push and mobile push notifications.
```php
NotificationChannel::PUSH
```
**Status**: Interface defined, implementation needed.
### SMS Channel (Placeholder)
For SMS notifications via external provider.
```php
NotificationChannel::SMS
```
**Status**: Interface defined, implementation needed.
### Webhook Channel (Placeholder)
For sending notifications to external systems via HTTP webhooks.
```php
NotificationChannel::WEBHOOK
```
**Status**: Interface defined, implementation needed.
## Queue Integration
The notification system integrates seamlessly with the framework's Queue system for asynchronous delivery.
### Async Delivery
```php
// Queue for background processing
$dispatcher->sendLater($notification);
// Priority is mapped from notification priority:
// URGENT → HIGH
// HIGH → MEDIUM
// NORMAL → LOW
// LOW → LOW
```
### Immediate Delivery
```php
// Send immediately (blocks until complete)
$result = $dispatcher->sendNow($notification);
if ($result->isSuccess()) {
echo "Sent via: " . count($result->getSuccessful()) . " channels\n";
} else {
echo "Errors: " . implode(', ', $result->getErrors()) . "\n";
}
```
## Event System
The notification system dispatches events via the framework's EventBus.
### NotificationSent Event
Dispatched when a notification is successfully sent via at least one channel.
```php
use App\Framework\Notification\Events\NotificationSent;
use App\Framework\EventBus\Attributes\EventHandler;
#[EventHandler]
final class NotificationLogger
{
public function handleNotificationSent(NotificationSent $event): void
{
$this->logger->info('Notification sent', [
'notification_id' => $event->notification->id->toString(),
'recipient' => $event->notification->recipientId,
'channels' => count($event->result->getSuccessful())
]);
}
}
```
### NotificationFailed Event
Dispatched when a notification fails on all channels.
```php
use App\Framework\Notification\Events\NotificationFailed;
use App\Framework\EventBus\Attributes\EventHandler;
#[EventHandler]
final class NotificationFailureHandler
{
public function handleNotificationFailure(NotificationFailed $event): void
{
$this->alerting->sendAlert(
'Notification delivery failed',
$event->result->getErrors()
);
}
}
```
## Database Schema
The notifications table is created via migration:
```php
src/Framework/Notification/Migrations/CreateNotificationsTable.php
```
**Table Structure**:
- `id` (ULID) - Primary key
- `recipient_id` - User/entity receiving notification
- `type` - Notification category
- `title` - Notification title
- `body` - Notification message
- `data` - JSON structured data
- `channels` - JSON array of delivery channels
- `priority` - Delivery priority
- `status` - Current status (pending, sent, delivered, failed, read, archived)
- `created_at` - Creation timestamp
- `sent_at` - Delivery timestamp
- `read_at` - Read timestamp
- `action_url` - Optional action URL
- `action_label` - Optional action button label
**Indexes**:
- `recipient_id` + `status` + `created_at` (composite)
- `recipient_id` + `type` (composite)
- `read_at` (for unread queries)
## Configuration
### Setting Up Channels
```php
// In your Initializer
use App\Framework\Notification\Channels\DatabaseChannel;
use App\Framework\Notification\Channels\EmailChannel;
use App\Framework\Notification\NotificationDispatcher;
$container->singleton(NotificationDispatcher::class, function($c) {
return new NotificationDispatcher(
channels: [
$c->get(DatabaseChannel::class),
$c->get(EmailChannel::class),
// Add more channels as needed
],
queue: $c->get(Queue::class),
eventBus: $c->get(EventBus::class)
);
});
```
### Email Channel Setup
```php
$container->singleton(EmailChannel::class, function($c) {
return new EmailChannel(
mailer: $c->get(MailerInterface::class),
fromAddress: new Email('notifications@example.com'),
userEmailResolver: $c->get(UserEmailResolver::class)
);
});
```
## Best Practices
1. **Use Async Delivery**: Always prefer `sendLater()` for better performance
2. **Set Appropriate Priority**: Reserve URGENT for critical notifications
3. **Include Actions**: Add action URLs when user interaction is expected
4. **Structured Data**: Use `withData()` for additional context
5. **Type Classification**: Use proper NotificationType for filtering
6. **Cleanup Old Notifications**: Periodically delete old read/archived notifications
## Examples
### Welcome Notification
```php
$notification = Notification::create(
recipientId: $user->id,
type: NotificationType::system(),
title: 'Welcome to Our Platform!',
body: 'Thank you for signing up. Get started by completing your profile.',
NotificationChannel::DATABASE,
NotificationChannel::EMAIL
)->withAction('/profile/complete', 'Complete Profile');
$dispatcher->sendLater($notification);
```
### Security Alert
```php
$notification = Notification::create(
recipientId: $user->id,
type: NotificationType::security(),
title: 'New Login Detected',
body: "We detected a login from {$location} at {$time}",
NotificationChannel::DATABASE,
NotificationChannel::EMAIL
)
->withPriority(NotificationPriority::HIGH)
->withData([
'ip_address' => $ipAddress,
'location' => $location,
'device' => $device
]);
$dispatcher->sendNow($notification); // Immediate for security
```
### Order Confirmation
```php
$notification = Notification::create(
recipientId: $order->userId,
type: NotificationType::transactional(),
title: 'Order Confirmed',
body: "Your order #{$order->number} has been confirmed",
NotificationChannel::EMAIL
)
->withData([
'order_id' => $order->id,
'total' => $order->total->toDecimal(),
'items' => count($order->items)
])
->withAction("/orders/{$order->id}", 'View Order');
$dispatcher->sendLater($notification);
```
## Testing
Run tests with:
```bash
./vendor/bin/pest tests/Feature/NotificationSystemTest.php
```
## Extending the System
### Adding a New Channel
1. Implement `NotificationChannelInterface`
2. Add channel to `NotificationChannel` enum
3. Register channel in dispatcher initialization
4. Implement `send()` method with delivery logic
```php
final readonly class SmsChannel implements NotificationChannelInterface
{
public function __construct(
private SmsProvider $provider
) {}
public function send(Notification $notification): ChannelResult
{
// Implementation
}
public function supports(Notification $notification): bool
{
return $notification->supportsChannel(NotificationChannel::SMS);
}
public function getChannel(): NotificationChannel
{
return NotificationChannel::SMS;
}
}
```
## Architecture
The notification system follows framework principles:
-**Readonly Value Objects**: Notification, NotificationId, etc.
-**Final Classes**: All implementation classes are final
-**Composition over Inheritance**: Channel interface composition
-**Event-Driven**: Integration with EventBus
-**Queue Integration**: Async delivery via Queue system
-**Module Reuse**: Leverages Mail, Queue, EventBus, Database modules
## Future Enhancements
- [ ] User preference management for notification types
- [ ] Notification templates with variables
- [ ] Push notification implementation (Web Push API)
- [ ] SMS channel implementation
- [ ] Webhook channel implementation
- [ ] Notification batching/digest mode
- [ ] Quiet hours / Do Not Disturb
- [ ] A/B testing for notification content
- [ ] Analytics tracking

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Storage;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\SqlQuery;
use App\Framework\Notification\Notification;
use App\Framework\Notification\ValueObjects\NotificationChannel;
use App\Framework\Notification\ValueObjects\NotificationId;
use App\Framework\Notification\ValueObjects\NotificationPriority;
use App\Framework\Notification\ValueObjects\NotificationStatus;
use App\Framework\Notification\ValueObjects\NotificationType;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Database implementation of NotificationRepository
*/
final readonly class DatabaseNotificationRepository implements NotificationRepository
{
public function __construct(
private ConnectionInterface $connection
) {
}
public function save(Notification $notification): void
{
$query = new SqlQuery(
sql: <<<'SQL'
INSERT INTO notifications (
id, recipient_id, type, title, body, data,
channels, priority, status, created_at, sent_at,
read_at, action_url, action_label
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
sent_at = EXCLUDED.sent_at,
read_at = EXCLUDED.read_at
SQL,
params: [
$notification->id->toString(),
$notification->recipientId,
$notification->type->toString(),
$notification->title,
$notification->body,
json_encode($notification->data),
json_encode(array_map(fn($c) => $c->value, $notification->channels)),
$notification->priority->value,
$notification->status->value,
$notification->createdAt->format('Y-m-d H:i:s'),
$notification->sentAt?->format('Y-m-d H:i:s'),
$notification->readAt?->format('Y-m-d H:i:s'),
$notification->actionUrl,
$notification->actionLabel,
]
);
$this->connection->execute($query);
}
public function findById(NotificationId $id): ?Notification
{
$query = new SqlQuery(
sql: 'SELECT * FROM notifications WHERE id = ?',
params: [$id->toString()]
);
$row = $this->connection->queryOne($query);
return $row ? $this->hydrateNotification($row) : null;
}
public function findByUser(string $userId, int $limit = 20, int $offset = 0): array
{
$query = new SqlQuery(
sql: <<<'SQL'
SELECT * FROM notifications
WHERE recipient_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
SQL,
params: [$userId, $limit, $offset]
);
$rows = $this->connection->query($query)->fetchAll();
return array_map(fn($row) => $this->hydrateNotification($row), $rows);
}
public function findUnreadByUser(string $userId, int $limit = 20): array
{
$query = new SqlQuery(
sql: <<<'SQL'
SELECT * FROM notifications
WHERE recipient_id = ?
AND status != ?
ORDER BY created_at DESC
LIMIT ?
SQL,
params: [$userId, NotificationStatus::READ->value, $limit]
);
$rows = $this->connection->query($query)->fetchAll();
return array_map(fn($row) => $this->hydrateNotification($row), $rows);
}
public function countUnreadByUser(string $userId): int
{
$query = new SqlQuery(
sql: <<<'SQL'
SELECT COUNT(*) as count FROM notifications
WHERE recipient_id = ?
AND status != ?
SQL,
params: [$userId, NotificationStatus::READ->value]
);
return (int) $this->connection->queryScalar($query);
}
public function markAsRead(NotificationId $id): bool
{
$query = new SqlQuery(
sql: <<<'SQL'
UPDATE notifications
SET status = ?, read_at = ?
WHERE id = ?
SQL,
params: [
NotificationStatus::READ->value,
(new Timestamp())->format('Y-m-d H:i:s'),
$id->toString(),
]
);
return $this->connection->execute($query) > 0;
}
public function markAllAsReadForUser(string $userId): int
{
$query = new SqlQuery(
sql: <<<'SQL'
UPDATE notifications
SET status = ?, read_at = ?
WHERE recipient_id = ?
AND status != ?
SQL,
params: [
NotificationStatus::READ->value,
(new Timestamp())->format('Y-m-d H:i:s'),
$userId,
NotificationStatus::READ->value,
]
);
return $this->connection->execute($query);
}
public function delete(NotificationId $id): bool
{
$query = new SqlQuery(
sql: 'DELETE FROM notifications WHERE id = ?',
params: [$id->toString()]
);
return $this->connection->execute($query) > 0;
}
public function deleteOldByStatus(NotificationStatus $status, int $daysOld): int
{
$cutoffDate = (new Timestamp())->modify("-{$daysOld} days");
$query = new SqlQuery(
sql: <<<'SQL'
DELETE FROM notifications
WHERE status = ?
AND created_at < ?
SQL,
params: [
$status->value,
$cutoffDate->format('Y-m-d H:i:s'),
]
);
return $this->connection->execute($query);
}
private function hydrateNotification(array $row): Notification
{
$channels = array_map(
fn($c) => NotificationChannel::from($c),
json_decode($row['channels'], true)
);
return new Notification(
id: NotificationId::fromString($row['id']),
recipientId: $row['recipient_id'],
type: NotificationType::fromString($row['type']),
title: $row['title'],
body: $row['body'],
data: json_decode($row['data'], true) ?? [],
channels: $channels,
priority: NotificationPriority::from($row['priority']),
status: NotificationStatus::from($row['status']),
createdAt: Timestamp::fromString($row['created_at']),
sentAt: $row['sent_at'] ? Timestamp::fromString($row['sent_at']) : null,
readAt: $row['read_at'] ? Timestamp::fromString($row['read_at']) : null,
actionUrl: $row['action_url'],
actionLabel: $row['action_label']
);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\Storage;
use App\Framework\Notification\Notification;
use App\Framework\Notification\ValueObjects\NotificationId;
use App\Framework\Notification\ValueObjects\NotificationStatus;
/**
* Repository interface for notification persistence
*/
interface NotificationRepository
{
/**
* Save a notification
*
* @param Notification $notification The notification to save
* @return void
*/
public function save(Notification $notification): void;
/**
* Find notification by ID
*
* @param NotificationId $id The notification ID
* @return Notification|null The notification or null if not found
*/
public function findById(NotificationId $id): ?Notification;
/**
* Find all notifications for a user
*
* @param string $userId The user identifier
* @param int $limit Maximum number of notifications to return
* @param int $offset Offset for pagination
* @return array<Notification> Array of notifications
*/
public function findByUser(string $userId, int $limit = 20, int $offset = 0): array;
/**
* Find unread notifications for a user
*
* @param string $userId The user identifier
* @param int $limit Maximum number of notifications to return
* @return array<Notification> Array of unread notifications
*/
public function findUnreadByUser(string $userId, int $limit = 20): array;
/**
* Count unread notifications for a user
*
* @param string $userId The user identifier
* @return int Number of unread notifications
*/
public function countUnreadByUser(string $userId): int;
/**
* Mark notification as read
*
* @param NotificationId $id The notification ID
* @return bool True if updated successfully
*/
public function markAsRead(NotificationId $id): bool;
/**
* Mark all notifications as read for a user
*
* @param string $userId The user identifier
* @return int Number of notifications marked as read
*/
public function markAllAsReadForUser(string $userId): int;
/**
* Delete notification
*
* @param NotificationId $id The notification ID
* @return bool True if deleted successfully
*/
public function delete(NotificationId $id): bool;
/**
* Delete old notifications by status
*
* @param NotificationStatus $status The status to filter by
* @param int $daysOld Delete notifications older than this many days
* @return int Number of notifications deleted
*/
public function deleteOldByStatus(NotificationStatus $status, int $daysOld): int;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\ValueObjects;
/**
* Supported notification delivery channels
*/
enum NotificationChannel: string
{
case DATABASE = 'database';
case EMAIL = 'email';
case PUSH = 'push';
case SMS = 'sms';
case WEBHOOK = 'webhook';
public function isRealtime(): bool
{
return match ($this) {
self::DATABASE, self::PUSH => true,
self::EMAIL, self::SMS, self::WEBHOOK => false,
};
}
public function requiresExternalService(): bool
{
return match ($this) {
self::EMAIL, self::SMS, self::WEBHOOK => true,
self::DATABASE, self::PUSH => false,
};
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\ValueObjects;
use App\Framework\Ulid\UlidGenerator;
use App\Framework\Ulid\UlidValidator;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
/**
* Unique identifier for notifications
*
* Uses ULID for sortable, time-based IDs
*/
final readonly class NotificationId
{
private function __construct(
private string $value
) {
}
public static function generate(): self
{
$clock = new SystemClock();
$generator = new UlidGenerator();
return new self($generator->generate($clock));
}
public static function fromString(string $id): self
{
$validator = new UlidValidator();
if (!$validator->isValid($id)) {
throw new \InvalidArgumentException("Invalid notification ID: {$id}");
}
return new self($id);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\ValueObjects;
/**
* Notification priority levels
*
* Affects delivery order and user presentation
*/
enum NotificationPriority: string
{
case LOW = 'low';
case NORMAL = 'normal';
case HIGH = 'high';
case URGENT = 'urgent';
public function getNumericValue(): int
{
return match ($this) {
self::LOW => 1,
self::NORMAL => 5,
self::HIGH => 8,
self::URGENT => 10,
};
}
public function shouldInterruptUser(): bool
{
return match ($this) {
self::URGENT => true,
self::LOW, self::NORMAL, self::HIGH => false,
};
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\ValueObjects;
/**
* Notification delivery and read status
*/
enum NotificationStatus: string
{
case PENDING = 'pending';
case SENT = 'sent';
case DELIVERED = 'delivered';
case FAILED = 'failed';
case READ = 'read';
case ARCHIVED = 'archived';
public function isFinal(): bool
{
return match ($this) {
self::DELIVERED, self::FAILED, self::READ, self::ARCHIVED => true,
self::PENDING, self::SENT => false,
};
}
public function canRetry(): bool
{
return match ($this) {
self::FAILED => true,
self::PENDING, self::SENT, self::DELIVERED, self::READ, self::ARCHIVED => false,
};
}
public function isSuccessful(): bool
{
return match ($this) {
self::SENT, self::DELIVERED, self::READ => true,
self::PENDING, self::FAILED, self::ARCHIVED => false,
};
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Framework\Notification\ValueObjects;
/**
* Type/Category of notification for user preferences and filtering
*/
final readonly class NotificationType
{
private function __construct(
private string $value
) {
if (empty($value)) {
throw new \InvalidArgumentException('Notification type cannot be empty');
}
}
public static function fromString(string $type): self
{
return new self($type);
}
// Common notification types as named constructors
public static function system(): self
{
return new self('system');
}
public static function security(): self
{
return new self('security');
}
public static function marketing(): self
{
return new self('marketing');
}
public static function social(): self
{
return new self('social');
}
public static function transactional(): self
{
return new self('transactional');
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}