base structure

This commit is contained in:
Alex Rodriguez 2026-07-15 12:22:31 +02:00
parent 50fe5ef848
commit b93c739e35
31 changed files with 735 additions and 0 deletions

8
.env Normal file
View file

@ -0,0 +1,8 @@
# Variables para los servicios PostgreSQL y Redis en Docker Compose
# El backend lee sus propias variables desde backend/.env
POSTGRES_DB=wearlab_dev
POSTGRES_USER=app
POSTGRES_PASSWORD=password
REDIS_PASSWORD=

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# ── Secrets ───────────────────────────────────────────────────────────────────
.env.local
.env.*.local
*.pem
*.key
# ── PHP / Symfony ─────────────────────────────────────────────────────────────
backend/vendor/
backend/var/cache/
backend/var/log/
backend/var/sessions/
backend/.php-cs-fixer.cache
backend/.phpunit.cache/
# ── Node / Frontend ───────────────────────────────────────────────────────────
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/coverage/
# ── OS & editor ───────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*~

8
backend/.env Normal file
View file

@ -0,0 +1,8 @@
APP_ENV=dev
APP_DEBUG=1
APP_SECRET=changeme-generate-a-32-char-random-string
DATABASE_URL="postgresql://app:password@postgres:5432/wearlab_dev?serverVersion=16&charset=utf8"
REDIS_URL="redis://redis:6379"
CORS_ALLOW_ORIGIN=http://localhost:8080

64
backend/Dockerfile Normal file
View file

@ -0,0 +1,64 @@
# ── Stage: base ──────────────────────────────────────────────────────────────
FROM php:8.4-fpm-alpine AS base
RUN apk add --no-cache \
libpq-dev \
icu-dev \
libzip-dev \
zip \
unzip \
git \
curl \
autoconf \
g++ \
make
RUN docker-php-ext-install \
pdo_pgsql \
intl \
opcache \
zip
RUN pecl channel-update pecl.php.net && pecl install apcu redis && docker-php-ext-enable apcu redis
RUN echo "clear_env = no" >> /usr/local/etc/php-fpm.d/www.conf
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# ── Stage: dev ───────────────────────────────────────────────────────────────
FROM base AS dev
RUN apk add --no-cache linux-headers
RUN pecl channel-update pecl.php.net && pecl install xdebug && docker-php-ext-enable xdebug
RUN printf "xdebug.mode=debug\nxdebug.start_with_request=yes\nxdebug.client_port=9003\nxdebug.log_level=0\n" \
> /usr/local/etc/php/conf.d/xdebug.ini
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 9000
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["php-fpm"]
# ── Stage: prod ──────────────────────────────────────────────────────────────
FROM base AS prod
RUN printf "opcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n" \
> /usr/local/etc/php/conf.d/opcache-prod.ini
COPY . .
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--no-scripts
RUN chmod +x bin/console
EXPOSE 9000
CMD ["php-fpm"]

16
backend/bin/console Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Dotenv\Dotenv;
require_once dirname(__DIR__).'/vendor/autoload.php';
if (class_exists(Dotenv::class)) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool) ($_SERVER['APP_DEBUG'] ?? true));
$application = new Application($kernel);
$application->run();

46
backend/composer.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "app/wearlab-backend",
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.13",
"doctrine/orm": "^3.3",
"nelmio/cors-bundle": "^2.5",
"symfony/console": "7.*",
"symfony/dotenv": "7.*",
"symfony/framework-bundle": "7.*",
"symfony/var-exporter": "7.*",
"symfony/yaml": "7.*"
},
"require-dev": {
"phpunit/phpunit": "^11",
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-symfony": "^2.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": true
},
"sort-packages": true,
"optimize-autoloader": true,
"preferred-install": {
"*": "dist"
}
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
}
}

View file

@ -0,0 +1,7 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
];

View file

@ -0,0 +1,7 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: false

View file

@ -0,0 +1,4 @@
framework:
secret: '%env(APP_SECRET)%'
router:
utf8: true

View file

@ -0,0 +1,9 @@
nelmio_cors:
defaults:
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
allow_headers: ['Content-Type', 'Authorization']
max_age: 3600
paths:
'^/api/':
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']

View file

@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View file

@ -0,0 +1,11 @@
services:
_defaults:
autowire: true
autoconfigure: true
public: false
App\:
resource: '../src/'
exclude:
- '../src/Kernel.php'
- '../src/Domain/Entity/'

