feat: notification system with SendNotification job, dialog UI, tests
Backend: - Create notifications table (user_id, workspace_id, type, channel, title, body, data JSON, read_at, archived_at) - Create Notification model with Type enum (post_failed, account_disconnected, invite_received, member_joined, member_removed) and Channel enum (email, in_app, both) - Create SendNotification job: isolated from publish flow, handles saving in-app notification and sending email independently - NotificationController: index (excludes archived, scoped to workspace), markAsRead, markAllAsRead, archiveAll - Integrate with PublishToSocialPlatform (post failed/partial) - Integrate with VerifyWorkspaceConnections (batch disconnection) - Integrate with SocialAccount::markAsDisconnected (single disconnection) - All use SendNotification::dispatch() instead of direct Mail::to() Frontend: - NotificationBell component in sidebar footer with unread badge - Dialog with notification list, mark as read, mark all read, archive all - Click navigates to relevant page (post edit, accounts) - i18n for notifications UI (en, es, pt-BR) Tests: - 8 tests for NotificationController (auth, CRUD, workspace scoping) - 4 tests for SendNotification job (channels, email, data storage) All 745 tests passing.
This commit is contained in:
parent
ceb7b92b74
commit
09e37f2879
23 changed files with 848 additions and 12 deletions
12
app/Enums/Notification/Channel.php
Normal file
12
app/Enums/Notification/Channel.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Notification;
|
||||
|
||||
enum Channel: string
|
||||
{
|
||||
case Email = 'email';
|
||||
case InApp = 'in_app';
|
||||
case Both = 'both';
|
||||
}
|
||||
15
app/Enums/Notification/Type.php
Normal file
15
app/Enums/Notification/Type.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Notification;
|
||||
|
||||
enum Type: string
|
||||
{
|
||||
case PostFailed = 'post_failed';
|
||||
case PostPartiallyPublished = 'post_partially_published';
|
||||
case AccountDisconnected = 'account_disconnected';
|
||||
case InviteReceived = 'invite_received';
|
||||
case MemberJoined = 'member_joined';
|
||||
case MemberRemoved = 'member_removed';
|
||||
}
|
||||
75
app/Http/Controllers/App/NotificationController.php
Normal file
75
app/Http/Controllers/App/NotificationController.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->whereNull('archived_at')
|
||||
->latest()
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
$unreadCount = $request->user()
|
||||
->notifications()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->whereNull('archived_at')
|
||||
->whereNull('read_at')
|
||||
->count();
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $unreadCount,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, Notification $notification): JsonResponse
|
||||
{
|
||||
if ($notification->user_id !== $request->user()->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$notification->markAsRead();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$request->user()
|
||||
->notifications()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->whereNull('read_at')
|
||||
->update(['read_at' => now()]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function archiveAll(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$request->user()
|
||||
->notifications()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->whereNull('archived_at')
|
||||
->update(['archived_at' => now()]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Enums\PostPlatform\Status as PostPlatformStatus;
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Events\PostPlatformStatusUpdated;
|
||||
|
|
@ -25,7 +27,6 @@
|
|||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class PublishToSocialPlatform implements ShouldQueue
|
||||
{
|
||||
|
|
@ -134,8 +135,27 @@ private function notifyOwner(Post $post): void
|
|||
{
|
||||
$owner = $post->workspace->owner;
|
||||
|
||||
if ($owner) {
|
||||
Mail::to($owner)->send(new PostPublishFailed($post));
|
||||
if (! $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failedPlatforms = $post->postPlatforms()
|
||||
->with('socialAccount')
|
||||
->where('enabled', true)
|
||||
->get()
|
||||
->filter(fn ($pp) => $pp->status === PostPlatformStatus::Failed)
|
||||
->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')')
|
||||
->implode(', ');
|
||||
|
||||
SendNotification::dispatch(
|
||||
user: $owner,
|
||||
workspaceId: $post->workspace_id,
|
||||
type: Type::PostFailed,
|
||||
channel: Channel::Both,
|
||||
title: 'Post failed to publish',
|
||||
body: "Failed on: {$failedPlatforms}",
|
||||
data: ['post_id' => $post->id],
|
||||
mailable: new PostPublishFailed($post),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
app/Jobs/SendNotification.php
Normal file
68
app/Jobs/SendNotification.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendNotification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 10;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $data
|
||||
*/
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public string $workspaceId,
|
||||
public Type $type,
|
||||
public Channel $channel,
|
||||
public string $title,
|
||||
public string $body,
|
||||
public ?array $data = null,
|
||||
public ?Mailable $mailable = null,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Save in-app notification
|
||||
if ($this->channel !== Channel::Email) {
|
||||
Notification::create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspaceId,
|
||||
'type' => $this->type,
|
||||
'channel' => $this->channel,
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'data' => $this->data,
|
||||
]);
|
||||
}
|
||||
|
||||
// Send email
|
||||
if ($this->mailable && $this->channel !== Channel::InApp) {
|
||||
Mail::to($this->user)->send($this->mailable);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('SendNotification job failed', [
|
||||
'user_id' => $this->user->id,
|
||||
'type' => $this->type->value,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Mail\WorkspaceConnectionsDisconnected;
|
||||
|
|
@ -14,7 +16,6 @@
|
|||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class VerifyWorkspaceConnections implements ShouldQueue
|
||||
{
|
||||
|
|
@ -98,12 +99,30 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
|
|||
*/
|
||||
private function notifyOwner(Collection $disconnectedAccounts): void
|
||||
{
|
||||
$owner = $this->workspace->owner;
|
||||
|
||||
if (! $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('Sending workspace disconnection notification', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'disconnected_count' => $disconnectedAccounts->count(),
|
||||
]);
|
||||
|
||||
Mail::to($this->workspace->owner)
|
||||
->send(new WorkspaceConnectionsDisconnected($this->workspace, $disconnectedAccounts));
|
||||
$accountNames = $disconnectedAccounts
|
||||
->map(fn ($account) => $account->platform->label().' (@'.($account->username ?? $account->display_name).')')
|
||||
->implode(', ');
|
||||
|
||||
SendNotification::dispatch(
|
||||
user: $owner,
|
||||
workspaceId: $this->workspace->id,
|
||||
type: Type::AccountDisconnected,
|
||||
channel: Channel::Both,
|
||||
title: $disconnectedAccounts->count().' '.($disconnectedAccounts->count() === 1 ? 'account' : 'accounts').' disconnected',
|
||||
body: $accountNames,
|
||||
data: ['workspace_id' => $this->workspace->id],
|
||||
mailable: new WorkspaceConnectionsDisconnected($this->workspace, $disconnectedAccounts),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
app/Models/Notification.php
Normal file
78
app/Models/Notification.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use Database\Factories\NotificationFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Notification extends Model
|
||||
{
|
||||
/** @use HasFactory<NotificationFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'workspace_id',
|
||||
'type',
|
||||
'channel',
|
||||
'title',
|
||||
'body',
|
||||
'data',
|
||||
'read_at',
|
||||
'archived_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => Type::class,
|
||||
'channel' => Channel::class,
|
||||
'data' => 'array',
|
||||
'read_at' => 'datetime',
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function markAsRead(): void
|
||||
{
|
||||
$this->update(['read_at' => now()]);
|
||||
}
|
||||
|
||||
public function archive(): void
|
||||
{
|
||||
$this->update(['archived_at' => now()]);
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->read_at !== null;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->archived_at !== null;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,11 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Jobs\SendNotification;
|
||||
use App\Mail\AccountDisconnected;
|
||||
use Database\Factories\SocialAccountFactory;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
|
@ -15,7 +18,6 @@
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SocialAccount extends Model
|
||||
|
|
@ -105,8 +107,20 @@ public function markAsDisconnected(string $errorMessage): void
|
|||
'disconnected_at' => now(),
|
||||
]);
|
||||
|
||||
if ($wasConnected) {
|
||||
Mail::to($this->workspace->owner)->send(new AccountDisconnected($this));
|
||||
if ($wasConnected && $this->workspace->owner) {
|
||||
$platformName = $this->platform->label();
|
||||
$accountName = $this->username ?? $this->display_name;
|
||||
|
||||
SendNotification::dispatch(
|
||||
user: $this->workspace->owner,
|
||||
workspaceId: $this->workspace_id,
|
||||
type: Type::AccountDisconnected,
|
||||
channel: Channel::Both,
|
||||
title: "{$platformName} account disconnected",
|
||||
body: "@{$accountName} needs to be reconnected",
|
||||
data: ['social_account_id' => $this->id],
|
||||
mailable: new AccountDisconnected($this),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Cashier\Billable;
|
||||
|
|
@ -79,6 +80,14 @@ protected function casts(): array
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Notification, $this>
|
||||
*/
|
||||
public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notification::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has an active subscription.
|
||||
* In self-hosted mode, always returns true (no subscription required).
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\Media;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
|
|
@ -76,6 +77,7 @@ protected function configureMorphMap(): void
|
|||
{
|
||||
Relation::enforceMorphMap([
|
||||
'media' => Media::class,
|
||||
'notification' => Notification::class,
|
||||
'post' => Post::class,
|
||||
'postPlatform' => PostPlatform::class,
|
||||
'socialAccount' => SocialAccount::class,
|
||||
|
|
|
|||
37
database/factories/NotificationFactory.php
Normal file
37
database/factories/NotificationFactory.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Notification>
|
||||
*/
|
||||
class NotificationFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'type' => fake()->randomElement(Type::cases()),
|
||||
'channel' => Channel::InApp,
|
||||
'title' => fake()->sentence(4),
|
||||
'body' => fake()->sentence(10),
|
||||
'data' => null,
|
||||
'read_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function read(): static
|
||||
{
|
||||
return $this->state(fn () => ['read_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('user_id');
|
||||
$table->uuid('workspace_id');
|
||||
$table->string('type');
|
||||
$table->string('channel');
|
||||
$table->string('title');
|
||||
$table->text('body');
|
||||
$table->json('data')->nullable();
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamp('archived_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete();
|
||||
$table->index(['user_id', 'workspace_id', 'read_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
|
|
@ -41,6 +41,11 @@
|
|||
'settings' => 'Settings',
|
||||
],
|
||||
|
||||
'notifications' => 'Notifications',
|
||||
'mark_all_read' => 'Mark all as read',
|
||||
'archive_all' => 'Archive all',
|
||||
'no_notifications' => 'No notifications',
|
||||
|
||||
'support' => [
|
||||
'discord' => 'Discord',
|
||||
'share_feedback' => 'Share feedback',
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@
|
|||
'settings' => 'Configuración',
|
||||
],
|
||||
|
||||
'notifications' => 'Notificaciones',
|
||||
'mark_all_read' => 'Marcar todo como leído',
|
||||
'archive_all' => 'Archivar todo',
|
||||
'no_notifications' => 'Sin notificaciones',
|
||||
|
||||
'support' => [
|
||||
'discord' => 'Discord',
|
||||
'share_feedback' => 'Dar feedback',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -40,6 +40,11 @@
|
|||
'settings' => 'Configurações',
|
||||
],
|
||||
|
||||
'notifications' => 'Notificações',
|
||||
'mark_all_read' => 'Marcar tudo como lido',
|
||||
'archive_all' => 'Arquivar tudo',
|
||||
'no_notifications' => 'Sem notificações',
|
||||
|
||||
'support' => [
|
||||
'discord' => 'Discord',
|
||||
'share_feedback' => 'Enviar feedback',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { store as storePost } from '@/actions/App/Http/Controllers/App/PostContr
|
|||
import { index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import NotificationBell from '@/components/NotificationBell.vue';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -197,6 +198,9 @@ const switchWorkspace = (workspaceId: string) => {
|
|||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-if="currentWorkspace">
|
||||
<NotificationBell />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as-child tooltip="Discord">
|
||||
<a href="https://trypost.it/discord" target="_blank" rel="noopener noreferrer">
|
||||
|
|
|
|||
197
resources/js/components/NotificationBell.vue
Normal file
197
resources/js/components/NotificationBell.vue
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { IconArchive, IconBell, IconCheck, IconChecks } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { index, read, readAll, archiveAll } from '@/routes/app/notifications';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
data: Record<string, string> | null;
|
||||
read_at: string | null;
|
||||
archived_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const notifications = ref<Notification[]>([]);
|
||||
const unreadCount = ref(0);
|
||||
const loading = ref(false);
|
||||
const dialogOpen = ref(false);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await fetch(index.url(), {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await response.json();
|
||||
notifications.value = data.notifications;
|
||||
unreadCount.value = data.unread_count;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const csrfToken = () =>
|
||||
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
|
||||
const handleMarkAsRead = async (notification: Notification) => {
|
||||
await fetch(read.url(notification.id), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken() },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
notification.read_at = new Date().toISOString();
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
await fetch(readAll.url(), {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken() },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
notifications.value = notifications.value.map((n) => ({
|
||||
...n,
|
||||
read_at: n.read_at ?? new Date().toISOString(),
|
||||
}));
|
||||
unreadCount.value = 0;
|
||||
};
|
||||
|
||||
const handleArchiveAll = async () => {
|
||||
await fetch(archiveAll.url(), {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken() },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
notifications.value = [];
|
||||
unreadCount.value = 0;
|
||||
};
|
||||
|
||||
const handleNotificationClick = (notification: Notification) => {
|
||||
if (!notification.read_at) {
|
||||
handleMarkAsRead(notification);
|
||||
}
|
||||
|
||||
dialogOpen.value = false;
|
||||
|
||||
if (notification.data?.post_id) {
|
||||
router.visit(`/posts/${notification.data.post_id}/edit`);
|
||||
} else if (notification.data?.social_account_id || notification.data?.workspace_id) {
|
||||
router.visit('/accounts');
|
||||
}
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
dialogOpen.value = true;
|
||||
fetchNotifications();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenuButton :tooltip="$t('sidebar.notifications')" @click="openDialog">
|
||||
<div class="relative">
|
||||
<IconBell />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -top-1 -right-1 flex size-3.5 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-destructive-foreground"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span>{{ $t('sidebar.notifications') }}</span>
|
||||
</SidebarMenuButton>
|
||||
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle>{{ $t('sidebar.notifications') }}</DialogTitle>
|
||||
<div v-if="notifications.length > 0" class="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-7" @click="handleMarkAllAsRead">
|
||||
<IconChecks class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('sidebar.mark_all_read') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-7" @click="handleArchiveAll">
|
||||
<IconArchive class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('sidebar.archive_all') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="notifications.length === 0" class="py-8 text-center text-sm text-muted-foreground">
|
||||
{{ $t('sidebar.no_notifications') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="-mx-6 max-h-96 overflow-y-auto">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="flex cursor-pointer items-start gap-3 border-b px-6 py-3 transition-colors last:border-0 hover:bg-accent/50"
|
||||
:class="{ 'opacity-60': notification.read_at }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<span
|
||||
v-if="!notification.read_at"
|
||||
class="mt-1.5 size-2 shrink-0 rounded-full bg-primary"
|
||||
/>
|
||||
<span v-else class="mt-1.5 size-2 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium leading-tight">{{ notification.title }}</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{{ notification.body }}</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!notification.read_at"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0"
|
||||
@click.stop="handleMarkAsRead(notification)"
|
||||
>
|
||||
<IconCheck class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Http\Controllers\App\ApiKeyController;
|
||||
use App\Http\Controllers\App\BillingController;
|
||||
use App\Http\Controllers\App\MediaController;
|
||||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\OnboardingController;
|
||||
use App\Http\Controllers\App\PostController;
|
||||
use App\Http\Controllers\App\Settings\PasswordController;
|
||||
|
|
@ -163,6 +164,12 @@ function () {
|
|||
// Billing
|
||||
Route::get('settings/billing', [BillingController::class, 'index'])->name('app.billing.index');
|
||||
Route::get('settings/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal');
|
||||
|
||||
// Notifications
|
||||
Route::get('notifications', [NotificationController::class, 'index'])->name('app.notifications.index');
|
||||
Route::patch('notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('app.notifications.read');
|
||||
Route::post('notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('app.notifications.read-all');
|
||||
Route::post('notifications/archive-all', [NotificationController::class, 'archiveAll'])->name('app.notifications.archive-all');
|
||||
});
|
||||
|
||||
// Settings (auth required)
|
||||
|
|
|
|||
96
tests/Feature/Jobs/SendNotificationTest.php
Normal file
96
tests/Feature/Jobs/SendNotificationTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Notification\Channel;
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Jobs\SendNotification;
|
||||
use App\Mail\PostPublishFailed;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
});
|
||||
|
||||
test('send notification creates in-app notification for in_app channel', function () {
|
||||
(new SendNotification(
|
||||
user: $this->user,
|
||||
workspaceId: $this->workspace->id,
|
||||
type: Type::PostFailed,
|
||||
channel: Channel::InApp,
|
||||
title: 'Test title',
|
||||
body: 'Test body',
|
||||
))->handle();
|
||||
|
||||
expect(Notification::count())->toBe(1);
|
||||
|
||||
$notification = Notification::first();
|
||||
expect($notification->user_id)->toBe($this->user->id);
|
||||
expect($notification->title)->toBe('Test title');
|
||||
expect($notification->type)->toBe(Type::PostFailed);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('send notification sends email for email channel', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
(new SendNotification(
|
||||
user: $this->user,
|
||||
workspaceId: $this->workspace->id,
|
||||
type: Type::PostFailed,
|
||||
channel: Channel::Email,
|
||||
title: 'Test title',
|
||||
body: 'Test body',
|
||||
mailable: new PostPublishFailed($post),
|
||||
))->handle();
|
||||
|
||||
expect(Notification::count())->toBe(0);
|
||||
|
||||
Mail::assertQueued(PostPublishFailed::class);
|
||||
});
|
||||
|
||||
test('send notification creates notification and sends email for both channel', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
(new SendNotification(
|
||||
user: $this->user,
|
||||
workspaceId: $this->workspace->id,
|
||||
type: Type::PostFailed,
|
||||
channel: Channel::Both,
|
||||
title: 'Test title',
|
||||
body: 'Test body',
|
||||
mailable: new PostPublishFailed($post),
|
||||
))->handle();
|
||||
|
||||
expect(Notification::count())->toBe(1);
|
||||
|
||||
Mail::assertQueued(PostPublishFailed::class);
|
||||
});
|
||||
|
||||
test('send notification stores data json', function () {
|
||||
(new SendNotification(
|
||||
user: $this->user,
|
||||
workspaceId: $this->workspace->id,
|
||||
type: Type::PostFailed,
|
||||
channel: Channel::InApp,
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
data: ['post_id' => 'abc-123'],
|
||||
))->handle();
|
||||
|
||||
$notification = Notification::first();
|
||||
expect($notification->data)->toBe(['post_id' => 'abc-123']);
|
||||
});
|
||||
132
tests/Feature/NotificationControllerTest.php
Normal file
132
tests/Feature/NotificationControllerTest.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\User\Setup;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['setup' => Setup::Completed]);
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('notifications index requires authentication', function () {
|
||||
$response = $this->getJson(route('app.notifications.index'));
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('notifications index returns notifications and unread count', function () {
|
||||
Notification::factory()->count(3)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Notification::factory()->read()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson(route('app.notifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(4, 'notifications');
|
||||
$response->assertJsonPath('unread_count', 3);
|
||||
});
|
||||
|
||||
test('notifications index excludes archived notifications', function () {
|
||||
Notification::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Notification::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'archived_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson(route('app.notifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1, 'notifications');
|
||||
});
|
||||
|
||||
test('notifications index only shows current workspace notifications', function () {
|
||||
Notification::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
Notification::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->getJson(route('app.notifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1, 'notifications');
|
||||
});
|
||||
|
||||
test('mark notification as read', function () {
|
||||
$notification = Notification::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->patchJson(route('app.notifications.read', $notification));
|
||||
|
||||
$response->assertOk();
|
||||
expect($notification->fresh()->read_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('cannot mark another users notification as read', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$notification = Notification::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->patchJson(route('app.notifications.read', $notification));
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('mark all as read', function () {
|
||||
Notification::factory()->count(3)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->postJson(route('app.notifications.read-all'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$unread = Notification::where('user_id', $this->user->id)
|
||||
->whereNull('read_at')
|
||||
->count();
|
||||
|
||||
expect($unread)->toBe(0);
|
||||
});
|
||||
|
||||
test('archive all notifications', function () {
|
||||
Notification::factory()->count(3)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->postJson(route('app.notifications.archive-all'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$active = Notification::where('user_id', $this->user->id)
|
||||
->whereNull('archived_at')
|
||||
->count();
|
||||
|
||||
expect($active)->toBe(0);
|
||||
});
|
||||
Loading…
Reference in a new issue