mitgliederverwaltung/docs/database_schema.dbml

761 lines
34 KiB
Text

// Mila - Membership Management System
// Database Schema Documentation
//
// This file can be used with:
// - https://dbdiagram.io
// - https://dbdocs.io
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
//
// Version: 1.6
// Last Updated: 2026-06-15
// Hand-maintained (NOT auto-generated). 12 tables.
Project mila_membership_management {
database_type: 'PostgreSQL'
Note: '''
# Mila Membership Management System
A membership management application for small to mid-sized clubs.
## Key Features:
- User authentication (OIDC + Password with secure account linking)
- Member management with flexible custom fields
- Bidirectional email synchronization between users and members
- Full-text search capabilities (tsvector)
- Fuzzy search with trigram matching (pg_trgm)
- GDPR-compliant data management
## Domains:
- **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 (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).
'''
}
// ============================================
// ACCOUNTS DOMAIN
// ============================================
Table users {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
email citext [not null, unique, note: 'Email address (case-insensitive) - source of truth when linked to member']
hashed_password text [null, note: 'Bcrypt-hashed password (null for OIDC-only users)']
oidc_id text [null, unique, note: 'External OIDC identifier from authentication provider (e.g., Rauthy)']
member_id uuid [null, unique, note: 'Optional 1:1 link to member record']
indexes {
email [unique, name: 'users_unique_email_index']
oidc_id [unique, name: 'users_unique_oidc_id_index']
member_id [unique, name: 'users_unique_member_index']
}
Note: '''
**User Authentication Table**
Handles user login accounts with two authentication strategies:
1. Password-based authentication (email + hashed_password)
2. OIDC/SSO authentication (email + oidc_id)
**Relationship with Members:**
- Optional 1:1 relationship with members table (0..1 ↔ 0..1)
- A user can have 0 or 1 member (user.member_id can be NULL)
- A member can have 0 or 1 user (optional has_one relationship)
- Both entities can exist independently
- When linked, user.email is the source of truth
- Email changes sync bidirectionally between user ↔ member
**Constraints:**
- At least one auth method required (password OR oidc_id)
- Email must be unique across all users
- OIDC ID must be unique if present
- Member can only be linked to one user (enforced by unique index)
**Deletion Behavior:**
- When member is deleted → user.member_id set to NULL (user preserved)
- When user is deleted → member.user relationship cleared (member preserved)
'''
}
Table tokens {
jti text [pk, not null, note: 'JWT ID - unique token identifier']
subject text [not null, note: 'Token subject (usually user ID)']
purpose text [not null, note: 'Token purpose (e.g., "access", "refresh", "password_reset")']
expires_at timestamp [not null, note: 'Token expiration timestamp (UTC)']
extra_data jsonb [null, note: 'Additional token metadata']
created_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 {
subject [name: 'tokens_subject_idx', note: 'For user token lookups']
expires_at [name: 'tokens_expires_at_idx', note: 'For token cleanup queries']
purpose [name: 'tokens_purpose_idx', note: 'For purpose-based queries']
}
Note: '''
**AshAuthentication Token Management**
Stores JWT tokens for authentication and authorization.
**Token Purposes:**
- `access`: Short-lived access tokens for API requests
- `refresh`: Long-lived tokens for obtaining new access tokens
- `password_reset`: Temporary tokens for password reset flow
- `email_confirmation`: Temporary tokens for email verification
**Token Lifecycle:**
- Tokens are created during login/registration
- Can be revoked by deleting the record
- Expired tokens should be cleaned up periodically
- `store_all_tokens? true` enables token tracking
'''
}
// ============================================
// MEMBERSHIP DOMAIN
// ============================================
Table members {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
first_name text [null, note: 'Member first name (min length: 1 if present)']
last_name text [null, note: 'Member last name (min length: 1 if present)']
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
join_date date [null, note: 'Date when member joined club']
exit_date date [null, note: 'Date when member left club (must be after join_date)']
notes text [null, note: 'Additional notes about member']
city text [null, note: 'City of residence']
street text [null, note: 'Street name']
house_number text [null, note: 'House number']
postal_code text [null, note: '5-digit German postal code']
country text [null, note: 'Country of residence']
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)']
first_name [type: gin, name: 'members_first_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
last_name [type: gin, name: 'members_last_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
email [type: gin, name: 'members_email_trgm_idx', note: 'GIN trigram index for fuzzy search']
city [type: gin, name: 'members_city_trgm_idx', note: 'GIN trigram index for fuzzy search']
street [type: gin, name: 'members_street_trgm_idx', note: 'GIN trigram index for fuzzy search']
notes [type: gin, name: 'members_notes_trgm_idx', note: 'GIN trigram index for fuzzy search']
email [name: 'members_email_idx', note: 'B-tree index for exact lookups']
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
}
Note: '''
**Club Member Master Data**
Core entity for membership management containing:
- Personal information (name, email)
- Contact details (address)
- Membership status (join/exit dates, membership fee cycles)
- Additional notes
**Email Synchronization:**
When a member is linked to a user:
- User.email is the source of truth (overwrites member.email on link)
- Subsequent changes to either email sync bidirectionally
- Validates that email is not already used by another unlinked user
**Search Capabilities:**
1. Full-Text Search (tsvector):
- `search_vector` is auto-updated via trigger
- 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):
- Trigram-based similarity matching
- 6 GIN trigram indexes on searchable fields
- Configurable similarity threshold (default 0.2)
- Supports typos and partial matches
**Relationships:**
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
- 1:N with custom_field_values (custom dynamic fields)
- Optional N:1 with membership_fee_types - assigned fee type
- 1:N with membership_fee_cycles - billing history
**Validation Rules:**
- first_name, last_name: optional, but if present min 1 character
- email: 5-254 characters, valid email format (required)
- exit_date: must be after join_date (if both present)
- postal_code: optional (no format validation)
- country: optional
'''
}
Table custom_field_values {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
member_id uuid [not null, note: 'Link to member']
custom_field_id uuid [not null, note: 'Link to custom field definition']
indexes {
(member_id, custom_field_id) [unique, name: 'custom_field_values_unique_custom_field_per_member_index', note: 'One custom field value per custom field per member']
member_id [name: 'custom_field_values_member_id_idx']
custom_field_id [name: 'custom_field_values_custom_field_id_idx']
}
Note: '''
**Dynamic Custom Member Field Values**
Provides flexible, extensible attributes for members beyond the fixed schema.
**Value Storage:**
- Stored as JSONB map with type discrimination
- Format: `{type: "string|integer|boolean|date|email", value: <actual_value>}`
- Allows multiple data types in single column
**Supported Types:**
- `string`: Text data
- `integer`: Numeric data
- `boolean`: True/False flags
- `date`: Date values
- `email`: Validated email addresses
**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 values are deleted when the custom field is deleted (CASCADE)
**Use Cases:**
- Custom membership numbers
- Additional contact methods
- Club-specific attributes
- Flexible data model without schema migrations
'''
}
Table custom_fields {
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
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']
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']
slug [unique, name: 'custom_fields_unique_slug_index']
}
Note: '''
**CustomFieldValue Type Definitions**
Defines the schema and behavior for custom member custom_field_values.
**Attributes:**
- `name`: Unique identifier for the custom field
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
- `value_type`: Enforces data type consistency
- `description`: Documentation for users/admins
- `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
- Immutable after creation (does not change when name is updated)
- Lowercase, spaces replaced with hyphens, special characters removed
- UTF-8 support (ä → a, ß → ss, etc.)
- Used for human-readable identifiers (CSV export/import, API, etc.)
- Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller"
**Constraints:**
- `value_type` must be one of: string, integer, boolean, date, email
- `name` must be unique across all custom fields
- `slug` must be unique across all custom fields
- `slug` cannot be empty (validated on creation)
- Deleting a custom field cascades: its custom_field_values are deleted too (ON DELETE CASCADE)
**Examples:**
- 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"
'''
}
// ============================================
// MEMBERSHIP_FEES DOMAIN
// ============================================
Table membership_fee_types {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")']
amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)']
interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable']
description text [null, note: 'Optional description for the fee type']
indexes {
name [unique, name: 'membership_fee_types_unique_name_index']
}
Note: '''
**Membership Fee Type Definitions**
Defines the different types of membership fees with fixed billing intervals.
**Attributes:**
- `name`: Unique identifier for the fee type
- `amount`: Default fee amount (stored per cycle for audit trail)
- `interval`: Billing cycle - immutable after creation
- `description`: Optional documentation
**Interval Values:**
- `monthly`: 1st to last day of month
- `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter
- `half_yearly`: 1st of Jan/Jul to last day of half
- `yearly`: Jan 1 to Dec 31
**Immutability:**
The `interval` field cannot be changed after creation to prevent
complex migration scenarios. Create a new fee type to change intervals.
**Relationships:**
- 1:N with members - members assigned to this fee type
- 1:N with membership_fee_cycles - all cycles using this fee type
**Deletion Behavior:**
- ON DELETE RESTRICT: Cannot delete if members or cycles reference it
'''
}
Table membership_fee_cycles {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
cycle_start date [not null, note: 'Start date of the billing cycle']
amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)']
status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)']
notes text [null, note: 'Optional notes for this cycle']
member_id uuid [not null, note: 'FK to members - the member this cycle belongs to']
membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle']
indexes {
member_id [name: 'membership_fee_cycles_member_id_index']
membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index']
status [name: 'membership_fee_cycles_status_index']
cycle_start [name: 'membership_fee_cycles_cycle_start_index']
(member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start']
}
Note: '''
**Individual Membership Fee Cycles**
Represents a single billing cycle for a member with payment tracking.
**Design Decisions:**
- `cycle_end` is NOT stored - calculated from cycle_start + interval
- `amount` is stored per cycle to preserve historical values when fee type amount changes
- Cycles are aligned to calendar boundaries
**Status Values:**
- `unpaid`: Payment pending (default)
- `paid`: Payment received
- `suspended`: Payment suspended (e.g., hardship case)
**Constraints:**
- Unique: One cycle per member per cycle_start date
- member_id: Required (belongs_to)
- membership_fee_type_id: Required (belongs_to)
**Relationships:**
- N:1 with members - the member this cycle belongs to
- N:1 with membership_fee_types - the fee type for this cycle
**Deletion Behavior:**
- ON DELETE CASCADE (member_id): Cycles deleted when member deleted
- ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist
'''
}
// ============================================
// RELATIONSHIPS
// ============================================
// Optional 1:1 User ↔ Member Link
// - A user can have 0 or 1 linked member (optional)
// - A member can have 0 or 1 linked user (optional)
// - Both can exist independently
// - ON DELETE SET NULL: User preserved when member deleted
// - Email Synchronization: When linking occurs, user.email becomes source of truth
Ref: users.member_id - members.id [delete: set null]
// Member → Properties (1:N)
// - One member can have multiple custom_field_values
// - Each custom field value belongs to exactly one member
// - ON DELETE CASCADE: Properties deleted when member deleted
// - UNIQUE constraint: One custom field value per custom field per member
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 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
// - Optional relationship (member can have no fee type)
// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned
Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict]
// MembershipFeeCycle → Member (N:1)
// - Many cycles belong to one member
// - ON DELETE CASCADE: Cycles deleted when member deleted
Ref: membership_fee_cycles.member_id > members.id [delete: cascade]
// MembershipFeeCycle → MembershipFeeType (N:1)
// - Many cycles reference one fee type
// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it
Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict]
// ============================================
// ENUMS
// ============================================
// Valid data types for custom field values
// Determines how CustomFieldValue.value is interpreted
Enum custom_field_value_type {
string [note: 'Text data']
integer [note: 'Numeric data']
boolean [note: 'True/False flags']
date [note: 'Date values']
email [note: 'Validated email addresses']
}
// Token purposes for different authentication flows
Enum token_purpose {
access [note: 'Short-lived access tokens']
refresh [note: 'Long-lived refresh tokens']
password_reset [note: 'Password reset tokens']
email_confirmation [note: 'Email verification tokens']
}
// Billing interval for membership fee types
Enum membership_fee_interval {
monthly [note: '1st to last day of month']
quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter']
half_yearly [note: '1st of Jan/Jul to last day of half']
yearly [note: 'Jan 1 to Dec 31']
}
// Payment status for membership fee cycles
Enum membership_fee_status {
unpaid [note: 'Payment pending (default)']
paid [note: 'Payment received']
suspended [note: 'Payment suspended']
}
// ============================================
// TABLE GROUPS
// ============================================
TableGroup accounts_domain {
users
tokens
Note: '''
**Accounts Domain**
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).
'''
}
TableGroup membership_fees_domain {
membership_fee_types
membership_fee_cycles
Note: '''
**Membership Fees Domain**
Handles membership fee management including:
- Fee type definitions with intervals
- Individual billing cycles per member
- Payment status tracking
'''
}
// ============================================
// AUTHORIZATION DOMAIN
// ============================================
Table roles {
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key']
name text [not null, unique, note: 'Unique role name (e.g., "Vorstand", "Admin", "Mitglied")']
description text [null, note: 'Human-readable description of the role']
permission_set_name text [not null, note: 'Permission set name: "own_data", "read_only", "normal_user", or "admin"']
is_system_role boolean [not null, default: false, note: 'If true, role cannot be deleted (protects critical roles)']
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 {
name [unique, name: 'roles_unique_name_index']
}
Note: '''
**Role-Based Access Control (RBAC)**
Roles link users to permission sets. Each role references one of four hardcoded
permission sets defined in the application code.
**Permission Sets:**
- `own_data`: Users can only access their own linked member data
- `read_only`: Users can read all data but cannot modify
- `normal_user`: Users can read and modify most data (standard permissions)
- `admin`: Full access to all features and settings
**System Roles:**
- System roles (is_system_role = true) cannot be deleted
- Protects critical roles like "Mitglied" (member) from accidental deletion
- Only set via seed scripts or internal actions
**Relationships:**
- 1:N with users - users assigned to this role
- ON DELETE RESTRICT: Cannot delete role if users are assigned
**Constraints:**
- `name` must be unique
- `permission_set_name` must be a valid permission set (validated in application)
- System roles cannot be deleted (enforced via validation)
'''
}
// ============================================
// MEMBERSHIP DOMAIN (Additional Tables)
// ============================================
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 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: '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)']
indexes {
default_membership_fee_type_id [name: 'settings_default_membership_fee_type_id_index', note: 'B-tree index for fee type lookups']
}
Note: '''
**Global Application Settings (Singleton Resource)**
Stores global configuration for the association/club. There should only ever
be one settings record in the database (singleton pattern).
**Attributes:**
- `club_name`: The name of the association/club (required, can be set via ASSOCIATION_NAME env var)
- `member_field_visibility`: JSONB map storing visibility configuration for member fields
(e.g., `{"street": false, "house_number": false}`). Fields not in the map default to `true`.
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
they pay from the next full cycle after joining.
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
new members. Can be nil if no default is set.
**Singleton Pattern:**
- Only one settings record should exist
- Designed to be read and updated, not created/destroyed via normal CRUD
- Initial settings should be seeded
**Environment Variable Support:**
- `club_name` can be set via `ASSOCIATION_NAME` environment variable
- Database values always take precedence over environment variables
**Relationships:**
- Optional N:1 with membership_fee_types - default fee type for new members
- ON DELETE SET NULL: If default fee type is deleted, setting is cleared
'''
}
// ============================================
// 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) — 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)
// ============================================
TableGroup authorization_domain {
roles
Note: '''
**Authorization Domain**
Handles role-based access control (RBAC) with hardcoded permission sets.
Roles link users to permission sets for authorization.
'''
}
TableGroup membership_domain {
members
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 member groups (many-to-many), global application settings
(singleton), and the public join-request flow.
'''
}