View file

@ -0,0 +1,13 @@
#!/bin/sh
set -e
if [ ! -f /app/vendor/autoload.php ]; then
echo "→ Instalando dependencias de Composer..."
composer install --no-interaction --prefer-dist --no-scripts
echo "→ Dependencias instaladas."
fi
mkdir -p /app/var/cache /app/var/log /app/var/sessions
chmod -R 777 /app/var
exec "$@"

17
backend/public/index.php Normal file
View file

@ -0,0 +1,17 @@
<?php
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__).'/vendor/autoload.php';
if (class_exists(Dotenv::class)) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool) ($_SERVER['APP_DEBUG'] ?? true));
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class HealthController
{
#[Route('/api/health', name: 'api_health', methods: ['GET'])]
public function __invoke(Connection $connection): JsonResponse
{
try {
$connection->executeQuery('SELECT 1');
$dbStatus = 'ok';
} catch (\Throwable) {
$dbStatus = 'error';
}
try {
$redis = new \Redis();
$redisUrl = $_ENV['REDIS_URL'] ?? 'redis://redis:6379';
$parsed = parse_url($redisUrl);
$host = $parsed['host'] ?? 'redis';
$port = (int) ($parsed['port'] ?? 6379);
$redis->connect($host, $port, 1.0);
$redisStatus = $redis->ping() ? 'ok' : 'error';
$redis->close();
} catch (\Throwable) {
$redisStatus = 'error';
}
return new JsonResponse([
'status' => 'ok',
'database' => $dbStatus,
'redis' => $redisStatus,
'timestamp' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DateTimeInterface::ATOM),
]);
}
}

11
backend/src/Kernel.php Normal file
View file

@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
backend/var/.gitkeep Normal file
View file

98
docker-compose.yml Normal file
View file

@ -0,0 +1,98 @@
name: wearlab
services:
# ── Frontend: Vite dev server con Hot Module Replacement ─────────────────────
frontend:
build:
context: ./frontend
target: dev
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
ports:
- "5173:5173"
networks:
- app
# ── Backend: PHP 8.4-FPM (Symfony) ───────────────────────────────────────────
backend:
build:
context: ./backend
target: dev
volumes:
- ./backend:/app
- backend_vendor:/app/vendor
- backend_var:/app/var
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app
# ── Nginx: reverse proxy ──────────────────────────────────────────────────────
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx/nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- frontend
- backend
networks:
- app
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# ── PostgreSQL 16 ─────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
# ── Redis 7 ───────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
networks:
- app
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
networks:
app:
driver: bridge
volumes:
postgres_data:
redis_data:
frontend_node_modules:
backend_vendor:
backend_var:

View file

