diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php new file mode 100644 index 0000000..a51503a --- /dev/null +++ b/app/Http/Controllers/Api/DeviceController.php @@ -0,0 +1,41 @@ +json($r->user()->devices()->orderBy('name')->get()); + } + + public function store(Request $r): JsonResponse { + $data = $r->validate([ + 'name' => 'required|string|max:60', + 'hardware' => 'nullable|string|max:60', + 'firmware_version' => 'nullable|string|max:30', + ]); + $device = $r->user()->devices()->create([ + 'name' => $data['name'], + 'hardware' => $data['hardware'] ?? 'esp32', + 'firmware_version' => $data['firmware_version'] ?? null, + 'token' => Device::makeToken(), + ]); + return response()->json($device, 201); + } + + public function rotate(Request $r, Device $device): JsonResponse { + abort_unless($device->user_id === $r->user()->id, 403); + $device->update(['token' => Device::makeToken()]); + return response()->json($device); + } + + public function destroy(Request $r, Device $device): JsonResponse { + abort_unless($device->user_id === $r->user()->id, 403); + $device->delete(); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php new file mode 100644 index 0000000..e6f5851 --- /dev/null +++ b/app/Http/Controllers/Api/ProjectController.php @@ -0,0 +1,43 @@ +json($r->user()->projects()->orderBy('name')->get()); + } + + public function store(Request $r): JsonResponse { + $data = $r->validate([ + 'name' => 'required|string|max:80', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:40', + ]); + return response()->json( + $r->user()->projects()->create($data + ['color' => $data['color'] ?? '#6366f1']), + 201 + ); + } + + public function update(Request $r, Project $project): JsonResponse { + abort_unless($project->user_id === $r->user()->id, 403); + $data = $r->validate([ + 'name' => 'sometimes|required|string|max:80', + 'color' => 'nullable|string|max:7', + 'icon' => 'nullable|string|max:40', + ]); + $project->update($data); + return response()->json($project); + } + + public function destroy(Request $r, Project $project): JsonResponse { + abort_unless($project->user_id === $r->user()->id, 403); + $project->delete(); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/TagController.php b/app/Http/Controllers/Api/TagController.php new file mode 100644 index 0000000..6bdf5c5 --- /dev/null +++ b/app/Http/Controllers/Api/TagController.php @@ -0,0 +1,31 @@ +json($r->user()->tags()->orderBy('name')->get()); + } + + public function store(Request $r): JsonResponse { + $data = $r->validate([ + 'name' => 'required|string|max:30', + 'color' => 'nullable|string|max:7', + ]); + return response()->json( + $r->user()->tags()->create($data + ['color' => $data['color'] ?? '#a3a3a3']), + 201 + ); + } + + public function destroy(Request $r, Tag $tag): JsonResponse { + abort_unless($tag->user_id === $r->user()->id, 403); + $tag->delete(); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php new file mode 100644 index 0000000..478cd76 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthController.php @@ -0,0 +1,50 @@ +validate([ + 'name' => 'required|string|max:60', + 'email' => 'required|email|max:120|unique:users', + 'password' => 'required|string|min:8|max:120', + ]); + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + ]); + $token = $user->createToken('web')->plainTextToken; + return response()->json(['user' => $user, 'token' => $token], 201); + } + + public function login(Request $r): JsonResponse { + $data = $r->validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + $user = User::where('email', $data['email'])->first(); + if (! $user || ! Hash::check($data['password'], $user->password)) { + return response()->json(['message' => 'Credenciales inválidas'], 401); + } + $token = $user->createToken('web')->plainTextToken; + return response()->json(['user' => $user, 'token' => $token]); + } + + public function logout(Request $r): JsonResponse { + $r->user()->currentAccessToken()->delete(); + return response()->json(['ok' => true]); + } + + public function me(Request $r): JsonResponse { + return response()->json($r->user()); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..7642f1f --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,48 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + if (! Auth::attempt($cred, true)) { + return back()->withErrors(['email' => 'Credenciales inválidas'])->withInput(); + } + $r->session()->regenerate(); + return redirect()->route('app'); + } + + public function register(Request $r) { + $data = $r->validate([ + 'name' => 'required|string|max:60', + 'email' => 'required|email|unique:users,email', + 'password' => 'required|string|min:8|confirmed', + ]); + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + ]); + Auth::login($user); + return redirect()->route('app'); + } + + public function logout(Request $r) { + Auth::logout(); + $r->session()->invalidate(); + $r->session()->regenerateToken(); + return redirect()->route('home'); + } +} diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php new file mode 100644 index 0000000..64fbe77 --- /dev/null +++ b/app/Http/Controllers/PageController.php @@ -0,0 +1,16 @@ +check()) return redirect()->route('app'); + return view('pages.home'); + } + public function app() { + return view('pages.app'); + } + public function settings() { + return view('pages.settings'); + } +} diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index e760aa7..b405178 100644 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -5,92 +5,152 @@ use App\Models\Task; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\View\View; +use Illuminate\Validation\Rule; -class TaskController extends Controller -{ - /** UI web (lista + formulario). */ - public function index(): View - { - $tasks = Task::orderBy('done') - ->orderByRaw("FIELD(priority,'high','med','low')") - ->orderBy('created_at') - ->get(); - return view('tasks.index', compact('tasks')); +class TaskController extends Controller { + public function index(Request $r) { + $tasks = $r->user()->tasks() + ->with(['project:id,name,color', 'tags:id,name,color']) + ->orderBy('done') + ->orderByRaw("FIELD(priority,'high','med','low')") + ->orderBy('created_at') + ->get(); + return response()->json($tasks); } - /** GET /api/tasks/pending -> JSON para el ESP32 */ - public function pending(): JsonResponse - { - $tasks = Task::active() - ->orderByRaw("FIELD(priority,'high','med','low')") - ->orderBy('created_at') - ->limit(20) - ->get(); - + public function pending(Request $r): JsonResponse { + $tasks = $r->user()->tasks() + ->active() + ->with('project:id,name,color') + ->orderByRaw("FIELD(priority,'high','med','low')") + ->orderBy('created_at') + ->limit(20) + ->get(); return response()->json($tasks->map(fn ($t) => [ 'id' => $t->id, 'title' => $t->title, 'priority' => $t->priority, + 'project' => $t->project?->name, 'next_due_in' => $t->next_due_in, + 'estimate_min' => $t->estimate_min, ])); } - /** POST /api/tasks -> crear (también desde la web) */ - public function store(Request $request): JsonResponse|\Illuminate\Http\RedirectResponse - { - $data = $request->validate([ - 'title' => 'required|string|max:120', - 'priority' => 'nullable|in:low,med,high', + public function store(Request $r): JsonResponse { + $data = $r->validate([ + 'title' => 'required|string|max:120', + 'description' => 'nullable|string', + 'priority' => 'nullable|in:low,med,high', + 'estimate_min' => 'nullable|integer|min:1|max:9999', + 'deadline' => 'nullable|date', + 'project_id' => 'nullable|integer|exists:projects,id', + 'recurrence' => 'nullable|in:daily,weekly,monthly', + 'tags' => 'nullable|array', + 'tags.*' => 'string|max:30', ]); - - $task = Task::create([ - 'title' => $data['title'], - 'priority' => $data['priority'] ?? 'med', + $task = $r->user()->tasks()->create([ + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'priority' => $data['priority'] ?? 'med', + 'estimate_min' => $data['estimate_min'] ?? null, + 'deadline' => $data['deadline'] ?? null, + 'project_id' => $data['project_id'] ?? null, + 'recurrence' => $data['recurrence'] ?? null, ]); - - if ($request->wantsJson()) { - return response()->json($task, 201); + if (! empty($data['tags'])) { + $this->syncTags($task, $data['tags']); } - return redirect()->route('tasks.index'); + return response()->json($task->load('project', 'tags'), 201); } - /** POST /api/tasks/{task}/done -> marcar como hecha */ - public function done(Task $task): JsonResponse - { - $task->update([ - 'done' => true, - 'done_at' => now(), + public function update(Request $r, Task $task): JsonResponse { + abort_unless($task->user_id === $r->user()->id, 403); + $data = $r->validate([ + 'title' => 'sometimes|required|string|max:120', + 'description' => 'nullable|string', + 'priority' => 'nullable|in:low,med,high', + 'estimate_min' => 'nullable|integer|min:1|max:9999', + 'deadline' => 'nullable|date', + 'project_id' => 'nullable|integer|exists:projects,id', + 'recurrence' => 'nullable|in:daily,weekly,monthly', + 'tags' => 'nullable|array', ]); + $task->update(array_intersect_key($data, array_flip([ + 'title', 'description', 'priority', 'estimate_min', + 'deadline', 'project_id', 'recurrence', + ]))); + if (array_key_exists('tags', $data)) { + $this->syncTags($task, $data['tags']); + } + return response()->json($task->load('project', 'tags')); + } + + public function done(Request $r, Task $task): JsonResponse { + abort_unless($task->user_id === $r->user()->id, 403); + $task->update(['done' => true, 'done_at' => now()]); + // Si la tarea es recurrente, generamos la siguiente + if ($task->recurrence) { + $next = $task->replicate(['done', 'done_at', 'snooze_until']); + $next->done = false; + $next->done_at = null; + $next->snooze_until = null; + $next->deadline = match ($task->recurrence) { + 'daily' => $task->deadline?->addDay() ?? now()->addDay(), + 'weekly' => $task->deadline?->addWeek() ?? now()->addWeek(), + 'monthly' => $task->deadline?->addMonth() ?? now()->addMonth(), + default => null, + }; + $next->save(); + } return response()->json(['ok' => true, 'id' => $task->id]); } - /** POST /api/tasks/{task}/snooze -> posponer X minutos */ - public function snooze(Request $request, Task $task): JsonResponse - { - $minutes = (int) $request->input('minutes', 5); - $minutes = max(1, min(120, $minutes)); - - $task->update([ - 'snooze_until' => now()->addMinutes($minutes), - ]); + public function snooze(Request $r, Task $task): JsonResponse { + abort_unless($task->user_id === $r->user()->id, 403); + $minutes = (int) $r->input('minutes', 5); + $minutes = max(1, min(720, $minutes)); + $task->update(['snooze_until' => now()->addMinutes($minutes)]); return response()->json(['ok' => true, 'id' => $task->id, 'minutes' => $minutes]); } - /** DELETE /api/tasks/{task} -> borrar */ - public function destroy(Task $task): JsonResponse|\Illuminate\Http\RedirectResponse - { + public function destroy(Request $r, Task $task): JsonResponse { + abort_unless($task->user_id === $r->user()->id, 403); $task->delete(); - if (request()->wantsJson()) { - return response()->json(['ok' => true]); - } - return redirect()->route('tasks.index'); + return response()->json(['ok' => true]); } - /** POST /tasks/{task}/restore -> reabrir */ - public function restore(Task $task): \Illuminate\Http\RedirectResponse - { + public function restore(Request $r, Task $task): JsonResponse { + abort_unless($task->user_id === $r->user()->id, 403); $task->update(['done' => false, 'done_at' => null, 'snooze_until' => null]); - return redirect()->route('tasks.index'); + return response()->json($task); + } + + /** Estadísticas rápidas para el dashboard */ + public function stats(Request $r): JsonResponse { + $userId = $r->user()->id; + $today = now()->startOfDay(); + return response()->json([ + 'pending' => Task::forUser($userId)->active()->count(), + 'today' => Task::forUser($userId)->active() + ->where('created_at', '>=', $today)->count(), + 'done_today'=> Task::forUser($userId)->where('done', true) + ->where('done_at', '>=', $today)->count(), + 'overdue' => Task::forUser($userId)->active() + ->where('deadline', '<', now())->count(), + 'total_done'=> Task::forUser($userId)->where('done', true)->count(), + ]); + } + + private function syncTags(Task $task, array $tagNames): void { + $userId = $task->user_id; + $ids = []; + foreach ($tagNames as $name) { + $tag = \App\Models\Tag::firstOrCreate( + ['user_id' => $userId, 'name' => $name], + ['color' => '#a3a3a3'] + ); + $ids[] = $tag->id; + } + $task->tags()->sync($ids); } } diff --git a/app/Http/Middleware/AuthenticateDevice.php b/app/Http/Middleware/AuthenticateDevice.php new file mode 100644 index 0000000..9f6a186 --- /dev/null +++ b/app/Http/Middleware/AuthenticateDevice.php @@ -0,0 +1,25 @@ +bearerToken(); + if (! $token) { + return response()->json(['error' => 'No device token'], 401); + } + $device = Device::where('token', $token)->first(); + if (! $device) { + return response()->json(['error' => 'Invalid device token'], 401); + } + $device->touchSeen(); + $request->setUserResolver(fn () => $device->user); + $request->attributes->set('device', $device); + return $next($request); + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php new file mode 100644 index 0000000..beb8c0a --- /dev/null +++ b/app/Models/Device.php @@ -0,0 +1,33 @@ + 'datetime', + 'settings' => 'array', + ]; + + public function user(): BelongsTo { return $this->belongsTo(User::class); } + + /** Genera un token nuevo seguro */ + public static function makeToken(): string { + return Str::random(48); + } + + public function touchSeen(): void { + $this->update(['last_seen_at' => now()]); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..86f4fc8 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,17 @@ +belongsTo(User::class); } + public function tasks(): HasMany { return $this->hasMany(Task::class); } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..b2567e9 --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,17 @@ +belongsTo(User::class); } + public function tasks(): BelongsToMany { return $this->belongsToMany(Task::class); } +} diff --git a/app/Models/Task.php b/app/Models/Task.php index 5c62ce3..c38f699 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -4,41 +4,47 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Builder; -class Task extends Model -{ +class Task extends Model { use HasFactory; protected $fillable = [ - 'title', - 'priority', - 'done', - 'snooze_until', - 'done_at', + 'user_id', 'project_id', 'title', 'description', 'priority', + 'estimate_min', 'deadline', 'recurrence', + 'done', 'done_at', 'snooze_until', ]; protected $casts = [ 'done' => 'boolean', - 'snooze_until' => 'datetime', 'done_at' => 'datetime', + 'snooze_until' => 'datetime', + 'deadline' => 'datetime', ]; - /** Tareas "activas": no hechas y no snoozed. */ - public function scopeActive(Builder $q): Builder - { + public function user(): BelongsTo { return $this->belongsTo(User::class); } + public function project(): BelongsTo { return $this->belongsTo(Project::class); } + public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); } + + /** Tareas activas (no hechas y no snoozed) */ + public function scopeActive(Builder $q): Builder { return $q->where('done', false) - ->where(function ($q2) { - $q2->whereNull('snooze_until') - ->orWhere('snooze_until', '<=', now()); - }); + ->where(function ($q2) { + $q2->whereNull('snooze_until') + ->orWhere('snooze_until', '<=', now()); + }); } - public function getNextDueInAttribute(): int - { - if (! $this->snooze_until || $this->snooze_until->isPast()) { - return 0; - } - return max(0, now()->diffInSeconds($this->snooze_until, false)); + public function scopeForUser(Builder $q, int $userId): Builder { + return $q->where('user_id', $userId); + } + + public function getNextDueInAttribute(): int { + if (! $this->deadline) return 0; + if ($this->deadline->isPast()) return 0; + return max(0, now()->diffInSeconds($this->deadline, false)); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 4b327d2..c5e9963 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,10 +13,12 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'auth.device' => \App\Http\Middleware\AuthenticateDevice::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( - fn (Request $request) => $request->is('api/*'), + fn (Request $request) => $request->is('api/*') || $request->is('api/device/*'), ); })->create(); diff --git a/database/factories/DeviceFactory.php b/database/factories/DeviceFactory.php new file mode 100644 index 0000000..b2c48ac --- /dev/null +++ b/database/factories/DeviceFactory.php @@ -0,0 +1,24 @@ + + */ +class DeviceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 0000000..2539d83 --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,24 @@ + + */ +class ProjectFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/migrations/2026_06_16_215608_create_tasks_table.php b/database/migrations/2026_06_16_215608_create_tasks_table.php index 5129b6e..d8373bc 100644 --- a/database/migrations/2026_06_16_215608_create_tasks_table.php +++ b/database/migrations/2026_06_16_215608_create_tasks_table.php @@ -4,23 +4,25 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ - public function up(): void - { - Schema::create('tasks', function (Blueprint $table) { - $table->id(); - $table->string('title', 120); - $table->enum('priority', ['low', 'med', 'high'])->default('med'); - $table->boolean('done')->default(false); - $table->timestamp('snooze_until')->nullable(); - $table->timestamp('done_at')->nullable(); - $table->timestamps(); +return new class extends Migration { + public function up(): void { + Schema::create('tasks', function (Blueprint $t) { + $t->id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->foreignId('project_id')->nullable()->constrained()->nullOnDelete(); + $t->string('title', 120); + $t->text('description')->nullable(); + $t->enum('priority', ['low', 'med', 'high'])->default('med'); + $t->unsignedSmallInteger('estimate_min')->nullable(); // estimación en min + $t->timestamp('deadline')->nullable(); + $t->string('recurrence', 30)->nullable(); // 'daily', 'weekly', null + $t->boolean('done')->default(false); + $t->timestamp('done_at')->nullable(); + $t->timestamp('snooze_until')->nullable(); + $t->timestamps(); + + $t->index(['user_id', 'done', 'snooze_until']); }); } - - public function down(): void - { - Schema::dropIfExists('tasks'); - } + public function down(): void { Schema::dropIfExists('tasks'); } }; diff --git a/database/migrations/2026_06_16_234900_create_projects_table.php b/database/migrations/2026_06_16_234900_create_projects_table.php new file mode 100644 index 0000000..27fde7c --- /dev/null +++ b/database/migrations/2026_06_16_234900_create_projects_table.php @@ -0,0 +1,20 @@ +id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('name', 80); + $t->string('color', 7)->default('#6366f1'); // hex color + $t->string('icon', 40)->nullable(); // emoji o nombre + $t->timestamps(); + $t->unique(['user_id', 'name']); + }); + } + public function down(): void { Schema::dropIfExists('projects'); } +}; diff --git a/database/migrations/2026_06_16_234920_create_tags_table.php b/database/migrations/2026_06_16_234920_create_tags_table.php new file mode 100644 index 0000000..aa2f5d8 --- /dev/null +++ b/database/migrations/2026_06_16_234920_create_tags_table.php @@ -0,0 +1,30 @@ +id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('name', 30); + $t->string('color', 7)->default('#a3a3a3'); + $t->timestamps(); + $t->unique(['user_id', 'name']); + }); + + // Pivot task <-> tag + Schema::create('tag_task', function (Blueprint $t) { + $t->id(); + $t->foreignId('task_id')->constrained()->cascadeOnDelete(); + $t->foreignId('tag_id')->constrained()->cascadeOnDelete(); + $t->unique(['task_id', 'tag_id']); + }); + } + public function down(): void { + Schema::dropIfExists('tag_task'); + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2026_06_16_234940_create_devices_table.php b/database/migrations/2026_06_16_234940_create_devices_table.php new file mode 100644 index 0000000..e8ab6ef --- /dev/null +++ b/database/migrations/2026_06_16_234940_create_devices_table.php @@ -0,0 +1,22 @@ +id(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('name', 60); // "ESP32 del curro" + $t->string('token', 64)->unique(); // token dedicado + $t->string('hardware', 60)->default('esp32'); + $t->string('firmware_version', 30)->nullable(); + $t->timestamp('last_seen_at')->nullable(); + $t->json('settings')->nullable(); // config del bot + $t->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('devices'); } +}; diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2535786..c6be77d 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -5,11 +5,18 @@ chown -R www-data:www-data /var/www chmod -R 755 /var/www/storage chmod -R 755 /var/www/bootstrap/cache -# Run migrations + seed on first boot if DB is empty -# (only if explicitly enabled; off by default to avoid surprise migrations) +# Run migrations on first boot if AUTO_MIGRATE=1. +# We wait for the DB to be reachable (max 60 s) to avoid race with the db container. if [ "${AUTO_MIGRATE:-0}" = "1" ]; then - echo "[entrypoint] AUTO_MIGRATE=1 -> running migrations" - php artisan migrate --force --no-interaction + echo "[entrypoint] AUTO_MIGRATE=1 -> waiting for DB..." + for i in $(seq 1 60); do + if php -r "try { new PDO('mysql:host='.getenv('DB_HOST').';port='.getenv('DB_PORT').';dbname='.getenv('DB_DATABASE'), getenv('DB_USERNAME'), getenv('DB_PASSWORD')); exit(0); } catch (Exception \$e) { exit(1); }" 2>/dev/null; then + echo "[entrypoint] DB ready after ${i}s -> running migrations" + php artisan migrate --force --no-interaction + break + fi + sleep 1 + done fi # Execute the main command diff --git a/resources/views/pages/app.blade.php b/resources/views/pages/app.blade.php new file mode 100644 index 0000000..a3c7f9a --- /dev/null +++ b/resources/views/pages/app.blade.php @@ -0,0 +1,395 @@ + +
+ {{-- NAV --}} + + + {{-- STATS --}} +
+ +
+ + {{-- FILTROS --}} +
+ + + + + + +
+ +
+
+ + {{-- LISTA DE TAREAS --}} +
+ + + +
+ +
+
+ + {{-- MODAL NUEVA / EDITAR TAREA --}} +
+
+

+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + {{-- TOAST --}} +
+
+ + + + +
diff --git a/resources/views/pages/home.blade.php b/resources/views/pages/home.blade.php new file mode 100644 index 0000000..f97c66a --- /dev/null +++ b/resources/views/pages/home.blade.php @@ -0,0 +1,138 @@ + +
+ {{-- NAV --}} + + + {{-- HERO --}} +
+
+
+
Para cerebros que van a 200 km/h
+

+ Tus tareas, en una + pantalla + con + cara. +

+

+ Un bot físico que te recuerda lo que tienes que hacer, vibrando y haciendo muecas. + Diseñado para personas con TDAH que se pierden entre 14 pestañas del navegador. +

+ +
+
+ Open source +
+
+ Self-hosted +
+
+ Hecho con cariño +
+
+
+ + {{-- PREVIEW --}} +
+
+
+
+
+
+
+
+
dummybot.local
+
+
+
+
😰
+
+
Revisar PR de cliente X
+
+ alta + ~25 min +
+
+
+
+
😐
+
+
Llamar al dentista
+
+ media + 5 min +
+
+
+
+
😊
+
+
Comprar café
+
✓ hecha hace 12 min
+
+
+
+
+
+
+
+
+ + {{-- FEATURES --}} +
+
+ @php + $features = [ + ['icon' => '⚡', 'title' => 'Sin fricción', 'desc' => 'Abre la app, escribe la tarea, listo. Tu DummyBot la muestra en la pantalla.'], + ['icon' => '🔁', 'title' => 'Tareas recurrentes', 'desc' => 'Llamar al dentista cada lunes. Se autogeneran al marcarlas como hechas.'], + ['icon' => '🏷️', 'title' => 'Proyectos y tags', 'desc' => 'Agrupa por cliente, contexto o lo que sea. Filtros al vuelo.'], + ['icon' => '⌨️', 'title' => 'Atajos de teclado', 'desc' => 'n = nueva · d = done · s = snooze · / = buscar. Sin tocar el ratón.'], + ['icon' => '📊', 'title' => 'Stats honestas', 'desc' => 'Sin gamificación tóxica. Solo datos para que veas cómo vas.'], + ['icon' => '🔌', 'title' => 'API abierta', 'desc' => 'Webhook, JSON, importa de Notion, etc. Si lo puedes describir, lo puedes hacer.'], + ]; + @endphp + @foreach ($features as $f) +
+
{{ $f['icon'] }}
+

{{ $f['title'] }}

+

{{ $f['desc'] }}

+
+ @endforeach +
+
+ +
+ DummyBot · Hecho con + + para cerebros no neurotípicos +
+
+
diff --git a/resources/views/pages/login.blade.php b/resources/views/pages/login.blade.php new file mode 100644 index 0000000..1e0d044 --- /dev/null +++ b/resources/views/pages/login.blade.php @@ -0,0 +1,36 @@ + +
+
+ + 🤖 + DummyBot + +

Bienvenido de vuelta

+

Entra para ver tus tareas

+ + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ @csrf +
+ + +
+
+ + +
+ +
+

+ ¿No tienes cuenta? + Crea una +

+
+
+
diff --git a/resources/views/pages/register.blade.php b/resources/views/pages/register.blade.php new file mode 100644 index 0000000..6ff5677 --- /dev/null +++ b/resources/views/pages/register.blade.php @@ -0,0 +1,43 @@ + +
+
+ + 🤖 + DummyBot + +

Crea tu cuenta

+

Tus tareas, en un bot que se enfurruña si las dejas

+ + @if ($errors->any()) +
+
    @foreach ($errors->all() as $e)
  • {{ $e }}
  • @endforeach
+
+ @endif + +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

+ ¿Ya tienes cuenta? + Entrar +

+
+
+
diff --git a/resources/views/pages/settings.blade.php b/resources/views/pages/settings.blade.php new file mode 100644 index 0000000..657738f --- /dev/null +++ b/resources/views/pages/settings.blade.php @@ -0,0 +1,257 @@ + +
+ ← Volver +

Ajustes

+ + {{-- TABS --}} +
+ + + + +
+ + {{-- DEVICES --}} +
+
+

Tus DummyBots físicos. Cada uno tiene su propio token.

+ +
+ + +
+ + {{-- PROJECTS --}} +
+
+

Agrupa tareas por proyecto (cliente, contexto, etc.)

+ +
+
+ +
+
+ + {{-- TAGS --}} +
+
+

Etiquetas transversales para filtrar

+ +
+
+ +
+
+ + {{-- API DOC --}} +
+

API REST completa. Todas las rutas devuelven JSON.

+
GET    /api/tasks                  Listar tareas
+POST   /api/tasks                  Crear tarea
+PUT    /api/tasks/{id}             Editar
+DELETE /api/tasks/{id}             Borrar
+POST   /api/tasks/{id}/done        Marcar como hecha
+POST   /api/tasks/{id}/snooze      Posponer {minutes}
+POST   /api/tasks/{id}/restore     Reabrir
+
+GET    /api/projects               Listar proyectos
+POST   /api/projects               Crear {name, color}
+...
+
+GET    /api/tags                   Listar tags
+POST   /api/tags                   Crear {name, color}
+
+GET    /api/devices                Tus dispositivos
+POST   /api/devices                Crear dispositivo (devuelve token)
+POST   /api/devices/{id}/rotate    Rotar token
+
+# Para el DummyBot físico:
+GET    /api/device/tasks/pending   (auth.device)
+POST   /api/device/tasks           (auth.device)
+POST   /api/device/heartbeat       (auth.device) ping
+
+Auth: Authorization: Bearer <token>
+
+ + {{-- MODAL NUEVO DISPOSITIVO --}} +
+
+

Nuevo DummyBot

+
+ + +
+ + +
+
+
+
+ + {{-- MODALES PROYECTOS Y TAGS --}} +
+
+

Nuevo proyecto

+
+ + +
+ + +
+
+
+
+ +
+
+

Nuevo tag

+
+ + +
+ + +
+
+
+
+ +
+
+ + + + +
diff --git a/resources/views/tasks/index.blade.php b/resources/views/tasks/index.blade.php deleted file mode 100644 index 7f862c1..0000000 --- a/resources/views/tasks/index.blade.php +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - DummyBot – tareas - - - -

DummyBot · gestor de tareas TDAH-friendly

- -
- @csrf - - - -
- - - - diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 26e294a..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - @fonts - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

With so many options available to you,
we suggest you start with the following:

- - - -

- v{{ app()->version() }} - - View changelog - - - - -

-
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- 13 --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/api.php b/routes/api.php index f387808..1acd783 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,12 +1,58 @@ group(function () { + Route::get ('auth/me', [AuthController::class, 'me']); + Route::post('auth/logout', [AuthController::class, 'logout']); +}); + +// ─── Endpoints del DummyBot (ESP32) ─── +// Usa el token del dispositivo en Authorization: Bearer +Route::middleware('auth.device')->prefix('device')->group(function () { + Route::get ('tasks/pending', [TaskController::class, 'pending']); + Route::post ('tasks', [TaskController::class, 'store']); + Route::post ('tasks/{task}/done', [TaskController::class, 'done']); + Route::post ('tasks/{task}/snooze', [TaskController::class, 'snooze']); + Route::delete('tasks/{task}', [TaskController::class, 'destroy']); + Route::post ('heartbeat', function (Request $r) { + return response()->json([ + 'ok' => true, + 'server_time' => now()->toIso8601String(), + 'device' => $r->attributes->get('device')?->only('id','name'), + ]); + }); +}); + +// ─── Web UI (mismo backend; usa sesiones + Sanctum) ─── +// Tareas +Route::middleware('auth:sanctum')->group(function () { + Route::get ('/tasks', [TaskController::class, 'index']); + Route::post ('/tasks', [TaskController::class, 'store']); + Route::put ('/tasks/{task}', [TaskController::class, 'update']); + Route::delete('/tasks/{task}', [TaskController::class, 'destroy']); + Route::post ('/tasks/{task}/done', [TaskController::class, 'done']); + Route::post ('/tasks/{task}/snooze', [TaskController::class, 'snooze']); + Route::post ('/tasks/{task}/restore',[TaskController::class, 'restore']); + Route::get ('/tasks/stats', [TaskController::class, 'stats']); + // Proyectos + Route::get ('/projects', [\App\Http\Controllers\Api\ProjectController::class, 'index']); + Route::post ('/projects', [\App\Http\Controllers\Api\ProjectController::class, 'store']); + Route::put ('/projects/{project}',[\App\Http\Controllers\Api\ProjectController::class, 'update']); + Route::delete('/projects/{project}',[\App\Http\Controllers\Api\ProjectController::class, 'destroy']); + // Tags + Route::get ('/tags', [\App\Http\Controllers\Api\TagController::class, 'index']); + Route::post ('/tags', [\App\Http\Controllers\Api\TagController::class, 'store']); + Route::delete('/tags/{tag}', [\App\Http\Controllers\Api\TagController::class, 'destroy']); + // Devices + Route::get ('/devices', [\App\Http\Controllers\Api\DeviceController::class, 'index']); + Route::post ('/devices', [\App\Http\Controllers\Api\DeviceController::class, 'store']); + Route::post ('/devices/{device}/rotate', [\App\Http\Controllers\Api\DeviceController::class, 'rotate']); + Route::delete('/devices/{device}',[\App\Http\Controllers\Api\DeviceController::class, 'destroy']); +}); diff --git a/routes/web.php b/routes/web.php index c58b26d..43d9dd2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,9 +1,18 @@ name('tasks.index'); -Route::post('/tasks', [TaskController::class, 'store'])->name('tasks.store'); -Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('tasks.destroy'); -Route::post('/tasks/{task}/restore', [TaskController::class, 'restore'])->name('tasks.restore'); +Route::get ('/', [PageController::class, 'home'])->name('home'); +Route::get ('/login', [LoginController::class, 'showLogin'])->name('login'); +Route::get ('/register',[LoginController::class, 'showRegister'])->name('register'); +Route::post('/login', [LoginController::class, 'login']); +Route::post('/register',[LoginController::class, 'register']); +Route::post('/logout', [LoginController::class, 'logout'])->middleware('auth:sanctum'); + +Route::middleware('auth:sanctum')->group(function () { + Route::get('/app', [PageController::class, 'app'])->name('app'); + Route::get('/app/settings', [PageController::class, 'settings'])->name('settings'); +});