Jornada de áudios diários
TLDR: O áudio entregue em
GET /audios/dailydepende do perfil do usuário: quem entrou depois do lançamento daJourneypercorre 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_datee que aceitou os termos — percorre uma sequência fixa de áudios viaJourney, 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 <<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
- learnings/audio_push_notification_single_source.md — por que só o beat cria o push diário
- learnings/audio_duration_recalc_on_file_change.md