This document covers every entity in the Beyou domain, explaining what each one does for the user and how it is structured in the database. The goal is a clear mental model of the data layer before reading or writing code.
One ground rule shapes everything here: the schema belongs to Flyway. Migrations under db/migration/ create and evolve every table, and Hibernate runs with ddl-auto: validate in every environment, so an entity mapping that disagrees with the migrations fails at startup instead of silently rewriting the schema.
Beyou's domain revolves around a simple idea: a user creates habits, tasks, and goals, organizes them into categories, and executes them through daily routines. Every check generates XP and writes history. Around that core loop sit four supporting families: daily history rows, immutable routine snapshots, feedback threads, and the AI agent's chats.
flowchart TD
U["๐ค User"]
U --> CAT["๐ Categories"]
U --> HAB["๐ช Habits"]
U --> TSK["๐ Tasks"]
U --> GOL["๐ฏ Goals"]
U --> RTN["๐ Routines"]
CAT -.-|"tags"| HAB
CAT -.-|"tags"| TSK
CAT -.-|"tags"| GOL
RTN --> SEC["๐ Sections"]
SEC --> HG["Habit Groups"]
SEC --> TG["Task Groups"]
HG -.-|"references"| HAB
TG -.-|"references"| TSK
HG --> CHK["โ
Checks"]
TG --> CHK
CHK -->|"generates"| XP["๐ฎ XP"]
CHK -->|"writes"| HIST["๐
Daily history<br/>check + xp rows"]
RTN -->|"frozen daily into"| SNAP["๐ง Snapshots"]
Product role: the central entity. Every piece of data in Beyou belongs to a user. The user has a profile (name, photo, motivational phrase), preferences (theme, language, timezone, dashboard widgets), a gamification state (XP, level, streaks), and two small text fields the AI agent uses as memory.
Key fields
| Field | Type | Notes |
|---|---|---|
| id | UUID | Auto-generated |
| name | String | |
| String | Unique | |
| password | String | BCrypt hash |
| isGoogleAccount | boolean | True for OAuth users |
| emailVerified | boolean | New accounts confirm by e-mail before first login |
| verificationToken / verificationTokenExpiry | String / LocalDateTime | Verification state lives as columns here, not in a separate entity |
| verificationTokenSentAt | Instant | When the last verification mail went out, read by the resend cooldown. An Instant against the LocalDateTime beside it because it is compared to a clock and never displayed. Null means no mail on record, which is how every row predating the column reads, and how a row whose send failed is reset |
| perfilPhrase / perfilPhraseAuthor | String | Optional motivational quote |
| perfilPhoto | String (512) | The Google CDN avatar URL, set at OAuth sign-in. NOT a path to an uploaded photo: an upload writes {upload-dir}/user-photos/{userId}.jpg and never touches this column, so it stays null for accounts that never signed in with Google. A profile is served the file first and this second, and removal has to clear both |
| themeInUse / languageInUse | String | Preferences |
| timezone | String | Required. The account's IANA zone, taken from the client at signup and falling back to UTC. Every date the app ever writes is resolved against it |
| timezoneSource | TimezoneSource enum | DEFAULT, DETECTED or EXPLICIT: whether the zone above was ever actually chosen. Only DEFAULT may be corrected automatically |
| widgetsIdInUse | List of String | Active dashboard widget IDs |
| isTutorialCompleted | boolean | Onboarding flag |
| userContext | String (2000) | The AI agent's global memory about this user |
| xpDecayStrategy | XpDecayStrategy enum | GRADUAL, FLAT, or TIME_WINDOW; how late check-ins lose XP |
| maxConstance | Integer | Highest streak ever achieved |
| completedDays | Set of LocalDate | Days with completed routine activity |
| userRole | UserRole enum | USER or ADMIN (admin only by manual database update) |
| constanceConfiguration | ConstanceConfiguration enum | ANY or COMPLETE |
Embedded: XpProgress and CheckProgress (both described below).
Relationships: the user owns six collections, all OneToMany with cascade ALL and orphan removal: categories, habits, tasks, goals, routines, and routine snapshots. Account deletion works entirely through this cascade; the tasks collection was added precisely to close a gap in it.
Business logic: implements Spring Security's UserDetails. Streak calculation used to live here as a walk over completedDays; it now belongs to UserStreakService in the checkday package, on top of the daily history rows.
Product role: categories organize habits, tasks, and goals by life area ("Health", "Career"). Categories earn XP too, so users can see where they invest the most effort.
Key fields: name, description, iconId, timestamps.
Embedded: XpProgress only. Deliberately no CheckProgress: a category earns XP but is never itself checked.
Relationships
Product role: a behavior the user wants to build. Each habit has its own level and XP progression, plus a streak record, so showing up keeps paying.
Key fields
| Field | Type | Notes |
|---|---|---|
| name / description / iconId | String | |
| importance | Integer | 1 to 4 |
| dificulty | Integer | 1 to 4. Yes, misspelled: it is the real field, column, and wire-format name |
| motivationalPhrase | String | Optional |
Embedded: XpProgress and CheckProgress. The old standalone constance counter is gone; CheckProgress replaced it.
Relationships
Product role: a concrete action. Unlike habits, tasks can be one-time ("Buy groceries"). One-time tasks get a soft-delete date after being checked, giving the system a grace period before a scheduler removes them.
Key fields
| Field | Type | Notes |
|---|---|---|
| name / description / iconId | String | |
| importance / dificulty | Integer | 1 to 4, same spelling as Habit |
| oneTimeTask | boolean | True for non-recurring tasks |
| markedToDelete | LocalDate | Set on completion of one-time tasks; TaskCleanupScheduler collects them |
Embedded: CheckProgress only. A task carries no XP of its own; checking one feeds the user, the routine, and the categories.
Relationships: belongs to a User (ManyToOne); tagged by Categories (ManyToMany, owning side, join table task_category).
Product role: a measurable objective ("Run 100 km"). Progress is currentValue against targetValue, and completion pays a calculated XP reward.
Key fields
| Field | Type | Notes |
|---|---|---|
| name / iconId / description | String | |
| targetValue / currentValue | Double | The measurable part |
| unit | String | km, books, etc. |
| complete | Boolean | Completion flag |
| motivation | String | Optional |
| startDate / endDate | LocalDate | Window and deadline |
| xpReward | double | Calculated on completion |
| completeDate | LocalDate | |
| status | GoalStatus enum | NOT_STARTED, IN_PROGRESS, COMPLETED (stored as strings) |
| term | GoalTerm enum | SHORT_TERM, MEDIUM_TERM, LONG_TERM |
Relationships: belongs to a User (ManyToOne); tagged by Categories (ManyToMany, owning side, join table goal_category).
Invariant worth knowing: constructing a goal with status COMPLETED silently downgrades it to IN_PROGRESS. Only the explicit complete endpoint pays XP, so nobody can post a pre-completed goal and farm a reward.
XP calculation: GoalXpCalculator multiplies four factors.
flowchart LR
TV["๐ฏ Target Value"] --> BASE["Base XP<br/>50 / 100 / 200 / 300"]
TV --> DIFF["Difficulty<br/>1.0x โ 2.0x"]
DL["๐
Days in window"] --> URG["Urgency<br/>1.0x โ 1.5x"]
CD["โ
Completed before deadline?"] --> CON["Consistency<br/>1.0x โ 1.3x"]
BASE --> TOTAL["Total XP Reward"]
DIFF --> TOTAL
URG --> TOTAL
CON --> TOTAL
Two embeddables carry the gamification state, and which entities embed which is a design decision in itself.
| Field | Type | Notes |
|---|---|---|
| xp | double | Total accumulated XP |
| level | int | Current level |
| actualLevelXp / nextLevelXp | double | Boundaries of the current level |
Embedded by User, Category, Habit, and Routine: the four things that level up. addXp and removeXp walk the level curve in both directions through a level-lookup function, and cap at the top level.
| Field | Type | Notes |
|---|---|---|
| check_current_streak / check_best_streak | int | Streaks |
| check_total_check_ins | int | Lifetime count |
| check_first_check_in_date / check_last_check_in_date | LocalDate | Nullable bounds |
Embedded by User, Habit, Task, and Routine: the four things that get checked. Category is excluded on purpose, and Task appears here despite having no XP.
Product role: the daily execution tool. A routine has sections ("Morning", "Work", "Evening"), each holding habit and task groups. Checking items generates XP across every related entity.
Inheritance: Routine is an abstract base using single-table inheritance with a dtype discriminator. DiaryRoutine is the only concrete type today.
flowchart TD
R["๐ Routine<br/>(abstract, single-table)"]
R --> DR["๐ DiaryRoutine"]
DR --> RS1["๐ Section: Morning"]
DR --> RS2["๐ Section: Evening"]
RS1 --> HG1["๐ช Habit Group"]
RS1 --> TG1["๐ Task Group"]
HG1 --> HC["โ
HabitGroupCheck"]
TG1 --> TC["โ
TaskGroupCheck"]
Fields: name, iconId. Embeds XpProgress and CheckProgress.
Relationships
Extends Routine, adding routineSections (OneToMany, cascade ALL, orphan removal, ordered by orderIndex).
Fields: name, iconId, startTime, endTime, orderIndex, favorite.
Relationships: belongs to a Routine (ManyToOne); contains HabitGroups and TaskGroups (OneToMany, cascade ALL, orphan removal). One quirk to be aware of: these collections are unidirectional and mapped through join tables (routine_sections_habit_groups, routine_sections_task_groups), while ItemGroup also carries its own routine_section_id column. The section-to-group link is effectively mapped twice.
Product role: which days of the week a routine is active, which decides whether it appears on the dashboard for a given day.
The entity is minimal: an id plus a set of WeekDay enums stored in the schedule_days collection table. The foreign key lives on the routine's side. One gotcha: the enum identifiers are capitalized words (Monday, Tuesday, ...), not SCREAMING_CASE, and they are stored as strings, which is also what a CHECK constraint on the table enforces and what every response emits. Incoming JSON is the one place that forgives a mismatch โ any letter case, and the Portuguese day names, resolve to the same constant โ because an agent tool call that guessed SCREAMING_CASE used to cost an entire LLM round trip to correct.
Product role: placing a habit or task inside a routine section creates a "group", the trackable instance that gets checked or skipped each day. Each check is a historical record with date, time, and the XP it generated.
ItemGroup (abstract, joined inheritance): startTime, endTime, and the ManyToOne back to its section. Concrete types HabitGroup (references a Habit) and TaskGroup (references a Task), each owning their check collections (cascade ALL, no orphan removal, so history survives).
BaseCheck (abstract, joined inheritance): checkDate, checkTime, checked, skipped, xpGenerated. Concrete types HabitGroupCheck and TaskGroupCheck, each owned by their group.
Product role: the dashboard's history and progress widgets need per-day answers ("what happened to this habit on Tuesday?", "how much XP did this category earn this week?"). Walking the raw check tables for that is expensive and fragile, so two dedicated history tables record one row per entity per day.
EntityCheckDay (table entity_check_day): one outcome per entity per day, unique on (owner_type, owner_id, day).
EntityXpDay (table entity_xp_day): the net XP delta per entity per day, same uniqueness pattern.
Product role: routines change. Sections get renamed, habits get removed, whole routines get deleted. Without snapshots, yesterday's view of your day would silently rewrite itself. So every scheduled day, each routine is frozen into an immutable copy, and past days render exactly as they looked.
RoutineSnapshot (table routine_snapshot): unique per (routine, day).
SnapshotCheck (table snapshot_check): one row per habit or task group in the frozen routine.
Late check-ins and XP decay: checking a past day through a snapshot still pays XP, but decayed according to the user's chosen XpDecayStrategy:
| Strategy | Behavior |
|---|---|
| GRADUAL | 0.8x one day late, then 0.6x, 0.4x, and 0.2x from four days on |
| FLAT | 0.5x no matter how late |
| TIME_WINDOW | Full XP up to two days late, nothing after |
The snapshot scheduler runs per timezone, using each account's own timezone column, so a routine is frozen at that user's midnight rather than the server's.
Product role: users report bugs and ask for features inside the app; an admin reads, replies, and tracks status.
Product role: the agent chat that can create routines and answer questions keeps its conversations in the domain, with two layers of memory.
Five small entities hang off the account. Three hold hashes for the security flows, all ManyToOne to User; the other two hold a preference and a log of the mail that preference allowed:
| Entity | Table | What it holds |
|---|---|---|
| RefreshToken | refresh_tokens | Hash of the 15-day refresh token, expiry, revokedAt |
| PasswordResetToken | password_reset_tokens | Hash of the reset token, expiry, usedAt |
| AccountDeletionCode | account_deletion_codes | BCrypt hash of a six-digit code, expiry, usedAt, and an attempts counter that kills the code after a few wrong guesses |
| NotificationPreferences | notification_preferences | Whether the account may be sent engagement mail, plus the token an unsubscribe link carries. OneToOne rather than ManyToOne, keyed by the user's own id via @MapsId so the key and the association cannot disagree |
| NotificationSend | notification_sends | One row per engagement mail actually sent: the kind, and the RECIPIENT'S local date rather than the server's. A UNIQUE constraint on (user, kind, day) is what stops the hourly pass mailing the same thing twice; the same rows answer the per-account gap and the global daily cap |
E-mail verification is the exception: it lives as columns on the users table rather than as its own entity. That also means the token sits there in plaintext, unlike the reset and deletion tokens beside it, which are stored as BCrypt hashes.
The unsubscribe token is stored raw too, and that one is a decision rather than an inheritance. The three above are one-shot secrets, so a hash is free. An unsubscribe token is stable โ every engagement mail for the rest of the account's life links to it โ and a hash cannot be un-hashed to build that link, so hashing would force a new token per send and kill the link in every message already delivered. A row's absence means the account has never been mailed and never opened the setting; readers must treat that as opted in.
Unlike the three token tables, this one has no expiry and no single-use marker: a preference is not spent by being used.
XpByLevel (table xp_by_level) is a pure reference table: one row per level, holding the XP threshold to reach it. It is seeded by a repeatable Flyway migration with a quadratic curve:
threshold(level) = round(50 ร levelยฒ)
Levels run 0 to 100. Early levels come fast (level 2 costs 200 XP), late ones demand sustained effort (level 100 sits at 500,000). Lookups are cached per level, and XpProgress walks this curve in both directions when XP is added or removed.
sequenceDiagram
participant U as User
participant R as Routine Service
participant X as XpProgress
participant H as History tables
U->>R: Check habit in routine
R->>X: habit.addXp / category.gainXp / routine.addXp / user.addXp
R->>R: Record HabitGroupCheck with xpGenerated
R->>H: Write EntityXpDay deltas (user, category, habit, routine)
R->>H: Write EntityCheckDay outcome (DONE)
R-->>U: Updated XP across all entities
| Strategy | Used by | How it works |
|---|---|---|
| Single table | Routine โ DiaryRoutine | One table with a dtype discriminator. Fast queries; tolerable NOT NULL embedded columns only because there is a single subclass. |
| Joined | ItemGroup โ HabitGroup / TaskGroup, BaseCheck โ HabitGroupCheck / TaskGroupCheck | Base table plus child tables joined by foreign key. Cleaner schema, one more join per query. |
Understanding the cascades matters most at account deletion, which relies on them end to end.
| Parent | Children | Cascade | Orphan removal |
|---|---|---|---|
| User | Categories, Habits, Tasks, Goals, Routines, RoutineSnapshots | ALL | Yes. Deleting a user removes everything |
| User (DB level) | EntityCheckDay, EntityXpDay rows | ON DELETE CASCADE | Handled by the database FK |
| DiaryRoutine | RoutineSections | ALL | Yes |
| RoutineSection | HabitGroups, TaskGroups | ALL | Yes |
| Routine | Schedule | REMOVE | No. Unscheduling is explicit |
| Habit | HabitGroups | ALL | No. Deleting a habit does not silently rewrite routines |
| HabitGroup / TaskGroup | Checks | ALL | No. Check history is preserved |
| RoutineSnapshot | SnapshotChecks | ALL | Yes |
flowchart LR
subgraph core["Core"]
users
categories
habits
tasks
goals
end
subgraph joins["Join tables"]
habit_category
task_category
goal_category
schedule_days
end
subgraph routine["Routine"]
routines
routine_sections
schedules
item_groups
habit_groups
task_groups
base_checks
habit_group_checks
task_group_checks
end
subgraph history["History & snapshots"]
entity_check_day
entity_xp_day
routine_snapshot
snapshot_check
end
subgraph support["Feedback & AI"]
feedback
feedback_reply
feedback_attachment
chats
agent_message
spring_ai_chat_memory
end
subgraph auth["Auth"]
refresh_tokens
password_reset_tokens
account_deletion_codes
notification_preferences
notification_sends
end
subgraph system["Reference & docs"]
xp_by_level
docs_tables["docs_* (8 tables)"]
end
All primary keys are UUIDs except xp_by_level, whose key is the level itself. Timestamps are set via JPA lifecycle callbacks. The docs_* tables follow one repeated pattern: a topic root with a unique key plus a per-locale content row, imported from the beyou-arch-design repository.