First Commit
This commit is contained in:
+195
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\MessageType;
|
||||
use App\Models\Insight;
|
||||
use App\Models\TokenUsage;
|
||||
use App\Models\UserSubject;
|
||||
use App\Services\AIService;
|
||||
use App\Services\MarkdownService;
|
||||
use App\Services\TelegramService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Artisan command to send scheduled insights to users.
|
||||
* Processes user subjects with active schedules and generates AI insights.
|
||||
*/
|
||||
class SendScheduledInsights extends Command
|
||||
{
|
||||
protected $signature = "insights:send
|
||||
{--user= : Send insights for a specific user ID}
|
||||
{--subject= : Send insights for a specific subject ID}
|
||||
{--force : Ignore time and frequency checks, send immediately}
|
||||
{--type= : Override message type (insight, language_lesson, game_simulator, technical_teaching)}";
|
||||
|
||||
protected $description = "Send scheduled insights to users based on their subject schedules";
|
||||
|
||||
public function handle(AIService $ai, TelegramService $telegram): int
|
||||
{
|
||||
$force = (bool) $this->option("force");
|
||||
$overrideType = MessageType::tryFrom($this->option('type') ?? '') ?? null;
|
||||
|
||||
$query = UserSubject::where("is_active", true)
|
||||
->whereHas("subject", function ($q) {
|
||||
$q->where("is_active", true);
|
||||
})
|
||||
->with(["user", "subject"]);
|
||||
|
||||
if ($this->option("user")) {
|
||||
$query->where("user_id", (int) $this->option("user"));
|
||||
}
|
||||
|
||||
if ($this->option("subject")) {
|
||||
$query->where("subject_id", (int) $this->option("subject"));
|
||||
}
|
||||
|
||||
$subscriptions = $query->get();
|
||||
$sentCount = 0;
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
if ($force || $this->shouldSend($subscription)) {
|
||||
$this->sendInsight($subscription, $ai, $telegram, $overrideType);
|
||||
$sentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Sent {$sentCount} insights.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an insight should be sent based on the schedule.
|
||||
* Only called when --force is NOT used.
|
||||
*/
|
||||
private function shouldSend(UserSubject $subscription): bool
|
||||
{
|
||||
$user = $subscription->user;
|
||||
$timezone = $user->timezone ?? 'UTC';
|
||||
$now = now()->setTimezone($timezone);
|
||||
|
||||
// Check if today is a scheduled weekday
|
||||
if ($subscription->weekdays && is_array($subscription->weekdays) && count($subscription->weekdays) > 0) {
|
||||
$today = (string) $now->format('w');
|
||||
$found = false;
|
||||
foreach ($subscription->weekdays as $day) {
|
||||
if ((string) $day === $today) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
return false; // Not a scheduled day
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current hour:minute matches any preferred time
|
||||
if ($subscription->preferred_times && is_array($subscription->preferred_times) && count($subscription->preferred_times) > 0) {
|
||||
$currentHM = $now->format('H:i');
|
||||
$matchFound = false;
|
||||
foreach ($subscription->preferred_times as $time) {
|
||||
$timeStr = is_string($time) ? substr($time, 0, 5) : '09:00';
|
||||
// Allow 15-minute window each way (total 30 min window)
|
||||
$scheduled = \Carbon\Carbon::parse($timeStr, $timezone);
|
||||
$diff = abs($now->diffInMinutes($scheduled));
|
||||
if ($diff <= 15) {
|
||||
$matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$matchFound) {
|
||||
return false; // Not the right time
|
||||
}
|
||||
}
|
||||
|
||||
// Never sent before - send now (time and weekday already match)
|
||||
if (!$subscription->last_sent_at) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check frequency
|
||||
$lastSent = $subscription->last_sent_at->setTimezone($timezone);
|
||||
|
||||
return match ($subscription->frequency) {
|
||||
"daily" => $lastSent->lt($now->copy()->subDay()),
|
||||
"weekly" => $lastSent->lt($now->copy()->subWeek()),
|
||||
"biweekly" => $lastSent->lt($now->copy()->subWeeks(2)),
|
||||
"monthly" => $lastSent->lt($now->copy()->subMonth()),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
private function sendInsight(UserSubject $subscription, AIService $ai, TelegramService $telegram, ?MessageType $overrideType = null): void
|
||||
{
|
||||
$user = $subscription->user;
|
||||
$subject = $subscription->subject;
|
||||
$language = $subscription->insight_language ?? $user->locale ?? 'en';
|
||||
|
||||
$messageType = $overrideType ?? MessageType::AiGenerated;
|
||||
|
||||
$prompt = match ($messageType) {
|
||||
MessageType::LanguageLesson => __('Teach me a practical language lesson about :subject with examples, practice sentences, pronunciation, grammar, and vocabulary.', ['subject' => $subject->name]),
|
||||
MessageType::GameSimulator => __('Give me a strategic tip for :subject with actionable strategies, commands, and practical steps.', ['subject' => $subject->name]),
|
||||
MessageType::TechnicalTeaching => __('Give me a technical teaching lesson about :subject covering components, techniques, repairs, schematics, and practical exercises.', ['subject' => $subject->name]),
|
||||
default => __('Generate an insightful learning tip about :subject. Include practical advice and key concepts.', ['subject' => $subject->name]),
|
||||
};
|
||||
|
||||
if ($subject->introduction) {
|
||||
$prompt = __('Context about :subject: :introduction', [
|
||||
'subject' => $subject->name,
|
||||
'introduction' => $subject->introduction,
|
||||
]) . "\n\n" . $prompt;
|
||||
}
|
||||
|
||||
$result = $ai->generateInsight($subject->name, $prompt, ['language' => $language], $messageType);
|
||||
|
||||
if (!$result["success"]) {
|
||||
$this->error("Failed to generate message for user {$user->id}: " . ($result["error"] ?? "Unknown error"));
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $subject, $result, $subscription, $telegram, $messageType) {
|
||||
Insight::create([
|
||||
"user_id" => $user->id,
|
||||
"subject_id" => $subject->id,
|
||||
"content" => $result["content"],
|
||||
"type" => $messageType->value,
|
||||
"input_tokens" => $result["input_tokens"],
|
||||
"output_tokens" => $result["output_tokens"],
|
||||
"sent_at" => now(),
|
||||
]);
|
||||
|
||||
TokenUsage::create([
|
||||
"user_id" => $user->id,
|
||||
"input_tokens" => $result["input_tokens"],
|
||||
"output_tokens" => $result["output_tokens"],
|
||||
"total_tokens" => $result["input_tokens"] + $result["output_tokens"],
|
||||
"model" => config("services." . config('services.ai.default_provider', 'openrouter') . ".model"),
|
||||
"provider" => config('services.ai.default_provider', 'openrouter'),
|
||||
]);
|
||||
|
||||
$subscription->update(["last_sent_at" => now()]);
|
||||
|
||||
// Send to Telegram if enabled
|
||||
if ($subscription->hasChannel('telegram')) {
|
||||
$telegramUser = $user->telegramUsers()
|
||||
->where("is_active", true)
|
||||
->first();
|
||||
|
||||
if ($telegramUser && $telegramUser->isFullyConfigured()) {
|
||||
$botTelegram = new TelegramService($telegramUser->bot_token);
|
||||
$plainText = MarkdownService::strip($result["content"]);
|
||||
$botTelegram->sendMessage(
|
||||
$telegramUser->chat_id,
|
||||
"📚 *{$subject->name}*\n\n{$plainText}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Sent message to user {$user->id} for subject {$subject->id}");
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum MessageType: string
|
||||
{
|
||||
case Insight = 'insight';
|
||||
case AiGenerated = 'ai_generated';
|
||||
case Manual = 'manual';
|
||||
case Introduction = 'introduction';
|
||||
case LanguageLesson = 'language_lesson';
|
||||
case GameSimulator = 'game_simulator';
|
||||
case TechnicalTeaching = 'technical_teaching';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Insight => __('insight'),
|
||||
self::AiGenerated => __('AI Generated'),
|
||||
self::Manual => __('Manual'),
|
||||
self::Introduction => __('Introduction'),
|
||||
self::LanguageLesson => __('Language Lesson'),
|
||||
self::GameSimulator => __('Game / Simulator'),
|
||||
self::TechnicalTeaching => __('Technical Teaching'),
|
||||
};
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\SubscriptionPlan;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\UserSubscription;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BillingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
$activeSubscription = UserSubscription::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->with('plan')
|
||||
->first();
|
||||
|
||||
$subscriptionHistory = UserSubscription::where('user_id', $user->id)
|
||||
->with('plan')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$invoices = Invoice::where('user_id', $user->id)
|
||||
->with('subscription.plan')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$plans = SubscriptionPlan::where('is_active', true)->get();
|
||||
|
||||
$notifications = UserNotification::where('user_id', $user->id)
|
||||
->where('is_read', false)
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return view('admin.billing.index', compact(
|
||||
'activeSubscription', 'subscriptionHistory', 'invoices', 'plans', 'notifications'
|
||||
));
|
||||
}
|
||||
|
||||
public function subscribe(Request $request, SubscriptionPlan $plan): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Check if user already has active subscription
|
||||
$existing = UserSubscription::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return back()->with('error', __('You already have an active subscription.'));
|
||||
}
|
||||
|
||||
$subscription = UserSubscription::create([
|
||||
'user_id' => $user->id,
|
||||
'subscription_plan_id' => $plan->id,
|
||||
'start_date' => now(),
|
||||
'end_date' => now()->addDays($plan->duration_days),
|
||||
'status' => 'active',
|
||||
'payment_approved' => false,
|
||||
]);
|
||||
|
||||
// Create pending invoice
|
||||
$lastInvoice = Invoice::orderByDesc('id')->first();
|
||||
$nextNumber = $lastInvoice ? (int)filter_var($lastInvoice->invoice_number, FILTER_SANITIZE_NUMBER_INT) + 1 : 1001;
|
||||
|
||||
Invoice::create([
|
||||
'user_id' => $user->id,
|
||||
'user_subscription_id' => $subscription->id,
|
||||
'invoice_number' => 'INV-' . $nextNumber . '/' . now()->year,
|
||||
'amount' => $plan->price,
|
||||
'currency' => $plan->currency,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// Notify SuperAdmin
|
||||
UserNotification::create([
|
||||
'user_id' => 1, // SuperAdmin
|
||||
'type' => 'admin_message',
|
||||
'title' => __('New Subscription'),
|
||||
'message' => __('User :name subscribed to plan :plan. Payment pending approval.', [
|
||||
'name' => $user->name,
|
||||
'plan' => $plan->name,
|
||||
]),
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Subscription created successfully. Your payment is pending approval.'));
|
||||
}
|
||||
|
||||
public function downloadInvoice(Invoice $invoice)
|
||||
{
|
||||
if ($invoice->user_id !== Auth::id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($invoice->pdf_path && Storage::exists($invoice->pdf_path)) {
|
||||
return Storage::download($invoice->pdf_path, "invoice-{$invoice->invoice_number}.pdf");
|
||||
}
|
||||
|
||||
return back()->with('error', __('Invoice PDF not available yet.'));
|
||||
}
|
||||
|
||||
public function notifications(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
$notifications = UserNotification::where('user_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.billing.notifications', compact('notifications'));
|
||||
}
|
||||
|
||||
public function markNotificationRead(UserNotification $notification): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
if ($notification->user_id !== Auth::id()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$notification->markAsRead();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function markAllNotificationsRead(): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
UserNotification::where('user_id', Auth::id())
|
||||
->where('is_read', false)
|
||||
->update(['is_read' => true, 'read_at' => now()]);
|
||||
|
||||
return back()->with('success', __('All notifications marked as read.'));
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomSubject;
|
||||
use App\Models\CustomSubjectQuestion;
|
||||
use App\Models\TokenUsage;
|
||||
use App\Services\AIService;
|
||||
use App\Services\PdfExtractionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for managing user custom subjects created from PDFs.
|
||||
*/
|
||||
class CustomSubjectController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the user's custom subjects.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$customSubjects = CustomSubject::where('user_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return view('admin.custom-subjects.index', compact('customSubjects'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created custom subject from a PDF upload.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'pdf' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$pdfFile = $request->file('pdf');
|
||||
|
||||
if (!$pdfFile->isValid()) {
|
||||
return back()->with('error', __('Failed to upload PDF. Please try again.'));
|
||||
}
|
||||
|
||||
$fileName = time() . '_' . uniqid() . '.pdf';
|
||||
$storagePath = 'custom_subjects/' . $user->id;
|
||||
$pdfPath = $pdfFile->storeAs($storagePath, $fileName, 'local');
|
||||
|
||||
$customSubject = CustomSubject::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $validated['name'],
|
||||
'pdf_path' => $pdfPath,
|
||||
'processing_status' => 'processing',
|
||||
]);
|
||||
|
||||
// Extract text asynchronously (in real app, use a queue job)
|
||||
$pdfExtractionService = new PdfExtractionService();
|
||||
$fullPath = Storage::disk('local')->path($pdfPath);
|
||||
$result = $pdfExtractionService->extract($fullPath);
|
||||
|
||||
if ($result['success']) {
|
||||
$customSubject->update([
|
||||
'extracted_content' => $result['content'],
|
||||
'processing_status' => 'completed',
|
||||
]);
|
||||
} else {
|
||||
$customSubject->update([
|
||||
'processing_status' => 'failed',
|
||||
'error_message' => $result['error'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
return back()->with('success', __('Custom subject created successfully.'));
|
||||
}
|
||||
|
||||
return back()->with('error', __('Failed to process PDF: :error', ['error' => $result['error']]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified custom subject.
|
||||
*/
|
||||
public function edit(CustomSubject $customSubject): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($customSubject->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return view('admin.custom-subjects.edit', compact('customSubject'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified custom subject.
|
||||
*/
|
||||
public function update(Request $request, CustomSubject $customSubject): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($customSubject->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$customSubject->update([
|
||||
'name' => $validated['name'],
|
||||
'is_active' => $request->has('is_active'),
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Custom subject updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified custom subject with Q&A interface.
|
||||
*/
|
||||
public function show(CustomSubject $customSubject): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($customSubject->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$questions = $customSubject->questions()
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
// Get AI settings from DB (unified for all subjects), fallback to config
|
||||
$provider = \App\Models\Setting::where('key', 'ai_default_provider')->value('value');
|
||||
$model = \App\Models\Setting::where('key', 'ai_default_model')->value('value');
|
||||
|
||||
if (!$provider) {
|
||||
$provider = config('services.ai.default_provider', 'openrouter');
|
||||
}
|
||||
|
||||
if (!$model) {
|
||||
$model = config('services.ai.providers.' . $provider . '.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
}
|
||||
|
||||
return view('admin.custom-subjects.show', compact('customSubject', 'questions', 'provider', 'model'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question about the custom subject and get an AI answer.
|
||||
*/
|
||||
public function ask(Request $request, CustomSubject $customSubject): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($customSubject->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($customSubject->processing_status !== 'completed' || empty($customSubject->extracted_content)) {
|
||||
return back()->with('error', __('This PDF is still being processed or has no content.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'question' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$question = trim($validated['question']);
|
||||
|
||||
// Get AI settings from DB (unified for all subjects), fallback to config
|
||||
$provider = \App\Models\Setting::where('key', 'ai_default_provider')->value('value');
|
||||
$model = \App\Models\Setting::where('key', 'ai_default_model')->value('value');
|
||||
|
||||
if (!$provider) {
|
||||
$provider = config('services.ai.default_provider', 'openrouter');
|
||||
}
|
||||
|
||||
if (!$model) {
|
||||
$model = config('services.ai.providers.' . $provider . '.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
}
|
||||
|
||||
$language = $user->locale ?? 'en';
|
||||
$languageNames = [
|
||||
'pt_BR' => 'Brazilian Portuguese',
|
||||
'en' => 'English',
|
||||
'es' => 'Spanish',
|
||||
];
|
||||
$languageName = $languageNames[$language] ?? 'English';
|
||||
|
||||
$systemPrompt = "You are an expert assistant analyzing the content of a document titled '{$customSubject->name}'. "
|
||||
. "Your task is to answer questions based ONLY on the provided document content. "
|
||||
. "If the answer is not found in the document, say so clearly. "
|
||||
. "Do NOT make up information. "
|
||||
. "CRITICAL LANGUAGE INSTRUCTION: You MUST respond ONLY in {$languageName} ({$language}). "
|
||||
. "Do NOT use any other language.";
|
||||
|
||||
$userMessage = "Document content:\n\n{$customSubject->extracted_content}\n\n"
|
||||
. "Question: {$question}\n\n"
|
||||
. "CRITICAL: Answer EXCLUSIVELY in {$languageName}. Do NOT use English. Do NOT translate. "
|
||||
. "The entire response must be ONLY in {$languageName}.";
|
||||
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userMessage],
|
||||
];
|
||||
|
||||
$aiService = new AIService($provider, $model);
|
||||
$result = $aiService->chat($messages);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to get response: :error', [
|
||||
'error' => $result['error'] ?? __('Unknown error'),
|
||||
]));
|
||||
}
|
||||
|
||||
$answer = $result['content'];
|
||||
$inputTokens = $result['input_tokens'] ?? 0;
|
||||
$outputTokens = $result['output_tokens'] ?? 0;
|
||||
|
||||
CustomSubjectQuestion::create([
|
||||
'custom_subject_id' => $customSubject->id,
|
||||
'user_id' => $user->id,
|
||||
'question' => $question,
|
||||
'answer' => $answer,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'asked_at' => now(),
|
||||
]);
|
||||
|
||||
// Track token usage
|
||||
if ($inputTokens > 0 || $outputTokens > 0) {
|
||||
TokenUsage::create([
|
||||
'user_id' => $user->id,
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $inputTokens + $outputTokens,
|
||||
'model' => $model,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Question answered successfully!'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified custom subject.
|
||||
*/
|
||||
public function destroy(CustomSubject $customSubject): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($customSubject->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Delete PDF file from storage
|
||||
if ($customSubject->pdf_path && Storage::disk('local')->exists($customSubject->pdf_path)) {
|
||||
Storage::disk('local')->delete($customSubject->pdf_path);
|
||||
}
|
||||
|
||||
$customSubject->delete();
|
||||
|
||||
return back()->with('success', __('Custom subject deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified question from the custom subject.
|
||||
*/
|
||||
public function destroyQuestion(CustomSubject $customSubject, CustomSubjectQuestion $question): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($question->custom_subject_id !== $customSubject->id || $question->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$question->delete();
|
||||
|
||||
return back()->with('success', __('Question deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a question answer to the user's preferred language.
|
||||
*/
|
||||
public function translateQuestion(Request $request, CustomSubject $customSubject, CustomSubjectQuestion $question): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($question->custom_subject_id !== $customSubject->id || $question->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$question->answer) {
|
||||
return back()->with('error', __('This question has no answer to translate.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'target_language' => ['required', 'string', 'max:10'],
|
||||
]);
|
||||
|
||||
$targetLanguage = $validated['target_language'];
|
||||
|
||||
// Get AI settings
|
||||
$provider = \App\Models\Setting::where('key', 'ai_default_provider')->value('value');
|
||||
$model = \App\Models\Setting::where('key', 'ai_default_model')->value('value');
|
||||
|
||||
if (!$provider) {
|
||||
$provider = config('services.ai.default_provider', 'openrouter');
|
||||
}
|
||||
|
||||
if (!$model) {
|
||||
$model = config('services.ai.providers.' . $provider . '.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
}
|
||||
|
||||
$aiService = new AIService($provider, $model);
|
||||
$result = $aiService->translate($question->answer, $targetLanguage, $customSubject->name);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to translate question: :error', [
|
||||
'error' => $result['error'] ?? __('Unknown error'),
|
||||
]));
|
||||
}
|
||||
|
||||
// Update question with translated answer
|
||||
$question->update([
|
||||
'answer' => $result['content'],
|
||||
]);
|
||||
|
||||
// Track token usage
|
||||
$inputTokens = $result['input_tokens'] ?? 0;
|
||||
$outputTokens = $result['output_tokens'] ?? 0;
|
||||
|
||||
if ($inputTokens > 0 || $outputTokens > 0) {
|
||||
TokenUsage::create([
|
||||
'user_id' => $user->id,
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $inputTokens + $outputTokens,
|
||||
'model' => $model,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Question translated successfully!'));
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\MessageType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Insight;
|
||||
use App\Models\Subject;
|
||||
use App\Models\TokenUsage;
|
||||
use App\Services\AIService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for managing AI-generated insights.
|
||||
*/
|
||||
class InsightController extends Controller
|
||||
{
|
||||
/**
|
||||
* The AI service instance.
|
||||
*/
|
||||
protected AIService $ai;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(AIService $ai)
|
||||
{
|
||||
$this->ai = $ai;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of insights for the authenticated user.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$subjectId = $request->input('subject_id');
|
||||
|
||||
/** @var \Illuminate\Pagination\LengthAwarePaginator $insights */
|
||||
$insightsQuery = Insight::where('user_id', $user->id)
|
||||
->with('subject');
|
||||
|
||||
if ($subjectId) {
|
||||
$insightsQuery->where('subject_id', (int) $subjectId);
|
||||
}
|
||||
|
||||
$insights = $insightsQuery->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, Subject> $subjects */
|
||||
$subjects = Subject::where('is_active', true)->get();
|
||||
|
||||
// Token usage statistics
|
||||
$totalInputTokens = TokenUsage::where('user_id', $user->id)->sum('input_tokens');
|
||||
$totalOutputTokens = TokenUsage::where('user_id', $user->id)->sum('output_tokens');
|
||||
$totalTokens = $totalInputTokens + $totalOutputTokens;
|
||||
|
||||
return view('admin.insights.index', compact(
|
||||
'insights',
|
||||
'subjects',
|
||||
'subjectId',
|
||||
'totalInputTokens',
|
||||
'totalOutputTokens',
|
||||
'totalTokens'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new AI insight for the specified subject.
|
||||
*/
|
||||
public function generate(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject_id' => ['required', 'exists:subjects,id'],
|
||||
'custom_prompt' => ['nullable', 'string', 'max:500'],
|
||||
'message_type' => ['nullable', 'string', 'max:50'],
|
||||
'language' => ['nullable', 'string', 'in:pt_BR,en,es'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
/** @var Subject|null $subject */
|
||||
$subject = Subject::findOrFail((int) $validated['subject_id']);
|
||||
|
||||
$messageType = MessageType::tryFrom($validated['message_type'] ?? '') ?? MessageType::AiGenerated;
|
||||
|
||||
$language = $validated['language']
|
||||
?? app()->getLocale()
|
||||
?? 'en';
|
||||
|
||||
// Build the prompt with subject context
|
||||
$prompt = $validated['custom_prompt']
|
||||
?: $this->buildDefaultPrompt($subject, $messageType);
|
||||
|
||||
$context = ['language' => $language];
|
||||
|
||||
if ($subject->introduction) {
|
||||
$context['introduction'] = $subject->introduction;
|
||||
$prompt = "Context about {$subject->name}: {$subject->introduction}\n\n{$prompt}";
|
||||
}
|
||||
|
||||
// Generate message via AI based on type
|
||||
$result = $this->ai->generateInsight($subject->name, $prompt, $context, $messageType);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to generate message: :error', [
|
||||
'error' => $result['error'] ?? __('Unknown error'),
|
||||
]));
|
||||
}
|
||||
|
||||
// Store the generated message
|
||||
Insight::create([
|
||||
'user_id' => $user->id,
|
||||
'subject_id' => $subject->id,
|
||||
'content' => $result['content'],
|
||||
'type' => $messageType->value,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// Track token usage
|
||||
TokenUsage::create([
|
||||
'user_id' => $user->id,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'total_tokens' => $result['input_tokens'] + $result['output_tokens'],
|
||||
'model' => config('services.openrouter.model', 'google/gemini-2.0-flash-exp:free'),
|
||||
'provider' => 'openrouter',
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Message generated successfully!'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a default prompt based on the message type.
|
||||
*/
|
||||
private function buildDefaultPrompt(Subject $subject, MessageType $messageType): string
|
||||
{
|
||||
return match ($messageType) {
|
||||
MessageType::LanguageLesson => __('Teach me a practical language lesson about :subject with examples, practice sentences, pronunciation, grammar, and vocabulary.', ['subject' => $subject->name]),
|
||||
MessageType::GameSimulator => __('Give me a strategic tip for :subject with actionable strategies, commands, and practical steps.', ['subject' => $subject->name]),
|
||||
MessageType::TechnicalTeaching => __('Give me a technical teaching lesson about :subject covering components, techniques, repairs, schematics, and practical exercises.', ['subject' => $subject->name]),
|
||||
default => __('Generate an insightful learning tip about :subject. Include practical advice and key concepts.', ['subject' => $subject->name]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a manually created insight.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject_id' => ['required', 'exists:subjects,id'],
|
||||
'content' => ['required', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
Insight::create([
|
||||
'user_id' => $user->id,
|
||||
'subject_id' => (int) $validated['subject_id'],
|
||||
'content' => $validated['content'],
|
||||
'type' => 'manual',
|
||||
'input_tokens' => 0,
|
||||
'output_tokens' => 0,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Insight created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified insight.
|
||||
*/
|
||||
public function destroy(Insight $insight): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($insight->user_id !== $user->id && !$user->hasRole('superadmin')) {
|
||||
return back()->with('error', __('You do not have permission to delete this insight.'));
|
||||
}
|
||||
|
||||
$insight->delete();
|
||||
|
||||
return back()->with('success', __('Insight deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an insight to the user's preferred language.
|
||||
*/
|
||||
public function translate(Request $request, Insight $insight): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($insight->user_id !== $user->id && !$user->hasRole('superadmin')) {
|
||||
return back()->with('error', __('You do not have permission to translate this insight.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'target_language' => ['required', 'string', 'max:10'],
|
||||
]);
|
||||
|
||||
$targetLanguage = $validated['target_language'];
|
||||
|
||||
// Get user's language preference for this subject
|
||||
$userSubject = \App\Models\UserSubject::where('user_id', $insight->user_id)
|
||||
->where('subject_id', $insight->subject_id)
|
||||
->first();
|
||||
|
||||
$language = $userSubject->insight_language ?? $user->locale ?? 'en';
|
||||
|
||||
// Don't translate if already in the target language
|
||||
if ($language === $targetLanguage) {
|
||||
return back()->with('info', __('Insight is already in the selected language.'));
|
||||
}
|
||||
|
||||
$result = $this->ai->translate($insight->content, $targetLanguage, $insight->subject->name);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to translate message: :error', [
|
||||
'error' => $result['error'] ?? __('Unknown error'),
|
||||
]));
|
||||
}
|
||||
|
||||
// Create a new insight with translated content
|
||||
Insight::create([
|
||||
'user_id' => $insight->user_id,
|
||||
'subject_id' => $insight->subject_id,
|
||||
'content' => $result['content'],
|
||||
'type' => $insight->type->value,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// Track token usage
|
||||
TokenUsage::create([
|
||||
'user_id' => $insight->user_id,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'total_tokens' => $result['input_tokens'] + $result['output_tokens'],
|
||||
'model' => config('services.openrouter.model', 'google/gemini-2.0-flash-exp:free'),
|
||||
'provider' => 'openrouter',
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Message translated successfully!'));
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Subject;
|
||||
use App\Models\UserInterest;
|
||||
use App\Models\UserSubject;
|
||||
use App\Services\AIService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Manages user interests and AI-generated subject suggestions.
|
||||
*/
|
||||
class InterestController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's interests and AI suggestions.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$interests = $user->userInterests()->orderByDesc('created_at')->get();
|
||||
$suggestions = session('suggestions', []);
|
||||
|
||||
return view('admin.interests.index', compact('interests', 'suggestions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new user interest.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'interest' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$user->userInterests()->create([
|
||||
'interest' => $validated['interest'],
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Interest added successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified user interest.
|
||||
*/
|
||||
public function destroy(UserInterest $userInterest): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $userInterest);
|
||||
|
||||
$userInterest->delete();
|
||||
|
||||
return back()->with('success', __('Interest removed successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI subject suggestions based on user interests.
|
||||
*/
|
||||
public function generateSuggestions(Request $request): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$interests = $user->userInterests()->pluck('interest')->toArray();
|
||||
|
||||
if (empty($interests)) {
|
||||
return back()->with('error', __('Add at least one interest before generating suggestions.'));
|
||||
}
|
||||
|
||||
$interestsList = implode(', ', $interests);
|
||||
|
||||
$prompt = "Com base nos interesses do usuário: {$interestsList}. Sugira de 3 a 5 temas de assuntos de aprendizado que seriam relevantes para este usuário. Para cada tema, forneça: 1) name (formato: 'Aprendizado e Treinamento de [Tema]'), 2) description (máximo 1000 caracteres), 3) introduction (máximo 5000 caracteres). Retorne APENAS um array JSON válido (sem texto adicional, sem markdown, sem code blocks) com objetos contendo esses campos. Todo o conteúdo deve estar em Português do Brasil.";
|
||||
|
||||
/** @var \App\Services\AIService $ai */
|
||||
$ai = app(AIService::class);
|
||||
$result = $ai->generateInsight('Sugestões de assuntos', $prompt, ['language' => 'pt_BR']);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to generate suggestions: :error', ['error' => $result['error'] ?? __('Unknown error')]));
|
||||
}
|
||||
|
||||
$suggestions = $this->extractJsonFromAiResponse($result['content']);
|
||||
|
||||
if (!$suggestions || !is_array($suggestions)) {
|
||||
return back()->with('error', __('Failed to parse AI suggestions. Please try again.'));
|
||||
}
|
||||
|
||||
return back()->with('suggestions', $suggestions)->with('success', __('Suggestions generated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subject automatically from a suggestion.
|
||||
*/
|
||||
public function createSubjectFromSuggestion(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'introduction' => ['nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
$existing = Subject::where('name', $request->name)->first();
|
||||
|
||||
if ($existing) {
|
||||
return back()->with('error', __('A subject with this name already exists.'));
|
||||
}
|
||||
|
||||
$subject = Subject::create([
|
||||
'user_id' => Auth::id(),
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'introduction' => $request->introduction,
|
||||
'is_system' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
UserSubject::firstOrCreate(
|
||||
[
|
||||
'user_id' => Auth::id(),
|
||||
'subject_id' => $subject->id,
|
||||
],
|
||||
[
|
||||
'frequency' => 'daily',
|
||||
'preferred_times' => ['09:00'],
|
||||
'insight_language' => Auth::user()->locale ?? 'en',
|
||||
'delivery_channels' => ['dashboard'],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
return back()->with('success', __('Subject created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from AI response, handling code blocks and plain text.
|
||||
*/
|
||||
private function extractJsonFromAiResponse(string $content): ?array
|
||||
{
|
||||
$content = trim($content);
|
||||
|
||||
if (str_starts_with($content, '```')) {
|
||||
$content = preg_replace('/^```(?:json)?\s*/', '', $content);
|
||||
$content = preg_replace('/\s*```$/', '', $content);
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (preg_match('/\[.*\]/s', $content, $matches)) {
|
||||
$data = json_decode($matches[0], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\MessageType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InsightSchedule;
|
||||
use App\Models\Subject;
|
||||
use App\Models\UserSubject;
|
||||
use App\Services\AIService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for managing user subjects and subscriptions.
|
||||
*/
|
||||
class SubjectController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of available subjects and user subscriptions.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, Subject> $systemSubjects */
|
||||
$systemSubjects = Subject::where('is_system', true)
|
||||
->where('is_active', true)
|
||||
->with(['userSubjects' => function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id);
|
||||
}])
|
||||
->get();
|
||||
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, UserSubject> $mySubjects */
|
||||
$mySubjects = UserSubject::where('user_id', $user->id)
|
||||
->whereHas('subject', function ($query) {
|
||||
$query->where('user_id', '!=', null)
|
||||
->where('is_system', false);
|
||||
})
|
||||
->with('subject')
|
||||
->get();
|
||||
|
||||
return view('admin.subjects.index', compact('systemSubjects', 'mySubjects'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created custom subject.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'introduction' => ['nullable', 'string', 'max:5000'],
|
||||
'message_type' => ['nullable', 'string', 'max:50'],
|
||||
'custom_instructions' => ['nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
/** @var Subject $subject */
|
||||
$subject = Subject::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'introduction' => $validated['introduction'] ?? null,
|
||||
'message_type' => $validated['message_type'] ?? null,
|
||||
'custom_instructions' => $validated['custom_instructions'] ?? null,
|
||||
'is_system' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Auto-subscribe the user to their own subject
|
||||
UserSubject::firstOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'subject_id' => $subject->id,
|
||||
],
|
||||
[
|
||||
'frequency' => 'daily',
|
||||
'preferred_times' => ['09:00'],
|
||||
'insight_language' => $user->locale ?? 'en',
|
||||
'delivery_channels' => ['dashboard'],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
return back()->with('success', __('Subject created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new custom subject using AI.
|
||||
*/
|
||||
public function generateAiSubject(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'theme' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$theme = $request->input('theme');
|
||||
|
||||
$prompt = "Crie um assunto de aprendizado e treinamento sobre o tema: {$theme}. Retorne APENAS um objeto JSON válido (sem texto adicional, sem markdown, sem code blocks) com os campos: name (string, máximo 255 caracteres, formato: 'Aprendizado e Treinamento de [Tema]'), description (string, máximo 1000 caracteres), introduction (string, máximo 5000 caracteres). Todo o conteúdo deve estar em Português do Brasil. O assunto deve ser educativo, prático e adequado para geração de insights de aprendizagem pela IA.";
|
||||
|
||||
$ai = app(AIService::class);
|
||||
$result = $ai->generateInsight($theme, $prompt, ['language' => 'pt_BR']);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to generate subject: :error', ['error' => $result['error'] ?? __('Unknown error')]));
|
||||
}
|
||||
|
||||
$content = $result['content'];
|
||||
$data = $this->extractJsonFromAiResponse($content);
|
||||
|
||||
if (!$data || !isset($data['name'])) {
|
||||
$data = [
|
||||
'name' => "Aprendizado e Treinamento de {$theme}",
|
||||
'description' => $theme,
|
||||
'introduction' => $content,
|
||||
];
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
$subject = Subject::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'introduction' => $data['introduction'] ?? null,
|
||||
'is_system' => false,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
UserSubject::firstOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'subject_id' => $subject->id,
|
||||
],
|
||||
[
|
||||
'frequency' => 'daily',
|
||||
'preferred_times' => ['09:00'],
|
||||
'insight_language' => $user->locale ?? 'en',
|
||||
'delivery_channels' => ['dashboard'],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
return back()->with('success', __('Subject created successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from AI response, handling code blocks and plain text.
|
||||
*/
|
||||
private function extractJsonFromAiResponse(string $content): ?array
|
||||
{
|
||||
$content = trim($content);
|
||||
|
||||
if (str_starts_with($content, '```')) {
|
||||
$content = preg_replace('/^```(?:json)?\s*/', '', $content);
|
||||
$content = preg_replace('/\s*```$/', '', $content);
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (preg_match('/\{.*\}/s', $content, $matches)) {
|
||||
$data = json_decode($matches[0], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a subject with scheduling preferences.
|
||||
*/
|
||||
public function subscribe(Request $request, Subject $subject): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'frequency' => ['required', 'string', 'in:daily,weekly,biweekly,monthly'],
|
||||
'preferred_times' => ['required', 'array', 'min:1'],
|
||||
'preferred_times.*' => ['required', 'string', 'date_format:H:i'],
|
||||
'weekdays' => ['nullable', 'array'],
|
||||
'weekdays.*' => ['integer', 'between:0,6'],
|
||||
'insight_language' => ['required', 'string', 'in:pt_BR,en,es'],
|
||||
'delivery_channels' => ['nullable', 'array'],
|
||||
'delivery_channels.*' => ['string', 'in:dashboard,telegram'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$channels = $validated['delivery_channels'] ?? ['dashboard'];
|
||||
if (!in_array('dashboard', $channels, true)) {
|
||||
$channels[] = 'dashboard'; // Dashboard is always enabled
|
||||
}
|
||||
|
||||
UserSubject::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'subject_id' => $subject->id,
|
||||
],
|
||||
[
|
||||
'frequency' => $validated['frequency'],
|
||||
'preferred_times' => $validated['preferred_times'],
|
||||
'weekdays' => $validated['weekdays'] ?? null,
|
||||
'insight_language' => $validated['insight_language'],
|
||||
'delivery_channels' => $channels,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
return back()->with('success', __('Subscribed to subject successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified subject.
|
||||
*/
|
||||
public function update(Request $request, Subject $subject): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'message_type' => ['nullable', 'string', 'max:50'],
|
||||
'custom_instructions' => ['nullable', 'string', 'max:5000'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$subject->update([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? $subject->description,
|
||||
'message_type' => $validated['message_type'] ?? $subject->message_type,
|
||||
'custom_instructions' => $validated['custom_instructions'] ?? $subject->custom_instructions,
|
||||
'is_active' => $validated['is_active'] ?? $subject->is_active,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Subject updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active status of a user's subject subscription.
|
||||
*/
|
||||
public function toggle(Request $request, UserSubject $userSubject): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'is_active' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$userSubject->update([
|
||||
'is_active' => $validated['is_active'],
|
||||
]);
|
||||
|
||||
if (!$validated['is_active']) {
|
||||
InsightSchedule::where('user_id', $userSubject->user_id)
|
||||
->where('subject_id', $userSubject->subject_id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$status = $validated['is_active'] ? __('activated') : __('deactivated');
|
||||
|
||||
return back()->with('success', __("Subject {$status} successfully."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the schedule for a user's subject subscription.
|
||||
*/
|
||||
public function updateSchedule(Request $request, UserSubject $userSubject): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'frequency' => ['required', 'string', 'in:daily,weekly,biweekly,monthly'],
|
||||
'preferred_times' => ['required', 'array', 'min:1'],
|
||||
'preferred_times.*' => ['required', 'string', 'date_format:H:i'],
|
||||
'weekdays' => ['nullable', 'array'],
|
||||
'weekdays.*' => ['integer', 'between:0,6'],
|
||||
'insight_language' => ['required', 'string', 'in:pt_BR,en,es'],
|
||||
'delivery_channels' => ['nullable', 'array'],
|
||||
'delivery_channels.*' => ['string', 'in:dashboard,telegram'],
|
||||
]);
|
||||
|
||||
$channels = $validated['delivery_channels'] ?? ['dashboard'];
|
||||
if (!in_array('dashboard', $channels, true)) {
|
||||
$channels[] = 'dashboard';
|
||||
}
|
||||
|
||||
$userSubject->update([
|
||||
'frequency' => $validated['frequency'],
|
||||
'preferred_times' => $validated['preferred_times'],
|
||||
'weekdays' => $validated['weekdays'] ?? $userSubject->weekdays,
|
||||
'insight_language' => $validated['insight_language'],
|
||||
'delivery_channels' => $channels,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Schedule updated successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified subject.
|
||||
*/
|
||||
public function destroy(Subject $subject): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
// Only allow deletion if the user owns the subject or is SuperAdmin
|
||||
if ($subject->user_id !== $user->id && !$user->hasRole('superadmin')) {
|
||||
return back()->with('error', __('You do not have permission to delete this subject.'));
|
||||
}
|
||||
|
||||
$subject->delete();
|
||||
|
||||
return back()->with('success', __('Subject deleted successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a subject.
|
||||
*/
|
||||
public function unsubscribe(UserSubject $userSubject): RedirectResponse
|
||||
{
|
||||
$userSubject->delete();
|
||||
|
||||
return back()->with('success', __('Unsubscribed from subject successfully.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an immediate AI insight for a subject.
|
||||
*/
|
||||
public function sendInsight(Request $request, Subject $subject): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$ai = app(AIService::class);
|
||||
|
||||
$messageType = MessageType::tryFrom($request->input('message_type', '')) ?? MessageType::AiGenerated;
|
||||
|
||||
if ($messageType === MessageType::AiGenerated && $subject->message_type) {
|
||||
$messageType = MessageType::tryFrom($subject->message_type) ?? MessageType::AiGenerated;
|
||||
}
|
||||
|
||||
// Get the user's language preference for this subject
|
||||
$userSubject = UserSubject::where('user_id', $user->id)
|
||||
->where('subject_id', $subject->id)
|
||||
->first();
|
||||
|
||||
$language = $userSubject->insight_language ?? $user->locale ?? 'en';
|
||||
|
||||
$prompt = match ($messageType) {
|
||||
MessageType::LanguageLesson => __('Teach me a practical language lesson about :subject with examples, practice sentences, pronunciation, grammar, and vocabulary.', ['subject' => $subject->name]),
|
||||
MessageType::GameSimulator => __('Give me a strategic tip for :subject with actionable strategies, commands, and practical steps.', ['subject' => $subject->name]),
|
||||
MessageType::TechnicalTeaching => __('Give me a technical teaching lesson about :subject covering components, techniques, repairs, schematics, and practical exercises.', ['subject' => $subject->name]),
|
||||
default => __('Generate an insightful learning tip about :subject. Include practical advice and key concepts.', ['subject' => $subject->name]),
|
||||
};
|
||||
|
||||
$context = ['language' => $language];
|
||||
|
||||
if ($subject->introduction) {
|
||||
$context['introduction'] = $subject->introduction;
|
||||
$prompt = __('Context about :subject: :introduction', [
|
||||
'subject' => $subject->name,
|
||||
'introduction' => $subject->introduction,
|
||||
]) . "\n\n" . $prompt;
|
||||
}
|
||||
|
||||
$result = $ai->generateInsight($subject->name, $prompt, $context, $messageType);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to generate message: :error', [
|
||||
'error' => $result['error'] ?? __('Unknown error'),
|
||||
]));
|
||||
}
|
||||
|
||||
\App\Models\Insight::create([
|
||||
'user_id' => $user->id,
|
||||
'subject_id' => $subject->id,
|
||||
'content' => $result['content'],
|
||||
'type' => $messageType->value,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
\App\Models\TokenUsage::create([
|
||||
'user_id' => $user->id,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'total_tokens' => $result['input_tokens'] + $result['output_tokens'],
|
||||
'model' => config('services.openrouter.model', 'google/gemini-2.0-flash-exp:free'),
|
||||
'provider' => 'openrouter',
|
||||
]);
|
||||
|
||||
// Send to Telegram if user has a bot configured
|
||||
$this->sendToTelegram($user, $subject->id, $result['content']);
|
||||
|
||||
return back()->with('success', __('Message generated and sent successfully!'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an AI-generated introduction for a subject as an insight.
|
||||
* Always generates a fresh introduction via OpenRouter AI.
|
||||
*/
|
||||
public function sendIntroduction(Request $request, Subject $subject): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$ai = app(AIService::class);
|
||||
|
||||
$userSubject = UserSubject::where('user_id', $user->id)
|
||||
->where('subject_id', $subject->id)
|
||||
->first();
|
||||
|
||||
$language = $userSubject->insight_language ?? $user->locale ?? 'en';
|
||||
|
||||
$prompt = "Create a comprehensive and engaging introduction about the subject: {$subject->name}. "
|
||||
. "Explain what it is, why it's important to learn, its key concepts, "
|
||||
. "and what the learner will gain from studying it. "
|
||||
. "Be informative, inspiring, and practical.";
|
||||
|
||||
$context = ['language' => $language];
|
||||
|
||||
if ($subject->introduction) {
|
||||
$context['existing_introduction'] = $subject->introduction;
|
||||
$prompt .= "\n\nHere is some context about the subject: {$subject->introduction}";
|
||||
}
|
||||
|
||||
$result = $ai->generateInsight($subject->name, $prompt, $context, MessageType::Introduction);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to generate introduction: :error', [
|
||||
'error' => $result['error'] ?? __('Unknown error'),
|
||||
]));
|
||||
}
|
||||
|
||||
$subject->update(['introduction' => $result['content']]);
|
||||
|
||||
\App\Models\Insight::create([
|
||||
'user_id' => $user->id,
|
||||
'subject_id' => $subject->id,
|
||||
'content' => $result['content'],
|
||||
'type' => MessageType::Introduction->value,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
\App\Models\TokenUsage::create([
|
||||
'user_id' => $user->id,
|
||||
'input_tokens' => $result['input_tokens'],
|
||||
'output_tokens' => $result['output_tokens'],
|
||||
'total_tokens' => $result['input_tokens'] + $result['output_tokens'],
|
||||
'model' => config('services.openrouter.model'),
|
||||
'provider' => 'openrouter',
|
||||
]);
|
||||
|
||||
// Send to Telegram if user has a bot configured
|
||||
$this->sendToTelegram($user, $subject->id, $result['content']);
|
||||
|
||||
return back()->with('success', __('Introduction generated by AI and sent successfully!'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send insight content to the user's Telegram bot if configured and enabled.
|
||||
*/
|
||||
private function sendToTelegram(\App\Models\User $user, int $subjectId, string $content): void
|
||||
{
|
||||
try {
|
||||
// Check if user has Telegram enabled for this subject
|
||||
$userSubject = \App\Models\UserSubject::where('user_id', $user->id)
|
||||
->where('subject_id', $subjectId)
|
||||
->first();
|
||||
|
||||
// If no user subject found or telegram channel not enabled, skip
|
||||
if (!$userSubject || !$userSubject->hasChannel('telegram')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var \App\Models\TelegramUser|null $telegramUser */
|
||||
$telegramUser = $user->telegramUsers()->where('is_active', true)->first();
|
||||
|
||||
if (!$telegramUser || !$telegramUser->isFullyConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subject = \App\Models\Subject::find($subjectId);
|
||||
$subjectName = $subject ? $subject->name : __('Subject');
|
||||
|
||||
$telegramService = new \App\Services\TelegramService($telegramUser->bot_token);
|
||||
|
||||
// Strip markdown for Telegram (it has its own formatting)
|
||||
$plainText = \App\Services\MarkdownService::strip($content);
|
||||
$message = "📚 *{$subjectName}*\n\n{$plainText}";
|
||||
|
||||
$telegramService->sendMessage($telegramUser->chat_id, $message);
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::error('Failed to send insight to Telegram', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TelegramUser;
|
||||
use App\Services\TelegramService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for managing Telegram bot integration.
|
||||
* Each user can configure their own Telegram bot for receiving insights.
|
||||
*/
|
||||
class TelegramController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the Telegram configuration page.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
/** @var TelegramUser|null $telegramUser */
|
||||
$telegramUser = $user->telegramUsers()->first();
|
||||
|
||||
return view('admin.telegram.index', compact('telegramUser'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect/configure the user's Telegram bot.
|
||||
*/
|
||||
public function connect(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'bot_token' => ['required', 'string', 'max:255'],
|
||||
'bot_username' => ['nullable', 'string', 'max:255'],
|
||||
'chat_id' => ['required', 'string', 'max:255'],
|
||||
'telegram_username' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
// Test the bot connection before saving
|
||||
$telegramService = new TelegramService($validated['bot_token']);
|
||||
$testResult = $telegramService->sendMessage(
|
||||
$validated['chat_id'],
|
||||
'🤖 *Annunk Bot Connected!*' . "\n\n"
|
||||
. '✅ Your Telegram bot has been successfully connected to Annunk.'
|
||||
. "\n\n" . 'You will now receive learning insights here.'
|
||||
);
|
||||
|
||||
if (!$testResult) {
|
||||
return back()->with('error', __(
|
||||
'Failed to connect. Please check your Bot Token and Chat ID. '
|
||||
. 'Make sure you have started a conversation with your bot first.'
|
||||
))->withInput();
|
||||
}
|
||||
|
||||
TelegramUser::updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'telegram_user_id' => $validated['chat_id'],
|
||||
'bot_token' => $validated['bot_token'],
|
||||
'bot_username' => $validated['bot_username'] ?? null,
|
||||
'chat_id' => $validated['chat_id'],
|
||||
'telegram_username' => $validated['telegram_username'] ?? null,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
return back()->with('success', __('Telegram bot connected successfully! A test message was sent.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test message to verify the bot is working.
|
||||
*/
|
||||
public function test(): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
/** @var TelegramUser|null $telegramUser */
|
||||
$telegramUser = $user->telegramUsers()->first();
|
||||
|
||||
if (!$telegramUser || !$telegramUser->isFullyConfigured()) {
|
||||
return back()->with('error', __('Please configure your Telegram bot first.'));
|
||||
}
|
||||
|
||||
$telegramService = new TelegramService($telegramUser->bot_token);
|
||||
$result = $telegramService->sendMessage(
|
||||
$telegramUser->chat_id,
|
||||
'🧪 *Annunk Test Message*' . "\n\n"
|
||||
. '✅ Your bot is working correctly!'
|
||||
. "\n\n" . 'You will receive learning insights according to your schedule.'
|
||||
);
|
||||
|
||||
if ($result) {
|
||||
return back()->with('success', __('Test message sent successfully! Check your Telegram.'));
|
||||
}
|
||||
|
||||
return back()->with('error', __('Failed to send test message. Please check your bot configuration.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the user's Telegram bot.
|
||||
*/
|
||||
public function disconnect(): RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$user->telegramUsers()->delete();
|
||||
|
||||
return back()->with('success', __('Telegram bot disconnected successfully.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Validation\MathCaptchaRule;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
$this->generateMathCaptcha();
|
||||
|
||||
return view("auth.login");
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
"email" => ["required", "string", "email"],
|
||||
"password" => ["required", "string"],
|
||||
"math_captcha" => ["required", "string", new MathCaptchaRule()],
|
||||
]);
|
||||
|
||||
if (!Auth::attempt($request->only("email", "password"), $request->boolean("remember"))) {
|
||||
return back()->withErrors([
|
||||
"email" => __("auth.failed"),
|
||||
])->onlyInput("email");
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route("dashboard", absolute: false));
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard("web")->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
private function generateMathCaptcha(): void
|
||||
{
|
||||
$num1 = random_int(1, 10);
|
||||
$num2 = random_int(1, 10);
|
||||
$answer = $num1 + $num2;
|
||||
|
||||
session([
|
||||
"math_captcha_answer" => $answer,
|
||||
"math_captcha_question" => __("What is :num1 + :num2?", ["num1" => $num1, "num2" => $num2]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Validation\MathCaptchaRule;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
$this->generateMathCaptcha();
|
||||
|
||||
return view("auth.register");
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
"name" => ["required", "string", "max:255"],
|
||||
"email" => ["required", "string", "lowercase", "email", "max:255", "unique:".User::class],
|
||||
"password" => ["required", "confirmed", Rules\Password::defaults()],
|
||||
"math_captcha" => ["required", "string", new MathCaptchaRule()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
"name" => $request->name,
|
||||
"email" => $request->email,
|
||||
"password" => Hash::make($request->password),
|
||||
"is_approved" => false,
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route("dashboard", absolute: false));
|
||||
}
|
||||
|
||||
private function generateMathCaptcha(): void
|
||||
{
|
||||
$num1 = random_int(1, 10);
|
||||
$num2 = random_int(1, 10);
|
||||
$answer = $num1 + $num2;
|
||||
|
||||
session([
|
||||
"math_captcha_answer" => $answer,
|
||||
"math_captcha_question" => __("What is :num1 + :num2?", ["num1" => $num1, "num2" => $num2]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Validation\ContentFilterRule;
|
||||
use App\Models\ChatMessage;
|
||||
use App\Models\TokenUsage;
|
||||
use App\Services\AIService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule as ValidationRule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for the AI Chat feature.
|
||||
* Allows users to chat with registered AI models.
|
||||
*/
|
||||
class ChatController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the AI chat interface.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
// Get available AI providers from config
|
||||
$providers = config('services.ai.providers', []);
|
||||
|
||||
// Get user's chat history
|
||||
$chatHistory = ChatMessage::where('user_id', $user->id)
|
||||
->orderBy('created_at', 'asc')
|
||||
->take(50)
|
||||
->get();
|
||||
|
||||
// Get chat default provider/model from DB, fallback to config
|
||||
$defaultProvider = \App\Models\Setting::where('key', 'chat_default_provider')->value('value');
|
||||
$defaultModel = \App\Models\Setting::where('key', 'chat_default_model')->value('value');
|
||||
|
||||
if (!$defaultProvider) {
|
||||
$defaultProvider = config('services.ai.default_provider', 'openrouter');
|
||||
}
|
||||
|
||||
if (!$defaultModel) {
|
||||
$defaultModel = config('services.ai.providers.'.$defaultProvider.'.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
}
|
||||
|
||||
return view('dashboard.chat', compact(
|
||||
'providers',
|
||||
'chatHistory',
|
||||
'defaultProvider',
|
||||
'defaultModel'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the AI and get a response.
|
||||
*/
|
||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:4000', new ContentFilterRule()],
|
||||
]);
|
||||
|
||||
$message = trim($validated['message']);
|
||||
|
||||
// Use chat default provider/model from DB
|
||||
$defaultProvider = \App\Models\Setting::where('key', 'chat_default_provider')->value('value');
|
||||
$defaultModel = \App\Models\Setting::where('key', 'chat_default_model')->value('value');
|
||||
|
||||
if (!$defaultProvider) {
|
||||
$defaultProvider = config('services.ai.default_provider', 'openrouter');
|
||||
}
|
||||
|
||||
if (!$defaultModel) {
|
||||
$defaultModel = config('services.ai.providers.'.$defaultProvider.'.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
}
|
||||
|
||||
// Build conversation history for context
|
||||
$recentMessages = ChatMessage::where('user_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->take(10)
|
||||
->get()
|
||||
->reverse()
|
||||
->values();
|
||||
|
||||
$messages = [];
|
||||
foreach ($recentMessages as $chat) {
|
||||
$messages[] = ['role' => 'user', 'content' => $chat->message];
|
||||
if ($chat->response) {
|
||||
$messages[] = ['role' => 'assistant', 'content' => $chat->response];
|
||||
}
|
||||
}
|
||||
$messages[] = ['role' => 'user', 'content' => $message];
|
||||
|
||||
// Initialize AI service with selected provider and model
|
||||
$aiService = new AIService($defaultProvider, $defaultModel);
|
||||
|
||||
// Use the chat completion endpoint directly
|
||||
$result = $aiService->chat($messages);
|
||||
|
||||
if (!$result['success']) {
|
||||
return back()->with('error', __('Failed to get response: :error', [
|
||||
'error' => $result['error'] ?? __('Unknown error'),
|
||||
]));
|
||||
}
|
||||
|
||||
$response = $result['content'];
|
||||
$inputTokens = $result['input_tokens'] ?? 0;
|
||||
$outputTokens = $result['output_tokens'] ?? 0;
|
||||
$model = $defaultModel;
|
||||
$provider = $defaultProvider;
|
||||
|
||||
// Save chat message
|
||||
ChatMessage::create([
|
||||
'user_id' => $user->id,
|
||||
'message' => $message,
|
||||
'response' => $response,
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// Track token usage
|
||||
if ($inputTokens > 0 || $outputTokens > 0) {
|
||||
TokenUsage::create([
|
||||
'user_id' => $user->id,
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $inputTokens + $outputTokens,
|
||||
'model' => $model,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Message sent successfully!'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear chat history.
|
||||
*/
|
||||
public function destroy(Request $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
ChatMessage::where('user_id', $user->id)->delete();
|
||||
|
||||
return back()->with('success', __('Chat history cleared.'));
|
||||
}
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
/**
|
||||
* Base controller for Annunk application.
|
||||
*/
|
||||
class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Insight;
|
||||
use App\Models\Subject;
|
||||
use App\Models\TokenUsage;
|
||||
use App\Models\UserSubject;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for the main user dashboard.
|
||||
* Displays insights, statistics, token usage, and charts.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user dashboard with insights and statistics.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
// Basic statistics
|
||||
$subjectsCount = UserSubject::where('user_id', $user->id)->count();
|
||||
$activeSubjectsCount = UserSubject::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$insightsCount = Insight::where('user_id', $user->id)->count();
|
||||
|
||||
// Token usage statistics
|
||||
$totalInputTokens = TokenUsage::where('user_id', $user->id)->sum('input_tokens');
|
||||
$totalOutputTokens = TokenUsage::where('user_id', $user->id)->sum('output_tokens');
|
||||
$totalTokens = $totalInputTokens + $totalOutputTokens;
|
||||
|
||||
// Recent insights (last 10)
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, Insight> $recentInsights */
|
||||
$recentInsights = Insight::where('user_id', $user->id)
|
||||
->with('subject')
|
||||
->orderByDesc('created_at')
|
||||
->take(10)
|
||||
->get();
|
||||
|
||||
// Active subjects with schedules
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, UserSubject> $activeSubjects */
|
||||
$activeSubjects = UserSubject::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->with('subject')
|
||||
->get();
|
||||
|
||||
// Token usage chart data (last 30 days)
|
||||
$tokenChartData = $this->getTokenChartData($user->id);
|
||||
|
||||
// Insights per subject chart data
|
||||
$subjectChartData = $this->getSubjectChartData($user->id);
|
||||
|
||||
// Weekly activity data
|
||||
$weeklyActivity = $this->getWeeklyActivity($user->id);
|
||||
|
||||
return view('dashboard.index', compact(
|
||||
'subjectsCount',
|
||||
'activeSubjectsCount',
|
||||
'insightsCount',
|
||||
'totalInputTokens',
|
||||
'totalOutputTokens',
|
||||
'totalTokens',
|
||||
'recentInsights',
|
||||
'activeSubjects',
|
||||
'tokenChartData',
|
||||
'subjectChartData',
|
||||
'weeklyActivity'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token usage data for charts (last 30 days).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getTokenChartData(int $userId): array
|
||||
{
|
||||
$days = 30;
|
||||
$data = [];
|
||||
|
||||
for ($i = $days - 1; $i >= 0; $i--) {
|
||||
$date = now()->subDays($i)->format('Y-m-d');
|
||||
$dailyUsage = TokenUsage::where('user_id', $userId)
|
||||
->whereDate('created_at', $date)
|
||||
->selectRaw('COALESCE(SUM(input_tokens), 0) as input_tokens')
|
||||
->selectRaw('COALESCE(SUM(output_tokens), 0) as output_tokens')
|
||||
->first();
|
||||
|
||||
$data['labels'][] = now()->subDays($i)->format('d/m');
|
||||
$data['input'][] = (int) ($dailyUsage->input_tokens ?? 0);
|
||||
$data['output'][] = (int) ($dailyUsage->output_tokens ?? 0);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights per subject for chart data.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getSubjectChartData(int $userId): array
|
||||
{
|
||||
$insightsBySubject = Insight::where('user_id', $userId)
|
||||
->selectRaw('subject_id, COUNT(*) as total')
|
||||
->groupBy('subject_id')
|
||||
->with('subject')
|
||||
->get();
|
||||
|
||||
$data = [
|
||||
'labels' => [],
|
||||
'values' => [],
|
||||
];
|
||||
|
||||
foreach ($insightsBySubject as $item) {
|
||||
$data['labels'][] = $item->subject?->name ?? __('Unknown');
|
||||
$data['values'][] = (int) $item->total;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekly activity data (insights per day of week).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getWeeklyActivity(int $userId): array
|
||||
{
|
||||
$daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
$data = [
|
||||
'labels' => $daysOfWeek,
|
||||
'values' => [],
|
||||
];
|
||||
|
||||
foreach (range(0, 6) as $day) {
|
||||
$count = Insight::where('user_id', $userId)
|
||||
->whereRaw('DAYOFWEEK(created_at) = ?', [$day + 1])
|
||||
->count();
|
||||
$data['values'][] = $count;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LanguageController extends Controller
|
||||
{
|
||||
public function switch(string $locale): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$supportedLocales = ['pt_BR', 'en', 'es'];
|
||||
|
||||
if (!in_array($locale, $supportedLocales, true)) {
|
||||
$locale = config('app.locale', 'pt_BR');
|
||||
}
|
||||
|
||||
App::setLocale($locale);
|
||||
session(['locale' => $locale]);
|
||||
|
||||
if (Auth::check()) {
|
||||
Auth::user()->update(['locale' => $locale]);
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserConsent;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LegalController extends Controller
|
||||
{
|
||||
public function terms()
|
||||
{
|
||||
$currentVersion = config('legal.terms_version', '1.0.0');
|
||||
return view('legal.terms', compact('currentVersion'));
|
||||
}
|
||||
|
||||
public function privacy()
|
||||
{
|
||||
$currentVersion = config('legal.privacy_version', '1.0.0');
|
||||
return view('legal.privacy', compact('currentVersion'));
|
||||
}
|
||||
|
||||
public function cookies()
|
||||
{
|
||||
$currentVersion = config('legal.cookie_version', '1.0.0');
|
||||
return view('legal.cookies', compact('currentVersion'));
|
||||
}
|
||||
|
||||
public function accept(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'terms_version' => 'required|string',
|
||||
'privacy_version' => 'required|string',
|
||||
'cookie_version' => 'required|string',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'terms_version' => $request->terms_version,
|
||||
'privacy_version' => $request->privacy_version,
|
||||
'cookie_version' => $request->cookie_version,
|
||||
'accepted_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
return redirect()->intended('/dashboard')->with('success', __('legal.accepted_successfully'));
|
||||
}
|
||||
|
||||
public function history()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$consents = $user->consents()->orderByDesc('accepted_at')->paginate(20);
|
||||
return view('legal.history', compact('consents'));
|
||||
}
|
||||
|
||||
public function acceptCookieBanner(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'terms_version' => 'required|string',
|
||||
'privacy_version' => 'required|string',
|
||||
'cookie_version' => 'required|string',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'terms_version' => $request->terms_version,
|
||||
'privacy_version' => $request->privacy_version,
|
||||
'cookie_version' => $request->cookie_version,
|
||||
'accepted_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(): \Illuminate\View\View
|
||||
{
|
||||
$notifications = UserNotification::where('user_id', Auth::id())
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function markAsRead(UserNotification $notification): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$this->authorizeNotification($notification);
|
||||
|
||||
$notification->markAsRead();
|
||||
|
||||
return back()->with('success', __('Notification marked as read.'));
|
||||
}
|
||||
|
||||
public function markAllAsRead(): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
UserNotification::where('user_id', Auth::id())
|
||||
->where('is_read', false)
|
||||
->update(['is_read' => true, 'read_at' => now()]);
|
||||
|
||||
return back()->with('success', __('All notifications marked as read.'));
|
||||
}
|
||||
|
||||
private function authorizeNotification(UserNotification $notification): void
|
||||
{
|
||||
if ($notification->user_id !== Auth::id()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
public function edit()
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => Auth::user(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:20'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
'timezone' => ['required', 'string', 'max:50', 'timezone'],
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$user->update($request->only('name', 'email', 'phone', 'locale', 'timezone'));
|
||||
|
||||
if ($request->filled('password')) {
|
||||
$user->password = Hash::make($request->password);
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return back()->with('success', 'Profile updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
Auth::logout();
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('home');
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\SuperAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\SubscriptionPlan;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\UserSubscription;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BillingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$stats = [
|
||||
'total_plans' => SubscriptionPlan::count(),
|
||||
'active_plans' => SubscriptionPlan::where('is_active', true)->count(),
|
||||
'active_subscriptions' => UserSubscription::where('status', 'active')->count(),
|
||||
'total_revenue' => Invoice::where('status', 'paid')->sum('amount'),
|
||||
'pending_invoices' => Invoice::where('status', 'pending')->count(),
|
||||
'expiring_soon' => UserSubscription::where('status', 'active')
|
||||
->where('end_date', '<=', now()->addDays(7))
|
||||
->count(),
|
||||
];
|
||||
|
||||
$plans = SubscriptionPlan::withCount('userSubscriptions')->get();
|
||||
$subscriptions = UserSubscription::with(['user', 'plan'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('superadmin.billing.index', compact('stats', 'plans', 'subscriptions'));
|
||||
}
|
||||
|
||||
public function storePlan(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'duration_days' => ['required', 'integer', 'min:1'],
|
||||
'token_limit' => ['nullable', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
SubscriptionPlan::create($validated + ['is_active' => true]);
|
||||
|
||||
return back()->with('success', __('Plan created successfully.'));
|
||||
}
|
||||
|
||||
public function updatePlan(Request $request, SubscriptionPlan $plan): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'duration_days' => ['required', 'integer', 'min:1'],
|
||||
'token_limit' => ['nullable', 'integer', 'min:0'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$plan->update($validated);
|
||||
|
||||
return back()->with('success', __('Plan updated successfully.'));
|
||||
}
|
||||
|
||||
public function approvePayment(UserSubscription $subscription): RedirectResponse
|
||||
{
|
||||
$subscription->update(['payment_approved' => true, 'status' => 'active']);
|
||||
|
||||
// Create invoice
|
||||
$lastInvoice = Invoice::orderByDesc('id')->first();
|
||||
$nextNumber = $lastInvoice ? (int)filter_var($lastInvoice->invoice_number, FILTER_SANITIZE_NUMBER_INT) + 1 : 1001;
|
||||
|
||||
$invoice = Invoice::create([
|
||||
'user_id' => $subscription->user_id,
|
||||
'user_subscription_id' => $subscription->id,
|
||||
'invoice_number' => 'INV-' . $nextNumber . '/' . now()->year,
|
||||
'amount' => $subscription->plan->price,
|
||||
'currency' => $subscription->plan->currency,
|
||||
'status' => 'paid',
|
||||
'payment_method' => 'manual',
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
// Generate PDF
|
||||
$this->generateInvoicePdf($invoice);
|
||||
|
||||
// Notify user
|
||||
UserNotification::create([
|
||||
'user_id' => $subscription->user_id,
|
||||
'type' => 'payment_received',
|
||||
'title' => __('Payment Approved'),
|
||||
'message' => __('Your payment of :amount has been approved. Your subscription is active until :date.', [
|
||||
'amount' => 'R$ ' . number_format($subscription->plan->price, 2, ',', '.'),
|
||||
'date' => $subscription->end_date->format('d/m/Y'),
|
||||
]),
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Payment approved. Invoice generated and user notified.'));
|
||||
}
|
||||
|
||||
public function cancelSubscription(UserSubscription $subscription): RedirectResponse
|
||||
{
|
||||
$subscription->update(['status' => 'cancelled', 'cancelled_at' => now()]);
|
||||
|
||||
UserNotification::create([
|
||||
'user_id' => $subscription->user_id,
|
||||
'type' => 'admin_message',
|
||||
'title' => __('Subscription Cancelled'),
|
||||
'message' => __('Your subscription has been cancelled by the administrator.'),
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Subscription cancelled.'));
|
||||
}
|
||||
|
||||
public function invoices(): View
|
||||
{
|
||||
$invoices = Invoice::with(['user', 'subscription.plan'])
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('superadmin.billing.invoices', compact('invoices'));
|
||||
}
|
||||
|
||||
public function downloadInvoice(Invoice $invoice)
|
||||
{
|
||||
if ($invoice->pdf_path && Storage::exists($invoice->pdf_path)) {
|
||||
return Storage::download($invoice->pdf_path, "invoice-{$invoice->invoice_number}.pdf");
|
||||
}
|
||||
|
||||
// Generate on the fly
|
||||
$this->generateInvoicePdf($invoice);
|
||||
|
||||
return Storage::download($invoice->pdf_path, "invoice-{$invoice->invoice_number}.pdf");
|
||||
}
|
||||
|
||||
private function generateInvoicePdf(Invoice $invoice): void
|
||||
{
|
||||
$pdf = Pdf::loadView('pdf.invoice', compact('invoice'));
|
||||
$path = "invoices/invoice-{$invoice->invoice_number}.pdf";
|
||||
Storage::put($path, $pdf->output());
|
||||
$invoice->update(['pdf_path' => $path]);
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SuperAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Subject;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class SystemController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
"total_users" => \App\Models\User::count(),
|
||||
"active_users" => \App\Models\User::where("is_active", true)->count(),
|
||||
"pending_users" => \App\Models\User::where("is_approved", false)->count(),
|
||||
"total_subjects" => Subject::count(),
|
||||
"total_insights" => \App\Models\Insight::count(),
|
||||
];
|
||||
|
||||
$subjects = Subject::all();
|
||||
|
||||
$aiDefaultProvider = \App\Models\Setting::where('key', 'ai_default_provider')->value('value');
|
||||
$aiDefaultModel = \App\Models\Setting::where('key', 'ai_default_model')->value('value');
|
||||
|
||||
if (!$aiDefaultProvider) {
|
||||
$aiDefaultProvider = config('services.ai.default_provider', 'openrouter');
|
||||
}
|
||||
|
||||
if (!$aiDefaultModel) {
|
||||
$aiDefaultModel = config('services.ai.providers.'.$aiDefaultProvider.'.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
}
|
||||
|
||||
$chatDefaultProvider = \App\Models\Setting::where('key', 'chat_default_provider')->value('value');
|
||||
$chatDefaultModel = \App\Models\Setting::where('key', 'chat_default_model')->value('value');
|
||||
|
||||
if (!$chatDefaultProvider) {
|
||||
$chatDefaultProvider = config('services.ai.default_provider', 'openrouter');
|
||||
}
|
||||
|
||||
if (!$chatDefaultModel) {
|
||||
$chatDefaultModel = config('services.ai.providers.'.$chatDefaultProvider.'.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
}
|
||||
|
||||
return view("superadmin.system.index", compact(
|
||||
"stats",
|
||||
"subjects",
|
||||
"aiDefaultProvider",
|
||||
"aiDefaultModel",
|
||||
"chatDefaultProvider",
|
||||
"chatDefaultModel"
|
||||
));
|
||||
}
|
||||
|
||||
public function updateAiSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
"ai_default_provider" => ["required", "string", "max:50"],
|
||||
"ai_default_model" => ["required", "string", "max:255"],
|
||||
]);
|
||||
|
||||
$provider = $request->input("ai_default_provider");
|
||||
$model = $request->input("ai_default_model");
|
||||
|
||||
\App\Models\Setting::updateOrCreate(
|
||||
["key" => "ai_default_provider"],
|
||||
["value" => $provider, "type" => "string"]
|
||||
);
|
||||
|
||||
\App\Models\Setting::updateOrCreate(
|
||||
["key" => "ai_default_model"],
|
||||
["value" => $model, "type" => "string"]
|
||||
);
|
||||
|
||||
return back()->with("success", __("AI settings updated successfully."));
|
||||
}
|
||||
|
||||
public function updateChatSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
"chat_default_provider" => ["required", "string", "max:50"],
|
||||
"chat_default_model" => ["required", "string", "max:255"],
|
||||
]);
|
||||
|
||||
$provider = $request->input("chat_default_provider");
|
||||
$model = $request->input("chat_default_model");
|
||||
|
||||
\App\Models\Setting::updateOrCreate(
|
||||
["key" => "chat_default_provider"],
|
||||
["value" => $provider, "type" => "string"]
|
||||
);
|
||||
|
||||
\App\Models\Setting::updateOrCreate(
|
||||
["key" => "chat_default_model"],
|
||||
["value" => $model, "type" => "string"]
|
||||
);
|
||||
|
||||
return back()->with("success", __("Chat AI settings updated successfully."));
|
||||
}
|
||||
|
||||
public function createSubject(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
"name" => ["required", "string", "max:255"],
|
||||
"description" => ["nullable", "string"],
|
||||
"introduction" => ["nullable", "string"],
|
||||
]);
|
||||
|
||||
Subject::create([
|
||||
"user_id" => null,
|
||||
"name" => $request->name,
|
||||
"description" => $request->description,
|
||||
"introduction" => $request->introduction,
|
||||
"is_system" => true,
|
||||
"is_active" => true,
|
||||
]);
|
||||
|
||||
return back()->with("success", "System subject created successfully.");
|
||||
}
|
||||
|
||||
public function updateSubject(Request $request, Subject $subject)
|
||||
{
|
||||
$request->validate([
|
||||
"name" => ["required", "string", "max:255"],
|
||||
"description" => ["nullable", "string"],
|
||||
"introduction" => ["nullable", "string"],
|
||||
"is_active" => ["boolean"],
|
||||
]);
|
||||
|
||||
$subject->update($request->only("name", "description", "introduction", "is_active"));
|
||||
|
||||
return back()->with("success", "System subject updated successfully.");
|
||||
}
|
||||
|
||||
public function destroySubject(Subject $subject)
|
||||
{
|
||||
$subject->delete();
|
||||
|
||||
return back()->with("success", "System subject deleted successfully.");
|
||||
}
|
||||
|
||||
public function generateAiSubject(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
"theme" => ["required", "string", "max:255"],
|
||||
]);
|
||||
|
||||
$theme = $request->input("theme");
|
||||
|
||||
$prompt = "Crie um assunto de aprendizado e treinamento sobre o tema: {$theme}. Retorne APENAS um objeto JSON válido (sem texto adicional, sem markdown, sem code blocks) com os campos: name (string, máximo 255 caracteres, formato: 'Aprendizado e Treinamento de [Tema]'), description (string, máximo 1000 caracteres), introduction (string, máximo 5000 caracteres). Todo o conteúdo deve estar em Português do Brasil. O assunto deve ser educativo, prático e adequado para geração de insights de aprendizagem pela IA.";
|
||||
|
||||
/** @var \App\Services\AIService $ai */
|
||||
$ai = app(\App\Services\AIService::class);
|
||||
$result = $ai->generateInsight($theme, $prompt, ["language" => "pt_BR"]);
|
||||
|
||||
if (!$result["success"]) {
|
||||
return back()->with("error", __("Failed to generate subject: :error", ["error" => $result["error"] ?? __("Unknown error")]));
|
||||
}
|
||||
|
||||
$content = $result["content"];
|
||||
|
||||
$data = $this->extractJsonFromAiResponse($content);
|
||||
|
||||
if (!$data || !isset($data["name"])) {
|
||||
$data = [
|
||||
"name" => "Aprendizado e Treinamento de {$theme}",
|
||||
"description" => $theme,
|
||||
"introduction" => $content,
|
||||
];
|
||||
}
|
||||
|
||||
Subject::create([
|
||||
"user_id" => null,
|
||||
"name" => $data["name"],
|
||||
"description" => $data["description"] ?? null,
|
||||
"introduction" => $data["introduction"] ?? null,
|
||||
"is_system" => true,
|
||||
"is_active" => true,
|
||||
]);
|
||||
|
||||
return back()->with("success", __("System subject generated successfully."));
|
||||
}
|
||||
|
||||
private function extractJsonFromAiResponse(string $content): ?array
|
||||
{
|
||||
$content = trim($content);
|
||||
|
||||
if (str_starts_with($content, "```")) {
|
||||
$content = preg_replace("/^```(?:json)?\s*/", "", $content);
|
||||
$content = preg_replace("/\s*```$/", "", $content);
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (preg_match("/\{.*\}/s", $content, $matches)) {
|
||||
$data = json_decode($matches[0], true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\SuperAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Controller for SuperAdmin user management.
|
||||
* Handles listing, approving, editing, pausing, and managing users.
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of all users.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = User::where('id', '!=', Auth::id());
|
||||
|
||||
// Filter by status
|
||||
if ($request->has('status')) {
|
||||
match ($request->status) {
|
||||
'pending' => $query->where('is_approved', false),
|
||||
'approved' => $query->where('is_approved', true),
|
||||
'paused' => $query->where('is_active', false),
|
||||
'verified' => $query->whereNotNull('email_verified_at'),
|
||||
'unverified' => $query->whereNull('email_verified_at'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Search by name or email
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$users = $query->orderByDesc('created_at')->paginate(20);
|
||||
|
||||
// Stats
|
||||
$stats = [
|
||||
'total' => User::count(),
|
||||
'pending' => User::where('is_approved', false)->count(),
|
||||
'active' => User::where('is_active', true)->count(),
|
||||
'paused' => User::where('is_active', false)->count(),
|
||||
'unverified' => User::whereNull('email_verified_at')->count(),
|
||||
];
|
||||
|
||||
return view('superadmin.users.index', compact('users', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a user account.
|
||||
*/
|
||||
public function approve(User $user): RedirectResponse
|
||||
{
|
||||
$user->update(['is_approved' => true]);
|
||||
|
||||
return back()->with('success', __('User :name approved successfully.', ['name' => $user->name]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject/unapprove a user account.
|
||||
*/
|
||||
public function reject(User $user): RedirectResponse
|
||||
{
|
||||
$user->update(['is_approved' => false]);
|
||||
|
||||
return back()->with('success', __('User :name rejected successfully.', ['name' => $user->name]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user active status (pause/activate).
|
||||
*/
|
||||
public function toggleActive(User $user): RedirectResponse
|
||||
{
|
||||
$user->update(['is_active' => !$user->is_active]);
|
||||
|
||||
$status = $user->is_active ? __('activated') : __('paused');
|
||||
|
||||
return back()->with('success', __("User :name {$status}.", ['name' => $user->name]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark user email as verified without requiring email confirmation.
|
||||
*/
|
||||
public function verifyEmail(User $user): RedirectResponse
|
||||
{
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
return back()->with('info', __('User :name email is already verified.', ['name' => $user->name]));
|
||||
}
|
||||
|
||||
$user->markEmailAsVerified();
|
||||
|
||||
return back()->with('success', __('User :name email verified successfully.', ['name' => $user->name]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the edit form for a user.
|
||||
*/
|
||||
public function edit(User $user): View
|
||||
{
|
||||
return view('superadmin.users.edit', compact('user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user details.
|
||||
*/
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
|
||||
'role' => ['required', 'string', 'in:admin,superadmin'],
|
||||
'token_limit' => ['nullable', 'integer', 'min:0'],
|
||||
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'token_limit' => $validated['token_limit'] !== null && $validated['token_limit'] !== ''
|
||||
? (int) $validated['token_limit']
|
||||
: null,
|
||||
]);
|
||||
|
||||
if (!empty($validated['password'])) {
|
||||
$user->update(['password' => Hash::make($validated['password'])]);
|
||||
}
|
||||
|
||||
// Sync role
|
||||
$user->syncRoles([$validated['role']]);
|
||||
|
||||
return redirect()->route('superadmin.users.index')
|
||||
->with('success', __('User :name updated successfully.', ['name' => $user->name]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user account.
|
||||
*/
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$name = $user->name;
|
||||
$user->delete();
|
||||
|
||||
return back()->with('success', __('User :name deleted successfully.', ['name' => $name]));
|
||||
}
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\UserConsent;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserConsent
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$currentVersions = config('legal', []);
|
||||
|
||||
$latestConsent = $user->consents()->orderByDesc('accepted_at')->first();
|
||||
|
||||
if (!$latestConsent ||
|
||||
$latestConsent->terms_version !== ($currentVersions['terms_version'] ?? '1.0.0') ||
|
||||
$latestConsent->privacy_version !== ($currentVersions['privacy_version'] ?? '1.0.0') ||
|
||||
$latestConsent->cookie_version !== ($currentVersions['cookie_version'] ?? '1.0.0')
|
||||
) {
|
||||
if (!$request->is('legal/*') && !$request->is('logout') && !$request->is('/') && !$request->is('legal/accept') && !$request->is('login') && !$request->is('register') && !$request->is('language/*')) {
|
||||
return redirect()->route('legal.terms');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = Session::get('locale');
|
||||
|
||||
if (!$locale && Auth::check() && Auth::user()->locale) {
|
||||
$locale = Auth::user()->locale;
|
||||
}
|
||||
|
||||
if (!$locale) {
|
||||
$locale = config('app.locale', 'pt_BR');
|
||||
}
|
||||
|
||||
App::setLocale($locale);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Validation;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
|
||||
class ContentFilterRule implements Rule
|
||||
{
|
||||
private array $blockedPatterns;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->blockedPatterns = [
|
||||
'pornografia',
|
||||
'porn',
|
||||
'sexo',
|
||||
'sexual',
|
||||
'política',
|
||||
'politica',
|
||||
'partido',
|
||||
'eleição',
|
||||
'eleicao',
|
||||
'religião',
|
||||
'religiao',
|
||||
'crente',
|
||||
'ateu',
|
||||
'deus',
|
||||
'diabo',
|
||||
'preconceito',
|
||||
'racismo',
|
||||
'racista',
|
||||
'homofobia',
|
||||
'homofóbico',
|
||||
'transfobia',
|
||||
'xenofobia',
|
||||
'nazismo',
|
||||
'nazi',
|
||||
'fascismo',
|
||||
'fascista',
|
||||
'palavrão',
|
||||
'palavrao',
|
||||
'porra',
|
||||
'caralho',
|
||||
'merda',
|
||||
'puta',
|
||||
'cuzão',
|
||||
'cuzao',
|
||||
'viado',
|
||||
'bicha',
|
||||
'macaco',
|
||||
'senzala',
|
||||
'escravo',
|
||||
];
|
||||
}
|
||||
|
||||
public function passes($attribute, $value): bool
|
||||
{
|
||||
$normalized = mb_strtolower(trim($value));
|
||||
|
||||
foreach ($this->blockedPatterns as $pattern) {
|
||||
if (str_contains($normalized, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
return __('Your message contains prohibited content. Please do not use sensitive data, profanity, or topics such as pornography, politics, religion, or prejudice.');
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Validation;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class MathCaptchaRule implements Rule
|
||||
{
|
||||
public function passes($attribute, $value): bool
|
||||
{
|
||||
if (!Session::has('math_captcha_answer')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected = Session::get('math_captcha_answer');
|
||||
|
||||
Session::forget('math_captcha_answer');
|
||||
Session::forget('math_captcha_question');
|
||||
|
||||
return $value !== null && $value !== '' && (int) $value === (int) $expected;
|
||||
}
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
return __('The captcha answer is incorrect. Please try again.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\View\Composers;
|
||||
|
||||
use App\Models\UserNotification;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class NavigationComposer
|
||||
{
|
||||
public function compose(View $view): void
|
||||
{
|
||||
if (Auth::check()) {
|
||||
$view->with('unreadNotifications', UserNotification::where('user_id', Auth::id())
|
||||
->where('is_read', false)
|
||||
->orderByDesc('created_at')
|
||||
->get());
|
||||
} else {
|
||||
$view->with('unreadNotifications', collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\TelegramUser;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use App\Services\TelegramService;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendNewUserNotification
|
||||
{
|
||||
public function handle(Registered $event): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $event->user;
|
||||
|
||||
UserNotification::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'new_registration',
|
||||
'title' => __('Welcome to Annunk!'),
|
||||
'message' => __('Your account has been created successfully. Please wait for admin approval to access all features.'),
|
||||
'is_read' => false,
|
||||
]);
|
||||
|
||||
$superAdmin = User::where('email', 'marcocosta@gmx.com')->first();
|
||||
|
||||
if ($superAdmin) {
|
||||
UserNotification::create([
|
||||
'user_id' => $superAdmin->id,
|
||||
'type' => 'new_registration',
|
||||
'title' => __('New User Registration'),
|
||||
'message' => __('A new user has registered: :name (:email). Please review and approve.', [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
]),
|
||||
'is_read' => false,
|
||||
]);
|
||||
|
||||
$this->sendTelegramNotification($superAdmin, $user);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendTelegramNotification(User $superAdmin, User $newUser): void
|
||||
{
|
||||
$telegramUser = $superAdmin->telegramUsers()
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$telegramUser || !$telegramUser->isFullyConfigured()) {
|
||||
Log::info('Telegram notification skipped: SuperAdmin has no active Telegram bot configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$telegramService = new TelegramService($telegramUser->bot_token);
|
||||
$message = "👋 *New User Registration - Annunk*\n\n"
|
||||
. "A new user has registered:\n"
|
||||
. "Name: {$newUser->name}\n"
|
||||
. "Email: {$newUser->email}\n\n"
|
||||
. "Please review and approve the registration in the admin panel.\n\n"
|
||||
. "System: Annunk";
|
||||
|
||||
$sent = $telegramService->sendMessage($telegramUser->chat_id, $message);
|
||||
|
||||
if (!$sent) {
|
||||
Log::error('Failed to send Telegram notification for new registration', [
|
||||
'super_admin_id' => $superAdmin->id,
|
||||
'new_user_id' => $newUser->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Exception while sending Telegram notification for new registration', [
|
||||
'error' => $e->getMessage(),
|
||||
'super_admin_id' => $superAdmin->id,
|
||||
'new_user_id' => $newUser->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $message
|
||||
* @property string|null $response
|
||||
* @property string|null $provider
|
||||
* @property string|null $model
|
||||
* @property int $input_tokens
|
||||
* @property int $output_tokens
|
||||
* @property \Illuminate\Support\Carbon|null $sent_at
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
*/
|
||||
class ChatMessage extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'message',
|
||||
'response',
|
||||
'provider',
|
||||
'model',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'input_tokens' => 'integer',
|
||||
'output_tokens' => 'integer',
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns the chat message.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $name
|
||||
* @property string $pdf_path
|
||||
* @property string|null $extracted_content
|
||||
* @property string $processing_status
|
||||
* @property string|null $error_message
|
||||
* @property bool $is_active
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
* @property-read Collection<int, CustomSubjectQuestion> $questions
|
||||
*/
|
||||
class CustomSubject extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'pdf_path',
|
||||
'extracted_content',
|
||||
'processing_status',
|
||||
'error_message',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user who owns the custom subject.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the questions for this custom subject.
|
||||
*/
|
||||
public function questions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomSubjectQuestion::class);
|
||||
}
|
||||
}
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $custom_subject_id
|
||||
* @property int $user_id
|
||||
* @property string $question
|
||||
* @property string|null $answer
|
||||
* @property string|null $provider
|
||||
* @property string|null $model
|
||||
* @property int $input_tokens
|
||||
* @property int $output_tokens
|
||||
* @property \Illuminate\Support\Carbon $asked_at
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read CustomSubject $customSubject
|
||||
* @property-read User $user
|
||||
*/
|
||||
class CustomSubjectQuestion extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'custom_subject_id',
|
||||
'user_id',
|
||||
'question',
|
||||
'answer',
|
||||
'provider',
|
||||
'model',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'asked_at',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'asked_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the custom subject associated with this question.
|
||||
*/
|
||||
public function customSubject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomSubject::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who asked this question.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\MessageType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $subject_id
|
||||
* @property string $content
|
||||
* @property string $type
|
||||
* @property array|null $metadata
|
||||
* @property int $input_tokens
|
||||
* @property int $output_tokens
|
||||
* @property \Illuminate\Support\Carbon|null $sent_at
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
* @property-read Subject $subject
|
||||
*/
|
||||
class Insight extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'subject_id',
|
||||
'content',
|
||||
'type',
|
||||
'metadata',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'sent_at' => 'datetime',
|
||||
'type' => MessageType::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns the insight.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subject associated with the insight.
|
||||
*/
|
||||
public function subject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subject::class);
|
||||
}
|
||||
}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $subject_id
|
||||
* @property string $frequency
|
||||
* @property array|null $weekdays
|
||||
* @property string|null $preferred_time
|
||||
* @property bool $is_active
|
||||
* @property \Illuminate\Support\Carbon|null $last_sent_at
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
* @property-read Subject $subject
|
||||
*/
|
||||
class InsightSchedule extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'subject_id',
|
||||
'frequency',
|
||||
'weekdays',
|
||||
'preferred_time',
|
||||
'is_active',
|
||||
'last_sent_at',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'weekdays' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'last_sent_at' => 'datetime',
|
||||
'preferred_time' => 'datetime:H:i',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user associated with this schedule.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subject associated with this schedule.
|
||||
*/
|
||||
public function subject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subject::class);
|
||||
}
|
||||
}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'user_subscription_id', 'invoice_number', 'amount', 'currency', 'status', 'payment_method', 'paid_at', 'pdf_path'];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function subscription(): BelongsTo { return $this->belongsTo(UserSubscription::class, 'user_subscription_id'); }
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $key
|
||||
* @property string|null $value
|
||||
* @property string $type
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
*/
|
||||
class Setting extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
'type',
|
||||
];
|
||||
}
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int|null $user_id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string|null $introduction
|
||||
* @property bool $is_system
|
||||
* @property bool $is_active
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User|null $user
|
||||
* @property-read Collection<int, UserSubject> $userSubjects
|
||||
* @property-read Collection<int, Insight> $insights
|
||||
*/
|
||||
class Subject extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'description',
|
||||
'introduction',
|
||||
'message_type',
|
||||
'custom_instructions',
|
||||
'is_system',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_system' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user who created the subject.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user subjects associated with this subject.
|
||||
*/
|
||||
public function userSubjects(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserSubject::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the insights associated with this subject.
|
||||
*/
|
||||
public function insights(): HasMany
|
||||
{
|
||||
return $this->hasMany(Insight::class);
|
||||
}
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SubscriptionPlan extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'description', 'price', 'currency', 'duration_days', 'token_limit', 'is_active'];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'duration_days' => 'integer',
|
||||
'token_limit' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function userSubscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserSubscription::class);
|
||||
}
|
||||
}
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Represents a Telegram user connection with their own bot configuration.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $telegram_user_id
|
||||
* @property string|null $bot_token User's own Telegram bot token
|
||||
* @property string|null $bot_username User's own Telegram bot username
|
||||
* @property string|null $chat_id Telegram chat ID for sending messages
|
||||
* @property string|null $telegram_username
|
||||
* @property string|null $telegram_first_name
|
||||
* @property string|null $telegram_last_name
|
||||
* @property bool $is_active
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
*/
|
||||
class TelegramUser extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'telegram_user_id',
|
||||
'bot_token',
|
||||
'bot_username',
|
||||
'chat_id',
|
||||
'telegram_username',
|
||||
'telegram_first_name',
|
||||
'telegram_last_name',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user associated with this telegram user.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a fully configured bot.
|
||||
*/
|
||||
public function isFullyConfigured(): bool
|
||||
{
|
||||
return !empty($this->bot_token) && !empty($this->chat_id);
|
||||
}
|
||||
}
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $input_tokens
|
||||
* @property int $output_tokens
|
||||
* @property int $total_tokens
|
||||
* @property string|null $model
|
||||
* @property string|null $provider
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
*/
|
||||
class TokenUsage extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'total_tokens',
|
||||
'model',
|
||||
'provider',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'input_tokens' => 'integer',
|
||||
'output_tokens' => 'integer',
|
||||
'total_tokens' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user associated with this token usage.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property \Illuminate\Support\Carbon|null $email_verified_at
|
||||
* @property string $password
|
||||
* @property string|null $remember_token
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property string|null $phone
|
||||
* @property string $locale
|
||||
* @property bool $is_active
|
||||
* @property bool $is_approved
|
||||
* @property-read Collection<int, UserSubject> $userSubjects
|
||||
* @property-read Collection<int, Insight> $insights
|
||||
* @property-read Collection<int, InsightSchedule> $insightSchedules
|
||||
* @property-read Collection<int, TelegramUser> $telegramUsers
|
||||
* @property-read Collection<int, TokenUsage> $tokenUsages
|
||||
* @property-read Collection<int, Subject> $subjects
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasRoles;
|
||||
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'locale',
|
||||
'timezone',
|
||||
'is_active',
|
||||
'is_approved',
|
||||
'token_limit',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'token_limit' => 'integer',
|
||||
];
|
||||
|
||||
/** @var array<int, string> */
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user subjects for the user.
|
||||
*/
|
||||
public function userSubjects(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserSubject::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user interests for the user.
|
||||
*/
|
||||
public function userInterests(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserInterest::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the insights for the user.
|
||||
*/
|
||||
public function insights(): HasMany
|
||||
{
|
||||
return $this->hasMany(Insight::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the insight schedules for the user.
|
||||
*/
|
||||
public function insightSchedules(): HasMany
|
||||
{
|
||||
return $this->hasMany(InsightSchedule::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the telegram users for the user.
|
||||
*/
|
||||
public function telegramUsers(): HasMany
|
||||
{
|
||||
return $this->hasMany(TelegramUser::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token usages for the user.
|
||||
*/
|
||||
public function tokenUsages(): HasMany
|
||||
{
|
||||
return $this->hasMany(TokenUsage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subjects created by the user.
|
||||
*/
|
||||
public function subjects(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subject::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user consents.
|
||||
*/
|
||||
public function consents(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserConsent::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user subscriptions.
|
||||
*/
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\UserSubscription::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user notifications.
|
||||
*/
|
||||
public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\UserNotification::class);
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserConsent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'terms_version',
|
||||
'privacy_version',
|
||||
'cookie_version',
|
||||
'accepted_at',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $interest
|
||||
* @property-read \Illuminate\Support\Carbon|null $created_at
|
||||
* @property-read \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
*/
|
||||
class UserInterest extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'interest',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns the interest.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserNotification extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'type', 'title', 'message', 'is_read', 'read_at'];
|
||||
|
||||
protected $casts = [
|
||||
'is_read' => 'boolean',
|
||||
'read_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
|
||||
public function markAsRead(): void
|
||||
{
|
||||
$this->update(['is_read' => true, 'read_at' => now()]);
|
||||
}
|
||||
|
||||
public function typeIcon(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
'payment_due' => '💰',
|
||||
'payment_overdue' => '🔴',
|
||||
'subscription_expiring' => '⚠️',
|
||||
'subscription_expired' => '🚫',
|
||||
'payment_received' => '✅',
|
||||
'admin_message' => '📢',
|
||||
'new_registration' => '👋',
|
||||
default => '📌',
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Represents the relationship between a user and a subject they are subscribed to.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $subject_id
|
||||
* @property bool $is_active
|
||||
* @property string $frequency Frequency of insight delivery (daily, weekly, biweekly, monthly)
|
||||
* @property array|null $preferred_times Array of preferred delivery times in H:i format
|
||||
* @property string|null $preferred_time (deprecated) Single preferred time, use preferred_times instead
|
||||
* @property array|null $weekdays Days of the week for delivery (0=Sunday, 6=Saturday)
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
* @property-read Subject $subject
|
||||
*/
|
||||
class UserSubject extends Model
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'subject_id',
|
||||
'is_active',
|
||||
'frequency',
|
||||
'preferred_times',
|
||||
'weekdays',
|
||||
'insight_language',
|
||||
'delivery_channels',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'weekdays' => 'array',
|
||||
'preferred_times' => 'array',
|
||||
'delivery_channels' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get default delivery channels.
|
||||
*/
|
||||
public static function getDefaultChannels(): array
|
||||
{
|
||||
return ['dashboard'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific delivery channel is enabled.
|
||||
*/
|
||||
public function hasChannel(string $channel): bool
|
||||
{
|
||||
$channels = $this->delivery_channels ?? self::getDefaultChannels();
|
||||
return in_array($channel, $channels, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user associated with this user subject.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subject associated with this user subject.
|
||||
*/
|
||||
public function subject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subject::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preferred times as a formatted string for display.
|
||||
*/
|
||||
public function getFormattedTimesAttribute(): string
|
||||
{
|
||||
if (empty($this->preferred_times)) {
|
||||
return __('Any time');
|
||||
}
|
||||
|
||||
$times = collect($this->preferred_times)
|
||||
->map(fn ($time) => substr($time, 0, 5))
|
||||
->implode(', ');
|
||||
|
||||
return $times;
|
||||
}
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class UserSubscription extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'subscription_plan_id', 'start_date', 'end_date', 'status', 'payment_approved', 'cancelled_at'];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'datetime',
|
||||
'end_date' => 'datetime',
|
||||
'payment_approved' => 'boolean',
|
||||
'cancelled_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function plan(): BelongsTo { return $this->belongsTo(SubscriptionPlan::class, 'subscription_plan_id'); }
|
||||
public function invoices(): HasMany { return $this->hasMany(Invoice::class); }
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active' && $this->end_date > now();
|
||||
}
|
||||
|
||||
public function isExpiringSoon(int $days = 7): bool
|
||||
{
|
||||
return $this->isActive() && $this->end_date <= now()->addDays($days);
|
||||
}
|
||||
}
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Subject;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class SubjectPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Subject $subject): bool
|
||||
{
|
||||
return $subject->is_system || $subject->user_id === $user->id;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(User $user, Subject $subject): bool
|
||||
{
|
||||
return $subject->user_id === $user->id || $user->hasRole("superadmin");
|
||||
}
|
||||
|
||||
public function delete(User $user, Subject $subject): bool
|
||||
{
|
||||
return $subject->user_id === $user->id || $user->hasRole("superadmin");
|
||||
}
|
||||
}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserInterest;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Policy for UserInterest model.
|
||||
*/
|
||||
class UserInterestPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, UserInterest $userInterest): bool
|
||||
{
|
||||
return $user->id === $userInterest->user_id;
|
||||
}
|
||||
}
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Http\View\Composers\NavigationComposer;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
\Illuminate\Cookie\Middleware\EncryptCookies::except('cookie_consent_accepted');
|
||||
|
||||
View::composer('layouts.navigation', NavigationComposer::class);
|
||||
}
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use App\Models\Subject;
|
||||
use App\Models\UserInterest;
|
||||
use App\Policies\SubjectPolicy;
|
||||
use App\Policies\UserInterestPolicy;
|
||||
|
||||
/**
|
||||
* Application authentication and authorization service provider.
|
||||
*/
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* The model to policy mappings for the application.
|
||||
*/
|
||||
protected $policies = [
|
||||
Subject::class => SubjectPolicy::class,
|
||||
UserInterest::class => UserInterestPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any authentication / authorization services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerPolicies();
|
||||
|
||||
Gate::before(function ($user, $ability) {
|
||||
if ($user->hasRole('superadmin')) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\SendNewUserNotification;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected $listen = [
|
||||
Registered::class => [
|
||||
SendNewUserNotification::class,
|
||||
],
|
||||
];
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
Executable
+464
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\MessageType;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Multi-provider AI service supporting OpenAI-compatible APIs.
|
||||
* Providers: OpenRouter, OpenAI, StepFun, llama.cpp, QwenCloud, etc.
|
||||
*/
|
||||
class AIService
|
||||
{
|
||||
/** @var string The API base URL */
|
||||
protected string $baseUrl;
|
||||
|
||||
/** @var string The API key */
|
||||
protected string $apiKey;
|
||||
|
||||
/** @var string The model name */
|
||||
protected string $model;
|
||||
|
||||
/** @var string The provider name for logging */
|
||||
protected string $provider;
|
||||
|
||||
/** @var array Default headers */
|
||||
protected array $headers = [];
|
||||
|
||||
/**
|
||||
* Get the configured default AI provider/model for translations.
|
||||
*
|
||||
* @return array{provider: string, model: string}
|
||||
*/
|
||||
public static function getTranslationDefaults(): array
|
||||
{
|
||||
$provider = \App\Models\Setting::where('key', 'ai_default_provider')->value('value');
|
||||
$model = \App\Models\Setting::where('key', 'ai_default_model')->value('value');
|
||||
|
||||
$provider = $provider ?: config('services.ai.default_provider', 'openrouter');
|
||||
$model = $model ?: config('services.ai.providers.'.$provider.'.model', 'inclusionai/ling-3.0-tiny:free');
|
||||
|
||||
return [
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new AIService instance.
|
||||
*
|
||||
* @param string|null $provider Optional provider name. If null, uses default from config.
|
||||
* @param string|null $model Optional model name. If null, uses provider default.
|
||||
*/
|
||||
public function __construct(?string $provider = null, ?string $model = null)
|
||||
{
|
||||
$defaultProvider = config('services.ai.default_provider', 'openrouter');
|
||||
$defaults = self::getTranslationDefaults();
|
||||
|
||||
$this->loadProvider($provider ?? $defaults['provider'] ?? $defaultProvider);
|
||||
|
||||
if ($model) {
|
||||
$this->model = $model;
|
||||
} elseif (!empty($defaults['model'])) {
|
||||
$this->model = $defaults['model'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load provider configuration.
|
||||
*/
|
||||
protected function loadProvider(string $provider): void
|
||||
{
|
||||
$this->provider = $provider;
|
||||
$config = config("services.ai.providers.{$provider}", []);
|
||||
|
||||
$this->baseUrl = rtrim($config['base_url'] ?? '', '/') . '/';
|
||||
$this->apiKey = $config['api_key'] ?? '';
|
||||
$this->model = $config['model'] ?? 'gpt-3.5-turbo';
|
||||
|
||||
// Set default headers
|
||||
$this->headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
if (!empty($this->apiKey)) {
|
||||
// Some providers use 'Authorization: Bearer', others use 'x-api-key'
|
||||
$this->headers['Authorization'] = 'Bearer ' . $this->apiKey;
|
||||
}
|
||||
|
||||
// Provider-specific headers
|
||||
if ($provider === 'openrouter') {
|
||||
$this->headers['HTTP-Referer'] = config('app.url');
|
||||
$this->headers['X-Title'] = config('app.name');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a message based on the configured message type.
|
||||
*
|
||||
* @param string $subject The subject/topic for the message
|
||||
* @param string $userMessage Additional user instructions
|
||||
* @param array $context Optional context (language, introduction, etc.)
|
||||
* @param \App\Enums\MessageType|null $messageType The type of message to generate
|
||||
*/
|
||||
public function generateInsight(string $subject, string $userMessage, array $context = [], ?MessageType $messageType = null): array
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => __('API key not configured for provider: :provider', ['provider' => $this->provider]),
|
||||
];
|
||||
}
|
||||
|
||||
// Get language preference from context
|
||||
$language = $context['language'] ?? 'en';
|
||||
unset($context['language']);
|
||||
|
||||
$languageNames = [
|
||||
'pt_BR' => 'Brazilian Portuguese',
|
||||
'en' => 'English',
|
||||
'es' => 'Spanish',
|
||||
];
|
||||
$languageName = $languageNames[$language] ?? 'English';
|
||||
|
||||
$messageType = $messageType ?? MessageType::Insight;
|
||||
|
||||
$baseSystemPrompt = "You are an expert learning assistant for the subject: {$subject}. ";
|
||||
$languageInstruction = "CRITICAL LANGUAGE INSTRUCTION - You MUST follow this exactly:\n"
|
||||
. "Your entire response MUST be written ONLY in {$languageName} ({$language}).\n"
|
||||
. "Do NOT use any other language. Do NOT include English translations or explanations.\n"
|
||||
. "If the subject is a language course (like English), you may use that language as examples,\n"
|
||||
. "but all instructions, explanations, and tips MUST be in {$languageName}.\n"
|
||||
. "This is mandatory. Start your response directly with the content in {$languageName}.";
|
||||
|
||||
$systemPrompt = match ($messageType) {
|
||||
MessageType::LanguageLesson => $baseSystemPrompt
|
||||
. "You are acting as a specialized Language Teacher. "
|
||||
. "CRITICAL LANGUAGE RULE FOR LESSONS:\n"
|
||||
. "- Explanations, instructions, and tips MUST be written in {$languageName} ({$language}).\n"
|
||||
. "- BUT all target-language content (examples, vocabulary, practice sentences) MUST be in the language being taught: English.\n"
|
||||
. "- Do NOT translate English examples into {$languageName}. Keep English content pure.\n"
|
||||
. "- For Practical Examples and Practice Sentences: the ENGLISH sentence comes FIRST, then its Portuguese translation AFTER a hyphen.\n"
|
||||
. "- Example: 'The experiment shows that light travels faster than sound. - O experimento mostra que a luz viaja mais rápido que o som.'\n\n"
|
||||
. "ABSOLUTE RULES FOR PRACTICAL EXAMPLES AND PRACTICE SENTENCES:\n"
|
||||
. "1. The English sentence MUST come FIRST.\n"
|
||||
. "2. After the English sentence, add a hyphen and the Portuguese translation.\n"
|
||||
. "3. Do NOT write the Portuguese sentence first.\n"
|
||||
. "4. Do NOT repeat the same sentence twice.\n"
|
||||
. "5. Do NOT mix languages in the same sentence.\n\n"
|
||||
. "Create a complete language lesson structured exactly as follows:\n\n"
|
||||
. "## Insight\n"
|
||||
. "A brief, motivating introduction/insight about the language concept being taught. Write this in {$languageName}.\n\n"
|
||||
. "## Grammar Explanation\n"
|
||||
. "Explain ONE specific grammar point clearly and concisely. "
|
||||
. "Include rules, usage, and common mistakes. Write this in {$languageName}.\n\n"
|
||||
. "## Vocabulary Building\n"
|
||||
. "List 5-8 essential words/phrases IN ENGLISH. For each, provide:\n"
|
||||
. "- The word/phrase IN ENGLISH wrapped in a <span class=\"tts\" data-text=\"[English word/phrase]\">[English word/phrase]</span> tag\n"
|
||||
. "- Its meaning in {$languageName}\n"
|
||||
. "- An example sentence IN ENGLISH wrapped in a <span class=\"tts\" data-text=\"[English example sentence]\">[English example sentence]</span> tag\n\n"
|
||||
. "## Practical Examples\n"
|
||||
. "Provide 3-5 practical example sentences IN ENGLISH showing real-world usage. "
|
||||
. "Format each line EXACTLY as:\n"
|
||||
. "<span class=\"tts\" data-text=\"[English sentence]\">[English sentence]</span> - [Portuguese translation]\n"
|
||||
. "Do NOT put Portuguese first. English MUST be first.\n\n"
|
||||
. "## Practice Sentences\n"
|
||||
. "Provide 5-7 complete sentences IN ENGLISH for the user to practice speaking. "
|
||||
. "Format each line EXACTLY as:\n"
|
||||
. "/[Complete IPA for the full English sentence]/ <span class=\"tts\" data-text=\"[English sentence]\">[English sentence]</span> - [Portuguese translation]\n"
|
||||
. "The IPA must be COMPLETE for the FULL English sentence. Do NOT truncate IPA.\n\n"
|
||||
. "WRONG FORMAT (DO NOT USE):\n"
|
||||
. "- [Portuguese sentence] - [English sentence]\n"
|
||||
. "CORRECT FORMAT (MANDATORY):\n"
|
||||
. "- [English sentence] - [Portuguese sentence]\n\n"
|
||||
. "Make it engaging, structured, and suitable for self-study.\n\n"
|
||||
. $languageInstruction,
|
||||
|
||||
MessageType::GameSimulator => $baseSystemPrompt
|
||||
. "You are acting as a Game and Simulator Strategy Expert. "
|
||||
. "Create content that includes:\n"
|
||||
. "- An insight or strategic tip related to the subject\n"
|
||||
. "- Practical strategies and tactics\n"
|
||||
. "- Commands, shortcuts, or step-by-step instructions where applicable\n"
|
||||
. "- Scenarios or simulation tips\n"
|
||||
. "Make it actionable and engaging.\n\n"
|
||||
. $languageInstruction,
|
||||
|
||||
MessageType::TechnicalTeaching => $baseSystemPrompt
|
||||
. "You are acting as a Specialized Technical Teacher. "
|
||||
. "Create content that includes:\n"
|
||||
. "- An insight about the technical subject\n"
|
||||
. "- Description and practical use of components/tools\n"
|
||||
. "- Types of components/tools and their differences\n"
|
||||
. "- Service techniques and best practices\n"
|
||||
. "- Repair techniques and troubleshooting\n"
|
||||
. "- How to read technical diagrams/schematics\n"
|
||||
. "- Practical examples and applied exercises when relevant\n"
|
||||
. "Be direct, technical, and educational. Use clear step-by-step explanations.\n\n"
|
||||
. $languageInstruction,
|
||||
|
||||
default => $baseSystemPrompt
|
||||
. "Provide insightful, educational content. Be concise and practical.\n\n"
|
||||
. $languageInstruction,
|
||||
};
|
||||
|
||||
$userMessageWithLanguage = "[IMPORTANTE: Responda EXCLUSIVAMENTE em {$languageName}. "
|
||||
. "NÃO use inglês. NÃO traduza. NÃO misture idiomas. "
|
||||
. "Toda a resposta deve ser APENAS em {$languageName}.]\n\n"
|
||||
. $userMessage;
|
||||
|
||||
if ($messageType === MessageType::TechnicalTeaching && str_contains(strtolower($subject), 'eletrônica')) {
|
||||
$userMessageWithLanguage .= "\n\nSPECIAL INSTRUCTION: For each lesson, include explanation, technical description, and practical applications of specific electronic components. Examples: LM555, AT89C2051, BC557.\n";
|
||||
$userMessageWithLanguage .= "CRITICAL: Write ALL content in Brazilian Portuguese. Even technical terms must be explained in Portuguese. Do NOT write in English.";
|
||||
}
|
||||
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userMessageWithLanguage],
|
||||
];
|
||||
|
||||
// Add context if provided
|
||||
if (!empty($context)) {
|
||||
$contextStr = "Context information:\n";
|
||||
foreach ($context as $key => $value) {
|
||||
$contextStr .= "- {$key}: {$value}\n";
|
||||
}
|
||||
array_unshift($messages, ['role' => 'system', 'content' => $contextStr]);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders($this->headers)
|
||||
->timeout(120)
|
||||
->post($this->baseUrl . 'chat/completions', [
|
||||
'model' => $this->model,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (!$data || !isset($data['choices'][0]['message'])) {
|
||||
$errorMsg = $data['error']['message'] ?? ($data['error'] ?? __('Unknown API error'));
|
||||
Log::error("AI Service ({$this->provider}) error response", [
|
||||
'response' => $data,
|
||||
'subject' => $subject,
|
||||
'message_type' => $messageType->value,
|
||||
]);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$message = $data['choices'][0]['message'];
|
||||
$content = $message['content'] ?? $message['reasoning'] ?? null;
|
||||
|
||||
if ($content) {
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $content,
|
||||
'input_tokens' => $data['usage']['prompt_tokens'] ?? 0,
|
||||
'output_tokens' => $data['usage']['completion_tokens'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
Log::error("AI Service ({$this->provider}): Empty response content", [
|
||||
'response' => $data,
|
||||
'subject' => $subject,
|
||||
'message_type' => $messageType->value,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => __('The AI model returned an empty response. Please try again.'),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("AI Service ({$this->provider}) error: " . $e->getMessage(), [
|
||||
'subject' => $subject,
|
||||
'message_type' => $messageType->value,
|
||||
]);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current provider name.
|
||||
*/
|
||||
public function getProvider(): string
|
||||
{
|
||||
return $this->provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current model name.
|
||||
*/
|
||||
public function getModel(): string
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate text to the specified language using AI.
|
||||
*
|
||||
* @param string $text The text to translate
|
||||
* @param string $targetLanguage The target language code (pt_BR, en, es)
|
||||
* @param string|null $subject Optional subject context for better translation
|
||||
*/
|
||||
public function translate(string $text, string $targetLanguage, ?string $subject = null): array
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => __('API key not configured for provider: :provider', ['provider' => $this->provider]),
|
||||
];
|
||||
}
|
||||
|
||||
$languageNames = [
|
||||
'pt_BR' => 'Brazilian Portuguese',
|
||||
'en' => 'English',
|
||||
'es' => 'Spanish',
|
||||
];
|
||||
$languageName = $languageNames[$targetLanguage] ?? 'English';
|
||||
|
||||
$subjectContext = $subject ? "This is about: {$subject}.\n" : '';
|
||||
|
||||
$systemPrompt = "You are a professional translator. Translate the following text to {$languageName} ({$targetLanguage}).\n\n"
|
||||
. "RULES:\n"
|
||||
. "1. Translate ONLY the content, preserving the original meaning, tone, and structure.\n"
|
||||
. "2. Keep all markdown formatting, HTML tags, and special characters exactly as they are.\n"
|
||||
. "3. Do NOT add any explanations, notes, or translations of your own.\n"
|
||||
. "4. Do NOT translate code blocks, URLs, or technical terms that are commonly kept in English.\n"
|
||||
. "5. Return ONLY the translated text, nothing else.\n"
|
||||
. "6. The entire response must be in {$languageName}.";
|
||||
|
||||
$userMessage = "{$subjectContext}Translate this text to {$languageName}:\n\n{$text}";
|
||||
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userMessage],
|
||||
];
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders($this->headers)
|
||||
->timeout(120)
|
||||
->post($this->baseUrl . 'chat/completions', [
|
||||
'model' => $this->model,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (!$data || !isset($data['choices'][0]['message'])) {
|
||||
$errorMsg = $data['error']['message'] ?? ($data['error'] ?? __('Unknown API error'));
|
||||
Log::error("AI Service ({$this->provider}) translation error", [
|
||||
'response' => $data,
|
||||
'target_language' => $targetLanguage,
|
||||
]);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$message = $data['choices'][0]['message'];
|
||||
$content = $message['content'] ?? $message['reasoning'] ?? null;
|
||||
|
||||
if ($content) {
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $content,
|
||||
'input_tokens' => $data['usage']['prompt_tokens'] ?? 0,
|
||||
'output_tokens' => $data['usage']['completion_tokens'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
Log::error("AI Service ({$this->provider}): Empty translation response", [
|
||||
'response' => $data,
|
||||
'target_language' => $targetLanguage,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => __('The AI model returned an empty response. Please try again.'),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("AI Service ({$this->provider}) translation error: " . $e->getMessage(), [
|
||||
'target_language' => $targetLanguage,
|
||||
]);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message to the AI and get a response.
|
||||
*
|
||||
* @param array<int, array{role: string, content: string}> $messages
|
||||
*/
|
||||
public function chat(array $messages): array
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => __('API key not configured for provider: :provider', ['provider' => $this->provider]),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders($this->headers)
|
||||
->timeout(120)
|
||||
->post($this->baseUrl . 'chat/completions', [
|
||||
'model' => $this->model,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (!$data || !isset($data['choices'][0]['message'])) {
|
||||
$errorMsg = $data['error']['message'] ?? ($data['error'] ?? __('Unknown API error'));
|
||||
Log::error("AI Service ({$this->provider}) chat error", [
|
||||
'response' => $data,
|
||||
]);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$message = $data['choices'][0]['message'];
|
||||
$content = $message['content'] ?? $message['reasoning'] ?? null;
|
||||
|
||||
if ($content) {
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $content,
|
||||
'input_tokens' => $data['usage']['prompt_tokens'] ?? 0,
|
||||
'output_tokens' => $data['usage']['completion_tokens'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
Log::error("AI Service ({$this->provider}): Empty chat response", [
|
||||
'response' => $data,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => __('The AI model returned an empty response. Please try again.'),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("AI Service ({$this->provider}) chat error: " . $e->getMessage(), []);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Simple Markdown parser service for rendering AI-generated insights.
|
||||
* Converts basic Markdown syntax to HTML without external dependencies.
|
||||
*/
|
||||
class MarkdownService
|
||||
{
|
||||
/**
|
||||
* Convert Markdown text to HTML.
|
||||
*
|
||||
* @param string|null $text The markdown text to convert.
|
||||
* @return string The converted HTML.
|
||||
*/
|
||||
public static function parse(?string $text): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = $text;
|
||||
|
||||
// Preserve safe HTML tags before escaping (e.g., <span class="tts" data-text="...">)
|
||||
$placeholders = [];
|
||||
$html = preg_replace_callback(
|
||||
'/<span\s+class="tts"\s+data-text="([^"]+)"\s*>(.*?)<\/span>/',
|
||||
function ($matches) use (&$placeholders) {
|
||||
$key = '___TTS_' . count($placeholders) . '___';
|
||||
$placeholders[$key] = $matches[0];
|
||||
return $key;
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
$html = htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
// Restore preserved tags
|
||||
foreach ($placeholders as $key => $original) {
|
||||
$html = str_replace($key, $original, $html);
|
||||
}
|
||||
|
||||
// Headers (h1-h4)
|
||||
$html = preg_replace('/^### (.+)$/m', '<h4>$1</h4>', $html);
|
||||
$html = preg_replace('/^## (.+)$/m', '<h3>$1</h3>', $html);
|
||||
$html = preg_replace('/^# (.+)$/m', '<h2>$1</h2>', $html);
|
||||
|
||||
// Bold and italic
|
||||
$html = preg_replace('/\*\*\*(.+?)\*\*\*/', '<strong><em>$1</em></strong>', $html);
|
||||
$html = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $html);
|
||||
$html = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $html);
|
||||
|
||||
// Inline code
|
||||
$html = preg_replace('/`(.+?)`/', '<code>$1</code>', $html);
|
||||
|
||||
// Lines starting with dashes or asterisks (unordered list)
|
||||
$html = preg_replace('/^[-*] (.+)$/m', '<li>$1</li>', $html);
|
||||
|
||||
// Wrap consecutive <li> in <ul>
|
||||
$html = preg_replace('/((?:<li>.*?<\/li>\n?)+)/', '<ul>$1</ul>', $html);
|
||||
|
||||
// Lines starting with numbers (ordered list)
|
||||
$html = preg_replace('/^\d+\.\s(.+)$/m', '<li>$1</li>', $html);
|
||||
|
||||
// Horizontal rules
|
||||
$html = preg_replace('/^---$/m', '<hr>', $html);
|
||||
|
||||
// Blockquotes
|
||||
$html = preg_replace('/^> (.+)$/m', '<blockquote>$1</blockquote>', $html);
|
||||
|
||||
// Line breaks (double newline = paragraph)
|
||||
$paragraphs = preg_split('/\n\n+/', $html);
|
||||
$result = [];
|
||||
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
$paragraph = trim($paragraph);
|
||||
|
||||
if (empty($paragraph)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already wrapped in block-level tags
|
||||
if (preg_match('/^<(?:ul|ol|li|h[2-4]|hr|blockquote|div)/', $paragraph)) {
|
||||
$result[] = $paragraph;
|
||||
} else {
|
||||
$result[] = '<p>' . $paragraph . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown formatting, returning plain text.
|
||||
*
|
||||
* @param string|null $text The markdown text to strip.
|
||||
* @return string Plain text without markdown formatting.
|
||||
*/
|
||||
public static function strip(?string $text): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = preg_replace('/[*_~`#>`\[\]()!-]/', '', $text);
|
||||
$text = preg_replace('/\n{3,}/', "\n\n", $text);
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
}
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OpenRouterService
|
||||
{
|
||||
public function generateInsight(string $subject, string $userMessage, array $context = []): array
|
||||
{
|
||||
$apiKey = config("services.openrouter.api_key");
|
||||
$model = config("services.openrouter.model", "google/gemini-2.0-flash-exp:free");
|
||||
|
||||
// Get language preference from context or default to English
|
||||
$language = $context['language'] ?? 'en';
|
||||
unset($context['language']);
|
||||
|
||||
// Map language codes to full names
|
||||
$languageNames = [
|
||||
'pt_BR' => 'Brazilian Portuguese',
|
||||
'en' => 'English',
|
||||
'es' => 'Spanish',
|
||||
];
|
||||
$languageName = $languageNames[$language] ?? 'English';
|
||||
|
||||
if (!$apiKey) {
|
||||
return [
|
||||
"success" => false,
|
||||
"error" => "OpenRouter API key not configured",
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$systemPrompt = "You are an expert learning assistant for the subject: {$subject}. "
|
||||
. "Provide insightful, educational content. Be concise and practical.\n\n"
|
||||
. "CRITICAL LANGUAGE INSTRUCTION - You MUST follow this exactly:\n"
|
||||
. "Your entire response MUST be written ONLY in {$languageName} ({$language}).\n"
|
||||
. "Do NOT use any other language. Do NOT include English translations or explanations.\n"
|
||||
. "If the subject is a language course (like English), you may use that language as examples,\n"
|
||||
. "but all instructions, explanations, and tips MUST be in {$languageName}.\n"
|
||||
. "This is mandatory. Start your response directly with the tip in {$languageName}.";
|
||||
|
||||
// Append language instruction to user message as well
|
||||
$userMessageWithLanguage = "[IMPORTANTE: Responda EXCLUSIVAMENTE em {$languageName}. "
|
||||
. "NÃO use inglês. NÃO traduza. NÃO misture idiomas. "
|
||||
. "Toda a resposta deve ser APENAS em {$languageName}.]\n\n"
|
||||
. $userMessage;
|
||||
|
||||
$messages = [
|
||||
["role" => "system", "content" => $systemPrompt],
|
||||
["role" => "user", "content" => $userMessageWithLanguage],
|
||||
];
|
||||
|
||||
// Add context if provided
|
||||
if (!empty($context)) {
|
||||
$contextStr = "Context information:\n";
|
||||
foreach ($context as $key => $value) {
|
||||
$contextStr .= "- {$key}: {$value}\n";
|
||||
}
|
||||
array_unshift($messages, ["role" => "system", "content" => $contextStr]);
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
"Authorization" => "Bearer " . $apiKey,
|
||||
"Content-Type" => "application/json",
|
||||
"HTTP-Referer" => config("app.url"),
|
||||
"X-Title" => config("app.name"),
|
||||
])->timeout(120)->post("https://openrouter.ai/api/v1/chat/completions", [
|
||||
"model" => $model,
|
||||
"messages" => $messages,
|
||||
]);
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
if (!$data || !isset($data["choices"][0]["message"])) {
|
||||
$errorMsg = $data["error"]["message"] ?? "Unknown API error";
|
||||
Log::error("OpenRouter API error response", [
|
||||
"response" => $data,
|
||||
"subject" => $subject,
|
||||
]);
|
||||
return [
|
||||
"success" => false,
|
||||
"error" => $errorMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$message = $data["choices"][0]["message"];
|
||||
$content = $message["content"] ?? $message["reasoning"] ?? null;
|
||||
|
||||
if ($content) {
|
||||
return [
|
||||
"success" => true,
|
||||
"content" => $content,
|
||||
"input_tokens" => $data["usage"]["prompt_tokens"] ?? 0,
|
||||
"output_tokens" => $data["usage"]["completion_tokens"] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
Log::error("OpenRouter API: Empty response content", [
|
||||
"response" => $data,
|
||||
"subject" => $subject,
|
||||
]);
|
||||
|
||||
return [
|
||||
"success" => false,
|
||||
"error" => "The AI model returned an empty response. Please try again.",
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("OpenRouter API error: " . $e->getMessage());
|
||||
return [
|
||||
"success" => false,
|
||||
"error" => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Smalot\PdfParser\Parser;
|
||||
|
||||
/**
|
||||
* Service for extracting text content from PDF files.
|
||||
*/
|
||||
class PdfExtractionService
|
||||
{
|
||||
/**
|
||||
* Extract text from a PDF file.
|
||||
*
|
||||
* @param string $filePath The path to the PDF file
|
||||
* @return array{success: bool, content: string|null, error: string|null}
|
||||
*/
|
||||
public function extract(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'content' => null,
|
||||
'error' => 'PDF file not found.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$parser = new Parser();
|
||||
$pdf = $parser->parseFile($filePath);
|
||||
$text = $pdf->getText();
|
||||
|
||||
$text = trim($text);
|
||||
|
||||
if (empty($text)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'content' => null,
|
||||
'error' => 'The PDF appears to be empty or contains no extractable text.',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $text,
|
||||
'error' => null,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PDF extraction error: ' . $e->getMessage(), [
|
||||
'file' => $filePath,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'content' => null,
|
||||
'error' => 'Failed to extract text from PDF: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Service for sending messages via Telegram bot API.
|
||||
* Supports user-specific bot tokens for individual bot configuration.
|
||||
*/
|
||||
class TelegramService
|
||||
{
|
||||
/** @var string|null The Telegram bot API token */
|
||||
protected ?string $botToken = null;
|
||||
|
||||
/** @var string The Telegram API base URL */
|
||||
protected string $apiBase = 'https://api.telegram.org/bot';
|
||||
|
||||
/**
|
||||
* Create a new TelegramService instance.
|
||||
*
|
||||
* @param string|null $botToken Optional bot token. If null, uses default from config.
|
||||
*/
|
||||
public function __construct(?string $botToken = null)
|
||||
{
|
||||
$this->botToken = $botToken ?? config('services.telegram.bot_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a Telegram chat.
|
||||
*
|
||||
* @param string $chatId The chat ID to send the message to.
|
||||
* @param string $message The message text (supports Markdown).
|
||||
* @return bool True if the message was sent successfully.
|
||||
*/
|
||||
public function sendMessage(string $chatId, string $message): bool
|
||||
{
|
||||
if (empty($this->botToken)) {
|
||||
Log::error('TelegramService: No bot token configured.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = $this->apiBase . $this->botToken . '/sendMessage';
|
||||
|
||||
$client = new \GuzzleHttp\Client();
|
||||
$response = $client->post($url, [
|
||||
'json' => [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $message,
|
||||
'parse_mode' => 'Markdown',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if ($data && ($data['ok'] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::error('TelegramService: API error', [
|
||||
'response' => $data,
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TelegramService: Failed to send message', [
|
||||
'error' => $e->getMessage(),
|
||||
'chat_id' => $chatId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a webhook for the bot.
|
||||
*
|
||||
* @param string $url The webhook URL.
|
||||
* @return bool True if the webhook was set successfully.
|
||||
*/
|
||||
public function setWebhook(string $url): bool
|
||||
{
|
||||
if (empty($this->botToken)) {
|
||||
Log::error('TelegramService: No bot token configured for webhook.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$webhookUrl = $this->apiBase . $this->botToken . '/setWebhook';
|
||||
|
||||
$client = new \GuzzleHttp\Client();
|
||||
$response = $client->post($webhookUrl, [
|
||||
'json' => [
|
||||
'url' => $url,
|
||||
'allowed_updates' => ['message', 'callback_query'],
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
return $data && ($data['ok'] ?? false);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TelegramService: Failed to set webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bot information.
|
||||
*
|
||||
* @return array|null Bot info or null on failure.
|
||||
*/
|
||||
public function getMe(): ?array
|
||||
{
|
||||
if (empty($this->botToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = $this->apiBase . $this->botToken . '/getMe';
|
||||
|
||||
$client = new \GuzzleHttp\Client();
|
||||
$response = $client->get($url, ['timeout' => 10]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if ($data && ($data['ok'] ?? false)) {
|
||||
return $data['result'];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TelegramService: Failed to get bot info', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user