Compare commits

..

67 Commits

Author SHA1 Message Date
m.razaghi
b1c5345d22 Merge remote-tracking branch 'upstream-local/clean-main' into clean-main 2026-08-18 13:39:19 +03:30
m.razaghi
8b853a6f8f Updated VolumController.php 2026-08-18 13:39:05 +03:30
m.razaghi
16fd292976 fix bugs 2026-08-16 21:26:26 +03:30
m.razaghi
464ed71360 Fixed SubscribePlanController.php 2026-08-16 17:33:19 +03:30
m.razaghi
27b365c7e2 Updated user management 2026-08-16 17:15:47 +03:30
m.razaghi
452bd77f84 Updated user management 2026-08-16 16:52:42 +03:30
m.razaghi
f358c151af Updated HomeController.php 2026-08-16 15:17:57 +03:30
m.razaghi
94aab63db6 Updated HomeController.php 2026-08-16 15:11:11 +03:30
m.razaghi
5d392ff50b Added gold currency api 2026-08-15 23:47:21 +03:30
m.razaghi
4e5ef028dc Added gold currency api 2026-08-15 23:43:51 +03:30
m.razaghi
500ef019a3 Added gold currency api 2026-08-15 21:15:32 +03:30
m.razaghi
def51f6c63 fix free subscribe issue 2026-08-14 21:28:10 +03:30
m.razaghi
88813a4f86 fix free subscribe issue 2026-08-14 21:26:39 +03:30
m.razaghi
b041736a22 fix free subscribe issue 2026-08-14 21:15:46 +03:30
m.razaghi
c3f53f08f2 fix free subscribe issue 2026-08-14 21:12:19 +03:30
m.razaghi
ab0ab84a49 fix free subscribe issue 2026-08-14 21:10:12 +03:30
m.razaghi
dda5a87980 fix free subscribe issue 2026-08-14 21:06:13 +03:30
m.razaghi
fcaa51626d fix free subscribe issue 2026-08-14 21:05:21 +03:30
m.razaghi
d7f6131a33 fix free subscribe issue 2026-08-14 20:28:09 +03:30
m.razaghi
52a13dcf6e fix free subscribe issue 2026-08-14 20:22:53 +03:30
m.razaghi
ec76bc9737 fix free subscribe issue 2026-08-14 20:11:20 +03:30
m.razaghi
255889b202 fix free subscribe issue 2026-08-14 20:09:09 +03:30
m.razaghi
f801ca9eed Updated dashboard 2026-08-09 17:08:30 +03:30
m.razaghi
1be9ed98b8 Updated dashboard 2026-08-09 17:02:19 +03:30
m.razaghi
6c157e79e4 Updated dashboard 2026-08-09 16:59:21 +03:30
m.razaghi
47dfbba165 Updated dashboard 2026-08-09 16:53:42 +03:30
m.razaghi
bd4789a85c Merge remote-tracking branch 'upstream-local/clean-main' into clean-main 2026-08-08 17:44:14 +03:30
m.razaghi
01d6584e71 Added quiz features 2026-08-08 17:44:09 +03:30
m.razaghi
2d6766760f Fixed Quiz 2026-08-07 21:36:48 +03:30
m.razaghi
bb69e7f953 Fixed quiz 2026-08-07 21:30:03 +03:30
m.razaghi
d705c7c56d Fixed Quiz 2026-08-07 20:01:46 +03:30
m.razaghi
768a0aaeb0 Added quiz features 2026-08-06 14:31:46 +03:30
m.razaghi
0fffd2eb3d Added quiz features 2026-08-06 14:25:54 +03:30
m.razaghi
041a3e7bd5 Added quiz features 2026-08-06 14:20:38 +03:30
m.razaghi
af21c1c77e Updated dashboard 2026-08-05 13:56:46 +03:30
m.razaghi
5aa2447e23 Updated dashboard 2026-08-03 18:00:05 +03:30
m.razaghi
74ead9d0ed Updated dashboard 2026-08-03 17:32:04 +03:30
m.razaghi
aad799d29f Updated dashboard 2026-08-03 17:26:15 +03:30
m.razaghi
6e68ed45e4 Added ticketing feature 2026-07-31 22:20:40 +03:30
m.razaghi
067c1d2351 Added ticketing feature 2026-07-31 19:17:40 +03:30
m.razaghi
0ae3343b17 Added ticketing feature 2026-07-31 19:07:54 +03:30
m.razaghi
516bc4b26b Added ticketing feature 2026-07-31 18:54:09 +03:30
m.razaghi
d77605db75 fixed bugs 2026-07-24 18:44:02 +03:30
m.razaghi
a2799a8d1b - Added new apis 2026-07-22 10:29:01 +03:30
m.razaghi
86c68951bb Fixed api issue 2026-07-16 17:56:59 +03:30
m.razaghi
cf0f8f0c3f Fixed api issue 2026-07-16 17:51:10 +03:30
m.razaghi
209b1f2530 Fixed api issue 2026-07-16 14:58:11 +03:30
m.razaghi
a6ae5724f6 added foldering apis 2026-07-14 00:24:30 +03:30
m.razaghi
a173f17cae updated apis 2026-07-14 00:10:53 +03:30
m.razaghi
39e85df27a Fixed api issue 2026-07-13 09:12:26 +03:30
m.razaghi
2d6ba2a70d Fixed api issue 2026-07-13 09:03:47 +03:30
m.razaghi
abdfe190a7 Fixed api issue 2026-07-12 18:34:10 +03:30
m.razaghi
032ae6ebf8 Fixed api issue 2026-07-12 18:28:57 +03:30
m.razaghi
2474f268d1 Fixed api issue 2026-07-12 18:25:31 +03:30
m.razaghi
7f744beec4 Fixed api issue 2026-07-12 18:21:02 +03:30
m.razaghi
7a987a33c9 Fixed api issue 2026-07-12 18:18:02 +03:30
m.razaghi
7ca903ec3c Fixed api issue 2026-07-12 18:16:02 +03:30
m.razaghi
56e57719eb Fixed api issue 2026-07-12 18:09:33 +03:30
m.razaghi
680cf9c4fd Fixed api issue 2026-07-12 17:50:51 +03:30
m.razaghi
375e3b166c Fixed api issue 2026-07-12 17:42:34 +03:30
m.razaghi
2fab528d0a Fixed api issue 2026-07-12 17:38:35 +03:30
m.razaghi
677e101181 Fixed api issue 2026-07-12 17:33:31 +03:30
m.razaghi
e041b85e74 Merge remote-tracking branch 'upstream-local/clean-main' into clean-main 2026-07-12 17:24:03 +03:30
m.razaghi
aad67585ef Fixed api issue 2026-07-12 17:23:53 +03:30
4f94af7fc4 Merge pull request 'Fixed api issue' (#14) from new-api into clean-main
Reviewed-on: #14
2026-07-12 13:23:51 +00:00
ee8d9cdb8f Merge pull request 'Fixed api issue' (#13) from new-api into clean-main
Reviewed-on: #13
2026-07-12 13:19:50 +00:00
6062d0b3a0 Merge pull request 'Fixed api issue' (#12) from new-api into clean-main
Reviewed-on: #12
2026-07-12 13:13:17 +00:00
66 changed files with 3961 additions and 511 deletions

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Suggestion;
use App\Models\Ticket;
use App\Models\UserSubscriber;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;
class DashboardController extends Controller
{
public function index()
{
// 1. Calculate General Statistics
$totalUsers = User::count();
// 2. Safely Count Free Subscribers using the relationship
$freeSubscribers = UserSubscriber::where('expired_at', '>=', now())
->whereHas('subscribe', function (Builder $query) {
$query->where('is_free', true);
})
->distinct('user_id')
->count('user_id');
// 3. Get Paid Subscribers & Breakdown by Plan Type AND Market Provider
$rawBreakdown = UserSubscriber::where('user_subscribers.expired_at', '>=', now())
->join('subscribe_plans', 'user_subscribers.subscribe_plan_id', '=', 'subscribe_plans.id')
->where('subscribe_plans.is_free', false) // Replaced redundant whereHas for better performance
->selectRaw('subscribe_plans.name as plan_name, user_subscribers.market_provider, count(DISTINCT user_subscribers.user_id) as total')
->groupBy('subscribe_plans.name', 'user_subscribers.market_provider')
->get();
// 4. Safely group the results in PHP to fix split numbers (merging null, '', and 'web')
$paidBreakdown = $rawBreakdown->map(function ($item) {
$provider = strtolower(trim($item->market_provider));
// Normalize the provider names
if (empty($provider) || $provider === 'web') {
$providerName = 'سایت';
} elseif ($provider === 'bazar') {
$providerName = 'بازار';
} elseif ($provider === 'myket') {
$providerName = 'مایکت';
} else {
$providerName = ucfirst($provider); // Dynamic fallback for any future gateways
}
return [
'display_name' => $item->plan_name . ' (' . $providerName . ')',
'total' => $item->total
];
})->groupBy('display_name')->map(function ($group, $key) {
// Merge any split rows (e.g. 'web' + null) into one accurate total sum
return (object) [
'display_name' => $key,
'total' => $group->sum('total')
];
})->values();
// Total paid is the exact sum of the merged breakdown
$paidSubscribers = $paidBreakdown->sum('total');
// 4. Fetch Recent Data (Limit to 5)
$recentSuggestions = Suggestion::with('user')->latest()->take(5)->get();
// Order tickets by updated_at so newly replied tickets jump to the top
$recentTickets = Ticket::with('user')->latest('updated_at')->take(5)->get();
// 5. Prepare Chart Data (Strictly the last 7 calendar days)
$chartDates = [];
$newUsersData = [];
$paidSubsData = [];
$freeSubsData = [];
for ($i = 6; $i >= 0; $i--) {
$date = now()->subDays($i);
$dbDate = $date->format('Y-m-d');
// Format X-axis to Jalali for the chart labels
$chartDates[] = \Morilog\Jalali\Jalalian::fromCarbon($date)->format('Y/m/d');
// Count records for this specific exact date
$newUsersData[] = User::whereDate('created_at', $dbDate)->count();
$paidSubsData[] = UserSubscriber::whereDate('created_at', $dbDate)
->whereHas('subscribe', fn($q) => $q->where('is_free', false))->count();
$freeSubsData[] = UserSubscriber::whereDate('created_at', $dbDate)
->whereHas('subscribe', fn($q) => $q->where('is_free', true))->count();
}
return view('admin.dashboard.index', compact(
'totalUsers',
'paidSubscribers',
'freeSubscribers',
'paidBreakdown',
'recentSuggestions',
'recentTickets',
'chartDates',
'newUsersData',
'paidSubsData',
'freeSubsData'
));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\QuizCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class QuizCategoryController extends Controller
{
public function index()
{
// Load categories and count how many quizzes are in each
$categories = QuizCategory::withCount('quizzes')->latest()->get();
return view('admin.quiz-categories.index', compact('categories'));
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
]);
QuizCategory::create([
'title' => $request->title,
// Automatically generate a unique slug for URLs
'slug' => Str::slug($request->title) . '-' . time(),
'description' => $request->description,
'is_active' => $request->has('is_active'),
]);
return back()->with('success', 'دسته‌بندی با موفقیت ایجاد شد.');
}
public function destroy(QuizCategory $quiz_category)
{
// Check if the category has quizzes before deleting
if ($quiz_category->quizzes()->count() > 0) {
return back()->with('error', 'این دسته‌بندی شامل آزمون است و نمی‌تواند حذف شود. ابتدا آزمون‌ها را حذف کنید.');
}
$quiz_category->delete();
return back()->with('success', 'دسته‌بندی با موفقیت حذف شد.');
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Quiz;
use App\Models\QuizCategory;
use App\Models\QuizSubject;
use App\Models\Question;
use App\Models\QuestionOption;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class QuizController extends Controller
{
public function index()
{
$quizzes = Quiz::with('category')->withCount('questions')->latest()->paginate(15);
return view('admin.quizzes.index', compact('quizzes'));
}
public function create()
{
$categories = QuizCategory::where('is_active', true)->get();
return view('admin.quizzes.create', compact('categories'));
}
public function store(Request $request)
{
// 1. Validate only the text and number fields
$validated = $request->validate([
'title' => 'required|string|max:255',
'category_id' => 'required|exists:quiz_categories,id',
'year' => 'nullable|integer',
'type' => 'required|in:multiple_choice,descriptive,mixed',
'duration_minutes' => 'required|integer|min:1',
'description' => 'nullable|string',
]);
// 2. Safely handle checkboxes and generate slug
$validated['slug'] = \Illuminate\Support\Str::slug($validated['title']) . '-' . time();
$validated['has_negative_score'] = $request->has('has_negative_score');
$validated['is_free'] = $request->has('is_free');
$validated['is_published'] = $request->has('is_published');
// 3. Create the quiz
$quiz = Quiz::create($validated);
return redirect()->route('quizzes.questions.index', $quiz->id)
->with('success', 'آزمون با موفقیت ایجاد شد. اکنون می‌توانید سوالات را اضافه کنید.');
}
public function destroy(Quiz $quiz)
{
$quiz->delete();
return redirect()->route('quizzes.index')->with('success', 'آزمون حذف شد.');
}
// --- Questions Management ---
public function questions(Quiz $quiz)
{
$quiz->load(['questions.options', 'questions.subject']);
$subjects = QuizSubject::all();
return view('admin.quizzes.questions', compact('quiz', 'subjects'));
}
public function storeQuestion(Request $request, Quiz $quiz)
{
$request->validate([
'question_text' => 'required|string',
'type' => 'required|in:multiple_choice,descriptive',
'subject_id' => 'nullable|exists:quiz_subjects,id',
'score' => 'required|numeric|min:0.5',
'solution_explanation' => 'nullable|string',
// Options validation for multiple-choice questions
'options' => 'required_if:type,multiple_choice|array|min:4|max:4',
'options.*' => 'required_if:type,multiple_choice|string',
'correct_option' => 'required_if:type,multiple_choice|integer|between:0,3',
]);
$question = $quiz->questions()->create([
'subject_id' => $request->subject_id,
'question_text' => $request->question_text,
'type' => $request->type,
'score' => $request->score,
'solution_explanation' => $request->solution_explanation,
'order' => $quiz->questions()->count() + 1,
]);
// Save options if question is multiple choice
if ($request->type === 'multiple_choice' && $request->has('options')) {
foreach ($request->options as $index => $optionText) {
$question->options()->create([
'option_text' => $optionText,
'is_correct' => ($index == $request->correct_option),
]);
}
}
return back()->with('success', 'سوال با موفقیت ثبت شد.');
}
public function destroyQuestion(Question $question)
{
$question->delete();
return back()->with('success', 'سوال حذف شد.');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\QuizSubject;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class QuizSubjectController extends Controller
{
public function index()
{
// Load subjects and count how many questions belong to each
$subjects = QuizSubject::withCount('questions')->latest()->get();
return view('admin.quiz-subjects.index', compact('subjects'));
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
]);
QuizSubject::create([
'title' => $request->title,
// Generate a unique slug for potential API/URL usage
'slug' => Str::slug($request->title) . '-' . time(),
]);
return back()->with('success', 'درس با موفقیت اضافه شد.');
}
public function destroy(QuizSubject $quiz_subject)
{
// Prevent deletion if questions are already assigned to this subject
if ($quiz_subject->questions()->count() > 0) {
return back()->with('error', 'این درس دارای سوال است و نمی‌تواند حذف شود. ابتدا سوالات مربوطه را ویرایش یا حذف کنید.');
}
$quiz_subject->delete();
return back()->with('success', 'درس با موفقیت حذف شد.');
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Ticket;
use App\Models\TicketMessage;
use Illuminate\Http\Request;
class TicketController extends Controller
{
public function index(Request $request)
{
// Eager load the user who created the ticket
$query = Ticket::query()->with('user');
if ($request->filled('q')) {
$q = $request->q;
$query->where(function ($qry) use ($q) {
$qry->where('subject', 'like', "%{$q}%")
->orWhereHas('user', fn ($u) => $u->where('mobile', 'like', "%{$q}%")
->orWhere('name', 'like', "%{$q}%"));
});
}
// Optional: Filter by status (open, answered, user_reply, closed)
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$perPage = min(max((int) $request->input('per_page', 15), 10), 100);
// Order by updated_at so tickets with new replies jump to the top
$tickets = $query->latest('updated_at')->paginate($perPage)->withQueryString();
return view('admin.tickets.index', compact('tickets'));
}
public function show(Ticket $ticket)
{
// Load messages and the user belonging to the ticket
$ticket->load(['messages.user', 'user']);
return view('admin.tickets.show', compact('ticket'));
}
public function reply(Request $request, Ticket $ticket)
{
$request->validate([
'message' => 'required|string',
'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120',
]);
$attachmentPath = null;
if ($request->hasFile('attachment')) {
$attachmentPath = $request->file('attachment')->store('tickets', 'public');
}
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => auth()->id(), // This will be the Admin's ID
'message' => $request->message,
'attachment_path' => $attachmentPath,
]);
$ticket->update(['status' => 'answered']);
return redirect()->route('tickets.show', $ticket->id)
->with('success', 'پاسخ شما با موفقیت ارسال شد.');
}
public function close(Ticket $ticket)
{
$ticket->update(['status' => 'closed']);
return redirect()->route('tickets.index')
->with('success', 'تیکت با موفقیت بسته شد.');
}
}

View File

@@ -41,6 +41,58 @@ class UsersController extends Controller
return view('admin.users.create');
}
/**
* Manually assign a paid subscription to a user from the Admin Panel.
*/
public function addManualSubscription(Request $request)
{
// 1. Validate the input
$request->validate([
'user_id' => 'required|exists:users,id',
'subscribe_plan_id' => 'required|exists:subscribe_plans,id',
]);
$user = \App\Models\User::findOrFail($request->user_id);
$plan = \App\Models\SubscribePlan::findOrFail($request->subscribe_plan_id);
// Prevent admins from accidentally adding free plans through this paid-only route
if ($plan->is_free) {
return back()->with('error', 'برای این عملیات باید یک اشتراک پولی انتخاب کنید.');
}
// 2. Find the latest active PAID subscription to correctly stack the days
$latestPaidSub = $user->userSubscribers()
->where('expired_at', '>', now())
->whereHas('subscribe', function ($query) {
$query->where('is_free', false);
})
->latest('expired_at')
->first();
// If they have an active paid plan, start from its expiration. Otherwise, start from right now.
$startDate = $latestPaidSub ? $latestPaidSub->expired_at : now();
$expiredAt = $startDate->copy()->addDays($plan->expired_day);
// 3. CLEANUP: Instantly expire any active FREE plans so they don't pollute the database
$user->userSubscribers()
->where('expired_at', '>', now())
->whereHas('subscribe', function ($query) {
$query->where('is_free', true);
})
->update(['expired_at' => now()]);
// 4. Create the manual paid subscription
$user->userSubscribers()->create([
'subscribe_plan_id' => $plan->id,
'expired_at' => $expiredAt,
'market_provider' => 'admin', // This will cleanly display as "(Admin)" in your dashboard charts!
'is_free' => false,
]);
// Redirect back with a success message for your blade view
return back()->with('success', 'اشتراک پولی با موفقیت برای کاربر ثبت شد.');
}
public function store(CreateUserRequest $request)
{
$validated = $request->validated();
@@ -53,7 +105,30 @@ class UsersController extends Controller
public function edit(User $user)
{
return view('admin.users.update', compact('user'));
$paidPlans = \App\Models\SubscribePlan::where('is_free', false)
->where('is_active', true)
->get();
return view('admin.users.update', compact('user', 'paidPlans'));
}
/**
* Instantly revoke a user's active subscriptions (Set to 0 days)
*/
public function revokeSubscription(Request $request)
{
$request->validate([
'user_id' => 'required|exists:users,id',
]);
$user = \App\Models\User::findOrFail($request->user_id);
// Instantly expire ALL active subscriptions by setting them to 'now()'
$user->userSubscribers()
->where('expired_at', '>', now())
->update(['expired_at' => now()]);
return back()->with('success', 'اشتراک کاربر با موفقیت صفر (لغو) شد.');
}
public function update(UpdateUserRequest $request, User $user)

View File

@@ -96,7 +96,8 @@ class ArtController extends Controller
}
$art->is_like = $this->isLiked($art->id);
$art->note = Note::select('id', 'note', 'color_code', 'created_at')->where('user_id', auth()->user()->id)->where('art_id', $id)->get();
$art->note = Note::select('id', 'note', 'color_code', 'created_at')->where('user_id', auth()->id())->where('art_id', $id)->get();
$art->highlight = \App\Models\ArtHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')->where('user_id', auth()->id())->where('art_id', $id)->get();
$art->category = $law?->category?->name;
$art->route = array_values($this->route(Art::class, $art));
@@ -109,7 +110,8 @@ class ArtController extends Controller
if ($previousArt) {
$previousLaw = Law::find($previousArt->law_id);
$previousArt->is_like = $this->isLiked($previousArt->id);
$previousArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->user()->id)->where('art_id', $previousArt->id)->get();
$previousArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->id())->where('art_id', $previousArt->id)->get();
$previousArt->highlight = \App\Models\ArtHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')->where('user_id', auth()->id())->where('art_id', $previousArt->id)->get();
$previousArt->category = $previousLaw?->category?->name;
$previousArt->route = array_values($this->route(Art::class, $previousArt));
}
@@ -123,7 +125,8 @@ class ArtController extends Controller
if ($nextArt) {
$nextLaw = Law::find($nextArt->law_id);
$nextArt->is_like = $this->isLiked($nextArt->id);
$nextArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->user()->id)->where('art_id', $nextArt->id)->get();
$nextArt->note = Note::select('id', 'note', 'color_code')->where('user_id', auth()->id())->where('art_id', $nextArt->id)->get();
$nextArt->highlight = \App\Models\ArtHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')->where('user_id', auth()->id())->where('art_id', $nextArt->id)->get();
$nextArt->category = $nextLaw?->category?->name;
$nextArt->route = array_values($this->route(Art::class, $nextArt));
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\ArtHighlight;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class ArtHighlightController extends Controller
{
use BaseApiResponse;
public function create(Request $request)
{
$validated = $request->validate([
'art_id' => 'required',
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
ArtHighlight::create([
'user_id' => auth()->id(),
'art_id' => $validated['art_id'],
'start_index' => $validated['start_index'],
'end_index' => $validated['end_index'],
'selected_text' => $validated['selected_text'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
}
public function update($id, Request $request)
{
$validated = $request->validate([
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
$highlight = ArtHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->update($validated);
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
}
public function destroy($id)
{
$highlight = ArtHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->delete();
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\AuthRequest;
use App\Models\User;
use App\Models\UserCode;
use App\Models\SubscribePlan;
use App\Traits\BaseApiResponse;
use Carbon\Carbon;
use Illuminate\Http\Request;
@@ -91,6 +92,29 @@ class AuthController extends Controller
if ($latestCode->code === $request->code) {
$token = $user->createToken('mobile')->plainTextToken;
// --- AUTO 30-DAY SUBSCRIPTION LOGIC ---
// 1. Check if they have ever used a free plan before
$hasUsedFreePlan = $user->userSubscribers()
->whereHas('subscribe', function ($query) {
$query->where('is_free', true);
})->exists();
// 2. If not, find the active free plan and assign it
if (!$hasUsedFreePlan) {
$freePlan = SubscribePlan::where('is_free', true)->where('is_active', true)->first();
if ($freePlan) {
// We intentionally DO NOT pass 'is_free' => true here,
// matching SubscribePlanController to ensure the frontend hides the banner!
$user->userSubscribers()->create([
'subscribe_plan_id' => $freePlan->id,
'expired_at' => now()->addDays($freePlan->expired_day + 1),
]);
}
}
// ----------------------------------------------
return $this->success(['token' => $token], 'ورود موفقیت امیز بود');
} else {
return $this->failed([], 'کد ورود اشتباه است', 404);

View File

@@ -10,7 +10,6 @@ use App\Models\BylawNode;
use App\Models\BylawCodificationLink;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BylawApiController extends Controller
{
@@ -80,8 +79,19 @@ class BylawApiController extends Controller
$perPage = min(max((int) $request->input('per_page', 15), 10), 100);
$paginator = $query->paginate($perPage);
$items = collect($paginator->items())->map(function ($item) {
return $this->formatBylaw($item);
$docIds = collect($paginator->items())->pluck('id')->map(fn($id) => (int)$id)->toArray();
$nodesByDoc = collect();
if (!empty($docIds)) {
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
->orderBy('sort_order', 'asc')
->get()
->groupBy(fn($node) => (int)$node->document_id);
}
$items = collect($paginator->items())->map(function ($item) use ($nodesByDoc) {
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
return $this->formatBylaw($item, $itemNodes);
});
$responseData = [
@@ -110,27 +120,37 @@ class BylawApiController extends Controller
$formatted = $this->formatBylaw($bylaw);
$formatted['note'] = \App\Models\BylawNote::select('id', 'note', 'color_code', 'created_at')
->where('user_id', auth()->user()->id)
->where('bylaw_document_id', $bylaw->id)
->get();
$formatted['highlight'] = \App\Models\BylawHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
->where('user_id', auth()->id())
->where('bylaw_document_id', $bylaw->id)
->get();
if (!$formatted['is_locked']) {
$nodes = BylawNode::with('type')
$eloquentNodes = BylawNode::with('type')
->where('document_id', $bylaw->id)
->orderBy('sort_order', 'asc')
->get()
->map(function ($node) {
return [
'id' => $node->id,
'parent_id' => $node->parent_id,
'node_type' => optional($node->type)->name,
'node_identifier' => $node->node_identifier,
'title' => $node->title,
'node_content' => $node->node_content,
'sort_order' => $node->sort_order,
'valid_from' => $node->valid_from,
'valid_to' => $node->valid_to,
'node_status' => $node->node_status,
];
});
->get();
$formatted['nodes'] = $nodes->toArray();
$formatted['nodes'] = $eloquentNodes->map(function ($node) {
return [
'id' => $node->id,
'parent_id' => $node->parent_id,
'node_type' => optional($node->type)->name,
'node_identifier' => $node->node_identifier,
'title' => $node->title,
'node_content' => $node->node_content,
'sort_order' => $node->sort_order,
'valid_from' => $node->valid_from,
'valid_to' => $node->valid_to,
'node_status' => $node->node_status,
];
})->toArray();
$links = BylawCodificationLink::where('source_entity_type', 'bylaw_doc')
->where('source_entity_id', $bylaw->id)
@@ -175,7 +195,8 @@ class BylawApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id')
->from('bylaw_nodes')
->where('node_content', 'LIKE', "%{$searchTerm}%");
->where('node_content', 'LIKE', "%{$searchTerm}%")
->orWhere('title', 'LIKE', "%{$searchTerm}%");
});
});
}
@@ -193,8 +214,19 @@ class BylawApiController extends Controller
$perPage = min(max((int) $request->input('per_page', 15), 10), 100);
$paginator = $query->paginate($perPage);
$items = collect($paginator->items())->map(function ($item) {
return $this->formatBylaw($item);
$docIds = collect($paginator->items())->pluck('id')->map(fn($id) => (int)$id)->toArray();
$nodesByDoc = collect();
if (!empty($docIds)) {
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
->orderBy('sort_order', 'asc')
->get()
->groupBy(fn($node) => (int)$node->document_id);
}
$items = collect($paginator->items())->map(function ($item) use ($nodesByDoc) {
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
return $this->formatBylaw($item, $itemNodes);
});
$responseData = [
@@ -224,18 +256,32 @@ class BylawApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id')
->from('bylaw_nodes')
->where('node_content', 'LIKE', "%{$searchTerm}%");
->where('node_content', 'LIKE', "%{$searchTerm}%")
->orWhere('title', 'LIKE', "%{$searchTerm}%");
});
});
}
$count = $query->count();
$items = $query->orderBy('approval_date', 'desc')->take(5)->get()->map(function ($item) {
return $this->formatBylaw($item);
$items = $query->orderBy('approval_date', 'desc')->take(5)->get();
$docIds = $items->pluck('id')->map(fn($id) => (int)$id)->toArray();
$nodesByDoc = collect();
if (!empty($docIds)) {
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
->orderBy('sort_order', 'asc')
->get()
->groupBy(fn($node) => (int)$node->document_id);
}
$formattedItems = $items->map(function ($item) use ($nodesByDoc) {
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
return $this->formatBylaw($item, $itemNodes);
});
return $this->success([
'items' => $items,
'items' => $formattedItems,
'count' => $count
], 'Success', 'Fast Search Results');
}
@@ -290,38 +336,66 @@ class BylawApiController extends Controller
->orderBy('id', 'asc')
->first();
$results = array_values(array_filter([
$previousItem ? $this->formatBylaw($previousItem) : null,
$this->formatBylaw($current),
$nextItem ? $this->formatBylaw($nextItem) : null
]));
$itemsToFormat = array_filter([$previousItem, $current, $nextItem]);
$docIds = collect($itemsToFormat)->pluck('id')->map(fn($id) => (int)$id)->toArray();
$nodesByDoc = collect();
if (!empty($docIds)) {
$nodesByDoc = BylawNode::whereIn('document_id', $docIds)
->orderBy('sort_order', 'asc')
->get()
->groupBy(fn($node) => (int)$node->document_id);
}
$results = collect($itemsToFormat)->map(function($item) use ($nodesByDoc) {
$itemNodes = $nodesByDoc->get((int)$item->id, collect());
$formatted = $this->formatBylaw($item, $itemNodes);
$formatted['note'] = \App\Models\BylawNote::select('id', 'note', 'color_code', 'created_at')
->where('user_id', auth()->user()->id)
->where('bylaw_document_id', $item->id)
->get();
$formatted['highlight'] = \App\Models\BylawHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
->where('user_id', auth()->id())
->where('bylaw_document_id', $item->id)
->get();
return $formatted;
})->values()->toArray();
return $this->success($results, 'Success', 'Pager Window Details');
}
private function isLockedForCurrentUser($item): bool
{
return ! auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
if (auth()->check() && auth()->user() && method_exists(auth()->user(), 'isSubscriber')) {
return !auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
}
return (bool) ($item->is_locked ?? false);
}
private function formatBylaw($item)
private function formatBylaw($item, $itemNodes = null)
{
$isLocked = $this->isLockedForCurrentUser($item);
$description = $item->description;
if ($itemNodes === null) {
$itemNodes = BylawNode::where('document_id', $item->id)->orderBy('sort_order', 'asc')->get();
}
// Check if description is practically empty (null, empty string, or just empty WYSIWYG HTML like <p></p>)
$cleanDesc = trim(strip_tags($description ?? ''));
$nodeContentAsDescription = collect($itemNodes)->pluck('node_content')
->map(function ($text) {
$safeText = trim((string)$text);
return mb_convert_encoding($safeText, 'UTF-8', 'UTF-8');
})
->filter(function ($text) {
return $text !== '';
})
->implode("\n\n");
// Direct Query Fallback: Fetch node_content manually via DB facade to bypass any Model restrictions
if (empty($cleanDesc)) {
$nodeContents = DB::table('bylaw_nodes')
->where('document_id', $item->id)
->orderBy('sort_order', 'asc')
->pluck('node_content')
->toArray();
$description = implode("\n\n", array_filter($nodeContents));
if (empty($nodeContentAsDescription)) {
$nodeContentAsDescription = mb_convert_encoding(trim((string)$item->description), 'UTF-8', 'UTF-8');
}
return [
@@ -341,8 +415,9 @@ class BylawApiController extends Controller
'execution_date' => $item->execution_date,
'bylaw_type' => $item->bylaw_type,
'global_status' => $item->global_status,
'pdf_file_url' => $isLocked ? null : ($item->pdf_file_path ? asset('storage/' . $item->pdf_file_path) : null),
'description' => $isLocked ? null : $description,
'pdf_file_url' => $isLocked ? null : ($item->pdf_file_path ? asset(str_replace('storage/storage/', 'storage/', 'storage/' . $item->pdf_file_path)) : null),
'description' => $isLocked ? null : $nodeContentAsDescription,
'debug_node_count' => collect($itemNodes)->count(),
'is_locked' => $isLocked,
];
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\BylawHighlight;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class BylawHighlightController extends Controller
{
use BaseApiResponse;
public function create(Request $request)
{
$validated = $request->validate([
'bylaw_id' => 'required', // Clean request parameter
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
BylawHighlight::create([
'user_id' => auth()->id(),
'bylaw_document_id' => $validated['bylaw_id'], // Mapped to DB column
'start_index' => $validated['start_index'],
'end_index' => $validated['end_index'],
'selected_text' => $validated['selected_text'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
}
public function update($id, Request $request)
{
$validated = $request->validate([
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
$highlight = BylawHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->update($validated);
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
}
public function destroy($id)
{
$highlight = BylawHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->delete();
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\BylawNote;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class BylawNoteController extends Controller
{
use BaseApiResponse;
public function create(Request $request)
{
$validated = $request->validate([
'bylaw_id' => 'required', // Cleaned up for the frontend!
'note' => 'required',
'color_code' => 'nullable',
]);
BylawNote::query()->create([
'user_id' => auth()->id(),
'bylaw_document_id' => $validated['bylaw_id'], // Maps perfectly to the DB
'note' => $validated['note'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'موفق', 'یادداشت با موفقیت اضافه شد');
}
public function update($id, Request $request)
{
$validated = $request->validate([
'note' => 'required',
'color_code' => 'nullable',
]);
$note = BylawNote::where('user_id', auth()->id())->findOrFail($id);
$note->update([
'note' => $validated['note'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'بروزرسانی', 'یادداشت با موفقیت بروزرسانی شد');
}
public function destroy($id)
{
$note = BylawNote::where('user_id', auth()->id())->findOrFail($id);
$note->delete();
return $this->success([], 'حذف', 'یادداشت با موفقیت حذف شد');
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\GoldCurrency;
use App\Traits\BaseApiResponse;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CurrencyController extends Controller
{
use BaseApiResponse;
public function goldPrices()
{
// 1. Check if we already updated the database today
$latestUpdate = GoldCurrency::max('updated_at');
$isUpdatedToday = $latestUpdate && Carbon::parse($latestUpdate)->isToday();
// 2. If not updated today, call the API
if (!$isUpdatedToday) {
$this->fetchAndCacheGoldPrices();
}
// 3. Fetch data from our own database
$goldCurrencies = GoldCurrency::all();
// 4. Debug check: If it is STILL empty, the API failed and we have no backup data yet.
if ($goldCurrencies->isEmpty()) {
// FIX: Pass an array to format the 'alert' as a JSON Object for Kotlin!
return $this->failed(null, [
'title' => 'خطا',
'message' => 'ارتباط با سرور قیمت‌ها برقرار نشد. لطفا بعدا تلاش کنید.'
]);
}
// 5. Map the data
$mappedCurrencies = $goldCurrencies->map(function ($currency) {
return [
'change_percent' => $currency->change_percent,
'change_value' => $currency->change_value,
'date' => $currency->updated_at->format('Y-m-d'),
'name' => $currency->name,
'name_en' => $currency->name_en,
'price' => $currency->price,
'symbol' => $currency->symbol,
'time' => $currency->updated_at->format('H:i:s'),
'time_unix' => $currency->time_unix,
'unit' => $currency->unit,
];
});
return $this->success([
'gold' => $mappedCurrencies
], 'موفق', 'لیست قیمت طلا و سکه دریافت شد.');
}
/**
* Private helper to fetch from the external API and save to DB
*/
private function fetchAndCacheGoldPrices()
{
try {
$url = 'https://BrsApi.ir/Api/Market/Gold_Currency.php?key=B727Ue3kBrbtCwHR3BmSVZKRRNF1sllp';
// Added withoutVerifying() for Iranian SSL issues and a User-Agent to bypass firewalls
$response = Http::withoutVerifying()
->withHeaders([
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept' => 'application/json',
])
->timeout(15)
->get($url);
if ($response->successful()) {
$data = $response->json();
if (isset($data['gold']) && is_array($data['gold'])) {
foreach ($data['gold'] as $item) {
GoldCurrency::updateOrCreate(
['name_en' => $item['name_en']],
[
'name' => $item['name'],
'price' => (int) $item['price'],
'change_percent' => (float) $item['change_percent'],
'change_value' => (int) $item['change_value'],
'symbol' => $item['symbol'] ?? null,
'unit' => $item['unit'] ?? null,
'time_unix' => (int) $item['time_unix'],
]
);
}
}
} else {
Log::warning('BrsApi returned an error status: ' . $response->status() . ' - ' . $response->body());
}
} catch (\Exception $e) {
Log::error('Failed to fetch from BrsApi: ' . $e->getMessage());
}
}
}

View File

@@ -5,8 +5,12 @@ namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\Folder;
use App\Models\FolderArt;
use App\Models\FolderBylaw;
use App\Models\FolderJudicialPrecedent;
use App\Models\FolderLegalOpinion;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class FolderController extends Controller
{
@@ -14,12 +18,18 @@ class FolderController extends Controller
public function index()
{
$folder = Folder::query()->where('user_id', auth()->id())->get()
// Eager load everything to prevent N+1 Database issues
$folder = Folder::with(['arts', 'bylaws', 'judicialPrecedents', 'legalOpinions'])
->where('user_id', auth()->id())
->get()
->map(function ($q) {
return [
'id' => $q->id,
'name' => $q->name,
'count_of_arts' => $q->arts->count(),
'count_of_bylaws' => $q->bylaws->count(),
'count_of_judicials' => $q->judicialPrecedents->count(),
'count_of_opinions' => $q->legalOpinions->count(),
'created_at' => $q->created_at,
];
});
@@ -34,7 +44,7 @@ class FolderController extends Controller
]);
Folder::create([
'user_id' => auth()->user()->id,
'user_id' => auth()->id(),
'name' => $validted['name'],
]);
@@ -44,40 +54,72 @@ class FolderController extends Controller
public function assign(Request $request)
{
$validted = $request->validate([
'folder_id' => 'required',
'art_id' => 'required',
'folder_id' => 'required',
'art_id' => 'nullable',
'bylaw_document_id' => 'nullable',
'judicial_precedent_id' => 'nullable',
'legal_opinion_id' => 'nullable',
]);
if (Folder::query()->where('id', $validted['folder_id'])->count() == 0) {
return $this->failed([], 'پوشه مورد نظر یافت نشد', 404);
// 🚨 SECURITY FIX: Check if the folder exists AND belongs to the logged-in user
if (Folder::where('id', $validted['folder_id'])->where('user_id', auth()->id())->count() == 0) {
return $this->failed([], 'پوشه مورد نظر یافت نشد یا شما دسترسی ندارید', 404);
}
if (FolderArt::query()->where('folder_id', $validted['folder_id'])->where('art_id', $validted['art_id'])->count() > 0) {
return $this->failed([], 'ارت مورد نظر قبلا اضافه شده است', 400);
if ($request->filled('art_id')) {
if (FolderArt::where('folder_id', $validted['folder_id'])->where('art_id', $validted['art_id'])->exists()) {
return $this->failed([], 'ارت مورد نظر قبلا اضافه شده است', 400);
}
FolderArt::create(['folder_id' => $validted['folder_id'], 'art_id' => $validted['art_id']]);
}
elseif ($request->filled('bylaw_document_id')) {
if (FolderBylaw::where('folder_id', $validted['folder_id'])->where('bylaw_document_id', $validted['bylaw_document_id'])->exists()) {
return $this->failed([], 'آیین‌نامه قبلا اضافه شده است', 400);
}
FolderBylaw::create(['folder_id' => $validted['folder_id'], 'bylaw_document_id' => $validted['bylaw_document_id']]);
}
elseif ($request->filled('judicial_precedent_id')) {
if (FolderJudicialPrecedent::where('folder_id', $validted['folder_id'])->where('judicial_precedent_id', $validted['judicial_precedent_id'])->exists()) {
return $this->failed([], 'رأی قبلا اضافه شده است', 400);
}
FolderJudicialPrecedent::create(['folder_id' => $validted['folder_id'], 'judicial_precedent_id' => $validted['judicial_precedent_id']]);
}
elseif ($request->filled('legal_opinion_id')) {
if (FolderLegalOpinion::where('folder_id', $validted['folder_id'])->where('legal_opinion_id', $validted['legal_opinion_id'])->exists()) {
return $this->failed([], 'نظریه قبلا اضافه شده است', 400);
}
FolderLegalOpinion::create(['folder_id' => $validted['folder_id'], 'legal_opinion_id' => $validted['legal_opinion_id']]);
}
else {
return $this->failed([], 'موردی برای افزودن یافت نشد', 400);
}
FolderArt::create([
'folder_id' => $validted['folder_id'],
'art_id' => $validted['art_id'],
]);
return $this->success([], 'اضافه شد', 'ارت با موفیت اضافه شد');
return $this->success([], 'اضافه شد', 'با موفیت اضافه شد');
}
public function folder($id)
{
$arts = FolderArt::where('folder_id', $id)
->with('arts.chapter', 'arts.part', 'arts.volum', 'arts.law', 'arts.book', 'arts.section', 'arts.gate')
->get()
->pluck('arts')
->flatten();
// Added 'arts.law.category' so we can fetch category_name without slowing down the DB
$folder = Folder::with([
'arts.chapter', 'arts.part', 'arts.volum', 'arts.law.category', 'arts.book', 'arts.section', 'arts.gate',
'bylaws.category', 'bylaws.approvalAuthority', 'bylaws.promulgationAuthority',
'judicialPrecedents.category',
'legalOpinions.category'
])->where('user_id', auth()->id())->find($id);
$arts = $arts->map(function ($art) {
if (!$folder) {
return $this->failed([], 'پوشه یافت نشد یا شما دسترسی ندارید', 404);
}
$items = collect();
// 1. Process Arts
$arts = $folder->arts->map(function ($art) {
$text = $art->text ?? '';
$shortText = '';
if (strlen($text) > 50) {
$shortText = substr($text, 0, 50);
$shortText = substr($shortText, 0, strrpos($shortText, ' ')).'...';
$shortText = mb_substr($text, 0, 50);
$shortText = mb_substr($shortText, 0, mb_strrpos($shortText, ' ')).'...';
} else {
$shortText = $text;
}
@@ -89,6 +131,17 @@ class FolderController extends Controller
'text' => $isLocked ? null : $shortText,
'number' => $art->number,
'is_locked' => $isLocked,
'type' => 'art',
'assigned_at' => $art->pivot->created_at,
'law_title' => $art->law?->title,
'law' => $art->law != null ? [
'id' => $art->law->id,
'title' => $art->law->title,
'number' => $art->law->number,
] : null,
'chapter' => $art->chapter != null ? [
'id' => $art->chapter->id,
'title' => $art->chapter->title,
@@ -104,11 +157,6 @@ class FolderController extends Controller
'title' => $art->volum->title,
'number' => $art->volum->number,
] : null,
'law' => $art->law != null ? [
'id' => $art->law->id,
'title' => $art->law->title,
'number' => $art->law->number,
] : null,
'book' => $art->book != null ? [
'id' => $art->book->id,
'title' => $art->book->title,
@@ -126,27 +174,191 @@ class FolderController extends Controller
] : null,
];
});
$items = $items->concat($arts);
return $this->success($arts);
// 2. Process Bylaws
$bylawIds = $folder->bylaws->pluck('id')->toArray();
$allBylawNodes = collect();
if (!empty($bylawIds)) {
$allBylawNodes = DB::table('bylaw_nodes')
->whereIn('document_id', $bylawIds)
->orderBy('sort_order', 'asc')
->get();
}
$bylaws = $folder->bylaws->map(function ($bylaw) use ($allBylawNodes) {
$itemNodes = $allBylawNodes->where('document_id', $bylaw->id);
$formatted = $this->formatBylaw($bylaw, $itemNodes);
$formatted['type'] = 'bylaw';
$formatted['assigned_at'] = $bylaw->pivot->created_at;
return $formatted;
});
$items = $items->concat($bylaws);
// 3. Process Judicial Precedents
$judicials = $folder->judicialPrecedents->map(function ($judicial) {
$formatted = $this->formatJudicial($judicial);
$formatted['type'] = 'judicial_precedent';
$formatted['assigned_at'] = $judicial->pivot->created_at;
return $formatted;
});
$items = $items->concat($judicials);
// 4. Process Legal Opinions
$opinions = $folder->legalOpinions->map(function ($opinion) {
$formatted = $this->formatOpinion($opinion);
$formatted['type'] = 'legal_opinion';
$formatted['assigned_at'] = $opinion->pivot->created_at;
return $formatted;
});
$items = $items->concat($opinions);
// Sort everything together based on when it was added to the folder!
$sortedItems = $items->sortByDesc('assigned_at')->values()->toArray();
return $this->success($sortedItems);
}
public function delete_folder($id)
{
if (Folder::query()->where('id', $id)->count() == 0) {
return $this->failed('پوشه یافت نشد', 'پوشه مورد نظر یافت نشد', 404);
}
Folder::query()->where('id', $id)->delete();
$folder = Folder::where('id', $id)->where('user_id', auth()->id())->first();
if (!$folder) {
return $this->failed('پوشه یافت نشد', 'پوشه مورد نظر یافت نشد یا شما دسترسی ندارید', 404);
}
$folder->delete();
return $this->success([], 'حذف شد', 'پوشه با موفقیت حذف شد');
}
private function checkFolderOwnership($folder_id)
{
return Folder::where('id', $folder_id)->where('user_id', auth()->id())->exists();
}
public function delete_art($id, $art_id)
{
if (FolderArt::query()->where('folder_id', $id)->where('art_id', $art_id)->count() == 0) {
return $this->failed([], 'ارت مورد نظر یافت نشد', 404);
}
FolderArt::query()->where('folder_id', $id)->where('art_id', $art_id)->delete();
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
return $this->success([], 'حذف شد', 'ارت با موفقیت حذف شد');
if (FolderArt::where('folder_id', $id)->where('art_id', $art_id)->count() == 0) {
return $this->failed([], 'مورد نظر یافت نشد', 404);
}
FolderArt::where('folder_id', $id)->where('art_id', $art_id)->delete();
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
}
public function delete_bylaw($id, $bylaw_id)
{
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
if (FolderBylaw::where('folder_id', $id)->where('bylaw_document_id', $bylaw_id)->count() == 0) {
return $this->failed([], 'مورد نظر یافت نشد', 404);
}
FolderBylaw::where('folder_id', $id)->where('bylaw_document_id', $bylaw_id)->delete();
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
}
public function delete_judicial($id, $judicial_id)
{
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
if (FolderJudicialPrecedent::where('folder_id', $id)->where('judicial_precedent_id', $judicial_id)->count() == 0) {
return $this->failed([], 'مورد نظر یافت نشد', 404);
}
FolderJudicialPrecedent::where('folder_id', $id)->where('judicial_precedent_id', $judicial_id)->delete();
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
}
public function delete_opinion($id, $opinion_id)
{
if (!$this->checkFolderOwnership($id)) return $this->failed([], 'عدم دسترسی', 403);
if (FolderLegalOpinion::where('folder_id', $id)->where('legal_opinion_id', $opinion_id)->count() == 0) {
return $this->failed([], 'مورد نظر یافت نشد', 404);
}
FolderLegalOpinion::where('folder_id', $id)->where('legal_opinion_id', $opinion_id)->delete();
return $this->success([], 'حذف شد', 'با موفقیت حذف شد');
}
/* -----------------------------------------------------------------
* FORMATTING HELPERS
* ----------------------------------------------------------------- */
private function isLockedForCurrentUserGeneric($item): bool
{
if (auth()->check() && auth()->user() && method_exists(auth()->user(), 'isSubscriber')) {
return !auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
}
return (bool) ($item->is_locked ?? false);
}
private function formatBylaw($item, $itemNodes = null)
{
$isLocked = $this->isLockedForCurrentUserGeneric($item);
$description = $item->description;
$cleanDesc = trim(str_replace(['&nbsp;', '&zwnj;', "\r", "\n", "\t", ' '], '', strip_tags($description ?? '')));
if (strlen($cleanDesc) < 5) {
if ($itemNodes === null || (is_object($itemNodes) && $itemNodes->isEmpty())) {
$dbNodes = DB::table('bylaw_nodes')->where('document_id', $item->id)->count();
if ($dbNodes > 0) {
$itemNodes = DB::table('bylaw_nodes')->where('document_id', $item->id)->orderBy('sort_order', 'asc')->get();
}
}
$nodeContents = [];
if ($itemNodes) {
foreach ($itemNodes as $node) {
$content = trim(strip_tags($node->node_content ?? ''));
if (!empty($content)) {
$nodeContents[] = $content;
}
}
}
$description = implode("\n\n", $nodeContents);
}
return [
'id' => $item->id,
'title' => $item->title,
'category_name' => optional($item->category)->name,
'approval_authority_name' => optional($item->approvalAuthority)->name,
'approval_date' => $item->approval_date,
'promulgation_number' => $item->promulgation_number,
'description' => $isLocked ? null : mb_substr($description, 0, 100) . '...',
'is_locked' => $isLocked,
];
}
private function formatJudicial($item)
{
$isLocked = $this->isLockedForCurrentUserGeneric($item);
return [
'id' => $item->id,
'title' => $item->subject,
'ruling_number' => $item->ruling_number,
'ruling_date' => $item->ruling_date,
'text' => $isLocked ? null : mb_substr($item->full_text, 0, 100) . '...',
'issuing_authority' => $item->issuing_authority,
'category_name' => optional($item->category)->name,
'is_locked' => $isLocked,
];
}
private function formatOpinion($item)
{
$isLocked = $this->isLockedForCurrentUserGeneric($item);
return [
'id' => $item->id,
'title' => $item->subject,
'opinion_number' => $item->opinion_number,
'opinion_date' => $item->opinion_date,
'text' => $isLocked ? null : mb_substr($item->full_text, 0, 100) . '...',
'issuing_authority' => $item->issuing_authority,
'category_name' => optional($item->category)->name,
'is_locked' => $isLocked,
];
}
}

View File

@@ -54,7 +54,8 @@ class GlobalSearchApiController extends Controller
$arts = Art::with(['law.category', 'chapter', 'part', 'volum', 'book', 'section', 'gate'])
->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$searchTerm}%")
->orWhere('text', 'LIKE', "%{$searchTerm}%");
->orWhere('text', 'LIKE', "%{$searchTerm}%")
->orWhere('number', 'LIKE', "%{$searchTerm}%");
})
->take($limitPerTable)->get()->map(function($item) use ($searchTerm) {
$formatted = $this->formatArt($item, $searchTerm, Art::class);
@@ -91,7 +92,7 @@ class GlobalSearchApiController extends Controller
// =========================================================
// 3. Search Bylaw Documents
// =========================================================
$bylaws = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
$bylawsQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
->where('is_published', true)
->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$searchTerm}%")
@@ -101,13 +102,27 @@ class GlobalSearchApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id')
->from('bylaw_nodes')
->where('node_content', 'LIKE', "%{$searchTerm}%");
->where('node_content', 'LIKE', "%{$searchTerm}%")
->orWhere('title', 'LIKE', "%{$searchTerm}%");
});
})->take($limitPerTable)->get()->map(function($item) {
$formatted = $this->formatBylaw($item);
$formatted['type'] = 'bylaw';
return $formatted;
});
})->take($limitPerTable)->get();
// Safely bulk-fetch nodes using DB facade
$docIds = $bylawsQuery->pluck('id')->toArray();
$allNodes = collect();
if (!empty($docIds)) {
$allNodes = DB::table('bylaw_nodes')
->whereIn('document_id', $docIds)
->orderBy('sort_order', 'asc')
->get();
}
$bylaws = $bylawsQuery->map(function($item) use ($allNodes) {
$itemNodes = $allNodes->where('document_id', $item->id);
$formatted = $this->formatBylaw($item, $itemNodes);
$formatted['type'] = 'bylaw';
return $formatted;
});
$sortableResults = $sortableResults->concat($bylaws);
// =========================================================
@@ -193,7 +208,7 @@ class GlobalSearchApiController extends Controller
'total' => $paginator->total(),
];
return $this->success($responseData, '', '');
return $this->success($responseData, 'Success', 'Global Search Results Fetched');
}
/**
@@ -213,7 +228,8 @@ class GlobalSearchApiController extends Controller
$artQuery = Art::with(['law', 'law.category', 'chapter', 'part', 'volum', 'book', 'section', 'gate'])
->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$searchTerm}%")
->orWhere('text', 'LIKE', "%{$searchTerm}%");
->orWhere('text', 'LIKE', "%{$searchTerm}%")
->orWhere('number', 'LIKE', "%{$searchTerm}%");
});
$totalCount += $artQuery->count();
$arts = $artQuery->take($limitPerTable)->get()->map(function($item) use ($searchTerm) {
@@ -250,7 +266,7 @@ class GlobalSearchApiController extends Controller
}
// 3. Bylaw Documents
$bylawQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
$bylawsQuery = BylawDocument::with(['category', 'approvalAuthority', 'promulgationAuthority'])
->where('is_published', true)
->where(function ($q) use ($searchTerm) {
$q->where('title', 'LIKE', "%{$searchTerm}%")
@@ -260,12 +276,27 @@ class GlobalSearchApiController extends Controller
->orWhereIn('id', function($subQuery) use ($searchTerm) {
$subQuery->select('document_id')
->from('bylaw_nodes')
->where('node_content', 'LIKE', "%{$searchTerm}%");
->where('node_content', 'LIKE', "%{$searchTerm}%")
->orWhere('title', 'LIKE', "%{$searchTerm}%");
});
});
$totalCount += $bylawQuery->count();
$bylaws = $bylawQuery->take($limitPerTable)->get()->map(function($item) {
$formatted = $this->formatBylaw($item);
$totalCount += $bylawsQuery->count();
$bylawsRaw = $bylawsQuery->take($limitPerTable)->get();
// Bulk-fetch nodes safely using DB facade
$docIds = $bylawsRaw->pluck('id')->toArray();
$allNodes = collect();
if (!empty($docIds)) {
$allNodes = DB::table('bylaw_nodes')
->whereIn('document_id', $docIds)
->orderBy('sort_order', 'asc')
->get();
}
$bylaws = $bylawsRaw->map(function($item) use ($allNodes) {
$itemNodes = $allNodes->where('document_id', $item->id);
$formatted = $this->formatBylaw($item, $itemNodes);
$formatted['type'] = 'bylaw';
return $formatted;
});
@@ -306,7 +337,7 @@ class GlobalSearchApiController extends Controller
return $this->success([
'items' => $results->take(15)->values(),
'count' => $totalCount
], '', '');
], 'Success', 'Global Fast Search Results');
}
/* -----------------------------------------------------------------
@@ -315,24 +346,35 @@ class GlobalSearchApiController extends Controller
private function isLockedForCurrentUserGeneric($item): bool
{
return ! auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
if (auth()->check() && auth()->user() && method_exists(auth()->user(), 'isSubscriber')) {
return !auth()->user()->isSubscriber() && (bool) ($item->is_locked ?? false);
}
return (bool) ($item->is_locked ?? false);
}
private function formatBylaw($item)
/**
* Exact 1:1 match with BylawApiController's encoding approach
*/
private function formatBylaw($item, $itemNodes = null)
{
$isLocked = $this->isLockedForCurrentUserGeneric($item);
$description = $item->description;
$cleanDesc = trim(strip_tags($description ?? ''));
if ($itemNodes === null) {
$itemNodes = DB::table('bylaw_nodes')->where('document_id', $item->id)->orderBy('sort_order', 'asc')->get();
}
if (empty($cleanDesc)) {
$nodeContents = DB::table('bylaw_nodes')
->where('document_id', $item->id)
->orderBy('sort_order', 'asc')
->pluck('node_content')
->toArray();
$nodeContentAsDescription = collect($itemNodes)->pluck('node_content')
->map(function ($text) {
$safeText = trim((string)$text);
return mb_convert_encoding($safeText, 'UTF-8', 'UTF-8');
})
->filter(function ($text) {
return $text !== '';
})
->implode("\n\n");
$description = implode("\n\n", array_filter($nodeContents));
if (empty($nodeContentAsDescription)) {
$nodeContentAsDescription = mb_convert_encoding(trim((string)$item->description), 'UTF-8', 'UTF-8');
}
return [
@@ -342,7 +384,7 @@ class GlobalSearchApiController extends Controller
'approval_authority_name' => optional($item->approvalAuthority)->name,
'approval_date' => $item->approval_date,
'promulgation_number' => $item->promulgation_number,
'description' => $isLocked ? null : $description,
'description' => $isLocked ? null : $nodeContentAsDescription,
'is_locked' => $isLocked,
];
}
@@ -379,10 +421,6 @@ class GlobalSearchApiController extends Controller
];
}
/**
* Exact 1:1 match with ArtController::searchModel mapping
* All relations (law, chapter, etc) are strictly Strings to prevent App Crashes!
*/
private function formatArt($item, $searchTerm, $modelClass)
{
$law = $item->law ?? Law::find($item->law_id);
@@ -390,6 +428,7 @@ class GlobalSearchApiController extends Controller
$text = $item->text ?? '';
$searchQuoted = preg_quote($searchTerm, '/');
if (!empty($searchTerm) && preg_match('/\b(.{0,50})\b('.$searchQuoted.')\b(.{0,50})\b/i', $text, $matches)) {
$context = trim($matches[1] ?? '').' '.($matches[2] ?? '').' '.trim($matches[3] ?? '');
} else {
@@ -404,10 +443,10 @@ class GlobalSearchApiController extends Controller
'is_locked' => $isLocked,
'route' => array_values($this->route($modelClass, $item)),
'category' => optional($law?->category)->name,
'law' => $law?->title ?? '', // STRICLY STRING! Prevents Android App Crash!
'law' => $law?->title ?? '',
'count_art' => $law?->arts?->count() ?? 0,
'count_volums' => $law?->volums?->count() ?? 0, // WITH S
'count_volum' => $law?->volums?->count() ?? 0, // WITHOUT S (Fallback)
'count_volums' => $law?->volums?->count() ?? 0,
'count_volum' => $law?->volums?->count() ?? 0,
'price' => $law?->price,
'image' => $law?->image,
];
@@ -424,7 +463,7 @@ class GlobalSearchApiController extends Controller
'number' => $item->number,
'is_locked' => $isLocked,
'route' => array_values($this->route($modelClass, $item)),
'law' => $law?->title ?? '', // STRICTLY STRING
'law' => $law?->title ?? '',
];
if ((new $modelClass)->getTable() == 'laws') {

View File

@@ -7,6 +7,7 @@ use App\Models\Law;
use App\Models\Notification;
use App\Models\Category;
use App\Models\RecentArt;
use App\Models\SubscribePlan;
use App\Models\UserSubscriber;
use App\Services\AppMarketPurchaseVerifier;
use App\Traits\BaseApiResponse;
@@ -20,9 +21,32 @@ class HomeController extends Controller
{
}
public function index()
{
return $this->buildHomeResponse();
}
public function homeV2()
{
return $this->buildHomeResponse();
}
public function homeV3()
{
return $this->buildHomeResponse();
}
/**
* UNIFIED CORE LOGIC: Safely builds the home response for ALL app versions (v1, v2, v3)
* By centralizing this, we ensure no old app version ever suffers from the math bug!
*/
private function buildHomeResponse()
{
$user = auth()->user();
// 1. Safely grant free subs (Won't overlap with paid plans)
$this->checkAndGrantFreeSubscription($user);
$this->refreshMarketSubscriptionAfterResponse($user);
$recent = RecentArt::query()->where('user_id', $user->id)->get()->map(function ($q) {
@@ -68,41 +92,41 @@ class HomeController extends Controller
];
});
$freeSubscription = $user->userSubscribers()
->whereHas('subscribe', function ($query) {
$query->where('is_free', true);
})
// --- THE FIXED AUTHORITY LOGIC APPLIED TO ALL VERSIONS ---
$activeSubscribers = $user->userSubscribers()
->with('subscribe')
->where('expired_at', '>=', now())
->first();
->get();
$current_plan = null;
if ($freeSubscription) {
$current_plan = [
'id' => $freeSubscription->id,
'name' => $freeSubscription->subscribe->name,
'price' => $freeSubscription->subscribe->price,
'expired_day' => $freeSubscription->expired_at->diffInDays(now()),
'is_free' => true,
];
}
if ($activeSubscribers->isNotEmpty()) {
// Separate Paid from Free
$paidSubscriptions = $activeSubscribers->reject(function ($subscriber) {
return $subscriber->is_free || ($subscriber->subscribe && $subscriber->subscribe->is_free);
});
$latestSubscription = $user->userSubscribers()
->where('expired_at', '>=', now())
->latest('expired_at')
->first();
// AUTHORITY RULE: Paid plans override Free plans
if ($paidSubscriptions->isNotEmpty()) {
$latestSubscription = $paidSubscriptions->sortByDesc('expired_at')->first();
} else {
$latestSubscription = $activeSubscribers->sortByDesc('expired_at')->first();
}
$expiredDays = UserSubscriber::query()->where('user_id', $user->id)->where('expired_at', '>=', now())->get()->sum(function ($subscriber) {
return $subscriber->expired_at->diffInDays(now());
});
// Fixed math (No sum)
$expiredDays = $latestSubscription->expired_at->diffInDays(now());
$isFree = (bool) (
$latestSubscription->is_free ||
($latestSubscription->subscribe && $latestSubscription->subscribe->is_free)
);
if ($latestSubscription) {
$current_plan = [
'id' => $latestSubscription->id,
'name' => $latestSubscription->subscribe->name ?? 'Subscription',
'price' => $latestSubscription->subscribe->price ?? 100,
'price' => $latestSubscription->subscribe->price ?? 0,
'expired_day' => $expiredDays,
'is_free' => (bool)$latestSubscription->is_free,
'is_free' => $isFree,
];
}
@@ -119,8 +143,8 @@ class HomeController extends Controller
return $this->success([
'home' => $homeData,
'hoqoqi_list' => $this->getCategoryList(1), // Category 1
'keyfari_list' => $this->getCategoryList(2), // Category 2
'hoqoqi_list' => $this->getCategoryList(1),
'keyfari_list' => $this->getCategoryList(2),
]);
}
@@ -152,104 +176,29 @@ class HomeController extends Controller
return $data;
}
public function index()
private function checkAndGrantFreeSubscription($user): void
{
$user = auth()->user();
$this->refreshMarketSubscriptionAfterResponse($user);
$recent = RecentArt::query()->where('user_id', $user->id)->get()->map(function ($q) {
return [
'id' => $q->law?->id,
'name' => $q->law?->title,
'is_locked' => $q->law?->is_locked,
];
});
$laws = Law::orderBy('created_at')->get()->map(function ($q) {
return [
'id' => $q->id,
'name' => $q->title,
'is_locked' => $q->is_locked,
];
});
$categories = ['hagigi', 'kifari'];
$lawsByCategory = [];
foreach ($categories as $category) {
$lawsByCategory[$category] = Law::whereHas('category', function ($q) use ($category) {
$q->where('type', $category);
})->get()->map(function ($q) {
return [
'id' => $q->id,
'title' => $q->title,
'is_locked' => $q->is_locked,
'category_id' => $q->category_id,
'price' => $q->price,
'image' => $q->image,
'type' => 'law',
];
});
}
$free_law = Law::where('is_locked', false)->orderBy('created_at')->get()->map(function ($q) {
return [
'id' => $q->id,
'name' => $q->title,
'is_locked' => $q->is_locked,
];
});
$freeSubscription = $user->userSubscribers()
$hasUsedFreePlan = $user->userSubscribers()
->whereHas('subscribe', function ($query) {
$query->where('is_free', true);
})
->where('expired_at', '>=', now())
->first();
})->exists();
$current_plan = null;
$hasActivePaidPlan = $user->userSubscribers()
->where('expired_at', '>', now())
->whereHas('subscribe', function ($query) {
$query->where('is_free', false);
})->exists();
if ($freeSubscription) {
$current_plan = [
'id' => $freeSubscription->id,
'name' => $freeSubscription->subscribe->name,
'price' => $freeSubscription->subscribe->price,
'expired_day' => $freeSubscription->expired_at->diffInDays(now()),
'is_free' => true,
];
if (!$hasUsedFreePlan && !$hasActivePaidPlan) {
$freePlan = SubscribePlan::where('is_free', true)->where('is_active', true)->first();
if ($freePlan) {
$user->userSubscribers()->create([
'subscribe_plan_id' => $freePlan->id,
'expired_at' => now()->addDays($freePlan->expired_day + 1),
]);
}
}
$latestSubscription = $user->userSubscribers()
->where('expired_at', '>=', now())
->latest('expired_at')
->first();
$expiredDays = UserSubscriber::query()->where('user_id', $user->id)->where('expired_at', '>=', now())->get()->sum(function ($subscriber) {
return $subscriber->expired_at->diffInDays(now());
});
if ($latestSubscription) {
$current_plan = [
'id' => $latestSubscription->id,
'name' => $latestSubscription->subscribe->name ?? 'Subscription',
'price' => $latestSubscription->subscribe->price ?? 100,
'expired_day' => $expiredDays,
'is_free' => (bool)$latestSubscription->is_free,
];
}
$unread_notifications_count = Notification::unreadForUser($user->id)->count();
return $this->success([
'recent' => $recent,
'laws' => $lawsByCategory,
'last_law' => $laws,
'free' => $free_law,
'current_plan' => $current_plan,
'unread_notifications_count' => $unread_notifications_count,
]);
}
private function refreshMarketSubscriptionAfterResponse($user): void

View File

@@ -146,38 +146,46 @@ class JudicialPrecedentApiController extends Controller
$current = JudicialPrecedent::find($id);
if (!$current) {
return $this->failed([], [
'title' => 'خطا',
'message' => 'رأی وحدت رویه مورد نظر یافت نشد.'
], 404);
return $this->failed([], ['title' => 'خطا', 'message' => 'رأی وحدت رویه مورد نظر یافت نشد.'], 404);
}
$previousItem = JudicialPrecedent::where(function ($query) use ($current) {
$query->where('ruling_date', '<', $current->ruling_date)
->orWhere(function ($subQuery) use ($current) {
$subQuery->where('ruling_date', '=', $current->ruling_date)
->where('id', '<', $current->id);
$subQuery->where('ruling_date', '=', $current->ruling_date)->where('id', '<', $current->id);
});
})
->orderBy('ruling_date', 'desc')
->orderBy('id', 'desc')
->first();
})->orderBy('ruling_date', 'desc')->orderBy('id', 'desc')->first();
$nextItem = JudicialPrecedent::where(function ($query) use ($current) {
$query->where('ruling_date', '>', $current->ruling_date)
->orWhere(function ($subQuery) use ($current) {
$subQuery->where('ruling_date', '=', $current->ruling_date)
->where('id', '>', $current->id);
$subQuery->where('ruling_date', '=', $current->ruling_date)->where('id', '>', $current->id);
});
})
->orderBy('ruling_date', 'asc')
->orderBy('id', 'asc')
->first();
})->orderBy('ruling_date', 'asc')->orderBy('id', 'asc')->first();
// Apply format and add Notes & Highlights
$formatWithNoteAndHighlight = function($item) {
if (!$item) return null;
$formatted = $this->formatItem($item);
$formatted['note'] = \App\Models\JudicialPrecedentNote::select('id', 'note', 'color_code', 'created_at')
->where('user_id', auth()->id())
->where('judicial_precedent_id', $item->id)
->get();
// NEW: Fetch Highlights
$formatted['highlight'] = \App\Models\JudicialPrecedentHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
->where('user_id', auth()->id())
->where('judicial_precedent_id', $item->id)
->get();
return $formatted;
};
$results = array_values(array_filter([
$previousItem ? $this->formatItem($previousItem) : null,
$this->formatItem($current),
$nextItem ? $this->formatItem($nextItem) : null
$previousItem ? $formatWithNoteAndHighlight($previousItem) : null,
$formatWithNoteAndHighlight($current),
$nextItem ? $formatWithNoteAndHighlight($nextItem) : null
]));
return $this->success($results, 'Success', 'Pager Window Details');

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\JudicialPrecedentHighlight;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class JudicialPrecedentHighlightController extends Controller
{
use BaseApiResponse;
public function create(Request $request)
{
$validated = $request->validate([
'judicial_id' => 'required',
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
JudicialPrecedentHighlight::create([
'user_id' => auth()->id(),
'judicial_precedent_id' => $validated['judicial_id'],
'start_index' => $validated['start_index'],
'end_index' => $validated['end_index'],
'selected_text' => $validated['selected_text'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
}
public function update($id, Request $request)
{
$validated = $request->validate([
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
$highlight = JudicialPrecedentHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->update($validated);
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
}
public function destroy($id)
{
$highlight = JudicialPrecedentHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->delete();
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\JudicialPrecedentNote;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class JudicialPrecedentNoteController extends Controller
{
use BaseApiResponse;
public function create(Request $request)
{
$validated = $request->validate([
'judicial_id' => 'required', // Cleaned up!
'note' => 'required',
'color_code' => 'nullable',
]);
JudicialPrecedentNote::query()->create([
'user_id' => auth()->id(),
'judicial_precedent_id' => $validated['judicial_id'], // Maps to DB
'note' => $validated['note'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'موفق', 'یادداشت با موفقیت اضافه شد');
}
public function update($id, Request $request)
{
$validated = $request->validate([
'note' => 'required',
'color_code' => 'nullable',
]);
$note = JudicialPrecedentNote::where('user_id', auth()->id())->findOrFail($id);
$note->update([
'note' => $validated['note'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'بروزرسانی', 'یادداشت با موفقیت بروزرسانی شد');
}
public function destroy($id)
{
$note = JudicialPrecedentNote::where('user_id', auth()->id())->findOrFail($id);
$note->delete();
return $this->success([], 'حذف', 'یادداشت با موفقیت حذف شد');
}
}

View File

@@ -147,49 +147,54 @@ class LegalOpinionApiController extends Controller
$current = LegalOpinion::find($id);
if (!$current) {
return $this->failed([], [
'title' => 'خطا',
'message' => 'نظریه قانونی مورد نظر یافت نشد.'
], 404);
return $this->failed([], ['title' => 'خطا', 'message' => 'نظریه قانونی مورد نظر یافت نشد.'], 404);
}
$previousItem = LegalOpinion::where(function ($query) use ($current) {
if ($current->opinion_date) {
$query->where('opinion_date', '<', $current->opinion_date)
->orWhere(function ($subQuery) use ($current) {
$subQuery->where('opinion_date', '=', $current->opinion_date)
->where('id', '<', $current->id);
});
$query->where('opinion_date', '<', $current->opinion_date)->orWhere(function ($subQuery) use ($current) {
$subQuery->where('opinion_date', '=', $current->opinion_date)->where('id', '<', $current->id);
});
} else {
$query->whereNull('opinion_date')->where('id', '<', $current->id);
}
})
->orderBy('opinion_date', 'desc')
->orderBy('id', 'desc')
->first();
})->orderBy('opinion_date', 'desc')->orderBy('id', 'desc')->first();
$nextItem = LegalOpinion::where(function ($query) use ($current) {
if ($current->opinion_date) {
$query->where('opinion_date', '>', $current->opinion_date)
->orWhere(function ($subQuery) use ($current) {
$subQuery->where('opinion_date', '=', $current->opinion_date)
->where('id', '>', $current->id);
});
$query->where('opinion_date', '>', $current->opinion_date)->orWhere(function ($subQuery) use ($current) {
$subQuery->where('opinion_date', '=', $current->opinion_date)->where('id', '>', $current->id);
});
} else {
$query->whereNotNull('opinion_date')
->orWhere(function ($subQuery) use ($current) {
$subQuery->whereNull('opinion_date')->where('id', '>', $current->id);
});
$query->whereNotNull('opinion_date')->orWhere(function ($subQuery) use ($current) {
$subQuery->whereNull('opinion_date')->where('id', '>', $current->id);
});
}
})
->orderBy('opinion_date', 'asc')
->orderBy('id', 'asc')
->first();
})->orderBy('opinion_date', 'asc')->orderBy('id', 'asc')->first();
// Apply format and add Notes & Highlights
$formatWithNoteAndHighlight = function($item) {
if (!$item) return null;
$formatted = $this->formatItem($item);
$formatted['note'] = \App\Models\LegalOpinionNote::select('id', 'note', 'color_code', 'created_at')
->where('user_id', auth()->id())
->where('legal_opinion_id', $item->id)
->get();
// NEW: Fetch Highlights
$formatted['highlight'] = \App\Models\LegalOpinionHighlight::select('id', 'start_index', 'end_index', 'selected_text', 'color_code')
->where('user_id', auth()->id())
->where('legal_opinion_id', $item->id)
->get();
return $formatted;
};
$results = array_values(array_filter([
$previousItem ? $this->formatItem($previousItem) : null,
$this->formatItem($current),
$nextItem ? $this->formatItem($nextItem) : null
$previousItem ? $formatWithNoteAndHighlight($previousItem) : null,
$formatWithNoteAndHighlight($current),
$nextItem ? $formatWithNoteAndHighlight($nextItem) : null
]));
return $this->success($results, 'Success', 'Pager Window Details');

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\LegalOpinionHighlight;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class LegalOpinionHighlightController extends Controller
{
use BaseApiResponse;
public function create(Request $request)
{
$validated = $request->validate([
'opinion_id' => 'required',
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
LegalOpinionHighlight::create([
'user_id' => auth()->id(),
'legal_opinion_id' => $validated['opinion_id'],
'start_index' => $validated['start_index'],
'end_index' => $validated['end_index'],
'selected_text' => $validated['selected_text'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'موفق', 'هایلایت با موفقیت اضافه شد');
}
public function update($id, Request $request)
{
$validated = $request->validate([
'start_index' => 'required|integer',
'end_index' => 'required|integer',
'selected_text' => 'nullable|string',
'color_code' => 'nullable|string',
]);
$highlight = LegalOpinionHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->update($validated);
return $this->success([], 'بروزرسانی', 'هایلایت با موفقیت بروزرسانی شد');
}
public function destroy($id)
{
$highlight = LegalOpinionHighlight::where('user_id', auth()->id())->findOrFail($id);
$highlight->delete();
return $this->success([], 'حذف', 'هایلایت با موفقیت حذف شد');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\LegalOpinionNote;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class LegalOpinionNoteController extends Controller
{
use BaseApiResponse;
public function create(Request $request)
{
$validated = $request->validate([
'opinion_id' => 'required', // Cleaned up!
'note' => 'required',
'color_code' => 'nullable',
]);
LegalOpinionNote::query()->create([
'user_id' => auth()->id(),
'legal_opinion_id' => $validated['opinion_id'], // Maps to DB
'note' => $validated['note'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'موفق', 'یادداشت با موفقیت اضافه شد');
}
public function update($id, Request $request)
{
$validated = $request->validate([
'note' => 'required',
'color_code' => 'nullable',
]);
$note = LegalOpinionNote::where('user_id', auth()->id())->findOrFail($id);
$note->update([
'note' => $validated['note'],
'color_code' => $validated['color_code'],
]);
return $this->success([], 'بروزرسانی', 'یادداشت با موفقیت بروزرسانی شد');
}
public function destroy($id)
{
$note = LegalOpinionNote::where('user_id', auth()->id())->findOrFail($id);
$note->delete();
return $this->success([], 'حذف', 'یادداشت با موفقیت حذف شد');
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\Question;
use App\Models\PvpMatch;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class PvPQuizController extends Controller
{
use BaseApiResponse;
/**
* STEP 1: Join the Matchmaking Queue
*/
public function findMatch(Request $request)
{
$userId = auth()->id();
// 1. Check if anyone else is waiting in the cache queue
$waitingPlayer = Cache::get('pvp_waiting_player');
// Ensure we don't match the user with themselves
if ($waitingPlayer && $waitingPlayer != $userId) {
// MATCH FOUND! Remove the waiting player from the queue
Cache::forget('pvp_waiting_player');
// Create the Game Room
$match = PvpMatch::create([
'status' => 'in_progress',
'started_at' => now(),
]);
// Add both players to the match
$match->players()->createMany([
['user_id' => $waitingPlayer, 'score' => 0],
['user_id' => $userId, 'score' => 0],
]);
// Select 5 random multiple-choice questions
$questions = Question::where('type', 'multiple_choice')
->inRandomOrder()
->limit(5)
->pluck('id');
// Attach questions to the match
$match->questions()->attach($questions);
// TODO (Next Step): Fire a WebSocket Event here to tell Player 1 the match started!
return $this->success([
'match_id' => $match->id,
'status' => 'matched',
'opponent_id' => $waitingPlayer,
'questions' => $match->questions()->with('options')->get()->makeHidden(['solution_explanation'])->each(function($q) {
$q->options->each->makeHidden(['is_correct']);
})
], 'موفق', 'حریف پیدا شد! بازی شروع می‌شود.');
}
// NO MATCH FOUND YET. Put this user in the waiting queue for 30 seconds.
Cache::put('pvp_waiting_player', $userId, 30);
return $this->success([
'status' => 'waiting'
], 'در حال جستجو', 'در حال یافتن حریف...');
}
/**
* STEP 2: Submit Answer for a single question (Fastest Finger Logic)
*/
public function submitAnswer(Request $request, PvpMatch $match)
{
$request->validate([
'question_id' => 'required|exists:questions,id',
'selected_option_id' => 'required|exists:question_options,id',
'time_taken_seconds' => 'required|numeric|min:0.1|max:15', // Max 15 seconds to answer
]);
$player = $match->players()->where('user_id', auth()->id())->firstOrFail();
$question = Question::with('options')->find($request->question_id);
$isCorrect = false;
$pointsEarned = 0;
// Check if correct
$correctOption = $question->options->where('is_correct', true)->first();
if ($correctOption && $correctOption->id == $request->selected_option_id) {
$isCorrect = true;
// MATH: Speed Bonus Calculation
// If base score is 10, and you answer in 2 seconds (out of 15), you get a bonus!
$baseScore = 10;
$maxTime = 15;
$speedBonus = ($maxTime - $request->time_taken_seconds) * 0.5; // Half a point for every second saved
$pointsEarned = $baseScore + $speedBonus;
}
// Save the answer
$match->answers()->create([
'user_id' => auth()->id(),
'question_id' => $question->id,
'selected_option_id' => $request->selected_option_id,
'is_correct' => $isCorrect,
'time_taken' => $request->time_taken_seconds,
'points_earned' => $pointsEarned
]);
// Update total score for the player
$player->increment('score', $pointsEarned);
// ==========================================
// CHECK IF THE MATCH IS OVER
// ==========================================
$totalQuestions = $match->questions()->count(); // Should be 5
$totalExpectedAnswers = $totalQuestions * 2; // 5 questions * 2 players = 10 answers
// Count how many answers have been submitted in this match globally
$currentTotalAnswers = $match->answers()->count();
$isMatchOver = false;
if ($currentTotalAnswers >= $totalExpectedAnswers) {
// The final answer was just submitted! End the game.
$this->finalizeMatch($match);
$isMatchOver = true;
}
return $this->success([
'is_correct' => $isCorrect,
'points_earned' => round($pointsEarned, 2),
'current_total_score' => round($player->score, 2),
'is_match_over' => $isMatchOver
], 'موفق', 'پاسخ ثبت شد.');
}
/**
* STEP 3: Finalize the Match and Declare Winner (Internal Helper)
*/
private function finalizeMatch(PvpMatch $match)
{
// Get both players, ordered by the highest score first
$players = $match->players()->orderByDesc('score')->get();
$player1 = $players->first(); // The one with the higher score
$player2 = $players->last(); // The one with the lower score
$winnerId = null;
// Check if it's a draw, or if someone actually won
if ($player1->score > $player2->score) {
$winnerId = $player1->user_id;
}
// If scores are exactly equal, winnerId stays null (Draw/Tie)
// Close the room
$match->update([
'status' => 'completed',
'ended_at' => now(),
'winner_id' => $winnerId
]);
// TODO (Next Step): Fire a WebSocket Event here to push the "Game Over" screen!
}
/**
* STEP 4: Get Final Match Results
*/
public function matchResult(PvpMatch $match)
{
// Ensure user was actually in this match
if (!$match->players()->where('user_id', auth()->id())->exists()) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$match->load([
'players.user:id,name', // Load player names
'winner:id,name' // Load winner name
]);
return $this->success([
'match_id' => $match->id,
'status' => $match->status,
'winner_id' => $match->winner_id,
'winner_name' => $match->winner ? $match->winner->name : 'مساوی (Draw)',
'players' => $match->players->map(function ($p) {
return [
'user_id' => $p->user_id,
'name' => optional($p->user)->name,
'final_score' => round($p->score, 2)
];
})
], 'موفق', 'نتایج نهایی مسابقه دریافت شد.');
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\Quiz;
use App\Models\QuizCategory;
use App\Models\QuizSubject;
use App\Models\UserQuizAttempt;
use App\Models\UserQuizAnswer;
use App\Models\Question;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class QuizController extends Controller
{
use BaseApiResponse;
/**
* 1. Get all active Categories
*/
public function categories()
{
$categories = QuizCategory::where('is_active', true)->get();
return $this->success($categories, 'موفق', 'لیست دسته‌بندی‌ها دریافت شد');
}
/**
* 2. Get all Subjects
*/
public function subjects()
{
$subjects = QuizSubject::all();
return $this->success($subjects, 'موفق', 'لیست دروس دریافت شد');
}
/**
* 3. List Quizzes (Filterable by Category or Subject)
*/
public function index(Request $request)
{
$query = Quiz::where('is_published', true)->with('category');
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
if ($request->filled('subject_id')) {
$query->whereHas('questions', function ($q) use ($request) {
$q->where('subject_id', $request->subject_id);
});
}
$quizzes = $query->latest()->get();
return $this->success($quizzes, 'موفق', 'لیست آزمون‌ها دریافت شد');
}
/**
* 4. Start a Quiz & Fetch Questions (SECURELY)
*/
public function start(Quiz $quiz)
{
if (!$quiz->is_published) {
return response()->json(['message' => 'این آزمون در دسترس نیست.'], 403);
}
$attempt = UserQuizAttempt::create([
'user_id' => auth()->id(),
'quiz_id' => $quiz->id,
'started_at' => now(),
'status' => 'in_progress'
]);
$quiz->load(['questions' => function($q) {
$q->orderBy('order', 'asc');
}, 'questions.options']);
// SECURITY: Hide the correct answers and explanations before sending to mobile app
$quiz->questions->each(function ($question) {
$question->makeHidden(['solution_explanation']);
$question->options->each->makeHidden(['is_correct']);
});
$data = [
'attempt_id' => $attempt->id,
'quiz' => $quiz
];
return $this->success($data, 'موفق', 'آزمون با موفقیت شروع شد');
}
/**
* 5. Submit the Quiz & Calculate Score
*/
public function submit(Request $request, UserQuizAttempt $attempt)
{
if ($attempt->user_id !== auth()->id() || $attempt->status !== 'in_progress') {
return response()->json(['message' => 'دسترسی غیرمجاز یا آزمون قبلاً پایان یافته است.'], 403);
}
$request->validate([
'answers' => 'required|array',
'answers.*.question_id' => 'required|exists:questions,id',
'answers.*.selected_option_id' => 'nullable|exists:question_options,id',
'answers.*.text_answer' => 'nullable|string',
]);
$quiz = $attempt->quiz;
$totalScore = 0.00;
$correctCount = 0;
$wrongCount = 0;
$unansweredCount = 0;
$needsGrading = false;
DB::beginTransaction();
try {
foreach ($request->answers as $ans) {
$question = Question::with('options')->find($ans['question_id']);
$isCorrect = false;
$earnedScore = 0.00;
// Handle Multiple Choice
if ($question->type === 'multiple_choice') {
if (empty($ans['selected_option_id'])) {
$unansweredCount++;
$isCorrect = null;
} else {
$correctOption = $question->options->where('is_correct', true)->first();
if ($correctOption && $correctOption->id == $ans['selected_option_id']) {
$isCorrect = true;
$earnedScore = $question->score;
$totalScore += $earnedScore;
$correctCount++;
} else {
$isCorrect = false;
if ($quiz->has_negative_score) {
$earnedScore = -($question->score / 3);
$totalScore += $earnedScore;
}
$wrongCount++;
}
}
}
// Handle Descriptive
else if ($question->type === 'descriptive') {
$needsGrading = true;
$isCorrect = null;
}
UserQuizAnswer::create([
'attempt_id' => $attempt->id,
'question_id' => $question->id,
'selected_option_id' => $ans['selected_option_id'] ?? null,
'text_answer' => $ans['text_answer'] ?? null,
'is_correct' => $isCorrect,
'earned_score' => $earnedScore,
]);
}
$attempt->update([
'completed_at' => now(),
'total_score' => $totalScore,
'correct_answers' => $correctCount,
'wrong_answers' => $wrongCount,
'unanswered' => $unansweredCount,
'status' => $needsGrading ? 'pending_grading' : 'completed',
]);
DB::commit();
$data = [
'attempt_id' => $attempt->id,
'status' => $attempt->status
];
return $this->success($data, 'موفق', 'آزمون با موفقیت ثبت شد.');
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'خطا در ثبت اطلاعات آزمون.'], 500);
}
}
/**
* 6. Fetch Results
*/
public function result(UserQuizAttempt $attempt)
{
if ($attempt->user_id !== auth()->id()) {
return response()->json(['message' => 'غیرمجاز'], 403);
}
$attempt->load([
'quiz.category',
'answers.question.options',
'answers.question.subject',
'answers.selectedOption'
]);
return $this->success($attempt, 'موفق', 'نتیجه آزمون با موفقیت دریافت شد');
}
}

View File

@@ -285,22 +285,42 @@ class SubscribePlanController extends Controller
{
$user = auth()->user();
$subscribePlans = UserSubscriber::where('user_id', $user->id)
->where('expired_at', '>', now())
->orderBy('expired_at', 'desc')
// 1. Fetch all active subscriptions
$activeSubscribers = $user->userSubscribers()
->with('subscribe')
->where('expired_at', '>=', now())
->get();
if ($subscribePlans->isNotEmpty()) {
$totalExpiredDays = $subscribePlans->sum('expired_day');
$latestPlan = $subscribePlans->first();
if ($activeSubscribers->isNotEmpty()) {
// 2. Separate Paid plans from Free plans
$paidSubscriptions = $activeSubscribers->reject(function ($subscriber) {
return $subscriber->is_free || ($subscriber->subscribe && $subscriber->subscribe->is_free);
});
// 3. AUTHORITY RULE: Paid plans absolutely override Free plans!
if ($paidSubscriptions->isNotEmpty()) {
$latestSubscription = $paidSubscriptions->sortByDesc('expired_at')->first();
} else {
$latestSubscription = $activeSubscribers->sortByDesc('expired_at')->first();
}
// 4. Calculate accurate remaining days
$expiredDays = $latestSubscription->expired_at->diffInDays(now());
$isFree = (bool) (
$latestSubscription->is_free ||
($latestSubscription->subscribe && $latestSubscription->subscribe->is_free)
);
// 5. Format the exact same object expected by the frontend
$subscribePlanData = [
'id' => $latestPlan->id,
'name' => $latestPlan->subscribe->name,
'price' => $latestPlan->subscribe->price,
'expired_day' => $totalExpiredDays,
'is_free' => $latestPlan->subscribe->is_free == 1,
'expired_at' => Carbon::now()->addDays($totalExpiredDays)->format('Y-m-d'),
'id' => $latestSubscription->id,
'name' => $latestSubscription->subscribe->name ?? 'Subscription',
'price' => $latestSubscription->subscribe->price ?? 0,
'expired_day' => $expiredDays,
'is_free' => $isFree,
'expired_at' => $latestSubscription->expired_at->format('Y-m-d'),
];
return $this->success($subscribePlanData, 'Subscribe Plan', 'Current Subscribe Plan');

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
class TicketApiController extends Controller
{
use BaseApiResponse;
public function index()
{
$tickets = Ticket::where('user_id', auth()->id())
->orderBy('updated_at', 'desc')
->get()
->map(function ($ticket) {
return [
'id' => $ticket->id,
'subject' => $ticket->subject,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at,
'updated_at' => $ticket->updated_at,
];
});
return $this->success($tickets, 'موفق', 'لیست تیکت‌ها با موفقیت دریافت شد');
}
public function show($id)
{
$ticket = Ticket::with(['messages' => function ($query) {
$query->orderBy('created_at', 'asc');
}])->where('user_id', auth()->id())->find($id);
if (!$ticket) {
return $this->failed([], 'یافت نشد', 'تیکت مورد نظر یافت نشد.', 404);
}
$formattedTicket = [
'id' => $ticket->id,
'subject' => $ticket->subject,
'status' => $ticket->status,
'priority' => $ticket->priority,
'created_at' => $ticket->created_at,
'messages' => $ticket->messages->map(function ($msg) {
return [
'id' => $msg->id,
// If user_id matches the logged in user, it's their message. Otherwise, it's admin's reply.
'is_me' => $msg->user_id === auth()->id(),
'message' => $msg->message,
'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
'created_at' => $msg->created_at,
];
}),
];
return $this->success($formattedTicket, 'موفق', 'جزئیات تیکت دریافت شد');
}
public function store(Request $request)
{
$validated = $request->validate([
'subject' => 'required|string|max:255',
'priority' => 'nullable|in:low,medium,high',
'message' => 'required|string',
'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120', // Max 5MB
]);
$ticket = Ticket::create([
'user_id' => auth()->id(),
'subject' => $validated['subject'],
'status' => 'open',
'priority' => $validated['priority'] ?? 'medium',
]);
$attachmentPath = null;
if ($request->hasFile('attachment')) {
$attachmentPath = $request->file('attachment')->store('tickets', 'public');
}
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => auth()->id(),
'message' => $validated['message'],
'attachment_path' => $attachmentPath,
]);
return $this->success(['ticket_id' => $ticket->id], 'موفق', 'تیکت شما با موفقیت ثبت شد');
}
public function reply(Request $request, $id)
{
$validated = $request->validate([
'message' => 'required|string',
'attachment' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120',
]);
$ticket = Ticket::where('user_id', auth()->id())->find($id);
if (!$ticket) {
return $this->failed([], 'خطا', 'تیکت یافت نشد', 404);
}
$attachmentPath = null;
if ($request->hasFile('attachment')) {
$attachmentPath = $request->file('attachment')->store('tickets', 'public');
}
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => auth()->id(),
'message' => $validated['message'],
'attachment_path' => $attachmentPath,
]);
$ticket->update(['status' => 'user_reply']);
return $this->success([], 'موفق', 'پاسخ شما با موفقیت ثبت شد');
}
}

View File

@@ -58,8 +58,8 @@ class VolumController extends Controller
$perPage = $request->per_page ?? 10;
if ($bookId) {
$book = Book::with(['divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'art'])
// ترتیب درست اعمال شد
$book = Book::with(['divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'art'])
->find($bookId);
if (! $book) {
@@ -68,7 +68,7 @@ class VolumController extends Controller
$data = [];
$foundRelation = false;
foreach (['divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'art'] as $relation) {
foreach (['divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'art'] as $relation) {
if ($book->{$relation}->count() > 0) {
$foundRelation = true;
$items = $book->{$relation}()->paginate($perPage);
@@ -96,7 +96,7 @@ class VolumController extends Controller
}
if ($division_id) {
$division = Division::with(['sections', 'chapters', 'parts', 'gate', 'branch', 'art'])
$division = Division::with(['sections', 'chapters', 'parts', 'gates', 'branchs', 'art'])
->find($division_id);
if (! $division) {
@@ -105,7 +105,7 @@ class VolumController extends Controller
$data = [];
$foundRelation = false;
foreach (['sections', 'chapters', 'parts', 'gate', 'branch', 'art'] as $relation) {
foreach (['sections', 'chapters', 'parts', 'gates', 'branchs', 'art'] as $relation) {
if ($division->{$relation}->count() > 0) {
$foundRelation = true;
$items = $division->{$relation}()->paginate($perPage);
@@ -130,7 +130,7 @@ class VolumController extends Controller
}
if ($section_id) {
$section = Section::with(['chapters', 'parts', 'gate', 'branch', 'art'])
$section = Section::with(['chapters', 'parts', 'gates', 'branchs', 'art'])
->find($section_id);
if (! $section) {
@@ -139,7 +139,7 @@ class VolumController extends Controller
$data = [];
$foundRelation = false;
foreach (['chapters', 'parts', 'gate', 'branch', 'art'] as $relation) {
foreach (['chapters', 'parts', 'gates', 'branchs', 'art'] as $relation) {
if ($section->{$relation}->count() > 0) {
$foundRelation = true;
$items = $section->{$relation}()->paginate($perPage);
@@ -164,7 +164,7 @@ class VolumController extends Controller
}
if ($volumeId) {
$volume = Volum::with(['book', 'divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'art'])
$volume = Volum::with(['book', 'divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'art'])
->find($volumeId);
if (! $volume) {
return $this->success([], 'Volume not found');
@@ -196,8 +196,42 @@ class VolumController extends Controller
}
}
if ($chapter_id) {
$chapter = Chapter::with(['parts', 'gates', 'branchs', 'art'])
->find($chapter_id);
if (! $chapter) {
return $this->success([], 'Chapter not found');
}
$data = [];
$foundRelation = false;
foreach (['parts', 'gates', 'branchs', 'art'] as $relation) {
if ($chapter->{$relation}->count() > 0) {
$foundRelation = true;
$items = $chapter->{$relation}()->paginate($perPage);
foreach ($items as $item) {
$data[] = [
'id' => $item->id,
'title' => $item->title,
'number' => $item->number,
'type' => $relation,
'is_locked' => auth()->user()->isSubscriber() !== false ? false : Law::where('id', $item->law_id)->first()?->is_locked,
];
}
break;
}
}
if ($foundRelation) {
return $this->success($data, 'Success');
} else {
return $this->success([], 'No relations found');
}
}
if ($part_id) {
$part = Part::with(['gate', 'branch', 'art'])
$part = Part::with(['gates', 'branchs', 'art'])
->find($part_id);
if (! $part) {
@@ -206,7 +240,7 @@ class VolumController extends Controller
$data = [];
$foundRelation = false;
foreach (['gate', 'branch', 'art'] as $relation) {
foreach (['gates', 'branchs', 'art'] as $relation) {
if ($part->{$relation}->count() > 0) {
$foundRelation = true;
$items = $part->{$relation}()->paginate($perPage);
@@ -230,20 +264,20 @@ class VolumController extends Controller
}
}
if ($chapter_id) {
$chapter = Chapter::with(['parts', 'gate', 'branch', 'art'])
->find($chapter_id);
if ($gate_id) {
$gate = Gate::with(['branchs', 'art'])
->find($gate_id);
if (! $chapter) {
return $this->success([], 'Chapter not found');
if (! $gate) {
return $this->success([], 'Gate not found');
}
$data = [];
$foundRelation = false;
foreach (['parts', 'gate', 'branch', 'art'] as $relation) {
if ($chapter->{$relation}->count() > 0) {
foreach (['branchs', 'art'] as $relation) {
if ($gate->{$relation}->count() > 0) {
$foundRelation = true;
$items = $chapter->{$relation}()->paginate($perPage);
$items = $gate->{$relation}()->paginate($perPage);
foreach ($items as $item) {
$data[] = [
'id' => $item->id,
@@ -298,74 +332,6 @@ class VolumController extends Controller
}
}
if ($gate_id) {
$gate = Gate::with(['branch', 'art'])
->find($gate_id);
if (! $gate) {
return $this->success([], 'Gate not found');
}
$data = [];
$foundRelation = false;
foreach (['branch', 'art'] as $relation) {
if ($gate->{$relation}->count() > 0) {
$foundRelation = true;
$items = $gate->{$relation}()->paginate($perPage);
foreach ($items as $item) {
$data[] = [
'id' => $item->id,
'title' => $item->title,
'number' => $item->number,
'type' => $relation,
'is_locked' => auth()->user()->isSubscriber() !== false ? false : Law::where('id', $item->law_id)->first()?->is_locked,
];
}
break;
}
}
if ($foundRelation) {
return $this->success($data, 'Success');
} else {
return $this->success([], 'No relations found');
}
}
if ($volumeId) {
$volume = Volum::with(['book', 'divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs'])
->find($volumeId);
if (! $volume) {
return $this->success([], 'Volume not found');
}
$data = [];
$foundRelation = false;
foreach (['book', 'divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs'] as $relation) {
if ($volume->{$relation}->count() > 0) {
$foundRelation = true;
$items = $volume->{$relation}()->paginate($perPage);
foreach ($items as $item) {
$data[] = [
'id' => $item->id,
'title' => $item->title,
'number' => $item->number,
'type' => $relation,
'is_locked' => auth()->user()->isSubscriber() !== false ? false : Law::where('id', $item->law_id)->first()?->is_locked,
];
}
break;
}
}
if ($foundRelation) {
return $this->success($data, 'Success');
} else {
return $this->success([], 'No relations found');
}
}
if ($law_Id) {
$law = Law::find($law_Id);
@@ -579,12 +545,12 @@ class VolumController extends Controller
}
if ($lawId) {
$law = Law::with(['volums', 'divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'arts'])->find($lawId);
$law = Law::with(['volums', 'divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'arts'])->find($lawId);
if (! $law) {
return $this->success([], 'Law not found');
}
$data = $this->checkRelations($law, ['volums', 'book', 'divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'arts'], $perPage);
$data = $this->checkRelations($law, ['volums', 'book', 'divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'arts'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
@@ -592,27 +558,13 @@ class VolumController extends Controller
return $this->success([], 'No related records found in the law');
}
if ($bookId) {
$book = Book::with(['divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'art'])->find($bookId);
if (! $book) {
return $this->success([], 'Book not found');
}
$data = $this->checkRelations($book, ['divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
return $this->success([], 'No related records found in the book');
}
if ($volumeId) {
$volume = Volum::with(['book', 'divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'art'])->find($volumeId);
$volume = Volum::with(['book', 'divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'art'])->find($volumeId);
if (! $volume) {
return $this->success([], 'Volume not found');
}
$data = $this->checkRelations($volume, ['book', 'divisions', 'sections', 'gates', 'parts', 'chapters', 'branchs', 'art'], $perPage);
$data = $this->checkRelations($volume, ['book', 'divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
@@ -620,27 +572,27 @@ class VolumController extends Controller
return $this->success([], 'No related records found in the volume');
}
if ($sectionId) {
$section = Section::with(['gates', 'parts', 'chapters', 'branchs', 'art'])->find($sectionId);
if (! $section) {
return $this->success([], 'Section not found');
if ($bookId) {
$book = Book::with(['divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'art'])->find($bookId);
if (! $book) {
return $this->success([], 'Book not found');
}
$data = $this->checkRelations($section, ['gates', 'parts', 'chapters', 'branchs', 'art'], $perPage);
$data = $this->checkRelations($book, ['divisions', 'sections', 'chapters', 'parts', 'gates', 'branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
return $this->success([], 'No related records found in the section');
return $this->success([], 'No related records found in the book');
}
if ($divisionId) {
$division = Division::with(['gates', 'parts', 'chapters', 'branchs', 'art'])->find($divisionId);
$division = Division::with(['sections', 'chapters', 'parts', 'gates', 'branchs', 'art'])->find($divisionId);
if (! $division) {
return $this->success([], 'Division not found');
}
$data = $this->checkRelations($division, ['gates', 'parts', 'chapters', 'branchs', 'art'], $perPage);
$data = $this->checkRelations($division, ['sections', 'chapters', 'parts', 'gates', 'branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
@@ -648,41 +600,27 @@ class VolumController extends Controller
return $this->success([], 'No related records found in the division');
}
if ($partId) {
$part = Part::with(['gates', 'art'])->find($partId);
if (! $part) {
return $this->success([], 'Part not found');
if ($sectionId) {
$section = Section::with(['chapters', 'parts', 'gates', 'branchs', 'art'])->find($sectionId);
if (! $section) {
return $this->success([], 'Section not found');
}
$data = $this->checkRelations($part, ['gates', 'art'], $perPage);
$data = $this->checkRelations($section, ['chapters', 'parts', 'gates', 'branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
return $this->success([], 'No related records found in the part');
}
if ($branchId) {
$branch = Branch::with(['gates', 'chapters', 'art'])->find($branchId);
if (! $branch) {
return $this->success([], 'Branch not found');
}
$data = $this->checkRelations($branch, ['gates', 'chapters', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
return $this->success([], 'No related records found in the branch');
return $this->success([], 'No related records found in the section');
}
if ($chapterId) {
$chapter = Chapter::with(['gate', 'parts', 'art'])->find($chapterId);
$chapter = Chapter::with(['parts', 'gates', 'branchs', 'art'])->find($chapterId);
if (! $chapter) {
return $this->success([], 'Chapter not found');
}
$data = $this->checkRelations($chapter, ['gate', 'parts', 'art'], $perPage);
$data = $this->checkRelations($chapter, ['parts', 'gates', 'branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
@@ -690,13 +628,27 @@ class VolumController extends Controller
return $this->success([], 'No related records found in the chapter');
}
if ($partId) {
$part = Part::with(['gates', 'branchs', 'art'])->find($partId);
if (! $part) {
return $this->success([], 'Part not found');
}
$data = $this->checkRelations($part, ['gates', 'branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
return $this->success([], 'No related records found in the part');
}
if ($gateId) {
$gate = Gate::with(['art'])->find($gateId);
$gate = Gate::with(['branchs', 'art'])->find($gateId);
if (! $gate) {
return $this->success([], 'Gate not found');
}
$data = $this->checkRelations($gate, ['art'], $perPage);
$data = $this->checkRelations($gate, ['branchs', 'art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
@@ -704,6 +656,20 @@ class VolumController extends Controller
return $this->success([], 'No related records found in the gate');
}
if ($branchId) {
$branch = Branch::with(['art'])->find($branchId);
if (! $branch) {
return $this->success([], 'Branch not found');
}
$data = $this->checkRelations($branch, ['art'], $perPage);
if ($data) {
return $this->success($data, 'Success');
}
return $this->success([], 'No related records found in the branch');
}
return $this->success([], 'No valid filter specified');
}
@@ -857,4 +823,5 @@ class VolumController extends Controller
return array_values($route);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ArtHighlight extends Model {
protected $fillable = ['user_id', 'art_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BylawHighlight extends Model {
protected $fillable = ['user_id', 'bylaw_document_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
}

11
app/Models/BylawNote.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BylawNote extends Model
{
protected $table = 'bylaw_notes';
protected $guarded = [];
}

View File

@@ -16,6 +16,21 @@ class Folder extends Model
public function arts()
{
return $this->belongsToMany(Art::class, 'folder_art');
return $this->belongsToMany(Art::class, 'folder_art')->withTimestamps();
}
public function bylaws()
{
return $this->belongsToMany(BylawDocument::class, 'folder_bylaw', 'folder_id', 'bylaw_document_id')->withTimestamps();
}
public function judicialPrecedents()
{
return $this->belongsToMany(JudicialPrecedent::class, 'folder_judicial_precedent', 'folder_id', 'judicial_precedent_id')->withTimestamps();
}
public function legalOpinions()
{
return $this->belongsToMany(LegalOpinion::class, 'folder_legal_opinion', 'folder_id', 'legal_opinion_id')->withTimestamps();
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FolderBylaw extends Model
{
use HasFactory;
protected $table = 'folder_bylaw';
protected $fillable = ['folder_id', 'bylaw_document_id'];
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FolderJudicialPrecedent extends Model
{
use HasFactory;
protected $table = 'folder_judicial_precedent';
protected $fillable = ['folder_id', 'judicial_precedent_id'];
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class FolderLegalOpinion extends Model
{
use HasFactory;
protected $table = 'folder_legal_opinion';
protected $fillable = ['folder_id', 'legal_opinion_id'];
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GoldCurrency extends Model
{
use HasFactory;
protected $fillable = [
'name',
'name_en',
'price',
'change_percent',
'change_value',
'symbol',
'unit',
'time_unix',
];
protected $casts = [
'price' => 'integer',
'change_percent' => 'float',
'change_value' => 'integer',
'time_unix' => 'integer',
];
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class JudicialPrecedentHighlight extends Model {
protected $fillable = ['user_id', 'judicial_precedent_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class JudicialPrecedentNote extends Model
{
protected $table = 'judicial_precedent_notes';
protected $guarded = [];
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LegalOpinionHighlight extends Model {
protected $fillable = ['user_id', 'legal_opinion_id', 'start_index', 'end_index', 'selected_text', 'color_code'];
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LegalOpinionNote extends Model
{
protected $table = 'legal_opinion_notes';
protected $guarded = [];
}

47
app/Models/PvpMatch.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PvpMatch extends Model
{
use HasFactory;
protected $fillable = [
'status',
'started_at',
'ended_at',
'winner_id',
];
protected $casts = [
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
// The two players in this match
public function players()
{
return $this->hasMany(PvpMatchPlayer::class, 'pvp_match_id');
}
// All answers submitted in this match
public function answers()
{
return $this->hasMany(PvpMatchAnswer::class, 'pvp_match_id');
}
// The 5 random questions assigned to this match via pivot table
public function questions()
{
return $this->belongsToMany(Question::class, 'pvp_match_questions', 'pvp_match_id', 'question_id');
}
// The user who won
public function winner()
{
return $this->belongsTo(User::class, 'winner_id');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PvpMatchAnswer extends Model
{
use HasFactory;
protected $fillable = [
'pvp_match_id',
'user_id',
'question_id',
'selected_option_id',
'is_correct',
'time_taken',
'points_earned',
];
protected $casts = [
'is_correct' => 'boolean',
'time_taken' => 'float',
'points_earned' => 'float',
];
public function match()
{
return $this->belongsTo(PvpMatch::class, 'pvp_match_id');
}
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function question()
{
return $this->belongsTo(Question::class, 'question_id');
}
public function selectedOption()
{
return $this->belongsTo(QuestionOption::class, 'selected_option_id');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PvpMatchPlayer extends Model
{
use HasFactory;
protected $fillable = [
'pvp_match_id',
'user_id',
'score',
];
protected $casts = [
'score' => 'float',
];
public function match()
{
return $this->belongsTo(PvpMatch::class, 'pvp_match_id');
}
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
}

46
app/Models/Question.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Question extends Model
{
use HasFactory;
protected $fillable = [
'quiz_id',
'subject_id',
'question_text',
'type',
'score',
'solution_explanation',
'order',
];
protected $casts = [
'score' => 'float',
'order' => 'integer',
];
public function quiz()
{
return $this->belongsTo(Quiz::class, 'quiz_id');
}
public function subject()
{
return $this->belongsTo(QuizSubject::class, 'subject_id');
}
public function options()
{
return $this->hasMany(QuestionOption::class, 'question_id');
}
public function userAnswers()
{
return $this->hasMany(UserQuizAnswer::class, 'question_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class QuestionOption extends Model
{
use HasFactory;
protected $fillable = [
'question_id',
'option_text',
'is_correct',
];
protected $casts = [
'is_correct' => 'boolean',
];
public function question()
{
return $this->belongsTo(Question::class, 'question_id');
}
}

47
app/Models/Quiz.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Quiz extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'title',
'slug',
'year',
'type',
'duration_minutes',
'has_negative_score',
'is_free',
'is_published',
'description',
];
protected $casts = [
'year' => 'integer',
'duration_minutes' => 'integer',
'has_negative_score' => 'boolean',
'is_free' => 'boolean',
'is_published' => 'boolean',
];
public function category()
{
return $this->belongsTo(QuizCategory::class, 'category_id');
}
public function questions()
{
return $this->hasMany(Question::class, 'quiz_id')->orderBy('order');
}
public function attempts()
{
return $this->hasMany(UserQuizAttempt::class, 'quiz_id');
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class QuizCategory extends Model
{
use HasFactory;
protected $fillable = [
'title',
'slug',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function quizzes()
{
return $this->hasMany(Quiz::class, 'category_id');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class QuizSubject extends Model
{
use HasFactory;
protected $fillable = [
'title',
'slug',
];
public function questions()
{
return $this->hasMany(Question::class, 'subject_id');
}
}

25
app/Models/Ticket.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Ticket extends Model
{
protected $fillable = [
'user_id',
'subject',
'status', // 'open', 'answered', 'user_reply', 'closed'
'priority', // 'low', 'medium', 'high'
];
public function messages()
{
return $this->hasMany(TicketMessage::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\User;
class TicketMessage extends Model
{
protected $fillable = [
'ticket_id',
'user_id',
'message',
'attachment_path',
];
public function ticket()
{
return $this->belongsTo(Ticket::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserQuizAnswer extends Model
{
use HasFactory;
protected $fillable = [
'attempt_id',
'question_id',
'selected_option_id',
'text_answer',
'is_correct',
'earned_score',
'reviewer_feedback',
];
protected $casts = [
'is_correct' => 'boolean',
'earned_score' => 'float',
];
public function attempt()
{
return $this->belongsTo(UserQuizAttempt::class, 'attempt_id');
}
public function question()
{
return $this->belongsTo(Question::class, 'question_id');
}
public function selectedOption()
{
return $this->belongsTo(QuestionOption::class, 'selected_option_id');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserQuizAttempt extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'quiz_id',
'started_at',
'completed_at',
'total_score',
'correct_answers',
'wrong_answers',
'unanswered',
'status',
];
protected $casts = [
'started_at' => 'datetime',
'completed_at' => 'datetime',
'total_score' => 'float',
'correct_answers' => 'integer',
'wrong_answers' => 'integer',
'unanswered' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function quiz()
{
return $this->belongsTo(Quiz::class, 'quiz_id');
}
public function answers()
{
return $this->hasMany(UserQuizAnswer::class, 'attempt_id');
}
}

View File

@@ -0,0 +1,234 @@
@extends('admin.layouts.app')
@section('content')
<!-- 1. Statistics Cards -->
<div class="row">
<div class="col-md-4">
<div class="card mini-stats-wid text-white bg-primary">
<div class="card-body">
<div class="d-flex">
<div class="flex-grow-1">
<p class="text-white-50 font-weight-medium mb-2">کل کاربران</p>
<h4 class="text-white mb-0">{{ number_format($totalUsers) }}</h4>
</div>
<div class="mini-stat-icon avatar-sm rounded-circle bg-white align-self-center">
<span class="avatar-title bg-white text-primary"><i class="mdi mdi-account-group font-size-24"></i></span>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mini-stats-wid text-white bg-success">
<div class="card-body">
<div class="d-flex">
<div class="flex-grow-1">
<p class="text-white-50 font-weight-medium mb-2">اشتراک‌های فعال (پولی)</p>
<h4 class="text-white mb-2">{{ number_format($paidSubscribers) }}</h4>
<!-- Breakdown of Paid Plans -->
<div>
@foreach($paidBreakdown as $plan)
<span class="badge bg-light text-success font-size-12 me-1 mb-1 shadow-sm">
{{ $plan->display_name }}: {{ $plan->total }}
</span>
@endforeach
</div>
</div>
<div class="mini-stat-icon avatar-sm rounded-circle bg-white align-self-center">
<span class="avatar-title bg-white text-success"><i class="mdi mdi-cash-multiple font-size-24"></i></span>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mini-stats-wid text-white bg-info">
<div class="card-body">
<div class="d-flex">
<div class="flex-grow-1">
<p class="text-white-50 font-weight-medium mb-2">اشتراک‌های فعال (رایگان)</p>
<h4 class="text-white mb-0">{{ number_format($freeSubscribers) }}</h4>
</div>
<div class="mini-stat-icon avatar-sm rounded-circle bg-white align-self-center">
<span class="avatar-title bg-white text-info"><i class="mdi mdi-gift font-size-24"></i></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. Charts Section -->
<div class="row mb-4">
<!-- Line Chart: New Users -->
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title mb-4">ثبت‌نام کاربران جدید (۷ روز گذشته)</h4>
<div id="users-line-chart" style="height: 350px;"></div>
</div>
</div>
</div>
<!-- Column Chart: Subscriptions -->
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title mb-4">اشتراک‌های ثبت شده (۷ روز گذشته)</h4>
<div id="subs-column-chart" style="height: 350px;"></div>
</div>
</div>
</div>
</div>
<!-- 3. Recent Tables Section -->
<div class="row">
<!-- Recent Tickets -->
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title mb-4">آخرین تیکت‌ها</h4>
<div class="table-responsive">
<table class="table table-hover table-centered table-nowrap mb-0">
<thead>
<tr>
<th>کاربر</th>
<th>موضوع</th>
<th>وضعیت</th>
<th>مشاهده</th>
</tr>
</thead>
<tbody>
@forelse($recentTickets as $ticket)
<tr>
<td>{{ optional($ticket->user)->mobile ?? 'ناشناس' }}</td>
<td>{{ Str::limit($ticket->subject, 30) }}</td>
<td>
@if($ticket->status === 'open' || $ticket->status === 'user_reply')
<span class="badge bg-danger">نیاز به پاسخ</span>
@else
<span class="badge bg-success">پاسخ داده شده</span>
@endif
</td>
<td><a href="{{ route('tickets.show', $ticket->id) }}" class="btn btn-sm btn-primary">مشاهده</a></td>
</tr>
@empty
<tr><td colspan="4" class="text-center">تیکتی وجود ندارد</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Recent Suggestions -->
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title mb-4">آخرین پیشنهادات</h4>
<div class="table-responsive">
<table class="table table-hover table-centered table-nowrap mb-0">
<thead>
<tr>
<th>کاربر</th>
<th>پیشنهاد</th>
<th>تاریخ ثبت</th>
</tr>
</thead>
<tbody>
@forelse($recentSuggestions as $suggestion)
<tr>
<td>{{ optional($suggestion->user)->mobile ?? 'ناشناس' }}</td>
<td title="{{ $suggestion->text }}">{{ Str::limit($suggestion->text, 40) }}</td>
<td dir="ltr">{{ \Morilog\Jalali\Jalalian::fromCarbon($suggestion->created_at)->format('Y/m/d H:i') }}</td>
</tr>
@empty
<tr><td colspan="3" class="text-center">پیشنهادی وجود ندارد</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
// --- 1. Line Chart: New Users ---
var userOptions = {
series: [{
name: "کاربران جدید",
data: {!! json_encode($newUsersData) !!}
}],
chart: {
height: 350,
type: 'line',
fontFamily: 'Vazirmatn, Tahoma, sans-serif',
toolbar: { show: false }
},
stroke: {
width: 3,
curve: 'smooth' // Makes the line wavy and modern
},
colors: ['#007bff'],
xaxis: {
categories: {!! json_encode($chartDates) !!}
},
dataLabels: { enabled: false }
};
var userChart = new ApexCharts(document.querySelector("#users-line-chart"), userOptions);
userChart.render();
// --- 2. Column Chart: Subscriptions ---
var subsOptions = {
series: [{
name: 'اشتراک پولی',
data: {!! json_encode($paidSubsData) !!}
}, {
name: 'اشتراک رایگان',
data: {!! json_encode($freeSubsData) !!}
}],
chart: {
type: 'bar', // 'bar' is used for column charts in ApexCharts
height: 350,
fontFamily: 'Vazirmatn, Tahoma, sans-serif',
toolbar: { show: false }
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '50%',
borderRadius: 4
},
},
dataLabels: { enabled: false },
stroke: { show: true, width: 2, colors: ['transparent'] },
colors: ['#28a745', '#17a2b8'], // Green for paid, Info/Blue for free
xaxis: {
categories: {!! json_encode($chartDates) !!}
},
fill: { opacity: 1 },
tooltip: {
y: {
formatter: function (val) {
return val + " عدد"
}
}
}
};
var subsChart = new ApexCharts(document.querySelector("#subs-column-chart"), subsOptions);
subsChart.render();
});
</script>
@endsection

View File

@@ -43,6 +43,7 @@
<div class="rightbar-overlay"></div>
@include('admin.partial.scripts')
@yield('scripts')
</body>
</html>
</html>

View File

@@ -23,6 +23,51 @@
</li>
<li>
<a href="{{ route('tickets.index') }}" class="waves-effect">
<i class="mdi mdi-headset"></i>
<span>تیکت‌های پشتیبانی</span>
@php
$openTicketsCount = \App\Models\Ticket::whereIn('status', ['open', 'user_reply'])->count();
@endphp
@if($openTicketsCount > 0)
<span class="badge badge-pill badge-danger float-right">{{ $openTicketsCount }}</span>
@endif
</a>
</li>
<li>
<a href="javascript:%20void(0);" class="has-arrow waves-effect">
<i class="mdi mdi-account-circle-outline"></i>
<span>پیشنهادات</span>
</a>
<ul class="sub-menu" aria-expanded="false">
<li><a href="{{route('suggestions.index')}}">پیشنهادات</a></li>
</ul>
</li>
<li>
<a href="{{ route('quizzes.index') }}" class="waves-effect">
<i class="mdi mdi-format-list-checks"></i>
<span>مدیریت آزمون‌ها</span>
</a>
</li>
<li>
<a href="{{ route('quiz-categories.index') }}" class="waves-effect">
<i class="mdi mdi-shape-plus"></i>
<span>دسته‌بندی آزمون‌ها</span>
</a>
</li>
<li>
<a href="{{ route('quiz-subjects.index') }}" class="waves-effect">
<i class="mdi mdi-book-open-page-variant"></i>
<span>مدیریت دروس</span>
</a>
</li>
<li>
<a href="javascript:%20void(0);" class="has-arrow waves-effect">
<i class="mdi mdi-account-circle-outline"></i>
@@ -256,15 +301,6 @@
</ul>
</li>
<li>
<a href="javascript:%20void(0);" class="has-arrow waves-effect">
<i class="mdi mdi-account-circle-outline"></i>
<span>پیشنهادات</span>
</a>
<ul class="sub-menu" aria-expanded="false">
<li><a href="{{route('suggestions.index')}}">پیشنهادات</a></li>
</ul>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,82 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
<!-- Form to Add Category -->
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-primary text-white">
<h6 class="mb-0 text-white">افزودن دسته‌بندی جدید</h6>
</div>
<div class="card-body">
<form action="{{ route('quiz-categories.store') }}" method="POST">
@csrf
<div class="mb-3">
<label class="form-label">عنوان دسته‌بندی <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" placeholder="مثال: کانون وکلا" required>
</div>
<div class="mb-3">
<label class="form-label">توضیحات (اختیاری)</label>
<textarea name="description" class="form-control" rows="3"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="is_active" id="isActive" checked>
<label class="form-check-label" for="isActive">فعال باشد</label>
</div>
<button type="submit" class="btn btn-success w-100">ثبت دسته‌بندی</button>
</form>
</div>
</div>
</div>
<!-- List of Categories -->
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-4">دسته‌بندی‌های موجود</h5>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>عنوان</th>
<th>تعداد آزمون‌ها</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@forelse($categories as $category)
<tr>
<td><strong>{{ $category->title }}</strong></td>
<td>{{ $category->quizzes_count }} آزمون</td>
<td>
@if($category->is_active)
<span class="badge bg-success">فعال</span>
@else
<span class="badge bg-danger">غیرفعال</span>
@endif
</td>
<td>
<form action="{{ route('quiz-categories.destroy', $category->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا از حذف این دسته‌بندی اطمینان دارید؟')">
@csrf
@method('DELETE')
<button class="btn btn-sm btn-outline-danger">حذف</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center py-3">هیچ دسته‌بندی یافت نشد.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,77 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
<!-- Form to Add Subject -->
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-primary text-white">
<h6 class="mb-0 text-white">افزودن درس جدید</h6>
</div>
<div class="card-body">
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('quiz-subjects.store') }}" method="POST">
@csrf
<div class="mb-3">
<label class="form-label">عنوان درس <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" value="{{ old('title') }}" placeholder="مثال: حقوق مدنی" required>
</div>
<button type="submit" class="btn btn-success w-100">ثبت درس</button>
</form>
</div>
</div>
</div>
<!-- List of Subjects -->
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-4">دروس ثبت شده</h5>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>عنوان درس</th>
<th>تعداد سوالات مرتبط</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@forelse($subjects as $subject)
<tr>
<td><strong>{{ $subject->title }}</strong></td>
<td>
<span class="badge bg-soft-info text-info">{{ $subject->questions_count }} سوال</span>
</td>
<td>
<form action="{{ route('quiz-subjects.destroy', $subject->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا از حذف این درس اطمینان دارید؟')">
@csrf
@method('DELETE')
<button class="btn btn-sm btn-outline-danger">حذف</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="3" class="text-center py-3">هیچ درسی یافت نشد.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,94 @@
@extends('admin.layouts.app')
@section('content')
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0 text-white">تعریف آزمون جدید</h5>
</div>
<div class="card-body">
<!-- Display Validation Errors if any -->
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('quizzes.store') }}" method="POST">
@csrf
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">عنوان آزمون <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" value="{{ old('title') }}" placeholder="مثلاً: آزمون وکالت کانون وکلا سال ۱۴۰۲" required>
</div>
<div class="col-md-6">
<label class="form-label">دسته‌بندی <span class="text-danger">*</span></label>
<select name="category_id" class="form-select" required>
<option value="">انتخاب کنید...</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
{{ $category->title }}
</option>
@endforeach
</select>
</div>
<div class="col-md-4">
<label class="form-label">سال برگزاری</label>
<input type="number" name="year" class="form-control" value="{{ old('year') }}" placeholder="1402">
</div>
<div class="col-md-4">
<label class="form-label">نوع سوالات <span class="text-danger">*</span></label>
<select name="type" class="form-select" required>
<option value="multiple_choice" {{ old('type') == 'multiple_choice' ? 'selected' : '' }}>چهار گزینه‌ای (تستی)</option>
<option value="descriptive" {{ old('type') == 'descriptive' ? 'selected' : '' }}>تشریحی</option>
<option value="mixed" {{ old('type') == 'mixed' ? 'selected' : '' }}>ترکیبی</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">مدت زمان (دقیقه)</label>
<input type="number" name="duration_minutes" class="form-control" value="{{ old('duration_minutes', 60) }}" required>
</div>
<div class="col-md-12">
<label class="form-label">توضیحات آزمون</label>
<textarea name="description" class="form-control" rows="3" placeholder="توضیحات و راهنمای شرکت در آزمون...">{{ old('description') }}</textarea>
</div>
<div class="col-md-4">
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="has_negative_score" id="negScore" {{ old('has_negative_score', true) ? 'checked' : '' }}>
<label class="form-check-label" for="negScore">محاسبه نمره منفی (برای تستی)</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="is_free" id="isFree" {{ old('is_free') ? 'checked' : '' }}>
<label class="form-check-label" for="isFree">آزمون رایگان است (بدون نیاز به اشتراک)</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="is_published" id="isPub" {{ old('is_published', true) ? 'checked' : '' }}>
<label class="form-check-label" for="isPub">انتشار فوری روی اپلیکیشن</label>
</div>
</div>
<div class="col-12 mt-4 text-end">
<a href="{{ route('quizzes.index') }}" class="btn btn-light me-2">انصراف</a>
<button type="submit" class="btn btn-success">ثبت و ورود به مرحله ثبت سوالات</button>
</div>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,71 @@
@extends('admin.layouts.app')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">مدیریت آزمون‌ها</h4>
<a href="{{ route('quizzes.create') }}" class="btn btn-primary">+ تعریف آزمون جدید</a>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>عنوان آزمون</th>
<th>دسته‌بندی</th>
<th>سال</th>
<th>نوع</th>
<th>تعداد سوالات</th>
<th>مدت (دقیقه)</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@forelse($quizzes as $quiz)
<tr>
<td><strong>{{ $quiz->title }}</strong></td>
<td><span class="badge bg-soft-info text-info">{{ optional($quiz->category)->title }}</span></td>
<td>{{ $quiz->year ?? '-' }}</td>
<td>
@if($quiz->type === 'multiple_choice')
<span class="badge bg-primary">چهار گزینه‌ای</span>
@elseif($quiz->type === 'descriptive')
<span class="badge bg-warning">تشریحی</span>
@else
<span class="badge bg-secondary">ترکیبی</span>
@endif
</td>
<td>{{ $quiz->questions_count }} سوال</td>
<td>{{ $quiz->duration_minutes }} دقیقه</td>
<td>
@if($quiz->is_published)
<span class="badge bg-success">منتشر شده</span>
@else
<span class="badge bg-secondary">پیش‌نویس</span>
@endif
</td>
<td>
<a href="{{ route('quizzes.questions.index', $quiz->id) }}" class="btn btn-sm btn-info me-1">مدیریت سوالات</a>
<form action="{{ route('quizzes.destroy', $quiz->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا از حذف این آزمون اطمینان دارید؟')">
@csrf
@method('DELETE')
<button class="btn btn-sm btn-danger">حذف</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-4">هیچ آزمونی ثبت نشده است.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-3">
{{ $quizzes->links() }}
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,165 @@
@extends('admin.layouts.app')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<h4>مدیریت سوالات: {{ $quiz->title }}</h4>
<a href="{{ route('quizzes.index') }}" class="btn btn-outline-secondary">بازگشت به لیست آزمون‌ها</a>
</div>
<div class="row">
<!-- Form to Add Question -->
<div class="col-lg-5">
<div class="card">
<div class="card-header bg-primary text-white">
<h6 class="mb-0 text-white">افزودن سوال جدید</h6>
</div>
<div class="card-body">
<!-- 🚨 Display Validation Errors Here 🚨 -->
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('quizzes.questions.store', $quiz->id) }}" method="POST">
@csrf
<div class="mb-3">
<label class="form-label">نوع سوال</label>
<select name="type" id="questionTypeSelect" class="form-select" onchange="toggleOptionFields(this.value)">
<option value="multiple_choice" {{ old('type') == 'multiple_choice' ? 'selected' : '' }}>چهار گزینه‌ای (تستی)</option>
<option value="descriptive" {{ old('type') == 'descriptive' ? 'selected' : '' }}>تشریحی</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">درس مربوطه</label>
<select name="subject_id" class="form-select">
<option value="">انتخاب درس (مثلاً: حقوق مدنی)</option>
@foreach($subjects as $subject)
<option value="{{ $subject->id }}" {{ old('subject_id') == $subject->id ? 'selected' : '' }}>
{{ $subject->title }}
</option>
@endforeach
</select>
</div>
<div class="mb-3">
<label class="form-label">متن سوال / مسئله حقوقی <span class="text-danger">*</span></label>
<textarea name="question_text" class="form-control" rows="4" required placeholder="صورت سوال یا مسئله حقوقی را وارد کنید...">{{ old('question_text') }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">بارم / ضریب سوال</label>
<input type="number" step="0.25" name="score" class="form-control" value="{{ old('score', '1.00') }}" required>
</div>
<!-- Multiple Choice Options Box -->
<div id="optionsContainer">
<h6 class="border-bottom pb-2 mb-3">گزینه‌ها و انتخاب پاسخ صحیح:</h6>
@for($i = 0; $i < 4; $i++)
<div class="input-group mb-2">
<div class="input-group-text">
<!-- Keep the correct radio button checked -->
<input class="form-check-input mt-0" type="radio" name="correct_option" value="{{ $i }}" {{ old('correct_option', 0) == $i ? 'checked' : '' }}>
</div>
<!-- Keep the typed option text -->
<input type="text" name="options[]" class="form-control" value="{{ old('options.'.$i) }}" placeholder="متن گزینه {{ $i + 1 }}">
</div>
@endfor
<small class="text-muted d-block mb-3">دکمه رادیویی کنار هر گزینه نشان‌دهنده گزینه صحیح است.</small>
</div>
<div class="mb-3">
<label class="form-label">پاسخ تشریحی و مستندات مواد قانونی</label>
<textarea name="solution_explanation" class="form-control" rows="3" placeholder="توضیح پاسخ، استدلال و مواد قانونی مربوطه...">{{ old('solution_explanation') }}</textarea>
</div>
<button type="submit" class="btn btn-success w-100">افزودن سوال</button>
</form>
</div>
</div>
</div>
<!-- Questions List -->
<div class="col-lg-7">
<div class="card">
<div class="card-header bg-dark text-white">
<h6 class="mb-0 text-white">سوالات ثبت شده ({{ $quiz->questions->count() }})</h6>
</div>
<div class="card-body">
@forelse($quiz->questions as $index => $question)
<div class="border rounded p-3 mb-3 bg-light">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="badge bg-secondary">سوال شماره {{ $index + 1 }}</span>
<div>
@if($question->subject)
<span class="badge bg-info me-1">{{ $question->subject->title }}</span>
@endif
<span class="badge bg-{{ $question->type === 'multiple_choice' ? 'primary' : 'warning' }}">
{{ $question->type === 'multiple_choice' ? 'تستی' : 'تشریحی' }}
</span>
</div>
</div>
<p class="fw-bold mb-2">{{ $question->question_text }}</p>
@if($question->type === 'multiple_choice')
<div class="row g-2 mb-2">
@foreach($question->options as $optIndex => $option)
<div class="col-6">
<div class="p-2 border rounded {{ $option->is_correct ? 'bg-success text-white fw-bold' : 'bg-white' }}">
{{ $optIndex + 1 }}) {{ $option->option_text }}
</div>
</div>
@endforeach
</div>
@endif
@if($question->solution_explanation)
<div class="alert alert-secondary py-2 px-3 mt-2 mb-0 font-size-13">
<strong>پاسخ تشریحی / مستند قانون:</strong> {{ $question->solution_explanation }}
</div>
@endif
<div class="text-end mt-2">
<form action="{{ route('questions.destroy', $question->id) }}" method="POST" class="d-inline" onsubmit="return confirm('آیا این سوال حذف شود؟')">
@csrf
@method('DELETE')
<button class="btn btn-sm btn-outline-danger">حذف سوال</button>
</form>
</div>
</div>
@empty
<p class="text-center text-muted py-4">هنوز سوالی برای این آزمون ثبت نشده است.</p>
@endforelse
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
// Run this immediately on page load to check if we should hide options
// in case the page reloads with old('type') set to 'descriptive'
document.addEventListener("DOMContentLoaded", function() {
var typeSelect = document.getElementById('questionTypeSelect');
toggleOptionFields(typeSelect.value);
});
function toggleOptionFields(type) {
const container = document.getElementById('optionsContainer');
if (type === 'descriptive') {
container.style.display = 'none';
} else {
container.style.display = 'block';
}
}
</script>
@endsection

View File

@@ -1,48 +1,58 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
@include('admin.partial.list-filter', ['filterRoute' => 'subscribe-plans.index'])
<div id="datatable_wrapper" class="dataTables_wrapper dt-bootstrap4 no-footer">
<div class="row">
<div class="col-sm-12">
<table id="datatable" class="table table-bordered dt-responsive nowrap dataTable no-footer dtr-inline" style="border-collapse: collapse; border-spacing: 0px; width: 100%;" role="grid" aria-describedby="datatable_info">
<thead>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
@include('admin.partial.list-filter', ['filterRoute' => 'subscribe-plans.index'])
<div id="datatable_wrapper" class="dataTables_wrapper dt-bootstrap4 no-footer">
<div class="row">
<div class="col-sm-12">
<table id="datatable" class="table table-bordered dt-responsive nowrap dataTable no-footer dtr-inline" style="border-collapse: collapse; border-spacing: 0px; width: 100%;" role="grid" aria-describedby="datatable_info">
<thead>
<tr role="row">
<th>نام پلن</th>
<th>نوع (Type)</th>
<th>قیمت</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
</thead>
<tbody>
@foreach ($subscribePlans as $item)
<tr>
<td>{{ $item->name }}</td>
<td>{{ $item->price }}</td>
<td>
<form action="{{ route('subscribe-plans.destroy', $item->id) }}" method="POST" style="display: inline;">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger waves-effect waves-light" onclick="return confirm('آیا مطمئنید که می‌خواهید این کتاب را حذف کنید؟')">حذف</button>
</form>
<a href="{{route('subscribe-plans.edit',$item->id)}}" type="button" class="btn btn-primary waves-effect waves-light">ویرایش</a>
</td>
</tr>
<tr>
<td>{{ $item->name }}</td>
<td><span class="badge badge-info">{{ $item->type ?? 'ندارد' }}</span></td>
<td>{{ number_format($item->price) }} تومان</td>
<td>
@if($item->is_active)
<span class="badge badge-success">فعال</span>
@else
<span class="badge badge-danger">غیرفعال</span>
@endif
</td>
<td>
<form action="{{ route('subscribe-plans.destroy', $item->id) }}" method="POST" style="display: inline;">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger waves-effect waves-light" onclick="return confirm('آیا مطمئنید که می‌خواهید این پلن را حذف کنید؟')">حذف</button>
</form>
<a href="{{route('subscribe-plans.edit',$item->id)}}" type="button" class="btn btn-primary waves-effect waves-light">ویرایش</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</tbody>
</table>
</div>
</div>
<div class="d-flex justify-content-center mt-3">
{{ $subscribePlans->withQueryString()->links() }}
</div>
</div>
<div class="d-flex justify-content-center mt-3">
{{ $subscribePlans->withQueryString()->links() }}
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@endsection

View File

@@ -0,0 +1,76 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<!-- Reuses your existing search component -->
@include('admin.partial.list-filter', ['filterRoute' => 'tickets.index'])
<div id="datatable_wrapper" class="dataTables_wrapper dt-bootstrap4 no-footer">
<div class="row">
<div class="col-sm-12">
<table id="datatable" class="table table-bordered dt-responsive nowrap dataTable no-footer dtr-inline" style="border-collapse: collapse; border-spacing: 0px; width: 100%;" role="grid">
<thead>
<tr role="row">
<th>شناسه</th>
<th>کاربر (موبایل)</th>
<th>موضوع</th>
<th>وضعیت</th>
<th>اولویت</th>
<th>تاریخ بروزرسانی</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@foreach ($tickets as $ticket)
<tr>
<td>#{{ $ticket->id }}</td>
<td>{{ optional($ticket->user)->mobile ?? 'کاربر ناشناس' }}</td>
<td>{{ Str::limit($ticket->subject, 50) }}</td>
<td>
@if($ticket->status === 'open')
<span class="badge bg-primary">باز (جدید)</span>
@elseif($ticket->status === 'user_reply')
<span class="badge bg-warning text-dark">پاسخ کاربر</span>
@elseif($ticket->status === 'answered')
<span class="badge bg-success">پاسخ داده شده</span>
@else
<span class="badge bg-secondary">بسته شده</span>
@endif
</td>
<td>
@if($ticket->priority === 'high')
<span class="badge bg-danger">مهم</span>
@elseif($ticket->priority === 'medium')
<span class="badge bg-info">متوسط</span>
@else
<span class="badge bg-light text-dark">کم</span>
@endif
</td>
<td dir="ltr">{{ $ticket->updated_at->format('Y/m/d H:i') }}</td>
<td>
<a href="{{ route('tickets.show', $ticket->id) }}" class="btn btn-primary waves-effect waves-light btn-sm">مشاهده و پاسخ</a>
@if($ticket->status !== 'closed')
<form action="{{ route('tickets.close', $ticket->id) }}" method="POST" style="display: inline;">
@csrf
<button type="submit" class="btn btn-secondary waves-effect waves-light btn-sm" onclick="return confirm('آیا از بستن این تیکت اطمینان دارید؟')">بستن</button>
</form>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="d-flex justify-content-center mt-3">
{{ $tickets->withQueryString()->links() }}
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,69 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
@include('admin.layouts.errors')
<div class="col-lg-8 offset-lg-2">
<!-- Ticket Info Header -->
<div class="card mb-4">
<div class="card-body bg-light">
<h4 class="card-title">موضوع: {{ $ticket->subject }}</h4>
<p class="mb-0 text-muted">تیکت #{{ $ticket->id }} | ایجاد کننده: {{ optional($ticket->user)->mobile }}</p>
</div>
</div>
<!-- Chat History -->
@foreach($ticket->messages as $msg)
<div class="card mb-3 shadow-sm {{ $msg->user_id === $ticket->user_id ? 'border-primary' : 'border-success' }}">
<div class="card-header {{ $msg->user_id === $ticket->user_id ? 'bg-primary text-white' : 'bg-success text-white' }}">
<strong>{{ $msg->user_id === $ticket->user_id ? 'کاربر' : 'پشتیبانی (شما)' }}</strong>
<span class="float-end" dir="ltr" style="font-size: 0.85rem;">{{ $msg->created_at->format('Y/m/d H:i') }}</span>
</div>
<div class="card-body">
<p style="white-space: pre-wrap;">{{ $msg->message }}</p>
@if($msg->attachment_path)
<hr>
<a href="{{ asset('storage/' . $msg->attachment_path) }}" target="_blank" class="btn btn-sm btn-info waves-effect waves-light">
<i class="fas fa-paperclip"></i> مشاهده فایل ضمیمه
</a>
@endif
</div>
</div>
@endforeach
<!-- Reply Form -->
<div class="card mt-4">
<div class="card-body">
@if($ticket->status !== 'closed')
<h4 class="card-title mb-3">ارسال پاسخ</h4>
<!-- Notice the enctype="multipart/form-data" which is required for attachments -->
<form action="{{ route('tickets.reply', $ticket->id) }}" method="post" enctype="multipart/form-data">
@csrf
<div class="mb-4">
<label for="message">متن پاسخ</label>
<textarea class="form-control" name="message" id="message" rows="5" placeholder="پاسخ خود را بنویسید..." required></textarea>
</div>
<div class="mb-4">
<label for="attachment">فایل ضمیمه (اختیاری)</label>
<input class="form-control" name="attachment" id="attachment" type="file" accept=".jpg,.jpeg,.png,.pdf">
<small class="text-muted">فرمت‌های مجاز: jpg, png, pdf (حداکثر 5 مگابایت)</small>
</div>
<div class="d-flex justify-content-between">
<button type="submit" class="btn btn-success waves-effect waves-light">ارسال پاسخ</button>
<a href="{{ route('tickets.index') }}" class="btn btn-outline-secondary">بازگشت به لیست</a>
</div>
</form>
@else
<div class="alert alert-secondary text-center mb-0">
این تیکت بسته شده است و امکان ارسال پاسخ جدید وجود ندارد.
<br>
<a href="{{ route('tickets.index') }}" class="btn btn-secondary btn-sm mt-2">بازگشت به لیست</a>
</div>
@endif
</div>
</div>
</div>
</div>
@endsection

View File

@@ -27,7 +27,11 @@
<td>{{ $user->mobile }}</td>
<td>{{ $user->is_admin ? 'ادمین' : 'مشتری' }}</td>
<td>
{{ $user->userSubscribers->sum(function ($subscriber) { return $subscriber->expired_at ? $subscriber->expired_at->diffInDays(now()) : 0; }) }}
@php
$activeSub = $user->userSubscribers->sortByDesc('expired_at')->first();
$days = $activeSub && $activeSub->expired_at ? $activeSub->expired_at->diffInDays(now()) : 0;
@endphp
{{ $days }} روز
</td>
<td>
<form action="{{ route('users.destroy', $user->id) }}" id="user-{{$user->id}}-delete" method="POST" style="display: inline;">

View File

@@ -1,34 +1,86 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
@include('admin.layouts.errors')
<div class="row">
@include('admin.layouts.errors')
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title">ویرایش کاربر</h4>
<div>
<form action="{{ route('users.update',$user->id) }}" method="post">
@csrf
@method('put')
<div class="mb-4">
<input class="form-control" name="name" type="text" placeholder="نام" value="{{$user->name}}">
</div>
<div class="mb-4">
<input class="form-control" name="email" type="email" placeholder="ایمیل" value="{{$user->email}}">
</div>
<div class="mb-4">
<input class="form-control" name="mobile" type="text" placeholder="موبایل" value="{{$user->mobile}}">
</div>
<div class="custom-control custom-switch mb-2" dir="ltr">
<input type="checkbox" class="custom-control-input" id="customSwitch1" name="is_admin" {{ $user->is_admin ? 'checked="true"' :'' }}">
<label class="custom-control-label" for="customSwitch1">ادمین</label>
</div>
<button class="btn btn-primary" type="submit">ثبت</button>
</form>
<!-- Existing User Edit Box -->
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title">ویرایش کاربر</h4>
<div>
<form action="{{ route('users.update',$user->id) }}" method="post">
@csrf
@method('put')
<div class="mb-4">
<input class="form-control" name="name" type="text" placeholder="نام" value="{{$user->name}}">
</div>
<div class="mb-4">
<input class="form-control" name="email" type="email" placeholder="ایمیل" value="{{$user->email}}">
</div>
<div class="mb-4">
<input class="form-control" name="mobile" type="text" placeholder="موبایل" value="{{$user->mobile}}">
</div>
<div class="custom-control custom-switch mb-2" dir="ltr">
<input type="checkbox" class="custom-control-input" id="customSwitch1" name="is_admin" {{ $user->is_admin ? 'checked="true"' :'' }}>
<label class="custom-control-label" for="customSwitch1">ادمین</label>
</div>
<button class="btn btn-primary" type="submit">ثبت</button>
</form>
</div>
</div>
</div>
</div>
<!-- Manual Subscription & Revoke Box -->
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title mb-4">مدیریت اشتراک</h4>
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
<!-- ADD SUBSCRIPTION FORM -->
<form action="{{ route('admin.subscriptions.manual') }}" method="POST">
@csrf
<input type="hidden" name="user_id" value="{{ $user->id }}">
<div class="form-group mb-4">
<label for="subscribe_plan_id" class="form-label">انتخاب پلن اشتراک</label>
<select name="subscribe_plan_id" id="subscribe_plan_id" class="form-control" required>
<option value="">یک پلن انتخاب کنید...</option>
@foreach($paidPlans as $plan)
<option value="{{ $plan->id }}">
{{ $plan->name }} | نوع: {{ $plan->type ?? 'ندارد' }} | ({{ number_format($plan->price) }} تومان)
</option>
@endforeach
</select>
</div>
<button type="submit" class="btn btn-success w-100">ثبت اشتراک دستی</button>
</form>
<hr class="my-4">
<!-- REVOKE (0 DAYS) FORM -->
<h5 class="text-danger mb-2">لغو اشتراک</h5>
<p class="text-muted font-size-13">با این کار، تمامی روزهای باقی‌مانده از اشتراک فعلی کاربر صفر خواهد شد.</p>
<form action="{{ route('admin.subscriptions.revoke') }}" method="POST">
@csrf
<input type="hidden" name="user_id" value="{{ $user->id }}">
<button type="submit" class="btn btn-outline-danger w-100" onclick="return confirm('آیا مطمئنید که می‌خواهید اشتراک این کاربر را صفر کنید؟')">
صفر کردن اشتراک (۰ روز)
</button>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
@endsection

View File

@@ -11,14 +11,18 @@ use App\Http\Controllers\api\HomeController;
use App\Http\Controllers\api\LawController;
use App\Http\Controllers\api\NoteController;
use App\Http\Controllers\api\NotificationController;
use App\Http\Controllers\api\TicketApiController;
use App\Http\Controllers\api\PartController;
use App\Http\Controllers\api\PayController;
use App\Http\Controllers\api\SectionController;
use App\Http\Controllers\api\SubscribePlanController;
use App\Http\Controllers\api\VersionController;
use App\Http\Controllers\api\VolumController;
use App\Http\Controllers\api\CurrencyController;
use App\Http\Controllers\SuggestionController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\api\QuizController;
use App\Http\Controllers\api\PvPQuizController;
Route::prefix('v1')->group(function () {
Route::post('login', [AuthController::class, 'login']);
@@ -30,6 +34,7 @@ Route::prefix('v1')->group(function () {
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::get('home', [HomeController::class, 'index']);
Route::get('home-v2', [HomeController::class, 'homeV2']);
Route::get('home-v3', [HomeController::class, 'homeV3']); // <-- NEW V3 ENDPOINT
Route::get('notifications', [NotificationController::class, 'index']);
Route::get('art', [ArtController::class, 'index']);
Route::get('categories', [CategoriesController::class, 'index']);
@@ -95,4 +100,70 @@ Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
// Global Search
Route::post('global-search', [\App\Http\Controllers\api\GlobalSearchApiController::class, 'search']);
Route::post('global-search/fast-search', [\App\Http\Controllers\api\GlobalSearchApiController::class, 'fast_search']); // NEW
// Bylaw Notes
Route::post('/bylaw-notes', [\App\Http\Controllers\api\BylawNoteController::class, 'create']);
Route::put('/bylaw-notes/{id}', [\App\Http\Controllers\api\BylawNoteController::class, 'update']);
Route::delete('/bylaw-notes/{id}', [\App\Http\Controllers\api\BylawNoteController::class, 'destroy']);
// Judicial Precedent Notes
Route::post('/judicial-notes', [\App\Http\Controllers\api\JudicialPrecedentNoteController::class, 'create']);
Route::put('/judicial-notes/{id}', [\App\Http\Controllers\api\JudicialPrecedentNoteController::class, 'update']);
Route::delete('/judicial-notes/{id}', [\App\Http\Controllers\api\JudicialPrecedentNoteController::class, 'destroy']);
// Legal Opinion Notes
Route::post('/opinion-notes', [\App\Http\Controllers\api\LegalOpinionNoteController::class, 'create']);
Route::put('/opinion-notes/{id}', [\App\Http\Controllers\api\LegalOpinionNoteController::class, 'update']);
Route::delete('/opinion-notes/{id}', [\App\Http\Controllers\api\LegalOpinionNoteController::class, 'destroy']);
Route::delete('/folders/{id}/bylaw/{bylaw_id}', [\App\Http\Controllers\api\FolderController::class, 'delete_bylaw']);
Route::delete('/folders/{id}/judicial/{judicial_id}', [\App\Http\Controllers\api\FolderController::class, 'delete_judicial']);
Route::delete('/folders/{id}/opinion/{opinion_id}', [\App\Http\Controllers\api\FolderController::class, 'delete_opinion']);
// --- HIGHLIGHTS ---
Route::prefix('highlights')->middleware('auth:sanctum')->group(function () {
// Arts
Route::post('/art', [\App\Http\Controllers\api\ArtHighlightController::class, 'create']);
Route::put('/art/{id}', [\App\Http\Controllers\api\ArtHighlightController::class, 'update']);
Route::delete('/art/{id}', [\App\Http\Controllers\api\ArtHighlightController::class, 'destroy']);
// Bylaws
Route::post('/bylaw', [\App\Http\Controllers\api\BylawHighlightController::class, 'create']);
Route::put('/bylaw/{id}', [\App\Http\Controllers\api\BylawHighlightController::class, 'update']);
Route::delete('/bylaw/{id}', [\App\Http\Controllers\api\BylawHighlightController::class, 'destroy']);
// Judicial Precedents
Route::post('/judicial', [\App\Http\Controllers\api\JudicialPrecedentHighlightController::class, 'create']);
Route::put('/judicial/{id}', [\App\Http\Controllers\api\JudicialPrecedentHighlightController::class, 'update']);
Route::delete('/judicial/{id}', [\App\Http\Controllers\api\JudicialPrecedentHighlightController::class, 'destroy']);
// Legal Opinions
Route::post('/opinion', [\App\Http\Controllers\api\LegalOpinionHighlightController::class, 'create']);
Route::put('/opinion/{id}', [\App\Http\Controllers\api\LegalOpinionHighlightController::class, 'update']);
Route::delete('/opinion/{id}', [\App\Http\Controllers\api\LegalOpinionHighlightController::class, 'destroy']);
});
// --- TICKETING SYSTEM ---
Route::prefix('tickets')->group(function () {
Route::get('/', [\App\Http\Controllers\api\TicketApiController::class, 'index']);
Route::get('/{id}', [\App\Http\Controllers\api\TicketApiController::class, 'show']);
Route::post('/', [\App\Http\Controllers\api\TicketApiController::class, 'store']);
Route::post('/{id}/reply', [\App\Http\Controllers\api\TicketApiController::class, 'reply']);
});
Route::get('/quiz/categories', [QuizController::class, 'categories']);
Route::get('/quiz/subjects', [QuizController::class, 'subjects']);
Route::get('/quizzes', [QuizController::class, 'index']);
// Start, Submit, Result flow
Route::get('/quizzes/{quiz}/start', [QuizController::class, 'start']);
Route::post('/quiz-attempts/{attempt}/submit', [QuizController::class, 'submit']);
Route::get('/quiz-attempts/{attempt}/result', [QuizController::class, 'result']);
Route::get('/pvp-matches/{match}/result', [PvPQuizController::class, 'matchResult']);
Route::get('market/gold', [CurrencyController::class, 'goldPrices']);
});

View File

@@ -29,10 +29,17 @@ use App\Http\Controllers\Admin\VersionController;
use App\Http\Controllers\Admin\VolumController;
use App\Http\Controllers\api\PayController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Admin\TicketController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\QuizController;
use App\Http\Controllers\Admin\QuizCategoryController;
use App\Http\Controllers\Admin\QuizSubjectController;
Route::get('payment/callback', [PayController::class, 'callback']);
Route::middleware(['auth', config('jetstream.auth_session')])->group(function () {
Route::get('dashboard', [HomeController::class, 'index'])->name('dashboard');
Route::resource('categories', CategoryController::class);
Route::resource('versions', VersionController::class);
Route::resource('users', UsersController::class);
@@ -58,5 +65,29 @@ Route::middleware(['auth', config('jetstream.auth_session')])->group(function ()
Route::resource('bylaw-node-type', BylawNodeTypeController::class);
Route::resource('bylaw-node', BylawNodeController::class);
Route::resource('bylaw-codification-link', BylawCodificationLinkController::class);
Route::get('suggestions', [SuggestionController::class, 'index'])->name('suggestions.index');
Route::get('tickets', [TicketController::class, 'index'])->name('tickets.index');
Route::get('tickets/{ticket}', [TicketController::class, 'show'])->name('tickets.show');
Route::post('tickets/{ticket}/reply', [TicketController::class, 'reply'])->name('tickets.reply');
Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close');
Route::resource('quizzes', QuizController::class);
Route::get('quizzes/{quiz}/questions', [QuizController::class, 'questions'])->name('quizzes.questions.index');
Route::post('quizzes/{quiz}/questions', [QuizController::class, 'storeQuestion'])->name('quizzes.questions.store');
Route::delete('questions/{question}', [QuizController::class, 'destroyQuestion'])->name('questions.destroy');
Route::resource('quiz-categories', QuizCategoryController::class)->except(['create', 'show', 'edit', 'update']);
Route::resource('quiz-subjects', QuizSubjectController::class)->except(['create', 'show', 'edit', 'update']);
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::post('/users/manual-subscription', [\App\Http\Controllers\Admin\UsersController::class, 'addManualSubscription'])
->name('admin.subscriptions.manual');
Route::post('/users/revoke-subscription', [\App\Http\Controllers\Admin\UsersController::class, 'revokeSubscription'])
->name('admin.subscriptions.revoke');
});