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({
+ {t('push_reminders_desc')} +
++ {t('push_permission_denied')} +
+ )} +