Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -49,6 +49,23 @@ Schedule::command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
Schedule::command('analytics:aggregate-feed')->dailyAt('03:20');
Schedule::command('analytics:aggregate-discovery-feedback')->dailyAt('03:25');
Schedule::command('analytics:aggregate-tag-interactions')->dailyAt('03:35');
Schedule::command('academy:analytics-rollup')
->hourlyAt(12)
->name('academy-analytics-rollup')
->withoutOverlapping()
->runInBackground();
Schedule::command('academy:analytics-recalculate-popularity --days=30')
->dailyAt('03:45')
->name('academy-analytics-popularity')
->withoutOverlapping()
->runInBackground();
Schedule::command('academy:analytics-prune-events --days=180')
->dailyAt('04:20')
->name('academy-analytics-prune')
->withoutOverlapping()
->runInBackground();
// Drain Redis artwork-stat delta queue so MySQL counters stay fresh.
// Offset this off the :00/:10 boundaries so it does not pile onto publish jobs.
@@ -172,7 +189,7 @@ Schedule::command('nova:recalculate-heat')
// Generate static sitemap XML files that nginx can serve directly without PHP.
// The generate command writes public/sitemap.xml + public/sitemaps/{name}.xml.
Schedule::command('skinbase:sitemaps:generate')
->dailyAt('22:30')
->cron('30 10,22 * * *')
->name('sitemaps-generate')
->withoutOverlapping()
->runInBackground();

View File

@@ -114,6 +114,18 @@ Route::middleware('ensure.onboarding.complete')
->get('/gallery/{id}/{username?}', [GalleryController::class, 'show'])
->name('legacy.gallery'); // We need to fix to a new gallery
// Legacy `profile.php?uname=...` compatibility: map old profile bookmarks to
// the canonical @username profile URL.
Route::get('/profile.php', function () {
$uname = trim((string) request()->query('uname', ''));
if ($uname === '') {
return redirect()->to('/', 301);
}
return redirect()->to('/@' . rawurlencode(strtolower($uname)), 301);
})->name('legacy.profile.php');
// ── PROFILE (legacy URL patterns) ────────────────────────────────────────────
Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername'])->where('username', '[A-Za-z0-9_-]{3,20}')->name('legacy.user.profile');
Route::get('/profile/{id}/{username?}', [ProfileController::class, 'legacyById'])->where('id', '\d+')->name('legacy.profile.id');

View File

