Modelo de Domínio

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 quadro geral

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 as famílias de apoio: as linhas de histórico diário, os snapshots imutáveis de rotina, os ciclos e micro-tarefas do Modo Foco, os registros de humor e o diário, as tarefas com data do calendário, as notas do quadro de Ideias, o caderno de estudos, 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"]

User

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
email String Único
password String Hash BCrypt
isGoogleAccount boolean True para contas OAuth. Significa Google especificamente, não federação em geral: uma conta criada por outro provedor tem isso false
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. languageInUse é en ou pt, definido no cadastro a partir do idioma que o cliente está mostrando e preenchido no boot para contas antigas; vazio vale inglês para todo prompt e e-mail
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.

Category

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

Habit

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 5
dificulty Integer 1 a 5. 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

Task

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, anulável Opcionais, ao contrário do Habit: 1 a 5 quando escolhidos, null quando o usuário não escolheu nada. Um valor ausente conta como 1 na fórmula de XP do check. Mesma grafia errada do Habit na coluna; a rede aceita as duas grafias
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).

Goal

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
parentId UUID, anulável A meta debaixo da qual esta fica. Nulo numa meta principal
archivedAt Instant, anulável Quando a meta foi guardada. Nulo enquanto está ativa. Não é um status (ver Arquivamento)

Relacionamentos: pertence a um User (ManyToOne); marcada por Categories (ManyToMany, lado dono, join table goal_category); opcionalmente aninhada debaixo de outra Goal (ManyToOne para si própria, parent_id, lazy, com uma coluna espelho só de leitura para o id chegar à resposta sem tocar na relação).

Aninhamento: uma meta pode ficar debaixo de outra, no máximo três níveis: uma meta grande, uma média debaixo dela, uma pequena debaixo dessa. A árvore é uma única auto-referência anulável, não uma segunda entidade, porque toda leitura já carrega todas as metas do utilizador numa query e os dois clientes montam a árvore a partir de parentId em código partilhado. As regras vivem uma vez só, em GoalService.resolveParent, e os seletores da web e do mobile apenas pré-filtram o que ele recusaria: o pai tem de ser do mesmo utilizador (GOAL_NOT_OWNED), não pode ser a própria meta nem uma descendente (GOAL_PARENT_CYCLE), e a cadeia tem de caber em três níveis contando os antepassados acima do novo pai e a subárvore que já está debaixo da meta (GOAL_DEPTH_EXCEEDED). Um pai é uma meta normal, com alvo, unidade e XP próprios; o resumo de submetas que aparece nos cards é calculado no cliente. A única ligação entre níveis no servidor: o primeiro increment numa submeta move um pai NOT_STARTED para IN_PROGRESS, tal como o primeiro increment da própria meta faz.

Arquivamento: uma meta pode ser guardada sem ser excluída. É um timestamp archived_at anulável e não um quarto GoalStatus, porque o status carrega a invariante de complete/status abaixo, e uma meta concluída e uma abandonada arquivam do mesmo jeito: arquivar não mexe no XP e deixa status, complete e xpReward como estão. O PUT /goal/archive recebe o estado desejado, não um alternar. As submetas vão junto, todas marcadas com o mesmo instante, e é por esse instante que a restauração sabe o que veio junto: ela limpa a meta e exatamente as descendentes que carregam aquele instante, então uma submeta arquivada sozinha antes continua arquivada. Nada novo entra debaixo de uma meta arquivada (GOAL_PARENT_ARCHIVED), mas um vínculo que já existe sobrevive a uma edição, já que toda edição devolve o pai. O GET /goal continua devolvendo as metas arquivadas com o campo preenchido, então a lista segue sendo uma consulta só, em cache, e cada tela filtra: a página de metas só as mostra na visão Arquivadas, e o visualizador, o dashboard e os seletores de pai as deixam de fora. No servidor, o resumo do dia, a lista de metas do card de categoria e os planos do agente as ignoram. Uma submeta restaurada sozinha debaixo de um pai ainda arquivado aparece como meta principal, do mesmo jeito que as filhas de um pai excluído.

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. O aninhamento não muda nada disso: um pai paga o seu próprio XP na sua própria conclusão, façam os filhos o que fizerem.

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

