Implement initial API structure with Docker support, CI/CD workflows, and authentication features
Despliegue Automático / desplegar (push) Failing after 1m29s

This commit is contained in:
2026-05-18 18:43:33 +02:00
commit 9d117a5887
23 changed files with 1091 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
.git
.gitea
.env
.envGitea
docs
README.md
pnpm-debug.log
npm-debug.log
+18
View File
@@ -0,0 +1,18 @@
name: CI - Prueba de Compilación
on:
pull_request:
branches: [main, master]
jobs:
test-build:
runs-on: ubuntu-latest
steps:
- name: 📥 Descargar código
uses: actions/checkout@v6
- name: 🛠️ Probar Build de Docker (Sin desplegar)
run: |
# Esto solo construye la imagen para ver si hay errores
# pero no levanta el contenedor en tu servidor
docker build .
+24
View File
@@ -0,0 +1,24 @@
name: Despliegue Automático
on:
push:
branches:
- main
- master
workflow_dispatch:
jobs:
desplegar:
runs-on: ubuntu-latest
steps:
- name: 📥 Descargar el código nuevo
uses: actions/checkout@v6
- name: 🔐 Generar archivo .env desde secrets
run: |
printf '%s\n' "${{ secrets.ENV_API_NOTAS }}" > .env
- name: 🚀 Construir y levantar Docker
run: |
docker compose up -d --build
+47
View File
@@ -0,0 +1,47 @@
# Dependencies
node_modules/
pnpm-lock.yaml
# Environment variables
.env
.env.local
.env.*.local
.envGitea
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
logs/
*.log
npm-debug.log*
pnpm-debug.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Database
*.sqlite
*.db
*.sqlite3
# OS
.DS_Store
Thumbs.db
# Build output
dist/
build/
# Temporary files
tmp/
temp/
*.tmp
+28
View File
@@ -0,0 +1,28 @@
FROM node:22-bookworm-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /app
FROM base AS deps
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
FROM base AS production
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["pnpm", "start"]
+42
View File
@@ -0,0 +1,42 @@
services:
db:
image: postgres:15-alpine
container_name: notas_postgres
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
container_name: notas_api
restart: unless-stopped
depends_on:
db:
condition: service_healthy
env_file:
- .env
environment:
NODE_ENV: ${NODE_ENV:-production}
APP_PORT: ${APP_PORT:-3000}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
DEFAULT_USER: ${DEFAULT_USER:-admin}
DEFAULT_PASS: ${DEFAULT_PASS:-supersecreta123}
ports:
- "${HOST_PORT:-3005}:3000"
volumes:
db_data:
+289
View File
@@ -0,0 +1,289 @@
# API de `api_notas`
Esta guía resume las rutas disponibles, qué datos esperan y los tipos más importantes del proyecto.
## Base URL
Todas las rutas cuelgan de:
```text
/api
```
Ejemplo local:
```text
http://localhost:3000/api
```
## Tipos principales
- `UUID`: identificador único en formato UUID.
- `string`: texto plano o cifrado.
- `boolean`: `true` o `false`.
- `number`: valor numérico.
- `ISO date string`: fecha en formato ISO 8601, por ejemplo `2026-05-18T10:00:00.000Z`.
## Rutas disponibles
### `GET /api/status`
Comprueba que la API está viva.
Respuesta ejemplo:
```json
{
"message": "¡API funcionando correctamente!"
}
```
### `POST /api/auth/register`
Crea un usuario nuevo.
Body:
```json
{
"username": "maria",
"password": "123456",
"encrypted_master_key": "texto_cifrado_opcional"
}
```
Campos:
- `username`: `string`, obligatorio.
- `password`: `string`, obligatorio.
- `encrypted_master_key`: `string | null`, opcional.
Respuesta ejemplo:
```json
{
"message": "Usuario registrado con éxito",
"accessToken": "jwt_access_token",
"refreshToken": "token_largo_aleatorio"
}
```
### `POST /api/auth/login`
Inicia sesión y devuelve tokens.
Body:
```json
{
"username": "maria",
"password": "123456",
"deviceName": "Flutter Pixel"
}
```
Campos:
- `username`: `string`, obligatorio.
- `password`: `string`, obligatorio.
- `deviceName`: `string`, opcional.
Respuesta ejemplo:
```json
{
"message": "Login exitoso",
"accessToken": "jwt_access_token",
"refreshToken": "token_largo_aleatorio",
"encrypted_master_key": "texto_cifrado"
}
```
### `POST /api/auth/refresh`
Genera un nuevo `accessToken` usando un `refreshToken` válido.
Body:
```json
{
"refreshToken": "token_largo_aleatorio"
}
```
Campos:
- `refreshToken`: `string`, obligatorio.
Respuesta ejemplo:
```json
{
"accessToken": "nuevo_jwt_access_token"
}
```
### `POST /api/auth/logout`
Revoca la sesión del dispositivo actual.
Body:
```json
{
"refreshToken": "token_largo_aleatorio"
}
```
Campos:
- `refreshToken`: `string`, obligatorio.
Respuesta ejemplo:
```json
{
"message": "Sesión cerrada en el dispositivo actual"
}
```
### `POST /api/sync`
Endpoint único de sincronización offline-first.
Autenticación requerida:
- Enviar `Authorization: Bearer <accessToken>` (obligatorio). El servidor ya no acepta `userId` en el body.
#### Request
Body ejemplo:
```json
{
"lastSyncAt": "2026-05-18T10:00:00.000Z",
"changes": {
"categories": [
{
"id": "uuid-categoria-1",
"encrypted_name": "texto_cifrado...",
"serverVersion": 1,
"isDeleted": false,
"updatedAt": "2026-05-18T10:05:00.000Z"
}
],
"notes": [
{
"id": "uuid-nota-1",
"categoryId": "uuid-categoria-1",
"encrypted_title": "titulo_cifrado...",
"encrypted_body": "cuerpo_cifrado...",
"serverVersion": 1,
"position": 2000,
"isDeleted": true,
"updatedAt": "2026-05-18T10:10:00.000Z"
}
]
}
}
```
Campos:
- `lastSyncAt`: `string` ISO, opcional. Si no se envía, el servidor usa `1970-01-01T00:00:00.000Z`.
- `changes`: `object`, obligatorio.
- `changes.categories`: `array`, opcional.
- `changes.notes`: `array`, opcional.
#### Estructura de categoría
```json
{
"id": "uuid",
"encrypted_name": "texto_cifrado",
"serverVersion": 1,
"isDeleted": false,
"updatedAt": "2026-05-18T10:05:00.000Z"
}
```
Campos:
- `id`: `UUID`, obligatorio.
- `encrypted_name`: `string`, obligatorio.
- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio.
- `isDeleted`: `boolean`, opcional, por defecto `false`.
- `updatedAt`: `ISO date string`, opcional (solo informativo para UI).
#### Estructura de nota
```json
{
"id": "uuid",
"categoryId": "uuid-categoria",
"encrypted_title": "titulo_cifrado",
"encrypted_body": "cuerpo_cifrado",
"serverVersion": 1,
"position": 2000,
"isDeleted": false,
"updatedAt": "2026-05-18T10:10:00.000Z"
}
```
Campos:
- `id`: `UUID`, obligatorio.
- `categoryId`: `UUID | null`, opcional.
- `encrypted_title`: `string`, obligatorio.
- `encrypted_body`: `string`, obligatorio.
- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio.
- `position`: `number`, opcional.
- `isDeleted`: `boolean`, opcional, por defecto `false`.
- `updatedAt`: `ISO date string`, opcional (solo informativo para UI).
#### Response
Respuesta ejemplo (nuevo contrato):
```json
{
"serverTimestamp": "2026-05-18T10:20:00.000Z",
"synced": true,
"changes": {
"categories": [],
"notes": [
{
"id": "uuid-nota-2",
"categoryId": null,
"encrypted_title": "titulo_cifrado...",
"encrypted_body": "cuerpo_cifrado...",
"serverVersion": 3,
"position": 0,
"isDeleted": false,
"updatedAt": "2026-05-18T10:15:00.000Z"
}
]
}
}
```
Campos de salida:
- `serverTimestamp`: `string` ISO con la hora del servidor.
- `synced`: `boolean`.
- `changes.categories`: array con todas las categorías cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos.
- `changes.notes`: array con todas las notas cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos y las copias creadas automáticamente en caso de conflicto.
## Reglas de sincronización
- El cliente debe guardar su último `lastSyncAt`.
- Un borrado no elimina el registro localmente: se marca con `isDeleted: true`.
- El servidor decide conflictos comparando `serverVersion` (no `updatedAt`).
- En categorías, solo se actualiza si `incoming.serverVersion === serverVersion`. Si no coincide, gana el servidor y no se crea duplicado.
- Si la versión entrante de una nota coincide con la versión actual del servidor, acepta el cambio y sube versión (`v -> v+1`).
- Si la versión del servidor es mayor que la entrante en una nota, el servidor crea una nueva nota duplicada con el contenido entrante para no perder datos.
## Notas importantes
- Las notas y categorías están pensadas para contenido cifrado del lado del cliente.
- El servidor guarda `encrypted_title`, `encrypted_body` y `encrypted_name`, pero no interpreta el contenido.
+18
View File
@@ -0,0 +1,18 @@
require('dotenv').config();
const app = require('./src/app');
const initDB = require('./src/initDB');
const puertoInterno = process.env.APP_PORT || process.env.PORT || 3000;
async function arrancarServidor() {
// 1. Preparamos la base de datos
await initDB();
// 2. Encendemos el servidor web
app.listen(puertoInterno, '0.0.0.0', () => {
console.log(`🚀 Servidor API escuchando en http://0.0.0.0:${puertoInterno}/api/status`);
console.log(`🌐 Desde el móvil usa la IP del portátil: http://<IP_DEL_PORTATIL>:${puertoInterno}/api/status`);
});
}
arrancarServidor();
+28
View File
@@ -0,0 +1,28 @@
{
"name": "api_notas",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.33.0",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.8",
"sqlite3": "^6.0.1"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const cors = require('cors');
const Routes = require('./routes');
const app = express();
// Middlewares
app.use(cors());
app.use(express.json());
// Montamos las rutas con el prefijo de versión
app.use('/api', Routes);
// Manejo de rutas no encontradas (404)
app.use((req, res) => {
res.status(404).json({ error: 'Ruta no encontrada' });
});
module.exports = app;
+9
View File
@@ -0,0 +1,9 @@
const { Sequelize } = require('sequelize');
require('dotenv').config();
const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialect: 'postgres',
logging: false, // Cambia a console.log si quieres ver las consultas SQL
});
module.exports = sequelize;
+77
View File
@@ -0,0 +1,77 @@
const authService = require('../services/authService');
// 1. REGISTRO
const register = async (req, res) => {
try {
console.log(req.body); // Log completo del body para depuración
const { username, password, encrypted_master_key } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Usuario y contraseña son obligatorios' });
}
console.log(`Intentando registrar usuario: ${username}`);
console.log(`Encrypted Master Key recibida: ${encrypted_master_key}`);
// Registramos el usuario y emitimos tokens para entrar directamente
await authService.register(username, password, encrypted_master_key);
// Generamos tokens reutilizando el flujo de login (no hay deviceName en register)
const nombreDispositivo = req.headers['user-agent'] || 'Dispositivo Desconocido';
const { accessToken, refreshToken } = await authService.login(username, password, nombreDispositivo);
res.status(201).json({
message: 'Usuario registrado con éxito',
accessToken,
refreshToken
});
} catch (error) {
res.status(400).json({ error: error.message });
}
};
// 2. LOGIN
const login = async (req, res) => {
try {
const { username, password, deviceName } = req.body;
// Si Flutter no envía un nombre, intentamos leer el User-Agent
const nombreDispositivo = deviceName || req.headers['user-agent'] || 'Dispositivo Desconocido';
const { user, accessToken, refreshToken } = await authService.login(username, password, nombreDispositivo);
res.json({
message: 'Login exitoso',
accessToken,
refreshToken,
encrypted_master_key: user.encrypted_master_key
});
} catch (error) {
res.status(401).json({ error: error.message });
}
};
// 3. REFRESCAR TOKEN
const refresh = async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'Refresh token es obligatorio' });
const { accessToken } = await authService.refreshAccessToken(refreshToken);
res.json({ accessToken });
} catch (error) {
res.status(401).json({ error: error.message });
}
};
// 4. CERRAR SESIÓN
const logout = async (req, res) => {
try {
const { refreshToken } = req.body;
await authService.logoutDevice(refreshToken);
res.json({ message: 'Sesión cerrada en el dispositivo actual' });
} catch (error) {
res.status(400).json({ error: error.message });
}
};
// ¡EXPORTAMOS LAS 4 FUNCIONES!
module.exports = { register, login, refresh, logout };
+39
View File
@@ -0,0 +1,39 @@
const jwt = require('jsonwebtoken');
const syncService = require('../services/syncService');
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET no está definido');
}
const sync = async (req, res) => {
try {
const authorizationHeader = req.headers.authorization || '';
if (!authorizationHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization header missing or invalid' });
}
const token = authorizationHeader.slice(7).trim();
const payload = jwt.verify(token, JWT_SECRET);
const userId = payload && payload.id;
if (!userId) return res.status(401).json({ error: 'Usuario inválido en token' });
const result = await syncService.sync(userId, req.body);
res.json(result);
} catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token inválido o expirado' });
}
const statusCode = (
error.message.includes('obligatorio') ||
error.message.includes('debe incluir') ||
error.message.includes('inválida')
) ? 400 : 500;
res.status(statusCode).json({ error: error.message });
}
};
module.exports = { sync };
+16
View File
@@ -0,0 +1,16 @@
const sequelize = require('./config/database');
async function initDB() {
try {
await sequelize.sync({ alter: true });
console.log('✅ Base de datos conectada y tablas sincronizadas.');
// console.log('️ Creación de usuario por defecto desactivada.');
} catch (error) {
console.error('❌ Error fatal al iniciar la base de datos:', error);
process.exit(1);
}
}
module.exports = initDB;
+29
View File
@@ -0,0 +1,29 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const User = require('./User');
const Category = sequelize.define('Category', {
id: {
type: DataTypes.UUID,
primaryKey: true,
},
// Como somos E2EE, el nombre de la categoría también viaja encriptado
encrypted_name: {
type: DataTypes.TEXT,
allowNull: false
},
serverVersion: {
type: DataTypes.INTEGER,
defaultValue: 1,
},
isDeleted: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
});
// Relación: Un Usuario tiene muchas Categorías
User.hasMany(Category, { foreignKey: 'userId' });
Category.belongsTo(User, { foreignKey: 'userId' });
module.exports = Category;
+41
View File
@@ -0,0 +1,41 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const User = require('./User');
const Category = require('./Category'); // Importamos la nueva tabla
const Note = sequelize.define('Note', {
id: {
type: DataTypes.UUID,
primaryKey: true,
},
encrypted_title: {
type: DataTypes.TEXT,
allowNull: false
},
encrypted_body: {
type: DataTypes.TEXT,
allowNull: false
},
position: {
type: DataTypes.DOUBLE,
defaultValue: 0,
},
serverVersion: {
type: DataTypes.INTEGER,
defaultValue: 1,
},
isDeleted: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
});
// Relación: Un Usuario tiene muchas Notas
User.hasMany(Note, { foreignKey: 'userId' });
Note.belongsTo(User, { foreignKey: 'userId' });
// Relación: Una Categoría tiene muchas Notas (y una nota puede no tener categoría)
Category.hasMany(Note, { foreignKey: 'categoryId', allowNull: true });
Note.belongsTo(Category, { foreignKey: 'categoryId', allowNull: true });
module.exports = Note;
+27
View File
@@ -0,0 +1,27 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const User = require('./User');
const RefreshToken = sequelize.define('RefreshToken', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
token: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
deviceName: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'Dispositivo Desconocido'
}
});
// Relación: Un Usuario puede tener muchos Refresh Tokens (muchos dispositivos conectados)
User.hasMany(RefreshToken, { foreignKey: 'userId', onDelete: 'CASCADE' });
RefreshToken.belongsTo(User, { foreignKey: 'userId' });
module.exports = RefreshToken;
+26
View File
@@ -0,0 +1,26 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const User = sequelize.define('User', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
password: {
type: DataTypes.STRING,
allowNull: false
},
// La llave de las notas encriptada con la contraseña del usuario
encrypted_master_key: {
type: DataTypes.TEXT,
allowNull: true
}
});
module.exports = User;
+17
View File
@@ -0,0 +1,17 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
// POST /api/auth/register
router.post('/register', authController.register);
// POST /api/auth/login
router.post('/login', authController.login);
// POST /api/auth/refresh
router.post('/refresh', authController.refresh);
// POST /api/auth/logout
router.post('/logout', authController.logout);
module.exports = router;
+16
View File
@@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const authRoutes = require('./authRoutes'); // Añadimos esto
const syncRoutes = require('./syncRoutes');
router.get('/status', (req, res) => {
res.json({ message: '¡API funcionando correctamente!' });
});
// Todas las rutas de auth estarán bajo /api/auth/...
router.use('/auth', authRoutes);
// Endpoint único de sincronización offline-first
router.use('/', syncRoutes);
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const express = require('express');
const router = express.Router();
const syncController = require('../controllers/syncController');
router.post('/sync', syncController.sync);
module.exports = router;
+90
View File
@@ -0,0 +1,90 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const User = require('../models/User');
const RefreshToken = require('../models/RefreshToken');
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET no está definido');
}
class AuthService {
async register(username, password, encrypted_master_key = null) {
const userExists = await User.findOne({ where: { username } });
if (userExists) throw new Error('El usuario ya existe');
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
return await User.create({ username, password: hashedPassword, encrypted_master_key });
}
// LOGIN: Crea el Access Token corto y registra el dispositivo con un Refresh Token único
async login(username, password, deviceName) {
const user = await User.findOne({ where: { username } });
if (!user) throw new Error('Credenciales inválidas');
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) throw new Error('Credenciales inválidas');
// A. Generar Access Token (Vence en 15 minutos - Sin datos sensibles)
const accessToken = jwt.sign(
{ id: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '15m' }
);
// B. Generar un Refresh Token único y aleatorio (String seguro de 64 caracteres)
const randomToken = crypto.randomBytes(32).toString('hex');
// C. Guardar el Refresh Token asociado al dispositivo en PostgreSQL
await RefreshToken.create({
token: randomToken,
deviceName: deviceName || 'Dispositivo Móvil',
userId: user.id
});
return { user, accessToken, refreshToken: randomToken };
}
// REFRESH: Intercambia un Refresh Token válido por un nuevo Access Token limpio
async refreshAccessToken(tokenRecibido) {
// 1. Buscar el token en la base de datos de Postgres
const tokenGuardado = await RefreshToken.findOne({
where: { token: tokenRecibido },
include: User
});
if (!tokenGuardado) {
throw new Error('Refresh Token inválido o sesión revocada');
}
const user = tokenGuardado.User;
// 2. Si existe, emitimos un nuevo Access Token de 15 minutos
const newAccessToken = jwt.sign(
{ id: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '15m' }
);
return { accessToken: newAccessToken };
}
// CERRAR SESIÓN EN UN SOLO DISPOSITIVO (El bisturí)
async logoutDevice(tokenValue) {
await RefreshToken.destroy({ where: { token: tokenValue } });
return { message: 'Sesión cerrada en este dispositivo correctamente.' };
}
// CERRAR SESIÓN EN TODOS LOS SITIOS (El botón nuclear)
async logoutAllDevices(userId) {
await RefreshToken.destroy({ where: { userId } });
return { message: 'Se ha cerrado sesión en todos los dispositivos de forma global.' };
}
}
module.exports = new AuthService();
+175
View File
@@ -0,0 +1,175 @@
const { Op } = require('sequelize');
const sequelize = require('../config/database');
const Note = require('../models/Note');
const Category = require('../models/Category');
const { randomUUID } = require('crypto');
const toIso = (value) => (value ? new Date(value).toISOString() : null);
const isValidDate = (value) => {
const parsed = new Date(value);
return !Number.isNaN(parsed.getTime());
};
const normalizeCategory = (category) => ({
id: category.id,
encrypted_name: category.encrypted_name,
isDeleted: Boolean(category.isDeleted),
serverVersion: category.serverVersion || 1,
updatedAt: category.updatedAt instanceof Date ? category.updatedAt.toISOString() : toIso(category.updatedAt)
});
const normalizeNote = (note) => ({
id: note.id,
categoryId: note.categoryId || null,
encrypted_title: note.encrypted_title,
encrypted_body: note.encrypted_body,
position: note.position,
isDeleted: Boolean(note.isDeleted),
serverVersion: note.serverVersion || 1,
updatedAt: note.updatedAt instanceof Date ? note.updatedAt.toISOString() : toIso(note.updatedAt)
});
const getIncomingVersion = (entityName, incoming) => {
if (!Number.isInteger(incoming.serverVersion) || incoming.serverVersion < 0) {
throw new Error(`Cada ${entityName} debe incluir serverVersion como entero >= 0`);
}
return incoming.serverVersion;
};
class SyncService {
async sync(userId, payload = {}) {
if (!userId) {
throw new Error('userId es obligatorio para sincronizar');
}
const lastSyncAtValue = payload.lastSyncAt || '1970-01-01T00:00:00.000Z';
if (!isValidDate(lastSyncAtValue)) {
throw new Error('lastSyncAt debe ser una fecha ISO válida');
}
const lastSyncAt = new Date(lastSyncAtValue);
const changes = payload.changes || {};
const incomingCategories = Array.isArray(changes.categories) ? changes.categories : [];
const incomingNotes = Array.isArray(changes.notes) ? changes.notes : [];
await sequelize.transaction(async (transaction) => {
for (const incomingCategory of incomingCategories) {
if (!incomingCategory.id || !incomingCategory.encrypted_name) {
throw new Error('Cada categoría debe incluir id y encrypted_name');
}
const incomingBaseVersion = getIncomingVersion('categoría', incomingCategory);
const existingCategory = await Category.findOne({
where: { id: incomingCategory.id, userId },
transaction
});
if (!existingCategory) {
await Category.create({
id: incomingCategory.id,
userId,
encrypted_name: incomingCategory.encrypted_name,
isDeleted: Boolean(incomingCategory.isDeleted),
serverVersion: 1
}, { transaction });
continue;
}
const serverVersion = existingCategory.serverVersion || 1;
if (incomingBaseVersion === serverVersion) {
await existingCategory.update({
encrypted_name: incomingCategory.encrypted_name,
isDeleted: Boolean(incomingCategory.isDeleted),
serverVersion: serverVersion + 1
}, { transaction });
} else {
// server wins for categories; do not create conflict copies for categories
}
}
for (const incomingNote of incomingNotes) {
if (!incomingNote.id || !incomingNote.encrypted_title || !incomingNote.encrypted_body) {
throw new Error('Cada nota debe incluir id, encrypted_title y encrypted_body');
}
const incomingBaseVersion = getIncomingVersion('nota', incomingNote);
const existingNote = await Note.findOne({
where: { id: incomingNote.id, userId },
transaction
});
if (!existingNote) {
await Note.create({
id: incomingNote.id,
userId,
categoryId: incomingNote.categoryId || null,
encrypted_title: incomingNote.encrypted_title,
encrypted_body: incomingNote.encrypted_body,
position: incomingNote.position ?? 0,
isDeleted: Boolean(incomingNote.isDeleted),
serverVersion: 1
}, { transaction });
continue;
}
const serverVersion = existingNote.serverVersion || 1;
if (incomingBaseVersion === serverVersion) {
await existingNote.update({
categoryId: incomingNote.categoryId || null,
encrypted_title: incomingNote.encrypted_title,
encrypted_body: incomingNote.encrypted_body,
position: incomingNote.position ?? existingNote.position,
isDeleted: Boolean(incomingNote.isDeleted),
serverVersion: serverVersion + 1
}, { transaction });
} else if (serverVersion > incomingBaseVersion) {
// Server version is newer -> create a new note with incoming content so user sees a duplicate
await Note.create({
id: randomUUID(),
userId,
categoryId: incomingNote.categoryId || null,
encrypted_title: incomingNote.encrypted_title,
encrypted_body: incomingNote.encrypted_body,
position: incomingNote.position ?? 0,
isDeleted: Boolean(incomingNote.isDeleted),
serverVersion: 1
}, { transaction });
} else {
throw new Error(`serverVersion inválida en la nota ${incomingNote.id}`);
}
}
});
const [pulledCategories, pulledNotes] = await Promise.all([
Category.findAll({
where: {
userId,
updatedAt: { [Op.gt]: lastSyncAt }
},
order: [['updatedAt', 'ASC']]
}),
Note.findAll({
where: {
userId,
updatedAt: { [Op.gt]: lastSyncAt }
},
order: [['updatedAt', 'ASC']]
})
]);
return {
serverTimestamp: new Date().toISOString(),
synced: true,
changes: {
categories: pulledCategories.map(normalizeCategory),
notes: pulledNotes.map(normalizeNote)
}
};
}
}
module.exports = new SyncService();