diff --git a/.gitignore b/.gitignore index fd45451..0facf34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,48 @@ # Dependencies node_modules/ +vendor/ -# Environment +# Environment files (all environments and subdirectories) .env -.env.backup -.env.production -.env.local +.env.* +.env*.local +*.env +*.env.* !.env.example +!*.env.example -# OS / editors +# OS & Editor artifacts .DS_Store +Thumbs.db /.fleet /.idea /.nova /.vscode /.zed .cursor/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? agents.md claude.md +# Sensitive keys & certificates +*.pem +*.key +*.cert +*.crt +id_rsa* +id_ed25519* + +# Logs & Debugging +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + # Backend (Laravel) backend/vendor/ backend/.phpunit.cache @@ -29,6 +53,18 @@ backend/public/hot backend/public/storage backend/storage/*.key backend/storage/pail +backend/storage/logs/*.log +backend/storage/framework/cache/* +!backend/storage/framework/cache/.gitignore +backend/storage/framework/sessions/* +!backend/storage/framework/sessions/.gitignore +backend/storage/framework/views/* +!backend/storage/framework/views/.gitignore +backend/storage/app/bills/* +backend/storage/app/public/avatars/* +backend/storage/app/public/weekly-menus/* +backend/bootstrap/cache/*.php +!backend/bootstrap/cache/.gitignore backend/bootstrap/ssr backend/resources/js/actions backend/resources/js/routes @@ -36,9 +72,11 @@ backend/resources/js/wayfinder backend/Homestead.json backend/Homestead.yaml backend/auth.json -backend/npm-debug.log -backend/yarn-error.log # Frontend (Next.js) frontend/.next/ frontend/out/ +frontend/build/ +frontend/*.tsbuildinfo +frontend/next-env.d.ts + diff --git a/README.md b/README.md index d5179e5..7f28660 100644 --- a/README.md +++ b/README.md @@ -75,16 +75,24 @@ pnpm dev ### Backend (`backend/.env`) -Defaults in `.env.example` match Docker Compose: +Configure your environment variables by copying the example template: -| Variable | Default | -|----------------|-------------| -| `DB_CONNECTION`| `pgsql` | -| `DB_HOST` | `127.0.0.1` | -| `DB_PORT` | `5432` | -| `DB_DATABASE` | `mealbuddy` | -| `DB_USERNAME` | `mealbuddy` | -| `DB_PASSWORD` | `secret` | +```bash +cp backend/.env.example backend/.env +php artisan key:generate +``` + +Key environment configuration: + +| Variable | Description | +|---|---| +| `APP_ENV` | `local` for development, `production` on server | +| `APP_DEBUG` | `true` for development, `false` on server | +| `DB_CONNECTION` | Database driver (`pgsql`) | +| `DB_HOST` / `DB_PORT` | PostgreSQL host and port | +| `DB_DATABASE` | Database name | +| `DB_USERNAME` | Database username | +| `DB_PASSWORD` | Strong password (configured in `.env`) | ### Frontend (`frontend/.env.local`) @@ -119,8 +127,17 @@ PHPUnit uses in-memory SQLite (fast, no Docker required): cd backend && composer test ``` +## Security Best Practices + +- **Never commit `.env` files**: All sensitive secrets (database passwords, application keys, API keys) must remain in local/server `.env` files and never be checked into version control. +- **Production settings**: Ensure `APP_ENV=production` and `APP_DEBUG=false` in production to prevent stack traces from leaking to users. +- **Unique App Key**: Always run `php artisan key:generate` on initial setup so each environment uses its own unique cryptographic encryption key. +- **CORS & Sanctum**: In production, restrict `SANCTUM_STATEFUL_DOMAINS` and CORS allowed origins (`config/cors.php`) strictly to your production domain (`meals.monlamit.com`). +- **File Permissions**: Keep `backend/storage` and `backend/bootstrap/cache` writable only by the web server user (`www-data`). + ## Notes - CORS and Sanctum allow `http://localhost:3000` (`backend/config/cors.php`, `backend/config/sanctum.php`). - Inertia pages under `backend/resources/js` are for Filament / legacy routes; the main app UI is in `frontend/`. - Change Postgres credentials in both root `.env` (Docker) and `backend/.env` (Laravel) if you customize them. + diff --git a/backend/.env.example b/backend/.env.example index 97d8ba7..17406cc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -69,6 +69,12 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false +# Web Push Notifications (VAPID) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@mealbuddy.monlam.ai + + VITE_APP_NAME="${APP_NAME}" LEAVE_TRACKER_API_KEY= diff --git a/backend/app/Console/Commands/SendLunchReminders.php b/backend/app/Console/Commands/SendLunchReminders.php new file mode 100644 index 0000000..87e7488 --- /dev/null +++ b/backend/app/Console/Commands/SendLunchReminders.php @@ -0,0 +1,106 @@ +option('force'); + + if (! $isForce) { + // 1. Skip on weekends + if ($today->isWeekend()) { + $this->info('Today is a weekend. Skipping lunch reminder.'); + return Command::SUCCESS; + } + + // 2. Skip on registered holidays/off-days + if (OffDay::whereDate('date', $today)->exists()) { + $this->info('Today is an official holiday/off-day. Skipping lunch reminder.'); + return Command::SUCCESS; + } + } + + // 3. Find today's lunch day record to check if any users already opted out + $lunchDay = LunchDay::whereDate('lunch_date', $today)->first(); + $optedOutUserIds = []; + + if ($lunchDay) { + $optedOutUserIds = LunchOrder::where('lunch_day_id', $lunchDay->id) + ->where('status', 'opted_out') + ->pluck('user_id') + ->toArray(); + } + + // 4. Query active employees who have push subscriptions and have NOT opted out + $users = User::where('role', 'employee') + ->where('is_active', true) + ->whereNotIn('id', $optedOutUserIds) + ->whereHas('pushSubscriptions') + ->with('pushSubscriptions') + ->get(); + + if ($users->isEmpty()) { + $this->info('No eligible employees with push subscriptions found to notify.'); + return Command::SUCCESS; + } + + $this->info("Sending lunch reminders to {$users->count()} employees..."); + + $sentCount = 0; + $failedCount = 0; + + foreach ($users as $user) { + foreach ($user->pushSubscriptions as $sub) { + $locale = $sub->locale ?? 'bo'; + + $payload = $locale === 'en' ? [ + 'title' => '🍽️ MealBuddy — Lunch Reminder', + 'body' => 'You’re in for lunch today! If you’re not joining, tap here to let us know before 10:00 AM.', + 'url' => '/vote', + ] : [ + 'title' => '🍽️ ཟས་མཐུན་ལས་རོགས། - ཉིན་གུང་གི་དྲན་སྐུལ།', + 'body' => 'ཁྱེད་རང་དེ་རིང་ཉིན་གུང་ལ་མཉམ་ཞུགས་བྱེད་ཀྱི་ཡོད་དམ། གལ་ཏེ་མཉམ་ཞུགས་བྱེད་ཀྱི་མེད་ན་ཆུ་ཚོད་ ༡༠:༠༠ སྔོན་ལ་འདིར་ཐེངས་ཤིག་བསྣུན་ནས་ང་ཚོར་ཤེས་སུ་འཇུག་རོགས།', + 'url' => '/vote', + ]; + + if ($webPushService->sendToSubscription($sub, $payload)) { + $sentCount++; + } else { + $failedCount++; + } + } + } + + $this->info("Reminders dispatched: {$sentCount} sent, {$failedCount} failed."); + + return Command::SUCCESS; + } +} diff --git a/backend/app/Http/Controllers/Api/PushSubscriptionController.php b/backend/app/Http/Controllers/Api/PushSubscriptionController.php new file mode 100644 index 0000000..7169e1a --- /dev/null +++ b/backend/app/Http/Controllers/Api/PushSubscriptionController.php @@ -0,0 +1,116 @@ +webPushService = $webPushService; + } + + /** + * Return public VAPID key to the frontend so it can create a subscription. + */ + public function getVapidPublicKey(): JsonResponse + { + $publicKey = config('services.webpush.vapid_public_key', env('VAPID_PUBLIC_KEY')); + + return response()->json([ + 'vapid_public_key' => $publicKey, + ]); + } + + /** + * Store or update a user's push subscription. + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'endpoint' => 'required|string', + 'keys.p256dh' => 'nullable|string', + 'keys.auth' => 'nullable|string', + 'content_encoding' => 'nullable|string', + 'locale' => 'nullable|string|in:bo,en', + ]); + + $user = $request->user(); + + $subscription = PushSubscription::updateOrCreate( + ['endpoint' => $request->input('endpoint')], + [ + 'user_id' => $user->id, + 'public_key' => $request->input('keys.p256dh'), + 'auth_token' => $request->input('keys.auth'), + 'content_encoding' => $request->input('content_encoding', 'aes128gcm'), + 'locale' => $request->input('locale', 'bo'), + ] + ); + + return response()->json([ + 'message' => 'Push subscription saved successfully.', + 'subscription' => $subscription, + ]); + } + + /** + * Remove a user's push subscription (unsubscribe). + */ + public function destroy(Request $request): JsonResponse + { + $request->validate([ + 'endpoint' => 'required|string', + ]); + + $deleted = $request->user()->pushSubscriptions() + ->where('endpoint', $request->input('endpoint')) + ->delete(); + + return response()->json([ + 'message' => 'Push subscription removed successfully.', + 'deleted' => $deleted, + ]); + } + + /** + * Trigger a test push notification to the current user. + */ + public function test(Request $request): JsonResponse + { + $user = $request->user(); + $subs = $user->pushSubscriptions; + + if ($subs->isEmpty()) { + return response()->json([ + 'error' => 'No active push subscriptions found for this user.', + ], 404); + } + + $locale = $request->input('locale', $subs->first()->locale ?? 'bo'); + + $payload = $locale === 'en' ? [ + 'title' => '🍽️ MealBuddy — Lunch Reminder', + 'body' => 'You’re in for lunch today! If you’re not joining, tap here to let us know before 10:00 AM.', + 'url' => '/vote', + ] : [ + 'title' => '🍽️ ཟས་མཐུན་ལས་རོགས། - ཉིན་གུང་གི་དྲན་སྐུལ།', + 'body' => 'ཁྱེད་རང་དེ་རིང་ཉིན་གུང་ལ་མཉམ་ཞུགས་བྱེད་ཀྱི་ཡོད་དམ། གལ་ཏེ་མཉམ་ཞུགས་བྱེད་ཀྱི་མེད་ན་ཆུ་ཚོད་ ༡༠:༠༠ སྔོན་ལ་འདིར་ཐེངས་ཤིག་བསྣུན་ནས་ང་ཚོར་ཤེས་སུ་འཇུག་རོགས།', + 'url' => '/vote', + ]; + + $results = $this->webPushService->sendToUsers([$user], $payload); + + return response()->json([ + 'message' => 'Test notification processed.', + 'results' => $results, + ]); + } +} diff --git a/backend/app/Models/PushSubscription.php b/backend/app/Models/PushSubscription.php new file mode 100644 index 0000000..45834bc --- /dev/null +++ b/backend/app/Models/PushSubscription.php @@ -0,0 +1,23 @@ +belongsTo(User::class); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 5992bc9..fdac606 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -80,6 +80,11 @@ public function uploadedMonthlyBills() return $this->hasMany(MonthlyBill::class, 'uploaded_by'); } + public function pushSubscriptions() + { + return $this->hasMany(PushSubscription::class); + } + /* |-------------------------------------------------------------------------- | Attribute Casting diff --git a/backend/app/Services/WebPushService.php b/backend/app/Services/WebPushService.php new file mode 100644 index 0000000..337332e --- /dev/null +++ b/backend/app/Services/WebPushService.php @@ -0,0 +1,101 @@ + [ + 'subject' => $subject, + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ], + ]; + $this->webPush = new WebPush($auth); + $this->webPush->setReuseVAPIDHeaders(true); + } + } + + public function isConfigured(): bool + { + return $this->webPush !== null; + } + + /** + * Send notification to a specific push subscription record. + */ + public function sendToSubscription(PushSubscription $sub, array $payload): bool + { + if (! $this->isConfigured()) { + Log::warning('[WebPush] VAPID keys not configured.'); + return false; + } + + try { + $subscription = Subscription::create([ + 'endpoint' => $sub->endpoint, + 'publicKey' => $sub->public_key, + 'authToken' => $sub->auth_token, + 'contentEncoding' => $sub->content_encoding ?: 'aes128gcm', + ]); + + $jsonPayload = json_encode($payload); + $report = $this->webPush->sendOneNotification($subscription, $jsonPayload); + + if ($report->isSuccess()) { + return true; + } + + Log::info("[WebPush] Notification failed for user {$sub->user_id}: {$report->getReason()}"); + + // If subscription is expired or unsubscribed (404/410 Gone), prune from database + if ($report->isSubscriptionExpired()) { + Log::info("[WebPush] Pruning expired subscription {$sub->id}"); + $sub->delete(); + } + + return false; + } catch (\Throwable $e) { + Log::error("[WebPush] Exception sending push to subscription {$sub->id}: " . $e->getMessage()); + return false; + } + } + + /** + * Send notification to all subscriptions of a collection of users or query. + */ + public function sendToUsers($users, array $payload): array + { + $sentCount = 0; + $failedCount = 0; + + foreach ($users as $user) { + foreach ($user->pushSubscriptions as $sub) { + if ($this->sendToSubscription($sub, $payload)) { + $sentCount++; + } else { + $failedCount++; + } + } + } + + return [ + 'sent' => $sentCount, + 'failed' => $failedCount, + ]; + } +} diff --git a/backend/composer.json b/backend/composer.json index 5ecbc84..5ba4f60 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -14,6 +14,7 @@ "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.14", + "minishlink/web-push": "^11.0", "spatie/laravel-activitylog": "^4.12" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index c72b51a..bf9889d 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -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": "29ef855598e23f713b68facb02949ab7", + "content-hash": "9099c2d01d4eed2af8b5dc8134676dfe", "packages": [ { "name": "bacon/bacon-qr-code", @@ -3794,6 +3794,85 @@ }, "time": "2025-07-25T09:04:22+00:00" }, + { + "name": "minishlink/web-push", + "version": "v11.0.0", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f8410afb73486ab895bb4792b879bdca62025b47", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "php": ">=8.2", + "php-http/discovery": "^1.19", + "php-http/httplug": "^2.4", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "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", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^2.7", + "php-http/guzzle7-adapter": "^1.1", + "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.", + "php-http/guzzle7-adapter": "Enables concurrent sending via WebPush::flushPooled() if you use Guzzle." + }, + "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/v11.0.0" + }, + "time": "2026-07-23T16:10:05+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4541,6 +4620,194 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -6079,6 +6346,71 @@ ], "time": "2026-04-27T14:27:52+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.2.3", @@ -9720,6 +10052,96 @@ ], "time": "2026-05-17T19:04:30+00:00" }, + { + "name": "web-token/jwt-library", + "version": "4.2.2", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/ae642340ee2ca91ca0c37edd72a9d06302651ca9", + "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1", + "symfony/deprecation-contracts": "^2.5|^3.0" + }, + "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.2.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-08-30T13:53:36+00:00" + }, { "name": "webmozart/assert", "version": "2.3.0", diff --git a/backend/config/services.php b/backend/config/services.php index f392b80..fca820b 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -40,4 +40,10 @@ 'url' => env('LEAVE_TRACKER_URL', 'https://portal.monlam.ai/api/v1/external/on-leave-today/'), ], + 'webpush' => [ + 'vapid_public_key' => env('VAPID_PUBLIC_KEY'), + 'vapid_private_key' => env('VAPID_PRIVATE_KEY'), + 'vapid_subject' => env('VAPID_SUBJECT', 'mailto:admin@mealbuddy.monlam.ai'), + ], + ]; diff --git a/backend/database/migrations/2026_09_11_120000_create_push_subscriptions_table.php b/backend/database/migrations/2026_09_11_120000_create_push_subscriptions_table.php new file mode 100644 index 0000000..7ae32a8 --- /dev/null +++ b/backend/database/migrations/2026_09_11_120000_create_push_subscriptions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('endpoint')->unique(); + $table->text('public_key')->nullable(); + $table->text('auth_token')->nullable(); + $table->string('content_encoding')->default('aes128gcm'); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('push_subscriptions'); + } +}; diff --git a/backend/database/migrations/2026_09_11_123000_add_locale_to_push_subscriptions_table.php b/backend/database/migrations/2026_09_11_123000_add_locale_to_push_subscriptions_table.php new file mode 100644 index 0000000..d9fa70b --- /dev/null +++ b/backend/database/migrations/2026_09_11_123000_add_locale_to_push_subscriptions_table.php @@ -0,0 +1,28 @@ +string('locale', 10)->default('bo')->after('content_encoding'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('push_subscriptions', function (Blueprint $table) { + $table->dropColumn('locale'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index ac29a43..558f88e 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -52,6 +52,16 @@ Route::put('/weekly-menus/{weekday}', [MenuController::class, 'update']); + /* + |-------------------------------------------------------------------------- + | Web Push Subscriptions + |-------------------------------------------------------------------------- + */ + Route::get('/push/vapid-key', [\App\Http\Controllers\Api\PushSubscriptionController::class, 'getVapidPublicKey']); + Route::post('/push-subscriptions', [\App\Http\Controllers\Api\PushSubscriptionController::class, 'store']); + Route::delete('/push-subscriptions', [\App\Http\Controllers\Api\PushSubscriptionController::class, 'destroy']); + Route::post('/push-subscriptions/test', [\App\Http\Controllers\Api\PushSubscriptionController::class, 'test']); + /* |-------------------------------------------------------------------------- | Lunch System diff --git a/backend/routes/console.php b/backend/routes/console.php index 45f9cf5..580b726 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -10,3 +10,4 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('lunch:sync-leaves')->dailyAt('09:00'); +Schedule::command('lunch:send-reminders')->weekdays()->at('09:00'); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index dc3431e..b66b6d6 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -6,6 +6,7 @@ import { ThemeProvider } from "@/components/providers/theme-provider"; import { PwaRegister } from "@/components/pwa/pwa-register"; import { PwaInstallPrompt } from "@/components/pwa/pwa-install-prompt"; import { NetworkStatusIndicator } from "@/components/pwa/network-status-indicator"; +import { PushNotificationBanner } from "@/components/pwa/push-notification-banner"; import "./globals.css"; const geistSans = Geist({ @@ -82,10 +83,13 @@ export default function RootLayout({ - {children} - - - + + {children} + + + + + diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 7028adb..f6290b9 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -3,11 +3,19 @@ import React, { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { motion } from 'framer-motion'; -import { ArrowLeft, Camera, Save, User, Sparkles, Utensils, Leaf, Drumstick } from 'lucide-react'; +import { ArrowLeft, Camera, Save, User, Sparkles, Utensils, Leaf, Drumstick, Bell, Send, CheckCircle2 } from 'lucide-react'; import { useLanguage } from '@/components/providers/language-provider'; import { useToast } from '@/components/providers/toast-provider'; import Header from '@/components/header'; import { apiUrl, authHeaders } from '@/lib/api-url'; +import { + isPushNotificationSupported, + getNotificationPermission, + getCurrentPushSubscription, + subscribeToPushNotifications, + unsubscribeFromPushNotifications, + triggerTestPushNotification, +} from '@/lib/push-notifications'; const GlassCard = ({ children, className = "" }: { children: React.ReactNode; className?: string }) => (
@@ -36,6 +44,62 @@ export default function ProfilePage() { const [avatarPreview, setAvatarPreview] = useState(''); const fileInputRef = useRef(null); + // Push notification state + const [pushStatus, setPushStatus] = useState<'loading' | 'subscribed' | 'unsubscribed' | 'denied' | 'unsupported'>('loading'); + const [pushLoading, setPushLoading] = useState(false); + const [testLoading, setTestLoading] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (!isPushNotificationSupported()) { + setPushStatus('unsupported'); + return; + } + if (Notification.permission === 'denied') { + setPushStatus('denied'); + return; + } + getCurrentPushSubscription().then((sub) => { + setPushStatus(sub ? 'subscribed' : 'unsubscribed'); + }); + }, []); + + const handleTogglePush = async () => { + setPushLoading(true); + if (pushStatus === 'subscribed') { + const res = await unsubscribeFromPushNotifications(); + if (res.success) { + setPushStatus('unsubscribed'); + showToast(t('push_disabled_success'), 'success'); + } else { + showToast(res.error || 'Failed to unsubscribe', 'error'); + } + } else { + const res = await subscribeToPushNotifications(); + if (res.success) { + setPushStatus('subscribed'); + showToast(t('push_enabled_success'), 'success'); + } else { + if (Notification.permission === 'denied') { + setPushStatus('denied'); + } + showToast(res.error || t('push_permission_denied'), 'error'); + } + } + setPushLoading(false); + }; + + const handleTestPush = async () => { + setTestLoading(true); + const res = await triggerTestPushNotification(); + setTestLoading(false); + if (res.success) { + showToast(t('push_test_success'), 'success'); + } else { + showToast(res.error || 'Failed to send test reminder', 'error'); + } + }; + useEffect(() => { const token = localStorage.getItem('user'); const savedUser = localStorage.getItem('user'); @@ -329,6 +393,90 @@ export default function ProfilePage() { + + {/* Push Notifications Card */} + +
+
+
+ +
+
+

+ {t('push_notifications')} +

+

+ {t('push_reminders_desc')} +

+
+
+ + {/* Status Badge */} +
+ {pushStatus === 'subscribed' && ( + + + {t('push_subscribed')} + + )} + {pushStatus === 'unsubscribed' && ( + + {t('push_not_subscribed')} + + )} + {pushStatus === 'denied' && ( + + {t('push_permission_denied')} + + )} + {pushStatus === 'unsupported' && ( + + {t('push_unsupported')} + + )} +
+
+ + {/* Action Controls */} +
+ {pushStatus !== 'unsupported' && pushStatus !== 'denied' && ( + + )} + + {pushStatus === 'subscribed' && ( + + )} + + {pushStatus === 'denied' && ( +

+ {t('push_permission_denied')} +

+ )} +
+
); diff --git a/frontend/components/providers/language-provider.tsx b/frontend/components/providers/language-provider.tsx index 488c93d..7ed4e0e 100644 --- a/frontend/components/providers/language-provider.tsx +++ b/frontend/components/providers/language-provider.tsx @@ -322,9 +322,22 @@ const translations = { new_version_available: 'New MealBuddy version available', refresh: 'Refresh', install_instructions_ios: 'To install: tap Share and select "Add to Home Screen"', - internet_connection_unavailable: 'Internet connection is unavailable. Live meal voting, menu updates, and billing cannot currently be loaded.', - offline_reconnect_prompt: 'Please reconnect to the internet and try again.', dismiss: 'Dismiss', + push_notifications: 'Push Notifications', + push_reminders_title: '9:00 AM Lunch Reminders', + push_reminders_desc: "Get reminded every morning at 9:00 AM to vote if you're not joining lunch before the 10:00 AM cutoff.", + push_enable_button: 'Enable Reminders', + push_disable_button: 'Disable Reminders', + push_test_button: 'Send Test Reminder', + push_subscribed: 'Subscribed to 9:00 AM reminders', + push_not_subscribed: 'Reminders disabled', + push_permission_denied: 'Notifications are blocked in browser settings', + push_enabled_success: '9:00 AM Lunch Reminders enabled successfully!', + push_disabled_success: 'Lunch reminders disabled.', + push_test_success: 'Test reminder notification sent! Check your screen.', + push_unsupported: 'Push notifications are not supported by this browser.', + push_reminder_notification_title: '🍽️ MealBuddy — Lunch Reminder', + push_reminder_notification_body: "You’re in for lunch today! If you’re not joining, tap here to let us know before 10:00 AM.", }, bo: { app_title: 'ཟས་མཐུན་ལས་རོགས།', @@ -644,6 +657,21 @@ const translations = { internet_connection_unavailable: 'དྲ་རྒྱའི་སྦྲེལ་མཐུད་མི་འདུག ཉིན་ཟས་འོས་བསྡུ་དང་རྩིས་ཁྲ་སོགས་དྲ་ཐོག་གི་གནས་ཚུལ་རྣམས་ལྟ་མི་ཐུབ།', offline_reconnect_prompt: 'དྲ་རྒྱ་ལ་སླར་སྦྲེལ་མཐུད་གནང་རྗེས་བསྐྱར་དུ་འབད་བརྩོན་བྱོས།', dismiss: 'སྣང་མེད་གཏོང་བ།', + push_notifications: 'བརྡ་ཐོ་སྒྲིག་བཀོད།', + push_reminders_title: 'ཞོགས་པ་ཆུ་ཚོད་ ༩:༠༠ པའི་ཉིན་ཟས་དྲན་སྐུལ།', + push_reminders_desc: 'ཉིན་ལྟར་ཞོགས་པ་ཆུ་ཚོད་ ༩:༠༠ པར་དྲན་སྐུལ་བརྡ་ཐོ་འབྱོར་ཏེ་ཆུ་ཚོད་ ༡༠ སྔོན་ལ་ཉིན་ཟས་བཞེས་མིན་ཐག་གཅོད་གནང་རོགས།', + push_enable_button: 'དྲན་སྐུལ་སྒོ་འབྱེད།', + push_disable_button: 'དྲན་སྐུལ་སྒོ་རྒྱག', + push_test_button: 'ཚོད་ལྟའི་བརྡ་ཐོ་གཏོང་བ།', + push_subscribed: 'ཞོགས་པ་ཆུ་ཚོད་ ༩:༠༠ པའི་དྲན་སྐུལ་སྒོ་ཕྱེས་ཟིན།', + push_not_subscribed: 'དྲན་སྐུལ་སྒོ་བརྒྱབ་ཡོད།', + push_permission_denied: 'དྲ་བཤེར་ཆས་ནང་བརྡ་ཐོ་བཀག་འགོག་བྱས་འདུག', + push_enabled_success: 'ཉིན་ཟས་དྲན་སྐུལ་བརྡ་ཐོ་ལམ་ལྷོང་ངང་སྒོ་ཕྱེས་ཟིན།', + push_disabled_success: 'ཉིན་ཟས་དྲན་སྐུལ་བརྡ་ཐོ་སྒོ་བརྒྱབ་ཟིན།', + push_test_success: 'ཚོད་ལྟའི་དྲན་སྐུལ་བརྡ་ཐོ་བཏང་ཟིན། ཁྱེད་ཀྱི་མཐོང་ངོས་ལ་གཟིགས་རོགས།', + push_unsupported: 'དྲ་བཤེར་ཆས་འདིའི་ནང་བརྡ་ཐོ་གཏོང་ཐབས་མི་འདུག', + push_reminder_notification_title: '🍽️ ཟས་མཐུན་ལས་རོགས། - ཉིན་གུང་གི་དྲན་སྐུལ།', + push_reminder_notification_body: 'ཁྱེད་རང་དེ་རིང་ཉིན་གུང་ལ་མཉམ་ཞུགས་བྱེད་ཀྱི་ཡོད་དམ། གལ་ཏེ་མཉམ་ཞུགས་བྱེད་ཀྱི་མེད་ན་ཆུ་ཚོད་ ༡༠:༠༠ སྔོན་ལ་འདིར་ཐེངས་ཤིག་བསྣུན་ནས་ང་ཚོར་ཤེས་སུ་འཇུག་རོགས།', } }; diff --git a/frontend/components/pwa/push-notification-banner.tsx b/frontend/components/pwa/push-notification-banner.tsx new file mode 100644 index 0000000..81e4736 --- /dev/null +++ b/frontend/components/pwa/push-notification-banner.tsx @@ -0,0 +1,135 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { Bell, X, CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'; +import { useLanguage } from '@/components/providers/language-provider'; +import { useToast } from '@/components/providers/toast-provider'; +import { + isPushNotificationSupported, + getNotificationPermission, + getCurrentPushSubscription, + subscribeToPushNotifications, +} from '@/lib/push-notifications'; + +const DISMISS_STORAGE_KEY = 'mealbuddy_push_prompt_dismissed_at'; +const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +export function PushNotificationBanner() { + const { t } = useLanguage(); + const { showToast } = useToast(); + const [isVisible, setIsVisible] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + // Only prompt for logged-in users on supported browsers + if (typeof window === 'undefined') return; + + const user = localStorage.getItem('user'); + if (!user) return; + + if (!isPushNotificationSupported()) return; + + const permission = getNotificationPermission(); + if (permission === 'denied') return; + + // Check if dismissed recently + const dismissedAt = localStorage.getItem(DISMISS_STORAGE_KEY); + if (dismissedAt) { + const diff = Date.now() - parseInt(dismissedAt, 10); + if (diff < DISMISS_COOLDOWN_MS) { + return; + } + } + + // Check if user is already subscribed + getCurrentPushSubscription().then((sub) => { + if (!sub) { + // Small delay to let page load cleanly first + const timer = setTimeout(() => { + setIsVisible(true); + }, 2000); + return () => clearTimeout(timer); + } + }); + }, []); + + const handleDismiss = () => { + setIsVisible(false); + try { + localStorage.setItem(DISMISS_STORAGE_KEY, Date.now().toString()); + } catch {} + }; + + const handleEnable = async () => { + setLoading(true); + const result = await subscribeToPushNotifications(); + setLoading(false); + + if (result.success) { + showToast(t('push_enabled_success'), 'success'); + setIsVisible(false); + } else { + showToast(result.error || t('push_permission_denied'), 'error'); + } + }; + + if (!isVisible) { + return null; + } + + return ( + + ); +} diff --git a/frontend/lib/push-notifications.ts b/frontend/lib/push-notifications.ts new file mode 100644 index 0000000..a968915 --- /dev/null +++ b/frontend/lib/push-notifications.ts @@ -0,0 +1,211 @@ +import { api } from "@/lib/api"; + +/** + * Convert a base64 string to a Uint8Array for PushManager subscription. + */ +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 buffer = new ArrayBuffer(rawData.length); + const outputArray = new Uint8Array(buffer); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +/** + * Check if Web Push notifications are supported in the current browser/environment. + */ +export function isPushNotificationSupported(): boolean { + return ( + typeof window !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); +} + +/** + * Get current notification permission status. + */ +export function getNotificationPermission(): NotificationPermission | "unsupported" { + if (!isPushNotificationSupported()) return "unsupported"; + return Notification.permission; +} + +/** + * Get the current active PushSubscription if already subscribed. + */ +export async function getCurrentPushSubscription(): Promise { + if (!isPushNotificationSupported()) return null; + + try { + const registration = await navigator.serviceWorker.ready; + return await registration.pushManager.getSubscription(); + } catch (error) { + console.error("Failed to get push subscription:", error); + return null; + } +} + +/** + * Fetch the VAPID Public Key from backend or fallback to env variable. + */ +export async function getVapidPublicKey(): Promise { + if (process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) { + return process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; + } + + const response = await api.get("/v1/push/vapid-key"); + return response.data.vapid_public_key; +} + +/** + * Subscribe the current user / browser to Web Push notifications. + */ +export async function subscribeToPushNotifications(): Promise<{ + success: boolean; + subscription?: PushSubscription; + error?: string; +}> { + if (!isPushNotificationSupported()) { + return { success: false, error: "Push notifications are not supported by this browser." }; + } + + try { + // 1. Request notification permission + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + return { + success: false, + error: + permission === "denied" + ? "Notification permission was denied. Please allow notifications in your browser settings." + : "Notification permission was dismissed.", + }; + } + + // 2. Wait for Service Worker registration + const registration = await navigator.serviceWorker.ready; + if (!registration) { + return { success: false, error: "Service worker is not ready." }; + } + + // 3. Get VAPID public key + const vapidPublicKey = await getVapidPublicKey(); + if (!vapidPublicKey) { + return { success: false, error: "VAPID public key is missing." }; + } + + const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); + + // 4. Subscribe to PushManager + let subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey as unknown as BufferSource, + }); + } + + // 5. Send subscription to Laravel backend + const jsonSub = subscription.toJSON(); + const currentLocale = + typeof window !== "undefined" + ? localStorage.getItem("mealbuddy_lang") || "bo" + : "bo"; + + await api.post("/v1/push-subscriptions", { + endpoint: subscription.endpoint, + keys: { + p256dh: jsonSub.keys?.p256dh || null, + auth: jsonSub.keys?.auth || null, + }, + locale: currentLocale === "en" ? "en" : "bo", + content_encoding: + (PushManager as unknown as { supportedContentEncodings?: string[] }) + ?.supportedContentEncodings?.[0] || "aes128gcm", + }); + + return { success: true, subscription }; + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : "Failed to subscribe to push notifications."; + console.error("Push subscription error:", err); + return { success: false, error: errorMsg }; + } +} + +/** + * Unsubscribe the current user / browser from Web Push notifications. + */ +export async function unsubscribeFromPushNotifications(): Promise<{ + success: boolean; + error?: string; +}> { + if (!isPushNotificationSupported()) { + return { success: true }; + } + + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + // Remove from backend first + try { + await api.delete("/v1/push-subscriptions", { + data: { endpoint: subscription.endpoint }, + }); + } catch (err) { + console.warn("Failed to remove push subscription on backend:", err); + } + + // Unsubscribe locally + await subscription.unsubscribe(); + } + + return { success: true }; + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : "Failed to unsubscribe."; + console.error("Push unsubscribe error:", err); + return { success: false, error: errorMsg }; + } +} + +/** + * Send a test push notification to verify the setup. + */ +export async function triggerTestPushNotification(): Promise<{ + success: boolean; + message?: string; + error?: string; +}> { + try { + const currentLocale = + typeof window !== "undefined" + ? localStorage.getItem("mealbuddy_lang") || "bo" + : "bo"; + + const response = await api.post("/v1/push-subscriptions/test", { + locale: currentLocale === "en" ? "en" : "bo", + }); + return { + success: true, + message: response.data.message || "Test push notification sent!", + }; + } catch (err: unknown) { + const errorMsg = + (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data + ?.error || + (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data + ?.message || + (err instanceof Error ? err.message : "Failed to send test push notification."); + return { success: false, error: errorMsg }; + } +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 6b40502..592b4af 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,6 +1,6 @@ // MealBuddy Production Service Worker // Cache Versioning -const VERSION = 'v1'; +const VERSION = 'v2'; const STATIC_CACHE = `mealbuddy-static-${VERSION}`; const ASSETS_CACHE = `mealbuddy-assets-${VERSION}`; const PAGES_CACHE = `mealbuddy-pages-${VERSION}`; @@ -187,3 +187,57 @@ self.addEventListener('fetch', (event) => { }) ); }); + +// Push Event: Handle incoming Web Push notifications (e.g. 9:00 AM Lunch Reminders) +self.addEventListener('push', (event) => { + let data = {}; + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data = { title: '🍽️ ཟས་མཐུན་ལས་རོགས། - ཉིན་གུང་གི་དྲན་སྐུལ།', body: event.data.text() }; + } + } + + const title = data.title || '🍽️ ཟས་མཐུན་ལས་རོགས། - ཉིན་གུང་གི་དྲན་སྐུལ།'; + const options = { + body: + data.body || + 'ཁྱེད་རང་དེ་རིང་ཉིན་གུང་ལ་མཉམ་ཞུགས་བྱེད་ཀྱི་ཡོད་དམ། གལ་ཏེ་མཉམ་ཞུགས་བྱེད་ཀྱི་མེད་ན་ཆུ་ཚོད་ ༡༠:༠༠ སྔོན་ལ་འདིར་ཐེངས་ཤིག་བསྣུན་ནས་ང་ཚོར་ཤེས་སུ་འཇུག་རོགས།', + icon: data.icon || '/icons/icon-192x192.png', + badge: data.badge || '/icons/icon-192x192.png', + tag: data.tag || 'mealbuddy-lunch-reminder', + renotify: true, + data: { + url: data.data?.url || data.url || '/vote', + }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// Notification Click Event: Navigate or focus on /vote +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const targetPath = event.notification.data?.url || '/vote'; + const targetUrl = new URL(targetPath, self.location.origin).href; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + // Check if MealBuddy window is already open + for (const client of clientList) { + if (client.url.startsWith(self.location.origin) && 'focus' in client) { + if ('navigate' in client && client.url !== targetUrl) { + return client.navigate(targetUrl).then((c) => (c ? c.focus() : client.focus())); + } + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + }) + ); +}); +