Harden credit flows and add RevenueCat webhook
This commit is contained in:
@@ -4,26 +4,24 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventStoreRequest;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Http\Resources\Tenant\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the tenant's events.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if (!$tenantId) {
|
||||
|
||||
if (! $tenantId) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_id' => 'Tenant ID not found in request context.',
|
||||
]);
|
||||
@@ -33,7 +31,6 @@ class EventController extends Controller
|
||||
->with(['eventType', 'photos'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
// Apply filters
|
||||
if ($request->has('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
@@ -42,55 +39,107 @@ class EventController extends Controller
|
||||
$query->where('event_type_id', $request->type_id);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$perPage = $request->get('per_page', 15);
|
||||
$events = $query->paginate($perPage);
|
||||
$events = $query->paginate($request->get('per_page', 15));
|
||||
|
||||
return EventResource::collection($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created event in storage.
|
||||
*/
|
||||
public function store(EventStoreRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Check credits balance
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
if ($tenant->event_credits_balance <= 0) {
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
}
|
||||
|
||||
if ($tenant->event_credits_balance < 1) {
|
||||
return response()->json([
|
||||
'error' => 'Insufficient event credits. Please purchase more credits.',
|
||||
], 402);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
$event = Event::create(array_merge($validated, [
|
||||
$eventData = array_merge($validated, [
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => 'draft', // Default status
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
|
||||
]));
|
||||
]);
|
||||
|
||||
// Decrement credits
|
||||
$tenant->decrement('event_credits_balance', 1);
|
||||
if (isset($eventData['event_date'])) {
|
||||
$eventData['date'] = $eventData['event_date'];
|
||||
unset($eventData['event_date']);
|
||||
}
|
||||
|
||||
$settings = $eventData['settings'] ?? [];
|
||||
foreach (['public_url', 'custom_domain', 'theme_color'] as $key) {
|
||||
if (array_key_exists($key, $eventData)) {
|
||||
$settings[$key] = $eventData[$key];
|
||||
unset($eventData[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($eventData['features'])) {
|
||||
$settings['features'] = $eventData['features'];
|
||||
unset($eventData['features']);
|
||||
}
|
||||
|
||||
if ($settings === [] || $settings === null) {
|
||||
unset($eventData['settings']);
|
||||
} else {
|
||||
$eventData['settings'] = $settings;
|
||||
}
|
||||
|
||||
foreach (['password', 'password_confirmation', 'password_protected', 'logo_image', 'cover_image'] as $unused) {
|
||||
unset($eventData[$unused]);
|
||||
}
|
||||
|
||||
$allowed = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'description',
|
||||
'date',
|
||||
'slug',
|
||||
'location',
|
||||
'max_participants',
|
||||
'settings',
|
||||
'event_type_id',
|
||||
'is_active',
|
||||
'join_link_enabled',
|
||||
'photo_upload_enabled',
|
||||
'task_checklist_enabled',
|
||||
'default_locale',
|
||||
'status',
|
||||
];
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
$note = sprintf('Event create: %s', $event->slug);
|
||||
if (! $tenant->decrementCredits(1, 'event_create', $note, null)) {
|
||||
throw new \RuntimeException('Unable to deduct credits');
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
$event->load(['eventType', 'tenant']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
'data' => new EventResource($event),
|
||||
'balance' => $tenant->event_credits_balance,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified event.
|
||||
*/
|
||||
public function show(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Ensure event belongs to tenant
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
}
|
||||
@@ -99,7 +148,7 @@ class EventController extends Controller
|
||||
'eventType',
|
||||
'photos' => fn ($query) => $query->with('likes')->latest(),
|
||||
'tasks',
|
||||
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance')
|
||||
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
@@ -107,27 +156,30 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified event in storage.
|
||||
*/
|
||||
public function update(EventStoreRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Ensure event belongs to tenant
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
// Update slug if name changed
|
||||
if (isset($validated['event_date'])) {
|
||||
$validated['date'] = $validated['event_date'];
|
||||
unset($validated['event_date']);
|
||||
}
|
||||
|
||||
if ($validated['name'] !== $event->name) {
|
||||
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||
}
|
||||
|
||||
$event->update($validated);
|
||||
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
|
||||
unset($validated[$unused]);
|
||||
}
|
||||
|
||||
$event->update($validated);
|
||||
$event->load(['eventType', 'tenant']);
|
||||
|
||||
return response()->json([
|
||||
@@ -136,19 +188,14 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified event from storage.
|
||||
*/
|
||||
public function destroy(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Ensure event belongs to tenant
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => 'Event not found'], 404);
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
$event->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -156,9 +203,6 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update event status (publish/unpublish)
|
||||
*/
|
||||
public function bulkUpdateStatus(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
@@ -178,18 +222,15 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique slug for event name
|
||||
*/
|
||||
private function generateUniqueSlug(string $name, int $tenantId, ?int $excludeId = null): string
|
||||
{
|
||||
$slug = Str::slug($name);
|
||||
$originalSlug = $slug;
|
||||
|
||||
$counter = 1;
|
||||
|
||||
while (Event::where('slug', $slug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', '!=', $excludeId)
|
||||
->when($excludeId, fn ($query) => $query->where('id', '!=', $excludeId))
|
||||
->exists()) {
|
||||
$slug = $originalSlug . '-' . $counter;
|
||||
$counter++;
|
||||
@@ -198,9 +239,6 @@ class EventController extends Controller
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search events by name or description
|
||||
*/
|
||||
public function search(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
@@ -213,7 +251,7 @@ class EventController extends Controller
|
||||
$events = Event::where('tenant_id', $tenantId)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('name', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
})
|
||||
->with('eventType')
|
||||
->limit(10)
|
||||
@@ -221,4 +259,4 @@ class EventController extends Controller
|
||||
|
||||
return EventResource::collection($events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ class OAuthController extends Controller
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
@@ -180,7 +180,7 @@ class OAuthController extends Controller
|
||||
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
|
||||
Log::warning('[OAuth] Authorization code missing or expired', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid or expired authorization code', 400);
|
||||
@@ -191,7 +191,7 @@ class OAuthController extends Controller
|
||||
if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
|
||||
Log::warning('[OAuth] Authorization code validation failed', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid authorization code', 400);
|
||||
@@ -221,7 +221,7 @@ class OAuthController extends Controller
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
@@ -283,6 +283,15 @@ class OAuthController extends Controller
|
||||
return $this->errorResponse('Invalid refresh token', 400);
|
||||
}
|
||||
|
||||
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
|
||||
$currentIp = (string) ($request->ip() ?? '');
|
||||
|
||||
if ($storedIp !== '' && $currentIp !== '' && ! hash_equals($storedIp, $currentIp)) {
|
||||
$storedRefreshToken->update(['revoked_at' => now()]);
|
||||
|
||||
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
|
||||
}
|
||||
|
||||
$client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
|
||||
if (! $client) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
@@ -292,7 +301,7 @@ class OAuthController extends Controller
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'tenant_id' => $storedRefreshToken->tenant_id,
|
||||
]);
|
||||
|
||||
@@ -560,4 +569,4 @@ class OAuthController extends Controller
|
||||
return redirect('/admin')->with('error', 'Connection error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
app/Http/Controllers/RevenueCatWebhookController.php
Normal file
69
app/Http/Controllers/RevenueCatWebhookController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ProcessRevenueCatWebhook;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RevenueCatWebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
$secret = (string) config('services.revenuecat.webhook', '');
|
||||
|
||||
if ($secret === '') {
|
||||
Log::error('RevenueCat webhook secret not configured');
|
||||
return response()->json(['error' => 'Webhook not configured'], 500);
|
||||
}
|
||||
|
||||
$signature = trim((string) $request->header('X-Signature', ''));
|
||||
if ($signature === '') {
|
||||
return response()->json(['error' => 'Signature missing'], 400);
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
if (! $this->signatureMatches($payload, $signature, $secret)) {
|
||||
return response()->json(['error' => 'Invalid signature'], 400);
|
||||
}
|
||||
|
||||
$decoded = json_decode($payload, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
|
||||
Log::warning('RevenueCat webhook received invalid JSON', [
|
||||
'error' => json_last_error_msg(),
|
||||
]);
|
||||
return response()->json(['error' => 'Invalid payload'], 400);
|
||||
}
|
||||
|
||||
ProcessRevenueCatWebhook::dispatch(
|
||||
$decoded,
|
||||
(string) $request->header('X-Event-Id', '')
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'accepted'], 202);
|
||||
}
|
||||
|
||||
private function signatureMatches(string $payload, string $providedSignature, string $secret): bool
|
||||
{
|
||||
$normalized = preg_replace('/\s+/', '', $providedSignature);
|
||||
if (! is_string($normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$candidates = [
|
||||
base64_encode(hash_hmac('sha1', $payload, $secret, true)),
|
||||
base64_encode(hash_hmac('sha256', $payload, $secret, true)),
|
||||
hash_hmac('sha1', $payload, $secret),
|
||||
hash_hmac('sha256', $payload, $secret),
|
||||
];
|
||||
|
||||
foreach ($candidates as $expected) {
|
||||
if (hash_equals($expected, $normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user