Os dois componentes compartilhados

Dois embutíveis carregam o estado de gamificação, e quais entidades embutem qual é uma decisão de design por si só.

XpProgress

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.

CheckProgress

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.

Routine

Papel no produto: a ferramenta de execução diária, em uma de duas formas. Uma rotina diária tem seções ("Manhã", "Trabalho", "Noite"), cada uma com grupos de hábitos e tarefas com suas próprias janelas de horário. Uma rotina em lista abre mão das duas coisas: é uma lista plana e ordenada que a pessoa marca na hora que quiser ao longo do dia. Marcar itens gera XP em cada entidade relacionada, igual nas duas.

Herança: Routine é uma base abstrata com herança single-table e discriminador dtype. DiaryRoutine é o único tipo concreto, e a forma é uma coluna routineType (DAILY ou LIST) em vez de uma segunda subclasse. Isso foi deliberado: todo o caminho de check faz cast para DiaryRoutine em cada ramo ao chegar na rotina pela seção do item, e o escritor de snapshot, o resolvedor de fechamento do dia e o serviço de schedule são todos tipados nela. Uma coluna deixa cada um desses intactos, então uma rotina em lista é agendada, fotografada, marcada, contabilizada em sequência e nivelada exatamente pelo código que já atende a diária. routines_routine_type_check espelha o enum; acrescentar um valor no Java sem acrescentar lá faz toda escrita do novo tipo falhar.

Uma rotina em lista ainda guarda seus itens em uma RoutineSection, criada no servidor com horários de início e fim nulos. Essa seção é uma representação interna e nunca chega a um cliente: a API recebe e devolve um array plano de items no lugar.

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"]

Routine (base abstrata)

Campos: name, iconId, routineType. Embute XpProgress e CheckProgress.

routineType nunca é nulo: a migration que o criou usa DAILY como padrão, então toda rotina escrita antes da forma em lista existir, e todo cliente que nunca ouviu falar do campo, mantém exatamente o comportamento que tinha. Leia pelos métodos isList() / isDaily() em vez de comparar o enum nas chamadas — esses dois são a costura, e é o que alguém procura para achar todos os lugares onde as formas de fato divergem.

Relacionamentos

DiaryRoutine

Estende Routine, adicionando routineSections (OneToMany, cascade ALL, orphan removal, ordenadas por orderIndex).

RoutineSection

Campos: name, iconId, startTime, endTime, orderIndex, favorite.

Os horários são anuláveis, e a seção única de uma rotina em lista deixa os dois nulos. Nome e ícone dela seguem os da rotina, porque a coluna é NOT NULL e porque esse nome é o que o escritor de snapshot copia para cada SnapshotCheck — uma linha de histórico que diz "Compras" é pelo menos verdadeira.

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.

Schedule

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.

Grupos de itens e checks

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, orderIndex e o ManyToOne de volta à seção. orderIndex só é lido por rotinas em lista, que não têm horário para ordenar; uma rotina diária ordena os itens por startTime nos dois clientes e ignora a coluna, ainda que ela seja gravada para as duas formas, para que compartilhem um único caminho de merge. 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.

Histórico diário: EntityCheckDay e EntityXpDay

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.

Snapshots de rotina

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.

Histórico do Modo Foco

Papel no produto: o Modo Foco é a tela que fica aberta enquanto a pessoa executa o dia, um item por vez, com um timer pomodoro e uma lista curta de coisas pequenas para fazer entre ciclos. O histórico dele responde a uma pergunta diferente da dos snapshots — não "o que eu fiz", mas "como eu executei".

FocusCycle (tabela focus_cycles): uma linha por ciclo COMPLETO.

FocusMicroTask (tabela focus_micro_tasks): uma coisa pequena feita junto de um item da rotina, num dia.

Como chega ao snapshot: a resposta do snapshot do dia junta as duas tabelas pelo SnapshotCheck.originalGroupId, então cada linha de check carrega as micro-tarefas criadas para aquele hábito ou tarefa e a contagem de pomodoros rodados nele. Duas leituras para o dia, juntadas em memória — nunca uma consulta por linha de check, que seria um N+1 do tamanho da rotina na tela de histórico.

