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:
2026-10-17 20:46:15 -03:00
commit 79d12fa2f4
128 changed files with 17053 additions and 0 deletions
+112
View File
@@ -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')]);
}
}
+159
View File
@@ -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(),
]);
}
}
+52
View File
@@ -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,
]);
}
}
+123
View File
@@ -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]);
}
}
+112
View File
@@ -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')]);
}
}
+107
View File
@@ -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')]);
}
}