Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -29,16 +53,30 @@ 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
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

35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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.

6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
106 changes: 106 additions & 0 deletions backend/app/Console/Commands/SendLunchReminders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

namespace App\Console\Commands;

use App\Models\LunchDay;
use App\Models\LunchOrder;
use App\Models\OffDay;
use App\Models\User;
use App\Services\WebPushService;
use Carbon\Carbon;
use Illuminate\Console\Command;

class SendLunchReminders extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lunch:send-reminders {--force : Send reminder even if weekend or holiday}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Send 9:00 AM lunch voting reminder to employees who have not opted out';

/**
* Execute the console command.
*/
public function handle(WebPushService $webPushService): int
{
$today = Carbon::today();
$isForce = $this->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;
}
}
116 changes: 116 additions & 0 deletions backend/app/Http/Controllers/Api/PushSubscriptionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\PushSubscription;
use App\Services\WebPushService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class PushSubscriptionController extends Controller
{
protected WebPushService $webPushService;

public function __construct(WebPushService $webPushService)
{
$this->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,
]);
}
}
23 changes: 23 additions & 0 deletions backend/app/Models/PushSubscription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class PushSubscription extends Model
{
protected $fillable = [
'user_id',
'endpoint',
'public_key',
'auth_token',
'content_encoding',
'locale',
];

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Loading
Loading