feat: implement account management features

- Added services and use cases for managing account details, including changing passwords and updating profiles.
- Introduced error handling for duplicate emails and password mismatches.
- Created controllers for handling account-related requests in the Express framework.
- Updated API routes to include new account management endpoints.
- Enhanced the account profile schema to support email updates.
- Implemented tests for account management functionalities to ensure reliability and correctness.
This commit is contained in:
David Arranz 2026-08-17 23:30:41 +02:00
parent a4f82decc6
commit fbf26d4745
27 changed files with 1358 additions and 16 deletions

View File

@ -187,6 +187,44 @@ export async function findCompanyRow(database: Sequelize, companyId: string) {
return rows[0] ?? null;
}
export async function findAccountRow(database: Sequelize, accountId: string) {
const rows = await database.query<{
id: string;
email: string;
password_hash: string;
name: string;
avatar_url: string | null;
language_code: string;
status: string;
}>("SELECT * FROM identity_accounts WHERE id = :accountId", {
replacements: { accountId },
type: QueryTypes.SELECT,
});
return rows[0] ?? null;
}
export async function updateAccountStatus(
database: Sequelize,
params: {
accountId: string;
status: "active" | "disabled" | "pending_verification";
}
) {
const queryInterface = database.getQueryInterface();
await queryInterface.bulkUpdate(
"identity_accounts",
{
status: params.status,
updated_at: new Date(),
},
{
id: params.accountId,
}
);
}
export async function loginByApi(params: {
request: (
path: string,

View File

@ -0,0 +1,481 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import bcrypt from "bcrypt";
import {
buildCompanyHeaders,
createTestAccount,
findAccountRow,
loginByApi,
updateAccountStatus,
} from "./http-test-fixtures.ts";
import { createHttpTestRuntime } from "./http-test-runtime.ts";
type ProblemDetails = {
title?: string;
detail?: string;
status?: number;
errors?: Array<{ field?: string; path?: string; message?: string }>;
};
type LoginResponseBody = {
access_token: string;
refresh_token: string;
};
type AccountResponseBody = {
id: string;
email: string;
name: string;
avatar_url: string | null;
language_code: string;
status: "active" | "disabled" | "pending_verification";
};
test("GET /api/v1/identity/account devuelve el perfil completo de la cuenta autenticada", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime, {
name: "Ada Lovelace",
languageCode: "en",
});
const response = await runtime.request("/api/v1/identity/account", {
headers: buildCompanyHeaders({
accessToken: account.accessToken,
}),
});
assert.equal(response.status, 200);
assert.deepEqual(response.body, {
id: account.id,
email: account.email,
name: "Ada Lovelace",
avatar_url: null,
language_code: "en",
status: "active",
} satisfies AccountResponseBody);
});
test("PATCH /api/v1/identity/account actualiza múltiples campos y persiste los cambios", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime, {
name: "Original Name",
languageCode: "es",
});
const response = await runtime.request("/api/v1/identity/account", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
name: "Updated Name",
email: `updated-${randomUUID()}@example.com`,
avatar_url: "https://cdn.example.com/avatar.png",
language_code: "en",
}),
});
assert.equal(response.status, 200);
const body = getAccountBody(response.body);
assert.equal(body.name, "Updated Name");
assert.equal(body.avatar_url, "https://cdn.example.com/avatar.png");
assert.equal(body.language_code, "en");
const persistedAccount = await findAccountRow(runtime.database, account.id);
assert.ok(persistedAccount);
assert.equal(persistedAccount.name, "Updated Name");
assert.equal(persistedAccount.email, body.email);
assert.equal(persistedAccount.avatar_url, "https://cdn.example.com/avatar.png");
assert.equal(persistedAccount.language_code, "en");
});
test("PATCH /api/v1/identity/account permite limpiar avatar_url con null explícito", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime, {
name: "Avatar Account",
});
await runtime.request("/api/v1/identity/account", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
avatar_url: "https://cdn.example.com/avatar-before.png",
}),
});
const clearResponse = await runtime.request("/api/v1/identity/account", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
avatar_url: null,
}),
});
assert.equal(clearResponse.status, 200);
assert.equal(getAccountBody(clearResponse.body).avatar_url, null);
const persistedAccount = await findAccountRow(runtime.database, account.id);
assert.ok(persistedAccount);
assert.equal(persistedAccount.avatar_url, null);
});
test("PATCH /api/v1/identity/account con body vacío conserva el perfil sin cambios", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime, {
name: "No Changes",
languageCode: "es",
});
const response = await runtime.request("/api/v1/identity/account", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({}),
});
assert.equal(response.status, 200);
assert.deepEqual(response.body, {
id: account.id,
email: account.email,
name: "No Changes",
avatar_url: null,
language_code: "es",
status: "active",
} satisfies AccountResponseBody);
});
test("PATCH /api/v1/identity/account devuelve 409 cuando el email ya existe", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const firstAccount = await createAuthenticatedAccount(runtime);
const secondAccount = await createTestAccount(runtime.database, {
email: `duplicate-${randomUUID()}@example.com`,
password: "Secret123!",
});
const response = await runtime.request("/api/v1/identity/account", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: firstAccount.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
email: secondAccount.email,
}),
});
assert.equal(response.status, 409);
});
test("PATCH /api/v1/identity/account devuelve 422 cuando el email es inválido", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime);
const response = await runtime.request("/api/v1/identity/account", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
email: "not-an-email",
}),
});
assert.equal(response.status, 422);
assert.equal(getProblemDetails(response.body).title, "Validation Failed");
});
test("POST /api/v1/identity/account/change-password cambia la contraseña y revoca los refresh tokens previos", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime, {
password: "CurrentPassword1!",
});
const response = await runtime.request("/api/v1/identity/account/change-password", {
method: "POST",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
current_password: "CurrentPassword1!",
new_password: "NextPassword123!",
}),
});
assert.equal(response.status, 200);
assert.deepEqual(response.body, {
success: true,
});
const refreshResponse = await runtime.request("/api/v1/identity/auth/refresh", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
refresh_token: account.refreshToken,
}),
});
assert.equal(refreshResponse.status, 401);
const oldPasswordLogin = await loginByApi({
request: runtime.request,
email: account.email,
password: "CurrentPassword1!",
});
assert.equal(oldPasswordLogin.status, 401);
const newPasswordLogin = await loginByApi({
request: runtime.request,
email: account.email,
password: "NextPassword123!",
});
assert.equal(newPasswordLogin.status, 200);
const persistedAccount = await findAccountRow(runtime.database, account.id);
assert.ok(persistedAccount);
assert.equal(await bcrypt.compare("NextPassword123!", persistedAccount.password_hash), true);
});
test("POST /api/v1/identity/account/change-password devuelve 422 si la contraseña actual es incorrecta", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime, {
password: "CurrentPassword1!",
});
const response = await runtime.request("/api/v1/identity/account/change-password", {
method: "POST",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
current_password: "WrongPassword1!",
new_password: "NextPassword123!",
}),
});
assert.equal(response.status, 422);
assert.equal(getProblemDetails(response.body).detail, "Current password is incorrect.");
});
test("POST /api/v1/identity/account/change-password devuelve 422 si la nueva contraseña no cumple el contrato", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createAuthenticatedAccount(runtime, {
password: "CurrentPassword1!",
});
const response = await runtime.request("/api/v1/identity/account/change-password", {
method: "POST",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
current_password: "CurrentPassword1!",
new_password: "short",
}),
});
assert.equal(response.status, 422);
});
test("una cuenta disabled no puede login, refresh, session ni usar self-service y vuelve a autenticarse tras reactivarse", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const disabledAccount = await createTestAccount(runtime.database, {
email: `disabled-${randomUUID()}@example.com`,
password: "Secret123!",
status: "disabled",
});
const disabledLoginResponse = await loginByApi({
request: runtime.request,
email: disabledAccount.email,
password: disabledAccount.password,
});
assert.equal(disabledLoginResponse.status, 401);
const account = await createAuthenticatedAccount(runtime, {
password: "CurrentPassword1!",
});
await updateAccountStatus(runtime.database, {
accountId: account.id,
status: "disabled",
});
const sessionResponse = await runtime.request("/api/v1/identity/auth/session", {
headers: buildCompanyHeaders({
accessToken: account.accessToken,
}),
});
assert.equal(sessionResponse.status, 401);
const currentAccountResponse = await runtime.request("/api/v1/identity/account", {
headers: buildCompanyHeaders({
accessToken: account.accessToken,
}),
});
assert.equal(currentAccountResponse.status, 401);
const updateAccountResponse = await runtime.request("/api/v1/identity/account", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
name: "Blocked Update",
}),
});
assert.equal(updateAccountResponse.status, 401);
const changePasswordResponse = await runtime.request("/api/v1/identity/account/change-password", {
method: "POST",
headers: {
...buildCompanyHeaders({
accessToken: account.accessToken,
}),
"content-type": "application/json",
},
body: JSON.stringify({
current_password: "CurrentPassword1!",
new_password: "NextPassword123!",
}),
});
assert.equal(changePasswordResponse.status, 401);
const refreshResponse = await runtime.request("/api/v1/identity/auth/refresh", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
refresh_token: account.refreshToken,
}),
});
assert.equal(refreshResponse.status, 401);
await updateAccountStatus(runtime.database, {
accountId: account.id,
status: "active",
});
const reenabledLoginResponse = await loginByApi({
request: runtime.request,
email: account.email,
password: "CurrentPassword1!",
});
assert.equal(reenabledLoginResponse.status, 200);
});
async function createAuthenticatedAccount(
runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>,
params?: {
email?: string;
password?: string;
name?: string;
languageCode?: string;
}
) {
const account = await createTestAccount(runtime.database, {
email: params?.email ?? `account-${randomUUID()}@example.com`,
password: params?.password ?? "Secret123!",
name: params?.name,
languageCode: params?.languageCode,
});
const loginResponse = await loginByApi({
request: runtime.request,
email: account.email,
password: account.password,
});
assert.equal(loginResponse.status, 200);
const loginBody = getLoginBody(loginResponse.body);
return {
...account,
accessToken: loginBody.access_token,
refreshToken: loginBody.refresh_token,
};
}
function getProblemDetails(body: unknown): ProblemDetails {
assert.equal(typeof body, "object");
assert.notEqual(body, null);
return body as ProblemDetails;
}
function getLoginBody(body: unknown): LoginResponseBody {
assert.equal(typeof body, "object");
assert.notEqual(body, null);
return body as LoginResponseBody;
}
function getAccountBody(body: unknown): AccountResponseBody {
assert.equal(typeof body, "object");
assert.notEqual(body, null);
return body as AccountResponseBody;
}

