711 lines
20 KiB
Markdown
711 lines
20 KiB
Markdown
# Capítulo 6: Estructura del Backend y Despliegue de la Aplicación
|
|
|
|
En este capítulo, exploraremos la estructura del backend que hemos implementado para nuestra aplicación de reservas de gimnasio y el proceso de despliegue para dispositivos móviles reales.
|
|
|
|
## 1. Arquitectura del Backend
|
|
|
|
Nuestra aplicación ahora cuenta con un backend real basado en Node.js, Express y PostgreSQL que gestiona todas las operaciones de datos. Este enfoque tiene varias ventajas sobre el almacenamiento local:
|
|
|
|
- Datos centralizados y accesibles desde cualquier dispositivo
|
|
- Mayor seguridad y control de acceso
|
|
- Escalabilidad mejorada para manejar más usuarios
|
|
- Sincronización de datos en tiempo real
|
|
|
|
### Estructura de Carpetas del Backend
|
|
|
|
```
|
|
backend/
|
|
├── config/
|
|
│ └── db.js # Configuración de la base de datos
|
|
├── controllers/
|
|
│ ├── authController.js # Controlador de autenticación
|
|
│ ├── bookingController.js # Controlador de reservas
|
|
│ ├── gymClassController.js # Controlador de clases
|
|
│ ├── uploadController.js # Controlador para subida de archivos
|
|
│ └── userController.js # Controlador de usuarios
|
|
├── models/
|
|
│ ├── Booking.js # Modelo de datos para reservas
|
|
│ ├── GymClass.js # Modelo de datos para clases
|
|
│ ├── User.js # Modelo de datos para usuarios
|
|
│ └── index.js # Exporta todos los modelos
|
|
├── routes/
|
|
│ ├── authRoutes.js # Rutas de autenticación
|
|
│ ├── bookingRoutes.js # Rutas de reservas
|
|
│ ├── gymClassRoutes.js # Rutas de clases
|
|
│ ├── uploadRoutes.js # Rutas para subida de archivos
|
|
│ └── userRoutes.js # Rutas de usuarios
|
|
├── uploads/ # Directorio para archivos subidos
|
|
├── server.js # Punto de entrada principal
|
|
└── package.json # Dependencias y scripts
|
|
```
|
|
|
|
### Tecnologías Utilizadas en el Backend
|
|
|
|
- **Node.js**: Entorno de ejecución JavaScript del lado del servidor
|
|
- **Express**: Framework web para crear APIs REST
|
|
- **PostgreSQL**: Base de datos relacional robusta para almacenar los datos
|
|
- **Sequelize**: ORM para modelar los datos y conectar con PostgreSQL
|
|
- **JWT (JSON Web Tokens)**: Para gestionar la autenticación de usuarios
|
|
- **Multer**: Para manejar la subida de archivos
|
|
|
|
## 2. Implementación del Backend
|
|
|
|
### Configuración de la Base de Datos
|
|
|
|
**config/db.js**:
|
|
|
|
```javascript
|
|
const { Sequelize } = require('sequelize');
|
|
const dotenv = require('dotenv');
|
|
|
|
dotenv.config();
|
|
|
|
// Create Sequelize instance
|
|
let sequelize;
|
|
|
|
if (process.env.DATABASE_URL) {
|
|
// Use connection string if available
|
|
sequelize = new Sequelize(process.env.DATABASE_URL, {
|
|
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
|
define: {
|
|
freezeTableName: true // Prevent Sequelize from pluralizing table names
|
|
},
|
|
dialectOptions: {
|
|
ssl: {
|
|
require: true,
|
|
rejectUnauthorized: false
|
|
}
|
|
},
|
|
pool: {
|
|
max: 5,
|
|
min: 0,
|
|
acquire: 30000,
|
|
idle: 10000
|
|
}
|
|
});
|
|
} else {
|
|
// Fall back to individual parameters
|
|
sequelize = new Sequelize(
|
|
process.env.DB_NAME || 'postgres',
|
|
process.env.DB_USER || 'postgres',
|
|
process.env.DB_PASSWORD || 'postgres',
|
|
{
|
|
host: process.env.DB_HOST || 'localhost',
|
|
port: process.env.DB_PORT || 5432,
|
|
dialect: 'postgres',
|
|
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
|
define: {
|
|
freezeTableName: true // Prevent Sequelize from pluralizing table names
|
|
},
|
|
dialectOptions: {
|
|
ssl: process.env.NODE_ENV === 'production' ? {
|
|
require: true,
|
|
rejectUnauthorized: false
|
|
} : false
|
|
},
|
|
pool: {
|
|
max: 5,
|
|
min: 0,
|
|
acquire: 30000,
|
|
idle: 10000
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Test the connection
|
|
const connectDB = async () => {
|
|
try {
|
|
await sequelize.authenticate();
|
|
console.log('Database connection established successfully.');
|
|
} catch (error) {
|
|
console.error('Unable to connect to the database:', error);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
module.exports = { sequelize, connectDB };
|
|
```
|
|
|
|
### Modelos de Datos
|
|
|
|
**models/User.js**:
|
|
|
|
```javascript
|
|
const { DataTypes } = require('sequelize');
|
|
const { sequelize } = require('../config/db');
|
|
const bcrypt = require('bcryptjs');
|
|
|
|
const User = sequelize.define('user', {
|
|
id: {
|
|
type: DataTypes.INTEGER,
|
|
primaryKey: true,
|
|
autoIncrement: true
|
|
},
|
|
name: {
|
|
type: DataTypes.STRING,
|
|
allowNull: false
|
|
},
|
|
email: {
|
|
type: DataTypes.STRING,
|
|
allowNull: false,
|
|
unique: true,
|
|
validate: {
|
|
isEmail: true
|
|
}
|
|
},
|
|
password: {
|
|
type: DataTypes.STRING,
|
|
allowNull: false
|
|
},
|
|
profilePicUrl: {
|
|
type: DataTypes.STRING,
|
|
defaultValue: '/uploads/default-avatar.jpg'
|
|
},
|
|
notificationsEnabled: {
|
|
type: DataTypes.BOOLEAN,
|
|
defaultValue: true
|
|
}
|
|
}, {
|
|
hooks: {
|
|
beforeCreate: async (user) => {
|
|
if (user.password) {
|
|
const salt = await bcrypt.genSalt(10);
|
|
user.password = await bcrypt.hash(user.password, salt);
|
|
}
|
|
},
|
|
beforeUpdate: async (user) => {
|
|
if (user.changed('password')) {
|
|
const salt = await bcrypt.genSalt(10);
|
|
user.password = await bcrypt.hash(user.password, salt);
|
|
}
|
|
}
|
|
},
|
|
tableName: 'users' // Explicitly set lowercase table name
|
|
});
|
|
|
|
// Method to check if password matches
|
|
User.prototype.matchPassword = async function(enteredPassword) {
|
|
return await bcrypt.compare(enteredPassword, this.password);
|
|
};
|
|
|
|
module.exports = User;
|
|
```
|
|
|
|
**models/GymClass.js**:
|
|
|
|
```javascript
|
|
const { DataTypes } = require('sequelize');
|
|
const { sequelize } = require('../config/db');
|
|
|
|
const GymClass = sequelize.define('gymClass', {
|
|
id: {
|
|
type: DataTypes.INTEGER,
|
|
primaryKey: true,
|
|
autoIncrement: true
|
|
},
|
|
name: {
|
|
type: DataTypes.STRING,
|
|
allowNull: false
|
|
},
|
|
description: {
|
|
type: DataTypes.TEXT,
|
|
allowNull: false
|
|
},
|
|
instructor: {
|
|
type: DataTypes.STRING,
|
|
allowNull: false
|
|
},
|
|
startTime: {
|
|
type: DataTypes.DATE,
|
|
allowNull: false
|
|
},
|
|
endTime: {
|
|
type: DataTypes.DATE,
|
|
allowNull: false
|
|
},
|
|
maxCapacity: {
|
|
type: DataTypes.INTEGER,
|
|
allowNull: false
|
|
},
|
|
currentBookings: {
|
|
type: DataTypes.INTEGER,
|
|
defaultValue: 0
|
|
},
|
|
category: {
|
|
type: DataTypes.STRING,
|
|
defaultValue: 'Otros'
|
|
},
|
|
imageUrl: {
|
|
type: DataTypes.STRING,
|
|
defaultValue: '/uploads/default-class.jpg'
|
|
}
|
|
}, {
|
|
tableName: 'classes' // Explicitly set lowercase table name
|
|
});
|
|
|
|
module.exports = GymClass;
|
|
```
|
|
|
|
**models/Booking.js**:
|
|
|
|
```javascript
|
|
const { DataTypes } = require('sequelize');
|
|
const { sequelize } = require('../config/db');
|
|
|
|
const Booking = sequelize.define('booking', {
|
|
id: {
|
|
type: DataTypes.INTEGER,
|
|
primaryKey: true,
|
|
autoIncrement: true
|
|
},
|
|
userId: {
|
|
type: DataTypes.INTEGER,
|
|
allowNull: false,
|
|
references: {
|
|
model: 'users',
|
|
key: 'id'
|
|
}
|
|
},
|
|
classId: {
|
|
type: DataTypes.INTEGER,
|
|
allowNull: false,
|
|
references: {
|
|
model: 'classes',
|
|
key: 'id'
|
|
}
|
|
},
|
|
className: {
|
|
type: DataTypes.STRING,
|
|
allowNull: false
|
|
},
|
|
date: {
|
|
type: DataTypes.DATE,
|
|
defaultValue: DataTypes.NOW
|
|
},
|
|
status: {
|
|
type: DataTypes.ENUM('confirmed', 'cancelled', 'pending'),
|
|
defaultValue: 'confirmed'
|
|
}
|
|
}, {
|
|
tableName: 'bookings' // Explicitly set lowercase table name
|
|
});
|
|
|
|
module.exports = Booking;
|
|
```
|
|
|
|
**models/index.js**:
|
|
|
|
```javascript
|
|
const User = require('./User');
|
|
const GymClass = require('./GymClass');
|
|
const Booking = require('./Booking');
|
|
|
|
// Define associations
|
|
User.hasMany(Booking, { foreignKey: 'userId' });
|
|
Booking.belongsTo(User, { foreignKey: 'userId' });
|
|
|
|
GymClass.hasMany(Booking, { foreignKey: 'classId' });
|
|
Booking.belongsTo(GymClass, { foreignKey: 'classId' });
|
|
|
|
module.exports = {
|
|
User,
|
|
GymClass,
|
|
Booking
|
|
};
|
|
```
|
|
|
|
### Controladores
|
|
|
|
**controllers/authController.js**:
|
|
|
|
```javascript
|
|
const jwt = require('jsonwebtoken');
|
|
const { User } = require('../models');
|
|
|
|
// Generate JWT Token
|
|
const generateToken = (id) => {
|
|
return jwt.sign({ id }, process.env.JWT_SECRET || 'secret_key_12345', {
|
|
expiresIn: '30d'
|
|
});
|
|
};
|
|
|
|
// @desc Register a new user
|
|
// @route POST /api/auth/register
|
|
// @access Public
|
|
exports.registerUser = async (req, res) => {
|
|
try {
|
|
const { name, email, password } = req.body;
|
|
|
|
// Check if user already exists
|
|
const existingUser = await User.findOne({ where: { email } });
|
|
if (existingUser) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Email is already registered'
|
|
});
|
|
}
|
|
|
|
// Create user
|
|
const user = await User.create({
|
|
name,
|
|
email,
|
|
password
|
|
});
|
|
|
|
// Generate token
|
|
const token = generateToken(user.id);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
token,
|
|
user: {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
profilePicUrl: user.profilePicUrl,
|
|
notificationsEnabled: user.notificationsEnabled
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error registering user:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Server error',
|
|
error: error.message
|
|
});
|
|
}
|
|
};
|
|
|
|
// @desc Login user
|
|
// @route POST /api/auth/login
|
|
// @access Public
|
|
exports.loginUser = async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
// Check for user email
|
|
const user = await User.findOne({ where: { email } });
|
|
if (!user) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
message: 'Invalid credentials'
|
|
});
|
|
}
|
|
|
|
// Check if password matches
|
|
const isMatch = await user.matchPassword(password);
|
|
if (!isMatch) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
message: 'Invalid credentials'
|
|
});
|
|
}
|
|
|
|
// Generate token
|
|
const token = generateToken(user.id);
|
|
|
|
res.json({
|
|
success: true,
|
|
token,
|
|
user: {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
profilePicUrl: user.profilePicUrl,
|
|
notificationsEnabled: user.notificationsEnabled
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error logging in:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Server error',
|
|
error: error.message
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
### Rutas API
|
|
|
|
**routes/authRoutes.js**:
|
|
|
|
```javascript
|
|
const express = require('express');
|
|
const { registerUser, loginUser } = require('../controllers/authController');
|
|
|
|
const router = express.Router();
|
|
|
|
router.post('/register', registerUser);
|
|
router.post('/login', loginUser);
|
|
|
|
module.exports = router;
|
|
```
|
|
|
|
### Punto de Entrada del Servidor
|
|
|
|
**server.js**:
|
|
|
|
```javascript
|
|
const path = require('path');
|
|
const express = require('express');
|
|
const dotenv = require('dotenv');
|
|
const cors = require('cors');
|
|
const morgan = require('morgan');
|
|
const fs = require('fs');
|
|
const { connectDB, sequelize } = require('./config/db');
|
|
|
|
// Load env variables
|
|
dotenv.config();
|
|
|
|
// Import route files
|
|
const userRoutes = require('./routes/userRoutes');
|
|
const gymClassRoutes = require('./routes/gymClassRoutes');
|
|
const bookingRoutes = require('./routes/bookingRoutes');
|
|
const uploadRoutes = require('./routes/uploadRoutes');
|
|
const authRoutes = require('./routes/authRoutes');
|
|
|
|
// Initialize express app
|
|
const app = express();
|
|
|
|
// Middleware
|
|
app.use(express.json());
|
|
app.use(cors());
|
|
|
|
// Dev logging middleware
|
|
if (process.env.NODE_ENV === 'development') {
|
|
app.use(morgan('dev'));
|
|
}
|
|
|
|
// Create uploads directory if it doesn't exist
|
|
const uploadsDir = path.join(__dirname, 'uploads');
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
}
|
|
|
|
// Set static folder for uploads
|
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
|
|
|
// Mount routers
|
|
app.use('/api/users', userRoutes);
|
|
app.use('/api/classes', gymClassRoutes);
|
|
app.use('/api/bookings', bookingRoutes);
|
|
app.use('/api/upload', uploadRoutes);
|
|
app.use('/api/auth', authRoutes);
|
|
|
|
// Root route
|
|
app.get('/', (req, res) => {
|
|
res.json({ message: 'Welcome to Gym API' });
|
|
});
|
|
|
|
// Error handling middleware
|
|
app.use((err, req, res, next) => {
|
|
console.error(err.stack);
|
|
res.status(500).json({
|
|
message: 'Server Error',
|
|
error: err.message
|
|
});
|
|
});
|
|
|
|
// Define port
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Connect to database and start server
|
|
const startServer = async () => {
|
|
try {
|
|
// Connect to database
|
|
await connectDB();
|
|
|
|
await sequelize.sync();
|
|
console.log('Database synchronized');
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
|
|
});
|
|
} catch (error) {
|
|
console.error('Unable to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
startServer();
|
|
|
|
// Handle unhandled promise rejections
|
|
process.on('unhandledRejection', (err) => {
|
|
console.log(`Error: ${err.message}`);
|
|
// Close server & exit process
|
|
process.exit(1);
|
|
});
|
|
```
|
|
|
|
## 3. Despliegue en Plataformas Móviles
|
|
|
|
Una de las grandes ventajas de usar Ionic con Capacitor es la posibilidad de desplegar la misma base de código en múltiples plataformas. Vamos a revisar el proceso para Android.
|
|
|
|
### Preparación para Android
|
|
|
|
1. **Instalar las Dependencias de Android**:
|
|
- Android Studio
|
|
- JDK (Java Development Kit)
|
|
- Configurar variables de entorno (ANDROID_HOME, JAVA_HOME)
|
|
|
|
2. **Añadir la Plataforma Android a nuestro Proyecto**:
|
|
|
|
```bash
|
|
# Instalar @capacitor/android si no está instalado
|
|
npm install @capacitor/android
|
|
|
|
# Añadir Android al proyecto
|
|
npx cap add android
|
|
```
|
|
|
|
3. **Sincronizar el Proyecto**:
|
|
|
|
Después de hacer cambios en el código web, necesitamos construir el proyecto y sincronizarlo con las plataformas nativas:
|
|
|
|
```bash
|
|
# Construir la aplicación
|
|
ionic build --prod
|
|
|
|
# Sincronizar con Android
|
|
npx cap sync android
|
|
```
|
|
|
|
4. **Abrir el Proyecto en Android Studio**:
|
|
|
|
```bash
|
|
npx cap open android
|
|
```
|
|
|
|
### Configuración del AndroidManifest.xml
|
|
|
|
El archivo `AndroidManifest.xml` es crucial para definir los permisos y configuraciones de la aplicación Android. Ya cubrimos algunos permisos esenciales:
|
|
|
|
```xml
|
|
<!-- Permisos para cámara -->
|
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
|
|
|
|
<!-- Permisos para notificaciones -->
|
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
|
```
|
|
|
|
### Personalización de Iconos y Splash Screen
|
|
|
|
Para personalizar los iconos y la pantalla de inicio:
|
|
|
|
1. **Reemplazar los Archivos de Iconos**:
|
|
- Crear iconos para diferentes resoluciones
|
|
- Reemplazar los archivos en `android/app/src/main/res/mipmap-*/`
|
|
|
|
2. **Reemplazar la Pantalla de Inicio (Splash Screen)**:
|
|
- Crear imágenes para diferentes orientaciones y resoluciones
|
|
- Reemplazar los archivos en `android/app/src/main/res/drawable-*/splash.png`
|
|
|
|
3. **Personalizar el Nombre de la Aplicación**:
|
|
- Editar `android/app/src/main/res/values/strings.xml`:
|
|
```xml
|
|
<resources>
|
|
<string name="app_name">Gym Reservation</string>
|
|
<string name="title_activity_main">Gym Reservation</string>
|
|
<string name="package_name">com.example.gymreservation</string>
|
|
<string name="custom_url_scheme">com.example.gymreservation</string>
|
|
</resources>
|
|
```
|
|
|
|
### Construir un APK de Prueba
|
|
|
|
Para generar un APK que puedas compartir con probadores:
|
|
|
|
1. En Android Studio, selecciona **Build > Build Bundle(s) / APK(s) > Build APK(s)**
|
|
2. Cuando termine, haz clic en **locate** para encontrar el archivo APK
|
|
3. Este archivo se puede compartir directamente para instalarlo en dispositivos Android (se debe habilitar la instalación desde fuentes desconocidas)
|
|
|
|
### Construir un Bundle para Google Play
|
|
|
|
Para publicar en la tienda de Google Play:
|
|
|
|
1. En Android Studio, selecciona **Build > Generate Signed Bundle / APK**
|
|
2. Elige **Android App Bundle**
|
|
3. Crea o selecciona una clave de firma (keystore)
|
|
4. Completa la información necesaria y genera el bundle (.aab)
|
|
5. Este archivo es el que se sube a la Google Play Console
|
|
|
|
## 4. Consideraciones de Seguridad
|
|
|
|
Al desplegar una aplicación móvil que se comunica con un backend, es crucial considerar aspectos de seguridad:
|
|
|
|
### Autenticación Segura
|
|
|
|
Nuestra aplicación implementa JWT (JSON Web Tokens) para autenticar usuarios, pero aquí hay algunas mejoras adicionales:
|
|
|
|
- **Tokens de Actualización**: Implementar tokens de corta duración con tokens de actualización
|
|
- **Almacenamiento Seguro**: Utilizar almacenamiento nativo seguro para tokens
|
|
- **HTTPS**: Garantizar que todas las comunicaciones API usen HTTPS
|
|
|
|
### Manejo de Datos Sensibles
|
|
|
|
- **No almacenar contraseñas en texto plano**: Ya estamos usando bcrypt para hash de contraseñas
|
|
- **Minimizar los datos almacenados localmente**: Solo guardar lo necesario en el dispositivo
|
|
- **Eliminar datos al cerrar sesión**: Limpiar datos sensibles cuando el usuario cierre sesión
|
|
|
|
### Permisos de Aplicación
|
|
|
|
- **Solicitar solo los permisos necesarios**: No solicitar permisos que no sean esenciales
|
|
- **Explicar por qué se necesitan permisos**: Proporcionar contexto cuando se solicitan permisos
|
|
- **Manejar denegaciones graciosamente**: La aplicación debe funcionar aunque se denieguen permisos no críticos
|
|
|
|
## 5. Optimizaciones de Rendimiento
|
|
|
|
Para garantizar que nuestra aplicación funcione de manera óptima en dispositivos reales:
|
|
|
|
### Optimización de Imágenes
|
|
|
|
- **Redimensionar imágenes**: Utilizar tamaños apropiados para dispositivos móviles
|
|
- **Compresión de imágenes**: Reducir el tamaño de archivo sin perder calidad visible
|
|
- **Carga progresiva**: Implementar carga progresiva para imágenes grandes
|
|
|
|
### Optimización de Red
|
|
|
|
- **Implementar caché**: Almacenar en caché respuestas de API para reducir solicitudes de red
|
|
- **Carga perezosa (lazy loading)**: Cargar datos solo cuando sean necesarios
|
|
- **Compresión de datos**: Utilizar gzip en el servidor para respuestas API
|
|
|
|
### Optimización de la UI
|
|
|
|
- **Virtualización de listas**: Para listas largas, solo renderizar elementos visibles
|
|
- **Animaciones eficientes**: Usar animaciones CSS en lugar de JavaScript cuando sea posible
|
|
- **Evitar work blocking**: No bloquear el hilo principal con operaciones pesadas
|
|
|
|
## 6. Pruebas y Depuración
|
|
|
|
Para identificar y resolver problemas en dispositivos reales:
|
|
|
|
### Herramientas de Depuración
|
|
|
|
- **Chrome Remote Debugging**: Para depurar WebView en dispositivos Android
|
|
- **Safari Web Inspector**: Para depurar WebView en dispositivos iOS
|
|
- **Capacitor Logs**: Usar `npx cap logs android` para ver logs en tiempo real
|
|
|
|
### Pruebas en Dispositivos Reales
|
|
|
|
- **Probar en diferentes tamaños de pantalla**: Asegurar que la UI sea responsiva
|
|
- **Probar con diferentes velocidades de red**: Simular conexiones lentas
|
|
- **Pruebas de batería**: Verificar que la aplicación no consuma demasiada batería
|
|
|
|
## Resumen
|
|
|
|
En este capítulo, hemos explorado:
|
|
|
|
1. La arquitectura de nuestro backend Node.js/Express/PostgreSQL
|
|
2. Los detalles de implementación de los modelos, controladores y rutas del backend
|
|
3. El proceso de despliegue para dispositivos Android
|
|
4. Consideraciones de seguridad para aplicaciones móviles
|
|
5. Técnicas de optimización de rendimiento
|
|
6. Herramientas y enfoques para pruebas y depuración
|
|
|
|
Con esto, nuestra aplicación Gym Reservation está lista para ser utilizada por usuarios reales, con un backend robusto y una experiencia móvil optimizada. |