Skip to content

[Janela Simulado] Card 02 · ms-simulado · PATCH /disponibilidade + filtro em getAvailable + gate em getToAnswer #162

Description

@FernandoAlmeidaPinto

[Janela Simulado] Card 02 · ms-simulado · PATCH /disponibilidade + filtro em getAvailable + gate em getToAnswer

Projeto: ms-simulado
Etapa: 5 (Janela Simulado — docs/simulado-janela-disponibilidade.md)
Estimativa: M
Bloqueia: Card 03
Bloqueado por: Card 01


Descrição

Ativa o comportamento: novo endpoint PATCH /v1/simulado/:id/disponibilidade pra admin setar as datas, filtro adicional em getAvailable respeitando a janela, gate em getToAnswer recusando com 403 se fora. POST /answer continua sem checagem — grace period intencional pra não cortar aluno que começou dentro da janela.

Escopo

Novo DTO UpdateDisponibilidadeDTO

export class UpdateDisponibilidadeDTO {
  @IsOptional() @IsDate() @Type(() => Date)
  disponivelDe?: Date | null;

  @IsOptional() @IsDate() @Type(() => Date)
  disponivelAte?: Date | null;
}

Aceita null explícito em qualquer um dos dois campos (pra "limpar" uma data setada).

Método SimuladoService.updateDisponibilidade

public async updateDisponibilidade(
  id: string,
  dto: UpdateDisponibilidadeDTO,
): Promise<Simulado> {
  const simulado = await this.simuladoRepository.getById(id);
  if (!simulado) throw new NotFoundException();

  // Validação cross-field
  if (dto.disponivelDe && dto.disponivelAte && dto.disponivelDe >= dto.disponivelAte) {
    throw new BadRequestException('disponivelDe deve ser anterior a disponivelAte');
  }

  await this.simuladoRepository.model.updateOne(
    { _id: id },
    { $set: { disponivelDe: dto.disponivelDe, disponivelAte: dto.disponivelAte } },
  );
  return this.simuladoRepository.getById(id);
}

Endpoint no SimuladoController

@Patch(':id/disponibilidade')
@ApiResponse({ status: 200, type: Simulado })
public async updateDisponibilidade(
  @Param('id') id: string,
  @Body() dto: UpdateDisponibilidadeDTO,
): Promise<Simulado> {
  return this.service.updateDisponibilidade(id, dto);
}

Filtro em SimuladoRepository.getAvailable

Alterar pra:

async getAvailable(categoriaId: string) {
  const now = new Date();
  return await this.model
    .find({
      categoria: categoriaId,
      bloqueado: false,
      $and: [
        { $or: [{ disponivelDe: null }, { disponivelDe: { $lte: now } }] },
        { $or: [{ disponivelAte: null }, { disponivelAte: { $gte: now } }] },
      ],
    })
    .select(['nome', '_id']);
}

O índice composto do Card 01 ({ categoria, bloqueado, disponivelDe, disponivelAte }) suporta essa query.

Gate em SimuladoService.getToAnswer

Antes de retornar o simulado, chamar isSimuladoAvailable. Se false, lançar:

import { isSimuladoAvailable, getAvailabilityStatus } from './availability';

public async getToAnswer(simuladoId: string): Promise<SimuladoAnswerDTOOutput> {
  const simulado = await this.simuladoRepository.getById(simuladoId);
  if (!simulado) return null;

  if (!isSimuladoAvailable(simulado)) {
    throw new HttpException(
      {
        message: 'Simulado fora da janela de disponibilidade',
        status: getAvailabilityStatus(simulado),
      },
      HttpStatus.FORBIDDEN,
    );
  }

  return this.GetSimulado(simuladoId);
}

O status detalhado no payload (bloqueado / antes_da_janela / depois_da_janela) permite ao frontend exibir mensagem específica.

POST /v1/simulado/answerNÃO modificar

Decisão deliberada do doc §4. Aluno que carregou o simulado dentro da janela pode submeter fora. Grace period implícito. Confirmar no PR que nenhuma checagem foi adicionada nesse endpoint.

Critérios de aceitação

  • PATCH /v1/simulado/:id/disponibilidade funciona:
    • Body com ambas as datas → persiste
    • Body com só uma → persiste (outra fica null)
    • Body com null explícito → limpa
    • Body inválido (disponivelDe >= disponivelAte) → 400
  • GET /v1/simulado/available?categoria=X não retorna simulados fora da janela
  • GET /v1/simulado/available?categoria=X retorna simulado com null/null (compatibilidade)
  • GET /v1/simulado/toanswer/:id retorna 403 com payload { message, status } pra:
    • Simulado bloqueado (bloqueado: true)
    • Simulado antes da janela (disponivelDe > now)
    • Simulado depois da janela (disponivelAte < now)
  • POST /v1/simulado/answer continua aceitando submissões independente da janela
  • Build passa; testes unitários e de integração cobertos

Risco

Médio. Pontos de atenção:

  • Cross-field validation em updateDisponibilidade: se malfeita, admin consegue setar de > ate e o isSimuladoAvailable sempre retorna false. Testar caso limite.
  • Timezone: sempre gravar UTC no banco. Frontend converte de local pra UTC ao enviar; leitura vem UTC e frontend renderiza local. Sem moment.tz ou similar, cuidado com Date naïve.
  • Race entre listar e abrir: aluno vê simulado na lista (dentro da janela), clica, e nesse meio-tempo a janela fecha. getToAnswer revalida com isSimuladoAvailable(now) → 403. UI trata (Card 04).

Observações

  • O gate em getToAnswer usa new Date() implicitamente (default do parâmetro now). Se algum teste precisa fixar tempo, chamar isSimuladoAvailable(simulado, fakeNow).
  • Aluno que abriu simulado e ficou 30min respondendo — envio funciona (grace period). Se sair da tela e voltar 1h depois, getToAnswer refaz o gate — cai em 403 se janela fechou. Comportamento correto.

Referências

  • Doc principal: docs/simulado-janela-disponibilidade.md §6 (endpoints) e §9 passos 3-6
  • Grace period: doc §4 (tabela decisões) e §7.4

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions