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:
Paulo Castellano 2026-03-30 16:47:03 -03:00
parent ceb7b92b74
commit 09e37f2879
23 changed files with 848 additions and 12 deletions

View 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';
}

View 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';
}

View 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]);
}
}

View file

@ -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),
);
}
}

View 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(),
]);
}
}

View file

@ -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),
);
}
}

View 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;
}
}

View file

@ -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();

View file

@ -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).

View file

@ -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,

View 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()]);
}
}

View file

@ -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');
}
};

View file

@ -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',

View file

@ -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

View file

@ -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',

View file

@ -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">

View 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>

View file

@ -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)

View 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']);
});

View 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);
});