// 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: }` - 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. ''' }