Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -19,6 +19,7 @@ use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyCourseSection;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\AcademyLessonRevision;
|
||||
@@ -26,6 +27,7 @@ use App\Models\AcademyPromptPack;
|
||||
use App\Models\AcademyPromptPackItem;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\User;
|
||||
use App\Services\Academy\AcademyAdminBillingOverviewService;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use App\Services\Academy\AcademyCourseLessonOrderingService;
|
||||
use App\Services\Academy\AcademyLessonMarkdownRenderer;
|
||||
@@ -38,6 +40,7 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -48,7 +51,13 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews';
|
||||
|
||||
private const PROMPT_PREVIEW_VARIANT_WIDTHS = [
|
||||
'thumb' => 480,
|
||||
'md' => 960,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly AcademyAdminBillingOverviewService $billingOverview,
|
||||
private readonly AcademyCacheService $cache,
|
||||
private readonly AcademyCourseLessonOrderingService $courseLessonOrdering,
|
||||
private readonly AcademyLessonMarkdownRenderer $lessonMarkdownRenderer,
|
||||
@@ -56,6 +65,8 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function dashboard(): Response
|
||||
{
|
||||
$billingSummary = $this->billingOverview->summary();
|
||||
|
||||
return Inertia::render('Admin/Academy/Dashboard', [
|
||||
'stats' => [
|
||||
'courses' => AcademyCourse::query()->count(),
|
||||
@@ -65,11 +76,13 @@ final class AcademyAdminController extends Controller
|
||||
'challenges' => AcademyChallenge::query()->count(),
|
||||
'submissions' => AcademyChallengeSubmission::query()->count(),
|
||||
'badges' => AcademyBadge::query()->count(),
|
||||
'creator_subscribers' => 0,
|
||||
'pro_subscribers' => 0,
|
||||
'mrr' => 0,
|
||||
'active_subscribers' => (int) ($billingSummary['active_subscribers'] ?? 0),
|
||||
'creator_subscribers' => (int) ($billingSummary['creator_subscribers'] ?? 0),
|
||||
'pro_subscribers' => (int) ($billingSummary['pro_subscribers'] ?? 0),
|
||||
'grace_period_subscribers' => (int) ($billingSummary['grace_period_subscribers'] ?? 0),
|
||||
],
|
||||
'links' => [
|
||||
'billing' => route('admin.academy.billing'),
|
||||
'courses' => route('admin.academy.courses.index'),
|
||||
'categories' => route('admin.academy.categories.index'),
|
||||
'lessons' => route('admin.academy.lessons.index'),
|
||||
@@ -83,6 +96,22 @@ final class AcademyAdminController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function billing(): Response
|
||||
{
|
||||
$summary = $this->billingOverview->summary();
|
||||
|
||||
return Inertia::render('Admin/Academy/Billing', [
|
||||
'summary' => $summary,
|
||||
'planBreakdown' => $summary['plan_breakdown'] ?? [],
|
||||
'recentEvents' => $this->billingOverview->recentEvents(),
|
||||
'links' => [
|
||||
'dashboard' => route('admin.academy.dashboard'),
|
||||
'pricing' => route('academy.pricing'),
|
||||
'account' => route('academy.billing.account'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function categoriesIndex(): Response
|
||||
{
|
||||
return $this->renderIndex('categories');
|
||||
@@ -100,13 +129,20 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse
|
||||
{
|
||||
$course = new AcademyCourse;
|
||||
$course->fill($this->persistCourseAttributes($request))->save();
|
||||
$course = $this->saveCourseFromRequest($request);
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.');
|
||||
}
|
||||
|
||||
public function coursesStoreJson(UpsertAcademyCourseRequest $request): RedirectResponse
|
||||
{
|
||||
$course = $this->saveCourseFromRequest($request);
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created from JSON.');
|
||||
}
|
||||
|
||||
public function coursesEdit(AcademyCourse $academyCourse): Response
|
||||
{
|
||||
return $this->renderForm('courses', $academyCourse);
|
||||
@@ -114,12 +150,94 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse
|
||||
{
|
||||
$academyCourse->fill($this->persistCourseAttributes($request, $academyCourse))->save();
|
||||
$this->saveCourseFromRequest($request, $academyCourse);
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.');
|
||||
}
|
||||
|
||||
public function coursesImportLessons(Request $request, AcademyCourse $academyCourse): RedirectResponse
|
||||
{
|
||||
$difficultyLevels = array_values(array_filter(array_map('strval', (array) config('academy.difficulty_levels', []))));
|
||||
|
||||
$validated = $request->validate([
|
||||
'defaults' => ['nullable', 'array'],
|
||||
'defaults.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
||||
'defaults.category_slug' => ['nullable', 'string', 'max:180'],
|
||||
'defaults.category' => ['nullable', 'string', 'max:180'],
|
||||
'defaults.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
|
||||
'defaults.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
|
||||
'defaults.lesson_type' => ['nullable', 'string', 'max:80'],
|
||||
'defaults.active' => ['nullable', 'boolean'],
|
||||
'defaults.series_name' => ['nullable', 'string', 'max:120'],
|
||||
'lessons' => ['required', 'array', 'min:1', 'max:250'],
|
||||
'lessons.*.title' => ['required', 'string', 'max:180'],
|
||||
'lessons.*.slug' => ['nullable', 'string', 'max:180'],
|
||||
'lessons.*.goal' => ['nullable', 'string'],
|
||||
'lessons.*.excerpt' => ['nullable', 'string'],
|
||||
'lessons.*.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'],
|
||||
'lessons.*.category_slug' => ['nullable', 'string', 'max:180'],
|
||||
'lessons.*.category' => ['nullable', 'string', 'max:180'],
|
||||
'lessons.*.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)],
|
||||
'lessons.*.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])],
|
||||
'lessons.*.lesson_type' => ['nullable', 'string', 'max:80'],
|
||||
'lessons.*.active' => ['nullable', 'boolean'],
|
||||
'lessons.*.series_name' => ['nullable', 'string', 'max:120'],
|
||||
'lessons.*.tags' => ['nullable', 'array'],
|
||||
'lessons.*.tags.*' => ['string', 'max:60'],
|
||||
]);
|
||||
|
||||
$defaults = (array) ($validated['defaults'] ?? []);
|
||||
$lessons = array_values((array) ($validated['lessons'] ?? []));
|
||||
|
||||
if ($lessons === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'lessons' => 'Provide at least one lesson row to import.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($academyCourse, $defaults, $lessons): void {
|
||||
$reservedSlugs = AcademyLesson::query()
|
||||
->pluck('slug')
|
||||
->filter(fn ($slug): bool => is_string($slug) && trim($slug) !== '')
|
||||
->map(fn ($slug): string => trim((string) $slug))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$nextOrder = (int) ((AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->max('order_num') ?? -1) + 1);
|
||||
|
||||
foreach ($lessons as $lessonData) {
|
||||
$attributes = $this->buildImportedCourseLessonAttributes($academyCourse, (array) $lessonData, $defaults, $reservedSlugs);
|
||||
|
||||
$lesson = new AcademyLesson;
|
||||
$lesson->fill($attributes)->save();
|
||||
|
||||
AcademyCourseLesson::query()->create([
|
||||
'course_id' => $academyCourse->id,
|
||||
'lesson_id' => $lesson->id,
|
||||
'section_id' => null,
|
||||
'order_num' => $nextOrder,
|
||||
'is_required' => true,
|
||||
'access_override' => null,
|
||||
'unlock_after_lesson_id' => null,
|
||||
]);
|
||||
|
||||
$nextOrder++;
|
||||
}
|
||||
|
||||
$this->courseLessonOrdering->syncCourse($academyCourse);
|
||||
|
||||
$academyCourse->forceFill([
|
||||
'lessons_count_cache' => (int) AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->count(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
$this->cache->clearAll();
|
||||
|
||||
return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])
|
||||
->with('success', sprintf('%d lesson%s imported into the course.', count($lessons), count($lessons) === 1 ? '' : 's'));
|
||||
}
|
||||
|
||||
public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse
|
||||
{
|
||||
$this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image);
|
||||
@@ -484,12 +602,40 @@ final class AcademyAdminController extends Controller
|
||||
private function renderIndex(string $resource): Response
|
||||
{
|
||||
$meta = $this->resourceMeta($resource);
|
||||
$query = $meta['model']::query()->latest('updated_at');
|
||||
$search = trim((string) request()->query('search', ''));
|
||||
$query = $meta['model']::query();
|
||||
|
||||
if ($resource === 'courses') {
|
||||
$query->withCount('courseLessons');
|
||||
|
||||
if ($search !== '') {
|
||||
$query->where(function ($builder) use ($search): void {
|
||||
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
|
||||
|
||||
$builder->where('title', 'like', $like)
|
||||
->orWhere('slug', 'like', $like)
|
||||
->orWhere('subtitle', 'like', $like)
|
||||
->orWhere('excerpt', 'like', $like)
|
||||
->orWhere('description', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
$query->orderByDesc('is_featured')
|
||||
->orderBy('order_num')
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id');
|
||||
} else {
|
||||
$query->latest('updated_at');
|
||||
}
|
||||
|
||||
if ($resource === 'prompts') {
|
||||
$query->with('category');
|
||||
}
|
||||
|
||||
if ($resource === 'lessons') {
|
||||
$query->with('courses:id,title');
|
||||
}
|
||||
|
||||
$items = $query->paginate(25)->withQueryString();
|
||||
$items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model));
|
||||
|
||||
@@ -500,6 +646,45 @@ final class AcademyAdminController extends Controller
|
||||
'items' => $items,
|
||||
'columns' => $meta['columns'],
|
||||
'createUrl' => route($meta['route_base'].'.create'),
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
],
|
||||
'summary' => $resource === 'courses' ? [
|
||||
'total' => (int) $items->total(),
|
||||
'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
|
||||
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
|
||||
|
||||
$builder->where(function ($inner) use ($like): void {
|
||||
$inner->where('title', 'like', $like)
|
||||
->orWhere('slug', 'like', $like)
|
||||
->orWhere('subtitle', 'like', $like)
|
||||
->orWhere('excerpt', 'like', $like)
|
||||
->orWhere('description', 'like', $like);
|
||||
});
|
||||
})->where('status', AcademyCourse::STATUS_PUBLISHED)->count(),
|
||||
'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
|
||||
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
|
||||
|
||||
$builder->where(function ($inner) use ($like): void {
|
||||
$inner->where('title', 'like', $like)
|
||||
->orWhere('slug', 'like', $like)
|
||||
->orWhere('subtitle', 'like', $like)
|
||||
->orWhere('excerpt', 'like', $like)
|
||||
->orWhere('description', 'like', $like);
|
||||
});
|
||||
})->where('is_featured', true)->count(),
|
||||
'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void {
|
||||
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
|
||||
|
||||
$builder->where(function ($inner) use ($like): void {
|
||||
$inner->where('title', 'like', $like)
|
||||
->orWhere('slug', 'like', $like)
|
||||
->orWhere('subtitle', 'like', $like)
|
||||
->orWhere('excerpt', 'like', $like)
|
||||
->orWhere('description', 'like', $like);
|
||||
});
|
||||
})->where('status', AcademyCourse::STATUS_DRAFT)->count(),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -538,6 +723,9 @@ final class AcademyAdminController extends Controller
|
||||
'outlineSummary' => $record instanceof AcademyCourse && $record->exists
|
||||
? $this->serializeCourseOutlineSummary($record)
|
||||
: null,
|
||||
'courseSections' => $record instanceof AcademyCourse && $record->exists
|
||||
? $this->serializeCourseEditorSections($record)
|
||||
: [],
|
||||
'courseLessons' => $record instanceof AcademyCourse && $record->exists
|
||||
? $this->serializeCourseEditorLessons($record)
|
||||
: [],
|
||||
@@ -547,9 +735,19 @@ final class AcademyAdminController extends Controller
|
||||
'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists
|
||||
? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record])
|
||||
: null,
|
||||
'importLessonsUrl' => $record instanceof AcademyCourse && $record->exists
|
||||
? route('admin.academy.courses.lessons.import', ['academyCourse' => $record])
|
||||
: null,
|
||||
'sectionStoreUrl' => $record instanceof AcademyCourse && $record->exists
|
||||
? route('admin.academy.courses.sections.store', ['academyCourse' => $record])
|
||||
: null,
|
||||
'reorderUrl' => $record instanceof AcademyCourse && $record->exists
|
||||
? route('admin.academy.courses.reorder', ['academyCourse' => $record])
|
||||
: null,
|
||||
'courseImportUrl' => $record instanceof AcademyCourse && ! $record->exists
|
||||
? route('admin.academy.courses.import-json')
|
||||
: null,
|
||||
'lessonCategoryOptions' => $this->categoriesForEditor('lesson'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -656,7 +854,7 @@ final class AcademyAdminController extends Controller
|
||||
'singular' => 'lesson',
|
||||
'subtitle' => 'Create and publish Academy lessons.',
|
||||
'route_base' => 'admin.academy.lessons',
|
||||
'columns' => ['title', 'difficulty', 'access_level', 'featured', 'active'],
|
||||
'columns' => ['title', 'course_names', 'course_order', 'difficulty', 'access_level', 'active'],
|
||||
'fields' => [
|
||||
['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')],
|
||||
['name' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()],
|
||||
@@ -694,6 +892,10 @@ final class AcademyAdminController extends Controller
|
||||
['name' => 'negative_prompt', 'label' => 'Negative Prompt', 'type' => 'textarea'],
|
||||
['name' => 'usage_notes', 'label' => 'Usage Notes', 'type' => 'textarea'],
|
||||
['name' => 'workflow_notes', 'label' => 'Workflow Notes', 'type' => 'textarea'],
|
||||
['name' => 'documentation', 'label' => 'Documentation JSON', 'type' => 'json'],
|
||||
['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'],
|
||||
['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'],
|
||||
['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'],
|
||||
['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()],
|
||||
['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()],
|
||||
['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'],
|
||||
@@ -785,10 +987,17 @@ final class AcademyAdminController extends Controller
|
||||
'courses' => [
|
||||
'id' => (int) $model->id,
|
||||
'title' => (string) $model->title,
|
||||
'slug' => (string) $model->slug,
|
||||
'subtitle' => (string) ($model->subtitle ?? ''),
|
||||
'excerpt' => (string) ($model->excerpt ?? ''),
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($model->cover_image ?? '')),
|
||||
'lessons_count' => (int) ($model->lessons_count_cache ?? $model->course_lessons_count ?? 0),
|
||||
'difficulty' => (string) $model->difficulty,
|
||||
'access_level' => (string) $model->access_level,
|
||||
'status' => (string) $model->status,
|
||||
'is_featured' => (bool) $model->is_featured,
|
||||
'published_at' => optional($model->published_at)->toIso8601String(),
|
||||
'updated_at' => optional($model->updated_at)->toIso8601String(),
|
||||
'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $model]),
|
||||
'destroy_url' => route('admin.academy.courses.destroy', ['academyCourse' => $model]),
|
||||
'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $model]),
|
||||
@@ -805,6 +1014,8 @@ final class AcademyAdminController extends Controller
|
||||
'lessons' => [
|
||||
'id' => (int) $model->id,
|
||||
'title' => (string) $model->title,
|
||||
'course_names' => $model->courses->pluck('title')->filter()->values()->all(),
|
||||
'course_order' => $model->course_order,
|
||||
'difficulty' => (string) $model->difficulty,
|
||||
'access_level' => (string) $model->access_level,
|
||||
'featured' => (bool) $model->featured,
|
||||
@@ -941,6 +1152,10 @@ final class AcademyAdminController extends Controller
|
||||
'negative_prompt' => (string) ($record->negative_prompt ?? ''),
|
||||
'usage_notes' => (string) ($record->usage_notes ?? ''),
|
||||
'workflow_notes' => (string) ($record->workflow_notes ?? ''),
|
||||
'documentation' => $this->encodePrettyJsonForForm($record->documentation),
|
||||
'placeholders' => $this->encodePrettyJsonForForm($record->placeholders),
|
||||
'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts),
|
||||
'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants),
|
||||
'difficulty' => (string) ($record->difficulty ?? 'beginner'),
|
||||
'access_level' => (string) ($record->access_level ?? 'free'),
|
||||
'aspect_ratio' => (string) ($record->aspect_ratio ?? ''),
|
||||
@@ -1464,9 +1679,46 @@ final class AcademyAdminController extends Controller
|
||||
return $validated;
|
||||
}
|
||||
|
||||
private function saveCourseFromRequest(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): AcademyCourse
|
||||
{
|
||||
$course ??= new AcademyCourse;
|
||||
$course->fill($this->persistCourseAttributes($request, $course))->save();
|
||||
|
||||
return $course;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function serializeCourseEditorSections(AcademyCourse $course): array
|
||||
{
|
||||
$course->loadMissing(['sections']);
|
||||
|
||||
return $course->sections
|
||||
->sortBy([['order_num', 'asc'], ['id', 'asc']])
|
||||
->values()
|
||||
->map(fn (AcademyCourseSection $section): array => [
|
||||
'id' => (int) $section->id,
|
||||
'title' => (string) $section->title,
|
||||
'slug' => (string) ($section->slug ?? ''),
|
||||
'description' => (string) ($section->description ?? ''),
|
||||
'order_num' => (int) ($section->order_num ?? 0),
|
||||
'is_visible' => (bool) ($section->is_visible ?? true),
|
||||
'update_url' => route('admin.academy.courses.sections.update', [
|
||||
'academyCourse' => $course,
|
||||
'academyCourseSection' => $section,
|
||||
]),
|
||||
'destroy_url' => route('admin.academy.courses.sections.destroy', [
|
||||
'academyCourse' => $course,
|
||||
'academyCourseSection' => $section,
|
||||
]),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -1479,12 +1731,17 @@ final class AcademyAdminController extends Controller
|
||||
->values()
|
||||
->map(function (AcademyCourseLesson $courseLesson) use ($course): array {
|
||||
$lesson = $courseLesson->lesson;
|
||||
$publicationMeta = $this->serializeLessonPublicationMeta($lesson instanceof AcademyLesson ? $lesson : null);
|
||||
|
||||
return [
|
||||
return array_merge([
|
||||
'id' => (int) $courseLesson->id,
|
||||
'lesson_id' => (int) $courseLesson->lesson_id,
|
||||
'title' => (string) ($lesson?->title ?? 'Untitled lesson'),
|
||||
'slug' => (string) ($lesson?->slug ?? ''),
|
||||
'cover_image' => (string) ($lesson?->cover_image ?? ''),
|
||||
'cover_image_url' => $lesson instanceof AcademyLesson
|
||||
? $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? ''))
|
||||
: null,
|
||||
'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null,
|
||||
'section_title' => (string) ($courseLesson->section?->title ?? ''),
|
||||
'order_num' => (int) ($courseLesson->order_num ?? 0),
|
||||
@@ -1493,6 +1750,7 @@ final class AcademyAdminController extends Controller
|
||||
'is_required' => (bool) $courseLesson->is_required,
|
||||
'difficulty' => (string) ($lesson?->difficulty ?? ''),
|
||||
'access_level' => (string) ($lesson?->access_level ?? ''),
|
||||
'active' => (bool) ($lesson?->active ?? false),
|
||||
'destroy_url' => route('admin.academy.courses.lessons.destroy', [
|
||||
'academyCourse' => $course,
|
||||
'academyCourseLesson' => $courseLesson,
|
||||
@@ -1500,42 +1758,208 @@ final class AcademyAdminController extends Controller
|
||||
'edit_url' => $lesson instanceof AcademyLesson
|
||||
? route('admin.academy.lessons.edit', ['academyLesson' => $lesson])
|
||||
: null,
|
||||
];
|
||||
], $publicationMeta);
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lessonData
|
||||
* @param array<string, mixed> $defaults
|
||||
* @param array<int, string> $reservedSlugs
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildImportedCourseLessonAttributes(AcademyCourse $course, array $lessonData, array $defaults, array &$reservedSlugs): array
|
||||
{
|
||||
$title = trim((string) ($lessonData['title'] ?? ''));
|
||||
$slugSource = $this->nullableTrimmedString($lessonData['slug'] ?? null) ?? $title;
|
||||
$excerpt = $this->nullableTrimmedString($lessonData['excerpt'] ?? null)
|
||||
?? $this->nullableTrimmedString($lessonData['goal'] ?? null);
|
||||
$difficulty = $this->nullableTrimmedString($lessonData['difficulty'] ?? null)
|
||||
?? $this->nullableTrimmedString($defaults['difficulty'] ?? null)
|
||||
?? $this->nullableTrimmedString($course->difficulty)
|
||||
?? 'beginner';
|
||||
$accessLevel = $this->nullableTrimmedString($lessonData['access_level'] ?? null)
|
||||
?? $this->nullableTrimmedString($defaults['access_level'] ?? null)
|
||||
?? 'free';
|
||||
$lessonType = $this->nullableTrimmedString($lessonData['lesson_type'] ?? null)
|
||||
?? $this->nullableTrimmedString($defaults['lesson_type'] ?? null)
|
||||
?? 'article';
|
||||
$seriesName = $this->nullableTrimmedString($lessonData['series_name'] ?? null)
|
||||
?? $this->nullableTrimmedString($defaults['series_name'] ?? null)
|
||||
?? $this->nullableTrimmedString($course->title);
|
||||
$active = array_key_exists('active', $lessonData)
|
||||
? (bool) $lessonData['active']
|
||||
: (array_key_exists('active', $defaults) ? (bool) $defaults['active'] : false);
|
||||
|
||||
return [
|
||||
'category_id' => $this->resolveImportedLessonCategoryId($lessonData, $defaults),
|
||||
'title' => $title,
|
||||
'slug' => $this->reserveImportedLessonSlug($slugSource, $reservedSlugs),
|
||||
'lesson_number' => null,
|
||||
'course_order' => null,
|
||||
'series_name' => $seriesName,
|
||||
'excerpt' => $excerpt,
|
||||
'content' => null,
|
||||
'content_markdown' => null,
|
||||
'difficulty' => $difficulty,
|
||||
'access_level' => $accessLevel,
|
||||
'lesson_type' => $lessonType,
|
||||
'cover_image' => null,
|
||||
'article_cover_image' => null,
|
||||
'tags' => collect((array) ($lessonData['tags'] ?? []))
|
||||
->map(fn ($tag): string => trim((string) $tag))
|
||||
->filter(fn (string $tag): bool => $tag !== '')
|
||||
->values()
|
||||
->all(),
|
||||
'video_url' => null,
|
||||
'reading_minutes' => 5,
|
||||
'featured' => false,
|
||||
'active' => $active,
|
||||
'published_at' => null,
|
||||
'seo_title' => null,
|
||||
'seo_description' => $excerpt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lessonData
|
||||
* @param array<string, mixed> $defaults
|
||||
*/
|
||||
private function resolveImportedLessonCategoryId(array $lessonData, array $defaults): ?int
|
||||
{
|
||||
foreach ([$lessonData, $defaults] as $source) {
|
||||
if ($source === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryId = $source['category_id'] ?? null;
|
||||
if ($categoryId !== null && AcademyCategory::query()->where('type', 'lesson')->whereKey((int) $categoryId)->exists()) {
|
||||
return (int) $categoryId;
|
||||
}
|
||||
|
||||
$categorySlug = $this->nullableTrimmedString($source['category_slug'] ?? null);
|
||||
if ($categorySlug !== null) {
|
||||
$category = AcademyCategory::query()->where('type', 'lesson')->where('slug', $categorySlug)->first();
|
||||
if ($category instanceof AcademyCategory) {
|
||||
return (int) $category->id;
|
||||
}
|
||||
}
|
||||
|
||||
$categoryName = $this->nullableTrimmedString($source['category'] ?? null);
|
||||
if ($categoryName !== null) {
|
||||
$category = AcademyCategory::query()->where('type', 'lesson')->whereRaw('lower(name) = ?', [Str::lower($categoryName)])->first();
|
||||
if ($category instanceof AcademyCategory) {
|
||||
return (int) $category->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $reservedSlugs
|
||||
*/
|
||||
private function reserveImportedLessonSlug(string $source, array &$reservedSlugs): string
|
||||
{
|
||||
$base = Str::slug($source);
|
||||
|
||||
if ($base === '') {
|
||||
$base = 'academy-lesson';
|
||||
}
|
||||
|
||||
$candidate = $base;
|
||||
$suffix = 2;
|
||||
|
||||
while (in_array($candidate, $reservedSlugs, true)) {
|
||||
$candidate = $base.'-'.$suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
$reservedSlugs[] = $candidate;
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function categoriesForEditor(string $type): array
|
||||
{
|
||||
return AcademyCategory::query()
|
||||
->where('type', $type)
|
||||
->orderBy('order_num')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function serializeCourseAvailableLessons(AcademyCourse $course): array
|
||||
{
|
||||
$course->loadMissing(['courseLessons']);
|
||||
|
||||
$attachedLessonIds = $course->courseLessons
|
||||
->pluck('lesson_id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
return AcademyLesson::query()
|
||||
->whereDoesntHave('courseLessons')
|
||||
->with('category')
|
||||
->orderBy('title')
|
||||
->get()
|
||||
->map(fn (AcademyLesson $lesson): array => [
|
||||
'id' => (int) $lesson->id,
|
||||
'title' => (string) $lesson->title,
|
||||
'slug' => (string) $lesson->slug,
|
||||
'difficulty' => (string) $lesson->difficulty,
|
||||
'access_level' => (string) $lesson->access_level,
|
||||
'active' => (bool) $lesson->active,
|
||||
'category' => $lesson->category ? (string) $lesson->category->name : '',
|
||||
'attached' => isset($attachedLessonIds[(int) $lesson->id]),
|
||||
])
|
||||
->map(function (AcademyLesson $lesson): array {
|
||||
$publicationMeta = $this->serializeLessonPublicationMeta($lesson);
|
||||
|
||||
return array_merge([
|
||||
'id' => (int) $lesson->id,
|
||||
'title' => (string) $lesson->title,
|
||||
'slug' => (string) $lesson->slug,
|
||||
'cover_image' => (string) ($lesson->cover_image ?? ''),
|
||||
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? '')),
|
||||
'difficulty' => (string) $lesson->difficulty,
|
||||
'access_level' => (string) $lesson->access_level,
|
||||
'active' => (bool) $lesson->active,
|
||||
'category' => $lesson->category ? (string) $lesson->category->name : '',
|
||||
'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $lesson]),
|
||||
'attached' => false,
|
||||
], $publicationMeta);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
private function serializeLessonPublicationMeta(?AcademyLesson $lesson): array
|
||||
{
|
||||
$publishedAt = $lesson?->published_at instanceof Carbon
|
||||
? $lesson->published_at->copy()
|
||||
: null;
|
||||
|
||||
if (! $publishedAt) {
|
||||
return [
|
||||
'published_at' => null,
|
||||
'publication_state' => 'draft',
|
||||
'publication_label' => 'Unscheduled',
|
||||
];
|
||||
}
|
||||
|
||||
if ($publishedAt->isFuture()) {
|
||||
return [
|
||||
'published_at' => $publishedAt->toIso8601String(),
|
||||
'publication_state' => 'scheduled',
|
||||
'publication_label' => 'Publishes '.$publishedAt->format('Y-m-d H:i'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'published_at' => $publishedAt->toIso8601String(),
|
||||
'publication_state' => 'published',
|
||||
'publication_label' => 'Published',
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeCourseOutlineSummary(AcademyCourse $course): array
|
||||
{
|
||||
$course->loadMissing(['sections', 'courseLessons']);
|
||||
@@ -1734,6 +2158,10 @@ final class AcademyAdminController extends Controller
|
||||
$validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName);
|
||||
}
|
||||
|
||||
$validated['documentation'] = $this->normalizePromptDocumentation($validated['documentation'] ?? null);
|
||||
$validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null);
|
||||
$validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null);
|
||||
$validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null);
|
||||
$validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? []));
|
||||
$previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? []));
|
||||
|
||||
@@ -1803,6 +2231,172 @@ final class AcademyAdminController extends Controller
|
||||
->all();
|
||||
}
|
||||
|
||||
private function encodePrettyJsonForForm(mixed $value): string
|
||||
{
|
||||
if ($value === null || $value === [] || $value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function normalizePromptDocumentation(mixed $documentation): ?array
|
||||
{
|
||||
if (! is_array($documentation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes'];
|
||||
$normalized = [
|
||||
'summary' => $this->nullableTrimmedString($documentation['summary'] ?? null),
|
||||
'display_notes' => $this->nullableTrimmedString($documentation['display_notes'] ?? null),
|
||||
];
|
||||
|
||||
foreach ($listFields as $field) {
|
||||
$normalized[$field] = $this->normalizePromptStringList($documentation[$field] ?? []);
|
||||
}
|
||||
|
||||
$hasContent = $normalized['summary'] !== null
|
||||
|| $normalized['display_notes'] !== null
|
||||
|| collect($listFields)->contains(fn (string $field): bool => $normalized[$field] !== []);
|
||||
|
||||
return $hasContent ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizePromptPlaceholders(mixed $placeholders): array
|
||||
{
|
||||
if (! is_array($placeholders)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($placeholders)
|
||||
->filter(static fn ($placeholder): bool => is_array($placeholder))
|
||||
->map(function (array $placeholder): array {
|
||||
return [
|
||||
'key' => $this->nullableTrimmedString($placeholder['key'] ?? null),
|
||||
'label' => $this->nullableTrimmedString($placeholder['label'] ?? null),
|
||||
'description' => $this->nullableTrimmedString($placeholder['description'] ?? null),
|
||||
'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
|
||||
'example' => $this->normalizePromptJsonValue($placeholder['example'] ?? null),
|
||||
'default' => $this->normalizePromptJsonValue($placeholder['default'] ?? null),
|
||||
'type' => $this->nullableTrimmedString($placeholder['type'] ?? null),
|
||||
];
|
||||
})
|
||||
->filter(function (array $placeholder): bool {
|
||||
return collect([
|
||||
$placeholder['key'] ?? null,
|
||||
$placeholder['label'] ?? null,
|
||||
$placeholder['description'] ?? null,
|
||||
$placeholder['example'] ?? null,
|
||||
$placeholder['default'] ?? null,
|
||||
$placeholder['type'] ?? null,
|
||||
])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizePromptHelperPrompts(mixed $helperPrompts): array
|
||||
{
|
||||
if (! is_array($helperPrompts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($helperPrompts)
|
||||
->filter(static fn ($helperPrompt): bool => is_array($helperPrompt))
|
||||
->map(function (array $helperPrompt): array {
|
||||
return [
|
||||
'title' => $this->nullableTrimmedString($helperPrompt['title'] ?? null),
|
||||
'type' => $this->nullableTrimmedString($helperPrompt['type'] ?? null) ?? 'other',
|
||||
'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null),
|
||||
'prompt' => $this->nullableTrimmedString($helperPrompt['prompt'] ?? null),
|
||||
'expected_output' => $this->nullableTrimmedString($helperPrompt['expected_output'] ?? null) ?? 'text',
|
||||
'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
];
|
||||
})
|
||||
->filter(function (array $helperPrompt): bool {
|
||||
return collect([
|
||||
$helperPrompt['title'] ?? null,
|
||||
$helperPrompt['description'] ?? null,
|
||||
$helperPrompt['prompt'] ?? null,
|
||||
])->contains(fn ($item): bool => $item !== null && $item !== '');
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizePromptVariants(mixed $variants): array
|
||||
{
|
||||
if (! is_array($variants)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($variants)
|
||||
->filter(static fn ($variant): bool => is_array($variant))
|
||||
->map(function (array $variant): array {
|
||||
return [
|
||||
'title' => $this->nullableTrimmedString($variant['title'] ?? null),
|
||||
'slug' => $this->nullableTrimmedString($variant['slug'] ?? null),
|
||||
'description' => $this->nullableTrimmedString($variant['description'] ?? null),
|
||||
'prompt' => $this->nullableTrimmedString($variant['prompt'] ?? null),
|
||||
'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null),
|
||||
'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
|
||||
'recommended_for' => $this->normalizePromptStringList($variant['recommended_for'] ?? []),
|
||||
'risk_notes' => $this->normalizePromptStringList($variant['risk_notes'] ?? []),
|
||||
'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
|
||||
];
|
||||
})
|
||||
->filter(function (array $variant): bool {
|
||||
return collect([
|
||||
$variant['title'] ?? null,
|
||||
$variant['description'] ?? null,
|
||||
$variant['prompt'] ?? null,
|
||||
$variant['negative_prompt'] ?? null,
|
||||
])->contains(fn ($item): bool => $item !== null && $item !== '');
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizePromptStringList(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
$value = $value === null ? [] : [$value];
|
||||
}
|
||||
|
||||
return collect($value)
|
||||
->map(fn ($item): string => trim((string) $item))
|
||||
->filter(static fn (string $item): bool => $item !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizePromptJsonValue(mixed $value): mixed
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $notes
|
||||
* @return array<int, array<string, string>>
|
||||
@@ -1966,6 +2560,23 @@ final class AcademyAdminController extends Controller
|
||||
|
||||
$storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp';
|
||||
Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
|
||||
|
||||
$sourceWidth = imagesx($image);
|
||||
$sourceHeight = imagesy($image);
|
||||
|
||||
foreach (self::PROMPT_PREVIEW_VARIANT_WIDTHS as $variant => $targetWidth) {
|
||||
$variantBinary = $this->encodePromptPreviewVariant($image, $targetWidth, $sourceWidth, $sourceHeight);
|
||||
|
||||
if ($variantBinary === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Storage::disk($this->promptPreviewImageDisk())->put(
|
||||
$this->promptPreviewVariantPath($storedPath, $variant),
|
||||
$variantBinary,
|
||||
['visibility' => 'public']
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
imagedestroy($image);
|
||||
}
|
||||
@@ -1973,6 +2584,62 @@ final class AcademyAdminController extends Controller
|
||||
return $storedPath;
|
||||
}
|
||||
|
||||
private function encodePromptPreviewVariant(\GdImage $source, int $targetWidth, int $sourceWidth, int $sourceHeight): ?string
|
||||
{
|
||||
if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth));
|
||||
$variant = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
|
||||
if (! $variant instanceof \GdImage) {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => 'The uploaded preview image could not be resized. Please try a different image.',
|
||||
]);
|
||||
}
|
||||
|
||||
imagealphablending($variant, false);
|
||||
imagesavealpha($variant, true);
|
||||
$transparent = imagecolorallocatealpha($variant, 0, 0, 0, 127);
|
||||
imagefilledrectangle($variant, 0, 0, $targetWidth, $targetHeight, $transparent);
|
||||
imagecopyresampled($variant, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
|
||||
|
||||
try {
|
||||
ob_start();
|
||||
$converted = imagewebp($variant, null, self::PROMPT_PREVIEW_WEBP_QUALITY);
|
||||
$webpBinary = ob_get_clean();
|
||||
|
||||
if (! $converted || ! is_string($webpBinary) || $webpBinary === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $webpBinary;
|
||||
} finally {
|
||||
imagedestroy($variant);
|
||||
}
|
||||
}
|
||||
|
||||
private function promptPreviewVariantPath(string $path, string $variant): string
|
||||
{
|
||||
$directory = pathinfo($path, PATHINFO_DIRNAME);
|
||||
$filename = pathinfo($path, PATHINFO_FILENAME);
|
||||
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
|
||||
|
||||
return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant);
|
||||
}
|
||||
|
||||
private function canonicalPromptPreviewPath(string $path): string
|
||||
{
|
||||
$directory = pathinfo($path, PATHINFO_DIRNAME);
|
||||
$filename = pathinfo($path, PATHINFO_FILENAME);
|
||||
$baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename;
|
||||
|
||||
return sprintf('%s/%s.webp', $directory, $baseFilename);
|
||||
}
|
||||
|
||||
private function deleteStoredPromptPreviewIfLocal(?string $path): void
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
@@ -1985,10 +2652,14 @@ final class AcademyAdminController extends Controller
|
||||
}
|
||||
|
||||
$disk = $this->promptPreviewImageDisk();
|
||||
$basePath = $this->canonicalPromptPreviewPath($path);
|
||||
$paths = [
|
||||
$basePath,
|
||||
$this->promptPreviewVariantPath($basePath, 'thumb'),
|
||||
$this->promptPreviewVariantPath($basePath, 'md'),
|
||||
];
|
||||
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
Storage::disk($disk)->delete($path);
|
||||
}
|
||||
Storage::disk($disk)->delete(array_values(array_unique($paths)));
|
||||
}
|
||||
|
||||
private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string
|
||||
|
||||
Reference in New Issue
Block a user