Initial commit

This commit is contained in:
Richard Jolley committed 2026-01-14 00:54:08 +00:00
commit c909d539bc
7178 files changed
+756624

No files matched your search

@@ -0,0 +1,276 @@
<?php
namespace App\Http\Controllers\Student;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Slot;
use Carbon\Carbon;
use App\Services\AvailabilityService;
class BookingController extends Controller
{
public function create(Request $request)
{
// Expect: /student/bookings/create?date=YYYY-MM-DD&slot_id=NN
$data = $request->validate([
'date' => ['required','date_format:Y-m-d'],
'slot_id' => ['required','exists:slots,id'],
]);
$slot = Slot::findOrFail($data['slot_id']);
// Prettify labels so Blade doesn’t need Carbon
$when = Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
$prettyDate = $when->format('D d M Y');
$prettyTime = $when->format('H:i');
$weekdayName = $when->format('l');
return view('student.bookings.create', [
'slot' => $slot,
'date' => $data['date'],
'prettyDate' => $prettyDate,
'prettyTime' => $prettyTime,
'weekdayName' => $weekdayName,
]);
}
public function createSeries(\Illuminate\Http\Request $request)
{
// Ensure these use's exist at the top of the file:
// use App\Models\Slot;
// use Carbon\Carbon;
// Expect: /student/bookings/series/create?date=YYYY-MM-DD&slot_id=NN&weeks=6
$data = $request->validate([
'date' => ['required','date_format:Y-m-d'],
'slot_id' => ['required','exists:slots,id'],
'weeks' => ['nullable','integer','min:1','max:52'],
]);
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
// Prettify labels (keep Blade Carbon-free)
$when = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
$prettyDate = $when->format('D d M Y');
$prettyTime = $when->format('H:i');
$weekdayName = $when->format('l');
$weeks = $data['weeks'] ?? 6;
return view('student.bookings.series', [
'slot' => $slot,
'date' => $data['date'],
'prettyDate' => $prettyDate,
'prettyTime' => $prettyTime,
'weekdayName' => $weekdayName,
'weeks' => $weeks,
]);
}
public function store(\Illuminate\Http\Request $request)
{
// Ensure these use's exist at the top of the file:
// use App\Models\Slot;
// use App\Models\Booking;
// use Carbon\Carbon;
// use Illuminate\Validation\ValidationException;
// use Illuminate\Database\QueryException;
$data = $request->validate([
'slot_id' => ['required','exists:slots,id'],
'date' => ['required','date_format:Y-m-d'],
'message' => ['nullable','string'],
]);
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
// Enforce > 2 hours’ notice (exactly 120 mins = NOT allowed)
$now = \Carbon\Carbon::now('Europe/London');
$sessionStart = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$data['date']} {$slot->time}", 'Europe/London');
$minutesUntil = $now->diffInMinutes($sessionStart, false);
if ($minutesUntil <= 120) {
throw \Illuminate\Validation\ValidationException::withMessages([
'date' => 'Bookings require more than 2 hours’ notice.',
]);
}
// Pull student name from the logged-in user
$user = $request->user();
$studentName = $user?->name ?? 'Student';
try {
\App\Models\Booking::create([
'slot_id' => $slot->id,
'date' => $data['date'],
'student_name' => $studentName,
'booked_from_page' => 'student:booking',
'message' => $data['message'] ?? null,
'status' => 'booked',
]);
} catch (\Illuminate\Database\QueryException $e) {
// Unique (slot_id, date) hit — slot already taken
if (isset($e->errorInfo[1]) && (int)$e->errorInfo[1] === 1062) {
throw \Illuminate\Validation\ValidationException::withMessages([
'slot_id' => 'Sorry, that slot was just taken. Please choose another.',
]);
}
throw $e;
}
return redirect()
->route('student.calendar')
->with('success', 'Your booking is confirmed.');
}
public function storeSeries(\Illuminate\Http\Request $request)
{
// Validates: slot, start date, and weeks count
$data = $request->validate([
'slot_id' => ['required','exists:slots,id'],
'date' => ['required','date_format:Y-m-d'], // start date
'weeks' => ['required','integer','min:1','max:52'],
]);
$slot = \App\Models\Slot::findOrFail($data['slot_id']);
$now = \Carbon\Carbon::now('Europe/London');
$startDate = \Carbon\Carbon::createFromFormat('Y-m-d', $data['date'], 'Europe/London');
$user = $request->user();
$student = $user?->name ?? 'Student';
$created = 0;
$skipped = 0;
for ($i = 0; $i < (int)$data['weeks']; $i++) {
$date = $startDate->copy()->addWeeks($i)->toDateString();
$sessionStart = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$date} {$slot->time}", 'Europe/London');
// Enforce > 2 hours’ notice (exactly 120 mins = NOT allowed)
$minutesUntil = $now->diffInMinutes($sessionStart, false);
if ($minutesUntil <= 120) {
$skipped++;
continue;
}
try {
\App\Models\Booking::create([
'slot_id' => $slot->id,
'date' => $date,
'student_name' => $student,
'booked_from_page' => 'student:block-series',
'message' => null,
'status' => 'booked',
]);
$created++;
} catch (\Illuminate\Database\QueryException $e) {
// Skip duplicates (unique (slot_id, date))
if (isset($e->errorInfo[1]) && (int)$e->errorInfo[1] === 1062) {
$skipped++;
continue;
}
throw $e;
}
}
return redirect()
->route('student.calendar')
->with('success', "Series created: {$created} added, {$skipped} skipped.");
}
public function cancelConfirm(\App\Models\Booking $booking)
{
// Ensure these use's exist at the top:
// use App\Models\Booking;
// use Carbon\Carbon;
$user = auth()->user();
$studentName = $user?->name ?? '';
// Authorize: must be this student's booking and still "booked"
if ($booking->student_name !== $studentName || $booking->status !== 'booked') {
abort(403);
}
// Need the slot's time to compute the session start
$booking->load('slot');
$now = \Carbon\Carbon::now('Europe/London');
$sessionStart = \Carbon\Carbon::createFromFormat(
'Y-m-d H:i:s',
$booking->date.' '.$booking->slot->time,
'Europe/London'
);
$minutesUntil = $now->diffInMinutes($sessionStart, false);
// If already started or in the past, disallow cancellation
if ($minutesUntil <= 0) {
return redirect()
->route('student.bookings.index')
->withErrors(['booking' => 'This session has started or is in the past and can’t be cancelled.']);
}
// Refund policy band (for display only; payments added later)
// ≥ 24h => 100%; < 24h => 50%
$refundPercent = ($minutesUntil >= 24 * 60) ? 100 : 50;
// Prettified labels so Blade stays Carbon-free
$prettyDate = $sessionStart->format('D d M Y');
$prettyTime = $sessionStart->format('H:i');
$weekdayName = $sessionStart->format('l');
return view('student.bookings.cancel', [
'booking' => $booking,
'prettyDate' => $prettyDate,
'prettyTime' => $prettyTime,
'weekdayName' => $weekdayName,
'refundPercent' => $refundPercent,
'minutesUntil' => $minutesUntil,
]);
}
public function cancelStore(\App\Models\Booking $booking)
{
// Ensure these use's exist at the top:
// use App\Models\Booking;
// use Carbon\Carbon;
$user = auth()->user();
$studentName = $user?->name ?? '';
// Authorize: must be this student's booking and still "booked"
if ($booking->student_name !== $studentName || $booking->status !== 'booked') {
abort(403);
}
// Need slot time to compute the session start
$booking->load('slot');
$now = \Carbon\Carbon::now('Europe/London');
$sessionStart = \Carbon\Carbon::createFromFormat(
'Y-m-d H:i:s',
$booking->date.' '.$booking->slot->time,
'Europe/London'
);
$minutesUntil = $now->diffInMinutes($sessionStart, false);
// If already started or in the past, disallow cancellation
if ($minutesUntil <= 0) {
return redirect()
->route('student.bookings.index')
->withErrors(['booking' => 'This session has started or is in the past and can’t be cancelled.']);
}
// Refund band (for info only; payments to be integrated later)
$refundPercent = ($minutesUntil >= 24 * 60) ? 100 : 50;
// For now, delete the booking to free the slot.
// (Later we can switch to a "cancelled" status or soft deletes if you want a history.)
$booking->delete();
return redirect()
->route('student.bookings.index')
->with('success', "Booking cancelled. Refund: {$refundPercent}%.");
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Student;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class BookingsListController extends Controller
{
public function index()
{
// Ensure these use's exist at the top of the file:
// use App\Models\Booking;
// use Carbon\Carbon;
$user = auth()->user();
$name = $user?->name ?? '';
$now = \Carbon\Carbon::now('Europe/London');
$today = $now->toDateString();
// Upcoming “booked” sessions for this student (names-based for now)
$raw = \App\Models\Booking::with('slot')
->where('student_name', $name)
->where('status', 'booked')
->whereDate('date', '>=', $today)
->orderBy('date')
->get();
// Pre-format labels so Blade stays Carbon-free
$bookings = $raw->map(function ($b) use ($now) {
$when = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', $b->date.' '.$b->slot->time, $now->timezone);
return [
'id' => $b->id,
'date' => $b->date,
'prettyDate' => $when->format('D d M Y'),
'prettyTime' => $when->format('H:i'),
'weekday' => $when->format('l'),
'status' => $b->status,
'slot_id' => $b->slot_id,
];
});
return view('student.bookings.index', [
'bookings' => $bookings,
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Student;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Slot;
use Carbon\Carbon;
class CalendarController extends Controller
{
public function index(Request $request)
{
$now = Carbon::now('Europe/London');
// Default: this Mon–Sun, but flip to next week after Fri 15:00
$start = $now->copy()->startOfWeek(Carbon::MONDAY);
$fridayCutoff = $start->copy()->addDays(4)->setTime(15, 0);
if ($now->greaterThanOrEqualTo($fridayCutoff)) {
$start->addWeek();
}
// Week navigation
$weekOffset = (int) $request->query('week', 0);
if ($weekOffset !== 0) {
$start->addWeeks($weekOffset);
}
$end = $start->copy()->addDays(6);
// Load slots + bookings for this visible week
$slots = Slot::with(['bookings' => function ($q) use ($start, $end) {
$q->whereBetween('date', [$start->toDateString(), $end->toDateString()]);
}])->get();
// Reuse the same view for now (we'll hide admin-only bits next)
return view('calendar.index', [
'start' => $start,
'end' => $end,
'weekOffset' => $weekOffset,
'slots' => $slots,
'now' => $now,
'viewer' => 'student',
]);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Student;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class CourseController extends Controller
{
public function show(Course $course)
{
// Load relationships so Blade can access subject, level, modules etc.
$course->load('subject', 'level', 'modules.lessonSections.chapters.lessons');
return view('student.courses.show', compact('course'));
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Student;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;
use App\Models\Course;
use App\Models\Lesson;
use App\Models\LessonProgress;
use App\Models\User;
class DashboardController extends Controller
{
public function index()
{
$user = Auth::user();
// 1) Enrolled courses (with subject & level)
$enrolled = $user->courses()
->with(['subject', 'level'])
->get();
// 2) Courses not yet enrolled
$availableCourses = Course::with(['subject', 'level'])
->whereNotIn('id', $enrolled->pluck('id'))
->get();
// 3) Find which courses this user has started (based on LessonProgress)
$progressEntries = LessonProgress::where('user_id', $user->id)
->with('lesson.chapter.lessonSection.module.course')
->orderByDesc('last_viewed_at')
->get();
$startedCourseIds = $progressEntries
->pluck('lesson.chapter.lessonSection.module.course.id')
->unique();
// Split enrolled courses into inProgress and notStarted
[$inProgressCourses, $notStartedCourses] = $enrolled->partition(
fn($course) => $startedCourseIds->contains($course->id)
);
// 4) Build map of the *latest viewed lesson per course*
$lastLessonsByCourse = collect();
foreach ($progressEntries as $entry) {
$course = $entry->lesson->chapter->lessonSection->module->course;
$courseId = $course->id;
// Store the first (i.e. most recent, since entries are sorted DESC) occurrence
if (!$lastLessonsByCourse->has($courseId)) {
$lastLessonsByCourse->put($courseId, $entry->lesson);
}
}
return view('student.dashboard', [
'inProgressCourses' => $inProgressCourses,
'notStartedCourses' => $notStartedCourses,
'availableCourses' => $availableCourses,
'lastLessonsByCourse' => $lastLessonsByCourse,
]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Student;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;
use App\Models\Course;
use App\Models\Lesson;
use App\Models\LessonProgress;
class LessonController extends Controller
{
public function show(Course $course, Lesson $lesson)
{
if (
$lesson->chapter &&
$lesson->chapter->lessonSection &&
$lesson->chapter->lessonSection->module &&
$lesson->chapter->lessonSection->module->course &&
$lesson->chapter->lessonSection->module->course->id !== $course->id
) {
abort(404);
}
$lesson->load([
'chapter.lessonSection.module.course.subject',
'chapter.lessonSection.module.course.level',
'questions' => fn($q) => $q->where('published', 1)->with('student'),
]);
LessonProgress::updateOrCreate(
['user_id' => Auth::id(), 'lesson_id' => $lesson->id],
['last_viewed_at' => now()]
);
$course = $lesson->chapter->lessonSection->module->course;
// Collect all chapters across all lesson sections for this course
$chapters = $course->lessonSections()
->with(['chapters.lessons'])
->get()
->pluck('chapters')
->flatten();
return view('student.lessons.show', compact('lesson', 'chapters'));
}
// Pretty route: /student/{course}/{lesson}
public function showByCourse(Course $course, Lesson $lesson)
{
\Log::info('Entered showByCourse route');
dd('Reached controller');
dd($course, $lesson);
// Ensure the lesson actually belongs to the course
$belongs = $lesson->chapter
&& $lesson->chapter->lessonSection
&& $lesson->chapter->lessonSection->module
&& $lesson->chapter->lessonSection->module->course
&& $lesson->chapter->lessonSection->module->course->id === $course->id;
if (!$belongs) {
abort(404);
}
return $this->show($lesson); // reuse the existing rendering logic
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Student;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\Lesson;
use App\Models\Course;
use App\Models\LessonQuestion;
class LessonQuestionController extends Controller
{
/**
* Store a new question from a student.
*/
public function store(Request $request, Course $course, Lesson $lesson)
{
$request->validate([
'question' => 'required|string|max:2000',
]);
LessonQuestion::create([
'lesson_id' => $lesson->id,
'user_id' => Auth::id(),
'question' => $request->question,
'published' => 0,
]);
return back()->with('success', 'Your question has been submitted.');
}
/**
* Optional — allow teachers (or admins) to post an answer.
*/
public function answer(Request $request, LessonQuestion $question)
{
$request->validate([
'answer' => 'required|string|max:5000',
]);
$question->update([
'answer' => $request->answer,
'answered_by' => Auth::id(),
'answered_at' => now(),
]);
return back()->with('success', 'Answer posted successfully.');
}
}