10 KiB
Database Schema Documentation
Overview
This document provides a comprehensive overview of the Mila Membership Management System database schema.
- DBML file:
database_schema.dbml— full per-column intent notes and relationship edges. - Search-vector performance: see
custom-fields-search-performance.mdfor trigger cost analysis and tuning.
The DBML is hand-maintained (not auto-generated); keep it in sync with priv/repo/migrations/.
Schema Statistics
| Metric | Count |
|---|---|
| Tables | 12 |
| Domains | 4 (Accounts, Membership, MembershipFees, Authorization) |
| Triggers | 3 (member, custom_field_values, member_groups → member search-vector) |
Tables Overview
Accounts Domain
users— authentication accounts. Dual auth (Password + OIDC), optional 1:1 link to a member; email is the source of truth when linked.tokens— JWT storage for AshAuthentication; multiple purposes, revocation by deletion.
OIDC account linking is recorded on the users table via the oidc_id column; there is no separate user_identities table.
Membership Domain
members— club member master data. Full-text + fuzzy search, bidirectional email sync with users, flexible address/contact data,country, optionalvereinfacht_contact_id(external vereinfacht.de contact).custom_field_values— dynamic per-member attributes. Union-type value in JSONB; one value per custom field per member.custom_fields— schema definitions for custom field values (type,required/show_in_overviewflags, optionaljoin_description, auto-generated slug).settings— global application settings (singleton). Club name (also viaASSOCIATION_NAMEenv), member-field visibility/required maps, fee defaults, plus OIDC, SMTP/mail-from, vereinfacht.de, public join-form,registration_enabled, andoidc_onlyconfiguration. See Settings configuration columns.groups— member groupings. Case-insensitive-unique names, auto-generated immutable slugs, optional descriptions; many-to-many with members.member_groups— join table for members ↔ groups. Unique(member_id, group_id), CASCADE delete on both sides (join table only).join_requests— public join flow (onboarding, double opt-in). Status machinepending_confirmation → submitted → approved/rejected; confirmation token stored as hash only, ~24h retention for unconfirmed records.
Authorization Domain
roles— RBAC. Links users to one of four hardcoded permission sets (own_data,read_only,normal_user,admin); system roles are deletion-protected.
MembershipFees Domain
membership_fee_types— fee types with immutable billing interval.membership_fee_cycles— per-member billing cycles with payment status.
Settings configuration columns
The singleton settings row carries runtime configuration (all nullable unless noted). Grouped by area:
- Member overview:
member_field_visibility(JSONB; absent key = visible),member_field_required(JSONB). - Membership fees:
include_joining_cycle(bool, NOT NULL, default true),default_membership_fee_type_id(FK → membership_fee_types, ON DELETE SET NULL). - Registration / login:
registration_enabled(bool, NOT NULL, default true),oidc_only(bool, NOT NULL, default false). - OIDC:
oidc_client_id,oidc_client_secret,oidc_base_url,oidc_redirect_uri,oidc_admin_group_name,oidc_groups_claim. - SMTP / mail-from:
smtp_host,smtp_port(bigint),smtp_username,smtp_password,smtp_ssl,smtp_from_name,smtp_from_email. - vereinfacht.de:
vereinfacht_api_url,vereinfacht_api_key,vereinfacht_club_id,vereinfacht_app_url. - Public join form:
join_form_enabled(bool, NOT NULL, default false),join_form_field_ids(text[]),join_form_field_required(JSONB).
Key Relationships
User (0..1) ←→ (0..1) Member
↓ ↓
Tokens (N) CustomFieldValues (N)
↓ ↓
Role (N:1) CustomField (1)
Member (1) → (N) MembershipFeeCycles
↓
MembershipFeeType (1)
Member (N) ←→ (N) Group
↓ ↓
MemberGroups (N) MemberGroups (N)
Settings (1) → MembershipFeeType (0..1)
Foreign Key On-Delete Behavior
| Relationship | On Delete | Rationale |
|---|---|---|
users.member_id → members.id |
SET NULL | Preserve user account when member deleted |
users.role_id → roles.id |
RESTRICT | Cannot delete a role that still has users |
custom_field_values.member_id → members.id |
CASCADE | Delete values with member |
custom_field_values.custom_field_id → custom_fields.id |
CASCADE | Delete values when the custom field is deleted |
members.membership_fee_type_id → membership_fee_types.id |
RESTRICT | Cannot delete a fee type assigned to members |
membership_fee_cycles.member_id → members.id |
CASCADE | Cycles deleted with member |
membership_fee_cycles.membership_fee_type_id → membership_fee_types.id |
RESTRICT | Cannot delete a fee type with cycles |
settings.default_membership_fee_type_id → membership_fee_types.id |
SET NULL | Clear default if fee type deleted |
member_groups.member_id → members.id |
CASCADE | Association removed; member preserved |
member_groups.group_id → groups.id |
CASCADE | Association removed; group preserved |
join_requests.reviewed_by_user_id is intentionally unconstrained (no FK); reviewed_by_display is denormalized so the UI need not load the reviewer User.
User ↔ Member is an optional 1:1 (both sides may be NULL; entities exist independently). Member ↔ Group is many-to-many through member_groups (CASCADE lives only on the join table).
Important Business Rules
Email Synchronization
- User.email is the source of truth when linked. On linking,
Member.email ← User.email(overwrite). Afterwards changes sync bidirectionally. Validation prevents email conflicts with other unlinked users.
Authentication Strategies
- Password: email + hashed_password. OIDC: email + oidc_id (Rauthy provider), the external identity recorded via the
oidc_idcolumn onusers. At least one method required per user.
Member Constraints
first_name/last_name: optional, but if present min 1 char.email: unique, validated format (5–254 chars).exit_datemust be afterjoin_date.postal_code,country: optional, no format validation.
CustomFieldValue System
- One value per custom field per member. Value stored as a union type in JSONB:
{type: "string|integer|boolean|date|email", value: <actual_value>}. Custom fields can be markedrequiredand toggledshow_in_overview.
Full-Text Search
Implementation
- Trigger on
members(INSERT/UPDATE):update_search_vectorruns functionmembers_search_vector_trigger() - Trigger on
custom_field_values(INSERT/UPDATE/DELETE):update_member_search_vector_on_custom_field_value_changeruns functionupdate_member_search_vector_from_custom_field_value() - Trigger on
member_groups(INSERT/UPDATE/DELETE):update_member_search_vector_on_member_groups_changeruns functionupdate_member_search_vector_from_member_groups() - Index Type: GIN (Generalized Inverted Index)
Weighted Fields
- Weight A (highest): first_name, last_name
- Weight B: email, notes, group names (from member_groups → groups)
- Weight C: city, street, house_number, postal_code, custom_field_values
- Weight D (lowest): join_date, exit_date
Group Names in Search
Group names are included in the member search vector so that searching for a group name (e.g. "Vorstand") finds all members in that group:
- Group names are aggregated from
member_groupsjoined withgroupsand receive weight 'B' - The trigger
update_member_search_vector_on_member_groups_changeruns on INSERT/UPDATE/DELETE onmember_groupsand refreshes the affected member'ssearch_vector - See migration
20260217120000_add_group_names_to_member_search_vector.exs(Issue #375)
Custom Field Values in Search
Custom field values are automatically included in the search vector:
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
- Values are converted to text format for indexing
- Custom field values receive weight 'C' (same as city, etc.)
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
Usage Example
SELECT * FROM members
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
Fuzzy Search (Trigram-based)
- Extension:
pg_trgm; GIN indexes withgin_trgm_opsonfirst_name,last_name,email,city,street,notes. - Similarity threshold: 0.2 (default, configurable) — balances precision/recall.
- Added: November 2025 (PR #187, closes #162).
Fuzzy search combines several strategies (applied as an OR-chain alongside full-text and substring matching):
- Full-text search — primary filter via tsvector.
- Trigram similarity —
similarity(field, query) > threshold. - Word similarity —
word_similarity(query, field) > threshold. - Substring matching —
LIKE/ILIKE. %operator — quick trigram-similarity check.
For the Elixir search action and per-strategy filter functions, see lib/membership/member.ex and custom-fields-search-performance.md.
Database Extensions
Installed extensions are defined in Mv.Repo.installed_extensions/0:
| Extension | Purpose | Notes |
|---|---|---|
ash-functions |
Ash helper SQL functions | installed by Ash |
citext |
Case-insensitive text | users.email |
pg_trgm |
Trigram fuzzy search | added in 20251001141005_add_trigram_to_members.exs; operators %, similarity(), word_similarity() |
gen_random_uuid() is built into PostgreSQL; uuid_generate_v7() is a custom SQL function defined in a migration (not provided by an extension).
Sensitive Data (GDPR / logging)
- Never log:
users.hashed_password(bcrypt), token fields (jti,purpose,extra_data), OIDC/SMTP/vereinfacht secrets insettings. - Personal data: all member fields, user email, join-request applicant data.
Last Updated: 2026-06-15
Schema Version: 1.6 (12 tables)
Database: PostgreSQL 17.6 (dev) / 16 (prod)