Este documento cobre cada entidade do domínio do Beyou, explicando o que cada uma faz pelo usuário e como está estruturada no banco de dados. O objetivo é um modelo mental claro da camada de dados antes de ler ou escrever código.
Uma regra de base molda tudo aqui: o schema pertence ao Flyway. As migrações em db/migration/ criam e evoluem cada tabela, e o Hibernate roda com ddl-auto: validate em todos os ambientes, então um mapeamento de entidade que discorde das migrações falha no startup em vez de reescrever o schema em silêncio.
O domínio do Beyou gira em torno de uma ideia simples: o usuário cria hábitos, tarefas e metas, os organiza em categorias e os executa em rotinas diárias. Cada check gera XP e escreve histórico. Ao redor desse ciclo central ficam quatro famílias de apoio: as linhas de histórico diário, os snapshots imutáveis de rotina, as threads de feedback e os chats do agente de IA.
flowchart TD
U["👤 Usuário"]
U --> CAT["📂 Categorias"]
U --> HAB["💪 Hábitos"]
U --> TSK["📝 Tarefas"]
U --> GOL["🎯 Metas"]
U --> RTN["📋 Rotinas"]
CAT -.-|"marca"| HAB
CAT -.-|"marca"| TSK
CAT -.-|"marca"| GOL
RTN --> SEC["📑 Seções"]
SEC --> HG["Grupos de hábito"]
SEC --> TG["Grupos de tarefa"]
HG -.-|"referencia"| HAB
TG -.-|"referencia"| TSK
HG --> CHK["✅ Checks"]
TG --> CHK
CHK -->|"gera"| XP["🎮 XP"]
CHK -->|"escreve"| HIST["📅 Histórico diário<br/>linhas de check + xp"]
RTN -->|"congelada por dia em"| SNAP["🧊 Snapshots"]
Papel no produto: a entidade central. Cada dado do Beyou pertence a um usuário. O usuário tem perfil (nome, foto, frase motivacional), preferências (tema, idioma, timezone, widgets do dashboard), estado de gamificação (XP, level, streaks) e dois pequenos campos de texto que o agente de IA usa como memória.
Campos principais
| Campo | Tipo | Notas |
|---|---|---|
| id | UUID | Gerado automaticamente |
| name | String | |
| String | Único | |
| password | String | Hash BCrypt |
| isGoogleAccount | boolean | True para contas OAuth |
| emailVerified | boolean | Contas novas confirmam por e-mail antes do primeiro login |
| verificationToken / verificationTokenExpiry | String / LocalDateTime | O estado de verificação vive como colunas aqui, sem entidade própria |
| verificationTokenSentAt | Instant | Quando o último e-mail de verificação saiu, lido pelo cooldown do reenvio. Um Instant contra o LocalDateTime ao lado porque é comparado a um relógio e nunca exibido. Null significa nenhum e-mail registrado, que é como toda linha anterior à coluna se lê, e como fica uma linha cujo envio falhou |
| perfilPhrase / perfilPhraseAuthor | String | Citação motivacional opcional |
| perfilPhoto | String (512) | A URL do avatar no CDN do Google, gravada no login OAuth. NÃO é o caminho de uma foto enviada: o upload escreve {upload-dir}/user-photos/{userId}.jpg e nunca toca nesta coluna, então ela fica nula em contas que nunca entraram com o Google. O perfil serve o arquivo primeiro e esta coluna depois, e remover tem que limpar os dois |
| themeInUse / languageInUse | String | Preferências |
| timezone | String | Obrigatório. O fuso IANA da conta, vindo do cliente no cadastro e caindo em UTC quando não vem. Toda data que o app escreve é resolvida contra ele |
| timezoneSource | enum TimezoneSource | DEFAULT, DETECTED ou EXPLICIT: se o fuso acima chegou a ser escolhido por alguém. Só DEFAULT pode ser corrigido automaticamente |
| widgetsIdInUse | Lista de String | IDs dos widgets ativos do dashboard |
| isTutorialCompleted | boolean | Flag do onboarding |
| userContext | String (2000) | A memória global do agente de IA sobre este usuário |
| xpDecayStrategy | Enum XpDecayStrategy | GRADUAL, FLAT ou TIME_WINDOW; como check-ins atrasados perdem XP |
| maxConstance | Integer | Maior streak já alcançado |
| completedDays | Set de LocalDate | Dias com atividade de rotina completada |
| userRole | Enum UserRole | USER ou ADMIN (admin só por update manual no banco) |
| constanceConfiguration | Enum ConstanceConfiguration | ANY ou COMPLETE |
Embutidos: XpProgress e CheckProgress (ambos descritos abaixo).
Relacionamentos: o usuário possui seis coleções, todas OneToMany com cascade ALL e orphan removal: categorias, hábitos, tarefas, metas, rotinas e snapshots de rotina. A exclusão de conta funciona inteiramente por esse cascade; a coleção de tarefas foi adicionada justamente para fechar uma brecha nele.
Lógica de negócio: implementa o UserDetails do Spring Security. O cálculo de streak, que antes vivia aqui como uma caminhada sobre completedDays, agora pertence ao UserStreakService no pacote checkday, em cima das linhas de histórico diário.
Papel no produto: categorias organizam hábitos, tarefas e metas por área da vida ("Saúde", "Carreira"). Categorias também ganham XP, então o usuário vê onde investe mais esforço.
Campos principais: name, description, iconId, timestamps.
Embutido: apenas XpProgress. Deliberadamente sem CheckProgress: uma categoria ganha XP, mas nunca é marcada.
Relacionamentos
Papel no produto: um comportamento que o usuário quer construir. Cada hábito tem seu próprio level e progressão de XP, mais um registro de streak, então continuar aparecendo segue valendo a pena.
Campos principais
| Campo | Tipo | Notas |
|---|---|---|
| name / description / iconId | String | |
| importance | Integer | 1 a 4 |
| dificulty | Integer | 1 a 4. Sim, com erro de grafia: é o nome real do campo, da coluna e do formato de rede |
| motivationalPhrase | String | Opcional |
Embutidos: XpProgress e CheckProgress. O antigo contador avulso constance se foi; o CheckProgress o substituiu.
Relacionamentos
Papel no produto: uma ação concreta. Diferente dos hábitos, tarefas podem ser únicas ("Comprar mantimentos"). Tarefas únicas recebem uma data de soft-delete ao serem marcadas, dando ao sistema um período de carência antes de um scheduler removê-las.
Campos principais
| Campo | Tipo | Notas |
|---|---|---|
| name / description / iconId | String | |
| importance / dificulty | Integer | 1 a 4, mesma grafia do Habit |
| oneTimeTask | boolean | True para tarefas não recorrentes |
| markedToDelete | LocalDate | Definido ao completar tarefas únicas; o TaskCleanupScheduler as recolhe |
Embutido: apenas CheckProgress. Uma tarefa não carrega XP próprio; marcá-la alimenta o usuário, a rotina e as categorias.
Relacionamentos: pertence a um User (ManyToOne); marcada por Categories (ManyToMany, lado dono, join table task_category).
Papel no produto: um objetivo mensurável ("Correr 100 km"). O progresso é currentValue contra targetValue, e a conclusão paga uma recompensa de XP calculada.
Campos principais
| Campo | Tipo | Notas |
|---|---|---|
| name / iconId / description | String | |
| targetValue / currentValue | Double | A parte mensurável |
| unit | String | km, livros, etc. |
| complete | Boolean | Flag de conclusão |
| motivation | String | Opcional |
| startDate / endDate | LocalDate | Janela e prazo |
| xpReward | double | Calculado na conclusão |
| completeDate | LocalDate | |
| status | Enum GoalStatus | NOT_STARTED, IN_PROGRESS, COMPLETED (guardados como string) |
| term | Enum GoalTerm | SHORT_TERM, MEDIUM_TERM, LONG_TERM |
Relacionamentos: pertence a um User (ManyToOne); marcada por Categories (ManyToMany, lado dono, join table goal_category).
Invariante que vale conhecer: construir uma meta com status COMPLETED a rebaixa silenciosamente para IN_PROGRESS. Só o endpoint explícito de conclusão paga XP, então ninguém consegue postar uma meta pré-completada para colher recompensa.
Cálculo de XP: o GoalXpCalculator multiplica quatro fatores.
flowchart LR
TV["🎯 Valor alvo"] --> BASE["XP base<br/>50 / 100 / 200 / 300"]
TV --> DIFF["Dificuldade<br/>1.0x – 2.0x"]
DL["📅 Dias na janela"] --> URG["Urgência<br/>1.0x – 1.5x"]
CD["✅ Concluída antes do prazo?"] --> CON["Consistência<br/>1.0x – 1.3x"]
BASE --> TOTAL["Recompensa total de XP"]
DIFF --> TOTAL
URG --> TOTAL
CON --> TOTAL
Dois embutíveis carregam o estado de gamificação, e quais entidades embutem qual é uma decisão de design por si só.
| Campo | Tipo | Notas |
|---|---|---|
| xp | double | XP total acumulado |
| level | int | Level atual |
| actualLevelXp / nextLevelXp | double | Fronteiras do level atual |
Embutido por User, Category, Habit e Routine: as quatro coisas que sobem de level. addXp e removeXp caminham pela curva de levels nas duas direções através de uma função de consulta, com teto no último level.
| Campo | Tipo | Notas |
|---|---|---|
| check_current_streak / check_best_streak | int | Streaks |
| check_total_check_ins | int | Contagem de toda a vida |
| check_first_check_in_date / check_last_check_in_date | LocalDate | Limites, anuláveis |
Embutido por User, Habit, Task e Routine: as quatro coisas que são marcadas. Category fica de fora de propósito, e Task aparece aqui mesmo sem ter XP.
Papel no produto: a ferramenta de execução diária. Uma rotina tem seções ("Manhã", "Trabalho", "Noite"), cada uma com grupos de hábitos e tarefas. Marcar itens gera XP em cada entidade relacionada.
Herança: Routine é uma base abstrata com herança single-table e discriminador dtype. DiaryRoutine é o único tipo concreto hoje.
flowchart TD
R["📋 Routine<br/>(abstrata, single-table)"]
R --> DR["📋 DiaryRoutine"]
DR --> RS1["📑 Seção: Manhã"]
DR --> RS2["📑 Seção: Noite"]
RS1 --> HG1["💪 Grupo de hábito"]
RS1 --> TG1["📝 Grupo de tarefa"]
HG1 --> HC["✅ HabitGroupCheck"]
TG1 --> TC["✅ TaskGroupCheck"]
Campos: name, iconId. Embute XpProgress e CheckProgress.
Relacionamentos
Estende Routine, adicionando routineSections (OneToMany, cascade ALL, orphan removal, ordenadas por orderIndex).
Campos: name, iconId, startTime, endTime, orderIndex, favorite.
Relacionamentos: pertence a uma Routine (ManyToOne); contém HabitGroups e TaskGroups (OneToMany, cascade ALL, orphan removal). Uma peculiaridade para conhecer: essas coleções são unidirecionais e mapeadas por join tables (routine_sections_habit_groups, routine_sections_task_groups), enquanto o ItemGroup também carrega sua própria coluna routine_section_id. O vínculo seção-grupo está, na prática, mapeado duas vezes.
Papel no produto: em quais dias da semana uma rotina está ativa, o que decide se ela aparece no dashboard de um dado dia.
A entidade é mínima: um id mais um conjunto de enums WeekDay guardados na tabela de coleção schedule_days. A chave estrangeira vive do lado da rotina. Uma pegadinha: os identificadores do enum são palavras capitalizadas (Monday, Tuesday, ...), sem SCREAMING_CASE, e são guardados como string — grafia que uma CHECK constraint na tabela também exige e que toda resposta devolve. O JSON de entrada é o único lugar que perdoa divergência: qualquer caixa, e os nomes em português, resolvem para a mesma constante, porque uma chamada de tool do agente que chutava SCREAMING_CASE custava uma ida e volta inteira ao LLM para se corrigir.
Papel no produto: colocar um hábito ou tarefa dentro de uma seção de rotina cria um "grupo", a instância rastreável que é marcada ou pulada a cada dia. Cada check é um registro histórico com data, hora e o XP que gerou.
ItemGroup (abstrata, herança joined): startTime, endTime e o ManyToOne de volta à seção. Tipos concretos HabitGroup (referencia um Habit) e TaskGroup (referencia uma Task), cada um dono das suas coleções de checks (cascade ALL, sem orphan removal, então o histórico sobrevive).
BaseCheck (abstrata, herança joined): checkDate, checkTime, checked, skipped, xpGenerated. Tipos concretos HabitGroupCheck e TaskGroupCheck, cada um pertencente ao seu grupo.
Papel no produto: os widgets de histórico e progresso do dashboard precisam de respostas por dia ("o que aconteceu com este hábito na terça?", "quanto XP esta categoria ganhou nesta semana?"). Varrer as tabelas cruas de checks para isso é caro e frágil, então duas tabelas dedicadas de histórico guardam uma linha por entidade por dia.
EntityCheckDay (tabela entity_check_day): um desfecho por entidade por dia, único em (owner_type, owner_id, day).
EntityXpDay (tabela entity_xp_day): o delta líquido de XP por entidade por dia, mesmo padrão de unicidade.
Papel no produto: rotinas mudam. Seções são renomeadas, hábitos são removidos, rotinas inteiras são apagadas. Sem snapshots, a visão de ontem do seu dia se reescreveria em silêncio. Então, a cada dia agendado, cada rotina é congelada em uma cópia imutável, e os dias passados renderizam exatamente como eram.
RoutineSnapshot (tabela routine_snapshot): único por (rotina, dia).
SnapshotCheck (tabela snapshot_check): uma linha por grupo de hábito ou tarefa da rotina congelada.
Check-ins atrasados e decaimento de XP: marcar um dia passado por um snapshot ainda paga XP, mas decaído conforme a XpDecayStrategy escolhida pelo usuário:
| Estratégia | Comportamento |
|---|---|
| GRADUAL | 0.8x com um dia de atraso, depois 0.6x, 0.4x e 0.2x de quatro dias em diante |
| FLAT | 0.5x não importa o atraso |
| TIME_WINDOW | XP cheio até dois dias de atraso, nada depois |
O scheduler de snapshots roda por timezone, usando a coluna de timezone de cada conta, então uma rotina é congelada na meia-noite daquele usuário, não na do servidor.
Papel no produto: usuários reportam bugs e pedem funcionalidades dentro do app; um admin lê, responde e acompanha o status.
Papel no produto: o chat do agente, que cria rotinas e responde perguntas, mantém as conversas no domínio, com duas camadas de memória.
Cinco pequenas entidades pendem da conta. Três guardam hashes para os fluxos de segurança, todas ManyToOne para User; as outras duas guardam uma preferência e um log do e-mail que essa preferência permitiu:
| Entidade | Tabela | O que guarda |
|---|---|---|
| RefreshToken | refresh_tokens | Hash do refresh token de 15 dias, expiração, revokedAt |
| PasswordResetToken | password_reset_tokens | Hash do token de reset, expiração, usedAt |
| AccountDeletionCode | account_deletion_codes | Hash BCrypt de um código de seis dígitos, expiração, usedAt e um contador de tentativas que mata o código depois de alguns erros |
| NotificationPreferences | notification_preferences | Se a conta pode receber e-mail de engajamento, mais o token que um link de cancelamento carrega. OneToOne em vez de ManyToOne, chaveada pelo próprio id do usuário via @MapsId para que a chave e a associação não possam divergir |
| NotificationSend | notification_sends | Uma linha por e-mail de engajamento efetivamente enviado: o tipo e a data local de QUEM RECEBE, não a do servidor. Uma constraint UNIQUE em (usuário, tipo, dia) é o que impede a passada horária de enviar a mesma coisa duas vezes; as mesmas linhas respondem ao intervalo por conta e ao teto diário global |
A verificação de e-mail é a exceção: vive como colunas na tabela users em vez de entidade própria. Isso também quer dizer que o token fica ali em texto plano, ao contrário dos tokens de reset e de exclusão ao lado, que são guardados como hash BCrypt.
O token de cancelamento também é guardado cru, e esse é uma decisão, não uma herança. Os três acima são segredos de uso único, então o hash é de graça. Um token de cancelamento é estável — todo e-mail de engajamento pelo resto da vida da conta aponta para ele — e um hash não pode ser desfeito para montar esse link, então hashear forçaria um token novo por envio e mataria o link de toda mensagem já entregue. A ausência de linha significa que a conta nunca recebeu e-mail e nunca abriu a configuração; quem lê tem que tratar isso como opt-in.
Diferente das três tabelas de token, esta não tem expiração nem marca de uso único: uma preferência não é gasta ao ser usada.
XpByLevel (tabela xp_by_level) é uma tabela de referência pura: uma linha por level, com o limiar de XP para alcançá-lo. É semeada por uma migração repetível do Flyway com uma curva quadrática:
limiar(level) = round(50 × level²)
Os levels vão de 0 a 100. Os primeiros vêm rápido (o level 2 custa 200 XP), os últimos exigem esforço sustentado (o level 100 fica em 500.000). As consultas são cacheadas por level, e o XpProgress caminha por essa curva nas duas direções quando XP entra ou sai.
sequenceDiagram
participant U as Usuário
participant R as Serviço de Rotina
participant X as XpProgress
participant H as Tabelas de histórico
U->>R: Marca hábito na rotina
R->>X: habit.addXp / category.gainXp / routine.addXp / user.addXp
R->>R: Registra HabitGroupCheck com xpGenerated
R->>H: Escreve deltas em EntityXpDay (user, category, habit, routine)
R->>H: Escreve desfecho em EntityCheckDay (DONE)
R-->>U: XP atualizado em todas as entidades
| Estratégia | Usada por | Como funciona |
|---|---|---|
| Single table | Routine → DiaryRoutine | Uma tabela com discriminador dtype. Consultas rápidas; colunas embutidas NOT NULL só toleráveis porque há uma única subclasse. |
| Joined | ItemGroup → HabitGroup / TaskGroup, BaseCheck → HabitGroupCheck / TaskGroupCheck | Tabela base mais tabelas filhas unidas por chave estrangeira. Schema mais limpo, um join a mais por consulta. |
Entender os cascades importa acima de tudo na exclusão de conta, que depende deles de ponta a ponta.
| Pai | Filhos | Cascade | Orphan removal |
|---|---|---|---|
| User | Categories, Habits, Tasks, Goals, Routines, RoutineSnapshots | ALL | Sim. Apagar um usuário remove tudo |
| User (nível de banco) | Linhas de EntityCheckDay, EntityXpDay | ON DELETE CASCADE | Por conta da FK do banco |
| DiaryRoutine | RoutineSections | ALL | Sim |
| RoutineSection | HabitGroups, TaskGroups | ALL | Sim |
| Routine | Schedule | REMOVE | Não. Desagendar é explícito |
| Habit | HabitGroups | ALL | Não. Apagar um hábito não reescreve rotinas em silêncio |
| HabitGroup / TaskGroup | Checks | ALL | Não. O histórico de checks é preservado |
| RoutineSnapshot | SnapshotChecks | ALL | Sim |
flowchart LR
subgraph core["Núcleo"]
users
categories
habits
tasks
goals
end
subgraph joins["Join tables"]
habit_category
task_category
goal_category
schedule_days
end
subgraph routine["Rotina"]
routines
routine_sections
schedules
item_groups
habit_groups
task_groups
base_checks
habit_group_checks
task_group_checks
end
subgraph history["Histórico & snapshots"]
entity_check_day
entity_xp_day
routine_snapshot
snapshot_check
end
subgraph support["Feedback & IA"]
feedback
feedback_reply
feedback_attachment
chats
agent_message
spring_ai_chat_memory
end
subgraph auth["Auth"]
refresh_tokens
password_reset_tokens
account_deletion_codes
notification_preferences
notification_sends
end
subgraph system["Referência & docs"]
xp_by_level
docs_tables["docs_* (8 tabelas)"]
end
Todas as chaves primárias são UUIDs, exceto a de xp_by_level, cuja chave é o próprio level. Os timestamps são definidos por callbacks de ciclo de vida do JPA. As tabelas docs_* seguem um padrão repetido: uma raiz de tópico com chave única mais uma linha de conteúdo por idioma, importadas do repositório beyou-arch-design.