feat: add push notification functionality and service worker
- Implemented push notifications with a service worker (sw.ts) to handle caching and notifications. - Added API routes for push subscription and notification management in routes/api.php. - Created NotificationTest to validate notification service functionality and ensure correct notifications are sent based on user roles and actions. - Updated Permissions component to manage notification permissions and subscriptions. - Enhanced CategoryIndex component to highlight categories based on notifications. - Excluded service worker from TypeScript compilation in tsconfig.json. - Updated Vite configuration to include service worker in the build process.
This commit is contained in:
parent
23cc327190
commit
95be00c9d1
70
app/Http/Controllers/Api/NotificationController.php
Normal file
70
app/Http/Controllers/Api/NotificationController.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AppNotification;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return response()->json($notifications);
|
||||
}
|
||||
|
||||
public function unreadCount(Request $request): JsonResponse
|
||||
{
|
||||
$count = $request->user()
|
||||
->notifications()
|
||||
->where('is_read', false)
|
||||
->count();
|
||||
|
||||
return response()->json(['count' => $count]);
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, AppNotification $notification): JsonResponse
|
||||
{
|
||||
if ($notification->user_id !== $request->user()->id) {
|
||||
return response()->json(['message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$notification->update([
|
||||
'is_read' => true,
|
||||
'read_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Notification marked as read.']);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, AppNotification $notification): JsonResponse
|
||||
{
|
||||
if ($notification->user_id !== $request->user()->id) {
|
||||
return response()->json(['message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$notification->delete();
|
||||
|
||||
return response()->json(['message' => 'Notification deleted.']);
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()
|
||||
->notifications()
|
||||
->where('is_read', false)
|
||||
->update([
|
||||
'is_read' => true,
|
||||
'read_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'All notifications marked as read.']);
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
35
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\DestroyPushSubscriptionRequest;
|
||||
use App\Http\Requests\Api\StorePushSubscriptionRequest;
|
||||
use App\Services\PushSubscriptionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PushSubscriptionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PushSubscriptionService $service,
|
||||
) {}
|
||||
|
||||
public function store(StorePushSubscriptionRequest $request): JsonResponse
|
||||
{
|
||||
return $this->service->store(
|
||||
$request->user(),
|
||||
$request->endpoint,
|
||||
$request->public_key,
|
||||
$request->auth_token,
|
||||
$request->content_encoding,
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(DestroyPushSubscriptionRequest $request): JsonResponse
|
||||
{
|
||||
return $this->service->destroy(
|
||||
$request->user(),
|
||||
$request->endpoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@ class PermissionController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('settings/permissions');
|
||||
return Inertia::render('settings/permissions', [
|
||||
'vapidPublicKey' => config('webpush.vapid.public_key'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
20
app/Http/Requests/Api/DestroyPushSubscriptionRequest.php
Normal file
20
app/Http/Requests/Api/DestroyPushSubscriptionRequest.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DestroyPushSubscriptionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/Api/StorePushSubscriptionRequest.php
Normal file
23
app/Http/Requests/Api/StorePushSubscriptionRequest.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePushSubscriptionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'public_key' => ['nullable', 'string', 'max:255'],
|
||||
'auth_token' => ['nullable', 'string', 'max:255'],
|
||||
'content_encoding' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -14,10 +14,11 @@ public function authorize(): bool
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'per_page' => 'nullable|integer|in:25,50,100,999999',
|
||||
'search' => 'nullable|string|max:255',
|
||||
'sort' => 'nullable|string',
|
||||
'direction' => 'nullable|string|in:asc,desc',
|
||||
'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'sort' => ['nullable', 'string'],
|
||||
'direction' => ['nullable', 'string', 'in:asc,desc'],
|
||||
'highlight' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -29,6 +30,13 @@ protected function casts(): array
|
||||
];
|
||||
}
|
||||
|
||||
protected function formattedAmount(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->total_amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function cancelled(Builder $query): void
|
||||
{
|
||||
|
||||
@ -14,13 +14,14 @@
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use NotificationChannels\WebPush\HasPushSubscriptions;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['full_name'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
use HasFactory, HasPushSubscriptions, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -49,7 +50,7 @@ protected function name(): Attribute
|
||||
protected function fullName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->userProfile?->full_name ?? '',
|
||||
get: fn () => $this->userProfile?->full_name ?: $this->username,
|
||||
);
|
||||
}
|
||||
|
||||
@ -158,11 +159,6 @@ public function purchaseItems(): HasMany
|
||||
return $this->hasMany(PurchaseItem::class);
|
||||
}
|
||||
|
||||
public function pushSubscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PushSubscription::class);
|
||||
}
|
||||
|
||||
public function rejections(): HasMany
|
||||
{
|
||||
return $this->hasMany(Rejection::class, 'rejected_by_id');
|
||||
|
||||
33
app/Notifications/WebPushNotification.php
Normal file
33
app/Notifications/WebPushNotification.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use NotificationChannels\WebPush\WebPushChannel;
|
||||
use NotificationChannels\WebPush\WebPushMessage;
|
||||
|
||||
class WebPushNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $body,
|
||||
public string $icon = '/icon-192x192.png',
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return [WebPushChannel::class];
|
||||
}
|
||||
|
||||
public function toWebPush(object $notifiable, mixed $notification): WebPushMessage
|
||||
{
|
||||
return (new WebPushMessage)
|
||||
->title($this->title)
|
||||
->icon($this->icon)
|
||||
->body($this->body);
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,10 @@
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
@ -48,7 +50,7 @@ public function paginatedTransactions(int $perPage = 15, string $search = '', st
|
||||
$cashAccount = $this->get();
|
||||
|
||||
if (! $cashAccount) {
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator(collect(), 0, $perPage);
|
||||
return new PaginationLengthAwarePaginator(collect(), 0, $perPage);
|
||||
}
|
||||
|
||||
$paginator = $cashAccount->cashTransactions()
|
||||
@ -91,7 +93,7 @@ private function formatTransaction(CashTransaction $transaction): array
|
||||
|
||||
public function deposit(array $data): CashTransaction
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$transaction = DB::transaction(function () use ($data) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
$newBalance = $cashAccount->balance + $data['amount'];
|
||||
|
||||
@ -112,11 +114,20 @@ public function deposit(array $data): CashTransaction
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Setoran Kas Toko',
|
||||
body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.cash-accounts.index'),
|
||||
);
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
public function withdrawal(array $data): CashTransaction
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$transaction = DB::transaction(function () use ($data) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
|
||||
if ($cashAccount->balance < $data['amount']) {
|
||||
@ -143,11 +154,20 @@ public function withdrawal(array $data): CashTransaction
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Penarikan Kas Toko',
|
||||
body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.cash-accounts.index'),
|
||||
);
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
public function updateTransaction(CashTransaction $transaction, array $data): CashTransaction
|
||||
{
|
||||
return DB::transaction(function () use ($transaction, $data) {
|
||||
$transaction = DB::transaction(function () use ($transaction, $data) {
|
||||
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => 'Transaksi ini tidak dapat diedit.',
|
||||
@ -195,6 +215,15 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Transaksi Kas Diperbarui',
|
||||
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.cash-accounts.index'),
|
||||
);
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
public function deleteTransaction(CashTransaction $transaction): bool
|
||||
@ -223,7 +252,18 @@ public function deleteTransaction(CashTransaction $transaction): bool
|
||||
|
||||
$transaction->clearMediaCollection('receipts');
|
||||
|
||||
return $transaction->delete();
|
||||
$deleted = $transaction->delete();
|
||||
|
||||
if ($deleted) {
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Transaksi Kas Dihapus',
|
||||
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.cash-accounts.index'),
|
||||
);
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -34,7 +35,7 @@ public function paginated(int $perPage = 15, string $search = '', string $sort =
|
||||
|
||||
public function create(array $data): EmployeeAdvance
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$employeeAdvance = DB::transaction(function () use ($data) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
$employee = auth()->user()->employee;
|
||||
|
||||
@ -69,6 +70,18 @@ public function create(array $data): EmployeeAdvance
|
||||
'status' => EmployeeAdvanceStatus::PENDING,
|
||||
]);
|
||||
});
|
||||
|
||||
$employeeAdvance->load('employee.user');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Kasbon Baru',
|
||||
body: 'Kasbon sebesar Rp '.number_format($data['amount'], 0, ',', '.')." dari {$employeeAdvance->employee->name} menunggu persetujuan".' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.employee-advances.index'),
|
||||
additionalUser: $employeeAdvance->employee->user ?? null,
|
||||
);
|
||||
|
||||
return $employeeAdvance;
|
||||
}
|
||||
|
||||
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
|
||||
@ -128,12 +141,20 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Kasbon Disetujui',
|
||||
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.employee-advances.index'),
|
||||
additionalUser: $employeeAdvance->employee->user ?? null,
|
||||
);
|
||||
|
||||
return $employeeAdvance;
|
||||
}
|
||||
|
||||
public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
||||
{
|
||||
return DB::transaction(function () use ($employeeAdvance) {
|
||||
$employeeAdvance = DB::transaction(function () use ($employeeAdvance) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
|
||||
$newBalance = $cashAccount->balance + $employeeAdvance->amount;
|
||||
@ -158,5 +179,15 @@ public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
||||
|
||||
return $employeeAdvance;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Kasbon Dibayar',
|
||||
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.employee-advances.index'),
|
||||
additionalUser: $employeeAdvance->employee->user ?? null,
|
||||
);
|
||||
|
||||
return $employeeAdvance;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Expense;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -75,7 +76,7 @@ private function formatExpense(Expense $expense): array
|
||||
|
||||
public function create(array $data): Expense
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$expense = DB::transaction(function () use ($data) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
|
||||
if ($cashAccount->balance < $data['amount']) {
|
||||
@ -109,11 +110,20 @@ public function create(array $data): Expense
|
||||
|
||||
return $expense;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Pengeluaran Baru',
|
||||
body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.expenses.index'),
|
||||
);
|
||||
|
||||
return $expense;
|
||||
}
|
||||
|
||||
public function update(Expense $expense, array $data): Expense
|
||||
{
|
||||
return DB::transaction(function () use ($expense, $data) {
|
||||
$expense = DB::transaction(function () use ($expense, $data) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
$cashTransaction = $expense->cashTransaction;
|
||||
|
||||
@ -166,6 +176,15 @@ public function update(Expense $expense, array $data): Expense
|
||||
|
||||
return $expense;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Pengeluaran Diperbarui',
|
||||
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.expenses.index'),
|
||||
);
|
||||
|
||||
return $expense;
|
||||
}
|
||||
|
||||
public function delete(Expense $expense): bool
|
||||
@ -184,7 +203,18 @@ public function delete(Expense $expense): bool
|
||||
|
||||
$expense->clearMediaCollection('receipts');
|
||||
|
||||
return $expense->delete();
|
||||
$deleted = $expense->delete();
|
||||
|
||||
if ($deleted) {
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Pengeluaran Dihapus',
|
||||
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.expenses.index'),
|
||||
);
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -121,7 +122,7 @@ public function pay(Payroll $payroll): Payroll
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($payroll) {
|
||||
$payroll = DB::transaction(function () use ($payroll) {
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
|
||||
$newBalance = $cashAccount->balance + $payroll->total_amount;
|
||||
@ -145,6 +146,18 @@ public function pay(Payroll $payroll): Payroll
|
||||
|
||||
return $payroll;
|
||||
});
|
||||
|
||||
$employeeUser = $payroll->employee->user ?? null;
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Gaji Dibayar',
|
||||
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.payroll-periods.index'),
|
||||
additionalUser: $employeeUser,
|
||||
);
|
||||
|
||||
return $payroll;
|
||||
}
|
||||
|
||||
public function cancel(Payroll $payroll): Payroll
|
||||
@ -165,6 +178,16 @@ public function cancel(Payroll $payroll): Payroll
|
||||
'status' => PayrollStatus::CANCELLED,
|
||||
]);
|
||||
|
||||
$employeeUser = $payroll->employee->user ?? null;
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Gaji Dibatalkan',
|
||||
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.payroll-periods.index'),
|
||||
additionalUser: $employeeUser,
|
||||
);
|
||||
|
||||
return $payroll;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\Attendance;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -117,6 +118,13 @@ public function checkIn(array $data): Attendance
|
||||
$this->registerMedia($attendance, $data['photo'], 'check-in');
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur'],
|
||||
title: 'Presensi Masuk',
|
||||
body: 'Presensi masuk oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.hr.attendances.index'),
|
||||
);
|
||||
|
||||
return $attendance;
|
||||
}
|
||||
|
||||
@ -132,6 +140,13 @@ public function checkOut(Attendance $attendance, array $data): Attendance
|
||||
$this->registerMedia($attendance, $data['photo'], 'check-out');
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur'],
|
||||
title: 'Presensi Pulang',
|
||||
body: 'Presensi pulang oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.hr.attendances.index'),
|
||||
);
|
||||
|
||||
return $attendance;
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Enums\LeaveRequestStatus;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Services\NotificationService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -37,7 +38,7 @@ public function paginated(int $perPage = 15, string $search = '', string $sort =
|
||||
|
||||
public function create(array $data): LeaveRequest
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$leaveRequest = DB::transaction(function () use ($data) {
|
||||
$employee = auth()->user()->employee;
|
||||
|
||||
if (! $employee) {
|
||||
@ -56,6 +57,18 @@ public function create(array $data): LeaveRequest
|
||||
'status' => LeaveRequestStatus::PENDING,
|
||||
]);
|
||||
});
|
||||
|
||||
$leaveRequest->load('employee.user');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Pengajuan Cuti Baru',
|
||||
body: "Pengajuan cuti {$leaveRequest->total_days} hari oleh ".auth()->user()->full_name.'.',
|
||||
url: route('admin.hr.leave-requests.index'),
|
||||
additionalUser: $leaveRequest->employee->user ?? null,
|
||||
);
|
||||
|
||||
return $leaveRequest;
|
||||
}
|
||||
|
||||
public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest
|
||||
@ -88,6 +101,16 @@ public function approve(LeaveRequest $leaveRequest): LeaveRequest
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$employeeUser = $leaveRequest->employee->user ?? null;
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Cuti Disetujui',
|
||||
body: "Cuti {$leaveRequest->employee->name} telah disetujui oleh ".auth()->user()->full_name.'.',
|
||||
url: route('admin.hr.leave-requests.index'),
|
||||
additionalUser: $employeeUser,
|
||||
);
|
||||
|
||||
return $leaveRequest;
|
||||
}
|
||||
|
||||
@ -99,6 +122,16 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$employeeUser = $leaveRequest->employee->user ?? null;
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Cuti Ditolak',
|
||||
body: "Cuti {$leaveRequest->employee->name} telah ditolak oleh ".auth()->user()->full_name.'.',
|
||||
url: route('admin.hr.leave-requests.index'),
|
||||
additionalUser: $employeeUser,
|
||||
);
|
||||
|
||||
return $leaveRequest;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductPrice;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@ -72,7 +73,7 @@ public function paginated(int $perPage = 15, string $search = '', string $sort =
|
||||
|
||||
public function create(array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$product = DB::transaction(function () use ($data) {
|
||||
$product = Product::create([
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
@ -110,6 +111,15 @@ public function create(array $data): Product
|
||||
|
||||
return $product;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Produk Baru',
|
||||
body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
public function getForEdit(Product $product): array
|
||||
@ -150,7 +160,7 @@ public function getForEdit(Product $product): array
|
||||
|
||||
public function update(Product $product, array $data): Product
|
||||
{
|
||||
return DB::transaction(function () use ($product, $data) {
|
||||
$product = DB::transaction(function () use ($product, $data) {
|
||||
$product->update([
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
@ -210,6 +220,15 @@ public function update(Product $product, array $data): Product
|
||||
|
||||
return $product;
|
||||
});
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Produk Diperbarui',
|
||||
body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
public function delete(Product $product): bool
|
||||
|
||||
31
app/Services/NotificationService.php
Normal file
31
app/Services/NotificationService.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\WebPushNotification;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null): void
|
||||
{
|
||||
$users = User::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('roles', fn ($q) => $q->whereIn('name', $roles))
|
||||
->get();
|
||||
|
||||
if ($additionalUser && ! $users->contains('id', $additionalUser->id)) {
|
||||
$users->push($additionalUser);
|
||||
}
|
||||
|
||||
$users->each(function (User $user) use ($title, $body, $url) {
|
||||
$user->notifications()->create([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
$user->notify(new WebPushNotification($title, $body));
|
||||
});
|
||||
}
|
||||
}
|
||||
33
app/Services/PushSubscriptionService.php
Normal file
33
app/Services/PushSubscriptionService.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PushSubscriptionService
|
||||
{
|
||||
public function store(
|
||||
User $user,
|
||||
string $endpoint,
|
||||
?string $publicKey,
|
||||
?string $authToken,
|
||||
?string $contentEncoding,
|
||||
): JsonResponse {
|
||||
$user->updatePushSubscription(
|
||||
$endpoint,
|
||||
$publicKey,
|
||||
$authToken,
|
||||
$contentEncoding,
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Subscription saved.']);
|
||||
}
|
||||
|
||||
public function destroy(User $user, string $endpoint): JsonResponse
|
||||
{
|
||||
$user->deletePushSubscription($endpoint);
|
||||
|
||||
return response()->json(['message' => 'Subscription removed.']);
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"laravel-notification-channels/webpush": "^11.0",
|
||||
"laravel/chisel": "^0.1.0",
|
||||
"laravel/fortify": "^1.37.2",
|
||||
"laravel/framework": "^13.17",
|
||||
|
||||
495
composer.lock
generated
495
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5891ce301b1670b38277f3a3c2ac2426",
|
||||
"content-hash": "b8312d5622d94884c0607ebccdfd2ed8",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@ -214,16 +214,16 @@
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.18.0",
|
||||
"version": "0.17.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/brick/math.git",
|
||||
"reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad"
|
||||
"reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad",
|
||||
"reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818",
|
||||
"reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -261,7 +261,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/brick/math/issues",
|
||||
"source": "https://github.com/brick/math/tree/0.18.0"
|
||||
"source": "https://github.com/brick/math/tree/0.17.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -269,7 +269,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-14T18:21:03+00:00"
|
||||
"time": "2026-05-25T20:34:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "carbonphp/carbon-doctrine-types",
|
||||
@ -1510,6 +1510,72 @@
|
||||
},
|
||||
"time": "2026-07-02T12:45:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel-notification-channels/webpush",
|
||||
"version": "11.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel-notification-channels/webpush.git",
|
||||
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel-notification-channels/webpush/zipball/85b577e64459a9df06a24062e2b300abbaa99fa9",
|
||||
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/notifications": "^12.0|^13.0",
|
||||
"illuminate/support": "^12.0|^13.0",
|
||||
"minishlink/web-push": "^10.0.1",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^3.1",
|
||||
"laravel/pint": "^1.25",
|
||||
"mockery/mockery": "^1.0",
|
||||
"orchestra/testbench": "^9.2|^10.0|^11.0",
|
||||
"phpunit/phpunit": "^11.5.3|^12.5.12|^13.1.11",
|
||||
"rector/rector": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"NotificationChannels\\WebPush\\WebPushServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"NotificationChannels\\WebPush\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Cretu Eusebiu",
|
||||
"email": "me@cretueusebiu.com",
|
||||
"homepage": "http://cretueusebiu.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Joost de Bruijn",
|
||||
"email": "joost@aqualabs.nl",
|
||||
"role": "Maintainer"
|
||||
}
|
||||
],
|
||||
"description": "Web Push Notifications driver for Laravel.",
|
||||
"homepage": "https://github.com/laravel-notification-channels/webpush",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel-notification-channels/webpush/issues",
|
||||
"source": "https://github.com/laravel-notification-channels/webpush/tree/11.0.0"
|
||||
},
|
||||
"time": "2026-05-24T13:22:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/chisel",
|
||||
"version": "v0.1.1",
|
||||
@ -2865,6 +2931,77 @@
|
||||
],
|
||||
"time": "2026-04-11T18:38:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "minishlink/web-push",
|
||||
"version": "v10.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-push-libs/web-push-php.git",
|
||||
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
|
||||
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-openssl": "*",
|
||||
"guzzlehttp/guzzle": "^7.9.2",
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^2.0|^3.0",
|
||||
"spomky-labs/base64url": "^2.0.4",
|
||||
"symfony/polyfill-php83": "^1.33",
|
||||
"web-token/jwt-library": "^3.4.9|^4.0.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^v3.92.2",
|
||||
"phpstan/phpstan": "^2.1.33",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^11.5.46|^12.5.2",
|
||||
"symfony/polyfill-iconv": "^1.33"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "Optional for performance.",
|
||||
"ext-gmp": "Optional for performance."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Minishlink\\WebPush\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Louis Lagrange",
|
||||
"email": "lagrange.louis@gmail.com",
|
||||
"homepage": "https://github.com/Minishlink"
|
||||
}
|
||||
],
|
||||
"description": "Web Push library for PHP",
|
||||
"homepage": "https://github.com/web-push-libs/web-push-php",
|
||||
"keywords": [
|
||||
"Push API",
|
||||
"WebPush",
|
||||
"notifications",
|
||||
"push",
|
||||
"web"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/web-push-libs/web-push-php/issues",
|
||||
"source": "https://github.com/web-push-libs/web-push-php/tree/v10.1.0"
|
||||
},
|
||||
"time": "2026-05-28T09:37:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@ -5325,6 +5462,71 @@
|
||||
],
|
||||
"time": "2026-06-22T07:55:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/base64url",
|
||||
"version": "v2.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Spomky-Labs/base64url.git",
|
||||
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d",
|
||||
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "^0.11|^0.12",
|
||||
"phpstan/phpstan-beberlei-assert": "^0.11|^0.12",
|
||||
"phpstan/phpstan-deprecation-rules": "^0.11|^0.12",
|
||||
"phpstan/phpstan-phpunit": "^0.11|^0.12",
|
||||
"phpstan/phpstan-strict-rules": "^0.11|^0.12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Base64Url\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
|
||||
"homepage": "https://github.com/Spomky-Labs/base64url",
|
||||
"keywords": [
|
||||
"base64",
|
||||
"rfc4648",
|
||||
"safe",
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Spomky-Labs/base64url/issues",
|
||||
"source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2020-11-03T09:10:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/cbor-php",
|
||||
"version": "3.3.0",
|
||||
@ -5584,16 +5786,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v8.1.1",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d"
|
||||
"reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d",
|
||||
"reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/535e18a1b8925f6c01a55b171d157ab66c2ace15",
|
||||
"reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -5660,7 +5862,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v8.1.1"
|
||||
"source": "https://github.com/symfony/console/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -5680,7 +5882,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-16T12:55:20+00:00"
|
||||
"time": "2026-07-27T13:58:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
@ -5824,16 +6026,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/error-handler",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/error-handler.git",
|
||||
"reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5"
|
||||
"reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5",
|
||||
"reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5",
|
||||
"url": "https://api.github.com/repos/symfony/error-handler/zipball/dc98404be5e8c949815e23fee1928f5de4f3f5d3",
|
||||
"reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -5881,7 +6083,7 @@
|
||||
"description": "Provides tools to manage errors and ease debugging PHP code",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/error-handler/tree/v8.1.0"
|
||||
"source": "https://github.com/symfony/error-handler/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -5901,20 +6103,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-07-22T15:42:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/event-dispatcher",
|
||||
"version": "v8.1.1",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/event-dispatcher.git",
|
||||
"reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0"
|
||||
"reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0",
|
||||
"reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0",
|
||||
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb",
|
||||
"reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -5967,7 +6169,7 @@
|
||||
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1"
|
||||
"source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -5987,7 +6189,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-09T12:28:30+00:00"
|
||||
"time": "2026-07-22T15:42:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/event-dispatcher-contracts",
|
||||
@ -6210,16 +6412,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.1.1",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-foundation.git",
|
||||
"reference": "6a168c8fcee806b57ac020244da14293d1f9a883"
|
||||
"reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883",
|
||||
"reference": "6a168c8fcee806b57ac020244da14293d1f9a883",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/9943adbf5a64e2951a8d9eb0485310d55624f0e8",
|
||||
"reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -6267,7 +6469,7 @@
|
||||
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v8.1.1"
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -6287,7 +6489,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-12T08:43:41+00:00"
|
||||
"time": "2026-07-29T07:22:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-kernel",
|
||||
@ -6400,16 +6602,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/mailer",
|
||||
"version": "v8.1.1",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mailer.git",
|
||||
"reference": "4fa583a7377f28d54e4de442fba76375b2e20a12"
|
||||
"reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mailer/zipball/4fa583a7377f28d54e4de442fba76375b2e20a12",
|
||||
"reference": "4fa583a7377f28d54e4de442fba76375b2e20a12",
|
||||
"url": "https://api.github.com/repos/symfony/mailer/zipball/221c7f326ace1ac2baee8331d829d5b7f04f4d53",
|
||||
"reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -6456,7 +6658,7 @@
|
||||
"description": "Helps sending emails",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mailer/tree/v8.1.1"
|
||||
"source": "https://github.com/symfony/mailer/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -6476,20 +6678,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-16T12:55:20+00:00"
|
||||
"time": "2026-07-28T07:35:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mime.git",
|
||||
"reference": "b164ae7e3f7915aacfe9ee155f2f358502440664"
|
||||
"reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664",
|
||||
"reference": "b164ae7e3f7915aacfe9ee155f2f358502440664",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/75f4779d4ec2e13f24a3a7e5d0347c340c7ca627",
|
||||
"reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -6542,7 +6744,7 @@
|
||||
"mime-type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mime/tree/v8.1.0"
|
||||
"source": "https://github.com/symfony/mime/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -6562,7 +6764,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-07-29T08:00:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
@ -7070,6 +7272,86 @@
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php83",
|
||||
"version": "v1.41.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php83.git",
|
||||
"reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6",
|
||||
"reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php83\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-01T12:47:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php84",
|
||||
"version": "v1.38.1",
|
||||
@ -7627,16 +7909,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/routing.git",
|
||||
"reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3"
|
||||
"reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3",
|
||||
"reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/1058d4e13bb81dd9a6f7565686df7e13b880cdbd",
|
||||
"reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -7683,7 +7965,7 @@
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/routing/tree/v8.1.0"
|
||||
"source": "https://github.com/symfony/routing/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -7703,7 +7985,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-07-22T15:42:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/serializer",
|
||||
@ -7893,16 +8175,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/string",
|
||||
"version": "v8.1.0",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/string.git",
|
||||
"reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9"
|
||||
"reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9",
|
||||
"reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9",
|
||||
"url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
|
||||
"reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -7959,7 +8241,7 @@
|
||||
"utf8"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/string/tree/v8.1.0"
|
||||
"source": "https://github.com/symfony/string/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -7979,7 +8261,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
"time": "2026-07-28T07:35:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation",
|
||||
@ -8318,16 +8600,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/var-dumper",
|
||||
"version": "v8.1.1",
|
||||
"version": "v8.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/var-dumper.git",
|
||||
"reference": "40096a2515a979f3125c5c928603995b8664c62a"
|
||||
"reference": "865103cf742a039f34645b971fc3ace308d6c167"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a",
|
||||
"reference": "40096a2515a979f3125c5c928603995b8664c62a",
|
||||
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/865103cf742a039f34645b971fc3ace308d6c167",
|
||||
"reference": "865103cf742a039f34645b971fc3ace308d6c167",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -8343,7 +8625,7 @@
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/uid": "^7.4|^8.0",
|
||||
"twig/twig": "^3.12"
|
||||
"twig/twig": "^3.12|^4.0"
|
||||
},
|
||||
"bin": [
|
||||
"Resources/bin/var-dump-server"
|
||||
@ -8381,7 +8663,7 @@
|
||||
"dump"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/var-dumper/tree/v8.1.1"
|
||||
"source": "https://github.com/symfony/var-dumper/tree/v8.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -8401,7 +8683,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-09T10:54:51+00:00"
|
||||
"time": "2026-07-22T15:42:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
@ -8773,6 +9055,95 @@
|
||||
],
|
||||
"time": "2026-05-31T15:00:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "web-token/jwt-library",
|
||||
"version": "4.1.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/web-token/jwt-library.git",
|
||||
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
|
||||
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
|
||||
"php": ">=8.2",
|
||||
"psr/clock": "^1.0",
|
||||
"spomky-labs/pki-framework": "^1.2.1"
|
||||
},
|
||||
"conflict": {
|
||||
"spomky-labs/jose": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
|
||||
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
|
||||
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
|
||||
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
|
||||
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
|
||||
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
|
||||
"symfony/console": "Needed to use console commands",
|
||||
"symfony/http-client": "To enable JKU/X5U support."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jose\\Component\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Florent Morselli",
|
||||
"homepage": "https://github.com/Spomky"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/web-token/jwt-framework/contributors"
|
||||
}
|
||||
],
|
||||
"description": "JWT library",
|
||||
"homepage": "https://github.com/web-token",
|
||||
"keywords": [
|
||||
"JOSE",
|
||||
"JWE",
|
||||
"JWK",
|
||||
"JWKSet",
|
||||
"JWS",
|
||||
"Jot",
|
||||
"RFC7515",
|
||||
"RFC7516",
|
||||
"RFC7517",
|
||||
"RFC7518",
|
||||
"RFC7519",
|
||||
"RFC7520",
|
||||
"bundle",
|
||||
"jwa",
|
||||
"jwt",
|
||||
"symfony"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/web-token/jwt-library/issues",
|
||||
"source": "https://github.com/web-token/jwt-library/tree/4.1.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Spomky",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/FlorentMorselli",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-06T18:12:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "2.4.1",
|
||||
|
||||
46
config/webpush.php
Normal file
46
config/webpush.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use NotificationChannels\WebPush\PushSubscription;
|
||||
|
||||
return [
|
||||
|
||||
/**
|
||||
* These are the keys for authentication (VAPID).
|
||||
* These keys must be safely stored and should not change.
|
||||
*/
|
||||
'vapid' => [
|
||||
'subject' => env('VAPID_SUBJECT'),
|
||||
'public_key' => env('VAPID_PUBLIC_KEY'),
|
||||
'private_key' => env('VAPID_PRIVATE_KEY'),
|
||||
'pem_file' => env('VAPID_PEM_FILE'),
|
||||
],
|
||||
|
||||
/**
|
||||
* This is model that will be used to for push subscriptions.
|
||||
*/
|
||||
'model' => PushSubscription::class,
|
||||
|
||||
/**
|
||||
* This is the name of the table that will be created by the migration and
|
||||
* used by the PushSubscription model shipped with this package.
|
||||
*/
|
||||
'table_name' => env('WEBPUSH_DB_TABLE', 'push_subscriptions'),
|
||||
|
||||
/**
|
||||
* This is the database connection that will be used by the migration and
|
||||
* the PushSubscription model shipped with this package.
|
||||
*/
|
||||
'database_connection' => env('WEBPUSH_DB_CONNECTION', env('DB_CONNECTION', 'mysql')),
|
||||
|
||||
/**
|
||||
* The Guzzle client options used by Minishlink\WebPush.
|
||||
*/
|
||||
'client_options' => [],
|
||||
|
||||
/**
|
||||
* The automatic padding in bytes used by Minishlink\WebPush.
|
||||
* Set to false to support Firefox Android with v1 endpoint.
|
||||
*/
|
||||
'automatic_padding' => env('WEBPUSH_AUTOMATIC_PADDING', true),
|
||||
|
||||
];
|
||||
@ -6,26 +6,31 @@
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('push_subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->string('endpoint', 500);
|
||||
$table->string('public_key', 255)->nullable();
|
||||
$table->string('auth_token', 255)->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
|
||||
$table->unique('endpoint');
|
||||
Schema::connection(config('webpush.database_connection'))->create(config('webpush.table_name'), function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx');
|
||||
$table->string('endpoint', 500)->unique();
|
||||
$table->string('public_key')->nullable();
|
||||
$table->string('auth_token')->nullable();
|
||||
$table->string('content_encoding')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('push_subscriptions');
|
||||
Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import { NotificationBell } from '@/components/notification-bell';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
@ -14,7 +15,8 @@ export function AppSidebarHeader({
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<NavUser />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
266
resources/js/components/notification-bell.tsx
Normal file
266
resources/js/components/notification-bell.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import { Bell, Check, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
url: string | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications/unread-count', {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok && mountedRef.current) {
|
||||
const data = await response.json();
|
||||
setUnreadCount(data.count);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications', {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok && mountedRef.current) {
|
||||
const data = await response.json();
|
||||
setNotifications(data);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAsRead = useCallback(async (id: number) => {
|
||||
try {
|
||||
await fetch(`/api/notifications/${id}/read`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
|
||||
);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteNotification = useCallback(async (id: number) => {
|
||||
try {
|
||||
await fetch(`/api/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
await fetch('/api/notifications/read-all', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => ({ ...n, is_read: true })),
|
||||
);
|
||||
setUnreadCount(0);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch is safe
|
||||
void fetchUnreadCount();
|
||||
intervalRef.current = setInterval(() => {
|
||||
void fetchUnreadCount();
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe
|
||||
void fetchNotifications();
|
||||
}
|
||||
}, [isOpen, fetchNotifications]);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-9 w-9"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="sr-only">Notifikasi</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-sm font-semibold">Notifikasi</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void markAllAsRead();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Tandai semua dibaca
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Tidak ada notifikasi
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div key={notification.id}>
|
||||
<div
|
||||
className={`flex items-start gap-2 px-4 py-3 ${
|
||||
!notification.is_read
|
||||
? 'border-l-2 border-l-primary bg-primary/5'
|
||||
: 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
<a
|
||||
href={notification.url || '#'}
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (notification.url) {
|
||||
window.location.href =
|
||||
notification.url;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
!notification.is_read
|
||||
? 'font-semibold'
|
||||
: 'font-medium'
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</span>
|
||||
{notification.body && (
|
||||
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
|
||||
{notification.body}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{new Date(
|
||||
notification.created_at,
|
||||
).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</a>
|
||||
<div className="flex shrink-0 flex-col gap-1 pt-0.5">
|
||||
{!notification.is_read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title="Tandai sudah dibaca"
|
||||
onClick={() => {
|
||||
void markAsRead(
|
||||
notification.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
title="Hapus"
|
||||
onClick={() => {
|
||||
void deleteNotification(
|
||||
notification.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
149
resources/js/hooks/use-push-notification.ts
Normal file
149
resources/js/hooks/use-push-notification.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function getInitialPermission(): PermissionStatus {
|
||||
if (!('Notification' in window)) {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
return Notification.permission as PermissionStatus;
|
||||
}
|
||||
|
||||
function getIsSupported(): boolean {
|
||||
return (
|
||||
'Notification' in window &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window
|
||||
);
|
||||
}
|
||||
|
||||
export function usePushNotification() {
|
||||
const [permission, setPermission] =
|
||||
useState<PermissionStatus>(getInitialPermission);
|
||||
const [isSupported] = useState<boolean>(getIsSupported);
|
||||
|
||||
const requestPermission =
|
||||
useCallback(async (): Promise<PermissionStatus> => {
|
||||
if (!('Notification' in window)) {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result as PermissionStatus);
|
||||
|
||||
return result as PermissionStatus;
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback(
|
||||
async (vapidPublicKey: string): Promise<boolean> => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
vapidPublicKey,
|
||||
) as BufferSource,
|
||||
});
|
||||
|
||||
const { endpoint } = subscription;
|
||||
const key = subscription.getKey('p256dh');
|
||||
const auth = subscription.getKey('auth');
|
||||
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(
|
||||
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ??
|
||||
'',
|
||||
),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint,
|
||||
public_key: key
|
||||
? btoa(String.fromCharCode(...new Uint8Array(key)))
|
||||
: null,
|
||||
auth_token: auth
|
||||
? btoa(String.fromCharCode(...new Uint8Array(auth)))
|
||||
: null,
|
||||
content_encoding: 'aes128gcm',
|
||||
}),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to subscribe to push notifications:',
|
||||
error,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unsubscribe = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription =
|
||||
await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/push/unsubscribe', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(
|
||||
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
|
||||
),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to unsubscribe from push notifications:',
|
||||
error,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
permission,
|
||||
isSupported,
|
||||
requestPermission,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
@ -32,9 +32,10 @@ type Props = {
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
highlight?: number;
|
||||
};
|
||||
|
||||
export default function CategoryIndex({ categories }: Props) {
|
||||
export default function CategoryIndex({ categories, highlight }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
const [deleting, setDeleting] = useState<Category | null>(null);
|
||||
@ -149,6 +150,26 @@ export default function CategoryIndex({ categories }: Props) {
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Kategori
|
||||
</h2>
|
||||
{highlight && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Menampilkan kategori dari notifikasi.
|
||||
<button
|
||||
onClick={() => {
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{},
|
||||
{
|
||||
replace: true,
|
||||
preserveState: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Tampilkan semua
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<Button asChild>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import { Bell, Camera, MapPin } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { usePushNotification } from '@/hooks/use-push-notification';
|
||||
import { edit } from '@/routes/permissions';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Bell, Camera, MapPin } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||
|
||||
@ -16,8 +17,15 @@ const isPWA = () => {
|
||||
|
||||
const getPlatform = (): 'android' | 'ios' | 'desktop' => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (/android/.test(ua)) return 'android';
|
||||
if (/iphone|ipad|ipod/.test(ua)) return 'ios';
|
||||
|
||||
if (/android/.test(ua)) {
|
||||
return 'android';
|
||||
}
|
||||
|
||||
if (/iphone|ipad|ipod/.test(ua)) {
|
||||
return 'ios';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
};
|
||||
|
||||
@ -38,83 +46,140 @@ const getDeniedMessage = (appName: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
function getInitialNotificationPermission(): PermissionStatus {
|
||||
if (!('Notification' in window)) {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
return Notification.permission as PermissionStatus;
|
||||
}
|
||||
|
||||
function getInitialCameraPermission(): PermissionStatus {
|
||||
if (!navigator.mediaDevices) {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getInitialLocationPermission(): PermissionStatus {
|
||||
if (!('geolocation' in navigator)) {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export default function Permissions() {
|
||||
const [notifications, setNotifications] =
|
||||
useState<PermissionStatus>('unknown');
|
||||
const [camera, setCamera] = useState<PermissionStatus>('unknown');
|
||||
const [location, setLocation] = useState<PermissionStatus>('unknown');
|
||||
const { vapidPublicKey } = usePage().props as { vapidPublicKey?: string };
|
||||
const [notifications, setNotifications] = useState<PermissionStatus>(
|
||||
getInitialNotificationPermission,
|
||||
);
|
||||
const [camera, setCamera] = useState<PermissionStatus>(
|
||||
getInitialCameraPermission,
|
||||
);
|
||||
const [location, setLocation] = useState<PermissionStatus>(
|
||||
getInitialLocationPermission,
|
||||
);
|
||||
const [isSubscribing, setIsSubscribing] = useState(false);
|
||||
|
||||
const { isSupported, requestPermission, subscribe, unsubscribe } =
|
||||
usePushNotification();
|
||||
|
||||
const appName = 'DST Collection';
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted')
|
||||
setNotifications('granted');
|
||||
else if (Notification.permission === 'denied')
|
||||
setNotifications('denied');
|
||||
else setNotifications('prompt');
|
||||
const checkCameraPermission = useCallback(async () => {
|
||||
if (!navigator.mediaDevices) {
|
||||
setCamera('denied');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.mediaDevices) {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setCamera('granted');
|
||||
})
|
||||
.catch(() => {
|
||||
} catch {
|
||||
if ('permissions' in navigator) {
|
||||
navigator.permissions
|
||||
.query({ name: 'camera' as PermissionName })
|
||||
.then((result) => {
|
||||
try {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'camera' as PermissionName,
|
||||
});
|
||||
setCamera(result.state as PermissionStatus);
|
||||
})
|
||||
.catch(() => setCamera('denied'));
|
||||
} catch {
|
||||
setCamera('denied');
|
||||
}
|
||||
} else {
|
||||
setCamera('denied');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCamera('denied');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkLocationPermission = useCallback(async () => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
setLocation('denied');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
() => setLocation('granted'),
|
||||
() => {
|
||||
async () => {
|
||||
if ('permissions' in navigator) {
|
||||
navigator.permissions
|
||||
.query({ name: 'geolocation' })
|
||||
.then((result) => {
|
||||
try {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'geolocation',
|
||||
});
|
||||
setLocation(result.state as PermissionStatus);
|
||||
})
|
||||
.catch(() => setLocation('denied'));
|
||||
} catch {
|
||||
setLocation('denied');
|
||||
}
|
||||
} else {
|
||||
setLocation('denied');
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setLocation('denied');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
|
||||
checkCameraPermission();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
|
||||
checkLocationPermission();
|
||||
}, [checkCameraPermission, checkLocationPermission]);
|
||||
|
||||
const handleNotifications = async (checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!('Notification' in window)) return;
|
||||
if (Notification.permission === 'denied') {
|
||||
setNotifications('denied');
|
||||
if (isSubscribing) {
|
||||
return;
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
setNotifications(
|
||||
result === 'granted'
|
||||
? 'granted'
|
||||
: result === 'denied'
|
||||
? 'denied'
|
||||
: 'prompt',
|
||||
);
|
||||
|
||||
if (checked) {
|
||||
if (!isSupported || !('Notification' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
setNotifications('denied');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubscribing(true);
|
||||
|
||||
try {
|
||||
const result = await requestPermission();
|
||||
setNotifications(result);
|
||||
|
||||
if (result === 'granted' && vapidPublicKey) {
|
||||
await subscribe(vapidPublicKey);
|
||||
}
|
||||
} finally {
|
||||
setIsSubscribing(false);
|
||||
}
|
||||
} else {
|
||||
await unsubscribe();
|
||||
setNotifications('prompt');
|
||||
}
|
||||
};
|
||||
@ -179,7 +244,9 @@ export default function Permissions() {
|
||||
<Switch
|
||||
checked={notifications === 'granted'}
|
||||
onCheckedChange={handleNotifications}
|
||||
disabled={isDenied(notifications)}
|
||||
disabled={
|
||||
isDenied(notifications) || isSubscribing
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
106
resources/js/sw.ts
Normal file
106
resources/js/sw.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { precacheAndRoute } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
registerRoute(
|
||||
({ url }) => url.origin === 'https://fonts.bunny.net',
|
||||
new CacheFirst({
|
||||
cacheName: 'bunny-fonts-cache',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) => url.origin === 'https://fonts.googleapis.com',
|
||||
new CacheFirst({
|
||||
cacheName: 'google-fonts-cache',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/'),
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 10,
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
self.addEventListener('push', (event: PushEvent) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data.json();
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '/icon-192x192.png',
|
||||
badge: '/icon-192x192.png',
|
||||
data: data.data || {},
|
||||
actions: data.actions || [],
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(
|
||||
data.title || 'DST Collection',
|
||||
options,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||
event.notification.close();
|
||||
|
||||
const action = event.action;
|
||||
const data = event.notification.data;
|
||||
|
||||
let url = '/';
|
||||
|
||||
if (action === 'view_category' && data.category_id) {
|
||||
url = `/admin/master/categories`;
|
||||
} else if (data.url) {
|
||||
url = data.url;
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clients) => {
|
||||
for (const client of clients) {
|
||||
if (client.url.includes(url) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(url);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\NotificationController;
|
||||
use App\Http\Controllers\Api\PresignedUrlController;
|
||||
use App\Http\Controllers\Api\PushSubscriptionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/presigned-url', [PresignedUrlController::class, 'store'])
|
||||
@ -11,3 +13,31 @@
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('presigned-url.show')
|
||||
->where('key', '.*');
|
||||
|
||||
Route::post('/push/subscribe', [PushSubscriptionController::class, 'store'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('push.subscribe');
|
||||
|
||||
Route::delete('/push/unsubscribe', [PushSubscriptionController::class, 'destroy'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('push.unsubscribe');
|
||||
|
||||
Route::get('/notifications', [NotificationController::class, 'index'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('notifications.index');
|
||||
|
||||
Route::get('/notifications/unread-count', [NotificationController::class, 'unreadCount'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('notifications.unread-count');
|
||||
|
||||
Route::patch('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('notifications.mark-as-read');
|
||||
|
||||
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('notifications.destroy');
|
||||
|
||||
Route::patch('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])
|
||||
->middleware(['web', 'auth', 'verified'])
|
||||
->name('notifications.mark-all-as-read');
|
||||
|
||||
888
tests/Feature/Admin/NotificationTest.php
Normal file
888
tests/Feature/Admin/NotificationTest.php
Normal file
@ -0,0 +1,888 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attendance;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Expense;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use App\Notifications\WebPushNotification;
|
||||
use App\Services\Admin\Finance\CashAccountService;
|
||||
use App\Services\Admin\Finance\EmployeeAdvanceService;
|
||||
use App\Services\Admin\Finance\ExpenseService;
|
||||
use App\Services\Admin\Finance\PayrollPeriodService;
|
||||
use App\Services\Admin\HR\AttendanceService;
|
||||
use App\Services\Admin\HR\LeaveRequestService;
|
||||
use App\Services\Admin\Master\ProductService;
|
||||
use App\Services\NotificationService;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
app()[PermissionRegistrar::class]->forgetCachedPermissions();
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HELPERS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function notifUserWithRole(string $role, bool $active = true): User
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => $active]);
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function notifEmployeeWithRole(string $role = 'Kasir'): array
|
||||
{
|
||||
$user = notifUserWithRole($role);
|
||||
$employee = Employee::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
return ['user' => $user, 'employee' => $employee];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| NOTIFICATION SERVICE — UNIT TESTS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('NotificationService creates in-app notification for users with matching role', function () {
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
notifUserWithRole('Kasir'); // should NOT receive
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer'],
|
||||
title: 'Test Title',
|
||||
body: 'Test Body.',
|
||||
url: '/test-url'
|
||||
);
|
||||
|
||||
expect($developer->notifications()->count())->toBe(1);
|
||||
expect($owner->notifications()->count())->toBe(1);
|
||||
|
||||
$notification = $developer->notifications()->first();
|
||||
expect($notification->title)->toBe('Test Title')
|
||||
->and($notification->body)->toBe('Test Body.')
|
||||
->and($notification->url)->toBe('/test-url')
|
||||
->and($notification->is_read)->toBeFalse();
|
||||
});
|
||||
|
||||
test('NotificationService does not notify inactive users', function () {
|
||||
notifUserWithRole('Developer', active: false);
|
||||
$active = notifUserWithRole('Developer');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Developer'],
|
||||
title: 'Test',
|
||||
body: 'Body.',
|
||||
url: '/test'
|
||||
);
|
||||
|
||||
expect($active->notifications()->count())->toBe(1);
|
||||
expect(User::where('is_active', false)->first()->notifications()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('NotificationService dispatches WebPushNotification to each user', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Developer', 'Owner'],
|
||||
title: 'Push Title',
|
||||
body: 'Push Body.',
|
||||
url: '/push'
|
||||
);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
});
|
||||
|
||||
test('NotificationService includes additionalUser when not already in role-based list', function () {
|
||||
$kasir = notifUserWithRole('Kasir');
|
||||
$developer = notifUserWithRole('Developer');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Developer'],
|
||||
title: 'Title',
|
||||
body: 'Body.',
|
||||
url: '/url',
|
||||
additionalUser: $kasir
|
||||
);
|
||||
|
||||
expect($kasir->notifications()->count())->toBe(1);
|
||||
expect($developer->notifications()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('NotificationService does not duplicate additionalUser already in role list', function () {
|
||||
$developer = notifUserWithRole('Developer');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Developer'],
|
||||
title: 'Title',
|
||||
body: 'Body.',
|
||||
url: '/url',
|
||||
additionalUser: $developer
|
||||
);
|
||||
|
||||
expect($developer->notifications()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('NotificationService handles null additionalUser', function () {
|
||||
$developer = notifUserWithRole('Developer');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Developer'],
|
||||
title: 'Title',
|
||||
body: 'Body.',
|
||||
url: '/url',
|
||||
additionalUser: null
|
||||
);
|
||||
|
||||
expect($developer->notifications()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('NotificationService notifies multiple users across multiple roles', function () {
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$direktur = notifUserWithRole('Direktur');
|
||||
$adminToko = notifUserWithRole('Admin Toko');
|
||||
notifUserWithRole('Kasir'); // should NOT receive
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
|
||||
title: 'Multi Role',
|
||||
body: 'Body.',
|
||||
url: '/multi'
|
||||
);
|
||||
|
||||
expect($developer->notifications()->count())->toBe(1);
|
||||
expect($owner->notifications()->count())->toBe(1);
|
||||
expect($direktur->notifications()->count())->toBe(1);
|
||||
expect($adminToko->notifications()->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| PRODUCT NOTIFICATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('ProductService create sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$kasir = notifUserWithRole('Kasir');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new ProductService;
|
||||
$product = $service->create([
|
||||
'name' => 'Test Product',
|
||||
'description' => 'A test product',
|
||||
'status' => 'active',
|
||||
'category_ids' => [],
|
||||
'use_same_price' => false,
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Default',
|
||||
'stock' => 10,
|
||||
'reject_stock' => 0,
|
||||
'retail_stock' => 5,
|
||||
'prices' => [
|
||||
['type' => 'retail', 'price' => 50000],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertNothingSentTo($kasir);
|
||||
|
||||
expect($developer->notifications()->count())->toBe(1);
|
||||
expect($developer->notifications()->first()->title)->toBe('Produk Baru');
|
||||
});
|
||||
|
||||
test('ProductService update sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$adminToko = notifUserWithRole('Admin Toko');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new ProductService;
|
||||
$product = $service->create([
|
||||
'name' => 'Original Product',
|
||||
'description' => 'Desc',
|
||||
'status' => 'active',
|
||||
'category_ids' => [],
|
||||
'use_same_price' => false,
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Var 1',
|
||||
'stock' => 10,
|
||||
'reject_stock' => 0,
|
||||
'retail_stock' => 5,
|
||||
'prices' => [
|
||||
['type' => 'retail', 'price' => 50000],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$product = $service->update($product, [
|
||||
'name' => 'Updated Product',
|
||||
'description' => 'Desc updated',
|
||||
'status' => 'active',
|
||||
'category_ids' => [],
|
||||
'use_same_price' => false,
|
||||
'variants' => [
|
||||
[
|
||||
'id' => $product->productVariants->first()->id,
|
||||
'name' => 'Var 1',
|
||||
'stock' => 20,
|
||||
'reject_stock' => 0,
|
||||
'retail_stock' => 10,
|
||||
'prices' => [
|
||||
['type' => 'retail', 'price' => 60000],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertSentTo($adminToko, WebPushNotification::class);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CASH ACCOUNT NOTIFICATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('CashAccountService deposit sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$kasir = notifUserWithRole('Kasir');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new CashAccountService;
|
||||
$service->deposit([
|
||||
'amount' => 500000,
|
||||
'description' => 'Setoran modal',
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertNothingSentTo($kasir);
|
||||
|
||||
expect($developer->notifications()->first()->title)->toBe('Setoran Kas Toko');
|
||||
});
|
||||
|
||||
test('CashAccountService withdrawal sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$direktur = notifUserWithRole('Direktur');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new CashAccountService;
|
||||
$service->withdrawal([
|
||||
'amount' => 200000,
|
||||
'description' => 'Penarikan kas',
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($direktur, WebPushNotification::class);
|
||||
|
||||
expect($developer->notifications()->first()->title)->toBe('Penarikan Kas Toko');
|
||||
});
|
||||
|
||||
test('CashAccountService updateTransaction sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new CashAccountService;
|
||||
$transaction = $service->deposit([
|
||||
'amount' => 500000,
|
||||
'description' => 'Initial deposit',
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$service->updateTransaction($transaction, [
|
||||
'amount' => 600000,
|
||||
'description' => 'Updated deposit',
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
|
||||
expect($developer->notifications()->where('title', 'Transaksi Kas Diperbarui')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('CashAccountService deleteTransaction sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new CashAccountService;
|
||||
$transaction = $service->deposit([
|
||||
'amount' => 500000,
|
||||
'description' => 'To be deleted',
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$service->deleteTransaction($transaction);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
|
||||
expect($developer->notifications()->where('title', 'Transaksi Kas Dihapus')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| EXPENSE NOTIFICATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('ExpenseService create sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
CashAccount::factory()->create(['balance' => 5000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$kasir = notifUserWithRole('Kasir');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new ExpenseService;
|
||||
$expense = $service->create([
|
||||
'amount' => 100000,
|
||||
'description' => 'Office supplies',
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertNothingSentTo($kasir);
|
||||
|
||||
expect($developer->notifications()->first()->title)->toBe('Pengeluaran Baru');
|
||||
});
|
||||
|
||||
test('ExpenseService update sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
CashAccount::factory()->create(['balance' => 5000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$adminToko = notifUserWithRole('Admin Toko');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new ExpenseService;
|
||||
$expense = $service->create([
|
||||
'amount' => 100000,
|
||||
'description' => 'Original',
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$service->update($expense, [
|
||||
'amount' => 150000,
|
||||
'description' => 'Updated expense',
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($adminToko, WebPushNotification::class);
|
||||
|
||||
expect($developer->notifications()->where('title', 'Pengeluaran Diperbarui')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('ExpenseService delete sends notification to correct roles', function () {
|
||||
Notification::fake();
|
||||
|
||||
CashAccount::factory()->create(['balance' => 5000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$direktur = notifUserWithRole('Direktur');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new ExpenseService;
|
||||
$expense = $service->create([
|
||||
'amount' => 100000,
|
||||
'description' => 'To delete',
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$service->delete($expense);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($direktur, WebPushNotification::class);
|
||||
|
||||
expect($developer->notifications()->where('title', 'Pengeluaran Dihapus')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| EMPLOYEE ADVANCE (KASBON) NOTIFICATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('EmployeeAdvanceService create sends notification including the requesting employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
CashAccount::factory()->create(['balance' => 5000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$kasbonData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $kasbonData['employee'];
|
||||
$kasbonUser = $kasbonData['user'];
|
||||
|
||||
$this->actingAs($kasbonUser);
|
||||
|
||||
$service = new EmployeeAdvanceService;
|
||||
$advance = $service->create([
|
||||
'amount' => 500000,
|
||||
'description' => 'Emergency need',
|
||||
'due_date' => now()->addDays(30)->toDateString(),
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
|
||||
|
||||
expect($kasbonUser->notifications()->first()->title)->toBe('Kasbon Baru');
|
||||
});
|
||||
|
||||
test('EmployeeAdvanceService approve sends notification including the employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
CashAccount::factory()->create(['balance' => 5000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$kasbonData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $kasbonData['employee'];
|
||||
$kasbonUser = $kasbonData['user'];
|
||||
|
||||
$this->actingAs($kasbonUser);
|
||||
|
||||
$service = new EmployeeAdvanceService;
|
||||
$advance = $service->create([
|
||||
'amount' => 300000,
|
||||
'description' => 'Advance',
|
||||
'due_date' => now()->addDays(14)->toDateString(),
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$admin = notifUserWithRole('Admin Toko');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$service->approve($advance);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
|
||||
|
||||
expect($kasbonUser->notifications()->where('title', 'Kasbon Disetujui')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('EmployeeAdvanceService pay sends notification including the employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
CashAccount::factory()->create(['balance' => 5000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$kasbonData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $kasbonData['employee'];
|
||||
$kasbonUser = $kasbonData['user'];
|
||||
|
||||
$this->actingAs($kasbonUser);
|
||||
|
||||
$service = new EmployeeAdvanceService;
|
||||
$advance = $service->create([
|
||||
'amount' => 200000,
|
||||
'description' => 'To pay',
|
||||
'due_date' => now()->addDays(7)->toDateString(),
|
||||
]);
|
||||
|
||||
$admin = notifUserWithRole('Admin Toko');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$service->approve($advance);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$service->pay($advance);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
|
||||
|
||||
expect($kasbonUser->notifications()->where('title', 'Kasbon Dibayar')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| PAYROLL NOTIFICATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('PayrollPeriodService pay sends notification including the employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
CashAccount::factory()->create(['balance' => 50000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$employeeData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $employeeData['employee'];
|
||||
$employeeUser = $employeeData['user'];
|
||||
|
||||
$period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7, 'status' => 'open']);
|
||||
$payroll = Payroll::factory()->create([
|
||||
'payroll_period_id' => $period->id,
|
||||
'employee_id' => $employee->id,
|
||||
'base_salary' => 3000000,
|
||||
'bonus_amount' => 0,
|
||||
'deduction_amount' => 0,
|
||||
'total_amount' => 3000000,
|
||||
'status' => 'unpaid',
|
||||
]);
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new PayrollPeriodService;
|
||||
$service->pay($payroll);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertSentTo($employeeUser, WebPushNotification::class);
|
||||
|
||||
expect($employeeUser->notifications()->where('title', 'Gaji Dibayar')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('PayrollPeriodService cancel sends notification including the employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$direktur = notifUserWithRole('Direktur');
|
||||
$employeeData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $employeeData['employee'];
|
||||
$employeeUser = $employeeData['user'];
|
||||
|
||||
$period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 8, 'status' => 'open']);
|
||||
$payroll = Payroll::factory()->create([
|
||||
'payroll_period_id' => $period->id,
|
||||
'employee_id' => $employee->id,
|
||||
'base_salary' => 3000000,
|
||||
'bonus_amount' => 0,
|
||||
'deduction_amount' => 0,
|
||||
'total_amount' => 3000000,
|
||||
'status' => 'unpaid',
|
||||
]);
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new PayrollPeriodService;
|
||||
$service->cancel($payroll);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($direktur, WebPushNotification::class);
|
||||
Notification::assertSentTo($employeeUser, WebPushNotification::class);
|
||||
|
||||
expect($employeeUser->notifications()->where('title', 'Gaji Dibatalkan')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| LEAVE REQUEST NOTIFICATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('LeaveRequestService create sends notification including the requesting employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$leaveData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $leaveData['employee'];
|
||||
$leaveUser = $leaveData['user'];
|
||||
|
||||
$this->actingAs($leaveUser);
|
||||
|
||||
$service = new LeaveRequestService;
|
||||
$leaveRequest = $service->create([
|
||||
'start_date' => now()->addDays(5)->toDateString(),
|
||||
'end_date' => now()->addDays(7)->toDateString(),
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertSentTo($leaveUser, WebPushNotification::class);
|
||||
|
||||
expect($leaveUser->notifications()->first()->title)->toBe('Pengajuan Cuti Baru');
|
||||
});
|
||||
|
||||
test('LeaveRequestService approve sends notification including the employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$leaveData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $leaveData['employee'];
|
||||
$leaveUser = $leaveData['user'];
|
||||
|
||||
$this->actingAs($leaveUser);
|
||||
|
||||
$service = new LeaveRequestService;
|
||||
$leaveRequest = $service->create([
|
||||
'start_date' => now()->addDays(10)->toDateString(),
|
||||
'end_date' => now()->addDays(12)->toDateString(),
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$admin = notifUserWithRole('Admin Toko');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$service->approve($leaveRequest);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($leaveUser, WebPushNotification::class);
|
||||
|
||||
expect($leaveUser->notifications()->where('title', 'Cuti Disetujui')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('LeaveRequestService reject sends notification including the employee', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$leaveData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $leaveData['employee'];
|
||||
$leaveUser = $leaveData['user'];
|
||||
|
||||
$this->actingAs($leaveUser);
|
||||
|
||||
$service = new LeaveRequestService;
|
||||
$leaveRequest = $service->create([
|
||||
'start_date' => now()->addDays(15)->toDateString(),
|
||||
'end_date' => now()->addDays(17)->toDateString(),
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$admin = notifUserWithRole('Admin Toko');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$service->reject($leaveRequest);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($leaveUser, WebPushNotification::class);
|
||||
|
||||
expect($leaveUser->notifications()->where('title', 'Cuti Ditolak')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ATTENDANCE NOTIFICATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('AttendanceService checkIn sends notification to Developer, Owner, Direktur', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$direktur = notifUserWithRole('Direktur');
|
||||
$adminToko = notifUserWithRole('Admin Toko');
|
||||
|
||||
$attData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $attData['employee'];
|
||||
$attendant = $attData['user'];
|
||||
|
||||
$this->actingAs($attendant);
|
||||
|
||||
$service = new AttendanceService;
|
||||
$attendance = $service->checkIn([
|
||||
'latitude' => -6.200000,
|
||||
'longitude' => 106.816666,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertSentTo($direktur, WebPushNotification::class);
|
||||
Notification::assertNothingSentTo($adminToko);
|
||||
|
||||
expect($developer->notifications()->first()->title)->toBe('Presensi Masuk');
|
||||
});
|
||||
|
||||
test('AttendanceService checkOut sends notification to Developer, Owner, Direktur', function () {
|
||||
Notification::fake();
|
||||
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
$direktur = notifUserWithRole('Direktur');
|
||||
$adminToko = notifUserWithRole('Admin Toko');
|
||||
|
||||
$attData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $attData['employee'];
|
||||
$attendant = $attData['user'];
|
||||
|
||||
$this->actingAs($attendant);
|
||||
|
||||
$service = new AttendanceService;
|
||||
$attendance = $service->checkIn([
|
||||
'latitude' => -6.200000,
|
||||
'longitude' => 106.816666,
|
||||
]);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$attendance = $service->checkOut($attendance, [
|
||||
'latitude' => -6.200100,
|
||||
'longitude' => 106.816700,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($owner, WebPushNotification::class);
|
||||
Notification::assertSentTo($direktur, WebPushNotification::class);
|
||||
Notification::assertNothingSentTo($adminToko);
|
||||
|
||||
expect($developer->notifications()->where('title', 'Presensi Pulang')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IN-APP NOTIFICATION RECORDS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('services create in-app notification records in database', function () {
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$owner = notifUserWithRole('Owner');
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Developer', 'Owner'],
|
||||
title: 'DB Test',
|
||||
body: 'Body here.',
|
||||
url: '/db-test'
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'user_id' => $developer->id,
|
||||
'title' => 'DB Test',
|
||||
'body' => 'Body here.',
|
||||
'url' => '/db-test',
|
||||
'is_read' => false,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'DB Test',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ProductService create creates in-app notification record', function () {
|
||||
CashAccount::factory()->create(['balance' => 1000000]);
|
||||
$developer = notifUserWithRole('Developer');
|
||||
|
||||
$this->actingAs($developer);
|
||||
|
||||
$service = new ProductService;
|
||||
$product = $service->create([
|
||||
'name' => 'DB Notification Product',
|
||||
'description' => 'Test',
|
||||
'status' => 'active',
|
||||
'category_ids' => [],
|
||||
'use_same_price' => false,
|
||||
'variants' => [
|
||||
[
|
||||
'name' => 'Var',
|
||||
'stock' => 10,
|
||||
'reject_stock' => 0,
|
||||
'retail_stock' => 5,
|
||||
'prices' => [
|
||||
['type' => 'retail', 'price' => 50000],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'user_id' => $developer->id,
|
||||
'title' => 'Produk Baru',
|
||||
]);
|
||||
});
|
||||
|
||||
test('CashAccountService deposit creates in-app notification record', function () {
|
||||
CashAccount::factory()->create(['balance' => 5000000]);
|
||||
$owner = notifUserWithRole('Owner');
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
$service = new CashAccountService;
|
||||
$service->deposit([
|
||||
'amount' => 250000,
|
||||
'description' => 'Test deposit',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Setoran Kas Toko',
|
||||
]);
|
||||
});
|
||||
|
||||
test('AttendanceService checkIn creates in-app notification record', function () {
|
||||
$developer = notifUserWithRole('Developer');
|
||||
$attData = notifEmployeeWithRole('Kasir');
|
||||
$employee = $attData['employee'];
|
||||
$attendant = $attData['user'];
|
||||
|
||||
$this->actingAs($attendant);
|
||||
|
||||
$service = new AttendanceService;
|
||||
$service->checkIn([
|
||||
'latitude' => -6.2,
|
||||
'longitude' => 106.8,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'user_id' => $developer->id,
|
||||
'title' => 'Presensi Masuk',
|
||||
]);
|
||||
});
|
||||
@ -117,5 +117,8 @@
|
||||
"resources/js/**/*.ts",
|
||||
"resources/js/**/*.d.ts",
|
||||
"resources/js/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"resources/js/sw.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@ -32,44 +32,10 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'favicon.svg', 'apple-touch-icon.png', 'assets/logo.png'],
|
||||
manifest: false,
|
||||
srcDir: 'resources/js',
|
||||
filename: 'sw.ts',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.bunny\.net\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'bunny-fonts-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 10,
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user