Skip to content

Commit 0c57346

Browse files
committed
feat(phase-24/25): scheduled report delivery and health check endpoints
Phase 24: Report Schedules - ReportSchedule model with BelongsToTenant, frequency, recipients, filters - SendScheduledReportJob queues mail delivery and updates next_run_at - ScheduledReportMail with styled HTML template for financial/inventory/HR - ReportScheduleController: full CRUD + send-now endpoint - Artisan command reports:send-scheduled dispatches due schedules - Scheduled hourly via console routes - 10 feature tests covering CRUD, validation, tenant isolation, mail delivery Phase 25: Health Checks & Metrics - GET /api/v1/health — public endpoint checking DB, cache, queue, storage - GET /api/v1/metrics — authenticated endpoint with system stats - Returns 200 (healthy) or 503 (unhealthy) based on check results - 6 feature tests covering auth, structure, and check contents Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 8ffed30 commit 0c57346

12 files changed

Lines changed: 822 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Jobs\SendScheduledReportJob;
6+
use App\Models\ReportSchedule;
7+
use Illuminate\Console\Command;
8+
9+
class SendScheduledReports extends Command
10+
{
11+
protected $signature = 'reports:send-scheduled';
12+
protected $description = 'Dispatch queued jobs for all due report schedules';
13+
14+
public function handle(): int
15+
{
16+
$due = ReportSchedule::where('is_active', true)
17+
->where(function ($q) {
18+
$q->whereNull('next_run_at')
19+
->orWhere('next_run_at', '<=', now());
20+
})
21+
->get();
22+
23+
foreach ($due as $schedule) {
24+
SendScheduledReportJob::dispatch($schedule);
25+
$this->info("Queued report: {$schedule->name} ({$schedule->report_type})");
26+
}
27+
28+
$this->info("Dispatched {$due->count()} scheduled report job(s).");
29+
30+
return self::SUCCESS;
31+
}
32+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use Illuminate\Http\JsonResponse;
6+
use Illuminate\Support\Facades\Cache;
7+
use Illuminate\Support\Facades\DB;
8+
use Illuminate\Support\Facades\Queue;
9+
use Illuminate\Support\Facades\Storage;
10+
11+
class HealthController extends ApiController
12+
{
13+
public function check(): JsonResponse
14+
{
15+
$checks = [];
16+
$overall = 'healthy';
17+
18+
// Database
19+
try {
20+
DB::select('SELECT 1');
21+
$checks['database'] = ['status' => 'healthy'];
22+
} catch (\Throwable $e) {
23+
$checks['database'] = ['status' => 'unhealthy', 'error' => $e->getMessage()];
24+
$overall = 'unhealthy';
25+
}
26+
27+
// Cache
28+
try {
29+
Cache::put('health_check', true, 5);
30+
$checks['cache'] = Cache::get('health_check') === true
31+
? ['status' => 'healthy']
32+
: ['status' => 'degraded', 'error' => 'Cache write/read mismatch'];
33+
if ($checks['cache']['status'] !== 'healthy') {
34+
$overall = 'degraded';
35+
}
36+
} catch (\Throwable $e) {
37+
$checks['cache'] = ['status' => 'degraded', 'error' => $e->getMessage()];
38+
$overall = 'degraded';
39+
}
40+
41+
// Queue
42+
try {
43+
$failedJobs = DB::table('failed_jobs')->count();
44+
$checks['queue'] = [
45+
'status' => 'healthy',
46+
'failed_jobs' => $failedJobs,
47+
];
48+
if ($failedJobs > 100) {
49+
$checks['queue']['status'] = 'degraded';
50+
$overall = 'degraded';
51+
}
52+
} catch (\Throwable $e) {
53+
$checks['queue'] = ['status' => 'degraded', 'error' => $e->getMessage()];
54+
}
55+
56+
// Storage
57+
try {
58+
$diskUsage = disk_free_space(storage_path());
59+
$checks['storage'] = [
60+
'status' => 'healthy',
61+
'free_bytes' => $diskUsage,
62+
];
63+
} catch (\Throwable $e) {
64+
$checks['storage'] = ['status' => 'degraded', 'error' => $e->getMessage()];
65+
}
66+
67+
$httpStatus = $overall === 'healthy' ? 200 : 503;
68+
69+
return response()->json([
70+
'status' => $overall,
71+
'timestamp' => now()->toIso8601String(),
72+
'version' => config('app.version', '1.0.0'),
73+
'checks' => $checks,
74+
], $httpStatus);
75+
}
76+
77+
public function metrics(): JsonResponse
78+
{
79+
$tenantId = auth()->user()?->tenant_id;
80+
81+
$stats = [
82+
'tenants' => DB::table('tenants')->count(),
83+
'users' => DB::table('users')->count(),
84+
'api_requests_today' => null, // would need request logging table
85+
'queue_jobs' => [
86+
'pending' => DB::table('jobs')->count(),
87+
'failed' => DB::table('failed_jobs')->count(),
88+
],
89+
'database' => [
90+
'size_mb' => $this->getDatabaseSize(),
91+
],
92+
'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
93+
'peak_memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
94+
'php_version' => PHP_VERSION,
95+
'laravel_version' => app()->version(),
96+
];
97+
98+
return $this->success($stats);
99+
}
100+
101+
private function getDatabaseSize(): ?float
102+
{
103+
try {
104+
$path = database_path('database.sqlite');
105+
if (file_exists($path)) {
106+
return round(filesize($path) / 1024 / 1024, 2);
107+
}
108+
return null;
109+
} catch (\Throwable) {
110+
return null;
111+
}
112+
}
113+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Jobs\SendScheduledReportJob;
6+
use App\Models\ReportSchedule;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
10+
class ReportScheduleController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
15+
$schedules = ReportSchedule::where('tenant_id', $tenantId)
16+
->with('user:id,name')
17+
->latest()
18+
->get();
19+
20+
return $this->success($schedules);
21+
}
22+
23+
public function store(Request $request): JsonResponse
24+
{
25+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
26+
27+
$data = $request->validate([
28+
'name' => ['required', 'string', 'max:255'],
29+
'report_type' => ['required', 'in:financial,inventory,hr'],
30+
'frequency' => ['required', 'in:daily,weekly,monthly'],
31+
'recipients' => ['required', 'array', 'min:1'],
32+
'recipients.*' => ['email'],
33+
'filters' => ['nullable', 'array'],
34+
'is_active' => ['boolean'],
35+
]);
36+
37+
$schedule = ReportSchedule::create([
38+
...$data,
39+
'tenant_id' => $tenantId,
40+
'user_id' => $request->user()->id,
41+
'is_active' => $data['is_active'] ?? true,
42+
'next_run_at' => (new ReportSchedule())->fill($data)->computeNextRunAt(),
43+
]);
44+
45+
return $this->success($schedule, 201);
46+
}
47+
48+
public function show(Request $request, ReportSchedule $reportSchedule): JsonResponse
49+
{
50+
return $this->success($reportSchedule->load('user:id,name'));
51+
}
52+
53+
public function update(Request $request, ReportSchedule $reportSchedule): JsonResponse
54+
{
55+
$data = $request->validate([
56+
'name' => ['sometimes', 'string', 'max:255'],
57+
'report_type' => ['sometimes', 'in:financial,inventory,hr'],
58+
'frequency' => ['sometimes', 'in:daily,weekly,monthly'],
59+
'recipients' => ['sometimes', 'array', 'min:1'],
60+
'recipients.*' => ['email'],
61+
'filters' => ['nullable', 'array'],
62+
'is_active' => ['boolean'],
63+
]);
64+
65+
$reportSchedule->update($data);
66+
67+
return $this->success($reportSchedule->fresh());
68+
}
69+
70+
public function destroy(ReportSchedule $reportSchedule): JsonResponse
71+
{
72+
$reportSchedule->delete();
73+
return $this->success(['message' => 'Report schedule deleted.']);
74+
}
75+
76+
public function sendNow(ReportSchedule $reportSchedule): JsonResponse
77+
{
78+
SendScheduledReportJob::dispatch($reportSchedule);
79+
return $this->success(['message' => 'Report queued for delivery.']);
80+
}
81+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Mail\ScheduledReportMail;
6+
use App\Models\ReportSchedule;
7+
use Illuminate\Bus\Queueable;
8+
use Illuminate\Contracts\Queue\ShouldQueue;
9+
use Illuminate\Foundation\Bus\Dispatchable;
10+
use Illuminate\Queue\InteractsWithQueue;
11+
use Illuminate\Queue\SerializesModels;
12+
use Illuminate\Support\Facades\DB;
13+
use Illuminate\Support\Facades\Mail;
14+
use App\Modules\Finance\Models\Invoice;
15+
use App\Modules\Finance\Models\Bill;
16+
use App\Modules\Inventory\Models\Product;
17+
use App\Modules\Inventory\Models\StockMovement;
18+
use App\Modules\HR\Models\Employee;
19+
use App\Modules\HR\Models\PayrollRun;
20+
21+
class SendScheduledReportJob implements ShouldQueue
22+
{
23+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
24+
25+
public int $tries = 3;
26+
27+
public function __construct(public readonly ReportSchedule $schedule) {}
28+
29+
public function handle(): void
30+
{
31+
$data = match ($this->schedule->report_type) {
32+
'financial' => $this->buildFinancialReport(),
33+
'inventory' => $this->buildInventoryReport(),
34+
'hr' => $this->buildHrReport(),
35+
default => [],
36+
};
37+
38+
foreach ($this->schedule->recipients as $email) {
39+
Mail::to($email)->send(new ScheduledReportMail($this->schedule, $data));
40+
}
41+
42+
$this->schedule->update([
43+
'last_sent_at' => now(),
44+
'next_run_at' => $this->schedule->computeNextRunAt(),
45+
]);
46+
}
47+
48+
private function buildFinancialReport(): array
49+
{
50+
$tenantId = $this->schedule->tenant_id;
51+
$year = now()->year;
52+
53+
$invoiceSummary = Invoice::where('tenant_id', $tenantId)
54+
->whereYear('created_at', $year)
55+
->selectRaw('SUM(total) as total_invoiced, SUM(CASE WHEN status = \'paid\' THEN total ELSE 0 END) as total_paid, SUM(CASE WHEN status != \'paid\' THEN total ELSE 0 END) as total_outstanding, SUM(CASE WHEN status != \'paid\' AND due_date < datetime(\'now\') THEN total ELSE 0 END) as total_overdue')
56+
->first();
57+
58+
$monthlyRevenue = DB::table('invoices')
59+
->join('invoice_items', 'invoices.id', '=', 'invoice_items.invoice_id')
60+
->where('invoices.tenant_id', $tenantId)
61+
->where('invoices.status', 'paid')
62+
->whereYear('invoices.created_at', $year)
63+
->selectRaw("strftime('%m', invoices.created_at) as month, SUM(invoice_items.quantity * invoice_items.unit_price) as revenue")
64+
->groupBy('month')
65+
->orderBy('month')
66+
->get()
67+
->toArray();
68+
69+
return [
70+
'invoice_summary' => $invoiceSummary ? $invoiceSummary->toArray() : [],
71+
'monthly_revenue' => $monthlyRevenue,
72+
];
73+
}
74+
75+
private function buildInventoryReport(): array
76+
{
77+
$tenantId = $this->schedule->tenant_id;
78+
$stockStats = Product::where('tenant_id', $tenantId)
79+
->selectRaw('COUNT(*) as total_products, SUM(stock_quantity * cost_price) as stock_value')
80+
->first();
81+
82+
$lowStockCount = Product::where('tenant_id', $tenantId)
83+
->whereColumn('stock_quantity', '<=', 'reorder_point')
84+
->where('reorder_point', '>', 0)
85+
->count();
86+
87+
return [
88+
'total_products' => $stockStats?->total_products ?? 0,
89+
'stock_value' => $stockStats?->stock_value ?? 0,
90+
'low_stock_count' => $lowStockCount,
91+
];
92+
}
93+
94+
private function buildHrReport(): array
95+
{
96+
$tenantId = $this->schedule->tenant_id;
97+
98+
$headcount = Employee::where('tenant_id', $tenantId)
99+
->selectRaw('status, COUNT(*) as count')
100+
->groupBy('status')
101+
->get()
102+
->toArray();
103+
104+
$payrollSummary = PayrollRun::where('tenant_id', $tenantId)
105+
->whereYear('created_at', now()->year)
106+
->selectRaw('SUM(total_gross) as total_gross, SUM(total_net) as total_net, COUNT(*) as run_count')
107+
->first();
108+
109+
return [
110+
'headcount' => $headcount,
111+
'payroll_summary' => $payrollSummary ? $payrollSummary->toArray() : [],
112+
];
113+
}
114+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace App\Mail;
4+
5+
use App\Models\ReportSchedule;
6+
use Illuminate\Mail\Mailable;
7+
use Illuminate\Mail\Mailables\Content;
8+
use Illuminate\Mail\Mailables\Envelope;
9+
use Illuminate\Queue\SerializesModels;
10+
11+
class ScheduledReportMail extends Mailable
12+
{
13+
use SerializesModels;
14+
15+
public function __construct(
16+
public readonly ReportSchedule $schedule,
17+
public readonly array $reportData,
18+
) {}
19+
20+
public function envelope(): Envelope
21+
{
22+
return new Envelope(
23+
subject: "[{$this->schedule->report_type}] {$this->schedule->name} Report"
24+
);
25+
}
26+
27+
public function content(): Content
28+
{
29+
return new Content(
30+
view: 'emails.scheduled-report',
31+
with: [
32+
'schedule' => $this->schedule,
33+
'reportData' => $this->reportData,
34+
]
35+
);
36+
}
37+
}

0 commit comments

Comments
 (0)