Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .cursor/rules/type-and-file-names.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ The same conjugation rule applies in Portuguese and in English. Keep the languag
| --- | --- |
| List a collection | `{Verb}{Resources}Controller` / `{Verb}{Resources}UseCase` |
| Get one by id | `{Verb}{Resource}UseCase` (and matching controller) |
| Tests | One file per action; kebab-case of the same verb + resource stem |
| Tests | One file per type: `{TypeName}.test.ts` |

Type files are PascalCase and match the exported type. Test files use kebab-case of that stem (for example `lists-countries.test.ts`), not a different verb and not several actions in one file.
Type files are PascalCase and match the exported type. Test files use the same TitleCase name as the type (for example `ListaPaisesUseCase.test.ts`). Folders use camelCase when the name is compound (for example `src/domain/usuarioSessao`, `src/domain/faseSucessional`). Tests live under the same folder name as `src` (for example `test/unit/domain/usuarioSessao/`). Do not use kebab-case or TitleCase for those folders.

## When renaming

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Knex } from 'knex'

export async function run(knex: Knex): Promise<void> {
await knex.transaction(async trx => {
await trx.schema.createTable('usuarios_sessoes', table => {
table.uuid('id').primary().defaultTo(trx.raw('gen_random_uuid()'))

table.integer('usuario_id').notNullable()
table.foreign('usuario_id')
.references('id')
.inTable('usuarios')
table.index('usuario_id')

table.string('refresh_token_hash', 64).notNullable().unique()

table.timestamp('created_at').notNullable().defaultTo(trx.fn.now())
table.timestamp('last_used_at').notNullable()
table.timestamp('expires_at').notNullable()
})
})
}
19 changes: 19 additions & 0 deletions src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type Either } from '@/library/either/Either'

import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection'

interface Dependencies {
usuarioSessaoCollection: UsuarioSessaoCollection
}

export class ApagaUsuarioSessaoUseCase {
private readonly usuarioSessaoCollection: UsuarioSessaoCollection

constructor(dependencies: Dependencies) {
this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection
}

async execute(params: { id: string }): Promise<Either<Error, void>> {
return this.usuarioSessaoCollection.deleteById(params.id)
}
}
19 changes: 19 additions & 0 deletions src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type Either } from '@/library/either/Either'

import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection'

interface Dependencies {
usuarioSessaoCollection: UsuarioSessaoCollection
}

export class ApagaUsuarioSessoesUseCase {
private readonly usuarioSessaoCollection: UsuarioSessaoCollection

constructor(dependencies: Dependencies) {
this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection
}

async execute(params: { usuarioId: number }): Promise<Either<Error, void>> {
return this.usuarioSessaoCollection.deleteByUsuarioId(params.usuarioId)
}
}
52 changes: 52 additions & 0 deletions src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Either } from '@/library/either/Either'

import { type Attributes, UsuarioSessao } from './UsuarioSessao'
import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection'

interface Dependencies {
usuarioSessaoCollection: UsuarioSessaoCollection
now?: () => Date
}

export class BuscaUsuarioSessaoPorHashUseCase {
private readonly usuarioSessaoCollection: UsuarioSessaoCollection
private readonly now: () => Date

constructor(dependencies: Dependencies) {
this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection
this.now = dependencies.now ?? (() => new Date())
}

async execute(params: { refreshTokenHash: string }): Promise<Either<Error, Attributes | null>> {
const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(params.refreshTokenHash)
if (found.left()) {
return found
}
if (!found.value) {
return Either.right(null)
}

const expired = await this.deleteIfExpired(found.value)
if (expired.left()) {
return expired
}
if (expired.value) {
return Either.right(null)
}

return Either.right(found.value)
}

private async deleteIfExpired(session: Attributes): Promise<Either<Error, boolean>> {
if (!UsuarioSessao.expired(session.expiresAt, this.now())) {
return Either.right(false)
}

const deleted = await this.usuarioSessaoCollection.deleteById(session.id)
if (deleted.left()) {
return deleted
}

return Either.right(true)
}
}
52 changes: 52 additions & 0 deletions src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Either } from '@/library/either/Either'

import { type Attributes, UsuarioSessao } from './UsuarioSessao'
import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection'

interface Dependencies {
usuarioSessaoCollection: UsuarioSessaoCollection
now?: () => Date
}

export class BuscaUsuarioSessaoPorIdUseCase {
private readonly usuarioSessaoCollection: UsuarioSessaoCollection
private readonly now: () => Date

constructor(dependencies: Dependencies) {
this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection
this.now = dependencies.now ?? (() => new Date())
}

async execute(params: { id: string }): Promise<Either<Error, Attributes | null>> {
const found = await this.usuarioSessaoCollection.findById(params.id)
if (found.left()) {
return found
}
if (!found.value) {
return Either.right(null)
}

const expired = await this.deleteIfExpired(found.value)
if (expired.left()) {
return expired
}
if (expired.value) {
return Either.right(null)
}

return Either.right(found.value)
}

private async deleteIfExpired(session: Attributes): Promise<Either<Error, boolean>> {
if (!UsuarioSessao.expired(session.expiresAt, this.now())) {
return Either.right(false)
}

const deleted = await this.usuarioSessaoCollection.deleteById(session.id)
if (deleted.left()) {
return deleted
}

return Either.right(true)
}
}
64 changes: 64 additions & 0 deletions src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { randomUUID } from 'node:crypto'

import { type RefreshToken } from '@/library/auth/RefreshToken'
import { Either } from '@/library/either/Either'

import { type Attributes, UsuarioSessao } from './UsuarioSessao'
import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection'

interface Dependencies {
usuarioSessaoCollection: UsuarioSessaoCollection
refreshToken: RefreshToken
now?: () => Date
}

