docs(db): refresh, condense and align database and groups docs

This commit is contained in:
Moritz 2026-06-15 21:53:36 +02:00
parent 5d8f173529
commit 0b36a43edc
4 changed files with 360 additions and 1875 deletions

View file

@ -6,8 +6,9 @@
// - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
//
// Version: 1.4
// Last Updated: 2026-01-13
// Version: 1.6
// Last Updated: 2026-06-15
// Hand-maintained (NOT auto-generated). 12 tables.
Project mila_membership_management {
database_type: 'PostgreSQL'
@ -25,15 +26,16 @@ Project mila_membership_management {
- GDPR-compliant data management
## Domains:
- **Accounts**: User authentication and session management
- **Membership**: Club member data and custom fields
- **Accounts**: User authentication, sessions, OIDC strategy identities
- **Membership**: Club member data, custom fields, groups, settings, public join requests
- **MembershipFees**: Membership fee types and billing cycles
- **Authorization**: Role-based access control (RBAC)
## Required PostgreSQL Extensions:
- uuid-ossp (UUID generation)
## Required PostgreSQL Extensions (see Mv.Repo.installed_extensions/0):
- ash-functions (Ash helper SQL functions)
- citext (case-insensitive text)
- pg_trgm (trigram-based fuzzy search)
UUIDv7 ids use uuid_generate_v7(), a custom SQL function defined in a migration (not an extension).
'''
}
@ -135,7 +137,8 @@ Table members {
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
vereinfacht_contact_id text [null, note: 'External contact id from the vereinfacht.de API (no FK; null if unlinked)']
indexes {
email [unique, name: 'members_unique_email_index']
search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)']
@ -169,7 +172,8 @@ Table members {
**Search Capabilities:**
1. Full-Text Search (tsvector):
- `search_vector` is auto-updated via trigger
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
- Weighted fields (A/B/C/D map): see the "Weighted Fields" section of
database-schema-readme.md (single source of truth, matches the search trigger)
- GIN index for fast text search
2. Fuzzy Search (pg_trgm):
@ -225,7 +229,7 @@ Table custom_field_values {
**Constraints:**
- Each member can have only ONE custom field value per custom field
- Custom field values are deleted when member is deleted (CASCADE)
- Custom field cannot be deleted if custom field values exist (RESTRICT)
- Custom field values are deleted when the custom field is deleted (CASCADE)
**Use Cases:**
- Custom membership numbers
@ -241,8 +245,9 @@ Table custom_fields {
slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.']
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
description text [null, note: 'Human-readable description']
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
join_description text [null, note: 'Optional label shown for this field on the public join form (e.g., a GDPR confirmation text); supports inline external links. Falls back to name when null.']
required boolean [not null, default: false, note: 'If true, all members must have this custom field']
show_in_overview boolean [not null, default: true, note: 'If true, this custom field is displayed in the member overview table and can be sorted']
indexes {
name [unique, name: 'custom_fields_unique_name_index']
@ -259,8 +264,9 @@ Table custom_fields {
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
- `value_type`: Enforces data type consistency
- `description`: Documentation for users/admins
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
- `join_description`: Optional label shown for this field on the public join form (falls back to `name` when null)
- `required`: Enforces that all members must have this custom field
- `show_in_overview`: When true, the field is shown in the member overview table and can be sorted
**Slug Generation:**
- Automatically generated from `name` on creation
@ -275,13 +281,13 @@ Table custom_fields {
- `name` must be unique across all custom fields
- `slug` must be unique across all custom fields
- `slug` cannot be empty (validated on creation)
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
- Deleting a custom field cascades: its custom_field_values are deleted too (ON DELETE CASCADE)
**Examples:**
- Membership Number (string, immutable, required) → slug: "membership-number"
- Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
- Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
- Certification Date (date, immutable, optional) → slug: "certification-date"
- Membership Number (string, required) → slug: "membership-number"
- Emergency Contact (string, optional) → slug: "emergency-contact"
- Certified Trainer (boolean, optional) → slug: "certified-trainer"
- Certification Date (date, optional) → slug: "certification-date"
'''
}
@ -399,8 +405,8 @@ Ref: custom_field_values.member_id > members.id [delete: cascade]
// CustomFieldValue → CustomField (N:1)
// - Many custom_field_values can reference one custom field
// - CustomFieldValue type defines the schema/behavior
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
// - ON DELETE CASCADE: deleting the custom field deletes its custom_field_values
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: cascade]
// Member → MembershipFeeType (N:1)
// - Many members can be assigned to one fee type
@ -462,25 +468,14 @@ Enum membership_fee_status {
TableGroup accounts_domain {
users
tokens
Note: '''
**Accounts Domain**
Handles user authentication and session management using AshAuthentication.
Supports multiple authentication strategies (Password, OIDC).
'''
}
TableGroup membership_domain {
members
custom_field_values
custom_fields
Note: '''
**Membership Domain**
Core business logic for club membership management.
Supports flexible, extensible member data model.
Handles user authentication and session management using AshAuthentication.
Supports multiple authentication strategies (Password, OIDC). OIDC linking
is recorded on the users table via the oidc_id column (there is no separate
user_identities table).
'''
}
@ -550,9 +545,32 @@ Table roles {
Table settings {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
club_name text [not null, note: 'The name of the association/club (min length: 1)']
member_field_visibility jsonb [null, note: 'Visibility configuration for member fields in overview (JSONB map)']
member_field_visibility jsonb [null, note: 'Visibility config for member fields in overview (JSONB map; absent key = visible)']
member_field_required jsonb [null, note: 'Required-field config for member fields (JSONB map)']
include_joining_cycle boolean [not null, default: true, note: 'Whether to include the joining cycle in membership fee generation']
default_membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - default fee type for new members']
default_membership_fee_type_id uuid [null, note: 'Logical reference to membership_fee_types (default fee type for new members) - app-enforced, NO DB foreign key']
registration_enabled boolean [not null, default: true, note: 'Whether self-service user registration is enabled']
oidc_only boolean [not null, default: false, note: 'If true, only OIDC login is offered (password login hidden)']
oidc_client_id text [null, note: 'OIDC client id']
oidc_client_secret text [null, note: 'OIDC client secret']
oidc_base_url text [null, note: 'OIDC provider base URL (e.g., Rauthy)']
oidc_redirect_uri text [null, note: 'OIDC redirect URI']
oidc_admin_group_name text [null, note: 'Provider group name mapped to admin role on login']
oidc_groups_claim text [null, note: 'JWT claim carrying the user groups for role sync']
smtp_host text [null, note: 'Outbound SMTP host']
smtp_port bigint [null, note: 'Outbound SMTP port']
smtp_username text [null, note: 'SMTP auth username']
smtp_password text [null, note: 'SMTP auth password (secret)']
smtp_ssl text [null, note: 'SMTP TLS/SSL mode']
smtp_from_name text [null, note: 'Display name for the From header (mail_from)']
smtp_from_email text [null, note: 'Email address for the From header (mail_from)']
vereinfacht_api_url text [null, note: 'vereinfacht.de API base URL']
vereinfacht_api_key text [null, note: 'vereinfacht.de API key (secret)']
vereinfacht_club_id text [null, note: 'vereinfacht.de club identifier']
vereinfacht_app_url text [null, note: 'vereinfacht.de app URL (for links)']
join_form_enabled boolean [not null, default: false, note: 'Whether the public join form is enabled']
join_form_field_ids text[] [null, note: 'Ordered custom_field ids shown on the public join form']
join_form_field_required jsonb [null, note: 'Per-field required config for the join form (JSONB map)']
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
@ -590,19 +608,123 @@ Table settings {
'''
}
// ============================================
// MEMBERSHIP DOMAIN — Groups
// ============================================
Table groups {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
name text [not null, note: 'Group name (unique case-insensitively via LOWER(name))']
slug text [not null, unique, note: 'URL-friendly, immutable identifier auto-generated from name (shared GenerateSlug change)']
description text [null, note: 'Optional description']
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
indexes {
slug [unique, name: 'groups_unique_slug_index', note: 'Case-sensitive unique slug']
name [unique, name: 'groups_unique_name_lower_index', note: 'UNIQUE on LOWER(name) - case-insensitive name uniqueness']
}
Note: '''
**Member Groups**
Flat groupings of members (no hierarchy in current schema). Many-to-many
with members via member_groups. `slug` is generated by the shared
Mv.Membership.Changes.GenerateSlug change (same as custom_fields) and is
used for URL routing (/groups/:slug). Group names feed the member
search_vector at weight B (see member_groups note).
**Future extension path (not yet in schema):**
- parent_group_id (self-referential, nullable) + circular-ref guard + path calc for hierarchy
- member_group_roles table linking MemberGroup to a Role (position within a group)
'''
}
Table member_groups {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
member_id uuid [not null, note: 'FK to members']
group_id uuid [not null, note: 'FK to groups']
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
indexes {
(member_id, group_id) [unique, name: 'member_groups_unique_member_group_index', note: 'One association per member per group']
member_id [name: 'member_groups_member_id_index']
group_id [name: 'member_groups_group_id_index']
}
Note: '''
**Member ↔ Group Join Table**
CASCADE delete on BOTH foreign keys (the cascade lives only on the join
table; members and groups themselves are never deleted by association
removal). INSERT/UPDATE/DELETE here fires the trigger that refreshes the
affected member's search_vector so group names (weight B) stay current.
'''
}
// ============================================
// MEMBERSHIP DOMAIN — Public Join Requests
// ============================================
Table join_requests {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
status text [not null, default: 'pending_confirmation', note: 'pending_confirmation → submitted (after email confirm) → approved/rejected']
email text [not null, note: 'Applicant email']
first_name text [null, note: 'Applicant first name']
last_name text [null, note: 'Applicant last name']
form_data jsonb [null, note: 'Submitted join-form field values (custom fields)']
schema_version integer [null, note: 'Version of the join-form schema used at submission time']
confirmation_token_hash text [null, note: 'Hash of the double-opt-in token (raw token never stored)']
confirmation_token_expires_at timestamp [null, note: 'Token expiry (UTC)']
confirmation_sent_at timestamp [null, note: 'When the confirmation email was sent (UTC)']
submitted_at timestamp [null, note: 'When email was confirmed and request submitted (UTC)']
approved_at timestamp [null, note: 'When an admin approved (UTC)']
rejected_at timestamp [null, note: 'When an admin rejected (UTC)']
reviewed_by_user_id uuid [null, note: 'User who reviewed (no FK constraint)']
reviewed_by_display text [null, note: 'Reviewer display string, denormalized so the UI need not load the User']
source text [null, note: 'Origin of the request (e.g., public form)']
inserted_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
indexes {
confirmation_token_hash [unique, name: 'join_requests_confirmation_token_hash_unique', note: 'Partial unique WHERE confirmation_token_hash IS NOT NULL']
email [name: 'join_requests_email_index']
status [name: 'join_requests_status_index']
}
Note: '''
**Public Join Flow (Onboarding, Double Opt-In)**
Stores public join-form submissions. Double opt-in: the confirmation token
is stored as a hash only; unconfirmed records have a ~24h retention and are
removed by a scheduled cleanup job. `reviewed_by_user_id` is intentionally
unconstrained (no FK); `reviewed_by_display` is denormalized so showing the
reviewer does not require loading the User.
'''
}
// ============================================
// RELATIONSHIPS (Additional)
// ============================================
// MemberGroup → Member (N:1)
// - ON DELETE CASCADE (join table only): association removed, member preserved
Ref: member_groups.member_id > members.id [delete: cascade]
// MemberGroup → Group (N:1)
// - ON DELETE CASCADE (join table only): association removed, group preserved
Ref: member_groups.group_id > groups.id [delete: cascade]
// User → Role (N:1)
// - Many users can be assigned to one role
// - ON DELETE RESTRICT: Cannot delete role if users are assigned
Ref: users.role_id > roles.id [delete: restrict]
// Settings → MembershipFeeType (N:1, optional)
// - Settings can reference a default membership fee type
// - ON DELETE SET NULL: If fee type is deleted, setting is cleared
Ref: settings.default_membership_fee_type_id > membership_fee_types.id [delete: set null]
// Settings → MembershipFeeType (N:1, optional) — LOGICAL relationship only
// - No DB foreign key (cross-domain dependency is deliberately avoided);
// referential integrity is enforced in the app (Mv.Membership.Setting)
Ref: settings.default_membership_fee_type_id > membership_fee_types.id
// ============================================
// TABLE GROUPS (Updated)
@ -624,12 +746,16 @@ TableGroup membership_domain {
custom_field_values
custom_fields
settings
groups
member_groups
join_requests
Note: '''
**Membership Domain**
Core business logic for club membership management.
Supports flexible, extensible member data model.
Includes global application settings (singleton).
Includes member groups (many-to-many), global application settings
(singleton), and the public join-request flow.
'''
}