Domain Model

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.

The big picture

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

User

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
email 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.

Category

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

Habit

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

Task

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).

Goal

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

The two shared components

Two embeddables carry the gamification state, and which entities embed which is a design decision in itself.

XpProgress

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.

CheckProgress

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.

Routine

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

Routine (abstract base)

Fields: name, iconId. Embeds XpProgress and CheckProgress.

Relationships

DiaryRoutine

Extends Routine, adding routineSections (OneToMany, cascade ALL, orphan removal, ordered by orderIndex).

RoutineSection

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.

Schedule

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.

Item groups and checks

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.

Daily history: EntityCheckDay and EntityXpDay

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.

Routine snapshots

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.

Feedback

Product role: users report bugs and ask for features inside the app; an admin reads, replies, and tracks status.

AI agent chats

Product role: the agent chat that can create routines and answer questions keeps its conversations in the domain, with two layers of memory.

Auth and account entities

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.

XP progression system

The level curve

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.

XP flow on a routine check

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

Inheritance strategies

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.

Cascade and deletion rules

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

Database tables summary

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.