export class CriaUsuarioSessaoUseCase {
private readonly usuarioSessaoCollection: UsuarioSessaoCollection
private readonly refreshToken: RefreshToken
private readonly now: () => Date

constructor(dependencies: Dependencies) {
this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection
this.refreshToken = dependencies.refreshToken
this.now = dependencies.now ?? (() => new Date())
}

async execute(params: { usuarioId: number }): Promise<Either<Error, { session: Attributes; refreshToken: string }>> {
const generated = this.refreshToken.generate()
if (generated.left()) {
return generated
}

const createdAt = this.now()
const attributes: Attributes = {
id: randomUUID(),
usuarioId: params.usuarioId,
refreshTokenHash: generated.value.hash,
createdAt,
lastUsedAt: createdAt,
expiresAt: UsuarioSessao.refreshExpiresAt(createdAt)
}

const session = UsuarioSessao.create(attributes)
if (session.left()) {
return session
}

const persisted = await this.usuarioSessaoCollection.create({
id: session.value.id,
usuarioId: session.value.usuarioId,
refreshTokenHash: session.value.refreshTokenHash,
createdAt: session.value.createdAt,
lastUsedAt: session.value.lastUsedAt,
expiresAt: session.value.expiresAt
})
if (persisted.left()) {
return persisted
}

return Either.right({
session: persisted.value,
refreshToken: generated.value.token
})
}
}
83 changes: 83 additions & 0 deletions src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { type RefreshToken } from '@/library/auth/RefreshToken'
import { Either } from '@/library/either/Either'

import { UserSessionNotFoundError } from './error/UserSessionNotFoundError'
import { type Attributes, UsuarioSessao } from './UsuarioSessao'
import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection'

interface Dependencies {
usuarioSessaoCollection: UsuarioSessaoCollection
refreshToken: RefreshToken
now?: () => Date
}

export class RotacionaUsuarioSessaoUseCase {
private readonly usuarioSessaoCollection: UsuarioSessaoCollection
private readonly refreshToken: RefreshToken
private readonly now: () => Date

constructor(dependencies: Dependencies) {
this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection
this.refreshToken = dependencies.refreshToken
this.now = dependencies.now ?? (() => new Date())
}

async execute(params: { refreshToken: string }): Promise<Either<Error, { session: Attributes; refreshToken: string }>> {
const hashed = this.refreshToken.hash(params.refreshToken)
if (hashed.left()) {
return hashed
}

const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(hashed.value)
if (found.left()) {
return found
}
if (!found.value) {
return Either.left(new UserSessionNotFoundError())
}

const now = this.now()
const expired = await this.deleteIfExpired(found.value, now)
if (expired.left()) {
return expired
}
if (expired.value) {
return Either.left(new UserSessionNotFoundError())
}

const generated = this.refreshToken.generate()
if (generated.left()) {
return generated
}

const updated = await this.usuarioSessaoCollection.updateRotation(found.value.id, {
refreshTokenHash: generated.value.hash,
lastUsedAt: now,
expiresAt: UsuarioSessao.refreshExpiresAt(now)
})
if (updated.left()) {
return updated
}
if (!updated.value) {
return Either.left(new UserSessionNotFoundError())
}

return Either.right({
session: updated.value,
refreshToken: generated.value.token
})
}

private async deleteIfExpired(session: Attributes, now: Date): Promise<Either<Error, boolean>> {
if (!UsuarioSessao.expired(session.expiresAt, now)) {
return Either.right(false)
}

const deleted = await this.usuarioSessaoCollection.deleteById(session.id)
if (deleted.left()) {
return deleted
}

return Either.right(true)
}
}
62 changes: 62 additions & 0 deletions src/domain/usuarioSessao/UsuarioSessao.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Either } from '@/library/either/Either'

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const REFRESH_TOKEN_HASH_PATTERN = /^[0-9a-f]{64}$/
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000

export interface Attributes {
id: string
usuarioId: number
refreshTokenHash: string
createdAt: Date
lastUsedAt: Date
expiresAt: Date
}

export class UsuarioSessao {
static readonly REFRESH_TTL_DAYS = 30

readonly id: string
readonly usuarioId: number
readonly refreshTokenHash: string
readonly createdAt: Date
readonly lastUsedAt: Date
readonly expiresAt: Date

private constructor(attributes: Attributes) {
this.id = attributes.id
this.usuarioId = attributes.usuarioId
this.refreshTokenHash = attributes.refreshTokenHash
this.createdAt = attributes.createdAt
this.lastUsedAt = attributes.lastUsedAt
this.expiresAt = attributes.expiresAt
}

static refreshExpiresAt(now: Date): Date {
return new Date(now.getTime() + UsuarioSessao.REFRESH_TTL_DAYS * MILLISECONDS_PER_DAY)
}

static expired(expiresAt: Date, now: Date): boolean {
return expiresAt.getTime() <= now.getTime()
}

static create(attributes: Attributes): Either<Error, UsuarioSessao> {
if (!UUID_PATTERN.test(attributes.id)) {
return Either.left(new Error('Id da sessão deve ser um UUID'))
}

if (!Number.isInteger(attributes.usuarioId) || attributes.usuarioId <= 0) {
return Either.left(new Error('usuarioId da sessão deve ser um inteiro positivo'))
}

if (!REFRESH_TOKEN_HASH_PATTERN.test(attributes.refreshTokenHash)) {
return Either.left(new Error('refreshTokenHash da sessão deve ser SHA-256 hex'))
}

if (attributes.expiresAt.getTime() <= attributes.createdAt.getTime()) {
return Either.left(new Error('expiresAt da sessão deve ser posterior a createdAt'))
}

return Either.right(new UsuarioSessao(attributes))
}
}
Loading
Loading