From bede2018a63e712fa5eb82aa92715cf7dc5a3940 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 24 Jun 2026 08:35:09 +0100 Subject: [PATCH] Initial commit: NutriApp - Rust + Axum + Dioxus - CI/CD con Gitea Runner --- .env.example | 14 ++ .gitignore | 20 +++ Cargo.toml | 17 +++ Dioxus.toml | 14 ++ Dockerfile | 29 ++++ backend/Cargo.toml | 58 ++++++++ backend/migrations/20260623000001_initial.sql | 96 +++++++++++++ backend/src/auth/middleware.rs | 2 + backend/src/auth/mod.rs | 4 + backend/src/jobs/mod.rs | 4 + backend/src/main.rs | 128 ++++++++++++++++++ backend/src/models/familia.rs | 11 ++ backend/src/models/ingrediente.rs | 11 ++ backend/src/models/mod.rs | 4 + backend/src/models/receta.rs | 18 +++ backend/src/models/usuario.rs | 12 ++ backend/src/routes/mod.rs | 15 ++ backend/src/ws/mod.rs | 15 ++ frontend/Cargo.toml | 26 ++++ frontend/index.html | 25 ++++ frontend/src/api/client.rs | 55 ++++++++ frontend/src/api/mod.rs | 2 + frontend/src/components/mod.rs | 3 + frontend/src/components/nav.rs | 1 + frontend/src/components/receta_card.rs | 22 +++ frontend/src/components/slot_calendario.rs | 1 + frontend/src/main.rs | 34 +++++ frontend/src/pages/calendario.rs | 3 + frontend/src/pages/inventario.rs | 2 + frontend/src/pages/lista_compras.rs | 1 + frontend/src/pages/mod.rs | 4 + frontend/src/pages/recetas.rs | 2 + k8s/deployment.yml | 71 ++++++++++ shared/Cargo.toml | 9 ++ shared/src/lib.rs | 96 +++++++++++++ 35 files changed, 829 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 Dioxus.toml create mode 100644 Dockerfile create mode 100644 backend/Cargo.toml create mode 100644 backend/migrations/20260623000001_initial.sql create mode 100644 backend/src/auth/middleware.rs create mode 100644 backend/src/auth/mod.rs create mode 100644 backend/src/jobs/mod.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/models/familia.rs create mode 100644 backend/src/models/ingrediente.rs create mode 100644 backend/src/models/mod.rs create mode 100644 backend/src/models/receta.rs create mode 100644 backend/src/models/usuario.rs create mode 100644 backend/src/routes/mod.rs create mode 100644 backend/src/ws/mod.rs create mode 100644 frontend/Cargo.toml create mode 100644 frontend/index.html create mode 100644 frontend/src/api/client.rs create mode 100644 frontend/src/api/mod.rs create mode 100644 frontend/src/components/mod.rs create mode 100644 frontend/src/components/nav.rs create mode 100644 frontend/src/components/receta_card.rs create mode 100644 frontend/src/components/slot_calendario.rs create mode 100644 frontend/src/main.rs create mode 100644 frontend/src/pages/calendario.rs create mode 100644 frontend/src/pages/inventario.rs create mode 100644 frontend/src/pages/lista_compras.rs create mode 100644 frontend/src/pages/mod.rs create mode 100644 frontend/src/pages/recetas.rs create mode 100644 k8s/deployment.yml create mode 100644 shared/Cargo.toml create mode 100644 shared/src/lib.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b66695a --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# ─── Base de datos ────────────────────────────────────────── +# PostgreSQL en la Raspberry Pi 5 del homelab +DATABASE_URL=postgres://joel:***@192.168.1.87:5432/nutriapp + +# ─── Servidor ─────────────────────────────────────────────── +HOST=0.0.0.0 +PORT=3100 + +# ─── Logging ──────────────────────────────────────────────── +RUST_LOG=nutriapp_backend=debug,tower_http=debug + +# ─── Auth ─────────────────────────────────────────────────── +# Clave para firmar cookies de sesión (generar con: openssl rand -base64 64) +SESSION_KEY=cambiar-por-clave-real-de-64-bytes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdf4cf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Rust +target/ +**/*.rs.bk +*.pdb + +# Dioxus +frontend/dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Secrets +.env + +# OS +.DS_Store +Thumbs.db diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bec14e5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] +resolver = "2" +members = [ + "backend", + "frontend", + "shared", +] + +[workspace.dependencies] +# --- Serialización --- +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# --- Utilidades --- +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +thiserror = "2" diff --git a/Dioxus.toml b/Dioxus.toml new file mode 100644 index 0000000..0a27bc8 --- /dev/null +++ b/Dioxus.toml @@ -0,0 +1,14 @@ +[application] +name = "NutriApp" +default_platform = "web" + +[web.app] +title = "NutriApp" + +[web.watcher] +reload_html = true +watch_path = ["src", "public"] + +[web.resource] +style = [] +script = [] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..942a5bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# ─── NutriApp Dockerfile ────────────────────────────────────── +# Imagen mínima para producción en ARM64 (Raspberry Pi 5) +# Build: docker build -t k3s.proyectosjoel.com/nutriapp:latest . + +FROM debian:bookworm-slim + +# Dependencias mínimas (ca-certificates para TLS a PostgreSQL) +RUN apt-get update -qq && \ + apt-get install -y -qq ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Binario compilado por CI/CD (cargo build --release) +COPY target/release/nutriapp-backend /app/nutriapp + +# Frontend estático (Dioxus → WASM compilado) +COPY frontend/dist /app/frontend/dist + +# Migraciones SQL +COPY backend/migrations /app/migrations + +EXPOSE 3100 + +ENV HOST=0.0.0.0 +ENV PORT=3100 +ENV RUST_LOG=nutriapp_backend=info + +CMD ["/app/nutriapp"] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..e606cf6 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "nutriapp-backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +# --- Workspace shared --- +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +shared = { path = "../shared" } + +# --- Runtime async --- +tokio = { version = "1", features = ["full"] } + +# --- HTTP framework + WebSockets --- +axum = { version = "0.8", features = ["ws"] } + +# --- Base de datos --- +sqlx = { version = "0.8", features = [ + "runtime-tokio", + "tls-rustls", + "postgres", + "chrono", + "uuid", + "migrate", +] } + +# --- Middleware HTTP --- +tower = "0.5" +tower-http = { version = "0.6", features = [ + "cors", + "trace", + "compression-gzip", + "limit", + "fs", +] } + +# --- Auth + sesiones --- +bcrypt = "0.16" +tower-sessions = "0.14" +tower-sessions-sqlx-store = { version = "0.14", features = ["postgres"] } + +# --- Jobs programados --- +tokio-cron-scheduler = "0.13" + +# --- Configuración --- +config = "0.15" +dotenvy = "0.15" + +# --- Logging --- +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# --- Upload de archivos (fotos de recetas) --- +axum-typed-multipart = "0.17" diff --git a/backend/migrations/20260623000001_initial.sql b/backend/migrations/20260623000001_initial.sql new file mode 100644 index 0000000..cbc32f4 --- /dev/null +++ b/backend/migrations/20260623000001_initial.sql @@ -0,0 +1,96 @@ +-- ============================================================ +-- NutriApp — Migración inicial +-- ============================================================ + +-- Habilitar extensión UUID +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ─── Familias ──────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS familias ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + nombre VARCHAR(255) NOT NULL, + codigo_invitacion VARCHAR(8) UNIQUE NOT NULL, + creador_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ─── Usuarios ──────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS usuarios ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) UNIQUE NOT NULL, + nombre VARCHAR(255) NOT NULL, + password_hash TEXT NOT NULL, + familia_id UUID REFERENCES familias(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- FK circular: creador_id → usuarios +ALTER TABLE familias + ADD CONSTRAINT fk_familias_creador + FOREIGN KEY (creador_id) REFERENCES usuarios(id) ON DELETE SET NULL; + +-- ─── Ingredientes (catálogo global por familia) ────────────── +CREATE TABLE IF NOT EXISTS ingredientes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + familia_id UUID NOT NULL REFERENCES familias(id) ON DELETE CASCADE, + nombre VARCHAR(255) NOT NULL, + categoria VARCHAR(50), + unidad_medida VARCHAR(20) NOT NULL DEFAULT 'unidad', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(familia_id, nombre) +); + +-- ─── Recetas ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS recetas ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + familia_id UUID NOT NULL REFERENCES familias(id) ON DELETE CASCADE, + creador_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, + nombre VARCHAR(255) NOT NULL, + descripcion TEXT, + porciones INTEGER NOT NULL DEFAULT 1, + tiempo_preparacion_min INTEGER, + instrucciones TEXT, + imagen_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ─── Ingredientes de cada receta ──────────────────────────── +CREATE TABLE IF NOT EXISTS receta_ingredientes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + receta_id UUID NOT NULL REFERENCES recetas(id) ON DELETE CASCADE, + ingrediente_id UUID NOT NULL REFERENCES ingredientes(id) ON DELETE CASCADE, + cantidad DOUBLE PRECISION NOT NULL, + unidad VARCHAR(20) NOT NULL DEFAULT 'unidad', + UNIQUE(receta_id, ingrediente_id) +); + +-- ─── Calendario semanal (slots de comida) ────────────────── +CREATE TABLE IF NOT EXISTS slots_comida ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + familia_id UUID NOT NULL REFERENCES familias(id) ON DELETE CASCADE, + fecha DATE NOT NULL, + tipo VARCHAR(20) NOT NULL CHECK (tipo IN ('desayuno', 'comida', 'cena')), + receta_id UUID REFERENCES recetas(id) ON DELETE SET NULL, + porciones INTEGER NOT NULL DEFAULT 1, + creado_por UUID REFERENCES usuarios(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(familia_id, fecha, tipo, receta_id) +); + +-- ─── Inventario familiar ──────────────────────────────────── +CREATE TABLE IF NOT EXISTS inventario ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + familia_id UUID NOT NULL REFERENCES familias(id) ON DELETE CASCADE, + ingrediente_id UUID NOT NULL REFERENCES ingredientes(id) ON DELETE CASCADE, + cantidad DOUBLE PRECISION NOT NULL DEFAULT 0, + unidad VARCHAR(20) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(familia_id, ingrediente_id) +); + +-- ─── Índices ───────────────────────────────────────────────── +CREATE INDEX idx_recetas_familia ON recetas(familia_id); +CREATE INDEX idx_slots_familia_fecha ON slots_comida(familia_id, fecha); +CREATE INDEX idx_inventario_familia ON inventario(familia_id); +CREATE INDEX idx_ingredientes_familia ON ingredientes(familia_id); diff --git a/backend/src/auth/middleware.rs b/backend/src/auth/middleware.rs new file mode 100644 index 0000000..4c31ba8 --- /dev/null +++ b/backend/src/auth/middleware.rs @@ -0,0 +1,2 @@ +// Middleware de autenticación +// Extraerá CurrentUser de la sesión y lo inyectará en los handlers diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs new file mode 100644 index 0000000..1353048 --- /dev/null +++ b/backend/src/auth/mod.rs @@ -0,0 +1,4 @@ +// Auth module — login, registro, middleware de sesión +// Se implementará con tower-sessions + bcrypt + +pub mod middleware; diff --git a/backend/src/jobs/mod.rs b/backend/src/jobs/mod.rs new file mode 100644 index 0000000..2f99ed9 --- /dev/null +++ b/backend/src/jobs/mod.rs @@ -0,0 +1,4 @@ +// Jobs programados con tokio-cron-scheduler +// - Generar lista de compras cada domingo +// - Limpiar sesiones expiradas +// - Recordatorios de menú diario diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..05f73bb --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,128 @@ +use axum::{ + Router, + routing::get, + response::Json, +}; +use serde::Serialize; +use sqlx::postgres::PgPoolOptions; +use tower_http::{ + cors::CorsLayer, + services::{ServeDir, ServeFile}, + trace::TraceLayer, + compression::CompressionLayer, + limit::RequestBodyLimitLayer, +}; +use tower_sessions::{ + SessionManagerLayer, + cookie::Key, +}; +use tower_sessions_sqlx_store::PostgresStore; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod routes; +mod models; +mod auth; +mod ws; +mod jobs; + +// ─── App State ──────────────────────────────────────────────── + +#[derive(Clone)] +pub struct AppState { + pub db: sqlx::PgPool, +} + +// ─── Health Endpoint ────────────────────────────────────────── + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + version: &'static str, + db: &'static str, +} + +async fn health() -> Json { + Json(HealthResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION"), + db: "connected", + }) +} + +// ─── Entrypoint ─────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // --- Logging --- + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "nutriapp_backend=debug,tower_http=debug".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // --- Config desde .env --- + dotenvy::dotenv().ok(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| { + "postgres://joel:factory_admin_2026@192.168.1.87:5432/nutriapp".to_string() + }); + + let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "3100".to_string()) + .parse() + .unwrap_or(3100); + + // --- Pool PostgreSQL --- + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(&database_url) + .await?; + + tracing::info!("✅ PostgreSQL conectado"); + + // --- Migraciones automáticas --- + sqlx::migrate!("./migrations") + .run(&pool) + .await?; + + tracing::info!("✅ Migraciones aplicadas"); + + // --- Session store --- + let session_store = PostgresStore::new(pool.clone()); + session_store.migrate().await?; + + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false); // false en dev (sin HTTPS), true en prod + + // --- App state --- + let state = AppState { db: pool }; + + // --- Router --- + let app = Router::new() + // API + .route("/api/health", get(health)) + .nest("/api", routes::api_routes()) + // WebSocket + .nest("/ws", ws::ws_routes()) + // Frontend estático (producción) + .nest_service("/", ServeDir::new("../frontend/dist") + .fallback(ServeFile::new("../frontend/dist/index.html"))) + // Middleware (orden importa: último = más externo) + .layer(CompressionLayer::new()) + .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10MB + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()) + .layer(session_layer) + .with_state(state); + + // --- Arranque --- + let addr = format!("{host}:{port}"); + tracing::info!("🚀 NutriApp corriendo en http://{addr}"); + + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/backend/src/models/familia.rs b/backend/src/models/familia.rs new file mode 100644 index 0000000..74945ca --- /dev/null +++ b/backend/src/models/familia.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Familia { + pub id: Uuid, + pub nombre: String, + pub codigo_invitacion: String, + pub creador_id: Uuid, + pub created_at: chrono::NaiveDateTime, +} diff --git a/backend/src/models/ingrediente.rs b/backend/src/models/ingrediente.rs new file mode 100644 index 0000000..b2d66df --- /dev/null +++ b/backend/src/models/ingrediente.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Ingrediente { + pub id: Uuid, + pub nombre: String, + pub categoria: Option, // "verdura", "proteina", "lacteo", etc. + pub unidad_medida: String, // "g", "ml", "unidad", "cucharada" + pub created_at: chrono::NaiveDateTime, +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..29871b4 --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,4 @@ +pub mod receta; +pub mod ingrediente; +pub mod usuario; +pub mod familia; diff --git a/backend/src/models/receta.rs b/backend/src/models/receta.rs new file mode 100644 index 0000000..256f622 --- /dev/null +++ b/backend/src/models/receta.rs @@ -0,0 +1,18 @@ +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Receta { + pub id: Uuid, + pub familia_id: Uuid, + pub creador_id: Uuid, + pub nombre: String, + pub descripcion: Option, + pub porciones: i32, + pub tiempo_preparacion_min: Option, + pub instrucciones: Option, + pub imagen_url: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} diff --git a/backend/src/models/usuario.rs b/backend/src/models/usuario.rs new file mode 100644 index 0000000..5e5221f --- /dev/null +++ b/backend/src/models/usuario.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Usuario { + pub id: Uuid, + pub email: String, + pub nombre: String, + pub password_hash: String, + pub familia_id: Option, + pub created_at: chrono::NaiveDateTime, +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..6590651 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,15 @@ +use axum::{Router, routing::get}; + +pub fn api_routes() -> Router { + Router::new() + .route("/v1/recetas", get(list_recetas)) + // TODO: .route("/v1/recetas", post(create_receta)) + // TODO: .route("/v1/recetas/:id", get(get_receta)) +} + +async fn list_recetas() -> axum::response::Json { + axum::response::Json(serde_json::json!({ + "recetas": [], + "total": 0 + })) +} diff --git a/backend/src/ws/mod.rs b/backend/src/ws/mod.rs new file mode 100644 index 0000000..f1f0d76 --- /dev/null +++ b/backend/src/ws/mod.rs @@ -0,0 +1,15 @@ +use axum::{Router, routing::get}; + +pub fn ws_routes() -> Router { + Router::new() + .route("/familia/:familia_id", get(ws_familia_handler)) +} + +// WebSocket colaborativo para edición en tiempo real del menú +async fn ws_familia_handler( + // ws: axum::extract::ws::WebSocketUpgrade, + // Path(familia_id): axum::extract::Path, + // State(state): axum::extract::State, +) -> &'static str { + "WebSocket endpoint — TODO: implementar broadcast por familia" +} diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml new file mode 100644 index 0000000..c7f4235 --- /dev/null +++ b/frontend/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nutriapp-frontend" +version = "0.1.0" +edition = "2024" + +[dependencies] +# --- Dioxus --- +dioxus = { version = "0.6", features = ["web"] } + +# --- Shared --- +shared = { path = "../shared" } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["v4"] } +chrono = { workspace = true } + +# --- HTTP calls al backend --- +reqwest = { version = "0.12", features = ["json"] } + +# --- WASM bindings --- +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = ["console"] } +console_error_panic_hook = "0.1" + +# --- Logging --- +log = "0.4" diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..137d6af --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,25 @@ + + + + + + NutriApp + + + +
+ + + diff --git a/frontend/src/api/client.rs b/frontend/src/api/client.rs new file mode 100644 index 0000000..b7615e5 --- /dev/null +++ b/frontend/src/api/client.rs @@ -0,0 +1,55 @@ +// Cliente HTTP para la API del backend (Axum en :3100) +// TODO: reemplazar con llamadas reales a /api/v1/* + +use reqwest::Client; +use shared::{RecetaDto, SlotComidaDto, ItemCompraDto, ListaCompraRequest}; + +const API_BASE: &str = "http://localhost:3100/api/v1"; + +pub async fn listar_recetas() -> Result, String> { + let client = Client::new(); + let resp = client + .get(format!("{API_BASE}/recetas")) + .send() + .await + .map_err(|e| e.to_string())?; + + resp.json::>() + .await + .map_err(|e| e.to_string()) +} + +pub async fn listar_slots( + familia_id: &str, + desde: &str, + hasta: &str, +) -> Result, String> { + let client = Client::new(); + let resp = client + .get(format!( + "{API_BASE}/calendario?familia_id={familia_id}&desde={desde}&hasta={hasta}" + )) + .send() + .await + .map_err(|e| e.to_string())?; + + resp.json::>() + .await + .map_err(|e| e.to_string()) +} + +pub async fn generar_lista_compras( + req: &ListaCompraRequest, +) -> Result, String> { + let client = Client::new(); + let resp = client + .post(format!("{API_BASE}/lista-compras")) + .json(req) + .send() + .await + .map_err(|e| e.to_string())?; + + resp.json::>() + .await + .map_err(|e| e.to_string()) +} diff --git a/frontend/src/api/mod.rs b/frontend/src/api/mod.rs new file mode 100644 index 0000000..d66aab2 --- /dev/null +++ b/frontend/src/api/mod.rs @@ -0,0 +1,2 @@ +// API client para llamadas al backend (Axum en :3100) +pub mod client; diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs new file mode 100644 index 0000000..e43f8ba --- /dev/null +++ b/frontend/src/components/mod.rs @@ -0,0 +1,3 @@ +pub mod receta_card; +pub mod slot_calendario; +pub mod nav; diff --git a/frontend/src/components/nav.rs b/frontend/src/components/nav.rs new file mode 100644 index 0000000..b973007 --- /dev/null +++ b/frontend/src/components/nav.rs @@ -0,0 +1 @@ +// Barra de navegación reutilizable diff --git a/frontend/src/components/receta_card.rs b/frontend/src/components/receta_card.rs new file mode 100644 index 0000000..4fed79b --- /dev/null +++ b/frontend/src/components/receta_card.rs @@ -0,0 +1,22 @@ +// Componente tarjeta de receta — nombre, porciones, tiempo, ingredientes +use dioxus::prelude::*; +use shared::RecetaDto; + +#[component] +pub fn RecetaCard(receta: RecetaDto, on_click: Option>) -> Element { + rsx! { + div { + class: "receta-card", + onclick: move |_| { + if let Some(ref handler) = on_click { + handler.call(()); + } + }, + h3 { "{receta.nombre}" } + span { "🍽️ {receta.porciones} porciones" } + if let Some(tiempo) = receta.tiempo_preparacion_min { + span { "⏱️ {tiempo} min" } + } + } + } +} diff --git a/frontend/src/components/slot_calendario.rs b/frontend/src/components/slot_calendario.rs new file mode 100644 index 0000000..fdd72ee --- /dev/null +++ b/frontend/src/components/slot_calendario.rs @@ -0,0 +1 @@ +// Slot de comida en el calendario (desayuno/comida/cena de un día) diff --git a/frontend/src/main.rs b/frontend/src/main.rs new file mode 100644 index 0000000..b6cdb3e --- /dev/null +++ b/frontend/src/main.rs @@ -0,0 +1,34 @@ +#![allow(non_snake_case)] + +use dioxus::prelude::*; + +mod pages; +mod components; +mod api; + +fn main() { + console_error_panic_hook::set_once(); + dioxus::launch(App); +} + +#[component] +fn App() -> Element { + rsx! { + div { class: "app-container", + header { class: "header", + h1 { "🍽️ NutriApp" } + nav { + a { href: "/", "Calendario" } + a { href: "/recetas", "Recetas" } + a { href: "/lista", "Lista de Compras" } + a { href: "/inventario", "Inventario" } + } + } + main { class: "main-content", + // Placeholder — routing se agrega después + h2 { "Bienvenido a NutriApp" } + p { "Planifica tus comidas semanales en familia." } + } + } + } +} diff --git a/frontend/src/pages/calendario.rs b/frontend/src/pages/calendario.rs new file mode 100644 index 0000000..9947c4d --- /dev/null +++ b/frontend/src/pages/calendario.rs @@ -0,0 +1,3 @@ +// Página de calendario semanal +// Grid 7 columnas (días) × 3 filas (desayuno, comida, cena) +// Drag & drop de recetas a slots diff --git a/frontend/src/pages/inventario.rs b/frontend/src/pages/inventario.rs new file mode 100644 index 0000000..0ebf6e7 --- /dev/null +++ b/frontend/src/pages/inventario.rs @@ -0,0 +1,2 @@ +// Página de inventario — productos disponibles en casa +// Se descuenta de la lista de compras diff --git a/frontend/src/pages/lista_compras.rs b/frontend/src/pages/lista_compras.rs new file mode 100644 index 0000000..58377fd --- /dev/null +++ b/frontend/src/pages/lista_compras.rs @@ -0,0 +1 @@ +// Página de lista de compras generada automáticamente desde el menú semanal diff --git a/frontend/src/pages/mod.rs b/frontend/src/pages/mod.rs new file mode 100644 index 0000000..35cc437 --- /dev/null +++ b/frontend/src/pages/mod.rs @@ -0,0 +1,4 @@ +pub mod calendario; +pub mod recetas; +pub mod lista_compras; +pub mod inventario; diff --git a/frontend/src/pages/recetas.rs b/frontend/src/pages/recetas.rs new file mode 100644 index 0000000..2bf7a0c --- /dev/null +++ b/frontend/src/pages/recetas.rs @@ -0,0 +1,2 @@ +// Página de recetas +// Lista, búsqueda, crear/editar receta con ingredientes dinámicos diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000..a53c575 --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,71 @@ +# ─── NutriApp K3s Deployment ───────────────────────────────── +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nutriapp + namespace: nutriapp + labels: + app: nutriapp +spec: + replicas: 1 + selector: + matchLabels: + app: nutriapp + template: + metadata: + labels: + app: nutriapp + spec: + containers: + - name: nutriapp + image: k3s.proyectosjoel.com/nutriapp:latest + imagePullPolicy: Always + ports: + - containerPort: 3100 + name: http + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: nutriapp-db + key: url + - name: SESSION_KEY + valueFrom: + secretKeyRef: + name: nutriapp-db + key: session-key + resources: + requests: + memory: "32Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /api/health + port: 3100 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/health + port: 3100 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: nutriapp + namespace: nutriapp + labels: + app: nutriapp +spec: + selector: + app: nutriapp + ports: + - port: 3100 + targetPort: 3100 + name: http + type: ClusterIP diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..b578ad4 --- /dev/null +++ b/shared/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "shared" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..50addba --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,96 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Tipos compartidos entre backend y frontend (Dioxus). +/// Ambos crates dependen de `shared`. + +// ─── Enums ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TipoComida { + Desayuno, + Comida, + Cena, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum UnidadMedida { + Gramos, + Mililitros, + Unidad, + Cucharada, + Cucharadita, + Taza, + Pizca, + AlGusto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum CategoriaIngrediente { + Verdura, + Fruta, + Proteina, + Lacteo, + Cereal, + Legumbre, + Bebida, + Snack, + Condimento, + Otro, +} + +// ─── DTOs ───────────────────────────────────────────────────── + +/// Receta con sus ingredientes +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecetaDto { + pub id: Uuid, + pub nombre: String, + pub descripcion: Option, + pub porciones: i32, + pub tiempo_preparacion_min: Option, + pub instrucciones: Option, + pub imagen_url: Option, + pub ingredientes: Vec, +} + +/// Cantidad de un ingrediente en una receta +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IngredienteRecetaDto { + pub ingrediente_id: Uuid, + pub nombre: String, + pub cantidad: f64, + pub unidad: UnidadMedida, + pub categoria: CategoriaIngrediente, +} + +/// Un platillo asignado a un slot del calendario +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotComidaDto { + pub id: Uuid, + pub fecha: NaiveDate, + pub tipo: TipoComida, + pub receta_id: Uuid, + pub receta_nombre: String, + pub porciones: i32, +} + +/// Solicitud de lista de compras +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListaCompraRequest { + pub familia_id: Uuid, + pub desde: NaiveDate, + pub hasta: NaiveDate, +} + +/// Item de la lista de compras +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ItemCompraDto { + pub ingrediente_id: Uuid, + pub nombre: String, + pub cantidad_total: f64, + pub unidad: UnidadMedida, + pub categoria: CategoriaIngrediente, + pub en_inventario: bool, +}