Registos de humor e o diário

Papel no produto: como correu um dia, e o que a pessoa quis guardar sobre ele. Hábitos, metas e rotinas registam o que foi FEITO. Nenhum registo diz como correu.

MoodEntry (tabela mood_entries): uma linha por utilizador por dia, adicionada na V31.

Privacidade: a nota é o único sítio do produto onde alguém escreve longamente para si próprio. Nunca chega ao armazenamento do navegador (a slice mood está na blacklist do persist na web e o redux do mobile é só em memória), a exportação da conta leva cada registo com as palavras intactas, e a ferramenta de leitura do assistente devolve datas, níveis e se um dia tem nota, nunca a nota.

Tarefas do calendário

Papel no produto: uma obrigação avulsa que pertence a uma data, como assinar um contrato ou marcar o dentista, com um lembrete antes. Antes do calendário, o único jeito de colocar uma dessas no app era criar uma tarefa única e adicioná-la a uma rotina, o que dava trabalho o bastante para quase nunca acontecer.

CalendarTask (tabela calendar_tasks): uma linha por tarefa, adicionada na V40.

Campo Tipo Notas
title String (255) Obrigatório, de 2 a 255 caracteres
note String (1000), anulável Uma nota curta, não um diário. O limite também está na coluna, então um cliente que pule o DTO não consegue colocar um romance num e-mail de lembrete
taskDate LocalDate O dia do próprio dono, como toda data aqui
taskTime LocalTime, anulável Só minutos. Null quer dizer "em algum momento do dia", e aí o lembrete usa o horário padrão de lembrete do dono
advanceMinutes int Quanto tempo antes do horário o lembrete dispara. De 0 a 10080 (uma semana), com um CHECK espelhando o DTO; os clientes oferecem até um dia
completedAt Instant, anulável A conclusão é este timestamp. Não existe flag separada
xpAwarded double O que concluir pagou, para que desmarcar devolva exatamente isso e não o que a fórmula disser naquele dia
acknowledgedAt Instant, anulável Quando a notificação do navegador foi clicada
reminderEmailSentAt Instant, anulável Quando o e-mail de reserva saiu
createdAt / updatedAt Instant updatedAt também é como o job de e-mail percebe que uma tarefa foi editada enquanto o e-mail dela estava saindo

Notas rápidas (o quadro de Ideias)

Papel no produto: uma caixa de entrada para ideias cruas. A pessoa tira um pensamento da cabeça em poucos segundos, como um post-it num quadro livre, e decide depois se ele vira tarefa ou vai para o lixo. Sem XP e sem sequência.

QuickNote (tabela quick_notes): uma linha por nota, adicionada na V42.

Campo Tipo Notas
text String (500) Obrigatório, guardado sem os espaços das pontas. Tamanho de post-it. O limite também está na coluna, então um cliente que pule o DTO não consegue guardar mais do que o quadro consegue desenhar
color QuickNoteColor (varchar 16) YELLOW, PINK, BLUE, GREEN, PURPLE ou ORANGE, padrão YELLOW, com um CHECK espelhando o enum. A ordem de declaração é a ordem do "Organizar"
x / y int O canto superior esquerdo da nota em unidades do quadro, as mesmas em todo cliente. Cada um entre -100000 e 100000, com um CHECK espelhando QuickNote.COORD_LIMIT
z int Ordem de empilhamento entre as notas do dono; a maior fica na frente
archivedAt Instant, anulável Null enquanto a nota está no quadro
convertedTaskId UUID, anulável A tarefa em que a nota se transformou. Um id solto em vez de uma relação mapeada
createdAt / updatedAt Instant

Caderno de estudos

Papel no produto: onde alguém organiza um assunto que está aprendendo. Um tópico vira um quadro de roadmap, cada nó abre uma página de anotações, fontes entram nas páginas para uma IA de estudo ancorada nelas, e flashcards voltam num cronograma espaçado. Nove tabelas chegaram juntas na V34, todas com cascade a partir de users. As regras e a classe dona de cada uma estão no tópico do caderno de estudos.

