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 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"]
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. 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.
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 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
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).
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
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, 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"]
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
Estende Routine, adicionando routineSections (OneToMany, cascade ALL, orphan removal, ordenadas por orderIndex).
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.
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, 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.
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: 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.
kind é POMODORO, SHORT_BREAK ou LONG_BREAK, gravado como varchar espelhado por um CHECK (o padrão da V19/V25). minutes é limitado a 1..180 por um CHECK também, repetindo o clamp do cliente onde ele não pode ser contornado.item_group_id é nulo permitido e ON DELETE SET NULL: um ciclo pode rodar sem nada selecionado, e apagar uma rotina não pode apagar o fato de que alguém focou 25 minutos naquela manhã.cycle_date é o dia local do DONO, resolvido pelo fuso dele, como toda linha datada aqui.notebook_page_id (adicionado na V34) diz em qual página do caderno de estudos o ciclo rodou, quando começou de uma. É um id simples e não uma associação: nada aqui lê a página, e o caderno só soma minutos de POMODORO por id de página. Os ciclos de estudo leem a mesma coluna: um pomodoro registrado com uma página credita o ciclo que tem o tópico dessa página, e a fatia de hoje e o histórico de um ciclo somam esses minutos por tópico. ON DELETE SET NULL pelo mesmo motivo do item group.FocusMicroTask (tabela focus_micro_tasks): uma coisa pequena feita junto de um item da rotina, num dia.
item_group_id NOT NULL, cascade na exclusão), não a uma sessão. Mudar de item não leva a lista junto.pinned é uma flag de template. Selecionar outro item cria uma linha para aquele nome lá também, então um "alongar" fixado percorrido por quatro itens deixa quatro linhas, uma por item, cada uma marcável por conta própria. O conjunto de fixadas é derivado das próprias linhas do usuário com pinned = true — sem segunda tabela para manter em sincronia — e fixar é propriedade do NOME: fixe em qualquer lugar e está fixado em todos, desafixe em qualquer lugar e deixa de ser template em todos.UNIQUE (user, dia, item, nome) é o que torna materializar um template idempotente: a leitura da lista de um item cria as linhas fixadas que faltam antes de responder, e a segunda leitura não cria nada. O conjunto é limitado aos 50 nomes fixados mais recentes, porque essa leitura corre num GET e insere linhas. Apagar uma linha fixada também desafixa o NOME — senão a leitura seguinte trazia-a logo de volta.done_at é timestamp e não booleano, então "feito" carrega o quando. Nulo é aberto.order_index é a posição na lista daquele item, escopada a (user, dia, item). Linhas escritas antes de existir ordenação carregam 0, e o created_at continua a ser o desempate na consulta, então uma lista que ninguém arrastou volta na ordem em que foi escrita. Reordenar envia a lista INTEIRA e não um movimento: o cliente já tem o que está a mostrar, e dois clientes a arrastar ao mesmo tempo caem numa das duas ordens em vez de um intercalado que nenhum pediu.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.
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.
UNIQUE (user_id, entry_date) sustenta o modelo. Um dia tem um humor, então registar de novo é uma atualização, e todos os leitores (a faixa da semana, o calendário do mês, a contagem de dias seguidos) podem confiar que uma data significa uma linha.INSERT ... ON CONFLICT DO UPDATE nativo, e não por ler-depois-gravar. Dois toques no widget do dashboard com meio segundo de intervalo passam ambos por qualquer verificação que a aplicação consiga fazer. O perdedor bate então na constraint única DENTRO de uma transação, o que a envenena, e não sobra nada para repetir. Deixar o Postgres resolver o conflito significa que não há corrida para recuperar.PUT /mood/{date} substitui o registo, nota incluída. O PATCH /mood/{date} define o nível e não tem campo nenhum onde uma nota caiba, por isso o widget do dashboard e o assistente, que usam ambos o PATCH, não conseguem apagar texto que nunca carregaram. Uma rota única de upsert teria feito de perder o diário um acidente a um toque: escrever de manhã, tocar numa carinha à noite, texto perdido.mood é um inteiro de 1 a 5 com um CHECK a espelhar a escala, e não um varchar de enum como na V19 e na V27. Este é genuinamente ordinal, porque a média da semana é aritmética sobre ele, e as etiquetas são traduzidas nos clientes. É integer e não smallint porque o campo da entidade é um Integer e o ddl-auto: validate do Hibernate recusa arrancar com int2 contra INTEGER.note é text, sem limite de comprimento na coluna. O teto de 4000 caracteres vive no DTO, onde ultrapassá-lo é um erro de validação que o cliente mostra, em vez de um corte que ninguém desfaz.entry_date é o dia local do DONO, resolvido pelo UserDateResolver antes da escrita, como toda linha datada aqui. Dias depois do hoje do dono são recusados no serviço com MOOD_FUTURE_DATE, por isso as células desativadas do calendário são uma conveniência e não a imposição. Dias passados continuam editáveis.ON DELETE CASCADE. Um diário não é histórico que valha a pena guardar para além da conta que o escreveu, e este é o dado mais pessoal que o produto armazena.@Cacheable com chave no utilizador serviria a resposta de um intervalo para outro, e uma chave composta precisaria do seu próprio caminho de invalidação a cada escrita.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.
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 |
tasks. Uma tarefa de rotina só é concluída por um check de rotina, paga XP que depende da sequência e se apaga no dia seguinte a um check de tarefa única. Uma tarefa do calendário não faz nada disso: é concluída por conta própria, paga um valor fixo e fica no seu dia para sempre, feita ou não. Juntar as duas entortaria as três regras para toda tarefa de rotina só para servir a esta. Uma tarefa passada que ninguém concluiu continua no seu próprio dia como não feita e nunca vai para hoje.CalendarReminderTime.instantOf calcula isso a cada leitura. O notifyAt da resposta, a lista de próximos lembretes que o navegador consulta e o job de e-mail chamam essa mesma função. Uma coluna guardada ficaria velha no dia em que o dono trocasse de fuso ou de horário padrão, e ninguém perceberia. Dias inteiros de antecedência saem do calendário e o resto sai do relógio, então "1 dia antes" mantém o horário de parede mesmo com uma mudança de horário de verão no meio.acknowledged_at, reminder_email_sent_at e completed_at são todo o estado do lembrete. Editar a data, o horário ou a antecedência limpa os dois primeiros, então uma tarefa movida lembra de novo nos dois canais.findByIdAndUserId), então a tarefa de outra pessoa se comporta igual a uma que não existe: 404 CALENDAR_TASK_NOT_FOUND.(user_id, task_date) atende toda leitura dos clientes. Um parcial em task_date cobre as tarefas que ainda não tiveram reação, que é o que o job de e-mail varre a cada cinco minutos em todas as contas, e um parcial em reminder_email_sent_at deixa o job contar os e-mails do dia para o seu teto. Tarefas feitas e respondidas, que são a maior parte da tabela depois de um tempo, não custam nada ao job.ON DELETE CASCADE, como mood_entries. Uma lista de afazeres não significa nada depois da conta que a escreveu.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 |
QuickNotePlacement: uma nota tem 200 por 160, uma célula é isso mais um espaço de 24 em cada direção (224 por 184), cinco colunas e quantas linhas forem precisas. Uma nota criada sem lugar fica na primeira célula, linha por linha a partir de (0, 0), cujo retângulo do tamanho de uma nota não se sobrepõe a nenhuma nota ativa. O "Organizar" coloca todas as notas ativas na mesma grade, agrupadas por cor na ordem do enum e depois pela criação, e dá a cada uma um z igual à sua posição. A classe é pura e tem testes unitários próprios, e os clientes espelham os tamanhos em @beyou/types para desenhar a nota do tamanho com que o servidor a posiciona.archived_at IS NULL, e o índice (user_id, archived_at) atende tanto a leitura do quadro quanto essa contagem.converted_task_id é ON DELETE SET NULL a partir de tasks. A tarefa tem vida própria depois que existe. Apagá-la, à mão ou pela limpeza de tarefas únicas, não pode ser barrado por um post-it arquivado, e a nota simplesmente deixa de apontar para ela. Um índice parcial na coluna (só as linhas que têm valor) evita que esse SET NULL varra as notas de todas as contas a cada tarefa apagada. O id não é mapeado como @ManyToOne para que ler o quadro nunca arraste junto uma tarefa, as categorias dela e o cache de tarefas.pg_advisory_xact_lock(hashtext('quick-notes:' + userId))) antes de ler as notas ativas. Um quadro vazio não tem linha para travar, e sem a trava um Enter duplo põe duas notas na mesma célula e dois creates com 199 notas chegam a 201. A conversão em tarefa trava a linha da própria nota, então um envio duplo faz uma tarefa e um 409.findByIdAndUserId e o gêmeo com trava), então a nota de outra pessoa se comporta igual a uma que não existe: 404 QUICK_NOTE_NOT_FOUND. É o segundo domínio, depois do calendário, a responder assim.ON DELETE CASCADE a partir de users, como calendar_tasks. Um quadro de ideias não significa nada depois da conta que o escreveu.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.
page_id preenchido, a página que ele abre) ou SECTION (page_id nulo, uma faixa com rótulo, width e height), garantido por um CHECK.page_id é coluna do próprio nó e não reaproveita a árvore, porque um nó vinculado abre uma página cuja casa fica em outro tópico. UNIQUE (board_page_id, page_id) põe uma página uma vez em cada quadro.x e y em pixels do quadro.UNIQUE (source_node_id, target_node_id), mais um CHECK contra autolaço). As arestas só ordenam os nós.NotebookCard (notebook_cards) e NotebookCardReview (notebook_card_reviews):
front, back, um source_label opcional e o estado do SM-2: due_on (uma data no fuso do dono), interval_days (teto de 365), ease (começa em 2,5, piso de 1,3), reps e lapses.rating (AGAIN, HARD, GOOD, EASY), review_date no fuso do dono e xp_paid. A sequência de revisão conta os dias distintos de revisão. POST /notebook/reviews/finish paga as linhas ainda não pagas do dia, no máximo 30, e um dia que nunca foi coletado não recebe depois.NotebookSource (notebook_sources) e os trechos (notebook_source_chunks):
status é PENDING, READING, READY ou FAILED, com progress de 0 a 100 e um error_key quando falha. enabled a desliga sem apagar.content, seu ordinal e o page_number do PDF. search é um tsvector gerado (configuração simple, índice GIN) para o fallback full-text, e embedding é um real[] com o embedding_model que o gerou. Nenhuma entidade JPA mapeia essa tabela: o SourceChunkStore a lê e grava com SQL puro. Também não há pgvector; o tópico do caderno de estudos explica por quê.NotebookStudyOutput (notebook_study_outputs) e NotebookChatMessage (notebook_chat_messages):
content é markdown dentro de JSON nas três primeiras e, num quiz, as perguntas com as respostas, que só saem do servidor na correção. score e total guardam a última correção, e passed_at a primeira aprovação, o momento em que os 20 de XP foram pagos. Uma página guarda uma OVERVIEW.role USER ou ASSISTANT, o content e as citations da resposta em JSON. Desde a V41 uma resposta também pode ter diagram, o diagrama que o tutor desenhou com ela, já limpo e guardado como JSON em text; fica nulo nas perguntas e nas respostas sem diagrama.NotebookRoadmapDraft (notebook_roadmap_drafts), adicionada na V35:
status é DRAFTING enquanto o modelo escreve em segundo plano, depois READY ou FAILED com um error_key.request é o que foi pedido, result os nós rascunhados e choices as marcações da pessoa, uma por nó, tudo JSON em text. started_at é quando a chamada atual ao modelo começou, para o cronômetro do diálogo.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):
name (80 caracteres), o default_mode que os blocos novos recebem (TIME ou GOAL, GOAL por padrão), max_block_minutes (15 a 240, 90 por padrão) e position entre os ciclos da pessoa. lap_number e lap_started_at descrevem a volta em andamento; as voltas fechadas são linhas próprias. archived_at preenchido quer dizer arquivado: o ciclo mantém o histórico e libera os tópicos, e nada o desarquiva.@DynamicUpdate, e toda escrita que mexe na volta pega antes o lock da linha, para uma renomeação e um crédito que fecha a volta não sobrescreverem um ao outro.weekday (MONDAY a SUNDAY), com chave (cycle_id, weekday). As sete sempre existem, então um dia de descanso é uma linha com minutes = 0 (0 a 720) e os minutos da semana são uma soma simples. start_time é opcional e dá horários à fatia do dia.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.
UNIQUE (cycle_id, topic_id) põe um tópico uma vez por ciclo.(topic_id) WHERE active impede a mesma página de ser assunto ativo duas vezes. A sobreposição na cadeia de casa (um assunto acima ou abaixo de outro) é recusada pelo StudyCycleService sob um lock consultivo por usuário, porque um índice não enxerga ancestrais. Arquivar põe active em false, e é isso que libera o tópico.StudyCycleBlock (study_cycle_blocks): um bloco da fila. Só configuração, para um replanejamento poder reescrevê-lo sem mexer no que foi estudado.
position é o lugar na fila, a partir de 1, topic_id o tópico que ele estuda (cascade), mode TIME ou GOAL, e planned_minutes de 5 a 600.goal_source (NEXT ou PAGE) só existe em blocos GOAL. page_id é o conceito escolhido de um bloco GOAL + PAGE, ou uma dica opcional num bloco TIME, e o ON DELETE SET NULL deixa o bloco quando a página some. require_quiz exige também o quiz da página, e note guarda até 200 caracteres.StudyCycleLapBlock (study_cycle_lap_blocks) e StudyCycleLap (study_cycle_laps):
minutes (pode passar dos previstos, porque o último bloco de um tópico recebe a sobra), target_page_id (o conceito que um bloco GOAL resolveu, SET NULL se a página for apagada), state (OPEN, DONE, UNDERSTOOD ou TURN_USED) e closed_at. UNIQUE (block_id, lap_number). A linha nasce na primeira vez que a volta credita, resolve ou fecha o bloco, então um bloco sem linha está OPEN e sem minutos. @DynamicUpdate, porque um crédito grava os minutos e uma página concluída em outro lugar grava o estado.number, started_at, closed_at, minutes, planned_minutes, understood, turns_used e blocks, com UNIQUE (cycle_id, number). Os números ficam guardados porque um bloco editado ou apagado depois mudaria esses valores se eles fossem recalculados.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).
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.
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 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.
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. |
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.
mood_entries, e o diálogo lê a entrada de ontem direto da API de humor.UNIQUE (user_id, briefing_date) carrega o modelo. O serviço lê antes de escrever, mas dois clientes que abrem no mesmo segundo passam os dois por essa leitura, e a restrição é o que faz o perdedor tentar de novo contra a linha do vencedor em vez de pagar um segundo parágrafo.narrative_status é PENDING | READY | UNAVAILABLE, um varchar com CHECK, seguindo a V19 e a V27. Nenhum dos três quer dizer que algo correu mal. A metade dos factos responde sempre, portanto um texto nulo é um estado vulgar, e os clientes mostram a própria cópia traduzida.briefing_date é o dia da própria conta, resolvido pelo UserDateResolver como qualquer outra data neste esquema.snapshotId e o snapshotCheckId que o POST /routine/snapshot/check e o /skip já aceitam. As regras retroativas (decaimento de XP, o resultado carimbado no dia do próprio snapshot, a verificação de dono) ficam no único serviço que já as aplica.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.
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.