From 2726b6c3128f652b923ccb8ad039b55ac005366f Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Tue, 17 Feb 2026 00:03:35 +0700 Subject: [PATCH] Implement catalog CRUD overhaul, snapshot fallback activation, and billing/UX hardening --- app/.env.example | 1 + .../Api/V1/AdminSubscriptionController.php | 8 + .../Api/V1/AdminUserController.php | 7 + .../Controllers/Api/V1/EmojiApiController.php | 85 ++- .../Api/V1/UserKeywordController.php | 109 +++- .../Billing/BillingPaymentController.php | 119 ++++ .../Controllers/Billing/PakasirController.php | 93 ++- .../Controllers/Billing/PayPalController.php | 107 +++- .../Dashboard/AdminDashboardController.php | 9 +- .../Dashboard/AdminEmojiCatalogController.php | 245 ++++++++ .../Dashboard/UserDashboardController.php | 111 +++- .../Http/Controllers/Web/SiteController.php | 59 +- app/app/Models/UserKeyword.php | 5 + .../Billing/PaypalWebhookProcessor.php | 38 +- .../Billing/SubscriptionTransitionService.php | 162 +++++ .../EmojiCatalog/EmojiCatalogService.php | 592 ++++++++++++++++++ .../Services/Keywords/KeywordQuotaService.php | 58 ++ app/config/dewemoji.php | 1 + ...0_add_is_active_to_user_keywords_table.php | 25 + ...001000_add_unique_index_to_emojis_slug.php | 45 ++ .../dashboard/admin/catalog-form.blade.php | 129 ++++ .../views/dashboard/admin/catalog.blade.php | 175 ++++++ .../dashboard/admin/subscriptions.blade.php | 4 +- .../views/dashboard/admin/user-show.blade.php | 2 +- app/resources/views/dashboard/app.blade.php | 4 + .../views/dashboard/user/billing.blade.php | 310 ++++++++- .../views/dashboard/user/keywords.blade.php | 38 +- app/resources/views/site/api-docs.blade.php | 4 +- .../views/site/emoji-detail.blade.php | 149 ++++- app/resources/views/site/home.blade.php | 38 +- app/resources/views/site/pricing.blade.php | 142 ++++- app/routes/api.php | 5 +- app/routes/console.php | 20 + app/routes/dashboard.php | 12 + app/routes/web.php | 4 + billing-cooldown-runtime-checklist.md | 129 ++++ sequel-ace-coolify-staging-mysql-guide.md | 96 +++ 37 files changed, 2936 insertions(+), 204 deletions(-) create mode 100644 app/app/Http/Controllers/Billing/BillingPaymentController.php create mode 100644 app/app/Http/Controllers/Dashboard/AdminEmojiCatalogController.php create mode 100644 app/app/Services/Billing/SubscriptionTransitionService.php create mode 100644 app/app/Services/EmojiCatalog/EmojiCatalogService.php create mode 100644 app/app/Services/Keywords/KeywordQuotaService.php create mode 100644 app/database/migrations/2026_02_16_000300_add_is_active_to_user_keywords_table.php create mode 100644 app/database/migrations/2026_02_16_001000_add_unique_index_to_emojis_slug.php create mode 100644 app/resources/views/dashboard/admin/catalog-form.blade.php create mode 100644 app/resources/views/dashboard/admin/catalog.blade.php create mode 100644 billing-cooldown-runtime-checklist.md create mode 100644 sequel-ace-coolify-staging-mysql-guide.md diff --git a/app/.env.example b/app/.env.example index 8ae7018..40dafc6 100644 --- a/app/.env.example +++ b/app/.env.example @@ -73,6 +73,7 @@ DEWEMOJI_MAX_LIMIT=50 DEWEMOJI_FREE_MAX_LIMIT=20 DEWEMOJI_PRO_MAX_LIMIT=50 DEWEMOJI_BILLING_MODE=sandbox +DEWEMOJI_CHECKOUT_PENDING_COOLDOWN=120 DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com DEWEMOJI_FRONTEND_HEADER=web-v1 DEWEMOJI_METRICS_ENABLED=true diff --git a/app/app/Http/Controllers/Api/V1/AdminSubscriptionController.php b/app/app/Http/Controllers/Api/V1/AdminSubscriptionController.php index 06bcc8e..840a827 100644 --- a/app/app/Http/Controllers/Api/V1/AdminSubscriptionController.php +++ b/app/app/Http/Controllers/Api/V1/AdminSubscriptionController.php @@ -6,12 +6,18 @@ use App\Http\Controllers\Controller; use App\Models\Subscription; use App\Models\User; use App\Models\UserApiKey; +use App\Services\Keywords\KeywordQuotaService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; class AdminSubscriptionController extends Controller { + public function __construct( + private readonly KeywordQuotaService $keywordQuota + ) { + } + private function authorizeAdmin(Request $request): ?JsonResponse { $token = (string) config('dewemoji.admin.token', ''); @@ -76,6 +82,7 @@ class AdminSubscriptionController extends Controller if ($status === 'active') { $user->update(['tier' => 'personal']); + $this->keywordQuota->enforceForUser((int) $user->id, 'personal'); } return response()->json(['ok' => true, 'subscription' => $sub]); @@ -151,6 +158,7 @@ class AdminSubscriptionController extends Controller User::where('id', $userId)->update([ 'tier' => $active ? 'personal' : 'free', ]); + $this->keywordQuota->enforceForUser($userId, $active ? 'personal' : 'free'); if (!$active) { UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]); } diff --git a/app/app/Http/Controllers/Api/V1/AdminUserController.php b/app/app/Http/Controllers/Api/V1/AdminUserController.php index 5e0d717..5d50768 100644 --- a/app/app/Http/Controllers/Api/V1/AdminUserController.php +++ b/app/app/Http/Controllers/Api/V1/AdminUserController.php @@ -4,11 +4,17 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\User; +use App\Services\Keywords\KeywordQuotaService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class AdminUserController extends Controller { + public function __construct( + private readonly KeywordQuotaService $keywordQuota + ) { + } + private function authorizeAdmin(Request $request): ?JsonResponse { $adminToken = (string) config('dewemoji.admin.token', ''); @@ -110,6 +116,7 @@ class AdminUserController extends Controller $user->tier = $data['tier']; $user->save(); + $this->keywordQuota->enforceForUser((int) $user->id, (string) $user->tier); return response()->json([ 'ok' => true, diff --git a/app/app/Http/Controllers/Api/V1/EmojiApiController.php b/app/app/Http/Controllers/Api/V1/EmojiApiController.php index b6f1ef6..aaca255 100644 --- a/app/app/Http/Controllers/Api/V1/EmojiApiController.php +++ b/app/app/Http/Controllers/Api/V1/EmojiApiController.php @@ -117,10 +117,76 @@ class EmojiApiController extends Controller $filtered = $this->filterItems($items, $q, $category, $subSlug); + // Signed-in users can enrich search with their active private keywords. + $user = $this->apiKeys->resolveUser($request) ?? $request->user(); + if ($user && $q !== '') { + $bySlug = []; + foreach ($filtered as $row) { + $slug = (string) ($row['slug'] ?? ''); + if ($slug !== '') { + $bySlug[$slug] = $row; + } + } + + $itemsBySlug = []; + foreach ($items as $row) { + $slug = (string) ($row['slug'] ?? ''); + if ($slug !== '') { + $itemsBySlug[$slug] = $row; + } + } + + $privateRows = DB::table('user_keywords') + ->where('user_id', $user->id) + ->where('is_active', true) + ->whereRaw('LOWER(keyword) LIKE ?', ['%'.strtolower($q).'%']) + ->get(['id', 'emoji_slug', 'keyword', 'lang']); + + foreach ($privateRows as $privateRow) { + $slug = (string) $privateRow->emoji_slug; + if ($slug === '' || isset($bySlug[$slug])) { + continue; + } + + $sourceItem = $itemsBySlug[$slug] ?? null; + if (!$sourceItem) { + continue; + } + + // Preserve current category/subcategory filters. + $itemCategory = trim((string) ($sourceItem['category'] ?? '')); + $itemSubcategory = trim((string) ($sourceItem['subcategory'] ?? '')); + if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) { + continue; + } + if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) { + continue; + } + + $sourceItem['source'] = 'private'; + $sourceItem['matched_keyword_id'] = (int) $privateRow->id; + $sourceItem['matched_keyword'] = (string) $privateRow->keyword; + $sourceItem['matched_lang'] = (string) $privateRow->lang; + $bySlug[$slug] = $sourceItem; + } + + $filtered = array_values($bySlug); + } + $total = count($filtered); $offset = ($page - 1) * $limit; $pageItems = array_slice($filtered, $offset, $limit); - $outItems = array_map(fn (array $item): array => $this->transformItem($item, $tier), $pageItems); + $outItems = array_map(function (array $item) use ($tier): array { + $out = $this->transformItem($item, $tier); + if (isset($item['source'])) { + $out['source'] = (string) $item['source']; + $out['matched_keyword_id'] = $item['matched_keyword_id'] ?? null; + $out['matched_keyword'] = $item['matched_keyword'] ?? null; + $out['matched_lang'] = $item['matched_lang'] ?? null; + } + + return $out; + }, $pageItems); $responsePayload = [ 'items' => $outItems, @@ -155,10 +221,6 @@ class EmojiApiController extends Controller if (!$user) { return response()->json(['ok' => false, 'error' => 'unauthorized'], 401); } - if ((string) $user->tier !== 'personal') { - return response()->json(['ok' => false, 'error' => 'personal_required'], 403); - } - $q = trim((string) ($request->query('q', $request->query('query', '')))); $category = $this->normalizeCategoryFilter((string) $request->query('category', '')); $subSlug = $this->slugify((string) $request->query('subcategory', '')); @@ -193,6 +255,7 @@ class EmojiApiController extends Controller if ($q !== '') { $rows = DB::table('user_keywords') ->where('user_id', $user->id) + ->where('is_active', true) ->whereRaw('LOWER(keyword) LIKE ?', ['%'.strtolower($q).'%']) ->get(['id', 'emoji_slug', 'keyword', 'lang']); @@ -209,7 +272,7 @@ class EmojiApiController extends Controller } } - $tier = self::TIER_PRO; + $tier = $this->detectTier($request); $merged = []; foreach ($privateMatches as $slug => $meta) { $sourceItem = $publicBySlug[$slug] ?? $itemsBySlug[$slug] ?? null; @@ -245,7 +308,7 @@ class EmojiApiController extends Controller 'total' => $total, 'page' => $page, 'limit' => $limit, - 'plan' => 'personal', + 'plan' => $tier, ]); } @@ -297,6 +360,7 @@ class EmojiApiController extends Controller $payload['user_keywords'] = DB::table('user_keywords') ->where('user_id', $user->id) ->where('emoji_slug', $slug) + ->where('is_active', true) ->orderByDesc('id') ->get(['id', 'keyword', 'lang']); } else { @@ -361,7 +425,12 @@ class EmojiApiController extends Controller return self::$dataset; } - $path = (string) config('dewemoji.data_path'); + $settings = app(SettingsService::class); + $activePath = (string) $settings->get('emoji_dataset_active_path', ''); + $path = $activePath !== '' && is_file($activePath) + ? $activePath + : (string) config('dewemoji.data_path'); + if (!is_file($path)) { throw new RuntimeException('Emoji dataset file was not found at: '.$path); } diff --git a/app/app/Http/Controllers/Api/V1/UserKeywordController.php b/app/app/Http/Controllers/Api/V1/UserKeywordController.php index 4325325..c0622b9 100644 --- a/app/app/Http/Controllers/Api/V1/UserKeywordController.php +++ b/app/app/Http/Controllers/Api/V1/UserKeywordController.php @@ -5,13 +5,15 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\UserKeyword; use App\Services\Auth\ApiKeyService; +use App\Services\Keywords\KeywordQuotaService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class UserKeywordController extends Controller { public function __construct( - private readonly ApiKeyService $keys + private readonly ApiKeyService $keys, + private readonly KeywordQuotaService $keywordQuota ) { } @@ -35,7 +37,7 @@ class UserKeywordController extends Controller $user = $check['user']; $items = UserKeyword::where('user_id', $user->id) ->orderByDesc('id') - ->get(['id', 'emoji_slug', 'keyword', 'lang', 'created_at', 'updated_at']); + ->get(['id', 'emoji_slug', 'keyword', 'lang', 'is_active', 'created_at', 'updated_at']); return response()->json(['ok' => true, 'items' => $items]); } @@ -55,30 +57,35 @@ class UserKeywordController extends Controller $lang = $data['lang'] ?? 'und'; $user = $check['user']; + $existing = UserKeyword::where('user_id', $user->id) + ->where('emoji_slug', $data['emoji_slug']) + ->where('keyword', $data['keyword']) + ->first(); + $targetActive = $existing ? (bool) $existing->is_active : true; $limit = $this->keywordLimitFor($user); if ($limit !== null) { - $exists = UserKeyword::where('user_id', $user->id) - ->where('emoji_slug', $data['emoji_slug']) - ->where('keyword', $data['keyword']) - ->exists(); - if (!$exists) { - $count = UserKeyword::where('user_id', $user->id)->count(); - if ($count >= $limit) { - return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403); + if (!$existing && $targetActive) { + $activeCount = $this->keywordQuota->activeCount((int) $user->id); + if ($activeCount >= $limit) { + return response()->json(['ok' => false, 'error' => 'free_active_limit_reached', 'limit' => $limit], 403); } } } - $item = UserKeyword::updateOrCreate( - [ + if ($existing) { + $existing->update([ + 'lang' => $lang, + ]); + $item = $existing; + } else { + $item = UserKeyword::create([ 'user_id' => $user->id, 'emoji_slug' => $data['emoji_slug'], 'keyword' => $data['keyword'], - ], - [ 'lang' => $lang, - ] - ); + 'is_active' => true, + ]); + } return response()->json(['ok' => true, 'item' => $item]); } @@ -127,11 +134,11 @@ class UserKeywordController extends Controller $item->delete(); $item = $duplicate; } else { - $item->update([ - 'emoji_slug' => $data['emoji_slug'], - 'keyword' => $data['keyword'], - 'lang' => $data['lang'] ?? 'und', - ]); + $item->update([ + 'emoji_slug' => $data['emoji_slug'], + 'keyword' => $data['keyword'], + 'lang' => $data['lang'] ?? 'und', + ]); } return response()->json(['ok' => true, 'item' => $item]); @@ -146,7 +153,7 @@ class UserKeywordController extends Controller $items = UserKeyword::where('user_id', $check['user']->id) ->orderByDesc('id') - ->get(['emoji_slug', 'keyword', 'lang']); + ->get(['emoji_slug', 'keyword', 'lang', 'is_active']); return response()->json(['ok' => true, 'items' => $items]); } @@ -169,29 +176,34 @@ class UserKeywordController extends Controller $skipped = 0; $user = $check['user']; $limit = $this->keywordLimitFor($user); - $current = UserKeyword::where('user_id', $user->id)->count(); + $activeCount = $this->keywordQuota->activeCount((int) $user->id); foreach ($data['items'] as $row) { - $exists = UserKeyword::where('user_id', $user->id) + $existing = UserKeyword::where('user_id', $user->id) ->where('emoji_slug', $row['emoji_slug']) ->where('keyword', $row['keyword']) - ->exists(); + ->first(); + $targetActive = filter_var($row['is_active'] ?? true, FILTER_VALIDATE_BOOL); - if (!$exists && $limit !== null && $current >= $limit) { + if (!$existing && $targetActive && $limit !== null && $activeCount >= $limit) { $skipped += 1; continue; } - UserKeyword::updateOrCreate( - [ + + if ($existing) { + $existing->update([ + 'lang' => $row['lang'] ?? 'und', + ]); + } else { + UserKeyword::create([ 'user_id' => $user->id, 'emoji_slug' => $row['emoji_slug'], 'keyword' => $row['keyword'], - ], - [ 'lang' => $row['lang'] ?? 'und', - ] - ); - if (!$exists) { - $current += 1; + 'is_active' => $targetActive, + ]); + if ($targetActive) { + $activeCount += 1; + } } $count += 1; } @@ -199,6 +211,35 @@ class UserKeywordController extends Controller return response()->json(['ok' => true, 'imported' => $count, 'skipped' => $skipped]); } + public function toggleActive(Request $request, int $id): JsonResponse + { + $check = $this->ensureUser($request); + if (!isset($check['user'])) { + return response()->json(['ok' => false, 'error' => $check['error']], $check['status']); + } + + $data = $request->validate([ + 'is_active' => 'required|boolean', + ]); + + $user = $check['user']; + $item = UserKeyword::where('user_id', $user->id)->where('id', $id)->first(); + if (!$item) { + return response()->json(['ok' => false, 'error' => 'not_found'], 404); + } + + if ((bool) $data['is_active'] && ($limit = $this->keywordLimitFor($user))) { + $activeCount = $this->keywordQuota->activeCount((int) $user->id); + if (!$item->is_active && $activeCount >= $limit) { + return response()->json(['ok' => false, 'error' => 'free_active_limit_reached', 'limit' => $limit], 403); + } + } + + $item->update(['is_active' => (bool) $data['is_active']]); + + return response()->json(['ok' => true, 'item' => $item]); + } + private function keywordLimitFor($user): ?int { if ((string) ($user->tier ?? 'free') === 'personal') { diff --git a/app/app/Http/Controllers/Billing/BillingPaymentController.php b/app/app/Http/Controllers/Billing/BillingPaymentController.php new file mode 100644 index 0000000..e56bbb7 --- /dev/null +++ b/app/app/Http/Controllers/Billing/BillingPaymentController.php @@ -0,0 +1,119 @@ +user(); + if (!$user || (int) $payment->user_id !== (int) $user->id) { + abort(403); + } + + if ((string) $payment->status !== 'pending') { + return response()->json(['ok' => false, 'error' => 'payment_not_pending'], 409); + } + + $provider = strtolower((string) $payment->provider); + return match ($provider) { + 'paypal' => $this->resumePayPal($payment), + 'pakasir' => $this->resumePakasir($payment), + default => response()->json(['ok' => false, 'error' => 'provider_unsupported'], 422), + }; + } + + private function resumePayPal(Payment $payment): JsonResponse + { + $raw = is_array($payment->raw_payload) ? $payment->raw_payload : []; + $links = is_array($raw['links'] ?? null) ? $raw['links'] : []; + $approveUrl = null; + foreach ($links as $link) { + if (!is_array($link)) { + continue; + } + if ((string) ($link['rel'] ?? '') === 'approve') { + $href = trim((string) ($link['href'] ?? '')); + if ($href !== '') { + $approveUrl = $href; + break; + } + } + } + + if ($approveUrl === null) { + return response()->json(['ok' => false, 'error' => 'resume_data_missing'], 422); + } + + return response()->json([ + 'ok' => true, + 'mode' => 'redirect', + 'provider' => 'paypal', + 'approve_url' => $approveUrl, + 'payment_id' => $payment->id, + ]); + } + + private function resumePakasir(Payment $payment): JsonResponse + { + $raw = is_array($payment->raw_payload) ? $payment->raw_payload : []; + $pay = is_array($raw['payment'] ?? null) + ? $raw['payment'] + : (is_array($raw['data'] ?? null) ? $raw['data'] : $raw); + + $paymentNumber = trim((string) ( + $pay['payment_number'] + ?? $pay['qris_string'] + ?? $pay['qr_string'] + ?? $pay['qr_value'] + ?? '' + )); + $expiredAt = trim((string) ( + $pay['expired_at'] + ?? $pay['expires_at'] + ?? $pay['expired'] + ?? '' + )); + $totalPayment = (int) ( + $pay['total_payment'] + ?? $pay['amount'] + ?? $raw['total_payment'] + ?? $raw['amount'] + ?? $payment->amount + ); + $orderId = trim((string) ( + $pay['order_id'] + ?? $raw['order_id'] + ?? $payment->order?->provider_ref + ?? '' + )); + + if ($paymentNumber === '') { + return response()->json(['ok' => false, 'error' => 'resume_data_missing'], 422); + } + + if ($expiredAt !== '') { + $ts = strtotime($expiredAt); + if ($ts !== false && $ts < time()) { + return response()->json(['ok' => false, 'error' => 'payment_expired'], 409); + } + } + + return response()->json([ + 'ok' => true, + 'mode' => 'qris', + 'provider' => 'pakasir', + 'payment_id' => $payment->id, + 'payment_number' => $paymentNumber, + 'expired_at' => $expiredAt, + 'amount' => (int) $payment->amount, + 'total_payment' => $totalPayment, + 'order_id' => $orderId, + ]); + } +} diff --git a/app/app/Http/Controllers/Billing/PakasirController.php b/app/app/Http/Controllers/Billing/PakasirController.php index 2635030..0cba07f 100644 --- a/app/app/Http/Controllers/Billing/PakasirController.php +++ b/app/app/Http/Controllers/Billing/PakasirController.php @@ -9,6 +9,8 @@ use App\Models\PricingPlan; use App\Models\Subscription; use App\Models\User; use App\Models\WebhookEvent; +use App\Services\Billing\SubscriptionTransitionService; +use App\Services\Keywords\KeywordQuotaService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -17,6 +19,12 @@ use Illuminate\Support\Str; class PakasirController extends Controller { + public function __construct( + private readonly SubscriptionTransitionService $subscriptionTransition, + private readonly KeywordQuotaService $keywordQuota + ) { + } + public function createTransaction(Request $request): JsonResponse { $data = $request->validate([ @@ -28,6 +36,14 @@ class PakasirController extends Controller return response()->json(['error' => 'auth_required'], 401); } + if ($this->hasActiveLifetimeSubscription((int) $user->id)) { + return response()->json(['error' => 'lifetime_active'], 409); + } + + if ($cooldownPayload = $this->pendingCheckoutCooldown((int) $user->id)) { + return response()->json($cooldownPayload, 409); + } + $config = config('dewemoji.billing.providers.pakasir', []); $enabled = (bool) ($config['enabled'] ?? false); $apiBase = rtrim((string) ($config['api_base'] ?? ''), '/'); @@ -44,18 +60,7 @@ class PakasirController extends Controller return response()->json(['error' => 'invalid_plan_amount'], 422); } - Subscription::where('user_id', $user->id) - ->where('provider', 'pakasir') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); - Order::where('user_id', $user->id) - ->where('provider', 'pakasir') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); - Payment::where('user_id', $user->id) - ->where('provider', 'pakasir') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); + $this->subscriptionTransition->cancelPendingForUser((int) $user->id); $order = Order::create([ 'user_id' => $user->id, @@ -190,6 +195,40 @@ class PakasirController extends Controller return $orderRef; } + /** + * @return array|null + */ + private function pendingCheckoutCooldown(int $userId): ?array + { + $cooldown = (int) config('dewemoji.billing.pending_cooldown_seconds', 120); + if ($cooldown <= 0) { + return null; + } + + $pending = Payment::query() + ->where('user_id', $userId) + ->where('status', 'pending') + ->orderByDesc('id') + ->first(); + if (!$pending || !$pending->created_at) { + return null; + } + + $age = max(0, now()->getTimestamp() - $pending->created_at->getTimestamp()); + $remaining = $cooldown - $age; + if ($remaining <= 0) { + return null; + } + + return [ + 'error' => 'pending_cooldown', + 'retry_after' => (int) $remaining, + 'pending_payment_id' => (int) $pending->id, + 'provider' => (string) ($pending->provider ?? ''), + 'created_at' => $pending->created_at->toIso8601String(), + ]; + } + public function cancelPending(Request $request): JsonResponse { $user = $request->user(); @@ -209,17 +248,17 @@ class PakasirController extends Controller $order = $orderQuery->orderByDesc('id')->first(); if (!$order) { - return response()->json(['ok' => true, 'cancelled' => false]); + return response()->json(['ok' => true, 'canceled' => false, 'cancelled' => false]); } - $order->update(['status' => 'cancelled']); - Payment::where('order_id', $order->id)->where('status', 'pending')->update(['status' => 'cancelled']); + $order->update(['status' => 'canceled']); + Payment::where('order_id', $order->id)->where('status', 'pending')->update(['status' => 'canceled']); Subscription::where('user_id', $user->id) ->where('provider', 'pakasir') ->where('status', 'pending') - ->update(['status' => 'cancelled']); + ->update(['status' => 'canceled']); - return response()->json(['ok' => true, 'cancelled' => true]); + return response()->json(['ok' => true, 'canceled' => true, 'cancelled' => true]); } public function paymentStatus(Request $request): JsonResponse @@ -343,6 +382,13 @@ class PakasirController extends Controller ); User::where('id', $user->id)->update(['tier' => 'personal']); + $this->keywordQuota->enforceForUser((int) $user->id, 'personal'); + $this->subscriptionTransition->settleForActivatedPlan( + (int) $user->id, + 'pakasir', + $orderId, + (string) $order->plan_code + ); } private function resolvePlanAmountIdr(string $planCode): int @@ -360,4 +406,17 @@ class PakasirController extends Controller return (int) ($fallback['amount'] ?? 0); } + + private function hasActiveLifetimeSubscription(int $userId): bool + { + return Subscription::query() + ->where('user_id', $userId) + ->where('plan', 'personal_lifetime') + ->where('status', 'active') + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->exists(); + } } diff --git a/app/app/Http/Controllers/Billing/PayPalController.php b/app/app/Http/Controllers/Billing/PayPalController.php index c725737..2c25589 100644 --- a/app/app/Http/Controllers/Billing/PayPalController.php +++ b/app/app/Http/Controllers/Billing/PayPalController.php @@ -9,6 +9,8 @@ use App\Models\PricingPlan; use App\Models\Subscription; use App\Models\User; use App\Models\WebhookEvent; +use App\Services\Billing\SubscriptionTransitionService; +use App\Services\Keywords\KeywordQuotaService; use App\Services\System\SettingsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -20,6 +22,12 @@ use Illuminate\Support\Str; class PayPalController extends Controller { + public function __construct( + private readonly SubscriptionTransitionService $subscriptionTransition, + private readonly KeywordQuotaService $keywordQuota + ) { + } + public function createSubscription(Request $request): RedirectResponse|JsonResponse { $data = $request->validate([ @@ -31,6 +39,14 @@ class PayPalController extends Controller return response()->json(['error' => 'auth_required'], 401); } + if ($this->hasActiveLifetimeSubscription((int) $user->id)) { + return response()->json(['error' => 'lifetime_active'], 409); + } + + if ($cooldownPayload = $this->pendingCheckoutCooldown((int) $user->id)) { + return response()->json($cooldownPayload, 409); + } + $mode = $this->resolvePaypalMode($this->billingMode()); if (!$this->paypalConfigured($mode)) { return response()->json(['error' => 'paypal_not_configured'], 422); @@ -95,18 +111,7 @@ class PayPalController extends Controller $amountUsd = $this->resolvePlanAmountUsd($data['plan_code']); - Subscription::where('user_id', $user->id) - ->where('provider', 'paypal') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); - Order::where('user_id', $user->id) - ->where('provider', 'paypal') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); - Payment::where('user_id', $user->id) - ->where('provider', 'paypal') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); + $this->subscriptionTransition->cancelPendingForUser((int) $user->id); $order = Order::create([ 'user_id' => $user->id, @@ -213,6 +218,7 @@ class PayPalController extends Controller if ($subscriptionId === '') { return false; } + $resolvedPlan = 'personal_monthly'; $sub = Subscription::firstOrNew([ 'provider' => 'paypal', 'provider_ref' => $subscriptionId, @@ -221,8 +227,13 @@ class PayPalController extends Controller $order = Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first(); if ($order) { $sub->user_id = $order->user_id; + $resolvedPlan = (string) ($order->plan_code ?: $resolvedPlan); } } + if (!empty($sub->plan)) { + $resolvedPlan = (string) $sub->plan; + } + $sub->plan = $resolvedPlan; $sub->status = 'active'; $sub->started_at = $sub->started_at ?? now(); $sub->next_renewal_at = $resource['billing_info']['next_billing_time'] ?? null; @@ -230,6 +241,13 @@ class PayPalController extends Controller if ($sub->user_id) { User::where('id', $sub->user_id)->update(['tier' => 'personal']); + $this->keywordQuota->enforceForUser((int) $sub->user_id, 'personal'); + $this->subscriptionTransition->settleForActivatedPlan( + (int) $sub->user_id, + 'paypal', + $subscriptionId, + $resolvedPlan + ); } Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']); @@ -278,6 +296,40 @@ class PayPalController extends Controller return config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$planCode}") ?: null; } + /** + * @return array|null + */ + private function pendingCheckoutCooldown(int $userId): ?array + { + $cooldown = (int) config('dewemoji.billing.pending_cooldown_seconds', 120); + if ($cooldown <= 0) { + return null; + } + + $pending = Payment::query() + ->where('user_id', $userId) + ->where('status', 'pending') + ->orderByDesc('id') + ->first(); + if (!$pending || !$pending->created_at) { + return null; + } + + $age = max(0, now()->getTimestamp() - $pending->created_at->getTimestamp()); + $remaining = $cooldown - $age; + if ($remaining <= 0) { + return null; + } + + return [ + 'error' => 'pending_cooldown', + 'retry_after' => (int) $remaining, + 'pending_payment_id' => (int) $pending->id, + 'provider' => (string) ($pending->provider ?? ''), + 'created_at' => $pending->created_at->toIso8601String(), + ]; + } + private function resolvePlanAmountUsd(string $planCode): int { $plan = PricingPlan::where('code', $planCode)->first(); @@ -444,16 +496,7 @@ class PayPalController extends Controller return response()->json(['error' => 'paypal_invalid_response'], 502); } - Order::where('user_id', $user->id) - ->where('provider', 'paypal') - ->where('type', 'one_time') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); - Payment::where('user_id', $user->id) - ->where('provider', 'paypal') - ->where('type', 'one_time') - ->where('status', 'pending') - ->update(['status' => 'cancelled']); + $this->subscriptionTransition->cancelPendingForUser((int) $user->id); $order = Order::create([ 'user_id' => $user->id, @@ -571,7 +614,27 @@ class PayPalController extends Controller ]); User::where('id', $order->user_id)->update(['tier' => 'personal']); + $this->keywordQuota->enforceForUser((int) $order->user_id, 'personal'); + $this->subscriptionTransition->settleForActivatedPlan( + (int) $order->user_id, + 'paypal', + $orderId, + 'personal_lifetime' + ); return true; } + + private function hasActiveLifetimeSubscription(int $userId): bool + { + return Subscription::query() + ->where('user_id', $userId) + ->where('plan', 'personal_lifetime') + ->where('status', 'active') + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->exists(); + } } diff --git a/app/app/Http/Controllers/Dashboard/AdminDashboardController.php b/app/app/Http/Controllers/Dashboard/AdminDashboardController.php index 1dccc4d..e2e857b 100644 --- a/app/app/Http/Controllers/Dashboard/AdminDashboardController.php +++ b/app/app/Http/Controllers/Dashboard/AdminDashboardController.php @@ -14,6 +14,7 @@ use App\Models\UserKeyword; use App\Models\WebhookEvent; use App\Models\AdminAuditLog; use App\Services\Billing\PayPalPlanSyncService; +use App\Services\Keywords\KeywordQuotaService; use App\Services\System\SettingsService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -26,7 +27,10 @@ use Symfony\Component\HttpFoundation\StreamedResponse; class AdminDashboardController extends Controller { - public function __construct(private readonly SettingsService $settings) + public function __construct( + private readonly SettingsService $settings, + private readonly KeywordQuotaService $keywordQuota + ) { } @@ -83,6 +87,7 @@ class AdminDashboardController extends Controller if ($data['tier'] === 'free') { UserApiKey::where('user_id', $data['user_id'])->update(['revoked_at' => now()]); } + $this->keywordQuota->enforceForUser((int) $data['user_id'], (string) $data['tier']); $this->logAdminAction('user_tier_update', $data); return back()->with('status', 'User tier updated.'); @@ -220,6 +225,7 @@ class AdminDashboardController extends Controller if ($data['status'] === 'active') { $user->update(['tier' => 'personal']); + $this->keywordQuota->enforceForUser((int) $user->id, 'personal'); } $this->logAdminAction('subscription_grant', [ @@ -550,6 +556,7 @@ class AdminDashboardController extends Controller User::where('id', $userId)->update([ 'tier' => $active ? 'personal' : 'free', ]); + $this->keywordQuota->enforceForUser($userId, $active ? 'personal' : 'free'); if (!$active) { UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]); } diff --git a/app/app/Http/Controllers/Dashboard/AdminEmojiCatalogController.php b/app/app/Http/Controllers/Dashboard/AdminEmojiCatalogController.php new file mode 100644 index 0000000..4188875 --- /dev/null +++ b/app/app/Http/Controllers/Dashboard/AdminEmojiCatalogController.php @@ -0,0 +1,245 @@ +query('q', '')); + $items = $this->listItems($q); + + return view('dashboard.admin.catalog', [ + 'items' => $items, + 'totalRows' => (int) DB::table('emojis')->count(), + 'snapshots' => $this->catalog->listSnapshots(), + 'activeVersion' => (string) $this->settings->get('emoji_dataset_active_version', ''), + 'activePath' => (string) $this->settings->get('emoji_dataset_active_path', ''), + 'filters' => ['q' => $q], + ]); + } + + public function create(Request $request): View + { + return view('dashboard.admin.catalog-form', [ + 'mode' => 'create', + 'selected' => null, + ]); + } + + public function edit(Request $request, int $emojiId): View + { + $selected = $this->catalog->findItem($emojiId); + abort_if($selected === null, 404); + + return view('dashboard.admin.catalog-form', [ + 'mode' => 'edit', + 'selected' => $selected, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validateItemPayload($request); + unset($validated['emoji_id']); + + try { + $emojiId = $this->catalog->saveItem($validated); + } catch (Throwable $e) { + report($e); + return back()->withInput()->with('error', $e->getMessage() ?: 'Failed to create catalog item.'); + } + + $this->logAdminAction($request, 'emoji_catalog_create', [ + 'emoji_id' => $emojiId, + 'slug' => $validated['slug'], + ]); + + return redirect()->route('dashboard.admin.catalog.edit', ['emojiId' => $emojiId]) + ->with('status', 'Catalog item created.'); + } + + public function update(Request $request, int $emojiId): RedirectResponse + { + $validated = $this->validateItemPayload($request); + $validated['emoji_id'] = $emojiId; + + try { + $savedId = $this->catalog->saveItem($validated); + } catch (Throwable $e) { + report($e); + return back() + ->withInput() + ->with('error', $e->getMessage() ?: 'Failed to save catalog item.'); + } + + $this->logAdminAction($request, 'emoji_catalog_upsert', [ + 'emoji_id' => $savedId, + 'slug' => $validated['slug'], + ]); + + return back()->with('status', 'Catalog item updated.'); + } + + public function destroy(Request $request, int $emojiId): RedirectResponse + { + try { + $selected = $this->catalog->findItem($emojiId); + $this->catalog->deleteItem($emojiId); + } catch (Throwable $e) { + report($e); + return back()->with('error', $e->getMessage() ?: 'Failed to delete catalog item.'); + } + + $this->logAdminAction($request, 'emoji_catalog_delete', [ + 'emoji_id' => $emojiId, + 'slug' => (string) ($selected['slug'] ?? ''), + ]); + + return back()->with('status', 'Catalog item deleted.'); + } + + public function importCurrentJson(Request $request): RedirectResponse + { + $path = (string) config('dewemoji.data_path'); + try { + $result = $this->catalog->importFromDataFile($path); + } catch (Throwable $e) { + report($e); + return back()->with('error', $e->getMessage() ?: 'Failed to import dataset.'); + } + + $this->logAdminAction($request, 'emoji_catalog_import_json', [ + 'path' => $path, + 'total' => $result['total'], + 'imported' => $result['imported'], + 'skipped' => $result['skipped'], + ]); + + return back()->with( + 'status', + "Import completed. New rows: {$result['imported']}, skipped existing: {$result['skipped']}, total scanned: {$result['total']}." + ); + } + + public function publish(Request $request): RedirectResponse + { + try { + $result = $this->catalog->publishSnapshot($request->user()?->email); + } catch (Throwable $e) { + report($e); + return back()->with('error', $e->getMessage() ?: 'Failed to publish snapshot.'); + } + + $this->logAdminAction($request, 'emoji_catalog_publish', $result); + + return back()->with('status', "Published snapshot version {$result['version']} ({$result['count']} emojis)."); + } + + public function activateSnapshot(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'snapshot' => 'required|string', + ]); + + try { + $result = $this->catalog->activateSnapshot($validated['snapshot'], $request->user()?->email); + } catch (Throwable $e) { + report($e); + return back()->with('error', $e->getMessage() ?: 'Failed to activate snapshot.'); + } + + $this->logAdminAction($request, 'emoji_catalog_activate_snapshot', $result); + + return back()->with('status', "Snapshot {$result['version']} is now active."); + } + + /** + * @param array $payload + */ + private function logAdminAction(Request $request, string $action, array $payload): void + { + $admin = $request->user(); + if (!$admin) { + return; + } + + AdminAuditLog::create([ + 'admin_id' => $admin->id, + 'admin_email' => $admin->email, + 'action' => $action, + 'payload' => $payload, + 'ip_address' => $request->ip(), + ]); + } + + private function listItems(string $q): LengthAwarePaginator + { + $itemsQuery = DB::table('emojis')->select([ + 'emoji_id', + 'slug', + 'emoji_char as emoji', + 'name', + 'category', + 'subcategory', + 'updated_at', + ]); + + if ($q !== '') { + $itemsQuery->where(function ($sub) use ($q): void { + $sub->where('slug', 'like', '%'.$q.'%') + ->orWhere('name', 'like', '%'.$q.'%') + ->orWhere('category', 'like', '%'.$q.'%') + ->orWhere('subcategory', 'like', '%'.$q.'%'); + }); + } + + return $itemsQuery->orderBy('emoji_id')->paginate(30)->withQueryString(); + } + + /** + * @return array + */ + private function validateItemPayload(Request $request): array + { + return $request->validate([ + 'emoji_id' => 'nullable|integer', + 'slug' => 'required|string|max:160', + 'emoji' => 'nullable|string|max:32', + 'name' => 'required|string|max:200', + 'category' => 'nullable|string|max:120', + 'subcategory' => 'nullable|string|max:120', + 'aliases' => 'nullable|string', + 'shortcodes' => 'nullable|string', + 'alt_shortcodes' => 'nullable|string', + 'keywords_en' => 'nullable|string', + 'keywords_id' => 'nullable|string', + 'description' => 'nullable|string', + 'unified' => 'nullable|string|max:120', + 'default_presentation' => 'nullable|string|max:16', + 'version' => 'nullable|string|max:16', + 'supports_skin_tone' => 'nullable|boolean', + 'permalink' => 'nullable|string|max:255', + 'title' => 'nullable|string|max:255', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string|max:255', + ]); + } +} diff --git a/app/app/Http/Controllers/Dashboard/UserDashboardController.php b/app/app/Http/Controllers/Dashboard/UserDashboardController.php index b2badc0..4554550 100644 --- a/app/app/Http/Controllers/Dashboard/UserDashboardController.php +++ b/app/app/Http/Controllers/Dashboard/UserDashboardController.php @@ -12,6 +12,7 @@ use App\Models\UserKeyword; use App\Models\WebhookEvent; use App\Http\Controllers\Api\V1\EmojiApiController; use App\Services\Auth\ApiKeyService; +use App\Services\Keywords\KeywordQuotaService; use Carbon\Carbon; use Illuminate\Contracts\View\View; use Illuminate\Http\JsonResponse; @@ -24,7 +25,8 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; class UserDashboardController extends Controller { public function __construct( - private readonly ApiKeyService $keys + private readonly ApiKeyService $keys, + private readonly KeywordQuotaService $keywordQuota ) { } @@ -128,6 +130,9 @@ class UserDashboardController extends Controller $items = UserKeyword::where('user_id', $user?->id) ->orderByDesc('id') ->get(); + $activeCount = UserKeyword::where('user_id', $user?->id) + ->where('is_active', true) + ->count(); $emojiLookup = []; $dataPath = (string) config('dewemoji.data_path'); @@ -152,6 +157,7 @@ class UserDashboardController extends Controller 'user' => $user, 'emojiLookup' => $emojiLookup, 'freeLimit' => $this->keywordLimitFor($user), + 'activeCount' => $activeCount, ]); } @@ -175,30 +181,35 @@ class UserDashboardController extends Controller 'lang' => 'nullable|string|max:10', ]); - if ($limit = $this->keywordLimitFor($user)) { - $exists = UserKeyword::where('user_id', $user->id) - ->where('emoji_slug', $data['emoji_slug']) - ->where('keyword', $data['keyword']) - ->exists(); + $existing = UserKeyword::where('user_id', $user->id) + ->where('emoji_slug', $data['emoji_slug']) + ->where('keyword', $data['keyword']) + ->first(); + $targetActive = $existing ? (bool) $existing->is_active : true; - if (!$exists) { - $count = UserKeyword::where('user_id', $user->id)->count(); - if ($count >= $limit) { - return $this->rejectKeywordLimit($request, $limit); + if ($limit = $this->keywordLimitFor($user)) { + if (!$existing && $targetActive) { + $activeCount = $this->keywordQuota->activeCount((int) $user->id); + if ($activeCount >= $limit) { + return $this->rejectKeywordLimit($request, $limit, 'free_active_limit_reached'); } } } - $item = UserKeyword::updateOrCreate( - [ + if ($existing) { + $existing->update([ + 'lang' => $data['lang'] ?? 'und', + ]); + $item = $existing; + } else { + $item = UserKeyword::create([ 'user_id' => $user->id, 'emoji_slug' => $data['emoji_slug'], 'keyword' => $data['keyword'], - ], - [ 'lang' => $data['lang'] ?? 'und', - ] - ); + 'is_active' => true, + ]); + } if ($request->expectsJson()) { return response()->json(['ok' => true, 'item' => $item]); @@ -287,7 +298,7 @@ class UserDashboardController extends Controller $imported = 0; $skipped = 0; $limit = $this->keywordLimitFor($user); - $current = UserKeyword::where('user_id', $user->id)->count(); + $activeCount = $this->keywordQuota->activeCount((int) $user->id); foreach ($items as $row) { if (!is_array($row)) { continue; @@ -298,35 +309,39 @@ class UserDashboardController extends Controller if ($emojiSlug === '' || $keyword === '') { continue; } - $exists = UserKeyword::where('user_id', $user->id) + $existing = UserKeyword::where('user_id', $user->id) ->where('emoji_slug', $emojiSlug) ->where('keyword', $keyword) - ->exists(); + ->first(); + $targetActive = filter_var($row['is_active'] ?? true, FILTER_VALIDATE_BOOL); - if (!$exists && $limit !== null && $current >= $limit) { + if (!$existing && $targetActive && $limit !== null && $activeCount >= $limit) { $skipped += 1; continue; } - UserKeyword::updateOrCreate( - [ + if ($existing) { + $existing->update([ + 'lang' => $lang !== '' ? $lang : 'und', + ]); + } else { + UserKeyword::create([ 'user_id' => $user->id, 'emoji_slug' => $emojiSlug, 'keyword' => $keyword, - ], - [ 'lang' => $lang !== '' ? $lang : 'und', - ] - ); - if (!$exists) { - $current += 1; + 'is_active' => $targetActive, + ]); + if ($targetActive) { + $activeCount += 1; + } } $imported += 1; } $message = "Imported {$imported} keywords."; if ($skipped > 0) { - $message .= " {$skipped} skipped (free limit reached)."; + $message .= " {$skipped} skipped (active free limit reached)."; } return back()->with('status', $message); @@ -341,7 +356,7 @@ class UserDashboardController extends Controller $items = UserKeyword::where('user_id', $user->id) ->orderByDesc('id') - ->get(['emoji_slug', 'keyword', 'lang']) + ->get(['emoji_slug', 'keyword', 'lang', 'is_active']) ->values() ->toJson(JSON_PRETTY_PRINT); @@ -429,13 +444,41 @@ class UserDashboardController extends Controller return view('dashboard.user.preferences'); } - private function rejectKeywordLimit(Request $request, int $limit): RedirectResponse|JsonResponse + public function toggleKeywordActive(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse { - if ($request->expectsJson()) { - return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403); + $user = $request->user(); + if (!$user || $keyword->user_id !== $user->id) { + abort(403); } - return back()->withErrors(['tier' => "Free plan limit reached ({$limit} keywords)."]); + $data = $request->validate([ + 'is_active' => 'required|boolean', + ]); + + $target = (bool) $data['is_active']; + if ($target && ($limit = $this->keywordLimitFor($user))) { + $activeCount = $this->keywordQuota->activeCount((int) $user->id); + if (!$keyword->is_active && $activeCount >= $limit) { + return $this->rejectKeywordLimit($request, $limit, 'free_active_limit_reached'); + } + } + + $keyword->update(['is_active' => $target]); + + if ($request->expectsJson()) { + return response()->json(['ok' => true, 'item' => $keyword]); + } + + return back()->with('status', $target ? 'Keyword activated.' : 'Keyword deactivated.'); + } + + private function rejectKeywordLimit(Request $request, int $limit, string $error = 'free_limit_reached'): RedirectResponse|JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['ok' => false, 'error' => $error, 'limit' => $limit], 403); + } + + return back()->withErrors(['tier' => "Free plan active limit reached ({$limit} keywords)."]); } private function keywordLimitFor(?User $user): ?int diff --git a/app/app/Http/Controllers/Web/SiteController.php b/app/app/Http/Controllers/Web/SiteController.php index a9eb29a..6cae4cc 100644 --- a/app/app/Http/Controllers/Web/SiteController.php +++ b/app/app/Http/Controllers/Web/SiteController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Models\Payment; use App\Models\PricingPlan; +use App\Models\Subscription; use App\Models\UserKeyword; use App\Services\System\SettingsService; use Illuminate\Contracts\View\View; @@ -118,6 +120,7 @@ class SiteController extends Controller public function pricing(): View { + $user = request()->user(); $currencyPref = strtoupper((string) session('pricing_currency', '')); if (!in_array($currencyPref, ['IDR', 'USD'], true)) { $currencyPref = $this->detectPricingCurrency(request()); @@ -150,6 +153,37 @@ class SiteController extends Controller $pricing[$key]['usd'] = $rate > 0 ? round($row['idr'] / $rate, 2) : 0; } + $hasActiveLifetime = false; + $hasPendingPayment = false; + $pendingCooldownRemaining = 0; + if ($user) { + $hasActiveLifetime = Subscription::query() + ->where('user_id', $user->id) + ->where('plan', 'personal_lifetime') + ->where('status', 'active') + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->exists(); + $hasPendingPayment = Payment::query() + ->where('user_id', $user->id) + ->where('status', 'pending') + ->exists(); + $cooldown = (int) config('dewemoji.billing.pending_cooldown_seconds', 120); + if ($cooldown > 0) { + $latestPending = Payment::query() + ->where('user_id', $user->id) + ->where('status', 'pending') + ->orderByDesc('id') + ->first(); + if ($latestPending && $latestPending->created_at) { + $age = max(0, now()->getTimestamp() - $latestPending->created_at->getTimestamp()); + $pendingCooldownRemaining = max(0, $cooldown - $age); + } + } + } + return view('site.pricing', [ 'currencyPref' => $currencyPref, 'usdRate' => $rate, @@ -164,6 +198,9 @@ class SiteController extends Controller && (string) config('dewemoji.billing.providers.pakasir.project', '') !== '', 'paypalEnabled' => $this->paypalEnabled($this->billingMode()), 'paypalPlans' => $this->paypalPlanAvailability($this->billingMode()), + 'hasActiveLifetime' => $hasActiveLifetime, + 'hasPendingPayment' => $hasPendingPayment, + 'pendingCooldownRemaining' => $pendingCooldownRemaining, ]); } @@ -249,7 +286,7 @@ class SiteController extends Controller public function emojiDetail(string $slug): View|Response { - $dataPath = (string) config('dewemoji.data_path'); + $dataPath = $this->datasetPath(); if (!is_file($dataPath)) { abort(500, 'Emoji dataset file not found.'); } @@ -298,13 +335,17 @@ class SiteController extends Controller $freeLimit = (int) config('dewemoji.pagination.free_max_limit', 20); $keywordLimit = $isPersonal ? null : $freeLimit; $userKeywords = []; + $activeKeywordCount = 0; if ($canManageKeywords) { + $activeKeywordCount = UserKeyword::where('user_id', $user->id) + ->where('is_active', true) + ->count(); $userKeywords = UserKeyword::where('user_id', $user->id) ->where('emoji_slug', $slug) ->orderByDesc('id') ->get(); } - $limitReached = $keywordLimit !== null && $userKeywords->count() >= $keywordLimit; + $limitReached = $keywordLimit !== null && $activeKeywordCount >= $keywordLimit; return view('site.emoji-detail', [ 'emoji' => $match, @@ -314,6 +355,7 @@ class SiteController extends Controller 'canManageKeywords' => $canManageKeywords, 'keywordLimit' => $keywordLimit, 'limitReached' => $limitReached, + 'activeKeywordCount' => $activeKeywordCount, 'userTier' => $user?->tier, ]); } @@ -389,7 +431,7 @@ class SiteController extends Controller */ private function loadDataset(): array { - $dataPath = (string) config('dewemoji.data_path'); + $dataPath = $this->datasetPath(); if (!is_file($dataPath)) { return ['emojis' => []]; } @@ -407,6 +449,17 @@ class SiteController extends Controller return $decoded; } + private function datasetPath(): string + { + $settings = app(SettingsService::class); + $activePath = (string) $settings->get('emoji_dataset_active_path', ''); + if ($activePath !== '' && is_file($activePath)) { + return $activePath; + } + + return (string) config('dewemoji.data_path'); + } + /** * @param array $emoji */ diff --git a/app/app/Models/UserKeyword.php b/app/app/Models/UserKeyword.php index 098a951..e4b999c 100644 --- a/app/app/Models/UserKeyword.php +++ b/app/app/Models/UserKeyword.php @@ -12,6 +12,11 @@ class UserKeyword extends Model 'emoji_slug', 'keyword', 'lang', + 'is_active', + ]; + + protected $casts = [ + 'is_active' => 'boolean', ]; public function user(): BelongsTo diff --git a/app/app/Services/Billing/PaypalWebhookProcessor.php b/app/app/Services/Billing/PaypalWebhookProcessor.php index 6192edb..1c743f7 100644 --- a/app/app/Services/Billing/PaypalWebhookProcessor.php +++ b/app/app/Services/Billing/PaypalWebhookProcessor.php @@ -2,12 +2,21 @@ namespace App\Services\Billing; +use App\Models\Order; +use App\Models\Payment; use App\Models\Subscription; use App\Models\User; use App\Models\UserApiKey; +use App\Services\Keywords\KeywordQuotaService; class PaypalWebhookProcessor { + public function __construct( + private readonly SubscriptionTransitionService $subscriptionTransition, + private readonly KeywordQuotaService $keywordQuota + ) { + } + /** * @param array $payload */ @@ -31,16 +40,34 @@ class PaypalWebhookProcessor } if ($eventType === 'BILLING.SUBSCRIPTION.ACTIVATED') { - Subscription::create([ - 'user_id' => $user->id, - 'plan' => 'personal', - 'status' => 'active', + $order = Order::query() + ->where('provider', 'paypal') + ->where('provider_ref', $subscriptionId) + ->latest('id') + ->first(); + $planCode = (string) ($order?->plan_code ?: 'personal_monthly'); + + Subscription::updateOrCreate([ 'provider' => 'paypal', 'provider_ref' => $subscriptionId, + ], [ + 'user_id' => $user->id, + 'plan' => $planCode, + 'status' => 'active', 'started_at' => now(), 'expires_at' => null, + 'canceled_at' => null, ]); + Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']); + Payment::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']); $user->update(['tier' => 'personal']); + $this->keywordQuota->enforceForUser((int) $user->id, 'personal'); + $this->subscriptionTransition->settleForActivatedPlan( + (int) $user->id, + 'paypal', + $subscriptionId, + $planCode + ); return; } @@ -50,7 +77,7 @@ class PaypalWebhookProcessor ->where('provider_ref', $subscriptionId) ->where('status', 'active') ->update([ - 'status' => $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'cancelled' : 'suspended', + 'status' => $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'canceled' : 'suspended', 'expires_at' => now(), ]); @@ -71,6 +98,7 @@ class PaypalWebhookProcessor User::where('id', $userId)->update([ 'tier' => $active ? 'personal' : 'free', ]); + $this->keywordQuota->enforceForUser($userId, $active ? 'personal' : 'free'); if (!$active) { UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]); } diff --git a/app/app/Services/Billing/SubscriptionTransitionService.php b/app/app/Services/Billing/SubscriptionTransitionService.php new file mode 100644 index 0000000..14834a6 --- /dev/null +++ b/app/app/Services/Billing/SubscriptionTransitionService.php @@ -0,0 +1,162 @@ +where('user_id', $userId) + ->where('status', 'pending') + ->update([ + 'status' => 'canceled', + 'canceled_at' => now(), + ]); + + Order::query() + ->where('user_id', $userId) + ->where('status', 'pending') + ->update(['status' => 'canceled']); + + Payment::query() + ->where('user_id', $userId) + ->where('status', 'pending') + ->update(['status' => 'canceled']); + } + + /** + * Cancel other active/pending subscriptions when a new plan has been activated. + */ + public function settleForActivatedPlan( + int $userId, + string $activeProvider, + string $activeProviderRef, + string $newPlanCode + ): void { + $others = Subscription::query() + ->where('user_id', $userId) + ->whereIn('status', ['active', 'pending']) + ->where(function ($query) use ($activeProvider, $activeProviderRef) { + $query->where('provider', '!=', $activeProvider) + ->orWhere('provider_ref', '!=', $activeProviderRef); + }) + ->get(); + + if ($others->isEmpty()) { + return; + } + + $mode = $this->resolvePaypalMode((string) config('dewemoji.billing.mode', 'sandbox')); + foreach ($others as $sub) { + if ((string) $sub->plan === 'personal_lifetime') { + // Never auto-cancel lifetime ownership on later recurring checkouts. + continue; + } + + if ($sub->status === 'pending') { + $sub->status = 'canceled'; + $sub->canceled_at = now(); + $sub->save(); + continue; + } + + // Lifetime is one-time ownership; cancel any recurring subscription. + $isRecurring = in_array((string) $sub->plan, ['personal_monthly', 'personal_annual'], true); + if ($sub->provider === 'paypal' && $isRecurring && $sub->provider_ref) { + $cancelled = $this->cancelPaypalSubscription($mode, (string) $sub->provider_ref); + if (!$cancelled) { + Log::warning('Could not cancel previous PayPal subscription during plan transition', [ + 'user_id' => $userId, + 'new_plan' => $newPlanCode, + 'old_subscription_id' => $sub->provider_ref, + ]); + continue; + } + } + + $sub->status = 'canceled'; + $sub->canceled_at = now(); + $sub->save(); + } + } + + private function cancelPaypalSubscription(string $mode, string $subscriptionId): bool + { + $token = $this->getAccessToken($mode); + if (!$token) { + return false; + } + + $apiBase = (string) config("dewemoji.billing.providers.paypal.{$mode}.api_base"); + if ($apiBase === '') { + return false; + } + + $res = Http::withToken($token) + ->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10)) + ->post(rtrim($apiBase, '/')."/v1/billing/subscriptions/{$subscriptionId}/cancel", [ + 'reason' => 'Switched plan in Dewemoji', + ]); + + // PayPal may return 204 on success, and 404 if already cancelled/not found. + return in_array($res->status(), [200, 204, 404], true); + } + + private function getAccessToken(string $mode): ?string + { + $clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id"); + $clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret"); + $apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base"); + + if (!$clientId || !$clientSecret || !$apiBase) { + return null; + } + + $res = Http::asForm() + ->withBasicAuth($clientId, $clientSecret) + ->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10)) + ->post(rtrim((string) $apiBase, '/').'/v1/oauth2/token', [ + 'grant_type' => 'client_credentials', + ]); + + if (!$res->successful()) { + return null; + } + + return $res->json('access_token'); + } + + private function paypalConfigured(string $mode): bool + { + $enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false); + $clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id"); + $clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret"); + $apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base"); + + return $enabled && $clientId && $clientSecret && $apiBase; + } + + private function resolvePaypalMode(string $preferred): string + { + if ($this->paypalConfigured($preferred)) { + return $preferred; + } + + $fallback = $preferred === 'live' ? 'sandbox' : 'live'; + if ($this->paypalConfigured($fallback)) { + return $fallback; + } + + return $preferred; + } +} diff --git a/app/app/Services/EmojiCatalog/EmojiCatalogService.php b/app/app/Services/EmojiCatalog/EmojiCatalogService.php new file mode 100644 index 0000000..b8a206e --- /dev/null +++ b/app/app/Services/EmojiCatalog/EmojiCatalogService.php @@ -0,0 +1,592 @@ +|null + */ + public function findItem(int $emojiId): ?array + { + $base = DB::table('emojis')->where('emoji_id', $emojiId)->first(); + if (!$base) { + return null; + } + + return $this->hydrateEditorItem((array) $base); + } + + public function nextEmojiId(): int + { + return (int) DB::table('emojis')->max('emoji_id') + 1; + } + + /** + * @param array $input + */ + public function saveItem(array $input): int + { + $payload = $this->normalizeItemPayload($input); + if ($payload['slug'] === '' || $payload['name'] === '') { + throw new RuntimeException('Slug and name are required.'); + } + + $incomingId = (int) ($input['emoji_id'] ?? 0); + $existingBySlug = DB::table('emojis')->where('slug', $payload['slug'])->first(); + if ($incomingId > 0) { + if ($existingBySlug && (int) $existingBySlug->emoji_id !== $incomingId) { + throw new RuntimeException('Slug is already used by another emoji.'); + } + $emojiId = $incomingId; + } else { + $emojiId = (int) ($existingBySlug->emoji_id ?? 0); + if ($emojiId <= 0) { + $emojiId = $this->nextEmojiId(); + } + } + + DB::transaction(function () use ($emojiId, $payload): void { + $exists = DB::table('emojis')->where('emoji_id', $emojiId)->exists(); + $base = [ + 'emoji_id' => $emojiId, + 'slug' => $payload['slug'], + 'emoji_char' => $payload['emoji'], + 'name' => $payload['name'], + 'category' => $payload['category'], + 'subcategory' => $payload['subcategory'], + 'unified' => $payload['unified'], + 'default_presentation' => $payload['default_presentation'] ?: 'emoji', + 'version' => $payload['version'] ?: 'custom', + 'supports_skin_tone' => (bool) $payload['supports_skin_tone'], + 'permalink' => $payload['permalink'] ?: '/emoji/'.$payload['slug'], + 'description' => $payload['description'], + 'meta_title' => $payload['meta_title'], + 'meta_description' => $payload['meta_description'], + 'title' => $payload['title'], + 'updated_at' => now(), + ]; + if ($exists) { + DB::table('emojis')->where('emoji_id', $emojiId)->update($base); + } else { + $base['created_at'] = now(); + DB::table('emojis')->insert($base); + } + + DB::table('emoji_aliases')->where('emoji_id', $emojiId)->delete(); + foreach ($payload['aliases'] as $alias) { + DB::table('emoji_aliases')->insert([ + 'emoji_id' => $emojiId, + 'alias' => $alias, + ]); + } + + DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->delete(); + foreach ($payload['shortcodes'] as $shortcode) { + DB::table('emoji_shortcodes')->insert([ + 'emoji_id' => $emojiId, + 'shortcode' => $shortcode, + 'kind' => 'primary', + ]); + } + foreach ($payload['alt_shortcodes'] as $shortcode) { + DB::table('emoji_shortcodes')->insert([ + 'emoji_id' => $emojiId, + 'shortcode' => $shortcode, + 'kind' => 'alt', + ]); + } + + DB::table('emoji_keywords') + ->where('emoji_slug', $payload['slug']) + ->where(function ($query) { + $query->whereNull('owner_user_id') + ->orWhere('owner_user_id', ''); + }) + ->delete(); + + foreach ($payload['keywords_en'] as $keyword) { + DB::table('emoji_keywords')->insert([ + 'emoji_slug' => $payload['slug'], + 'lang' => 'en', + 'region' => '', + 'keyword' => $keyword, + 'status' => 'public', + 'owner_user_id' => null, + 'visibility' => 'public', + 'public_key' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + foreach ($payload['keywords_id'] as $keyword) { + DB::table('emoji_keywords')->insert([ + 'emoji_slug' => $payload['slug'], + 'lang' => 'id', + 'region' => '', + 'keyword' => $keyword, + 'status' => 'public', + 'owner_user_id' => null, + 'visibility' => 'public', + 'public_key' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + + return $emojiId; + } + + public function deleteItem(int $emojiId): void + { + $emoji = DB::table('emojis')->where('emoji_id', $emojiId)->first(); + if (!$emoji) { + return; + } + + DB::transaction(function () use ($emojiId, $emoji): void { + DB::table('emoji_aliases')->where('emoji_id', $emojiId)->delete(); + DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->delete(); + DB::table('emoji_keywords') + ->where('emoji_slug', (string) $emoji->slug) + ->where(function ($query) { + $query->whereNull('owner_user_id')->orWhere('owner_user_id', ''); + }) + ->delete(); + DB::table('emojis')->where('emoji_id', $emojiId)->delete(); + }); + } + + /** + * Import from JSON in non-destructive mode. + * Existing rows (matched by slug or emoji_id) are kept as-is. + * + * @return array{total:int,imported:int,skipped:int} + */ + public function importFromDataFile(string $path): array + { + if (!is_file($path)) { + throw new RuntimeException('Dataset file not found: '.$path); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new RuntimeException('Could not read dataset file.'); + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded) || !is_array($decoded['emojis'] ?? null)) { + throw new RuntimeException('Invalid emoji dataset format.'); + } + + $rows = $decoded['emojis']; + $total = 0; + $imported = 0; + $skipped = 0; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $total++; + $slug = $this->slugify((string) ($row['slug'] ?? '')); + $emojiId = (int) ($row['emoji_id'] ?? 0); + if ($slug !== '' && DB::table('emojis')->where('slug', $slug)->exists()) { + $skipped++; + continue; + } + if ($emojiId > 0 && DB::table('emojis')->where('emoji_id', $emojiId)->exists()) { + $skipped++; + continue; + } + + $this->saveItem($row); + $imported++; + } + + return [ + 'total' => $total, + 'imported' => $imported, + 'skipped' => $skipped, + ]; + } + + /** + * @param array $input + * @return array + */ + public function normalizeItemPayload(array $input): array + { + $slug = $this->slugify((string) ($input['slug'] ?? '')); + $name = trim((string) ($input['name'] ?? '')); + $emoji = trim((string) ($input['emoji'] ?? '')); + $category = trim((string) ($input['category'] ?? '')); + $subcategory = trim((string) ($input['subcategory'] ?? '')); + $description = trim((string) ($input['description'] ?? '')); + + $permalink = trim((string) ($input['permalink'] ?? '')); + if ($permalink === '' && $slug !== '') { + $permalink = '/emoji/'.$slug; + } + + $title = trim((string) ($input['title'] ?? '')); + if ($title === '' && $name !== '') { + $title = ($emoji !== '' ? $emoji.' ' : '').$name.' — meaning & copy'; + } + + $metaTitle = trim((string) ($input['meta_title'] ?? '')); + if ($metaTitle === '' && $name !== '') { + $metaTitle = ($emoji !== '' ? $emoji.' ' : '').$name.' | Meaning & Copy'; + } + + $metaDescription = trim((string) ($input['meta_description'] ?? '')); + if ($metaDescription === '' && $description !== '') { + $metaDescription = Str::limit($description, 160, '…'); + } + + $keywordsEn = $this->normalizeArray($input['keywords_en'] ?? []); + $keywordsId = $this->normalizeArray($input['keywords_id'] ?? []); + $aliases = $this->normalizeArray($input['aliases'] ?? []); + $shortcodes = $this->normalizeArray($input['shortcodes'] ?? []); + $altShortcodes = $this->normalizeArray($input['alt_shortcodes'] ?? []); + + return [ + 'emoji_id' => isset($input['emoji_id']) ? (int) $input['emoji_id'] : null, + 'slug' => $slug, + 'emoji' => $emoji, + 'name' => $name, + 'category' => $category, + 'subcategory' => $subcategory, + 'aliases' => $aliases, + 'shortcodes' => $shortcodes, + 'alt_shortcodes' => $altShortcodes, + 'keywords_en' => $keywordsEn, + 'keywords_id' => $keywordsId, + 'usage_examples' => [], + 'description' => $description, + 'codepoints' => [], + 'unified' => trim((string) ($input['unified'] ?? '')), + 'default_presentation' => trim((string) ($input['default_presentation'] ?? '')), + 'version' => trim((string) ($input['version'] ?? '')), + 'supports_skin_tone' => filter_var($input['supports_skin_tone'] ?? false, FILTER_VALIDATE_BOOL), + 'related' => [], + 'intent_tags' => [], + 'search_tokens' => [], + 'permalink' => $permalink, + 'title' => $title, + 'meta_title' => $metaTitle, + 'meta_description' => $metaDescription, + ]; + } + + /** + * @return array + */ + public function publishSnapshot(?string $createdBy = null): array + { + $emojiRows = DB::table('emojis')->orderBy('emoji_id')->get(); + $slugs = $emojiRows->pluck('slug')->filter()->values()->all(); + $keywords = DB::table('emoji_keywords') + ->whereIn('emoji_slug', $slugs) + ->where(function ($query) { + $query->whereNull('owner_user_id')->orWhere('owner_user_id', ''); + }) + ->select(['emoji_slug', 'lang', 'keyword']) + ->get() + ->groupBy('emoji_slug'); + $aliases = DB::table('emoji_aliases') + ->whereIn('emoji_id', $emojiRows->pluck('emoji_id')->all()) + ->select(['emoji_id', 'alias']) + ->get() + ->groupBy('emoji_id'); + $shortcodes = DB::table('emoji_shortcodes') + ->whereIn('emoji_id', $emojiRows->pluck('emoji_id')->all()) + ->select(['emoji_id', 'shortcode', 'kind']) + ->get() + ->groupBy('emoji_id'); + + $payload = [ + '@version' => (string) ($emojiRows->first()->version ?? 'custom'), + '@source' => 'db:emojis(+aliases,+keywords,+shortcodes)', + 'count' => $emojiRows->count(), + 'emojis' => $emojiRows->map(function ($item) use ($aliases, $shortcodes, $keywords): array { + $itemAliases = $aliases->get($item->emoji_id, collect())->pluck('alias')->filter()->values()->all(); + $itemShortcodes = $shortcodes->get($item->emoji_id, collect()); + $primaryShortcodes = $itemShortcodes->where('kind', 'primary')->pluck('shortcode')->filter()->values()->all(); + $altShortcodes = $itemShortcodes->where('kind', 'alt')->pluck('shortcode')->filter()->values()->all(); + $itemKeywords = $keywords->get($item->slug, collect()); + $keywordsEn = $itemKeywords->where('lang', 'en')->pluck('keyword')->filter()->values()->all(); + $keywordsId = $itemKeywords->where('lang', 'id')->pluck('keyword')->filter()->values()->all(); + + return [ + 'emoji' => (string) $item->emoji_char, + 'slug' => (string) $item->slug, + 'permalink' => (string) ($item->permalink ?: '/emoji/'.$item->slug), + 'title' => (string) ($item->title ?: (((string) $item->emoji_char !== '' ? $item->emoji_char.' ' : '').$item->name.' — meaning & copy')), + 'meta_title' => (string) ($item->meta_title ?: (((string) $item->emoji_char !== '' ? $item->emoji_char.' ' : '').$item->name.' | Meaning & Copy')), + 'meta_description' => (string) ($item->meta_description ?: Str::limit((string) ($item->description ?? ''), 160, '…')), + 'name' => (string) $item->name, + 'aliases' => $itemAliases, + 'shortcodes' => $primaryShortcodes, + 'alt_shortcodes' => $altShortcodes, + 'keywords_en' => $keywordsEn, + 'keywords_id' => $keywordsId, + 'usage_examples' => [], + 'description' => (string) ($item->description ?? ''), + 'category' => (string) $item->category, + 'subcategory' => (string) $item->subcategory, + 'codepoints' => [], + 'unified' => (string) $item->unified, + 'default_presentation' => (string) ($item->default_presentation ?: 'emoji'), + 'version' => (string) ($item->version ?: 'custom'), + 'supports_skin_tone' => (bool) $item->supports_skin_tone, + 'related' => [], + 'intent_tags' => [], + 'search_tokens' => $this->buildSearchTokens( + (string) $item->emoji_char, + (string) $item->slug, + (string) $item->name, + (string) $item->category, + (string) $item->subcategory, + $keywordsEn, + $keywordsId, + $itemAliases, + $primaryShortcodes, + $altShortcodes, + [], + [] + ), + ]; + })->values()->all(), + ]; + + $snapshotDir = $this->snapshotDirectory(); + if (!is_dir($snapshotDir) && !mkdir($snapshotDir, 0775, true) && !is_dir($snapshotDir)) { + throw new RuntimeException('Could not create snapshot directory.'); + } + + $version = now()->format('YmdHis'); + $filename = 'emojis-'.$version.'.json'; + $fullPath = $snapshotDir.'/'.$filename; + $written = file_put_contents($fullPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + if ($written === false) { + throw new RuntimeException('Could not write snapshot file.'); + } + + DB::transaction(function () use ($version, $fullPath, $createdBy): void { + $this->settings->setMany([ + 'emoji_dataset_active_path' => $fullPath, + 'emoji_dataset_active_version' => $version, + 'emoji_dataset_last_published_at' => now()->toIso8601String(), + ], $createdBy); + }); + + return [ + 'snapshot_id' => null, + 'version' => $version, + 'file_path' => $fullPath, + 'count' => $emojiRows->count(), + ]; + } + + /** + * @return array + */ + public function listSnapshots(): array + { + $dir = $this->snapshotDirectory(); + if (!is_dir($dir)) { + return []; + } + + $activePath = (string) $this->settings->get('emoji_dataset_active_path', ''); + $files = glob($dir.'/emojis-*.json') ?: []; + $items = []; + foreach ($files as $file) { + if (!is_file($file)) { + continue; + } + $name = basename($file); + if (!preg_match('/^emojis-(\d{14})\.json$/', $name, $m)) { + continue; + } + $items[] = [ + 'name' => $name, + 'version' => $m[1], + 'path' => $file, + 'is_active' => $activePath !== '' && realpath($activePath) === realpath($file), + 'modified_at' => (int) @filemtime($file), + ]; + } + + usort($items, static function (array $a, array $b): int { + return $b['version'] <=> $a['version']; + }); + + return $items; + } + + public function activateSnapshot(string $filename, ?string $updatedBy = null): array + { + if (!preg_match('/^emojis-(\d{14})\.json$/', $filename, $m)) { + throw new RuntimeException('Invalid snapshot filename.'); + } + + $fullPath = $this->snapshotDirectory().'/'.$filename; + if (!is_file($fullPath)) { + throw new RuntimeException('Snapshot file not found.'); + } + + $version = $m[1]; + $this->settings->setMany([ + 'emoji_dataset_active_path' => $fullPath, + 'emoji_dataset_active_version' => $version, + 'emoji_dataset_last_published_at' => now()->toIso8601String(), + ], $updatedBy); + + return [ + 'version' => $version, + 'file_path' => $fullPath, + ]; + } + + /** + * @param array $base + * @return array + */ + private function hydrateEditorItem(array $base): array + { + $emojiId = (int) ($base['emoji_id'] ?? 0); + $slug = (string) ($base['slug'] ?? ''); + $aliases = DB::table('emoji_aliases')->where('emoji_id', $emojiId)->pluck('alias')->filter()->values()->all(); + $sc = DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->get(['shortcode', 'kind']); + $keywords = DB::table('emoji_keywords') + ->where('emoji_slug', $slug) + ->where(function ($query) { + $query->whereNull('owner_user_id')->orWhere('owner_user_id', ''); + }) + ->get(['lang', 'keyword']); + + return [ + 'emoji_id' => $emojiId, + 'slug' => $slug, + 'emoji' => (string) ($base['emoji_char'] ?? ''), + 'name' => (string) ($base['name'] ?? ''), + 'category' => (string) ($base['category'] ?? ''), + 'subcategory' => (string) ($base['subcategory'] ?? ''), + 'aliases' => $aliases, + 'shortcodes' => $sc->where('kind', 'primary')->pluck('shortcode')->filter()->values()->all(), + 'alt_shortcodes' => $sc->where('kind', 'alt')->pluck('shortcode')->filter()->values()->all(), + 'keywords_en' => $keywords->where('lang', 'en')->pluck('keyword')->filter()->values()->all(), + 'keywords_id' => $keywords->where('lang', 'id')->pluck('keyword')->filter()->values()->all(), + 'usage_examples' => [], + 'description' => (string) ($base['description'] ?? ''), + 'codepoints' => [], + 'unified' => (string) ($base['unified'] ?? ''), + 'default_presentation' => (string) ($base['default_presentation'] ?? ''), + 'version' => (string) ($base['version'] ?? ''), + 'supports_skin_tone' => (bool) ($base['supports_skin_tone'] ?? false), + 'related' => [], + 'intent_tags' => [], + 'search_tokens' => [], + 'permalink' => (string) ($base['permalink'] ?? ''), + 'title' => (string) ($base['title'] ?? ''), + 'meta_title' => (string) ($base['meta_title'] ?? ''), + 'meta_description' => (string) ($base['meta_description'] ?? ''), + 'is_active' => true, + 'sort_order' => 0, + ]; + } + + /** + * @param mixed $input + * @return array + */ + private function normalizeArray(mixed $input): array + { + if (is_string($input)) { + $input = preg_split('/\r\n|\r|\n|,/', $input) ?: []; + } + if (!is_array($input)) { + return []; + } + + $out = []; + foreach ($input as $value) { + $v = trim((string) $value); + if ($v !== '') { + $out[] = $v; + } + } + + return array_values(array_unique($out)); + } + + private function slugify(string $value): string + { + $value = strtolower(trim($value)); + $value = str_replace('&', 'and', $value); + $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; + + return trim($value, '-'); + } + + private function snapshotDirectory(): string + { + return storage_path('app/private/snapshots'); + } + + /** + * @param array $keywordsEn + * @param array $keywordsId + * @param array $aliases + * @param array $shortcodes + * @param array $altShortcodes + * @param array $intentTags + * @param array $codepoints + * @return array + */ + private function buildSearchTokens( + string $emoji, + string $slug, + string $name, + string $category, + string $subcategory, + array $keywordsEn, + array $keywordsId, + array $aliases, + array $shortcodes, + array $altShortcodes, + array $intentTags, + array $codepoints + ): array { + $tokens = [ + $emoji, + $slug, + $name, + strtolower($name), + strtolower($category), + strtolower($subcategory), + ]; + $tokens = array_merge($tokens, $keywordsEn, $keywordsId, $aliases, $shortcodes, $altShortcodes, $intentTags, $codepoints); + $cleaned = []; + foreach ($tokens as $token) { + $t = trim((string) $token); + if ($t !== '') { + $cleaned[] = $t; + } + } + + return array_values(array_unique($cleaned)); + } +} diff --git a/app/app/Services/Keywords/KeywordQuotaService.php b/app/app/Services/Keywords/KeywordQuotaService.php new file mode 100644 index 0000000..a011644 --- /dev/null +++ b/app/app/Services/Keywords/KeywordQuotaService.php @@ -0,0 +1,58 @@ +where('user_id', $userId) + ->where('is_active', true) + ->count(); + } + + public function canActivateMore(int $userId): bool + { + return $this->activeCount($userId) < $this->freeActiveLimit(); + } + + public function enforceForUser(int $userId, string $tier): void + { + if ($tier === 'personal') { + UserKeyword::query() + ->where('user_id', $userId) + ->where('is_active', false) + ->update(['is_active' => true]); + return; + } + + $limit = $this->freeActiveLimit(); + $activeIds = UserKeyword::query() + ->where('user_id', $userId) + ->where('is_active', true) + ->orderBy('created_at') + ->orderBy('id') + ->pluck('id') + ->all(); + + if (count($activeIds) <= $limit) { + return; + } + + $keep = array_slice($activeIds, 0, $limit); + UserKeyword::query() + ->where('user_id', $userId) + ->where('is_active', true) + ->whereNotIn('id', $keep) + ->update(['is_active' => false]); + } +} + diff --git a/app/config/dewemoji.php b/app/config/dewemoji.php index 2bb46af..5b9c9ee 100644 --- a/app/config/dewemoji.php +++ b/app/config/dewemoji.php @@ -12,6 +12,7 @@ return [ 'billing' => [ 'mode' => env('DEWEMOJI_BILLING_MODE', 'sandbox'), + 'pending_cooldown_seconds' => (int) env('DEWEMOJI_CHECKOUT_PENDING_COOLDOWN', 120), 'providers' => [ 'paypal' => [ 'enabled' => filter_var(env('DEWEMOJI_PAYPAL_ENABLED', false), FILTER_VALIDATE_BOOL), diff --git a/app/database/migrations/2026_02_16_000300_add_is_active_to_user_keywords_table.php b/app/database/migrations/2026_02_16_000300_add_is_active_to_user_keywords_table.php new file mode 100644 index 0000000..6b29a58 --- /dev/null +++ b/app/database/migrations/2026_02_16_000300_add_is_active_to_user_keywords_table.php @@ -0,0 +1,25 @@ +boolean('is_active')->default(true)->after('lang'); + $table->index(['user_id', 'is_active']); + }); + } + + public function down(): void + { + Schema::table('user_keywords', function (Blueprint $table): void { + $table->dropIndex(['user_id', 'is_active']); + $table->dropColumn('is_active'); + }); + } +}; + diff --git a/app/database/migrations/2026_02_16_001000_add_unique_index_to_emojis_slug.php b/app/database/migrations/2026_02_16_001000_add_unique_index_to_emojis_slug.php new file mode 100644 index 0000000..2a6e97d --- /dev/null +++ b/app/database/migrations/2026_02_16_001000_add_unique_index_to_emojis_slug.php @@ -0,0 +1,45 @@ +select('slug', DB::raw('COUNT(*) as aggregate')) + ->groupBy('slug') + ->havingRaw('COUNT(*) > 1') + ->limit(5) + ->pluck('slug') + ->all(); + + if (!empty($duplicates)) { + throw new \RuntimeException( + 'Cannot add unique index on emojis.slug because duplicate slugs exist: '.implode(', ', $duplicates) + ); + } + + Schema::table('emojis', function (Blueprint $table): void { + $table->unique('slug', 'emojis_slug_unique'); + }); + } + + public function down(): void + { + if (!Schema::hasTable('emojis')) { + return; + } + + Schema::table('emojis', function (Blueprint $table): void { + $table->dropUnique('emojis_slug_unique'); + }); + } +}; diff --git a/app/resources/views/dashboard/admin/catalog-form.blade.php b/app/resources/views/dashboard/admin/catalog-form.blade.php new file mode 100644 index 0000000..458e982 --- /dev/null +++ b/app/resources/views/dashboard/admin/catalog-form.blade.php @@ -0,0 +1,129 @@ +@extends('dashboard.app') + +@php + $isEdit = ($mode ?? 'create') === 'edit'; + $formAction = $isEdit + ? route('dashboard.admin.catalog.update', data_get($selected, 'emoji_id')) + : route('dashboard.admin.catalog.store'); + $textareas = [ + 'aliases' => data_get($selected, 'aliases', []), + 'shortcodes' => data_get($selected, 'shortcodes', []), + 'alt_shortcodes' => data_get($selected, 'alt_shortcodes', []), + 'keywords_en' => data_get($selected, 'keywords_en', []), + 'keywords_id' => data_get($selected, 'keywords_id', []), + ]; +@endphp + +@section('page_title', $isEdit ? 'Edit Emoji' : 'Create Emoji') +@section('page_subtitle', 'Edit/add emoji data in database. Publish frozen JSON from Catalog page after batch updates.') + +@section('dashboard_content') + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if (session('error')) +
+ {{ session('error') }} +
+ @endif + + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+
+ @csrf + @if ($isEdit) + @method('PUT') + @endif + +
+ + +
+ +
+ + + + + +
+ +
+ @foreach ($textareas as $field => $values) + + @endforeach +
+ + + +
+ + + + +
+ +
+ + +
+ Back to catalog + +
+
+
+
+@endsection diff --git a/app/resources/views/dashboard/admin/catalog.blade.php b/app/resources/views/dashboard/admin/catalog.blade.php new file mode 100644 index 0000000..519106d --- /dev/null +++ b/app/resources/views/dashboard/admin/catalog.blade.php @@ -0,0 +1,175 @@ +@extends('dashboard.app') + +@section('page_title', 'Emoji Catalog') +@section('page_subtitle', 'Manage emojis in database, then publish one frozen JSON snapshot when ready.') + +@section('dashboard_content') + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if (session('error')) +
+ {{ session('error') }} +
+ @endif + +
+
+
Catalog rows
+
{{ number_format($totalRows ?? $items->total()) }}
+
Read from `emojis` table
+ @if (($filters['q'] ?? '') !== '') +
Filtered result: {{ number_format($items->total()) }}
+ @endif +
+
+
Active snapshot
+
{{ $activeVersion ?: 'None' }}
+
{{ $activeVersion ? 'Published' : 'Not published yet' }}
+
+
+
Active file path
+
{{ $activePath ?: config('dewemoji.data_path') }}
+
Public search/API uses this dataset.
+
+
+ +
+
+
+ + + Reset +
+ +
+ + Add Emoji + +
+ @csrf + +
+
+ @csrf + +
+
+
+ +
+ + + + + + + + + + + + + + @forelse ($items as $item) + + + + + + + + + + @empty + + + + @endforelse + +
IDEmojiSlugNameCategoryUpdatedActions
#{{ $item->emoji_id }}{{ $item->emoji ?: '⬚' }}{{ $item->slug }}{{ $item->name }}{{ $item->category }}{{ $item->updated_at ? \Illuminate\Support\Carbon::parse($item->updated_at)->format('Y-m-d H:i') : '—' }} +
+ Edit +
+ @csrf + @method('DELETE') + +
+
+
+ @if (($filters['q'] ?? '') !== '') + No rows match "{{ $filters['q'] }}". + @else + No catalog rows in database. + @endif +
+
+ +
+ {{ $items->links('vendor.pagination.dashboard') }} +
+
+ +
+
+
+
Snapshot Versions
+
Use this for quick rollback if latest publish is broken.
+
+
+ +
+ + + + + + + + + + + + @forelse (($snapshots ?? []) as $snapshot) + + + + + + + + @empty + + @endforelse + +
VersionFileUpdatedStatusAction
{{ $snapshot['version'] }}{{ $snapshot['name'] }} + {{ $snapshot['modified_at'] > 0 ? \Illuminate\Support\Carbon::createFromTimestamp($snapshot['modified_at'])->format('Y-m-d H:i') : '—' }} + + @if ($snapshot['is_active']) + Active + @else + Inactive + @endif + + @if (!$snapshot['is_active']) +
+ @csrf + + +
+ @endif +
No snapshot files found yet. Publish once to create versioned snapshots.
+
+
+@endsection diff --git a/app/resources/views/dashboard/admin/subscriptions.blade.php b/app/resources/views/dashboard/admin/subscriptions.blade.php index 888614c..cfad5bf 100644 --- a/app/resources/views/dashboard/admin/subscriptions.blade.php +++ b/app/resources/views/dashboard/admin/subscriptions.blade.php @@ -42,7 +42,7 @@ - + @@ -165,7 +165,7 @@ {{ $row->plan }} @php - $inactive = in_array($row->status, ['revoked', 'cancelled', 'suspended'], true); + $inactive = in_array($row->status, ['revoked', 'canceled', 'cancelled', 'suspended'], true); $pill = $inactive ? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200'] : ($row->status === 'pending' diff --git a/app/resources/views/dashboard/admin/user-show.blade.php b/app/resources/views/dashboard/admin/user-show.blade.php index 9481a7a..fcbdb5d 100644 --- a/app/resources/views/dashboard/admin/user-show.blade.php +++ b/app/resources/views/dashboard/admin/user-show.blade.php @@ -59,7 +59,7 @@ {{ $row->plan }} @php - $inactive = in_array($row->status, ['revoked', 'cancelled', 'suspended'], true); + $inactive = in_array($row->status, ['revoked', 'canceled', 'cancelled', 'suspended'], true); $pill = $inactive ? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200'] : ($row->status === 'pending' diff --git a/app/resources/views/dashboard/app.blade.php b/app/resources/views/dashboard/app.blade.php index 0f33240..d42a966 100644 --- a/app/resources/views/dashboard/app.blade.php +++ b/app/resources/views/dashboard/app.blade.php @@ -18,6 +18,7 @@ ['label' => 'Users', 'route' => 'dashboard.admin.users', 'icon' => 'users'], ['label' => 'Subscriptions', 'route' => 'dashboard.admin.subscriptions', 'icon' => 'credit-card'], ['label' => 'Pricing', 'route' => 'dashboard.admin.pricing', 'icon' => 'badge-dollar-sign'], + ['label' => 'Catalog', 'route' => 'dashboard.admin.catalog', 'icon' => 'package-search'], ['label' => 'Webhooks', 'route' => 'dashboard.admin.webhooks', 'icon' => 'webhook'], ['label' => 'Audit Logs', 'route' => 'dashboard.admin.audit_logs', 'icon' => 'list-checks'], ['label' => 'Settings', 'route' => 'dashboard.admin.settings', 'icon' => 'settings'], @@ -113,6 +114,9 @@ Grant subscription + + Manage catalog + Review webhooks diff --git a/app/resources/views/dashboard/user/billing.blade.php b/app/resources/views/dashboard/user/billing.blade.php index 2968543..fb2b642 100644 --- a/app/resources/views/dashboard/user/billing.blade.php +++ b/app/resources/views/dashboard/user/billing.blade.php @@ -11,6 +11,15 @@ $hasSub = $subscription !== null; $orders = $orders ?? collect(); $payments = $payments ?? collect(); + $currentPlan = (string) ($subscription->plan ?? ($user?->tier === 'personal' ? 'personal_monthly' : 'free')); + $hasPendingPayment = $payments->contains(fn ($payment) => (string) ($payment->status ?? '') === 'pending'); + $pendingCooldownWindow = (int) config('dewemoji.billing.pending_cooldown_seconds', 120); + $latestPendingPayment = $payments->first(fn ($payment) => (string) ($payment->status ?? '') === 'pending'); + $pendingCooldownRemaining = 0; + if ($latestPendingPayment?->created_at && $pendingCooldownWindow > 0) { + $age = max(0, now()->getTimestamp() - $latestPendingPayment->created_at->getTimestamp()); + $pendingCooldownRemaining = max(0, $pendingCooldownWindow - $age); + } $formatPlan = function (?string $code): string { $value = (string) ($code ?? ''); return match ($value) { @@ -64,6 +73,14 @@
Downgrading to Free revokes any active API keys immediately.
+ @if ($hasPendingPayment) +
+ You have a pending checkout. Use Pay in the table below to continue the same payment. + @if ($pendingCooldownRemaining > 0) + New checkout unlocks in {{ $pendingCooldownRemaining }}s. + @endif +
+ @endif
@@ -78,6 +95,43 @@
+
+
Change plan
+

+ Plan change policy: when your new payment is confirmed, Dewemoji cancels the previous recurring plan automatically. + No prorated refund is applied. +

+
+ @if (in_array($currentPlan, ['personal_monthly', 'personal_annual'], true)) + @if ($currentPlan === 'personal_monthly') + + Switch to Annual + + @endif + @if ($currentPlan === 'personal_annual') + + Switch to Monthly + + @endif + + Upgrade to Lifetime + + @elseif ($currentPlan === 'personal_lifetime') + + Lifetime active + + @else + + Choose Personal Plan + + @endif +
+
+ @if ($payments->count() > 0)
Recent payments
@@ -90,6 +144,7 @@ Amount Status Created + Action @@ -110,6 +165,20 @@ {{ $status }} {{ $payment->created_at?->toDateString() ?? '—' }} + + @if ($status === 'pending') + + @else + + @endif + @endforeach @@ -117,7 +186,7 @@
@if ($payments->contains(fn ($payment) => in_array($payment->status, ['pending', 'failed'], true)))
- Pending or failed payments need a new checkout. Start a fresh transaction from the pricing page. + Failed payments need a new checkout. Pending payments can be continued from the table using the Pay action.
Start new checkout @@ -135,4 +204,243 @@ @endif + + @endsection + +@push('scripts') + + +@endpush diff --git a/app/resources/views/dashboard/user/keywords.blade.php b/app/resources/views/dashboard/user/keywords.blade.php index 3befe58..b94557e 100644 --- a/app/resources/views/dashboard/user/keywords.blade.php +++ b/app/resources/views/dashboard/user/keywords.blade.php @@ -9,7 +9,8 @@ $user = $user ?? auth()->user(); $isPersonal = $user && (string) $user->tier === 'personal'; $freeLimit = $freeLimit ?? null; - $limitReached = $freeLimit !== null && $items->count() >= $freeLimit; + $activeCount = (int) ($activeCount ?? $items->where('is_active', true)->count()); + $limitReached = $freeLimit !== null && $activeCount >= $freeLimit; $emojiLookup = $emojiLookup ?? []; @endphp @@ -31,7 +32,7 @@
{{ $isPersonal ? 'Ready to personalize' : 'Free plan keywords' }}

Add keywords to emojis to improve your personal search results.

@if (!$isPersonal && $freeLimit) -

Free plan limit: {{ $items->count() }} / {{ $freeLimit }} keywords.

+

Free active limit: {{ $activeCount }} / {{ $freeLimit }} keywords. Inactive keywords are stored but not used in search.

@endif
@@ -52,9 +53,12 @@ @if (!$isPersonal && $freeLimit)
- Free plan includes up to {{ $freeLimit }} keywords total. Upgrade for unlimited keywords. + Free plan allows up to {{ $freeLimit }} active keywords. You can keep extras as inactive and reactivate after upgrading.
@endif +
+ Search behavior: only Active private keywords are used in emoji matching. Inactive keywords stay saved in your account but are ignored by search and API results. +