@ -0,0 +1,23 @@
server {
listen 80;
server_name localhost;
# /api/* → Symfony vía PHP-FPM
location /api/ {
include fastcgi_params;
fastcgi_pass backend:9000;
fastcgi_param SCRIPT_FILENAME /app/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param DOCUMENT_ROOT /app/public;
}
# Todo lo demás → Vite dev server (incluye WebSocket para HMR)
location / {
proxy_pass http://frontend:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

View file

@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
root /usr/share/nginx/html;
# /api/* → PHP-FPM
location /api/ {
fastcgi_pass backend:9000;
fastcgi_param SCRIPT_FILENAME /app/public/index.php;
fastcgi_param PATH_INFO $fastcgi_path_info;
include fastcgi_params;
}
# React SPA — todo cae en index.html
location / {
try_files $uri $uri/ /index.html;
location ~* \.(js|css|png|jpg|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

103
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,103 @@
# Arquitectura del proyecto
## Estructura del repositorio
```
wearlab/
├── frontend/ ← SPA React + Vite + TypeScript
│ ├── Dockerfile ← multi-stage: dev / build / prod
│ ├── public/
│ └── src/
│ ├── components/ ← componentes UI reutilizables
│ ├── features/ ← módulos por funcionalidad de negocio
│ ├── hooks/ ← hooks globales
│ ├── lib/ ← cliente HTTP, utilidades
│ ├── pages/ ← componentes de nivel de ruta
│ ├── router/ ← configuración de React Router
│ ├── store/ ← estado global UI
│ ├── styles/ ← CSS global
│ └── types/ ← tipos TypeScript compartidos
├── backend/ ← API REST Symfony 7 + PHP 8.4
│ ├── Dockerfile ← multi-stage: dev / prod
│ ├── bin/console
│ ├── config/
│ │ └── packages/
│ ├── migrations/
│ ├── public/index.php
│ └── src/
│ ├── Controller/
│ ├── Application/
│ ├── Domain/
│ ├── Infrastructure/
│ └── Shared/
├── docker/
│ └── nginx/
│ ├── nginx.dev.conf ← proxy dev: /api → php-fpm, /* → Vite HMR
│ └── nginx.prod.conf ← producción: HTTPS, SPA fallback
├── docs/
│ └── ARCHITECTURE.md
├── docker-compose.yml ← desarrollo local
├── .env
├── .gitignore
└── README.md
```
---
## Cómo fluye una petición HTTP
### En desarrollo
```
Browser
→ http://localhost:8080 (Nginx puerto 80)
├── /api/* → FastCGI → backend:9000 (PHP-FPM) → PostgreSQL / Redis
└── /* → proxy → frontend:5173 (Vite HMR con WebSocket)
```
### En producción
```
Browser
→ https://tudominio.com (Nginx puerto 443, TLS)
├── /api/* → FastCGI → backend:9000 (PHP-FPM) → PostgreSQL / Redis
└── /* → archivos estáticos compilados por Vite (/dist)
```
---
## Servicios Docker
| Servicio | Imagen / Build | Puerto host | Descripción |
|---|---|---|---|
| frontend | `./frontend` (target: dev) | 5173 | Vite dev server con HMR |
| backend | `./backend` (target: dev) | — | PHP-FPM Symfony |
| nginx | `nginx:1.27-alpine` | 8080 | Reverse proxy |
| postgres | `postgres:16-alpine` | 5432 | Base de datos |
| redis | `redis:7-alpine` | 6379 | Caché y sesiones |
---
## Secretos y variables de entorno
| Dónde | Qué contiene | ¿Se commitea? |
|---|---|---|
| `.env` (repo raíz) | PostgreSQL, Redis | Sí |
| `backend/.env` | Symfony, DATABASE_URL, REDIS_URL | Sí (valores dev) |
| `.env.local` | Override local del desarrollador | No (.gitignore) |
---
## Branches (recomendado)
```
main ── producción ── protegido ── requiere PR + CI verde
develop ── staging ── protegido ── requiere PR + CI verde
feature/* ── desde develop, merged via PR
fix/* ── desde develop, merged via PR
hotfix/* ── desde main, merged a main + develop
```

23
frontend/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# ── Stage: base ──────────────────────────────────────────────────────────────
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# ── Stage: dev ───────────────────────────────────────────────────────────────
FROM base AS dev
EXPOSE 5173
CMD ["sh", "-c", "npm install && npm run dev"]
# ── Stage: build ─────────────────────────────────────────────────────────────
FROM base AS build
COPY . .
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
# ── Stage: prod ──────────────────────────────────────────────────────────────
FROM nginx:1.27-alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WearLab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
frontend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "wearlab-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.0.5"
}
}

55
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,55 @@
import { useEffect, useState } from 'react'
interface HealthResponse {
status: string
database: string
redis: string
timestamp: string
}
function App() {
const [health, setHealth] = useState<HealthResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/health')
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<HealthResponse>
})
.then((data) => setHealth(data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false))
}, [])
return (
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 480, margin: '4rem auto', padding: '0 1rem' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.25rem' }}>WearLab</h1>
<p style={{ color: '#666', marginTop: 0 }}>React + Symfony + PostgreSQL + Redis</p>
<hr style={{ margin: '1.5rem 0' }} />
<h2 style={{ fontSize: '1rem', marginBottom: '0.75rem' }}>API Health Check</h2>
{loading && <p style={{ color: '#888' }}>Connecting to API</p>}
{error && (
<div style={{ background: '#fee2e2', border: '1px solid #fca5a5', borderRadius: 6, padding: '0.75rem 1rem' }}>
<strong>Error:</strong> {error}
</div>
)}
{health && (
<div style={{ background: '#f0fdf4', border: '1px solid #86efac', borderRadius: 6, padding: '0.75rem 1rem' }}>
<div>Status: <strong>{health.status}</strong></div>
<div>Database: <strong>{health.database}</strong></div>
<div>Redis: <strong>{health.redis}</strong></div>
<div style={{ color: '#555', fontSize: '0.85rem', marginTop: '0.5rem' }}>{health.timestamp}</div>
</div>
)}
</div>
)
}
export default App

9
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
})