Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions app/Http/Controllers/RoutineController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

namespace App\Http\Controllers;

use App\Models\Routine;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class RoutineController extends Controller
{
public function index(Request $request): Response
{
$team = $request->user()->currentTeam;

$routines = $team->routines()
->with('agent:id,name,emoji')
->orderByDesc('created_at')
->get();

return Inertia::render('company/routines/index', [
'routines' => $routines,
'agents' => $team->agents()->select(['id', 'name', 'emoji'])->get(),
]);
}

public function store(Request $request): RedirectResponse
{
$team = $request->user()->currentTeam;

$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'agent_id' => ['required', 'string', 'exists:agents,id'],
'cron_expression' => ['required', 'string', 'max:255'],
'timezone' => ['nullable', 'string', 'timezone:all'],
]);

// Verify agent belongs to team
abort_unless(
$team->agents()->where('id', $validated['agent_id'])->exists(),
422,
'Agent does not belong to this team.',
);

// Enforce per-team limit
$activeCount = $team->routines()->where('status', 'active')->count();
abort_if($activeCount >= 50, 422, 'Maximum of 50 active routines per team.');

$routine = $team->routines()->create([
...$validated,
'timezone' => $validated['timezone'] ?? 'UTC',
'status' => 'active',
]);

$routine->update([
'next_run_at' => $routine->computeNextRun(),
]);

return redirect()->route('company.routines.index')
->with('success', 'Routine created.');
}

public function update(Request $request, Routine $routine): RedirectResponse
{
$this->authorizeTeam($request, $routine);

$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'agent_id' => ['required', 'string', 'exists:agents,id'],
'cron_expression' => ['required', 'string', 'max:255'],
'timezone' => ['nullable', 'string', 'timezone:all'],
]);

$team = $request->user()->currentTeam;

abort_unless(
$team->agents()->where('id', $validated['agent_id'])->exists(),
422,
'Agent does not belong to this team.',
);

$routine->update([
...$validated,
'timezone' => $validated['timezone'] ?? 'UTC',
]);

$routine->update([
'next_run_at' => $routine->computeNextRun(),
]);

return redirect()->route('company.routines.index')
->with('success', 'Routine updated.');
}

public function toggle(Request $request, Routine $routine): RedirectResponse
{
$this->authorizeTeam($request, $routine);

$newStatus = $routine->status === 'active' ? 'paused' : 'active';

$routine->update([
'status' => $newStatus,
'next_run_at' => $newStatus === 'active' ? $routine->computeNextRun() : null,
]);

return redirect()->route('company.routines.index')
->with('success', 'Routine '.($newStatus === 'active' ? 'activated' : 'paused').'.');
}

public function destroy(Request $request, Routine $routine): RedirectResponse
{
$this->authorizeTeam($request, $routine);

$routine->delete();

return redirect()->route('company.routines.index')
->with('success', 'Routine deleted.');
}

private function authorizeTeam(Request $request, Routine $routine): void
{
abort_unless($routine->team_id === $request->user()->current_team_id, 403);
}
}
91 changes: 91 additions & 0 deletions app/Jobs/ProcessRoutinesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace App\Jobs;

