diff --git a/apps/server/src/testing/http/http-test-fixtures.ts b/apps/server/src/testing/http/http-test-fixtures.ts index 58a6ba70..d4d4d143 100644 --- a/apps/server/src/testing/http/http-test-fixtures.ts +++ b/apps/server/src/testing/http/http-test-fixtures.ts @@ -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, diff --git a/apps/server/src/testing/http/identity-account.http.test.ts b/apps/server/src/testing/http/identity-account.http.test.ts new file mode 100644 index 00000000..288fd1f8 --- /dev/null +++ b/apps/server/src/testing/http/identity-account.http.test.ts @@ -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>, + 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; +} diff --git a/docs/architecture/identity-auth-api.md b/docs/architecture/identity-auth-api.md index aac7371c..065f5fd0 100644 --- a/docs/architecture/identity-auth-api.md +++ b/docs/architecture/identity-auth-api.md @@ -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 +``` + +### 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 +``` + +### 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 +``` + +### 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 diff --git a/docs/settings/frontend-erp-settings-system-design.md b/docs/settings/frontend-erp-settings-system-design.md index 11d3d412..e9699ff6 100644 --- a/docs/settings/frontend-erp-settings-system-design.md +++ b/docs/settings/frontend-erp-settings-system-design.md @@ -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. --- diff --git a/modules/identity/README.md b/modules/identity/README.md index 5eee7447..4f1f5ac3 100644 --- a/modules/identity/README.md +++ b/modules/identity/README.md @@ -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 diff --git a/modules/identity/src/api/application/accounts/index.ts b/modules/identity/src/api/application/accounts/index.ts index 14371ab4..a6bb5e6d 100644 --- a/modules/identity/src/api/application/accounts/index.ts +++ b/modules/identity/src/api/application/accounts/index.ts @@ -1 +1,3 @@ export * from "./contracts"; +export * from "./services"; +export * from "./use-cases"; diff --git a/modules/identity/src/api/application/accounts/services/account-email-already-exists.error.ts b/modules/identity/src/api/application/accounts/services/account-email-already-exists.error.ts new file mode 100644 index 00000000..36205b31 --- /dev/null +++ b/modules/identity/src/api/application/accounts/services/account-email-already-exists.error.ts @@ -0,0 +1,6 @@ +export class AccountEmailAlreadyExistsError extends Error { + public constructor(message = "Account email already exists.", options?: ErrorOptions) { + super(message, options); + this.name = "AccountEmailAlreadyExistsError"; + } +} diff --git a/modules/identity/src/api/application/accounts/services/current-password-mismatch.error.ts b/modules/identity/src/api/application/accounts/services/current-password-mismatch.error.ts new file mode 100644 index 00000000..4223c167 --- /dev/null +++ b/modules/identity/src/api/application/accounts/services/current-password-mismatch.error.ts @@ -0,0 +1,6 @@ +export class CurrentPasswordMismatchError extends Error { + public constructor(message = "Current password is incorrect.", options?: ErrorOptions) { + super(message, options); + this.name = "CurrentPasswordMismatchError"; + } +} diff --git a/modules/identity/src/api/application/accounts/services/index.ts b/modules/identity/src/api/application/accounts/services/index.ts new file mode 100644 index 00000000..5926b08d --- /dev/null +++ b/modules/identity/src/api/application/accounts/services/index.ts @@ -0,0 +1,2 @@ +export * from "./account-email-already-exists.error"; +export * from "./current-password-mismatch.error"; diff --git a/modules/identity/src/api/application/accounts/use-cases/change-current-account-password.use-case.ts b/modules/identity/src/api/application/accounts/use-cases/change-current-account-password.use-case.ts new file mode 100644 index 00000000..63c3e1a7 --- /dev/null +++ b/modules/identity/src/api/application/accounts/use-cases/change-current-account-password.use-case.ts @@ -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> { + 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); + } + }); + } +} diff --git a/modules/identity/src/api/application/accounts/use-cases/get-current-account.use-case.ts b/modules/identity/src/api/application/accounts/use-cases/get-current-account.use-case.ts new file mode 100644 index 00000000..2cded295 --- /dev/null +++ b/modules/identity/src/api/application/accounts/use-cases/get-current-account.use-case.ts @@ -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> { + 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); + } + } +} diff --git a/modules/identity/src/api/application/accounts/use-cases/index.ts b/modules/identity/src/api/application/accounts/use-cases/index.ts new file mode 100644 index 00000000..d63afdf7 --- /dev/null +++ b/modules/identity/src/api/application/accounts/use-cases/index.ts @@ -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"; diff --git a/modules/identity/src/api/application/accounts/use-cases/update-current-account.use-case.ts b/modules/identity/src/api/application/accounts/use-cases/update-current-account.use-case.ts new file mode 100644 index 00000000..a2b06248 --- /dev/null +++ b/modules/identity/src/api/application/accounts/use-cases/update-current-account.use-case.ts @@ -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> { + 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; + 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); + } +} diff --git a/modules/identity/src/api/domain/accounts/account.aggregate.ts b/modules/identity/src/api/domain/accounts/account.aggregate.ts index 1d288933..aedd64a9 100644 --- a/modules/identity/src/api/domain/accounts/account.aggregate.ts +++ b/modules/identity/src/api/domain/accounts/account.aggregate.ts @@ -25,6 +25,7 @@ export interface IAccountCreateProps { } export type AccountProfilePatchProps = Partial<{ + email: EmailAddress; name: Name; avatarUrl: Maybe; languageCode: LanguageCode; @@ -119,6 +120,11 @@ export class Account extends AggregateRoot { 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; diff --git a/modules/identity/src/api/index.ts b/modules/identity/src/api/index.ts index 2a5a5519..22001b3f 100644 --- a/modules/identity/src/api/index.ts +++ b/modules/identity/src/api/index.ts @@ -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); }, }; diff --git a/modules/identity/src/api/infrastructure/di/identity.di.ts b/modules/identity/src/api/infrastructure/di/identity.di.ts index 1e14c5f8..d775b2d2 100644 --- a/modules/identity/src/api/infrastructure/di/identity.di.ts +++ b/modules/identity/src/api/infrastructure/di/identity.di.ts @@ -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; + 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, }, diff --git a/modules/identity/src/api/infrastructure/express/controllers/change-current-account-password.controller.ts b/modules/identity/src/api/infrastructure/express/controllers/change-current-account-password.controller.ts new file mode 100644 index 00000000..b15f1c8f --- /dev/null +++ b/modules/identity/src/api/infrastructure/express/controllers/change-current-account-password.controller.ts @@ -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({ + success: true, + }), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/identity/src/api/infrastructure/express/controllers/get-current-account.controller.ts b/modules/identity/src/api/infrastructure/express/controllers/get-current-account.controller.ts new file mode 100644 index 00000000..ac3e19e6 --- /dev/null +++ b/modules/identity/src/api/infrastructure/express/controllers/get-current-account.controller.ts @@ -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({ + id: data.id, + email: data.email, + name: data.name, + avatar_url: data.avatarUrl, + language_code: data.languageCode, + status: data.status, + }), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/identity/src/api/infrastructure/express/controllers/index.ts b/modules/identity/src/api/infrastructure/express/controllers/index.ts index 5f03061c..01652dab 100644 --- a/modules/identity/src/api/infrastructure/express/controllers/index.ts +++ b/modules/identity/src/api/infrastructure/express/controllers/index.ts @@ -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"; diff --git a/modules/identity/src/api/infrastructure/express/controllers/update-current-account.controller.ts b/modules/identity/src/api/infrastructure/express/controllers/update-current-account.controller.ts new file mode 100644 index 00000000..312a8b05 --- /dev/null +++ b/modules/identity/src/api/infrastructure/express/controllers/update-current-account.controller.ts @@ -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({ + id: data.id, + email: data.email, + name: data.name, + avatar_url: data.avatarUrl, + language_code: data.languageCode, + status: data.status, + }), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/identity/src/api/infrastructure/express/identity-account-api-error-mapper.ts b/modules/identity/src/api/infrastructure/express/identity-account-api-error-mapper.ts new file mode 100644 index 00000000..7d7bbab4 --- /dev/null +++ b/modules/identity/src/api/infrastructure/express/identity-account-api-error-mapper.ts @@ -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); diff --git a/modules/identity/src/api/infrastructure/express/identity-account.routes.ts b/modules/identity/src/api/infrastructure/express/identity-account.routes.ts new file mode 100644 index 00000000..268328ba --- /dev/null +++ b/modules/identity/src/api/infrastructure/express/identity-account.routes.ts @@ -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("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); +}; diff --git a/modules/identity/src/api/infrastructure/express/index.ts b/modules/identity/src/api/infrastructure/express/index.ts index 99dc45c1..7e75ca73 100644 --- a/modules/identity/src/api/infrastructure/express/index.ts +++ b/modules/identity/src/api/infrastructure/express/index.ts @@ -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"; diff --git a/modules/identity/src/common/dto/account/request/update-account-profile.request.dto.ts b/modules/identity/src/common/dto/account/request/update-account-profile.request.dto.ts index 57cfac60..1a33854d 100644 --- a/modules/identity/src/common/dto/account/request/update-account-profile.request.dto.ts +++ b/modules/identity/src/common/dto/account/request/update-account-profile.request.dto.ts @@ -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(), diff --git a/modules/identity/src/common/dto/account/response/get-account-profile.response.dto.ts b/modules/identity/src/common/dto/account/response/get-account-profile.response.dto.ts index f45dfa0e..1c0a8be7 100644 --- a/modules/identity/src/common/dto/account/response/get-account-profile.response.dto.ts +++ b/modules/identity/src/common/dto/account/response/get-account-profile.response.dto.ts @@ -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; diff --git a/modules/identity/src/common/dto/account/response/update-account-profile.response.dto.ts b/modules/identity/src/common/dto/account/response/update-account-profile.response.dto.ts index 679824d4..7c2cf7dd 100644 --- a/modules/identity/src/common/dto/account/response/update-account-profile.response.dto.ts +++ b/modules/identity/src/common/dto/account/response/update-account-profile.response.dto.ts @@ -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; diff --git a/modules/identity/src/common/dto/account/shared/account-profile.dto.ts b/modules/identity/src/common/dto/account/shared/account-profile.dto.ts index b684138c..c1a1ccd8 100644 --- a/modules/identity/src/common/dto/account/shared/account-profile.dto.ts +++ b/modules/identity/src/common/dto/account/shared/account-profile.dto.ts @@ -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;