feat: add City10 platform with Laravel 13, AI agents, 3D viewer, and LGPD compliance
Initial commit of the complete City10 virtual city simulation platform. Backend (Laravel 13): - REST API with Sanctum tokens and session-based web authentication - Role-based access control (admin/user) with middleware protection - AI agent CRUD with OpenRouter.ai integration restricted to free models - Store management with 3D coordinates and AI shopkeeper assignment - Versioned legal documents (Terms, Privacy, Cookies) with LGPD audit trail - Math CAPTCHA on public forms and multi-language support (pt_BR, en, es) Frontend: - Responsive Blade templates with Tailwind CSS v4 - Three.js 3D city viewer with SSE chat streaming to AI agents - Admin panel for store and user administration Infrastructure: - MariaDB/MySQL database with comprehensive migrations - Database seeders for demo content and test accounts - PHPUnit test suite and Laravel Pint code styling - Vite build configuration with hot reload
This commit is contained in:
+112
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AiAgentRequest;
|
||||
use App\Models\AiAgent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* AI agent management endpoints (admin only).
|
||||
*
|
||||
* Agents power the NPCs and managers of the virtual city through the
|
||||
* OpenRouter.ai API, restricted to the allow-listed free models.
|
||||
*/
|
||||
class AiAgentController extends Controller
|
||||
{
|
||||
/**
|
||||
* Return the list of models allowed for agent configuration.
|
||||
*/
|
||||
public function models(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'models' => config('ai.allowed_models', []),
|
||||
'default' => config('ai.default_model'),
|
||||
'api_key_configured' => filled(config('ai.openrouter.api_key')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated listing of registered AI agents.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = AiAgent::query()->orderBy('name');
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
return response()->json($query->paginate((int) $request->query('per_page', 15)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a compact snapshot of active agents for the 3D renderer.
|
||||
*/
|
||||
public function world(): JsonResponse
|
||||
{
|
||||
$agents = AiAgent::query()
|
||||
->where('status', '!=', 'paused')
|
||||
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'current_goal'])
|
||||
->map(fn (AiAgent $agent): array => [
|
||||
'id' => $agent->id,
|
||||
'name' => $agent->name,
|
||||
'type' => $agent->type,
|
||||
'status' => $agent->status,
|
||||
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||
'rotation_y' => (float) $agent->rotation_y,
|
||||
'goal' => $agent->current_goal,
|
||||
]);
|
||||
|
||||
return response()->json(['agents' => $agents]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new AI agent.
|
||||
*/
|
||||
public function store(AiAgentRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$data['model'] = $data['model'] ?? config('ai.default_model');
|
||||
$data['status'] = $data['status'] ?? 'active';
|
||||
|
||||
$agent = AiAgent::create($data);
|
||||
|
||||
return response()->json($agent, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single AI agent.
|
||||
*/
|
||||
public function show(AiAgent $aiAgent): JsonResponse
|
||||
{
|
||||
return response()->json($aiAgent->load('stores:id,name,agent_id,is_open'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an AI agent.
|
||||
*/
|
||||
public function update(AiAgentRequest $request, AiAgent $aiAgent): JsonResponse
|
||||
{
|
||||
$aiAgent->update($request->validated());
|
||||
|
||||
return response()->json($aiAgent->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an AI agent.
|
||||
*/
|
||||
public function destroy(AiAgent $aiAgent): JsonResponse
|
||||
{
|
||||
$aiAgent->delete();
|
||||
|
||||
return response()->json(['message' => __('agents.deleted')]);
|
||||
}
|
||||
}
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Http\Requests\RegisterRequest;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Authentication endpoints for the City10 REST API.
|
||||
*
|
||||
* Issues personal access tokens (Laravel Sanctum) consumed by the 3D
|
||||
* frontend and by external integrations.
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Issue a fresh math CAPTCHA challenge for the login/registration forms.
|
||||
*
|
||||
* The expected result is stored server-side in the session, so clients
|
||||
* only receive the question to solve.
|
||||
*/
|
||||
public function captcha(): JsonResponse
|
||||
{
|
||||
$a = random_int(1, 9);
|
||||
$b = random_int(1, 9);
|
||||
|
||||
session()->put('captcha_result', $a + $b);
|
||||
session()->put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
|
||||
|
||||
return response()->json([
|
||||
'question' => sprintf('%d + %d = ?', $a, $b),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new regular user account.
|
||||
*
|
||||
* Applies a simple math CAPTCHA and immediately records acceptance of
|
||||
* the current legal documents when the client confirms them.
|
||||
*/
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'role' => User::ROLE_USER,
|
||||
'locale' => $data['locale'] ?? 'pt_BR',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$token = $user->createToken('city10-api')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user and return an API token.
|
||||
*/
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$user = User::where('email', $request->validated('email'))->first();
|
||||
|
||||
if ($user === null || ! Hash::check($request->validated('password'), $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('auth.failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $user->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('auth.disabled')],
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
$token = $user->createToken('city10-api')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'requires_terms' => ! $user->hasAcceptedCurrentTerms(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the authenticated user's own profile (name, locale, password).
|
||||
*
|
||||
* Role and active status are intentionally excluded: those are managed
|
||||
* exclusively through the admin endpoints.
|
||||
*/
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:120'],
|
||||
'locale' => ['sometimes', Rule::in(['pt_BR', 'en', 'es'])],
|
||||
'password' => ['sometimes', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$user->password = $data['password'];
|
||||
unset($data['password']);
|
||||
}
|
||||
|
||||
$user->fill($data)->save();
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the token used for the current request.
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()?->delete();
|
||||
|
||||
return response()->json(['message' => __('auth.logged_out')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated user plus consent status.
|
||||
*/
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$currentDocuments = LegalDocument::query()
|
||||
->where('is_current', true)
|
||||
->get(['id', 'type', 'version']);
|
||||
|
||||
$acceptedIds = $user->consents()->pluck('legal_document_id');
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'requires_terms' => ! $user->hasAcceptedCurrentTerms(),
|
||||
'pending_documents' => $currentDocuments
|
||||
->reject(fn ($doc) => $acceptedIds->contains($doc->id))
|
||||
->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiAgent;
|
||||
use App\Models\Store;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Aggregated city snapshot consumed by the Three.js frontend.
|
||||
*
|
||||
* Provides a single lightweight payload describing the static and dynamic
|
||||
* elements of the virtual city (stores and AI agents with coordinates).
|
||||
*/
|
||||
class CityController extends Controller
|
||||
{
|
||||
/**
|
||||
* Return the current world state used to bootstrap the 3D scene.
|
||||
*/
|
||||
public function world(): JsonResponse
|
||||
{
|
||||
$stores = Store::query()
|
||||
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||
->map(fn (Store $store): array => [
|
||||
'id' => $store->id,
|
||||
'name' => $store->name,
|
||||
'type' => $store->type,
|
||||
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||
'is_open' => $store->is_open,
|
||||
]);
|
||||
|
||||
$agents = AiAgent::query()
|
||||
->where('status', '!=', 'paused')
|
||||
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y'])
|
||||
->map(fn (AiAgent $agent): array => [
|
||||
'id' => $agent->id,
|
||||
'name' => $agent->name,
|
||||
'type' => $agent->type,
|
||||
'status' => $agent->status,
|
||||
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||
'rotation_y' => (float) $agent->rotation_y,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'stores' => $stores,
|
||||
'agents' => $agents,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ConsentRequest;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\UserConsent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Legal document and consent endpoints.
|
||||
*
|
||||
* Exposes the current Terms of Use, Privacy Policy and Cookie Policy and
|
||||
* records auditable consent events required by the Brazilian LGPD.
|
||||
*/
|
||||
class LegalController extends Controller
|
||||
{
|
||||
/**
|
||||
* Return the current version of every legal document.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$locale = $request->query('locale', 'pt_BR');
|
||||
|
||||
$documents = LegalDocument::query()
|
||||
->where('is_current', true)
|
||||
->orderBy('type')
|
||||
->get()
|
||||
->map(fn (LegalDocument $doc): array => [
|
||||
'id' => $doc->id,
|
||||
'type' => $doc->type,
|
||||
'version' => $doc->version,
|
||||
'title' => $doc->title[$locale] ?? ($doc->title['pt_BR'] ?? $doc->type),
|
||||
'body' => $doc->body[$locale] ?? ($doc->body['pt_BR'] ?? ''),
|
||||
'published_at' => $doc->published_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json(['documents' => $documents]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the consent status of the authenticated user.
|
||||
*/
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$current = LegalDocument::query()->where('is_current', true)->get();
|
||||
$accepted = $user->consents()->get(['legal_document_id', 'accepted_at']);
|
||||
|
||||
$items = $current->map(fn (LegalDocument $doc): array => [
|
||||
'id' => $doc->id,
|
||||
'type' => $doc->type,
|
||||
'version' => $doc->version,
|
||||
'accepted' => $accepted->contains('legal_document_id', $doc->id),
|
||||
'accepted_at' => $accepted->firstWhere('legal_document_id', $doc->id)?->accepted_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'all_accepted' => $items->every(fn (array $item): bool => $item['accepted']),
|
||||
'documents' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the acceptance of all current legal documents.
|
||||
*
|
||||
* The event is stored with timestamp, client IP, user agent and the
|
||||
* exact document versions accepted, forming an immutable audit trail.
|
||||
*/
|
||||
public function accept(ConsentRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$documents = LegalDocument::query()->where('is_current', true)->get();
|
||||
|
||||
DB::transaction(function () use ($user, $documents, $request): void {
|
||||
foreach ($documents as $document) {
|
||||
UserConsent::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'legal_document_id' => $document->id,
|
||||
],
|
||||
[
|
||||
'accepted_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => (string) $request->userAgent(),
|
||||
'source' => 'web_form',
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => __('legal.accepted'),
|
||||
'accepted_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full consent audit history of the authenticated user.
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$history = $request->user()
|
||||
->consents()
|
||||
->with('document:id,type,version')
|
||||
->orderByDesc('accepted_at')
|
||||
->get()
|
||||
->map(fn (UserConsent $consent): array => [
|
||||
'document_type' => $consent->document?->type,
|
||||
'document_version' => $consent->document?->version,
|
||||
'accepted_at' => $consent->accepted_at?->toIso8601String(),
|
||||
'ip_address' => $consent->ip_address,
|
||||
'source' => $consent->source,
|
||||
]);
|
||||
|
||||
return response()->json(['history' => $history]);
|
||||
}
|
||||
}
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreRequest as StoreFormRequest;
|
||||
use App\Models\Store;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Store management endpoints.
|
||||
*
|
||||
* Regular users may list stores and manage the ones they own; admins may
|
||||
* manage the entire registry. Coordinates follow the 3D city grid.
|
||||
*/
|
||||
class StoreController extends Controller
|
||||
{
|
||||
/**
|
||||
* Paginated listing of stores with geographic filtering.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Store::query()->with(['owner:id,name', 'agent:id,name,status'])->orderBy('name');
|
||||
|
||||
if ($search = $request->query('search')) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($request->boolean('only_open')) {
|
||||
$query->where('is_open', true);
|
||||
}
|
||||
|
||||
return response()->json($query->paginate((int) $request->query('per_page', 15)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a compact snapshot of all stores for the 3D renderer.
|
||||
*/
|
||||
public function world(): JsonResponse
|
||||
{
|
||||
$stores = Store::query()
|
||||
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||
->map(fn (Store $store): array => [
|
||||
'id' => $store->id,
|
||||
'name' => $store->name,
|
||||
'type' => $store->type,
|
||||
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||
'is_open' => $store->is_open,
|
||||
]);
|
||||
|
||||
return response()->json(['stores' => $stores]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new store.
|
||||
*/
|
||||
public function store(StoreFormRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
// Regular users always own the stores they create.
|
||||
if (! $request->user()->isAdmin()) {
|
||||
$data['owner_id'] = $request->user()->id;
|
||||
}
|
||||
|
||||
$store = Store::create($data);
|
||||
|
||||
return response()->json($store->load(['owner:id,name', 'agent:id,name']), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single store.
|
||||
*/
|
||||
public function show(Store $store): JsonResponse
|
||||
{
|
||||
return response()->json($store->load(['owner:id,name,email', 'agent:id,name,type,status']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a store (owner or admin only).
|
||||
*/
|
||||
public function update(StoreFormRequest $request, Store $store): JsonResponse
|
||||
{
|
||||
if (! $request->user()->isAdmin() && $store->owner_id !== $request->user()->id) {
|
||||
return response()->json(['message' => __('auth.forbidden')], 403);
|
||||
}
|
||||
|
||||
$store->update($request->validated());
|
||||
|
||||
return response()->json($store->fresh()->load(['owner:id,name', 'agent:id,name']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a store (owner or admin only).
|
||||
*/
|
||||
public function destroy(Request $request, Store $store): JsonResponse
|
||||
{
|
||||
if (! $request->user()->isAdmin() && $store->owner_id !== $request->user()->id) {
|
||||
return response()->json(['message' => __('auth.forbidden')], 403);
|
||||
}
|
||||
|
||||
$store->delete();
|
||||
|
||||
return response()->json(['message' => __('stores.deleted')]);
|
||||
}
|
||||
}
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* User management endpoints (admin only).
|
||||
*
|
||||
* Lists, creates, updates and deactivates accounts of the platform while
|
||||
* exposing never the password hash or other sensitive material.
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Paginated listing of user accounts with optional search.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = User::query()->orderBy('name');
|
||||
|
||||
if ($search = $request->query('search')) {
|
||||
$query->where(function ($q) use ($search): void {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($role = $request->query('role')) {
|
||||
$query->where('role', $role);
|
||||
}
|
||||
|
||||
return response()->json($query->paginate((int) $request->query('per_page', 15)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new user account (admin provisioning).
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
'role' => ['required', Rule::in([User::ROLE_ADMIN, User::ROLE_USER])],
|
||||
'locale' => ['nullable', Rule::in(['pt_BR', 'en', 'es'])],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$user = User::create($data);
|
||||
|
||||
return response()->json($user, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single user account.
|
||||
*/
|
||||
public function show(User $user): JsonResponse
|
||||
{
|
||||
return response()->json($user->load('stores:id,name,owner_id,is_open'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing user account.
|
||||
*/
|
||||
public function update(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:120'],
|
||||
'email' => ['sometimes', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'password' => ['sometimes', 'string', 'min:8'],
|
||||
'role' => ['sometimes', Rule::in([User::ROLE_ADMIN, User::ROLE_USER])],
|
||||
'locale' => ['sometimes', Rule::in(['pt_BR', 'en', 'es'])],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$user->fill($data);
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$user->password = $data['password'];
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-disable an account (keeps audit data intact).
|
||||
*
|
||||
* Prevents administrators from locking themselves out of the platform.
|
||||
*/
|
||||
public function destroy(Request $request, User $user): JsonResponse
|
||||
{
|
||||
if ($user->id === $request->user()?->id) {
|
||||
return response()->json(['message' => __('users.cannot_deactivate_self')], 422);
|
||||
}
|
||||
|
||||
$user->update(['is_active' => false]);
|
||||
|
||||
return response()->json(['message' => __('users.deactivated')]);
|
||||
}
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreRequest;
|
||||
use App\Models\AiAgent;
|
||||
use App\Models\Store;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Administrative store management (admin only).
|
||||
*
|
||||
* Provides the server-rendered CRUD screens where administrators create
|
||||
* and edit stores, defining their exact 3D placement in the virtual city
|
||||
* through a Three.js position picker.
|
||||
*/
|
||||
class StoreAdminController extends Controller
|
||||
{
|
||||
/**
|
||||
* Paginated listing of every store with search and type filtering.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Store::query()->with(['owner:id,name', 'agent:id,name'])->orderBy('name');
|
||||
|
||||
if ($search = $request->query('search')) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
$types = Store::query()->select('type')->distinct()->orderBy('type')->pluck('type');
|
||||
|
||||
return view('admin.stores.index', [
|
||||
'stores' => $query->paginate(12)->withQueryString(),
|
||||
'types' => $types,
|
||||
'currentSearch' => $search,
|
||||
'currentType' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the store creation form with the 3D position picker.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.stores.form', [
|
||||
'store' => new Store(['is_open' => true, 'pos_x' => 0, 'pos_y' => 0, 'pos_z' => 0, 'width' => 8, 'height' => 6, 'depth' => 8]),
|
||||
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
|
||||
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
|
||||
'isEdit' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a newly created store.
|
||||
*/
|
||||
public function store(StoreRequest $request): RedirectResponse
|
||||
{
|
||||
$store = Store::create($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('admin.stores.edit', $store)
|
||||
->with('status', __('admin.stores.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the store edit form pre-filled with the current 3D placement.
|
||||
*/
|
||||
public function edit(Store $store): View
|
||||
{
|
||||
return view('admin.stores.form', [
|
||||
'store' => $store,
|
||||
'owners' => User::query()->orderBy('name')->get(['id', 'name', 'email']),
|
||||
'agents' => AiAgent::query()->orderBy('name')->get(['id', 'name', 'type']),
|
||||
'isEdit' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing store.
|
||||
*/
|
||||
public function update(StoreRequest $request, Store $store): RedirectResponse
|
||||
{
|
||||
$store->update($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('admin.stores.index')
|
||||
->with('status', __('admin.stores.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a store from the city registry.
|
||||
*/
|
||||
public function destroy(Store $store): RedirectResponse
|
||||
{
|
||||
$store->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.stores.index')
|
||||
->with('status', __('stores.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use App\Http\Requests\RegisterRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Web authentication controllers (session based).
|
||||
*
|
||||
* Complements the token-based API with a traditional server-rendered
|
||||
* login and registration flow including a math CAPTCHA.
|
||||
*/
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the login form.
|
||||
*/
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('auth.login', [
|
||||
'captcha' => self::makeCaptcha(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the login form submission.
|
||||
*/
|
||||
public function login(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->only('email', 'password');
|
||||
$remember = $request->boolean('remember');
|
||||
|
||||
if (! Auth::attempt($credentials, $remember)) {
|
||||
return back()->withErrors([
|
||||
'email' => __('auth.failed'),
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user->is_active) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => __('auth.disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill(['last_login_at' => now()])->save();
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Seed the session locale from the user's stored preference so the
|
||||
// SetLocale middleware renders the UI in their chosen language.
|
||||
if (in_array($user->locale, ['pt_BR', 'en', 'es'], true)) {
|
||||
$request->session()->put('locale', $user->locale);
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the registration form.
|
||||
*/
|
||||
public function showRegister(): View
|
||||
{
|
||||
return view('auth.register', [
|
||||
'captcha' => self::makeCaptcha(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the registration form submission.
|
||||
*/
|
||||
public function register(RegisterRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'role' => User::ROLE_USER,
|
||||
'locale' => $data['locale'] ?? 'pt_BR',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('terms.accept');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out of the web session.
|
||||
*/
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh math CAPTCHA challenge.
|
||||
*
|
||||
* @return array{a: int, b: int, question: string}
|
||||
*/
|
||||
private static function makeCaptcha(): array
|
||||
{
|
||||
$a = random_int(1, 9);
|
||||
$b = random_int(1, 9);
|
||||
|
||||
session()->put('captcha_result', $a + $b);
|
||||
session()->put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
|
||||
|
||||
return [
|
||||
'a' => $a,
|
||||
'b' => $b,
|
||||
'question' => sprintf('%d + %d = ?', $a, $b),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiAgent;
|
||||
use App\Models\Store;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\StreamedResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* NPC chat endpoints consumed by the 3D city viewer.
|
||||
*
|
||||
* The POST endpoint accepts a user message, enqueues the OpenRouter call
|
||||
* asynchronously and immediately returns 202 so the UI can show a
|
||||
* "thinking" state. The GET endpoint streams agent replies as SSE events.
|
||||
* Only simulated city context (never personal data) is sent upstream,
|
||||
* as required by RULES.md.
|
||||
*/
|
||||
class ChatController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a message to an agent and trigger an asynchronous reply.
|
||||
*/
|
||||
public function send(Request $request, AiAgent $agent): Response
|
||||
{
|
||||
$data = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
// Persist the conversation in the agent's last_decision for audit
|
||||
// and stream the pending state to any connected SSE listener.
|
||||
$history = $agent->last_decision ?? [];
|
||||
$history[] = ['role' => 'user', 'content' => $data['message'], 'at' => now()->toIso8601String()];
|
||||
$agent->update([
|
||||
'last_decision' => array_slice($history, -20),
|
||||
'status' => 'thinking',
|
||||
]);
|
||||
|
||||
self::publish($agent, ['type' => 'thinking', 'agent_id' => $agent->id, 'status' => 'thinking']);
|
||||
|
||||
// Fire the OpenRouter call without blocking the HTTP response.
|
||||
dispatch(function () use ($agent, $data): void {
|
||||
self::think($agent, $data['message']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => 'accepted',
|
||||
'agent_id' => $agent->id,
|
||||
'message' => __('city.chat_accepted'),
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream agent status and replies as Server-Sent Events.
|
||||
*/
|
||||
public function stream(Request $request, AiAgent $agent): StreamedResponse
|
||||
{
|
||||
$request->session()->save();
|
||||
|
||||
return response()->stream(function () use ($agent): void {
|
||||
echo "retry: 3000\n\n";
|
||||
flush();
|
||||
|
||||
$last = null;
|
||||
|
||||
while (true) {
|
||||
if (connection_aborted()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Poll the cache for a message published by think().
|
||||
$payload = cache()->pull("chat.{$agent->id}");
|
||||
|
||||
if ($payload !== null) {
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
echo "event: message\n";
|
||||
echo "data: {$json}\n\n";
|
||||
flush();
|
||||
$last = $payload;
|
||||
}
|
||||
|
||||
// Heartbeat keeps proxies from closing the stream.
|
||||
echo ": ping\n\n";
|
||||
flush();
|
||||
|
||||
sleep(2);
|
||||
}
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/event-stream',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenRouter with the agent persona and simulated city context.
|
||||
*
|
||||
* Never includes personal data — only city state (RULES.md 2.2).
|
||||
*/
|
||||
private static function think(AiAgent $agent, string $userMessage): void
|
||||
{
|
||||
try {
|
||||
$apiKey = config('ai.openrouter.api_key');
|
||||
|
||||
if (! filled($apiKey)) {
|
||||
self::fail($agent, 'OPENROUTER_API_KEY not configured');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulated city context only — no PII (RULES.md 2.2).
|
||||
$cityContext = [
|
||||
'stores' => Store::query()
|
||||
->get(['name', 'type', 'is_open'])
|
||||
->map(fn ($s) => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
|
||||
->all(),
|
||||
'agent_name' => $agent->name,
|
||||
'agent_type' => $agent->type,
|
||||
'current_goal' => $agent->current_goal,
|
||||
];
|
||||
|
||||
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
|
||||
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
|
||||
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
'HTTP-Referer' => config('ai.openrouter.app_url'),
|
||||
'X-Title' => config('ai.openrouter.app_name'),
|
||||
])->timeout((int) config('ai.openrouter.timeout', 30))
|
||||
->post(config('ai.openrouter.base_url').'/chat/completions', [
|
||||
'model' => $agent->model,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $userMessage],
|
||||
],
|
||||
'max_tokens' => 300,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
self::fail($agent, 'HTTP '.$response->status());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$reply = $response->json('choices.0.message.content') ?? __('city.chat_error');
|
||||
|
||||
$history = $agent->last_decision ?? [];
|
||||
$history[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
|
||||
|
||||
$agent->update([
|
||||
'last_decision' => array_slice($history, -20),
|
||||
'status' => 'active',
|
||||
'error_count' => 0,
|
||||
'last_think_at' => now(),
|
||||
]);
|
||||
|
||||
self::publish($agent, [
|
||||
'type' => 'reply',
|
||||
'agent_id' => $agent->id,
|
||||
'name' => $agent->name,
|
||||
'reply' => $reply,
|
||||
'status' => 'active',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NPC think failed', ['agent' => $agent->id, 'error' => $e->getMessage()]);
|
||||
self::fail($agent, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failure with exponential backoff (RULES.md 2.4).
|
||||
*/
|
||||
private static function fail(AiAgent $agent, string $reason): void
|
||||
{
|
||||
$errors = (int) $agent->error_count + 1;
|
||||
$backoff = min(300, 2 ** min($errors, 8));
|
||||
|
||||
$agent->update([
|
||||
'status' => $errors >= 5 ? 'error' : 'active',
|
||||
'error_count' => $errors,
|
||||
]);
|
||||
|
||||
self::publish($agent, [
|
||||
'type' => 'error',
|
||||
'agent_id' => $agent->id,
|
||||
'reply' => __('city.chat_error'),
|
||||
'status' => $agent->status,
|
||||
'retry_after' => $backoff,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a message for the SSE stream to pick up.
|
||||
*/
|
||||
private static function publish(AiAgent $agent, array $payload): void
|
||||
{
|
||||
cache()->put("chat.{$agent->id}", $payload, now()->addMinutes(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiAgent;
|
||||
use App\Models\Store;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 3D city viewer.
|
||||
*
|
||||
* Serves the Three.js scene page plus a lightweight JSON payload of stores
|
||||
* and agents. The JSON endpoint reuses the web session so the browser can
|
||||
* fetch world data without a separate API token.
|
||||
*/
|
||||
class CityViewerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Render the city viewer page.
|
||||
*/
|
||||
public function __invoke(): View
|
||||
{
|
||||
return view('city.viewer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the world snapshot consumed by the viewer scene.
|
||||
*/
|
||||
public function data(): JsonResponse
|
||||
{
|
||||
$stores = Store::query()
|
||||
->get(['id', 'name', 'type', 'pos_x', 'pos_y', 'pos_z', 'width', 'height', 'depth', 'is_open'])
|
||||
->map(fn (Store $store): array => [
|
||||
'id' => $store->id,
|
||||
'name' => $store->name,
|
||||
'type' => $store->type,
|
||||
'position' => [(float) $store->pos_x, (float) $store->pos_y, (float) $store->pos_z],
|
||||
'size' => [(float) $store->width, (float) $store->height, (float) $store->depth],
|
||||
'is_open' => $store->is_open,
|
||||
]);
|
||||
|
||||
$agents = AiAgent::query()
|
||||
->where('status', '!=', 'paused')
|
||||
->get(['id', 'name', 'type', 'status', 'pos_x', 'pos_y', 'pos_z', 'rotation_y', 'last_decision'])
|
||||
->map(fn (AiAgent $agent): array => [
|
||||
'id' => $agent->id,
|
||||
'name' => $agent->name,
|
||||
'type' => $agent->type,
|
||||
'status' => $agent->status,
|
||||
'position' => [(float) $agent->pos_x, (float) $agent->pos_y, (float) $agent->pos_z],
|
||||
'rotation_y' => (float) $agent->rotation_y,
|
||||
'last_decision' => $agent->last_decision,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'stores' => $stores,
|
||||
'agents' => $agents,
|
||||
'layout' => self::cityLayout($stores->all()),
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic street layout from store positions.
|
||||
*
|
||||
* Returns road segments and block rectangles so the frontend can build
|
||||
* a consistent grid-based city without additional database tables.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $stores
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function cityLayout(array $stores): array
|
||||
{
|
||||
if ($stores === []) {
|
||||
return ['roads' => [], 'blocks' => []];
|
||||
}
|
||||
|
||||
$xs = array_map(fn ($s) => $s['position'][0], $stores);
|
||||
$zs = array_map(fn ($s) => $s['position'][2], $stores);
|
||||
|
||||
$minX = floor(min($xs) / 40) * 40 - 40;
|
||||
$maxX = ceil(max($xs) / 40) * 40 + 40;
|
||||
$minZ = floor(min($zs) / 40) * 40 - 40;
|
||||
$maxZ = ceil(max($zs) / 40) * 40 + 40;
|
||||
|
||||
$roads = [];
|
||||
|
||||
// Vertical roads every 40 units.
|
||||
for ($x = $minX; $x <= $maxX; $x += 40) {
|
||||
$roads[] = [
|
||||
'from' => [(float) $x, 0.0, (float) $minZ],
|
||||
'to' => [(float) $x, 0.0, (float) $maxZ],
|
||||
'width' => 8.0,
|
||||
'axis' => 'z',
|
||||
];
|
||||
}
|
||||
|
||||
// Horizontal roads every 40 units.
|
||||
for ($z = $minZ; $z <= $maxZ; $z += 40) {
|
||||
$roads[] = [
|
||||
'from' => [(float) $minX, 0.0, (float) $z],
|
||||
'to' => [(float) $maxX, 0.0, (float) $z],
|
||||
'width' => 8.0,
|
||||
'axis' => 'x',
|
||||
];
|
||||
}
|
||||
|
||||
// Blocks = cells between roads.
|
||||
$blocks = [];
|
||||
for ($x = $minX; $x < $maxX; $x += 40) {
|
||||
for ($z = $minZ; $z < $maxZ; $z += 40) {
|
||||
$blocks[] = [
|
||||
'min' => [(float) $x + 5.0, 0.0, (float) $z + 5.0],
|
||||
'max' => [(float) $x + 35.0, 0.0, (float) $z + 35.0],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['roads' => $roads, 'blocks' => $blocks];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Dashboard controller for the authenticated web area.
|
||||
*
|
||||
* Phase 1 renders an overview panel; the full admin console and 3D city
|
||||
* viewer are delivered in subsequent phases.
|
||||
*/
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user dashboard overview.
|
||||
*/
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return view('dashboard.index', [
|
||||
'user' => $user,
|
||||
'isAdmin' => $user->isAdmin(),
|
||||
'storeCount' => $user->stores()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
/**
|
||||
* Language switcher controller.
|
||||
*
|
||||
* Persists the selected locale (pt_BR, en, es) in the session and in the
|
||||
* user's profile when authenticated, so the whole UI is rendered in the
|
||||
* language chosen from the dashboard or the public pages.
|
||||
*/
|
||||
class LanguageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Switch the application locale for the current visitor.
|
||||
*/
|
||||
public function switch(Request $request): RedirectResponse
|
||||
{
|
||||
$locale = $request->input('locale');
|
||||
|
||||
if (! in_array($locale, ['pt_BR', 'en', 'es'], true)) {
|
||||
return back();
|
||||
}
|
||||
|
||||
App::setLocale($locale);
|
||||
$request->session()->put('locale', $locale);
|
||||
|
||||
if ($user = $request->user()) {
|
||||
$user->update(['locale' => $locale]);
|
||||
}
|
||||
|
||||
// Persist the cookie preference (1 year) used on subsequent visits.
|
||||
$response = back();
|
||||
$response->withCookie(cookie('city10-locale', $locale, 60 * 24 * 365, '/', null, false, true));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ConsentRequest;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\UserConsent;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Web controllers for legal documents and consent management.
|
||||
*
|
||||
* Renders the Terms of Use, Privacy Policy and Cookie Policy and records
|
||||
* the auditable acceptance required by the Brazilian LGPD before the
|
||||
* user can access the main platform features.
|
||||
*/
|
||||
class LegalController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the current version of a legal document.
|
||||
*/
|
||||
public function show(string $type): View
|
||||
{
|
||||
$document = LegalDocument::query()
|
||||
->where('type', $type)
|
||||
->where('is_current', true)
|
||||
->firstOrFail();
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return view('legal.show', [
|
||||
'document' => $document,
|
||||
'title' => $document->title[$locale] ?? ($document->title['pt_BR'] ?? $document->type),
|
||||
'body' => $document->body[$locale] ?? ($document->body['pt_BR'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the first-access terms acceptance screen.
|
||||
*/
|
||||
public function acceptForm(Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$current = LegalDocument::query()->where('is_current', true)->orderBy('type')->get();
|
||||
|
||||
if ($current->isEmpty()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$acceptedIds = $user->consents()->pluck('legal_document_id');
|
||||
$pending = $current->reject(fn (LegalDocument $doc) => $acceptedIds->contains($doc->id));
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$documents = $pending->map(fn (LegalDocument $doc): array => [
|
||||
'type' => $doc->type,
|
||||
'version' => $doc->version,
|
||||
'title' => $doc->title[$locale] ?? ($doc->title['pt_BR'] ?? $doc->type),
|
||||
'body' => $doc->body[$locale] ?? ($doc->body['pt_BR'] ?? ''),
|
||||
])->values();
|
||||
|
||||
return view('legal.accept', [
|
||||
'documents' => $documents,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the acceptance of all current legal documents.
|
||||
*/
|
||||
public function accept(ConsentRequest $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$documents = LegalDocument::query()->where('is_current', true)->get();
|
||||
|
||||
DB::transaction(function () use ($user, $documents, $request): void {
|
||||
foreach ($documents as $document) {
|
||||
UserConsent::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'legal_document_id' => $document->id,
|
||||
],
|
||||
[
|
||||
'accepted_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => (string) $request->userAgent(),
|
||||
'source' => 'web_form',
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('dashboard')->with('status', __('legal.accepted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the consent audit history of the authenticated user.
|
||||
*/
|
||||
public function history(Request $request): View
|
||||
{
|
||||
$history = $request->user()
|
||||
->consents()
|
||||
->with('document:id,type,version')
|
||||
->orderByDesc('accepted_at')
|
||||
->get();
|
||||
|
||||
return view('legal.history', [
|
||||
'history' => $history,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\LegalDocument;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Blocks access until the user accepted all current legal documents.
|
||||
*
|
||||
* Implements the LGPD requirement that the Terms of Use, Privacy Policy
|
||||
* and Cookie Policy must be accepted on first access before the main
|
||||
* platform features can be used.
|
||||
*/
|
||||
class EnsureTermsAccepted
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$currentIds = LegalDocument::query()->where('is_current', true)->pluck('id');
|
||||
|
||||
if ($currentIds->isEmpty()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$acceptedIds = $user->consents()->pluck('legal_document_id');
|
||||
$pending = $currentIds->diff($acceptedIds);
|
||||
|
||||
if ($pending->isNotEmpty()) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => __('legal.accept_required'),
|
||||
'pending' => $pending->values(),
|
||||
], 428);
|
||||
}
|
||||
|
||||
return redirect()->route('terms.accept');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Ensures the authenticated account is still active.
|
||||
*
|
||||
* Blocks disabled (deactivated) accounts from accessing any protected
|
||||
* route, even when a previously issued token or session remains valid.
|
||||
*/
|
||||
class EnsureUserIsActive
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user !== null && ! $user->is_active) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => __('auth.disabled'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
auth()->guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => __('auth.disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Restricts access to platform administrators.
|
||||
*
|
||||
* Returns 403 for non-admin users, protecting the admin-only endpoints
|
||||
* (user management, agent registry, store registry administration).
|
||||
*/
|
||||
class EnsureUserIsAdmin
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null || ! $user->isAdmin()) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => __('auth.forbidden')], 403);
|
||||
}
|
||||
|
||||
abort(403, __('auth.forbidden'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Applies the visitor's preferred locale on each request.
|
||||
*
|
||||
* Reads the locale from the session (set at login or when the user switches
|
||||
* language) or from the city10-locale cookie, falling back to the default.
|
||||
* This keeps the whole interface in the language chosen by the user.
|
||||
*/
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Supported UI locales.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SUPPORTED = ['pt_BR', 'en', 'es'];
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = ($request->hasSession() ? $request->session()->get('locale') : null)
|
||||
?: $request->cookie('city10-locale')
|
||||
?: config('app.locale');
|
||||
|
||||
if (in_array($locale, self::SUPPORTED, true)) {
|
||||
App::setLocale($locale);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Validates creation and update payloads for AI agents.
|
||||
*
|
||||
* Enforces the allow-list of free OpenRouter models defined in
|
||||
* config/ai.php so that no paid model can ever be activated.
|
||||
*/
|
||||
class AiAgentRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
// Authorization is enforced by admin role middleware in routes.
|
||||
return $this->user()?->isAdmin() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$allowed = config('ai.allowed_models', []);
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'type' => ['required', 'string', 'max:40'],
|
||||
'model' => ['required', 'string', Rule::in($allowed)],
|
||||
'system_prompt' => ['nullable', 'string', 'max:8000'],
|
||||
'current_goal' => ['nullable', 'string', 'max:2000'],
|
||||
'status' => ['nullable', Rule::in(['active', 'paused', 'thinking', 'error'])],
|
||||
'pos_x' => ['nullable', 'numeric', 'between:-100000,100000'],
|
||||
'pos_y' => ['nullable', 'numeric', 'between:-100000,100000'],
|
||||
'pos_z' => ['nullable', 'numeric', 'between:-100000,100000'],
|
||||
'rotation_y' => ['nullable', 'numeric', 'between:-6.2832,6.2832'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validation errors.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'model.in' => __('validation.model_not_allowed'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Validates the submission of legal document acceptances (LGPD consent).
|
||||
*/
|
||||
class ConsentRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'accepted_terms' => ['accepted'],
|
||||
'accepted_privacy' => ['accepted'],
|
||||
'accepted_cookies' => ['accepted'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validation errors.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'accepted_terms.accepted' => __('legal.must_accept_terms'),
|
||||
'accepted_privacy.accepted' => __('legal.must_accept_privacy'),
|
||||
'accepted_cookies.accepted' => __('legal.must_accept_cookies'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\Traits\ValidatesMathCaptcha;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Validates credentials submitted through the login form.
|
||||
*/
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
use ValidatesMathCaptcha;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'password' => ['required', 'string'],
|
||||
'remember' => ['nullable', 'boolean'],
|
||||
'captcha' => ['required', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validation errors.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'captcha.required' => __('validation.captcha_required'),
|
||||
'captcha.integer' => __('validation.captcha_required'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*
|
||||
* @param Validator $validator
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
if (! $this->validateMathCaptcha()) {
|
||||
$validator->errors()->add('captcha', __('validation.captcha_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\Traits\ValidatesMathCaptcha;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Validates new account registrations submitted through the public form.
|
||||
*/
|
||||
class RegisterRequest extends FormRequest
|
||||
{
|
||||
use ValidatesMathCaptcha;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'confirmed', Password::min(8)],
|
||||
'locale' => ['nullable', Rule::in(['pt_BR', 'en', 'es'])],
|
||||
'captcha' => ['required', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*
|
||||
* @param Validator $validator
|
||||
*/
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator): void {
|
||||
if (! $this->validateMathCaptcha()) {
|
||||
$validator->errors()->add('captcha', __('validation.captcha_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Validates creation and update payloads for city stores.
|
||||
*/
|
||||
class StoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
// Authorization is enforced by role middleware in the route file.
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$storeId = $this->route('store')?->id ?? $this->route('store');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:160'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'type' => ['required', 'string', 'max:60'],
|
||||
'owner_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'agent_id' => ['nullable', 'integer', Rule::exists('ai_agents', 'id')],
|
||||
'pos_x' => ['required', 'numeric', 'between:-100000,100000'],
|
||||
'pos_y' => ['required', 'numeric', 'between:-100000,100000'],
|
||||
'pos_z' => ['required', 'numeric', 'between:-100000,100000'],
|
||||
'width' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
|
||||
'height' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
|
||||
'depth' => ['nullable', 'numeric', 'min:0.1', 'max:1000'],
|
||||
'is_open' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
/**
|
||||
* Simple arithmetic CAPTCHA implemented with pure Laravel primitives.
|
||||
*
|
||||
* Generates a sum of two random integers, stores the expected result in
|
||||
* the session and validates the user answer. It introduces no external
|
||||
* dependency and is therefore safe from third-party licensing concerns.
|
||||
*/
|
||||
trait ValidatesMathCaptcha
|
||||
{
|
||||
/**
|
||||
* Generate a new math challenge and persist it in the session.
|
||||
*
|
||||
* @return array{a: int, b: int, question: string}
|
||||
*/
|
||||
protected static function generateMathCaptcha(): array
|
||||
{
|
||||
$a = random_int(1, 9);
|
||||
$b = random_int(1, 9);
|
||||
|
||||
Session::put('captcha_result', $a + $b);
|
||||
// Sliding expiry: challenge is valid for 10 minutes.
|
||||
Session::put('captcha_expires_at', now()->addMinutes(10)->getTimestamp());
|
||||
|
||||
return [
|
||||
'a' => $a,
|
||||
'b' => $b,
|
||||
'question' => sprintf('%d + %d = ?', $a, $b),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the submitted CAPTCHA answer against the session value.
|
||||
*/
|
||||
protected function validateMathCaptcha(): bool
|
||||
{
|
||||
$expected = Session::get('captcha_result');
|
||||
$expiresAt = Session::get('captcha_expires_at');
|
||||
|
||||
// Invalidate the challenge after it is consumed.
|
||||
Session::forget(['captcha_result', 'captcha_expires_at']);
|
||||
|
||||
if ($expected === null || $expiresAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now()->getTimestamp() > (int) $expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $this->input('captcha') === (int) $expected;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user