Najaah LMS - Feature Agent
Purpose
Specialized agent for implementing business logic, domain rules, workflows, and feature specifications for Najaah LMS.
When to Use This Agent
- •Implementing new features
- •Writing business logic in services
- •Creating workflow processes (requests, approvals)
- •Enforcing domain rules
- •Building authorization logic
- •Implementing state machines
Prerequisites
Always read the master skill first: /mnt/skills/user/najaah/SKILL.md
Core Responsibilities
1. Service Layer Implementation
Service Pattern (STRICT):
php
<?php
declare(strict_types=1);
namespace App\Services\Playback;
use App\Models\User;
use App\Models\Video;
use DomainException;
final readonly class PlaybackService
{
public function __construct(
private PlaybackAuthorizationService $authService,
private ViewLimitService $viewLimitService,
private BunnyEmbedTokenService $tokenService,
) {}
/**
* @return array{
* library_id: string,
* video_uuid: string,
* embed_token: string,
* embed_token_expires: int,
* session_id: string,
* expires_in: int
* }
*/
public function requestPlayback(
User $user,
Center $center,
Course $course,
Video $video
): array {
// 1. Authorization checks
$this->authService->assertCanStartPlayback($user, $center, $course, $video);
// 2. Business logic
// Implementation...
return [
'library_id' => $video->library_id,
'video_uuid' => $video->source_id,
// ...
];
}
/**
* Deny helper for clean exception throwing
*/
private function deny(string $code, string $message): never
{
throw new DomainException(
json_encode(['code' => $code, 'message' => $message])
);
}
}
Service Rules:
- •Constructor injection ONLY (no service locator pattern)
- •Readonly class and properties
- •Strict type hints everywhere
- •Return typed arrays with PHPDoc shapes
- •Use
deny()helper for domain exceptions - •No direct controller/request dependencies
2. Authorization Pattern
Authorization Service:
php
final readonly class PlaybackAuthorizationService
{
/**
* Assert all prerequisites for starting playback
*/
public function assertCanStartPlayback(
User $user,
Center $center,
Course $course,
Video $video
): void {
// User is student
if (!$user->is_student) {
$this->deny('UNAUTHORIZED', 'Only students can start playback.');
}
// Center access
if ($user->center_id !== $center->id) {
$this->deny('CENTER_MISMATCH', 'Student does not belong to this center.');
}
// Course ownership
if ($course->center_id !== $center->id) {
$this->deny('CENTER_MISMATCH', 'Course does not belong to this center.');
}
// Course published
if ($course->status !== Course::STATUS_PUBLISHED) {
$this->deny('NOT_FOUND', 'Course is not available.');
}
// Video attached to course
if (!$course->videos()->where('videos.id', $video->id)->exists()) {
$this->deny('NOT_FOUND', 'Video not found in this course.');
}
// Video ready for playback
if ($video->encoding_status !== Video::ENCODING_READY) {
$this->deny('VIDEO_NOT_READY', 'Video is still encoding.');
}
if ($video->lifecycle_status !== Video::LIFECYCLE_READY) {
$this->deny('VIDEO_NOT_READY', 'Video is not ready for playback.');
}
// Active enrollment
$enrollment = $user->enrollments()
->where('course_id', $course->id)
->where('status', Enrollment::STATUS_ACTIVE)
->first();
if (!$enrollment) {
$this->deny('ENROLLMENT_REQUIRED', 'Active enrollment required.');
}
// View limit check
if (!$this->viewLimitService->hasViewsRemaining($user, $video, $course)) {
$this->deny('VIEW_LIMIT_EXCEEDED', 'No remaining views for this video.');
}
}
private function deny(string $code, string $message): never
{
throw new DomainException(
json_encode(['code' => $code, 'message' => $message])
);
}
}
3. Business Rules Enforcement
View Limit Calculation:
php
final readonly class ViewLimitService
{
public function getRemainingViews(
User $user,
Video $video,
Course $course
): int {
// Get effective limit from hierarchy
$limit = $this->resolveViewLimit($user, $video, $course);
// Count full plays
$fullPlays = PlaybackSession::where('user_id', $user->id)
->where('video_id', $video->id)
->where('is_full_play', true)
->count();
// Get approved extra views
$extraViews = ExtraViewRequest::where('user_id', $user->id)
->where('video_id', $video->id)
->where('status', ExtraViewRequest::STATUS_APPROVED)
->sum('granted_views') ?? 0;
// Calculate remaining
$totalLimit = $limit + $extraViews;
return max(0, $totalLimit - $fullPlays);
}
public function hasViewsRemaining(
User $user,
Video $video,
Course $course
): bool {
return $this->getRemainingViews($user, $video, $course) > 0;
}
/**
* Resolve view limit from settings hierarchy
* Priority: Student > Video > Course > CourseVideo > Center
*/
private function resolveViewLimit(
User $user,
Video $video,
Course $course
): int {
// Student setting
$studentSetting = $user->settings?->settings['view_limit'] ?? null;
if ($studentSetting !== null) {
return (int) $studentSetting;
}
// Video setting
$videoSetting = $video->settings?->settings['view_limit'] ?? null;
if ($videoSetting !== null) {
return (int) $videoSetting;
}
// Course-Video pivot override
$pivotOverride = $course->videos()
->where('videos.id', $video->id)
->first()
?->pivot
?->view_limit_override;
if ($pivotOverride !== null) {
return $pivotOverride;
}
// Course setting
$courseSetting = $course->settings?->settings['view_limit'] ?? null;
if ($courseSetting !== null) {
return (int) $courseSetting;
}
// Center setting
$centerSetting = $course->center->settings?->settings['view_limit'] ?? null;
if ($centerSetting !== null) {
return (int) $centerSetting;
}
// Center default column
return $course->center->default_view_limit;
}
}
Full Play Detection:
php
public function updateProgress(
User $user,
PlaybackSession $session,
int $percentage
): array {
// Validate session ownership
if ($session->user_id !== $user->id) {
return []; // Silently ignore
}
// Session already ended
if ($session->ended_at !== null) {
return [];
}
// Session expired
if ($session->expires_at < now()) {
return [];
}
// Update last activity and extend session
$session->last_activity_at = now();
$session->expires_at = now()->addSeconds(config('playback.session_ttl'));
// Update progress if increased
if ($percentage > $session->progress_percent) {
$session->progress_percent = $percentage;
// Detect full play (80% threshold)
if ($percentage >= 80 && !$session->is_full_play) {
$session->is_full_play = true;
// View is now counted!
}
}
$session->save();
// Check if video is now locked
$isLocked = !$this->viewLimitService->hasViewsRemaining(
$user,
$session->video,
$session->course
);
// Update is_locked flag
$session->is_locked = $isLocked;
$session->save();
return [
'progress' => $session->progress_percent,
'is_full_play' => $session->is_full_play,
'is_locked' => $isLocked,
'remaining_views' => $this->viewLimitService->getRemainingViews(
$user,
$session->video,
$session->course
),
];
}
4. Workflow Implementation
Request-Approval Pattern:
php
// Device Change Request workflow
final readonly class DeviceChangeService
{
/**
* Student creates request
*/
public function create(
User $student,
string $newDeviceId,
string $model,
string $osVersion,
?string $reason
): DeviceChangeRequest {
// Validate student role
if (!$student->is_student) {
$this->deny('UNAUTHORIZED', 'Only students can request device changes.');
}
// Validate has active device
$currentDevice = $student->devices()
->where('status', UserDevice::STATUS_ACTIVE)
->first();
if (!$currentDevice) {
$this->deny('NO_ACTIVE_DEVICE', 'No active device found.');
}
// Check for pending request
$pending = DeviceChangeRequest::where('user_id', $student->id)
->where('status', DeviceChangeRequest::STATUS_PENDING)
->exists();
if ($pending) {
$this->deny('PENDING_REQUEST_EXISTS', 'Already have pending request.');
}
// Create request
return DeviceChangeRequest::create([
'user_id' => $student->id,
'center_id' => $student->center_id,
'current_device_id' => $currentDevice->device_id,
'new_device_id' => $newDeviceId,
'new_model' => $model,
'new_os_version' => $osVersion,
'status' => DeviceChangeRequest::STATUS_PENDING,
'reason' => $reason,
]);
}
/**
* Admin approves request
*/
public function approve(
User $admin,
DeviceChangeRequest $request,
?string $reason = null
): DeviceChangeRequest {
// Validate admin scope (same center)
if ($admin->center_id !== $request->center_id) {
$this->deny('UNAUTHORIZED', 'Cannot approve request from different center.');
}
// Validate request is pending
if ($request->status !== DeviceChangeRequest::STATUS_PENDING) {
$this->deny('INVALID_STATE', 'Request is not pending.');
}
DB::transaction(function () use ($request, $admin, $reason) {
$student = $request->user;
// Revoke current device
UserDevice::where('user_id', $student->id)
->where('device_id', $request->current_device_id)
->update(['status' => UserDevice::STATUS_REVOKED]);
// Create or update new device
UserDevice::updateOrCreate(
[
'user_id' => $student->id,
'device_id' => $request->new_device_id,
],
[
'model' => $request->new_model,
'os_version' => $request->new_os_version,
'status' => UserDevice::STATUS_ACTIVE,
'approved_at' => now(),
'last_used_at' => now(),
]
);
// Revoke all other devices
UserDevice::where('user_id', $student->id)
->where('device_id', '!=', $request->new_device_id)
->update(['status' => UserDevice::STATUS_REVOKED]);
// Update request
$request->update([
'status' => DeviceChangeRequest::STATUS_APPROVED,
'decision_reason' => $reason,
'decided_by' => $admin->id,
'decided_at' => now(),
]);
// Create audit log
AuditLog::create([
'user_id' => $admin->id,
'action' => 'device_change_approved',
'entity_type' => DeviceChangeRequest::class,
'entity_id' => $request->id,
'metadata' => [
'student_id' => $student->id,
'old_device' => $request->current_device_id,
'new_device' => $request->new_device_id,
],
]);
});
return $request->fresh();
}
/**
* Admin rejects request
*/
public function reject(
User $admin,
DeviceChangeRequest $request,
?string $reason = null
): DeviceChangeRequest {
// Validate admin scope
if ($admin->center_id !== $request->center_id) {
$this->deny('UNAUTHORIZED', 'Cannot reject request from different center.');
}
// Validate request is pending
if ($request->status !== DeviceChangeRequest::STATUS_PENDING) {
$this->deny('INVALID_STATE', 'Request is not pending.');
}
$request->update([
'status' => DeviceChangeRequest::STATUS_REJECTED,
'decision_reason' => $reason,
'decided_by' => $admin->id,
'decided_at' => now(),
]);
return $request;
}
private function deny(string $code, string $message): never
{
throw new DomainException(
json_encode(['code' => $code, 'message' => $message])
);
}
}
5. State Machine Pattern
Session Lifecycle:
php
final readonly class PlaybackSessionStateMachine
{
public function start(User $user, Video $video, Device $device): PlaybackSession
{
return PlaybackSession::create([
'user_id' => $user->id,
'video_id' => $video->id,
'device_id' => $device->id,
'started_at' => now(),
'expires_at' => now()->addSeconds(config('playback.session_ttl')),
'progress_percent' => 0,
'is_full_play' => false,
'auto_closed' => false,
'is_locked' => false,
'watch_duration' => 0,
]);
}
public function updateProgress(
PlaybackSession $session,
int $percentage
): void {
$wasNotFullPlay = !$session->is_full_play;
$session->update([
'progress_percent' => max($session->progress_percent, $percentage),
'is_full_play' => $percentage >= 80 ? true : $session->is_full_play,
'last_activity_at' => now(),
'expires_at' => now()->addSeconds(config('playback.session_ttl')),
]);
// Transition to full_play state
if ($wasNotFullPlay && $session->is_full_play) {
event(new VideoFullyWatched($session));
}
}
public function close(
PlaybackSession $session,
int $watchDuration,
string $reason
): void {
// Already ended, ignore
if ($session->ended_at !== null) {
return;
}
$session->update([
'ended_at' => now(),
'watch_duration' => $watchDuration,
'close_reason' => $reason,
'auto_closed' => in_array($reason, ['timeout', 'max_views']),
]);
event(new SessionClosed($session));
}
public function expire(PlaybackSession $session): void
{
$this->close($session, $session->watch_duration, 'timeout');
}
}
6. Settings Hierarchy Resolution
Settings Resolver:
php
final readonly class SettingsResolverService
{
/**
* Resolve setting from hierarchy
* Priority: Student > Video > Course > Center > System
*/
public function resolve(
string $key,
?User $user = null,
?Video $video = null,
?Course $course = null,
?Center $center = null
): mixed {
// Student level
if ($user && $user->settings) {
$value = data_get($user->settings->settings, $key);
if ($value !== null) {
return $value;
}
}
// Video level
if ($video && $video->settings) {
$value = data_get($video->settings->settings, $key);
if ($value !== null) {
return $value;
}
}
// Course level
if ($course && $course->settings) {
$value = data_get($course->settings->settings, $key);
if ($value !== null) {
return $value;
}
}
// Center level (settings table)
if ($center && $center->settings) {
$value = data_get($center->settings->settings, $key);
if ($value !== null) {
return $value;
}
}
// Center level (direct column)
if ($center) {
// Map setting keys to column names
$columnMap = [
'view_limit' => 'default_view_limit',
'pdf_download' => 'pdf_download_permission',
'extra_view_requests' => 'allow_extra_view_requests',
'device_limit' => 'device_limit',
];
if (isset($columnMap[$key])) {
return $center->{$columnMap[$key]};
}
}
// System default
return $this->getSystemDefault($key);
}
private function getSystemDefault(string $key): mixed
{
return match ($key) {
'view_limit' => 2,
'pdf_download' => true,
'extra_view_requests' => true,
'device_limit' => 1,
default => null,
};
}
}
Domain Rules Enforcement
Rule: One Device Per Student
php
public function register(User $user, string $deviceId, string $model, string $os): UserDevice
{
// Check for existing active device
$activeDevice = UserDevice::where('user_id', $user->id)
->where('status', UserDevice::STATUS_ACTIVE)
->first();
// Different device exists
if ($activeDevice && $activeDevice->device_id !== $deviceId) {
$this->deny('DEVICE_MISMATCH', 'Different device is already active.');
}
// Same device, update last used
if ($activeDevice && $activeDevice->device_id === $deviceId) {
$activeDevice->update(['last_used_at' => now()]);
return $activeDevice;
}
// First device, create and activate
$device = UserDevice::create([
'user_id' => $user->id,
'device_id' => $deviceId,
'model' => $model,
'os_version' => $os,
'status' => UserDevice::STATUS_ACTIVE,
'approved_at' => now(),
'last_used_at' => now(),
]);
// Revoke all other devices (safety)
UserDevice::where('user_id', $user->id)
->where('id', '!=', $device->id)
->update(['status' => UserDevice::STATUS_REVOKED]);
return $device;
}
Rule: No Concurrent Playback
php
public function assertNoConcurrentSession(User $user, Device $currentDevice): void
{
$activeSession = PlaybackSession::where('user_id', $user->id)
->whereNull('ended_at')
->where('expires_at', '>', now())
->first();
if ($activeSession) {
// Same device: close old session, allow new one
if ($activeSession->device_id === $currentDevice->id) {
$this->closeSession(
$activeSession->id,
$activeSession->watch_duration,
'user'
);
return;
}
// Different device: block
$this->deny(
'CONCURRENT_DEVICE',
'Playback is active on another device.'
);
}
}
Rule: View Limit Enforcement
php
public function assertViewsRemaining(User $user, Video $video, Course $course): void
{
$remaining = $this->viewLimitService->getRemainingViews($user, $video, $course);
if ($remaining <= 0) {
$this->deny(
'VIEW_LIMIT_EXCEEDED',
'No remaining views. Request extra views from your instructor.'
);
}
}
Rule: Enrollment Required
php
public function assertActiveEnrollment(User $user, Course $course): void
{
$enrollment = $user->enrollments()
->where('course_id', $course->id)
->where('status', Enrollment::STATUS_ACTIVE)
->first();
if (!$enrollment) {
$this->deny(
'ENROLLMENT_REQUIRED',
'You must be enrolled in this course.'
);
}
}
Event-Driven Patterns
Domain Events
php
namespace App\Events;
class VideoFullyWatched
{
public function __construct(
public readonly PlaybackSession $session
) {}
}
class SessionClosed
{
public function __construct(
public readonly PlaybackSession $session
) {}
}
class DeviceChanged
{
public function __construct(
public readonly User $user,
public readonly UserDevice $oldDevice,
public readonly UserDevice $newDevice
) {}
}
Event Listeners
php
namespace App\Listeners;
class UpdateViewCount
{
public function handle(VideoFullyWatched $event): void
{
// Invalidate view count cache
Cache::forget("views:{$event->session->user_id}:{$event->session->video_id}");
// Notify instructors if view limit reached
$remaining = app(ViewLimitService::class)->getRemainingViews(
$event->session->user,
$event->session->video,
$event->session->course
);
if ($remaining === 0) {
event(new ViewLimitReached($event->session->user, $event->session->video));
}
}
}
Transaction Management
Use Transactions for Multi-Step Operations
php
use Illuminate\Support\Facades\DB;
public function approve(User $admin, DeviceChangeRequest $request): DeviceChangeRequest
{
return DB::transaction(function () use ($admin, $request) {
// Step 1: Revoke old device
UserDevice::where('device_id', $request->current_device_id)
->update(['status' => UserDevice::STATUS_REVOKED]);
// Step 2: Activate new device
UserDevice::updateOrCreate(
['user_id' => $request->user_id, 'device_id' => $request->new_device_id],
['status' => UserDevice::STATUS_ACTIVE]
);
// Step 3: Update request
$request->update(['status' => 'APPROVED']);
// Step 4: Create audit log
AuditLog::create([...]);
return $request->fresh();
});
}
Validation Layer
Custom Validation Rules
php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class ValidDeviceId implements Rule
{
public function passes($attribute, $value): bool
{
// UUID v4 format
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value);
}
public function message(): string
{
return 'The :attribute must be a valid UUID v4.';
}
}
Usage in FormRequests
php
public function rules(): array
{
return [
'device_id' => ['required', 'string', new ValidDeviceId()],
'reason' => ['nullable', 'string', 'max:500'],
];
}
Feature Implementation Checklist
When implementing a new feature:
- • Define domain rules and business logic
- • Create service class with interface
- • Implement authorization service
- • Write service methods with strict types
- • Add validation rules
- • Create FormRequest classes
- • Implement controller (thin)
- • Create API resource for responses
- • Add domain events if needed
- • Write unit tests for services
- • Write feature tests for endpoints
- • Document in
/docs/features/ - • Update this skill if needed
Common Patterns Reference
Deny Helper
php
private function deny(string $code, string $message): never
{
throw new DomainException(
json_encode(['code' => $code, 'message' => $message])
);
}
Service Return Types
php
/**
* @return array{id: int, status: string, created_at: string}
*/
public function create(...): array
{
return [
'id' => $entity->id,
'status' => $entity->status,
'created_at' => $entity->created_at->toIso8601String(),
];
}
Authorization Checks
php
// In service method
$this->authService->assertCanPerformAction($user, $resource);
// In authorization service
public function assertCanPerformAction(User $user, Resource $resource): void
{
if (!$user->can('perform', $resource)) {
$this->deny('UNAUTHORIZED', 'Not authorized.');
}
}
Related Skills
- •Master Skill:
/mnt/skills/user/najaah/SKILL.md - •Architecture Agent:
/mnt/skills/user/najaah-architecture/SKILL.md - •Code Quality Agent:
/mnt/skills/user/najaah-quality/SKILL.md