# Database Schema Documentation

## Overview

This document provides a comprehensive overview of the Mila Membership Management System database schema.

- **DBML file:** [`database_schema.dbml`](./database_schema.dbml) — full per-column intent notes and relationship edges.
- **Search-vector performance:** see [`custom-fields-search-performance.md`](./custom-fields-search-performance.md) for trigger cost analysis and tuning.

The DBML is **hand-maintained** (not auto-generated); keep it in sync with `priv/repo/migrations/`.

## Schema Statistics

| Metric | Count |
|--------|-------|
| **Tables** | 12 |
| **Domains** | 4 (Accounts, Membership, MembershipFees, Authorization) |
| **Triggers** | 3 (member, custom_field_values, member_groups → member search-vector) |

## Tables Overview

### Accounts Domain
- **`users`** — authentication accounts. Dual auth (Password + OIDC), optional 1:1 link to a member; email is the source of truth when linked.
- **`tokens`** — JWT storage for AshAuthentication; multiple purposes, revocation by deletion.

OIDC account linking is recorded on the `users` table via the `oidc_id` column; there is no separate `user_identities` table.

### Membership Domain
- **`members`** — club member master data. Full-text + fuzzy search, bidirectional email sync with users, flexible address/contact data, `country`, optional `vereinfacht_contact_id` (external vereinfacht.de contact).
- **`custom_field_values`** — dynamic per-member attributes. Union-type value in JSONB; one value per custom field per member.
- **`custom_fields`** — schema definitions for custom field values (type, `required`/`show_in_overview` flags, optional `join_description`, auto-generated slug).
- **`settings`** — global application settings (singleton). Club name (also via `ASSOCIATION_NAME` env), member-field visibility/required maps, fee defaults, plus OIDC, SMTP/mail-from, vereinfacht.de, public join-form, `registration_enabled`, and `oidc_only` configuration. See [Settings configuration columns](#settings-configuration-columns).
- **`groups`** — member groupings. Case-insensitive-unique names, auto-generated immutable slugs, optional descriptions; many-to-many with members.
- **`member_groups`** — join table for members ↔ groups. Unique `(member_id, group_id)`, CASCADE delete on both sides (join table only).
- **`join_requests`** — public join flow (onboarding, double opt-in). Status machine `pending_confirmation → submitted → approved/rejected`; confirmation token stored as hash only, ~24h retention for unconfirmed records.

### Authorization Domain
- **`roles`** — RBAC. Links users to one of four hardcoded permission sets (`own_data`, `read_only`, `normal_user`, `admin`); system roles are deletion-protected.

### MembershipFees Domain
- **`membership_fee_types`** — fee types with immutable billing interval.
- **`membership_fee_cycles`** — per-member billing cycles with payment status.

## Settings configuration columns

The singleton `settings` row carries runtime configuration (all nullable unless noted). Grouped by area:

- **Member overview:** `member_field_visibility` (JSONB; absent key = visible), `member_field_required` (JSONB).
- **Membership fees:** `include_joining_cycle` (bool, NOT NULL, default true), `default_membership_fee_type_id` (FK → membership_fee_types, ON DELETE SET NULL).
- **Registration / login:** `registration_enabled` (bool, NOT NULL, default true), `oidc_only` (bool, NOT NULL, default false).
- **OIDC:** `oidc_client_id`, `oidc_client_secret`, `oidc_base_url`, `oidc_redirect_uri`, `oidc_admin_group_name`, `oidc_groups_claim`.
- **SMTP / mail-from:** `smtp_host`, `smtp_port` (bigint), `smtp_username`, `smtp_password`, `smtp_ssl`, `smtp_from_name`, `smtp_from_email`.
- **vereinfacht.de:** `vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`.
- **Public join form:** `join_form_enabled` (bool, NOT NULL, default false), `join_form_field_ids` (text[]), `join_form_field_required` (JSONB).

## Key Relationships

```
User (0..1) ←→ (0..1) Member
       ↓              ↓
    Tokens (N)    CustomFieldValues (N)
       ↓                    ↓
    Role (N:1)        CustomField (1)

Member (1) → (N) MembershipFeeCycles
                    ↓
            MembershipFeeType (1)

Member (N) ←→ (N) Group
       ↓              ↓
  MemberGroups (N)  MemberGroups (N)

Settings (1) → MembershipFeeType (0..1)
```

## Foreign Key On-Delete Behavior

| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
| `users.role_id → roles.id` | RESTRICT | Cannot delete a role that still has users |
| `custom_field_values.member_id → members.id` | CASCADE | Delete values with member |
| `custom_field_values.custom_field_id → custom_fields.id` | CASCADE | Delete values when the custom field is deleted |
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type assigned to members |
| `membership_fee_cycles.member_id → members.id` | CASCADE | Cycles deleted with member |
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Cannot delete a fee type with cycles |
| `settings.default_membership_fee_type_id → membership_fee_types.id` | SET NULL | Clear default if fee type deleted |
| `member_groups.member_id → members.id` | CASCADE | Association removed; member preserved |
| `member_groups.group_id → groups.id` | CASCADE | Association removed; group preserved |

`join_requests.reviewed_by_user_id` is intentionally **unconstrained** (no FK); `reviewed_by_display` is denormalized so the UI need not load the reviewer User.

**User ↔ Member** is an optional 1:1 (both sides may be NULL; entities exist independently). **Member ↔ Group** is many-to-many through `member_groups` (CASCADE lives only on the join table).

## Important Business Rules

### Email Synchronization
- **User.email is the source of truth when linked.** On linking, `Member.email ← User.email` (overwrite). Afterwards changes sync bidirectionally. Validation prevents email conflicts with other unlinked users.

### Authentication Strategies
- **Password:** email + hashed_password. **OIDC:** email + oidc_id (Rauthy provider), the external identity recorded via the `oidc_id` column on `users`. At least one method required per user.

### Member Constraints
- `first_name` / `last_name`: optional, but if present min 1 char.
- `email`: unique, validated format (5–254 chars).
- `exit_date` must be after `join_date`.
- `postal_code`, `country`: optional, no format validation.

### CustomFieldValue System
- One value per custom field per member. Value stored as a union type in JSONB: `{type: "string|integer|boolean|date|email", value: <actual_value>}`. Custom fields can be marked `required` and toggled `show_in_overview`.

## Full-Text Search

### Implementation
- **Trigger** on `members` (INSERT/UPDATE): `update_search_vector` runs function `members_search_vector_trigger()`
- **Trigger** on `custom_field_values` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_custom_field_value_change` runs function `update_member_search_vector_from_custom_field_value()`
- **Trigger** on `member_groups` (INSERT/UPDATE/DELETE): `update_member_search_vector_on_member_groups_change` runs function `update_member_search_vector_from_member_groups()`
- **Index Type:** GIN (Generalized Inverted Index)

### Weighted Fields
- **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes, group names (from member_groups → groups)
- **Weight C:** city, street, house_number, postal_code, custom_field_values
- **Weight D (lowest):** join_date, exit_date

### Group Names in Search
Group names are included in the member search vector so that searching for a group name (e.g. "Vorstand") finds all members in that group:
- Group names are aggregated from `member_groups` joined with `groups` and receive weight 'B'
- The trigger `update_member_search_vector_on_member_groups_change` runs on INSERT/UPDATE/DELETE on `member_groups` and refreshes the affected member's `search_vector`
- See migration `20260217120000_add_group_names_to_member_search_vector.exs` (Issue #375)

### Custom Field Values in Search
Custom field values are automatically included in the search vector:
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
- Values are converted to text format for indexing
- Custom field values receive weight 'C' (same as city, etc.)
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers

### Usage Example
```sql
SELECT * FROM members
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
```

## Fuzzy Search (Trigram-based)

- **Extension:** `pg_trgm`; GIN indexes with `gin_trgm_ops` on `first_name`, `last_name`, `email`, `city`, `street`, `notes`.
- **Similarity threshold:** 0.2 (default, configurable) — balances precision/recall.
- **Added:** November 2025 (PR #187, closes #162).

Fuzzy search combines several strategies (applied as an OR-chain alongside full-text and substring matching):

1. Full-text search — primary filter via tsvector.
2. Trigram similarity — `similarity(field, query) > threshold`.
3. Word similarity — `word_similarity(query, field) > threshold`.
4. Substring matching — `LIKE` / `ILIKE`.
5. `%` operator — quick trigram-similarity check.

For the Elixir search action and per-strategy filter functions, see `lib/membership/member.ex` and [`custom-fields-search-performance.md`](./custom-fields-search-performance.md).

## Database Extensions

Installed extensions are defined in `Mv.Repo.installed_extensions/0`:

| Extension | Purpose | Notes |
|-----------|---------|-------|
| `ash-functions` | Ash helper SQL functions | installed by Ash |
| `citext` | Case-insensitive text | `users.email` |
| `pg_trgm` | Trigram fuzzy search | added in `20251001141005_add_trigram_to_members.exs`; operators `%`, `similarity()`, `word_similarity()` |

`gen_random_uuid()` is built into PostgreSQL; `uuid_generate_v7()` is a custom SQL function defined in a migration (not provided by an extension).

## Sensitive Data (GDPR / logging)

- **Never log:** `users.hashed_password` (bcrypt), token fields (`jti`, `purpose`, `extra_data`), OIDC/SMTP/vereinfacht secrets in `settings`.
- **Personal data:** all member fields, user email, join-request applicant data.

---

**Last Updated:** 2026-06-15  
**Schema Version:** 1.6 (12 tables)  
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)

