docs(db): refresh, condense and align database and groups docs
This commit is contained in:
parent
5d8f173529
commit
0b36a43edc
4 changed files with 360 additions and 1875 deletions
|
|
@ -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.
|
||||
'''
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue