Jornada de áudios diários

TLDR: O áudio entregue em GET /audios/daily depende do perfil do usuário: quem entrou depois do lançamento da Journey percorre uma sequência fixa com avanço por conclusão; os demais recebem sempre o áudio diário mais atual publicado fora da jornada.

Regras de negócio: R-002 a R-009 em rules/audios/. Implementação histórica: plans/20260512172815_audio_journey.md.

Visão geral

A jornada de áudios diários funciona de forma diferente conforme o perfil do usuário:

  • Novo usuário: cadastrado após Journey.launch_date e que aceitou os termos — percorre uma sequência fixa de áudios via Journey, com avanço baseado em progresso
  • Usuário existente: cadastrado antes de Journey.launch_date — recebe sempre o áudio diário mais atual publicado

A distinção é feita comparando User.accept_terms_at com Journey.launch_date. O UserJourney é criado de forma síncrona, dentro do mesmo post_save do aceite de termos (apps/audios/signals.py, create_user_journey_on_terms_acceptance → JourneyService.create_for) — não passa por uma task Celery, justamente para evitar que uma leitura de GET /audios/daily logo após o aceite dos termos aconteça antes da UserJourney existir. A jornada ativa com launch_date mais recente é selecionada.

Fluxo

mermaid graph TD A["GET /audios/daily"] --> B{"usuário tem<br/>UserJourney?"} B -->|não| F["áudio mais recente<br/>sem JourneyAudio"] B -->|sim| C{"jornada<br/>concluída?"} C -->|sim| F C -->|não| D{"hoje &lt;<br/>current_audio_available_from?"} D -->|sim| E["previous_journey_audio.audio"] D -->|não| G["current_journey_audio.audio"] style A fill:#1f2937,color:#fff style F fill:#374151,color:#fff

Models

JourneyKind

Enum (str, Enum) que define os tipos de jornada disponíveis.

Valor Constante Descrição
"onboarding" JourneyKind.ONBOARDING Jornada de boas-vindas para novos usuários

JourneyKind.choices() retorna lista de tuplas (value, label) compatível com o campo choices do Django.

JourneyManager

Manager customizado que herda de SoftDeletionManager (preserva o soft deletion do BaseModel).

Método Retorno Descrição
by_kind(kind: JourneyKind) Journey Busca a jornada pelo tipo via get(kind=kind.value)
onboarding() Journey Atalho para by_kind(JourneyKind.ONBOARDING)
default() Journey Aponta para onboarding()

Journey

Representa uma jornada de áudios cadastrada no sistema.

Campo Tipo Descrição
name CharField(255) Nome da jornada
kind CharField(100, unique, nullable) Tipo da jornada — valores definidos em JourneyKind
launch_date DateField(nullable) Data a partir da qual novos usuários são associados a esta jornada
is_active BooleanField Liga/desliga a jornada
journey_audios reverse FK → JourneyAudio Áudios associados à jornada

JourneyAudio

Vincula áudios a uma Journey com posição ordenada.

Campo Tipo Descrição
journey FK → Journey Jornada a que pertence
audio FK → Audio Áudio associado
position PositiveIntegerField Posição dentro da jornada (1, 2, 3…)

Constraints: unique_together (journey, position) e (journey, audio).

UserJourney

Vincula um usuário a uma Journey e implementa uma state machine que controla qual áudio é entregue a cada dia.

Campo Tipo Descrição
user OneToOneField → User Um usuário, uma jornada ativa
journey FK → Journey Jornada atribuída
current_journey_audio FK → JourneyAudio (nullable) Áudio atual da jornada — None quando a jornada está concluída
previous_journey_audio FK → JourneyAudio (nullable) Áudio anterior — usado para servir o áudio do dia quando current ainda não foi liberado
current_audio_available_from DateField(default=date.today) Data a partir da qual current_journey_audio está disponível
started_at DateTimeField(auto_now_add) Data de início
updated_at DateTimeField(auto_now) Última atualização

State machine

``` init: current_journey_audio = JourneyAudio(position=1) previous_journey_audio = None current_audio_available_from = hoje

progress completed (áudio ouvido): previous_journey_audio = current_journey_audio current_journey_audio = next JourneyAudio (None se último) current_audio_available_from = hoje + 1 dia

journey completed (último áudio ouvido): previous_journey_audio = current_journey_audio (último) current_journey_audio = None current_audio_available_from = hoje + 1 dia ```

Property daily_audio

Encapsula a lógica de qual áudio servir com base no estado atual:

python @property def daily_audio(self): if date.today() < self.current_audio_available_from: return self.previous_journey_audio.audio # mesmo dia da conclusão return self.current_journey_audio.audio if self.current_journey_audio else None

Property is_completed

python @property def is_completed(self): return self.current_journey_audio is None

O signal on_audio_progress_save (em apps/audios/signals.py) executa as transições de estado quando AudioProgress.completed_at é setado. O endpoint GET /audios/daily é puramente de leitura.

Contratos

GET /audios/journey

Campo Origem
journey_completed true quando current_journey_audio é null
current_position UserJourney.current_position: posição do current_journey_audio; quando concluída, posição do previous_journey_audio
total UserJourney.total_items: journey.journey_audios.count()
user_id request.user.id

Usuário sem UserJourney recebe HTTP 404 (ver R-009).

Configuração via admin

O singleton DailyAudioSettings (menu Admin → Configuração de Áudio Diário) expõe:

Campo Default Descrição
release_time 06:00 Hora local em que o áudio do próximo dia fica disponível
push_time 07:00 Hora local do push diário (notificação enviada a todos)
push_enabled True Liga/desliga o push diário
last_push_sent_on — Readonly — controle interno de idempotência do push diário

O release_time controla quando o conteúdo do dia fica disponível para usuários existentes (R-007) e para o conteúdo diário livre pós-jornada (R-006).

Push diário

Uma task do Celery beat (enqueue_daily_audio_push, 60s) verifica se chegou o push_time e, se ainda não enviou no dia, cria uma Notification(send_to_all=True). A task dispatch_scheduled_notifications cuida do envio via Firebase.

O conteúdo do push é genérico — o áudio específico de cada aluno é resolvido individualmente quando ele abre o app (GET /audios/daily).

Refresh em tempo real

A cada criação ou atualização de Audio, um push Firebase é enviado para o tópico refresh_home, pedindo aos clientes que recarreguem a tela principal.

Audio.sequence — removido

O campo sequence foi removido do model Audio. A posição de um áudio dentro de uma jornada é gerenciada exclusivamente pelo JourneyAudio.position. Áudios sem entrada em JourneyAudio são conteúdo diário livre.

Referências