View File

@ -3,6 +3,9 @@
## Endpoints activos
```http
GET /identity/account
PATCH /identity/account
POST /identity/account/change-password
POST /identity/auth/login
POST /identity/auth/refresh
POST /identity/auth/logout
@ -120,6 +123,107 @@ export type LogoutResponseDTO = {
- para logout normal, enviar el `refresh_token` actual
- si logout falla por expiración de token, el frontend debe limpiar sesión igualmente
## GET `/identity/account`
### Headers
```http
Authorization: Bearer <access_token>
```
### Response
```ts
export type GetAccountProfileResponseDTO = {
id: string;
email: string;
name: string;
avatar_url: string | null;
language_code: string;
status: "active" | "disabled" | "pending_verification";
};
```
### Notas
- no debe llevar `X-Company-Id`
- representa la identidad global del usuario autenticado
- no depende de company activa
## PATCH `/identity/account`
### Headers
```http
Authorization: Bearer <access_token>
```
### Request
```ts
export type UpdateAccountProfileRequestDTO = {
email?: string;
name?: string;
avatar_url?: string | null;
language_code?: string;
};
```
### Response
```ts
export type UpdateAccountProfileResponseDTO = {
id: string;
email: string;
name: string;
avatar_url: string | null;
language_code: string;
status: "active" | "disabled" | "pending_verification";
};
```
### Reglas
- no debe llevar `X-Company-Id`
- PATCH real: campo omitido no modifica
- `avatar_url: null` limpia el avatar
- el cambio de email en V1 es inmediato y no requiere verificación adicional
- el backend valida unicidad de email
- `avatar_url` sigue siendo solo una referencia externa; no existe upload en `identity`
## POST `/identity/account/change-password`
### Headers
```http
Authorization: Bearer <access_token>
```
### Request
```ts
export type ChangeAccountPasswordRequestDTO = {
current_password: string;
new_password: string;
};
```
### Response
```ts
export type ChangeAccountPasswordResponseDTO = {
success: true;
};
```
### Reglas
- no debe llevar `X-Company-Id`
- exige `current_password`
- usa el hasher actual de `identity`
- revoca todos los refresh tokens persistidos de la cuenta
- no invalida de forma inmediata access tokens ya emitidos, porque hoy son stateless y no existe un subsistema de revocación inmediata de access tokens
## GET `/identity/auth/session`
### Headers
@ -181,6 +285,17 @@ export type AvailableCompanyDTO = {
```txt
400 -> request inválida o UUID de X-Company-Id mal formado
401 -> access token ausente/inválido/expirado o credenciales inválidas
401 -> cuenta no autenticable (`disabled` o `pending_verification`)
403 -> contexto tenant inválido, company no accesible o company no activa
409 -> conflicto de unicidad, por ejemplo email ya existente
500 -> error inesperado
```
## Administración global de cuentas
El dominio ya soporta enable/disable global de `Account`, pero no se documentan todavía endpoints HTTP administrativos operativos porque el runtime nuevo no dispone aún de un mecanismo inequívoco y cableado de autorización de administrador global.
Pendiente explícito:
- definir y cablear la autorización global real
- entonces exponer enable/disable por HTTP

View File

@ -82,6 +82,23 @@ Ejemplos futuros:
No forma parte de este diseño y no debe mezclarse con `/settings`.
Backend ya preparado actualmente para el subtree de usuario:
```txt
GET /identity/account
PATCH /identity/account
POST /identity/account/change-password
```
Regla de acceso:
```txt
/account
→ requiere usuario autenticado
→ no requiere empresa activa
→ no requiere X-Company-Id
```
No se introduce `scope: "company" | "user"` en V1.
---

View File

@ -13,6 +13,7 @@
Gestiona:
- `Account`
- self-service de `Account`
- login
- refresh
- logout
@ -27,10 +28,15 @@ No gestiona:
- la ficha operativa completa de empresa
- `companySlug` en auth
- el tenant dentro del access token
- upload físico de avatar
- autorización global de superadmin, porque no existe todavía un mecanismo runtime inequívoco
## Endpoints activos
```http
GET /identity/account
PATCH /identity/account
POST /identity/account/change-password
POST /identity/auth/login
POST /identity/auth/refresh
POST /identity/auth/logout
@ -43,6 +49,72 @@ El endpoint para companies accesibles es:
GET /companies/available
```
## Account self-service
`/identity/account` representa siempre la identidad global del usuario autenticado.
No requiere:
- `X-Company-Id`
- company activa
- contexto tenant
Sí requiere:
- access token válido
- cuenta autenticable
Campos editables en V1:
- `name`
- `email`
- `avatar_url`
- `language_code`
Reglas actuales:
- el cambio de email es inmediato y sin verificación adicional
- `avatar_url` es una referencia externa; `identity` no implementa subida de archivos
- cambio de contraseña exige `current_password`
- cambio de contraseña revoca todos los refresh tokens persistidos de la cuenta
- los access tokens ya emitidos siguen siendo válidos hasta expirar, porque hoy no existe un subsistema de invalidación inmediata de access tokens
## Estado global de Account
`Account.status` representa el estado global de autenticación de la identidad:
- `active`
- `disabled`
- `pending_verification`
Semántica runtime actual:
- `active` -> puede autenticarse
- `disabled` -> no puede login, refresh, session ni self-service
- `pending_verification` -> hoy también queda fuera de `canAuthenticate()`
Esto no debe mezclarse con:
- membership status
- company status
- accesibilidad tenant
## Administración global pendiente
El dominio ya soporta:
- `Account.enable()`
- `Account.disable()`
Sin embargo, el runtime actual no expone todavía endpoints HTTP administrativos para enable/disable global porque no existe un mecanismo inequívoco y cableado de "administrador global del ERP" en el módulo nuevo.
Estado actual:
- contratos de self-service completados
- `common/dto/account` reutilizado y completado
- `common/dto/company-users` sigue pendiente válido para futura administración por empresa
- enable/disable global queda documentado como pendiente hasta cerrar la autorización global real
Ese endpoint pertenece al flujo actual. Las reglas detalladas del flujo y los endpoints no válidos se mantienen en la documentación de arquitectura.
## Access token

View File

@ -1 +1,3 @@
export * from "./contracts";
export * from "./services";
export * from "./use-cases";

View File

@ -0,0 +1,6 @@
export class AccountEmailAlreadyExistsError extends Error {
public constructor(message = "Account email already exists.", options?: ErrorOptions) {
super(message, options);
this.name = "AccountEmailAlreadyExistsError";
}
}

View File

@ -0,0 +1,6 @@
export class CurrentPasswordMismatchError extends Error {
public constructor(message = "Current password is incorrect.", options?: ErrorOptions) {
super(message, options);
this.name = "CurrentPasswordMismatchError";
}
}

View File

@ -0,0 +1,2 @@
export * from "./account-email-already-exists.error";
export * from "./current-password-mismatch.error";

View File

@ -0,0 +1,95 @@
import type { ITransactionManager } from "@erp/core/api";
import type { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IAccountRepository } from "../contracts";
import { CurrentPasswordMismatchError } from "../services";
import { AccountNotFoundError, type IPasswordHasher, type IRefreshTokenRepository } from "../../authentication";
export type ChangeCurrentAccountPasswordInput = {
accountId: UniqueID;
currentPassword: string;
newPassword: string;
updatedAt: UtcDateTime;
};
export type ChangeCurrentAccountPasswordOutput = {
success: true;
};
export class ChangeCurrentAccountPasswordUseCase {
public constructor(
private readonly deps: {
accountRepository: IAccountRepository;
passwordHasher: IPasswordHasher;
refreshTokenRepository: IRefreshTokenRepository;
transactionManager: ITransactionManager;
}
) {}
public async execute(
input: ChangeCurrentAccountPasswordInput
): Promise<Result<ChangeCurrentAccountPasswordOutput, Error>> {
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const accountResult = await this.deps.accountRepository.findById(input.accountId, transaction);
if (accountResult.isFailure) {
return Result.fail(accountResult.error);
}
if (accountResult.data.isNone()) {
return Result.fail(
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
);
}
const account = accountResult.data.unwrap();
const passwordVerificationResult = await this.deps.passwordHasher.verify({
plainPassword: input.currentPassword,
passwordHash: account.passwordHash,
});
if (passwordVerificationResult.isFailure) {
return Result.fail(passwordVerificationResult.error);
}
if (!passwordVerificationResult.data) {
return Result.fail(new CurrentPasswordMismatchError());
}
const nextPasswordHashResult = await this.deps.passwordHasher.hash(input.newPassword);
if (nextPasswordHashResult.isFailure) {
return Result.fail(nextPasswordHashResult.error);
}
const changeResult = account.changePasswordHash({
passwordHash: nextPasswordHashResult.data,
updatedAt: input.updatedAt,
});
if (changeResult.isFailure) {
return Result.fail(changeResult.error);
}
if (changeResult.data) {
const updateResult = await this.deps.accountRepository.update(account, transaction);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
}
const revokeTokensResult = await this.deps.refreshTokenRepository.revokeAllByAccountId({
accountId: input.accountId,
revokedAt: input.updatedAt,
transaction,
});
if (revokeTokensResult.isFailure) {
return Result.fail(revokeTokensResult.error);
}
return Result.ok({ success: true as const });
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,57 @@
import { Result } from "@repo/rdx-utils";
import type { UniqueID } from "@repo/rdx-ddd";
import type { IAccountRepository } from "../contracts";
import { AccountNotFoundError } from "../../authentication";
export type GetCurrentAccountInput = {
accountId: UniqueID;
};
export type CurrentAccountOutput = {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
status: "active" | "disabled" | "pending_verification";
};
export class GetCurrentAccountUseCase {
public constructor(
private readonly deps: {
accountRepository: IAccountRepository;
}
) {}
public async execute(input: GetCurrentAccountInput): Promise<Result<CurrentAccountOutput, Error>> {
try {
const accountResult = await this.deps.accountRepository.findById(input.accountId);
if (accountResult.isFailure) {
return Result.fail(accountResult.error);
}
if (accountResult.data.isNone()) {
return Result.fail(
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
);
}
const account = accountResult.data.unwrap();
return Result.ok({
id: account.id.toPrimitive(),
email: account.email.toPrimitive(),
name: account.name.toPrimitive(),
avatarUrl: account.avatarUrl.match(
(avatarUrl) => avatarUrl.toPrimitive(),
() => null
),
languageCode: account.languageCode.toPrimitive(),
status: account.status.toPrimitive() as CurrentAccountOutput["status"],
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
}

View File

@ -0,0 +1,3 @@
export * from "./change-current-account-password.use-case";
export * from "./get-current-account.use-case";
export * from "./update-current-account.use-case";

View File

@ -0,0 +1,170 @@
import type { ITransactionManager } from "@erp/core/api";
import { EmailAddress, LanguageCode, Name, URLAddress, type UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { IAccountRepository } from "../contracts";
import { AccountEmailAlreadyExistsError } from "../services";
import { AccountNotFoundError } from "../../authentication";
import type { CurrentAccountOutput } from "./get-current-account.use-case";
export type UpdateCurrentAccountInput = {
accountId: UniqueID;
email?: string;
name?: string;
avatarUrl?: string | null;
languageCode?: string;
updatedAt: UtcDateTime;
};
export class UpdateCurrentAccountUseCase {
public constructor(
private readonly deps: {
accountRepository: IAccountRepository;
transactionManager: ITransactionManager;
}
) {}
public async execute(
input: UpdateCurrentAccountInput
): Promise<Result<CurrentAccountOutput, Error>> {
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const accountResult = await this.deps.accountRepository.findById(input.accountId, transaction);
if (accountResult.isFailure) {
return Result.fail(accountResult.error);
}
if (accountResult.data.isNone()) {
return Result.fail(
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
);
}
const account = accountResult.data.unwrap();
const patchResult = await this.buildPatchProps({
accountId: account.id,
currentEmail: account.email.toPrimitive(),
email: input.email,
name: input.name,
avatarUrl: input.avatarUrl,
languageCode: input.languageCode,
updatedAt: input.updatedAt,
transaction,
});
if (patchResult.isFailure) {
return Result.fail(patchResult.error);
}
const updateResult = account.updateProfile(patchResult.data);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
if (updateResult.data) {
const saveResult = await this.deps.accountRepository.update(account, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
}
return Result.ok({
id: account.id.toPrimitive(),
email: account.email.toPrimitive(),
name: account.name.toPrimitive(),
avatarUrl: account.avatarUrl.match(
(avatarUrl) => avatarUrl.toPrimitive(),
() => null
),
languageCode: account.languageCode.toPrimitive(),
status: account.status.toPrimitive() as CurrentAccountOutput["status"],
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
private async buildPatchProps(params: {
accountId: UniqueID;
currentEmail: string;
email?: string;
name?: string;
avatarUrl?: string | null;
languageCode?: string;
updatedAt: UtcDateTime;
transaction?: unknown;
}) {
const patchProps: {
email?: EmailAddress;
name?: Name;
avatarUrl?: Maybe<URLAddress>;
languageCode?: LanguageCode;
updatedAt: UtcDateTime;
} = {
updatedAt: params.updatedAt,
};
if (params.email !== undefined) {
const emailResult = EmailAddress.create(params.email);
if (emailResult.isFailure) {
return Result.fail(emailResult.error);
}
if (params.email !== params.currentEmail) {
const existingAccountResult = await this.deps.accountRepository.findByEmail(
emailResult.data,
params.transaction
);
if (existingAccountResult.isFailure) {
return Result.fail(existingAccountResult.error);
}
if (
existingAccountResult.data.isSome() &&
existingAccountResult.data.unwrap().id.toPrimitive() !== params.accountId.toPrimitive()
) {
return Result.fail(
new AccountEmailAlreadyExistsError(`Account email "${params.email}" already exists.`)
);
}
}
patchProps.email = emailResult.data;
}
if (params.name !== undefined) {
const nameResult = Name.create(params.name);
if (nameResult.isFailure) {
return Result.fail(nameResult.error);
}
patchProps.name = nameResult.data;
}
if (params.avatarUrl !== undefined) {
if (params.avatarUrl === null) {
patchProps.avatarUrl = Maybe.none();
} else {
const avatarUrlResult = URLAddress.create(params.avatarUrl);
if (avatarUrlResult.isFailure) {
return Result.fail(avatarUrlResult.error);
}
patchProps.avatarUrl = Maybe.some(avatarUrlResult.data);
}
}
if (params.languageCode !== undefined) {
const languageCodeResult = LanguageCode.create(params.languageCode);
if (languageCodeResult.isFailure) {
return Result.fail(languageCodeResult.error);
}
patchProps.languageCode = languageCodeResult.data;
}
return Result.ok(patchProps);
}
}

View File

@ -25,6 +25,7 @@ export interface IAccountCreateProps {
}
export type AccountProfilePatchProps = Partial<{
email: EmailAddress;
name: Name;
avatarUrl: Maybe<URLAddress>;
languageCode: LanguageCode;
@ -119,6 +120,11 @@ export class Account extends AggregateRoot<AccountInternalProps> {
let hasChanges = false;
if (patch.email && !this.props.email.equals(patch.email)) {
this.props.email = patch.email;
hasChanges = true;
}
if (patch.name && !this.props.name.equals(patch.name)) {
this.props.name = patch.name;
hasChanges = true;

View File

@ -5,7 +5,7 @@ export * from "./application";
export * from "./domain";
export * from "./infrastructure";
import { identityAuthRouter, models } from "./infrastructure";
import { identityAccountRouter, identityAuthRouter, models } from "./infrastructure";
import { buildIdentityDependencies, buildIdentityPublicServices } from "./infrastructure/di";
export * from "./infrastructure/persistence/sequelize";
@ -58,6 +58,7 @@ export const identityAPIModule: IModuleServer = {
async start(params) {
identityAuthRouter(params);
identityAccountRouter(params);
},
};

View File

@ -5,6 +5,14 @@ import {
IdentityCompanyAccessChecker,
} from "../../application";
import type { IAccountRepository } from "../../application/accounts";
import {
type ChangeCurrentAccountPasswordUseCase,
ChangeCurrentAccountPasswordUseCase as ChangeCurrentAccountPasswordUseCaseImpl,
type GetCurrentAccountUseCase,
GetCurrentAccountUseCase as GetCurrentAccountUseCaseImpl,
type UpdateCurrentAccountUseCase,
UpdateCurrentAccountUseCase as UpdateCurrentAccountUseCaseImpl,
} from "../../application/accounts";
import {
AccountAuthenticator,
type GetCurrentSessionUseCase,
@ -49,6 +57,14 @@ export type IdentityInternalDeps = {
getCurrentSession: () => GetCurrentSessionUseCase;
};
};
account: {
authenticateRequest: ReturnType<typeof identityAuthenticateRequest>;
useCases: {
getCurrentAccount: () => GetCurrentAccountUseCase;
updateCurrentAccount: () => UpdateCurrentAccountUseCase;
changeCurrentAccountPassword: () => ChangeCurrentAccountPasswordUseCase;
};
};
companyAccess: {
checker: IIdentityCompanyAccessChecker;
};
@ -132,6 +148,30 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
}),
},
},
account: {
authenticateRequest: identityAuthenticateRequest({
accessTokenVerifier,
accountRepository,
}),
useCases: {
getCurrentAccount: () =>
new GetCurrentAccountUseCaseImpl({
accountRepository,
}),
updateCurrentAccount: () =>
new UpdateCurrentAccountUseCaseImpl({
accountRepository,
transactionManager,
}),
changeCurrentAccountPassword: () =>
new ChangeCurrentAccountPasswordUseCaseImpl({
accountRepository,
passwordHasher,
refreshTokenRepository,
transactionManager,
}),
},
},
companyAccess: {
checker: companyAccessChecker,
},

View File

@ -0,0 +1,41 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { UtcDateTime } from "@repo/rdx-ddd";
import type { ChangeAccountPasswordRequestDTO, ChangeAccountPasswordResponseDTO } from "../../../../common";
import type { ChangeCurrentAccountPasswordUseCase } from "../../../application/accounts";
import { identityAccountApiErrorMapper } from "../identity-account-api-error-mapper";
export class ChangeCurrentAccountPasswordController extends ExpressController {
public constructor(
private readonly useCase: ChangeCurrentAccountPasswordUseCase,
private readonly nowFactory: () => UtcDateTime
) {
super();
this.errorMapper = identityAccountApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) {
return this.unauthorizedError("Unauthorized");
}
const dto = this.req.body as ChangeAccountPasswordRequestDTO;
const result = await this.useCase.execute({
accountId: user.userId,
currentPassword: dto.current_password,
newPassword: dto.new_password,
updatedAt: this.nowFactory(),
});
return result.match(
() =>
this.ok<ChangeAccountPasswordResponseDTO>({
success: true,
}),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,37 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { GetAccountProfileResponseDTO } from "../../../../common";
import type { GetCurrentAccountUseCase } from "../../../application/accounts";
import { identityAccountApiErrorMapper } from "../identity-account-api-error-mapper";
export class GetCurrentAccountController extends ExpressController {
public constructor(private readonly useCase: GetCurrentAccountUseCase) {
super();
this.errorMapper = identityAccountApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) {
return this.unauthorizedError("Unauthorized");
}
const result = await this.useCase.execute({
accountId: user.userId,
});
return result.match(
(data) =>
this.ok<GetAccountProfileResponseDTO>({
id: data.id,
email: data.email,
name: data.name,
avatar_url: data.avatarUrl,
language_code: data.languageCode,
status: data.status,
}),
(err) => this.handleError(err)
);
}
}

View File

@ -1,4 +1,7 @@
export * from "./change-current-account-password.controller";
export * from "./get-current-account.controller";
export * from "./get-current-session.controller";
export * from "./login-with-password.controller";
export * from "./logout.controller";
export * from "./refresh-session.controller";
export * from "./update-current-account.controller";

View File

@ -0,0 +1,48 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { UtcDateTime } from "@repo/rdx-ddd";
import type { UpdateAccountProfileRequestDTO, UpdateAccountProfileResponseDTO } from "../../../../common";
import type { UpdateCurrentAccountUseCase } from "../../../application/accounts";
import { identityAccountApiErrorMapper } from "../identity-account-api-error-mapper";
export class UpdateCurrentAccountController extends ExpressController {
public constructor(
private readonly useCase: UpdateCurrentAccountUseCase,
private readonly nowFactory: () => UtcDateTime
) {
super();
this.errorMapper = identityAccountApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) {
return this.unauthorizedError("Unauthorized");
}
const dto = this.req.body as UpdateAccountProfileRequestDTO;
const result = await this.useCase.execute({
accountId: user.userId,
email: dto.email,
name: dto.name,
avatarUrl: dto.avatar_url,
languageCode: dto.language_code,
updatedAt: this.nowFactory(),
});
return result.match(
(data) =>
this.ok<UpdateAccountProfileResponseDTO>({
id: data.id,
email: data.email,
name: data.name,
avatar_url: data.avatarUrl,
language_code: data.languageCode,
status: data.status,
}),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,53 @@
import {
ApiErrorMapper,
ConflictApiError,
NotFoundApiError,
type ErrorToApiRule,
ValidationApiError,
} from "@erp/core/api";
import { type DuplicateEntityError, isDuplicateEntityError } from "@erp/core/api";
import { AccountCannotBeUpdatedError } from "../../domain";
import { AccountEmailAlreadyExistsError, CurrentPasswordMismatchError } from "../../application/accounts";
import { AccountNotFoundError } from "../../application/authentication";
const accountNotFoundRule: ErrorToApiRule = {
priority: 120,
matches: (error) => error instanceof AccountNotFoundError,
build: (error) => new NotFoundApiError((error as AccountNotFoundError).message || "Account not found."),
};
const accountCannotBeUpdatedRule: ErrorToApiRule = {
priority: 120,
matches: (error) => error instanceof AccountCannotBeUpdatedError,
build: (error) =>
new ValidationApiError((error as AccountCannotBeUpdatedError).message || "Account cannot be updated."),
};
const currentPasswordMismatchRule: ErrorToApiRule = {
priority: 120,
matches: (error) => error instanceof CurrentPasswordMismatchError,
build: (error) =>
new ValidationApiError((error as CurrentPasswordMismatchError).message || "Current password is incorrect."),
};
const accountEmailAlreadyExistsRule: ErrorToApiRule = {
priority: 120,
matches: (error) => error instanceof AccountEmailAlreadyExistsError,
build: (error) =>
new ConflictApiError((error as AccountEmailAlreadyExistsError).message || "Account email already exists."),
};
const genericDuplicateEntityRule: ErrorToApiRule = {
priority: 110,
matches: isDuplicateEntityError,
build: (error) =>
new ConflictApiError((error as DuplicateEntityError).message || "Entity already exists."),
};
export const identityAccountApiErrorMapper = ApiErrorMapper.default()
.register(genericDuplicateEntityRule)
.register(accountEmailAlreadyExistsRule)
.register(currentPasswordMismatchRule)
.register(accountCannotBeUpdatedRule)
.register(accountNotFoundRule);

View File

@ -0,0 +1,54 @@
import { type NextFunction, type Request, type Response, Router } from "express";
import { type StartParams, validateRequest } from "@erp/core/api";
import {
ChangeAccountPasswordRequestSchema,
UpdateAccountProfileRequestSchema,
} from "../../../common";
import type { IdentityInternalDeps } from "../di";
import {
ChangeCurrentAccountPasswordController,
GetCurrentAccountController,
UpdateCurrentAccountController,
} from "./controllers";
import { buildUtcNow } from "./utils";
export const identityAccountRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
const deps = getInternal<IdentityInternalDeps>("identity").account;
const router = Router({ mergeParams: true });
router.use(deps.authenticateRequest);
router.get("/", (req: Request, res: Response, next: NextFunction) => {
const controller = new GetCurrentAccountController(deps.useCases.getCurrentAccount());
return controller.execute(req, res, next);
});
router.patch(
"/",
validateRequest(UpdateAccountProfileRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new UpdateCurrentAccountController(
deps.useCases.updateCurrentAccount(),
() => buildUtcNow()
);
return controller.execute(req, res, next);
}
);
router.post(
"/change-password",
validateRequest(ChangeAccountPasswordRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new ChangeCurrentAccountPasswordController(
deps.useCases.changeCurrentAccountPassword(),
() => buildUtcNow()
);
return controller.execute(req, res, next);
}
);
app.use(`${config.server.apiBasePath}/identity/account`, router);
};

View File

@ -1,3 +1,5 @@
export * from "./identity-account-api-error-mapper";
export * from "./identity-account.routes";
export * from "./controllers";
export * from "./identity-auth.routes";
export * from "./identity-auth-api-error-mapper";

View File

@ -1,7 +1,8 @@
import { LanguageCodeSchema, URLSchema } from "@erp/core";
import { EmailSchema, LanguageCodeSchema, URLSchema } from "@erp/core";
import { z } from "zod/v4";
export const UpdateAccountProfileRequestSchema = z.object({
email: EmailSchema.optional(),
name: z.string().min(1).optional(),
avatar_url: URLSchema.nullable().optional(),
language_code: LanguageCodeSchema.optional(),

View File

@ -1,9 +1,7 @@
import { z } from "zod/v4";
import type { z } from "zod/v4";
import { AccountProfileSchema } from "../shared";
export const GetAccountProfileResponseSchema = z.object({
account: AccountProfileSchema,
});
export const GetAccountProfileResponseSchema = AccountProfileSchema;
export type GetAccountProfileResponseDTO = z.infer<typeof GetAccountProfileResponseSchema>;

View File

@ -1,11 +1,7 @@
import { z } from "zod/v4";
import type { z } from "zod/v4";
import { AccountProfileSchema } from "../shared";
export const UpdateAccountProfileResponseSchema = z.object({
account: AccountProfileSchema,
});
export const UpdateAccountProfileResponseSchema = AccountProfileSchema;
export type UpdateAccountProfileResponseDTO = z.infer<
typeof UpdateAccountProfileResponseSchema
>;
export type UpdateAccountProfileResponseDTO = z.infer<typeof UpdateAccountProfileResponseSchema>;

View File

@ -1,4 +1,4 @@
import { EmailSchema, LanguageCodeSchema, OffsetDateTimeSchema, URLSchema } from "@erp/core";
import { EmailSchema, LanguageCodeSchema, URLSchema } from "@erp/core";
import { z } from "zod/v4";
export const AccountProfileSchema = z.object({
@ -8,8 +8,6 @@ export const AccountProfileSchema = z.object({
avatar_url: URLSchema.nullable(),
language_code: LanguageCodeSchema,
status: z.enum(["active", "disabled", "pending_verification"]),
created_at: OffsetDateTimeSchema,
updated_at: OffsetDateTimeSchema,
});
export type AccountProfileDTO = z.infer<typeof AccountProfileSchema>;