@@ -54,10 +54,12 @@ use App\Http\Controllers\Academy\AcademyProgressController;
use App\Http\Controllers\Academy\AcademyPromptController;
use App\Http\Controllers\Academy\AcademyPromptPackController;
use App\Http\Controllers\Academy\AcademyPromptSaveController;
use App\Http\Controllers\Academy\AcademyCheckoutController;
use App\Http\Controllers\Academy\AcademyPricingController;
use App\Http\Controllers\Academy\AcademyBillingController;
use App\Http\Controllers\User\MembersController;
use App\Http\Controllers\Settings\AcademyAdminController;
use App\Http\Controllers\Settings\AcademyAdminAnalyticsController;
use App\Http\Controllers\Academy\AcademyAnalyticsEventController;
use App\Http\Controllers\Academy\AcademyInteractionController;
use App\Http\Controllers\Settings\AcademyCourseBuilderController;
use App\Http\Controllers\User\TodayDownloadsController;
use App\Http\Controllers\User\MonthlyCommentatorsController;
@@ -145,9 +147,14 @@ Route::get('/pages/{slug}', [PageController::class, 'show'])
Route::get('/about', [PageController::class, 'marketing'])->defaults('slug', 'about')->name('about');
Route::get('/academy', [AcademyHomeController::class, 'index'])->name('academy.index');
Route::get('/academy/pricing', [AcademyPricingController::class, 'index'])->name('academy.pricing');
Route::get('/academy/pricing', [AcademyBillingController::class, 'pricing'])->name('academy.pricing');
Route::get('/academy/billing/pricing', [AcademyBillingController::class, 'pricing'])->name('academy.billing.pricing');
Route::prefix('academy')->name('academy.')->group(function () {
Route::post('/analytics/events', [AcademyAnalyticsEventController::class, 'store'])
->middleware('throttle:60,1')
->name('analytics.events.store');
Route::get('/courses', [AcademyCourseController::class, 'index'])->name('courses.index');
Route::get('/courses/{course:slug}', [AcademyCourseController::class, 'show'])->name('courses.show');
Route::get('/courses/{course:slug}/lessons/{lesson:slug}', [AcademyCourseLessonController::class, 'show'])->name('courses.lessons.show');
@@ -157,6 +164,9 @@ Route::prefix('academy')->name('academy.')->group(function () {
Route::get('/prompts', [AcademyPromptController::class, 'index'])->name('prompts.index');
Route::get('/prompts/{slug}', [AcademyPromptController::class, 'show'])->name('prompts.show');
Route::get('/billing/success', [AcademyBillingController::class, 'success'])->middleware('auth')->name('billing.success');
Route::get('/billing/cancel', [AcademyBillingController::class, 'cancel'])->name('billing.cancel');
Route::get('/packs', [AcademyPromptPackController::class, 'index'])->name('packs.index');
Route::get('/packs/{slug}', [AcademyPromptPackController::class, 'show'])->name('packs.show');
@@ -164,13 +174,22 @@ Route::prefix('academy')->name('academy.')->group(function () {
Route::get('/challenges/{slug}', [AcademyChallengeController::class, 'show'])->name('challenges.show');
Route::middleware(['auth'])->group(function () {
Route::post('/interactions/like', [AcademyInteractionController::class, 'like'])->name('interactions.like');
Route::post('/interactions/save', [AcademyInteractionController::class, 'save'])->name('interactions.save');
Route::post('/progress/lesson/start', [AcademyProgressController::class, 'startLesson'])->name('progress.lesson.start');
Route::post('/progress/lesson/complete', [AcademyProgressController::class, 'completeLesson'])->name('progress.lesson.complete');
Route::post('/progress/course/start', [AcademyProgressController::class, 'startCourse'])->name('progress.course.start');
Route::post('/progress/course/complete', [AcademyProgressController::class, 'completeCourse'])->name('progress.course.complete');
Route::post('/courses/{course:slug}/start', [AcademyCourseEnrollmentController::class, 'start'])->name('courses.start');
Route::post('/lessons/{lesson}/complete', [AcademyProgressController::class, 'complete'])->name('lessons.complete');
Route::post('/prompts/{prompt}/save', [AcademyPromptSaveController::class, 'store'])->name('prompts.save');
Route::delete('/prompts/{prompt}/save', [AcademyPromptSaveController::class, 'destroy'])->name('prompts.unsave');
Route::get('/challenges/{slug}/submit', [AcademyChallengeSubmissionController::class, 'create'])->name('challenges.submit');
Route::post('/challenges/{slug}/submit', [AcademyChallengeSubmissionController::class, 'store'])->name('challenges.submit.store');
Route::post('/checkout/{plan}', [AcademyCheckoutController::class, 'store'])->name('checkout');
Route::post('/checkout/{plan}', [AcademyBillingController::class, 'checkoutLegacy'])->name('checkout');
Route::post('/billing/checkout', [AcademyBillingController::class, 'checkout'])->name('billing.checkout');
Route::get('/billing/portal', [AcademyBillingController::class, 'portal'])->name('billing.portal');
Route::get('/billing', [AcademyBillingController::class, 'account'])->name('billing.account');
});
});
@@ -352,6 +371,10 @@ Route::get('/collections/featured', [\App\Http\Controllers\Web\CollectionDiscove
Route::get('/collections/trending', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'trending'])
->name('collections.trending');
Route::get('/worlds', [WorldController::class, 'index'])->name('worlds.index');
Route::get('/web-stories', [\App\Http\Controllers\Web\WorldWebStoryController::class, 'index'])->name('web-stories.index');
Route::get('/web-stories/{slug}', [\App\Http\Controllers\Web\WorldWebStoryController::class, 'show'])
->where('slug', '[a-z0-9]+(?:-[a-z0-9]+)*')
->name('web-stories.show');
Route::get('/worlds/create', function (Request $request) {
$user = $request->user();
@@ -1090,16 +1113,48 @@ Route::middleware(['auth', 'admin.access'])
Route::delete('/{homepageAnnouncement}', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'destroy'])->whereNumber('homepageAnnouncement')->name('destroy');
});
Route::prefix('web-stories')
->name('web-stories.')
->group(function () {
Route::get('/', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'store'])->name('store');
Route::get('/{story}/edit', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'edit'])->whereNumber('story')->name('edit');
Route::match(['put', 'patch'], '/{story}', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'update'])->whereNumber('story')->name('update');
Route::delete('/{story}', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'destroy'])->whereNumber('story')->name('destroy');
Route::post('/{story}/pages', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'storePage'])->whereNumber('story')->name('pages.store');
Route::match(['put', 'patch'], '/{story}/pages/{page}', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'updatePage'])->whereNumber('story')->whereNumber('page')->name('pages.update');
Route::delete('/{story}/pages/{page}', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'destroyPage'])->whereNumber('story')->whereNumber('page')->name('pages.destroy');
Route::post('/{story}/pages/reorder', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'reorderPages'])->whereNumber('story')->name('pages.reorder');
Route::post('/generate-from-world/{world}', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'generateFromWorld'])->whereNumber('world')->name('generate');
Route::post('/{story}/publish', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'publish'])->whereNumber('story')->name('publish');
Route::post('/{story}/unpublish', [\App\Http\Controllers\Settings\WorldWebStoryAdminController::class, 'unpublish'])->whereNumber('story')->name('unpublish');
});
Route::prefix('academy')->name('academy.')->group(function () {
Route::redirect('/', '/moderation/academy/dashboard')->name('root');
Route::get('/dashboard', [AcademyAdminController::class, 'dashboard'])->name('dashboard');
Route::get('/billing', [AcademyAdminController::class, 'billing'])->name('billing');
Route::prefix('analytics')->name('analytics.')->group(function () {
Route::get('/', [AcademyAdminAnalyticsController::class, 'overview'])->name('overview');
Route::get('/intelligence', [AcademyAdminAnalyticsController::class, 'intelligence'])->name('intelligence');
Route::get('/content', [AcademyAdminAnalyticsController::class, 'content'])->name('content');
Route::get('/prompts', [AcademyAdminAnalyticsController::class, 'prompts'])->name('prompts');
Route::get('/lessons', [AcademyAdminAnalyticsController::class, 'lessons'])->name('lessons');
Route::get('/courses', [AcademyAdminAnalyticsController::class, 'courses'])->name('courses');
Route::get('/search', [AcademyAdminAnalyticsController::class, 'search'])->name('search');
Route::get('/funnel', [AcademyAdminAnalyticsController::class, 'funnel'])->name('funnel');
});
Route::prefix('courses')->name('courses.')->group(function () {
Route::get('/', [AcademyAdminController::class, 'coursesIndex'])->name('index');
Route::get('/create', [AcademyAdminController::class, 'coursesCreate'])->name('create');
Route::post('/', [AcademyAdminController::class, 'coursesStore'])->name('store');
Route::post('/import-json', [AcademyAdminController::class, 'coursesStoreJson'])->name('import-json');
Route::get('/{academyCourse}/edit', [AcademyAdminController::class, 'coursesEdit'])->whereNumber('academyCourse')->name('edit');
Route::match(['put', 'patch'], '/{academyCourse}', [AcademyAdminController::class, 'coursesUpdate'])->whereNumber('academyCourse')->name('update');
Route::post('/{academyCourse}/import-lessons', [AcademyAdminController::class, 'coursesImportLessons'])->whereNumber('academyCourse')->name('lessons.import');
Route::delete('/{academyCourse}', [AcademyAdminController::class, 'coursesDestroy'])->whereNumber('academyCourse')->name('destroy');
Route::get('/{academyCourse}/builder', [AcademyCourseBuilderController::class, 'edit'])->whereNumber('academyCourse')->name('builder.edit');
Route::post('/{academyCourse}/sections', [AcademyCourseBuilderController::class, 'storeSection'])->whereNumber('academyCourse')->name('sections.store');