NotebookPage (tabela notebook_pages): tudo é uma página, inclusive um tópico.

Campo Tipo Notas
id UUID
user_id UUID Dono, cascade
kind NotebookPageKind TOPIC ou PAGE, varchar com CHECK
parent_id UUID Nulo só num tópico. Cascade: apagar uma página apaga a subárvore
topic_id UUID O tópico raiz, gravado na linha para que a árvore de um tópico seja uma leitura indexada. Nulo no próprio tópico
title / icon / description varchar 255 / 64 / 512
content text O documento BlockNote em JSON. Text e não JSONB, como todo JSON que este schema guarda, porque nada consulta dentro dele
content_text text Texto puro extraído no servidor pelo BlockText a cada salvamento, nunca vindo do cliente. É o que a IA lê e o que a exportação leva
content_revision bigint Sobe a cada escrita de content; toda escrita usa um compare-and-set sobre ele, e um salvamento de uma revisão mais antiga é recusado (NOTEBOOK_CONTENT_CONFLICT) e juntado pelo cliente. Entrou na V37
status NotebookStatus TO_STUDY, STUDYING ou DONE. Gravado, escrito só pelo NotebookProgressService
status_manual boolean Escolhido à mão numa página cujo quadro tem nós. False deixa os nós decidirem
position integer Ordem entre irmãs na árvore
goal_id / category_id / habit_id UUID Vínculos opcionais de um tópico, cada um ON DELETE SET NULL. A categoria recebe o XP do tópico
done_xp_at timestamptz Quando os 15 de XP pela conclusão foram pagos. Gravado uma vez e nunca limpo, então concluir, desfazer e concluir paga uma vez
last_opened_at timestamptz Alimenta o "continuar estudando". Escrito por uma query de UPDATE que nunca suja a linha
study_goal varchar(300) O objetivo da sala de estudo para a página, enviado antes dos trechos em toda resposta. Adicionado na V36
study_scope varchar(16) De quem são as anotações que a IA lê: PAGE (a página e as de cima, o padrão), SUBTREE ou TOPIC. Com CHECK
study_setup_at timestamptz Quando a sala de estudo foi preparada pela última vez; nulo abre a sala na tela de preparo

Uma constraint CHECK segura o formato: um TOPIC não tem pai nem tópico, uma PAGE tem os dois. A entidade é @DynamicUpdate, então o autosave e uma mudança de status que chegam no mesmo instante gravam cada um só as próprias colunas.

NotebookBoardNode (notebook_board_nodes) e NotebookBoardEdge (notebook_board_edges): um quadro por página, com board_page_id como chave.

NotebookCard (notebook_cards) e NotebookCardReview (notebook_card_reviews):

NotebookSource (notebook_sources) e os trechos (notebook_source_chunks):

NotebookStudyOutput (notebook_study_outputs) e NotebookChatMessage (notebook_chat_messages):

NotebookRoadmapDraft (notebook_roadmap_drafts), adicionada na V35:

Ciclos de estudo, sete tabelas adicionadas na V39. Um ciclo é uma fila ordenada de blocos de estudo sobre tópicos do caderno, estudada volta após volta; as regras estão no tópico do caderno de estudos. Os enums são colunas varchar com CHECK, como na V19, e os números pequenos são integer pelo motivo que a V31 explica.

StudyCycle (study_cycles) e StudyCycleDay (study_cycle_days):

StudyCycleTopic (study_cycle_topics): um assunto do ciclo, qualquer página do caderno (um tópico raiz ou uma página abaixo dele), guardado em topic_id por histórico, com importance e mastery (1 a 5 cada, os pesos do planejador), um minutes_override opcional por volta (5 a 600), position e active.

StudyCycleBlock (study_cycle_blocks): um bloco da fila. Só configuração, para um replanejamento poder reescrevê-lo sem mexer no que foi estudado.

StudyCycleLapBlock (study_cycle_lap_blocks) e StudyCycleLap (study_cycle_laps):

