496 lines
16 KiB
PHP
496 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Dashboard;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Order;
|
|
use App\Models\Payment;
|
|
use App\Models\Subscription;
|
|
use App\Models\User;
|
|
use App\Models\UserApiKey;
|
|
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;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
|
|
class UserDashboardController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ApiKeyService $keys,
|
|
private readonly KeywordQuotaService $keywordQuota
|
|
) {
|
|
}
|
|
|
|
public function overview(Request $request): View
|
|
{
|
|
$user = $request->user();
|
|
if (Gate::allows('admin')) {
|
|
$days = 7;
|
|
$start = now()->subDays($days - 1)->startOfDay();
|
|
$labels = [];
|
|
$values = [];
|
|
$subsValues = [];
|
|
$webhookValues = [];
|
|
|
|
$rawUsers = DB::table('users')
|
|
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
|
|
->where('created_at', '>=', $start)
|
|
->groupBy('day')
|
|
->orderBy('day')
|
|
->get()
|
|
->keyBy('day');
|
|
|
|
$rawSubs = DB::table('subscriptions')
|
|
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
|
|
->where('created_at', '>=', $start)
|
|
->groupBy('day')
|
|
->orderBy('day')
|
|
->get()
|
|
->keyBy('day');
|
|
|
|
$rawWebhooks = DB::table('webhook_events')
|
|
->selectRaw('DATE(COALESCE(received_at, created_at)) as day, COUNT(*) as total')
|
|
->where('created_at', '>=', $start)
|
|
->groupBy('day')
|
|
->orderBy('day')
|
|
->get()
|
|
->keyBy('day');
|
|
|
|
for ($i = $days - 1; $i >= 0; $i--) {
|
|
$date = Carbon::now()->subDays($i)->format('Y-m-d');
|
|
$labels[] = Carbon::parse($date)->format('M d');
|
|
$values[] = (int) ($rawUsers[$date]->total ?? 0);
|
|
$subsValues[] = (int) ($rawSubs[$date]->total ?? 0);
|
|
$webhookValues[] = (int) ($rawWebhooks[$date]->total ?? 0);
|
|
}
|
|
|
|
$usersTotal = User::count();
|
|
$usersPersonal = User::where('tier', 'personal')->count();
|
|
$subscriptionsActive = Subscription::where('status', 'active')->count();
|
|
$subscriptionsTotal = Subscription::count();
|
|
$webhookTotal = WebhookEvent::count();
|
|
$webhookErrors = WebhookEvent::where('status', 'error')->count();
|
|
|
|
return view('dashboard.index', [
|
|
'chartLabels' => $labels,
|
|
'chartValues' => $values,
|
|
'chartSubs' => $subsValues,
|
|
'chartWebhooks' => $webhookValues,
|
|
'overviewMetrics' => [
|
|
'users_total' => $usersTotal,
|
|
'users_personal' => $usersPersonal,
|
|
'subscriptions_active' => $subscriptionsActive,
|
|
'subscriptions_total' => $subscriptionsTotal,
|
|
'webhook_total' => $webhookTotal,
|
|
'webhook_errors' => $webhookErrors,
|
|
],
|
|
]);
|
|
}
|
|
|
|
$recentKeywords = UserKeyword::where('user_id', $user?->id)
|
|
->orderByDesc('id')
|
|
->limit(6)
|
|
->get();
|
|
|
|
$recentWeekCount = UserKeyword::where('user_id', $user?->id)
|
|
->where('created_at', '>=', now()->subDays(7))
|
|
->count();
|
|
|
|
$totalKeywords = UserKeyword::where('user_id', $user?->id)->count();
|
|
$apiKeyCount = UserApiKey::where('user_id', $user?->id)
|
|
->whereNull('revoked_at')
|
|
->count();
|
|
|
|
$activeSubscription = Subscription::where('user_id', $user?->id)
|
|
->orderByDesc('started_at')
|
|
->first();
|
|
|
|
return view('dashboard.user.overview', [
|
|
'totalKeywords' => $totalKeywords,
|
|
'recentKeywords' => $recentKeywords,
|
|
'recentWeekCount' => $recentWeekCount,
|
|
'apiKeyCount' => $apiKeyCount,
|
|
'subscription' => $activeSubscription,
|
|
]);
|
|
}
|
|
|
|
public function keywords(Request $request): View
|
|
{
|
|
$user = $request->user();
|
|
|
|
$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');
|
|
if (is_file($dataPath)) {
|
|
$raw = file_get_contents($dataPath);
|
|
$decoded = json_decode((string) $raw, true);
|
|
if (is_array($decoded)) {
|
|
foreach ($decoded['emojis'] ?? [] as $row) {
|
|
$slug = (string) ($row['slug'] ?? '');
|
|
if ($slug !== '') {
|
|
$emojiLookup[$slug] = [
|
|
'emoji' => (string) ($row['emoji'] ?? ''),
|
|
'name' => (string) ($row['name'] ?? $slug),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return view('dashboard.user.keywords', [
|
|
'items' => $items,
|
|
'user' => $user,
|
|
'emojiLookup' => $emojiLookup,
|
|
'freeLimit' => $this->keywordLimitFor($user),
|
|
'activeCount' => $activeCount,
|
|
]);
|
|
}
|
|
|
|
public function keywordSearch(Request $request, EmojiApiController $emoji): JsonResponse
|
|
{
|
|
$request->query->set('private', 'true');
|
|
|
|
return $emoji->search($request);
|
|
}
|
|
|
|
public function storeKeyword(Request $request): RedirectResponse|JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
abort(403);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'emoji_slug' => 'required|string|max:120',
|
|
'keyword' => 'required|string|max:200',
|
|
'lang' => 'nullable|string|max:10',
|
|
]);
|
|
|
|
$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 ($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');
|
|
}
|
|
}
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
return back()->with('status', 'Keyword saved.');
|
|
}
|
|
|
|
public function updateKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($keyword->user_id !== $user->id) {
|
|
abort(403);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'emoji_slug' => 'required|string|max:120',
|
|
'keyword' => 'required|string|max:200',
|
|
'lang' => 'nullable|string|max:10',
|
|
]);
|
|
|
|
$duplicate = UserKeyword::where('user_id', $user->id)
|
|
->where('emoji_slug', $data['emoji_slug'])
|
|
->where('keyword', $data['keyword'])
|
|
->first();
|
|
|
|
if ($duplicate && $duplicate->id !== $keyword->id) {
|
|
$keyword->delete();
|
|
$keyword = $duplicate;
|
|
} else {
|
|
$keyword->update([
|
|
'emoji_slug' => $data['emoji_slug'],
|
|
'keyword' => $data['keyword'],
|
|
'lang' => $data['lang'] ?? 'und',
|
|
]);
|
|
}
|
|
|
|
if ($request->expectsJson()) {
|
|
return response()->json(['ok' => true, 'item' => $keyword]);
|
|
}
|
|
|
|
return back()->with('status', 'Keyword updated.');
|
|
}
|
|
|
|
public function deleteKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($keyword->user_id !== $user->id) {
|
|
abort(403);
|
|
}
|
|
|
|
$keyword->delete();
|
|
|
|
if ($request->expectsJson()) {
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
return back()->with('status', 'Keyword removed.');
|
|
}
|
|
|
|
public function importKeywords(Request $request): RedirectResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
abort(403);
|
|
}
|
|
|
|
$payload = $request->input('payload');
|
|
if ($request->hasFile('file')) {
|
|
$payload = $request->file('file')->get();
|
|
}
|
|
|
|
$items = json_decode((string) $payload, true);
|
|
if (!is_array($items)) {
|
|
return back()->withErrors(['payload' => 'Invalid JSON payload.']);
|
|
}
|
|
|
|
$imported = 0;
|
|
$skipped = 0;
|
|
$limit = $this->keywordLimitFor($user);
|
|
$activeCount = $this->keywordQuota->activeCount((int) $user->id);
|
|
foreach ($items as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
$emojiSlug = trim((string) ($row['emoji_slug'] ?? ''));
|
|
$keyword = trim((string) ($row['keyword'] ?? ''));
|
|
$lang = trim((string) ($row['lang'] ?? 'und'));
|
|
if ($emojiSlug === '' || $keyword === '') {
|
|
continue;
|
|
}
|
|
$existing = UserKeyword::where('user_id', $user->id)
|
|
->where('emoji_slug', $emojiSlug)
|
|
->where('keyword', $keyword)
|
|
->first();
|
|
$targetActive = filter_var($row['is_active'] ?? true, FILTER_VALIDATE_BOOL);
|
|
|
|
if (!$existing && $targetActive && $limit !== null && $activeCount >= $limit) {
|
|
$skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
if ($existing) {
|
|
$existing->update([
|
|
'lang' => $lang !== '' ? $lang : 'und',
|
|
]);
|
|
} else {
|
|
UserKeyword::create([
|
|
'user_id' => $user->id,
|
|
'emoji_slug' => $emojiSlug,
|
|
'keyword' => $keyword,
|
|
'lang' => $lang !== '' ? $lang : 'und',
|
|
'is_active' => $targetActive,
|
|
]);
|
|
if ($targetActive) {
|
|
$activeCount += 1;
|
|
}
|
|
}
|
|
$imported += 1;
|
|
}
|
|
|
|
$message = "Imported {$imported} keywords.";
|
|
if ($skipped > 0) {
|
|
$message .= " {$skipped} skipped (active free limit reached).";
|
|
}
|
|
|
|
return back()->with('status', $message);
|
|
}
|
|
|
|
public function exportKeywords(Request $request): BinaryFileResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
abort(403);
|
|
}
|
|
|
|
$items = UserKeyword::where('user_id', $user->id)
|
|
->orderByDesc('id')
|
|
->get(['emoji_slug', 'keyword', 'lang', 'is_active'])
|
|
->values()
|
|
->toJson(JSON_PRETTY_PRINT);
|
|
|
|
$filename = 'dewemoji-keywords-'.$user->id.'-'.now()->format('Ymd-His').'.json';
|
|
$path = storage_path('app/'.$filename);
|
|
file_put_contents($path, $items);
|
|
|
|
return response()->download($path, $filename)->deleteFileAfterSend(true);
|
|
}
|
|
|
|
public function apiKeys(Request $request): View
|
|
{
|
|
$user = $request->user();
|
|
$keys = UserApiKey::where('user_id', $user?->id)
|
|
->orderByDesc('id')
|
|
->get();
|
|
$canCreate = $user && (string) $user->tier === 'personal';
|
|
|
|
return view('dashboard.user.api-keys', [
|
|
'user' => $user,
|
|
'keys' => $keys,
|
|
'canCreate' => $canCreate,
|
|
'newKey' => session('new_api_key'),
|
|
]);
|
|
}
|
|
|
|
public function createApiKey(Request $request): RedirectResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
abort(403);
|
|
}
|
|
if ((string) $user->tier !== 'personal') {
|
|
return back()->withErrors(['api_key' => 'API keys are available on the Personal plan.']);
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'name' => 'nullable|string|max:100',
|
|
]);
|
|
|
|
$issued = $this->keys->issueKey($user, $data['name'] ?? null);
|
|
|
|
return back()->with('new_api_key', $issued['plain']);
|
|
}
|
|
|
|
public function revokeApiKey(Request $request, UserApiKey $key): RedirectResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user || $key->user_id !== $user->id) {
|
|
abort(403);
|
|
}
|
|
|
|
$key->update(['revoked_at' => Carbon::now()]);
|
|
|
|
return back()->with('status', 'API key revoked.');
|
|
}
|
|
|
|
public function billing(Request $request): View
|
|
{
|
|
$user = $request->user();
|
|
$subscription = Subscription::where('user_id', $user?->id)
|
|
->orderByDesc('started_at')
|
|
->first();
|
|
|
|
$orders = Order::where('user_id', $user?->id)
|
|
->orderByDesc('id')
|
|
->limit(5)
|
|
->get();
|
|
|
|
$payments = Payment::where('user_id', $user?->id)
|
|
->orderByDesc('id')
|
|
->limit(5)
|
|
->get();
|
|
|
|
return view('dashboard.user.billing', [
|
|
'subscription' => $subscription,
|
|
'user' => $user,
|
|
'orders' => $orders,
|
|
'payments' => $payments,
|
|
]);
|
|
}
|
|
|
|
public function preferences(Request $request): View
|
|
{
|
|
return view('dashboard.user.preferences');
|
|
}
|
|
|
|
public function toggleKeywordActive(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
if (!$user || $keyword->user_id !== $user->id) {
|
|
abort(403);
|
|
}
|
|
|
|
$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
|
|
{
|
|
if (!$user) {
|
|
return null;
|
|
}
|
|
if ((string) $user->tier === 'personal') {
|
|
return null;
|
|
}
|
|
|
|
return (int) config('dewemoji.pagination.free_max_limit', 20);
|
|
}
|
|
}
|