use App\Models\Routine;
use App\Models\Task;
use App\Models\Team;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class ProcessRoutinesJob implements ShouldQueue
{
use Queueable;

public function handle(): void
{
$routines = Routine::query()
->where('status', 'active')
->where('next_run_at', '<=', now())
->with('team')
->get();

foreach ($routines as $routine) {
$this->processRoutine($routine);
}
}

private function processRoutine(Routine $routine): void
{
// Skip if a pending task from this routine already exists
$hasPendingTask = Task::query()
->where('routine_id', $routine->id)
->whereIn('status', ['todo', 'in_progress', 'blocked'])
->exists();

if ($hasPendingTask) {
// Still update next_run_at so we check again later
$routine->update([
'next_run_at' => $routine->computeNextRun(),
]);

return;
}

// Enforce per-team limit of 50 active routines
$activeCount = Routine::query()
->where('team_id', $routine->team_id)
->where('status', 'active')
->count();

if ($activeCount > 50) {
Log::warning('Team has exceeded 50 active routines, skipping routine processing', [
'team_id' => $routine->team_id,
'routine_id' => $routine->id,
]);

return;
}

$identifier = $this->generateIdentifier($routine->team);

Task::query()->create([
'team_id' => $routine->team_id,
'agent_id' => $routine->agent_id,
'routine_id' => $routine->id,
'created_by_type' => 'routine',
'created_by_id' => $routine->id,
'title' => $routine->title,
'description' => $routine->description,
'status' => 'todo',
'priority' => 'medium',
'identifier' => $identifier,
]);

$routine->update([
'last_run_at' => now(),
'next_run_at' => $routine->computeNextRun(),
]);
}

/**
* Generate a sequential task identifier like TSK-1, TSK-2, etc.
*/
private function generateIdentifier(Team $team): string
{
$count = $team->tasks()->count();

return 'TSK-'.($count + 1);
}
}
91 changes: 91 additions & 0 deletions app/Models/Routine.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace App\Models;

use Carbon\Carbon;
use Cron\CronExpression;
use Database\Factories\RoutineFactory;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Routine extends Model
{
/** @use HasFactory<RoutineFactory> */
use HasFactory, HasUlids;

/**
* @var list<string>
*/
protected $fillable = [
'team_id',
'agent_id',
'title',
'description',
'cron_expression',
'timezone',
'status',
'last_run_at',
'next_run_at',
];

/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
];
}

/**
* @return BelongsTo<Team, $this>
*/
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}

/**
* @return BelongsTo<Agent, $this>
*/
public function agent(): BelongsTo
{
return $this->belongsTo(Agent::class);
}

/**
* @return HasMany<Task, $this>
*/
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}

/**
* Compute the next run time based on the cron expression and timezone.
*/
public function computeNextRun(): ?Carbon
{
try {
$next = CronExpression::factory($this->cron_expression)
->getNextRunDate(now(), 0, false, $this->timezone);

return Carbon::instance($next);
} catch (\Throwable) {
return null;
}
}

/**
* Determine if this routine is due to run.
*/
public function isDue(): bool
{
return $this->next_run_at && $this->next_run_at->lte(now());
}
}
9 changes: 9 additions & 0 deletions app/Models/Task.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Task extends Model
'completed_at',
'parent_task_id',
'goal_id',
'routine_id',
'checked_out_by_run',
'checked_out_at',
'checkout_expires_at',
Expand Down Expand Up @@ -109,6 +110,14 @@ public function goal(): BelongsTo
return $this->belongsTo(Goal::class);
}

/**
* @return BelongsTo<Routine, $this>
*/
public function routine(): BelongsTo
{
return $this->belongsTo(Routine::class);
}

/**
* The workforce agent assigned to this task.
*
Expand Down
8 changes: 8 additions & 0 deletions app/Models/Team.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ public function tasks(): HasMany
return $this->hasMany(Task::class);
}

/**
* @return HasMany<Routine, $this>
*/
public function routines(): HasMany
{
return $this->hasMany(Routine::class);
}

/**
* @return HasMany<Goal, $this>
*/
Expand Down
39 changes: 39 additions & 0 deletions database/factories/RoutineFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Database\Factories;

use App\Models\Agent;
use App\Models\Routine;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<Routine>
*/
class RoutineFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'team_id' => Team::factory(),
'agent_id' => Agent::factory(),
'title' => fake()->sentence(4),
'description' => fake()->paragraph(),
'cron_expression' => '0 9 * * *',
'timezone' => 'UTC',
'status' => 'active',
'next_run_at' => now()->addDay(),
];
}

public function paused(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'paused',
'next_run_at' => null,
]);
}
}
Loading
Loading