StudySession (study_sessions): estudo lançado à mão. topic_id (cascade), um page_id opcional (SET NULL), minutes de 1 a 600, studied_on (a data local do dono, que só posiciona a sessão no histórico), uma note de até 200 caracteres e created_at. cycle_id e lap_number registram onde os minutos foram creditados, para uma exclusão devolver os minutos daquela volta e de nenhuma outra; os dois ficam null quando nada foi creditado, e cycle_id vira SET NULL quando o ciclo é apagado, o que mantém a sessão como histórico do tópico. Índice em (user_id, studied_on).

FederatedIdentity

Papel no produto: uma identidade externa pela qual uma conta pode ser acessada, além da senha e do Google. Adicionada na V29.

A identidade é o par (issuer, subject) e nada mais. Ambos vêm de um ID token verificado e nenhum deles pode ser reatribuído a outra pessoa por ninguém além daquele emissor. O endereço que o provedor afirmou fica como registro do que foi dito, nunca como forma de encontrar um usuário, porque um provedor que pudesse alcançar uma conta afirmando o endereço dela poderia alcançar todas as contas.

Campos principais

Campo Tipo Notas
id UUID Gerado automaticamente
user User ON DELETE CASCADE, para que a exclusão da conta nunca seja travada por um vínculo
issuer String A claim iss, comparada byte a byte, nunca normalizada
subject String A claim sub. UNIQUE em conjunto com issuer
emailAtLink String O que o provedor afirmou no momento do vínculo. Registrado, nunca consultado
createdAt LocalDateTime
lastLoginAt LocalDateTime Atualizado a cada entrada por esta identidade

As linhas do Google aparecem preguiçosamente em vez de por migração em lote: o subject dele nunca foi guardado, então a linha é escrita no próximo login Google da conta. Veja o documento de segurança para a regra de resolução que essas linhas alimentam.

Feedback

Papel no produto: usuários reportam bugs e pedem funcionalidades dentro do app; um admin lê, responde e acompanha o status.

Chats do agente de IA

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.

Entidades de auth e conta

Cinco pequenas entidades pendem da conta. Três guardam hashes para os fluxos de segurança, todas ManyToOne para User; as outras duas guardam as preferências de e-mail e um log do e-mail de engajamento que elas permitiram:

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, o token que um link de cancelamento carrega e, desde a V40, como os lembretes do calendário chegam à conta (colunas abaixo). 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.

Colunas de notification_preferences

Coluna Tipo Padrão Notas
user_id UUID Chave primária e chave estrangeira para users
engagement_email boolean true Os nudges de engajamento. A única chave que o link de cancelamento vira
reminder_email boolean true O e-mail de reserva do calendário para um lembrete que ninguém respondeu. Independente de engagement_email: desligar nudges que ninguém pediu não pode silenciar um lembrete que alguém criou
reminder_web boolean true Notificações do navegador para lembretes do calendário. Guardada para a escolha acompanhar a conta; cada navegador ainda concede a própria permissão, e só o cliente lê esta coluna
reminder_default_time time 09:00 Quando uma tarefa do calendário sem horário próprio lembra, antes de a antecedência ser descontada
unsubscribe_token String (64) Guardado cru, pelo motivo acima
created_at / updated_at Instant

As três colunas de lembrete chegaram na V40 com padrões constantes, e uma linha ausente é lida com esses mesmos padrões, então o NotificationPreferencesService e a migração precisam concordar. O calendário lê as configurações sem criar a linha, dentro de transações só de leitura em que o getOrCreate não conseguiria inserir.

Sistema de progressão de XP

A curva de levels

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.

Fluxo de XP em um check de rotina

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égias de herança

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.

Resumo do Dia

Papel no produto: o diálogo que o usuário encontra na primeira abertura do painel num dia novo. O que ficou aberto de ontem, e o que vem hoje.

DailyBriefing (tabela daily_briefing): no máximo uma linha por usuário por dia, adicionada na V32.

