Este artículo forma parte de la serie "Desarrollando Fluento". Aquí tienes el índice completo:
- Idea y presentación del proyecto
- Arquitectura y Funcionalidad del MVP
- Implementación del backend (Este artículo)
- Próximamente: Algorítmos e implementación de IA
¡Bienvenidos a un nuevo artículo de la serie Construyendo Fluento! En este artículo vamos a ver el diseño e implementación del backend de la plataforma. Veremos tanto los diferentes endpoints que hemos definido para la API Rest como el modelo de base de datos que nos permita gestionar correctamente los requerimientos de la plataforma.
Te recomiendo visitar el anterior artículo para ver las funcionalidades que habíamos definido para el MVP. Vamos a recorrer cada una de ellas y explicar la implementación realizada.
También te recomiendo acceder al repositorio en Github del proyecto en la rama backend
para ver el código que teníamos en el momento de escribir este artículo. A lo largo del artículo iremos pegando trozos de código relevantes pero sería interesante que puedas ver el código en su totalidad.
Login y Registro
Como ya comentamos, usaremos Supabase para la gestión de los usuarios. Por esta razón, es muy probable que el login y registro de usuarios lo gestionemos directamente con el cliente de Supabase desde la app y que no necesitemos atacar a un endpoint de la API como tal. En cualquier caso, y aunque solo sea para poder probar la API y facilitar las tareas de debug, sí que hemos implementado estos 2 endpoints:
- POST /auth/login
- POST /auth/register
El funcionamiento es sencillo. Ambos reciben un body con un email y contraseña. El de registro además recibe un nombre. Con esto y en cada correspondiente servicio utilizamos el cliente de supabase para identificar o registrar al usuario.
enum LoginError {
EMAIL_NOT_CONFIRMED = "email_not_confirmed",
INVALID_CREDENTIALS = "invalid_credentials",
}
export const loginService = async (
email: string,
password: string,
): Promise<LoginResponse> => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.code === LoginError.EMAIL_NOT_CONFIRMED) {
throw new CustomError({
message: "El correo electrónico no ha sido confirmado",
statusCode: 400,
});
}
if (error.code === LoginError.INVALID_CREDENTIALS) {
throw new CustomError({
message: "Credenciales inválidas",
statusCode: 400,
});
}
throw new CustomError({
message: "Error al iniciar sesión",
statusCode: 401,
});
}
return {
access_token: data.session.access_token,
refresh_token: data.session.refresh_token,
};
};
export const registerService = async (
email: string,
name: string,
password: string,
): Promise<BasicResponse> => {
const user = await prisma.user.findUnique({
where: {
email: email,
},
});
if (user) {
throw new CustomError({
message: "El usuario ya existe",
statusCode: 400,
});
}
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) {
throw new CustomError({
message: "Error al crear el usuario",
statusCode: 400,
});
}
await prisma.user.create({
data: {
email: email,
name: name,
role: Role.USER,
sub: data.user.id,
isPremium: false,
},
});
return successResponse();
};
En el Login devolvemos el token que identifica al usuario y que deberá ser enviado como header de autenticación en el resto de llamadas que se realicen.
Cuando se registra un nuevo usuario en Supabase, también creamos nuestro correspondiente usuario en la tabla users
Este modelo User cuenta con una columna sub
en la que guardaremos el id del usuario Supabase. Esto nos permitirá luego relacionar el usuario de Supabase con el nuestro.
Autenticación y Autorización
Los métodos de Login y Registro serán los únicos públicos en el sistema. El resto de llamadas solo podrán ser invocadas por un usuario autenticado. Para ello hemos creado un método authenticate
que se encarga de realizar este verificación.
export const authenticate = async (
request: Request,
requiredRole: Role,
): Promise<string> => {
try {
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1];
const payload = decodeToken(token);
if (payload.exp < Date.now() / 1000) {
throw new CustomError({
message: "Token expirado.",
statusCode: 401,
});
}
const user = await prisma.user.findUnique({
where: {
sub: payload.sub,
},
});
if (!user || user.role !== requiredRole) {
throw new CustomError({
message: "No tienes permiso para acceder a este recurso.",
statusCode: 403,
});
}
return user.id;
} catch (error) {
if (error instanceof CustomError) throw error;
throw new CustomError({
message: "Token inválido o expirado.",
statusCode: 401,
});
}
};
Cada controlador que represente un método privado, lo primero que hará es invocar este método para realizar este verificación, retornando el correspondiente error en caso de producirse o el id del usuario en caso de que todo haya salido bien. Por ejemplo, el controlador para el GET del Explora:
export async function GetExploreController(request: Request) {
try {
await authenticate(request, Role.USER);
const response = await getExploreService();
return apiSuccess(response);
} catch (error) {
if (error instanceof CustomError) {
return apiError(error);
}
return apiError(
new CustomError({
message:
"Se ha producido un error inesperado al obtener las categorías",
}),
);
}
}
Explora
Vamos a pasar ahora al Explora. Esta es la sección en la que el usuario puede ver las listas públicas, aquellas que son gestionadas por la propia plataforma y aptas para ser utilizadas por cualquier usuario. El requerimiento en esta parte es recuperar todas estas listas separadas por categorías, para que la app pueda renderizar una vista al usuario que le permita visualizar todo este contenido de una forma amigable. Por ello implementamos el endpoint GET /explore
que nos devolverá dicha respuesta. Necesitamos por tanto 2 nuevas tablas en nuestra base de datos: Category
y List
.
model Category {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relaciones
lists List[]
}
model List {
id String @id @default(uuid())
name String
description String?
imageUrl String?
difficulty Difficulty
topic String?
grammarStructures String?
totalUnits Int @default(0)
creationStatus CreationStatus @default(IN_PROGRESS)
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relaciones
category Category? @relation(fields: [categoryId], references: [id])
categoryId String?
creator User? @relation(fields: [creatorId], references: [id])
creatorId String?
savedBy User[] @relation("UserSavedLists")
units Unit[]
sessions Session[]
}
Las listas vemos que se relacionan con una categoría de forma opcional. Esto es así porque este mecanismo solo aplicará a las listas públicas. Las privadas, aquellas que un usuario Premium crea pasa sí mismo, no pertenecerán a ninguna categoría. Además contamos con un booleano isPublic
que nos indica si una lista es pública o no. Esto nos facilitará futuras comprobaciones.
Una vez definidas estas tablas, el servicio del explora simplemente debe recuperar el listado de categorías con todas sus listas.
export async function getExploreService(): Promise<GetExploreResponse> {
const categories = await prisma.category.findMany({
include: {
lists: {
where: {
isPublic: true,
creationStatus: "COMPLETED",
},
select: {
id: true,
name: true,
description: true,
imageUrl: true,
difficulty: true,
totalUnits: true,
},
},
},
});
return {
categories: categories.map((category) => ({
id: category.id,
name: category.name,
lists: category.lists.map((list) => ({
id: list.id,
name: list.name,
description: list.description || "",
imageUrl: list.imageUrl || "",
difficulty: list.difficulty,
totalUnits: list.totalUnits,
})),
})),
};
}
Cabe destacar que no estamos metiendo paginación ni filtrado. En esta primera versión devolveremos todo el contenido siempre. Al ser un contenido que gestionaremos nosotros mismos, esto no tiene porque ser un problema. Incorporaremos estos mecanismos cuando exista cierto volumen que lo requiera.
Usuario
Continuamos con la recuperación de los datos del usuario. Cuando la aplicación arranque teniendo un usuario loggeado, es importante que recupere la información de dicho usuario, para poder renderizar su información o, especialmente, para poder evaluar si es un usuario Premium o no y actuar en consecuencia. Para ello, definimos el método GET /user
que nos devuelve esta información.
export async function getUserService(userId: string): Promise<GetUserResponse> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
isPremium: true,
},
});
if (!user) {
throw new CustomError({
message: "Usuario no encontrado",
statusCode: 404,
});
}
return {
id: user.id,
name: user.name,
email: user.email,
isPremium: user.isPremium,
};
}
Listas
La gestión de las listas es uno de los pilares fundamentales de este MVP. Vamos a ver el conjunto de métodos que definimos para poder implementar los requerimientos establecidos.
Crear lista
Un usuario Premium podrá crear sus propias listas privadas. Para ello implementamos el método POST /lists, que se encargará de crear dicha entidad List
siempre que el usuario autenticado tenga el campo isPremium
a true.
export async function createListService(
userId: string,
request: CreateListRequest,
): Promise<CreateListResponse> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isPremium: true },
});
if (!user.isPremium) {
throw new CustomError({
message: "Necesitas ser usuario Premium para crear listas",
type: "NEED_PREMIUM",
statusCode: 403,
});
}
const list = await prisma.list.create({
data: {
name: request.name,
difficulty: request.difficulty,
topic: request.topic,
grammarStructures: request.grammarStructures,
creationStatus: CreationStatus.PENDING,
isPublic: false,
creatorId: user.id,
},
});
//TODO: Migrar a un job
const updatedList = await generateListUnitsService(list.id, userId);
return {
id: updatedList.id,
name: updatedList.name,
topic: updatedList.topic,
grammarStructures: updatedList.grammarStructures,
difficulty: updatedList.difficulty as Difficulty,
creationStatus: updatedList.creationStatus as CreationStatus,
};
}
A destacar en este servicio está la creación de las unidades de la lista, que como podemos ver lo delegamos a otro servicio (generateListUnitsService
). Esto lo veremos más en detalle en otro artículo, ya que este servicio lo que hace básicamente es invocar la IA para la generación de las frases de la lista. El siguiente artículo será específico para tratar esta parte.
También es importante destacar en esta punto que este método para la generación de la lista se está lanzando de forma síncrona, lo cual no es conveniente ya que puede requerir cierto tiempo e incluso necesitar un reintento si algo falla. Tenemos pendiente implementar un sistema de colas que haremos y explicaremos más adelante.
Recuperar listas
Para poder mostrar al usuario las listas que ha creado, se implementa el método GET /lists, que simplemente recupera y devuelve dichas listas. Al igual que nos ocurría en el explora, aquí deberíamos incorporar un mecanismo de filtrado y paginación, sobre todo lo segundo ya que esto es algo que lo gestiona directamente el usuario y podría darse el caso de que cree muchas listas y que sea conveniente entregarlas paginadas. Dejamos pendiente esa tarea por el momento.
export async function getMyListsService(
userId: string,
): Promise<GetMyListsResponse> {
//TODO: Migrar a una respuesta paginada y añadir filtros
const lists = await prisma.list.findMany({
where: {
creatorId: userId,
},
});
return {
lists: lists.map((list) => ({
id: list.id,
name: list.name,
description: list.description || "",
imageUrl: list.imageUrl || "",
difficulty: list.difficulty as Difficulty,
totalUnits: list.totalUnits,
creationStatus: list.creationStatus,
})),
};
}
Detalle de lista
Los usuarios deberán poder acceder a cualquier de las listas y ver su información así como los posibles resultados de su actividad en esa lista, en caso de que la hubiera. Para todo ello, implementamos el método GET /lists/:listId, que será el responsable de generar esta respuesta.
export interface GetListDetailResponse {
id: string;
name: string;
description: string;
imageUrl: string;
difficulty: Difficulty;
topic: string;
grammarStructures: string;
totalUnits: number;
isSaved: boolean;
creationStatus: CreationStatus;
userProgress: {
practicedUnits: number;
passedUnits: number;
averageScore: number;
};
}
Para poder dar esta respuesta tenemos que introducir 2 nuevos modelos en nuestra base de datos, una que ya mencionamos: Session
y uno nuevo: Result
. Session
será la tabla que registre la actividad de un usuario en una lista. Result
será cada una de las evaluaciones que se realicen dentro de una Session
, o dicho de otra forma, cada resultado de un usuario al realizar una unidad dentro de una lista.
model Session {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relaciones
user User @relation(fields: [userId], references: [id])
userId String
list List @relation(fields: [listId], references: [id])
listId String
results Result[]
}
model Result {
id String @id @default(uuid())
score Int // 1-4
createdAt DateTime @default(now())
// Relaciones
session Session @relation(fields: [sessionId], references: [id])
sessionId String
unit Unit @relation(fields: [unitId], references: [id])
unitId String
user User @relation(fields: [userId], references: [id])
userId String
}
Con estas 2 tablas podremos llevar control de la actividad de cada usuario en las listas que practique.
Por tanto, volviendo a la respuesta del detalle de la lista, lo que tendremos que hacer es recuperar la sesión de la lista e incluir los resultados. Con esto ya tendremos toda la información que necesitamos para poder generar dicho objeto userProgress
export async function getListDetailService(
listId: string,
userId: string,
): Promise<GetListDetailResponse> {
// -------Recuperación de la lista y validaciones. Omitimos código para abreviar------------
const session = await prisma.session.findFirst({
where: {
listId,
userId,
},
include: {
results: {
include: {
unit: true,
},
},
},
});
const savedList = await prisma.list.findFirst({
where: {
id: listId,
savedBy: {
some: {
id: userId,
},
},
},
});
const bestResults = session?.results.reduce(
(acc, result) => {
if (!acc[result.unit.id] || acc[result.unit.id].score < result.score) {
acc[result.unit.id] = result;
}
return acc;
},
{} as Record<string, (typeof session.results)[0]>,
);
const uniqueBestResults = bestResults ? Object.values(bestResults) : [];
const userProgress = {
practicedUnits: uniqueBestResults.length,
passedUnits: uniqueBestResults.filter((result) => result.score >= 3).length,
averageScore:
uniqueBestResults.reduce((acc, result) => acc + result.score, 0) /
uniqueBestResults.length,
};
return {
id: list.id,
name: list.name,
description: list.description || "",
imageUrl: list.imageUrl || "",
difficulty: list.difficulty as Difficulty,
topic: list.topic,
grammarStructures: list.grammarStructures,
totalUnits: list.totalUnits,
isSaved: savedList ? true : false,
creationStatus: list.creationStatus as CreationStatus,
userProgress,
};
}
Listas guardadas
Otra funcionalidad contemplada en el MVP es que el usuario pueda guardar las listas que quiera, para tenerlas más accesibles. Para ello definimos 3 nuevos endpoints:
- GET /lists/saved: Recupera las listas guardadas
- POST /lists/:listId/save: Guarda la lista especificada
- DELETE /lists/:listId/save: Elimina de guardados la lista especificada
La implementación no tiene mayor complejidad. Son simples peticiones a la base de datos para recuperar o insertar los registros correspondientes.
Recuperar la sesión
Por último, para terminar con esta parte de las listas, tenemos un método que nos permite recuperar la sesión asignada a una lista: GET /lists/:listId/session
Básicamente, lo que haremos aquí es comprobar si existe dicha sesión y en ese caso devolverla. En caso contrario, creamos el registro y lo devolvemos.
export async function getListSessionService(
listId: string,
userId: string,
): Promise<GetListSessionResponse> {
const list = await prisma.list.findUnique({
where: { id: listId },
});
if (!list) {
throw new CustomError({
message: "La lista no existe",
statusCode: 404,
});
}
if (!list.isPublic && list.creatorId !== userId) {
throw new CustomError({
message: "No tienes permiso para acceder a esta lista",
statusCode: 403,
});
}
// Buscar una sesión existente o crear una nueva
let session = await prisma.session.findFirst({
where: {
listId,
userId,
},
});
if (!session) {
session = await prisma.session.create({
data: {
listId,
userId,
},
});
}
const nextUnit = await generateNextUnit(session.id);
return {
sessionId: session.id,
listId: list.id,
listName: list.name,
nextUnit: {
id: nextUnit.id,
answer: {
text: nextUnit.answerText,
audio: nextUnit.answerAudio,
},
question: {
text: nextUnit.questionText,
audio: nextUnit.questionAudio,
},
responseTime: nextUnit.responseTime,
},
};
}
En la respuesta que damos, destaca el objeto nextUnit
, que será el que indique la siguiente frase que el usuario debe practicar.
export interface GetListSessionResponse {
sessionId: string;
listId: string;
listName: string;
nextUnit: {
id: string;
question: {
text: string;
audio: string;
};
answer: {
text: string;
audio: string;
};
responseTime: number;
};
}
Para ello, podemos ver que en el servicio contamos con una última llamada al método generateNextUnit
. Este método será el que implemente el algoritmo basado en Anki para generar la siguiente unidad que tenga mayor interés para el usuario en el momento actual, basado en la actividad que ha ido realizando hasta entonces. Esto, al igual que la implementación de la IA, lo veremos en detalle en el siguiente artículo. En este punto quedémonos con que tenemos un método que nos devuelve dicha información.
Práctica
Y para terminar, vamos a ver la parte en la que el usuario practica en una de las listas. Terminamos el anterior punto con un método que nos permitía recuperar la sesión de una lista. Esta llamada será la que la app tendrá que hacer cuando el usuario quiera empezar a practicar. A partir de la información obtenida en el nextUnit
, podrá realizar la primera iteración en la lista.
Ahora bien, para que el usuario pueda iterar correctamente por las diferentes unidades debemos incorporar 2 métodos más.
Evaluar respuesta
Cuando el usuario finalice una unidad y, por tanto, tengamos el audio con su respuesta, deberemos evaluar dicha respuesta y emitir una puntuación. Para esto implementamos el método POST /sessions/:sessionId/units/:unitId/evaluate que recibe como body el fichero de audio. Este método realizará 2 cosas: convertir el audio a texto y, posteriormente, comparar dicho texto con la respuesta correcta para devolver un score. Para estas 2 acciones, usaremos de nuevo modelos de IA que detallaremos en el siguiente artículo.
export async function evaluateAnswerService(
sessionId: string,
unitId: string,
userId: string,
audioFile: File,
): Promise<EvaluateAnswerResponse> {
const session = await prisma.session.findUnique({
where: {
id: sessionId,
userId: userId,
},
include: {
list: {
include: {
units: {
where: {
id: unitId,
},
},
},
},
},
});
if (!session) {
throw new CustomError({
message: "Sesión no encontrada",
statusCode: 404,
});
}
const unit = session.list.units.find((unit) => unit.id === unitId);
if (!unit) {
throw new CustomError({
message: "La unidad no pertenece a la lista de la sesión",
statusCode: 404,
});
}
const transcription = await audioToText(audioFile);
const score = await evaluateAnswer(unit.answerText, transcription);
return { score };
}
Enviar la puntuación
El anterior método nos permitía calcular la puntuación obtenida en la unidad pero no se encarga de guardar la información en el sistema. Para esto creamos el endpoint POST /sessions/:sessionId/units/:unitId/evaluate que será el que crea el registro Result
y ejecuta de nuevo el algoritomo que nos da la siguiente unidad.
Si lo pensamos detenidamente, este enfoque puede paracer no tener mucho sentido. Lo natural sería que el anterior endpoint, el que calcula la evaluación, ya se encargue también de guardar el resultado y calcular la siguiente unidad, evitando tener que realizar 2 llamadas diferentes. Sin embargo, el hacerlo en 2 llamdas tiene su explicación. En esta primera versión de la app, la evaluación de la respuesta del usuario la haremos en el back, como acabamos de ver. Pero la idea a futuro es poder llevar esta evaluación directamente al lado del cliente, ejecutando modelos de IA directamente en el dispositivo para, principalmente, evitar el coste que esto supone. Cuando eso sea así, necesitaremos recibir en el back el resultado de la evaluación realizada para poder registrarla y continuar. Por esa razón mantenemos ya esta implementación en 2 pasos, pensando en esta migración a futuro.
Fin
Con esto damos por finalizado este artículo en el que hemos explicado la implementación del back para el MVP de Fluento. Hemos recorrido los diferentes métodos implementados parándonos en aquellas partes que requieren más atención. En el siguiente artículo entraremos en detalle en los diferentes algoritmos implementados, como la evaluación de las respuestas o la obtención de la siguiente unidad a practicar. Como siempre, cualquier duda o sugerencia, puedes contactarme a través de:
¡Nos vemos en el siguiente artículo!