Regras de cascade e exclusão

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
Goal (nível de banco) Submetas ON DELETE SET NULL As filhas sobem para o nível principal, nunca são apagadas com o pai. A UI avisa antes de apagar
RoutineSnapshot SnapshotChecks ALL Sim
User (nível BD) Linhas DailyBriefing ON DELETE CASCADE Tratado pela FK do banco. Um resumo é dado derivado, sem sentido depois da conta
User (nível de banco) Todas as tabelas do caderno ON DELETE CASCADE A exclusão da conta leva o caderno inteiro
User (nível de banco) StudyCycle, StudySession ON DELETE CASCADE As outras cinco tabelas do ciclo de estudos vêm em cascade por study_cycles, então a exclusão da conta leva as sete
StudyCycle (nível de banco) Os dias, tópicos, blocos, lap blocks e voltas fechadas; StudySession.cycle_id ON DELETE CASCADE, e SET NULL na sessão Apagar um ciclo leva tudo que está embaixo dele. Uma sessão lançada fica como histórico do tópico
User (nível de banco) Linhas de FocusCycle, FocusMicroTask, FederatedIdentity, MoodEntry, CalendarTask, QuickNote, NotificationPreferences, NotificationSend ON DELETE CASCADE Por conta da FK do banco
Task (nível de banco) QuickNote.converted_task_id ON DELETE SET NULL A nota arquivada fica e deixa de apontar para a tarefa. Apagar uma tarefa nunca é barrado pelo post-it de onde ela veio
ItemGroup (nível de banco) Linhas de FocusMicroTask, e FocusCycle.item_group_id ON DELETE CASCADE, e SET NULL no ciclo Uma microtarefa não tem sentido sem o item dela. Um ciclo terminado continua registrado com os minutos
NotebookPage (nível de banco) Páginas filhas, nós e arestas do quadro, cards e suas revisões, fontes e seus trechos, saídas, mensagens de chat ON DELETE CASCADE Apagar uma página apaga a subárvore. Uma página vinculada aos quadros dela a partir de outro tópico tem a casa em outro lugar e fica; só o nó sai
NotebookPage (nível de banco) FocusCycle.notebook_page_id ON DELETE SET NULL Os minutos continuam registrados
NotebookPage (nível de banco) As linhas de tópico e os blocos de um ciclo, e as sessões, por topic_id; o page_id de um bloco e o target_page_id de um lap block ON DELETE CASCADE pelo tópico, SET NULL pela página Apagar um tópico tira ele do ciclo. Uma página apagada deixa o bloco na fila sem página
Goal / Category / Habit (nível de banco) Os vínculos de um tópico ON DELETE SET NULL Os vínculos decoram o tópico. Apagar a meta não pode apagar um caderno

Dois testes amarram esta tabela ao schema. O AccountDeletionIntegrationTest e o ManualAccountDeleteRunbookTest leem do banco cada chave estrangeira que aponta para users, colocam uma linha atrás de cada uma, apagam a conta e esperam que nada sobre. Uma tabela nova do usuário faz os dois falharem até ganhar uma linha ali, e é essa a ideia: um cascade que ninguém exercita é só um comentário. O UserExportCompletenessIntegrationTest lê a mesma lista e falha para qualquer tabela que não tenha uma seção no export nem uma entrada em notIncluded.

Resumo das tabelas do banco

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
    focus_cycles
    focus_micro_tasks
    mood_entries
    daily_briefing
  end

  subgraph calendar["Calendário"]
    calendar_tasks
  end

  subgraph ideas["Quadro de Ideias"]
    quick_notes
  end

  subgraph notebook["Caderno de estudos"]
    notebook_pages
    notebook_board_nodes
    notebook_board_edges
    notebook_cards
    notebook_card_reviews
    notebook_sources
    notebook_source_chunks
    notebook_study_outputs
    notebook_chat_messages
    notebook_roadmap_drafts
  end

  subgraph cycles["Ciclos de estudo"]
    study_cycles
    study_cycle_days
    study_cycle_topics
    study_cycle_blocks
    study_cycle_lap_blocks
    study_cycle_laps
    study_sessions
  end

  subgraph support["Feedback & IA"]
    feedback
    feedback_reply
    feedback_attachment
    chats
    agent_message
    spring_ai_chat_memory
  end

  subgraph auth["Auth"]
    refresh_tokens
    federated_identities
    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.