# Membership Service - Service Documentation

> Auto-generated from source code audit on 2026-03-13.

## Overview

**Service**: membership-service-net
**Port**: 5003 (Development)
**Database**: membership_service (Neon PostgreSQL)
**Framework**: .NET 10.0, C# 14
**Architecture**: Clean Architecture + CQRS (MediatR 12.4.1)

The Membership Service manages member profiles, experience-based leveling system, and loyalty stamp cards. It extends user identity from IAM Service with membership-specific data (levels, EXP, preferences, stamp cards). Profile information (avatar, phone, address, DOB) is managed by IAM Service; this service handles level progression, experience tracking, and loyalty mechanics.

### Key Features
- Member profile management (linked to IAM user by shared ID)
- Configurable experience-based level system (admin-managed level definitions)
- Experience points tracking with 7 source types (Purchase, Referral, Activity, Promotion, Review, CheckIn, Admin)
- Automatic level-up when EXP thresholds are met
- Loyalty stamp cards (buy-N-get-1-free pattern)
- Stamp card lifecycle: create -> collect stamps -> complete -> claim reward -> reset
- IAM Service integration with in-memory caching
- Soft delete for members and level definitions

---

## API Endpoints

### MembersController
Route: `api/v{version:apiVersion}/members` | Auth: `[Authorize]`

| Method | Path | Description | Auth | Request | Response |
|--------|------|-------------|------|---------|----------|
| GET | `/{id:guid}` | Get member by ID | Authorize | Path: `id` (Guid) | `MemberDto` or 404 |
| GET | `/me` | Get current user's member profile | Authorize | JWT `sub`/`id` claim | `MemberDto` or 404 |
| GET | `/` | Get paginated members list | Authorize | Query: `pageIndex`, `pageSize`, `search` | `GetMembersResult` |
| POST | `/` | Create new member | Authorize | Body: `CreateMemberCommand` | 201 `CreateMemberResult` or 409 |
| PUT | `/{id:guid}` | Update member profile | Authorize | Path: `id`, Body: `UpdateMemberProfileCommand` | `UpdateMemberProfileResult` or 404 |
| POST | `/{id:guid}/experience` | Add experience points | Authorize | Path: `id`, Body: `AddExperienceCommand` | `AddExperienceResult` or 404 |
| GET | `/{id:guid}/progress` | Get member level progress | Authorize | Path: `id` | `MemberProgressDto` or 404 |
| GET | `/{id:guid}/experience` | Get experience history | Authorize | Query: `pageIndex`, `pageSize` | `ExperienceHistoryResult` |

### LevelsController
Route: `api/v{version:apiVersion}/levels` | Auth: `[Authorize]`

| Method | Path | Description | Auth | Request | Response |
|--------|------|-------------|------|---------|----------|
| GET | `/` | Get all level definitions | AllowAnonymous | Query: `includeInactive` (bool) | `IEnumerable<LevelDefinitionDto>` |
| POST | `/` | Create level definition | Admin role | Body: `CreateLevelDefinitionCommand` | 201 `CreateLevelDefinitionResult` or 409 |
| PUT | `/{id:guid}` | Update level definition | Admin role | Path: `id`, Body: `UpdateLevelDefinitionCommand` | `UpdateLevelDefinitionResult` or 404 |
| DELETE | `/{id:guid}` | Deactivate level definition (soft delete) | Admin role | Path: `id` | 204 or 404 |

### StampCardsController
Route: `api/v{version:apiVersion}/members/stamp-cards` | Auth: `[Authorize]`

| Method | Path | Description | Auth | Request | Response |
|--------|------|-------------|------|---------|----------|
| GET | `/` | Get stamp card for member at shop | Authorize | Query: `shopId`, `memberId` | `{success, data: StampCardDto}` or 404 |
| GET | `/shop/{shopId:guid}` | Get all stamp cards for shop (admin) | Authorize | Path: `shopId`, Query: `pageIndex`, `pageSize` | `{success, data: GetStampCardsResult}` |
| POST | `/` | Create new stamp card | Authorize | Body: `CreateStampCardCommand` | 201 `{success, data: CreateStampCardResult}` or 409 |
| POST | `/stamp` | Add stamp (auto-creates card if needed) | Authorize | Body: `AddStampCommand` | `{success, data: AddStampResult}` |
| POST | `/{id:guid}/claim` | Claim reward on completed card | Authorize | Path: `id` | `{success, data: ClaimRewardResult}` or 400/404 |
| POST | `/{id:guid}/reset` | Reset card for new round | Authorize | Path: `id` | `{success, data: ResetStampCardResult}` or 400/404 |

### Health Endpoints

| Path | Description |
|------|-------------|
| `/health` | Full health check (PostgreSQL) |
| `/health/live` | Liveness probe (app running) |
| `/health/ready` | Readiness probe (dependencies ready) |

---

## Commands

### CreateMemberCommand
- **Input**: `UserId` (Guid, required), `CountryCode` (string, default "VN"), `Gender` (string?, optional), `Name` (string?, optional), `Phone` (string?, optional)
- **Logic**: Checks for existing member by userId. Creates Member entity with level=1, exp=0. Stores name/phone in preferences JSON if provided. Raises `MemberCreatedDomainEvent`.
- **Validator**: `CreateMemberCommandValidator` -- UserId NotEmpty, CountryCode NotEmpty + Length(2), Gender Must be Male/Female/Other

### UpdateMemberProfileCommand
- **Input**: `MemberId` (Guid), `Gender` (string?), `Preferences` (string?, JSON), `CountryCode` (string?)
- **Logic**: Fetches member, applies partial updates via domain methods (UpdateGender, UpdateCountryCode, UpdatePreferences). Raises `MemberUpdatedDomainEvent`.
- **Validator**: `UpdateMemberProfileCommandValidator` -- MemberId NotEmpty, Gender must be valid, CountryCode Length(2), Preferences must be valid JSON

### AddExperienceCommand
- **Input**: `MemberId` (Guid), `Points` (int), `SourceId` (int, 1-7), `ReferenceId` (string?), `Metadata` (string?)
- **Logic**: Gets member and active level rules. Calls `member.AddExperience()` which creates ExperienceTransaction, updates EXP, auto-levels-up if threshold met. Saves transaction + member. Raises `MemberExperienceAddedDomainEvent` and optionally `MemberLevelUpDomainEvent`.
- **Validator**: None (no dedicated validator file)

### CreateLevelDefinitionCommand
- **Input**: `LevelNumber` (int, 1-100), `Name` (string, 1-100 chars), `RequiredExp` (int, >=0), `Description` (string?, 500 max), `IconUrl` (string?, 500 max), `BadgeColor` (string?, hex format)
- **Logic**: Checks level number uniqueness, creates LevelDefinition entity.
- **Validator**: None (uses DataAnnotations on command class)

### UpdateLevelDefinitionCommand
- **Input**: `Id` (Guid), `Name` (string?), `RequiredExp` (int?), `Description` (string?), `IconUrl` (string?), `BadgeColor` (string?), `ClearDescription`/`ClearIconUrl`/`ClearBadgeColor` (bool)
- **Logic**: Fetches level definition, applies partial updates via domain methods. Supports clearing nullable fields.
- **Validator**: None (uses DataAnnotations)

### DeactivateLevelDefinitionCommand
- **Input**: `Id` (Guid)
- **Logic**: Fetches level definition, calls `Deactivate()` (sets IsActive=false, soft delete).
- **Validator**: None

### CreateStampCardCommand
- **Input**: `ShopId` (Guid), `MemberId` (Guid), `CardName` (string), `TotalStampsRequired` (int, default 10)
- **Logic**: Checks for existing active card at shop for member. Creates new StampCard. Raises `StampCardCreatedDomainEvent`.
- **Validator**: `CreateStampCardCommandValidator` -- ShopId/MemberId NotEmpty, CardName NotEmpty + MaxLength(200), TotalStampsRequired 1-100

### AddStampCommand
- **Input**: `ShopId` (Guid), `MemberId` (Guid), `OrderId` (Guid?, optional)
- **Logic**: Finds active card for member+shop. Auto-creates card (10 stamps, "Loyalty Card") if none exists. Calls `stampCard.AddStamp()`. Raises `StampAddedDomainEvent`, and `StampCardCompletedDomainEvent` if all stamps collected.
- **Validator**: `AddStampCommandValidator` -- ShopId/MemberId NotEmpty

### ClaimRewardCommand
- **Input**: `StampCardId` (Guid)
- **Logic**: Finds stamp card, calls `ClaimReward()` (domain validates card is completed and reward not already claimed). Raises `RewardClaimedDomainEvent`.
- **Validator**: `ClaimRewardCommandValidator` -- StampCardId NotEmpty

### ResetStampCardCommand
- **Input**: `StampCardId` (Guid)
- **Logic**: Finds stamp card, calls `Reset()` (domain validates reward was claimed). Resets stamps to 0, clears completion state.
- **Validator**: `ResetStampCardCommandValidator` -- StampCardId NotEmpty

---

## Queries

### GetMemberByIdQuery
- **Input**: `MemberId` (Guid)
- **Logic**: Fetches member by ID. Extracts `name` and `phone` from preferences JSON for DisplayName/Phone fields.
- **Output**: `MemberDto` (Id, UserId, Gender, CountryCode, Preferences, DisplayName, Phone, CurrentLevel, CurrentExp, TotalExpEarned, CreatedAt, UpdatedAt)

### GetMembersQuery
- **Input**: `PageIndex` (int), `PageSize` (int), `SearchTerm` (string?)
- **Logic**: Paginated query with search by country code or gender. Ordered by created_at DESC.
- **Output**: `GetMembersResult` (Members, TotalCount, PageIndex, PageSize, TotalPages)

### GetMemberProgressQuery
- **Input**: `MemberId` (Guid)
- **Logic**: Fetches member + active level definitions. Calculates current/next level, EXP to next level, progress percentage (0-100%). 100% if at max level.
- **Output**: `MemberProgressDto` (MemberId, CurrentLevel, CurrentLevelName, CurrentExp, TotalExpEarned, ExpToNextLevel, ProgressPercent, NextLevel, NextLevelName, BadgeColor)

### GetExperienceHistoryQuery
- **Input**: `MemberId` (Guid), `PageIndex` (int), `PageSize` (int, default 20)
- **Logic**: Paginated experience transactions ordered by created_at DESC. Sets ExperienceSource from SourceId.
- **Output**: `ExperienceHistoryResult` (Transactions[], TotalCount, PageIndex, PageSize)

### GetLevelDefinitionsQuery
- **Input**: `IncludeInactive` (bool, default false)
- **Logic**: Returns all level definitions ordered by level_number. Includes benefits. Filters by active status unless IncludeInactive=true.
- **Output**: `IEnumerable<LevelDefinitionDto>` (Id, LevelNumber, Name, RequiredExp, Description, IconUrl, BadgeColor, IsActive, Benefits[])

### GetStampCardQuery
- **Input**: `ShopId` (Guid), `MemberId` (Guid)
- **Logic**: Gets most recent stamp card for member at shop.
- **Output**: `StampCardDto` (Id, ShopId, MemberId, CardName, TotalStampsRequired, CurrentStamps, IsCompleted, RewardClaimed, CreatedAt, CompletedAt)

### GetStampCardsQuery
- **Input**: `ShopId` (Guid), `PageIndex` (int), `PageSize` (int)
- **Logic**: Paginated stamp cards for a shop (admin view), ordered by created_at DESC.
- **Output**: `GetStampCardsResult` (Items[], TotalCount, PageIndex, PageSize)

---

## Domain Model

### Member (Aggregate Root)
**Table**: `members`

| Field | Type | Description |
|-------|------|-------------|
| Id / UserId | Guid | Primary key, same as IAM User ID (ValueGeneratedNever) |
| _countryCode | string | ISO 3166-1 alpha-2 country code (default "VN") |
| _gender | string? | Male, Female, Other |
| _currentLevel | int | Current level number (default 1) |
| _currentExp | int | Current experience points (default 0) |
| _totalExpEarned | int | Lifetime total EXP earned (default 0) |
| _preferences | string? | JSON (jsonb) user preferences, stores name/phone |
| _createdAt | DateTime | UTC creation timestamp |
| _updatedAt | DateTime | UTC last update timestamp |
| _isDeleted | bool | Soft delete flag (default false, global query filter) |

**Behavior Methods**:
- `AddExperience(points, source, levelRules, referenceId?, metadata?)` -> ExperienceTransaction -- adds EXP, auto-levels-up, creates transaction, raises MemberExperienceAddedDomainEvent + MemberLevelUpDomainEvent
- `UpdateGender(gender)` -- raises MemberUpdatedDomainEvent
- `UpdateCountryCode(countryCode)` -- raises MemberUpdatedDomainEvent
- `UpdatePreferences(preferencesJson)` -- raises MemberUpdatedDomainEvent
- `Delete()` -- soft delete
- `Restore()` -- undo soft delete

**Domain Events**: MemberCreatedDomainEvent, MemberExperienceAddedDomainEvent, MemberLevelUpDomainEvent, MemberUpdatedDomainEvent

### LevelDefinition (Aggregate Root)
**Table**: `level_definitions`

| Field | Type | Description |
|-------|------|-------------|
| Id | Guid | Primary key (ValueGeneratedNever) |
| _levelNumber | int | Unique level number (1, 2, 3...) |
| _name | string | Level name (Bronze, Silver, Gold..., max 100) |
| _requiredExp | int | EXP threshold to reach this level (default 0) |
| _description | string? | Level description |
| _iconUrl | string? | Badge icon URL (max 500) |
| _badgeColor | string? | Hex color for badge (max 20) |
| _isActive | bool | Active status (default true) |
| _createdAt | DateTime | UTC creation timestamp |
| _updatedAt | DateTime | UTC last update timestamp |

**Behavior Methods**:
- `UpdateName(name)`, `UpdateRequiredExp(requiredExp)`, `UpdateDescription(description?)`, `UpdateIconUrl(iconUrl?)`, `UpdateBadgeColor(badgeColor?)`
- `AddBenefit(benefit)`, `RemoveBenefit(benefit)`
- `Activate()`, `Deactivate()` (soft delete)

**Relationship**: HasMany `LevelBenefit` (cascade delete)

### LevelBenefit (Entity, owned by LevelDefinition)
**Table**: `level_benefits`

| Field | Type | Description |
|-------|------|-------------|
| Id | Guid | Primary key (ValueGeneratedNever) |
| _levelDefinitionId | Guid | FK to level_definitions |
| _benefitType | string | Benefit type (discount_percent, free_shipping, etc., max 50) |
| _benefitValue | string | JSON value (jsonb) |
| _description | string? | Benefit description |
| _isActive | bool | Active status (default true) |
| _createdAt | DateTime | UTC creation timestamp |

**Behavior Methods**: `UpdateBenefitValue(value)`, `UpdateDescription(description?)`, `Activate()`, `Deactivate()`

### ExperienceTransaction (Entity)
**Table**: `experience_transactions`

| Field | Type | Description |
|-------|------|-------------|
| Id | Guid | Primary key (ValueGeneratedNever) |
| _memberId | Guid | FK to members |
| _points | int | EXP points earned |
| _sourceId | int | Source enum ID (1-7) |
| _referenceId | string? | Reference ID (order, referral code, max 100) |
| _metadata | string? | Additional metadata (jsonb) |
| _levelAtTime | int | Member's level when EXP was earned |
| _createdAt | DateTime | UTC creation timestamp |

**Note**: Source navigation property is ignored by EF (Enumeration pattern), resolved via `ExperienceSource.FromValue<>()` in repository.

### ExperienceSource (Enumeration)
Not persisted as table. Stored as `source_id` integer in experience_transactions.

| Id | Name | Description |
|----|------|-------------|
| 1 | Purchase | Order/purchase completion |
| 2 | Referral | Friend referral |
| 3 | Activity | App browsing/engagement |
| 4 | Promotion | Promotional campaigns |
| 5 | Review | Product reviews |
| 6 | CheckIn | Daily check-in |
| 7 | Admin | Manually granted by admin |

### MembershipLevel (Enumeration)
**Table**: `membership_levels` (lookup table)

| Id | Name |
|----|------|
| 1 | Free |
| 2 | Basic |
| 3 | Premium |

**Note**: This enumeration is stored as a DB table but not actively used in current command/query logic. The actual leveling system uses the configurable `LevelDefinition` aggregate.

### StampCard (Aggregate Root)
**Table**: `stamp_cards`

| Field | Type | Description |
|-------|------|-------------|
| Id | Guid | Primary key |
| _shopId | Guid | Shop that issued the card |
| _memberId | Guid | Member who owns the card |
| _cardName | string | Display name (max 200) |
| _totalStampsRequired | int | Stamps needed for reward (default 10) |
| _currentStamps | int | Stamps collected so far (default 0) |
| _isCompleted | bool | All stamps collected (default false) |
| _rewardClaimed | bool | Reward claimed after completion (default false) |
| _createdAt | DateTime | UTC creation timestamp |
| _completedAt | DateTime? | UTC completion timestamp |

**Behavior Methods**:
- `AddStamp()` -- increments stamps, marks completed when threshold met. Raises StampAddedDomainEvent, StampCardCompletedDomainEvent
- `ClaimReward()` -- validates completed + not yet claimed. Raises RewardClaimedDomainEvent
- `Reset()` -- validates reward was claimed, resets all state for new round

**Domain Events**: StampCardCreatedDomainEvent, StampAddedDomainEvent, StampCardCompletedDomainEvent, RewardClaimedDomainEvent

---

## Database Schema

### Tables

#### `members`
| Column | Type | Constraints |
|--------|------|-------------|
| id | uuid | PK, ValueGeneratedNever |
| country_code | varchar(2) | NOT NULL |
| gender | varchar(10) | nullable |
| current_level | int | NOT NULL, default 1 |
| current_exp | int | NOT NULL, default 0 |
| total_exp_earned | int | NOT NULL, default 0 |
| preferences | jsonb | nullable |
| created_at | timestamp | NOT NULL |
| updated_at | timestamp | NOT NULL |
| is_deleted | bool | NOT NULL, default false |

**Indexes**: ix_members_created_at, ix_members_current_level, ix_members_current_exp, ix_members_is_deleted
**Global Query Filter**: `WHERE is_deleted = false`

#### `level_definitions`
| Column | Type | Constraints |
|--------|------|-------------|
| id | uuid | PK, ValueGeneratedNever |
| level_number | int | NOT NULL, UNIQUE |
| name | varchar(100) | NOT NULL |
| required_exp | int | NOT NULL, default 0 |
| description | text | nullable |
| icon_url | varchar(500) | nullable |
| badge_color | varchar(20) | nullable |
| is_active | bool | NOT NULL, default true |
| created_at | timestamp | NOT NULL |
| updated_at | timestamp | NOT NULL |

**Indexes**: ix_level_definitions_level_number (unique), ix_level_definitions_is_active

#### `level_benefits`
| Column | Type | Constraints |
|--------|------|-------------|
| id | uuid | PK, ValueGeneratedNever |
| level_definition_id | uuid | NOT NULL, FK -> level_definitions (cascade) |
| benefit_type | varchar(50) | NOT NULL |
| benefit_value | jsonb | NOT NULL |
| description | text | nullable |
| is_active | bool | NOT NULL, default true |
| created_at | timestamp | NOT NULL |

**Indexes**: ix_level_benefits_level_definition_id

#### `experience_transactions`
| Column | Type | Constraints |
|--------|------|-------------|
| id | uuid | PK, ValueGeneratedNever |
| member_id | uuid | NOT NULL |
| points | int | NOT NULL |
| source_id | int | NOT NULL |
| reference_id | varchar(100) | nullable |
| metadata | jsonb | nullable |
| level_at_time | int | NOT NULL |
| created_at | timestamp | NOT NULL |

**Indexes**: ix_experience_transactions_member_id, ix_experience_transactions_source, ix_experience_transactions_created_at

#### `membership_levels`
| Column | Type | Constraints |
|--------|------|-------------|
| id | int | PK, ValueGeneratedNever |
| name | varchar(50) | NOT NULL |

**Note**: Lookup table for MembershipLevel enumeration (Free=1, Basic=2, Premium=3). Not actively used in current business logic.

#### `stamp_cards`
| Column | Type | Constraints |
|--------|------|-------------|
| id | uuid | PK |
| shop_id | uuid | NOT NULL |
| member_id | uuid | NOT NULL |
| card_name | varchar(200) | NOT NULL |
| total_stamps_required | int | NOT NULL, default 10 |
| current_stamps | int | NOT NULL, default 0 |
| is_completed | bool | NOT NULL, default false |
| reward_claimed | bool | NOT NULL, default false |
| created_at | timestamp | NOT NULL |
| completed_at | timestamp | nullable |

**Indexes**: ix_stamp_cards_shop_member (composite: shop_id + member_id), ix_stamp_cards_shop_id, ix_stamp_cards_is_completed, ix_stamp_cards_created_at

### Migration History
- `20260115105939_AddLevelAndExperienceSystem` -- Initial migration with all tables

---

## Dependencies

### NuGet Packages

**API Layer**:
| Package | Version |
|---------|---------|
| MediatR | 12.4.1 |
| FluentValidation | 11.11.0 |
| FluentValidation.DependencyInjectionExtensions | 11.11.0 |
| Microsoft.EntityFrameworkCore.Design | 10.0.2 |
| Swashbuckle.AspNetCore | 7.2.0 |
| Swashbuckle.AspNetCore.Annotations | 7.2.0 |
| Asp.Versioning.Mvc | 8.1.0 |
| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 |
| AspNetCore.HealthChecks.NpgSql | 8.0.2 |
| AspNetCore.HealthChecks.Redis | 8.0.1 |
| Hellang.Middleware.ProblemDetails | 6.5.1 |
| Serilog.AspNetCore | 8.0.3 |
| Serilog.Sinks.Console | 6.0.0 |
| Serilog.Sinks.Seq | 8.0.0 |
| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.0 |

**Domain Layer**:
| Package | Version |
|---------|---------|
| MediatR.Contracts | 2.0.1 |

**Infrastructure Layer**:
| Package | Version |
|---------|---------|
| Microsoft.EntityFrameworkCore | 10.0.0 |
| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 |
| Microsoft.EntityFrameworkCore.Tools | 10.0.0 |
| MediatR | 12.4.1 |
| Dapper | 2.1.35 |
| Microsoft.Extensions.Http.Polly | 9.0.0 |
| Polly | 8.5.0 |
| StackExchange.Redis | 2.8.16 |
| Microsoft.Extensions.Http | 9.0.0 |

### Service Dependencies
- **IAM Service**: User validation, roles, permissions (via HttpIamServiceClient with in-memory caching)
- **PostgreSQL**: Primary data store (Neon PostgreSQL, connection with retry policy - 5 retries, 30s max delay)
- **Redis**: Configured in appsettings but not actively used in repositories (StackExchange.Redis package available)

---

## Configuration

### appsettings.json
```
ConnectionStrings:DefaultConnection  -- PostgreSQL connection string (also supports DATABASE_URL env var)
Redis:Host/Port/Password/Database    -- Redis connection (configured but not actively used in code)
Jwt:Secret/Issuer/Audience           -- JWT validation settings
Jwt:Authority                        -- OIDC authority URL (default: http://localhost:5001)
IamService:BaseUrl                   -- IAM Service base URL (default: http://iam-service-net:8080)
IamService:ServiceName               -- Internal service name header (default: membership-service)
IamService:TimeoutSeconds            -- HTTP client timeout (default: 30)
IamService:CacheDurationSeconds      -- User info cache TTL (default: 300 = 5min)
IamService:HealthCheckCacheDurationSeconds -- Health check cache TTL (default: 60 = 1min)
```

### Middleware Pipeline
1. Serilog request logging
2. ProblemDetails (RFC 7807)
3. Swagger (Development only)
4. CORS (AllowAnyOrigin/Method/Header)
5. Routing
6. Authentication (JWT Bearer)
7. Authorization
8. Health check endpoints
9. Controller mapping

### MediatR Pipeline Behaviors
1. `LoggingBehavior<,>` -- Request/response logging
2. `ValidatorBehavior<,>` -- FluentValidation in pipeline
3. `TransactionBehavior<,>` -- Auto-wraps commands in DB transactions

### DI Registrations
- `MembershipServiceContext` (DbContext + IUnitOfWork)
- `IMemberRepository` -> `MemberRepository`
- `ILevelDefinitionRepository` -> `LevelDefinitionRepository`
- `IExperienceTransactionRepository` -> `ExperienceTransactionRepository`
- `IStampCardRepository` -> `StampCardRepository`
- `IRequestManager` -> `RequestManager`
- `IIamServiceClient` -> `HttpIamServiceClient` (HttpClient factory)

### Auto-Migration
The service auto-applies EF Core migrations on startup. Migration failures are logged but do not crash the application.

---

## Tests

### Unit Tests (`tests/MembershipService.UnitTests/`)
- `Domain/MemberAggregateTests.cs` -- Member entity behavior
- `Domain/LevelDefinitionAggregateTests.cs` -- LevelDefinition entity behavior
- `Domain/ExperienceTransactionTests.cs` -- ExperienceTransaction entity
- `Domain/MembershipLevelTests.cs` -- MembershipLevel enumeration
- `Handlers/CreateLevelDefinitionCommandHandlerTests.cs`
- `Handlers/GetMemberProgressQueryHandlerTests.cs`
- `Handlers/UpdateLevelDefinitionCommandHandlerTests.cs`
- `Handlers/DeactivateLevelDefinitionCommandHandlerTests.cs`
- `Handlers/AddExperienceCommandHandlerTests.cs`

### Functional Tests (`tests/MembershipService.FunctionalTests/`)
- `CustomWebApplicationFactory.cs` -- Test setup with in-memory DB

---

## Domain Events Summary

| Event | Raised By | Trigger |
|-------|-----------|---------|
| MemberCreatedDomainEvent | Member constructor | New member created |
| MemberUpdatedDomainEvent | Member.UpdateGender/CountryCode/Preferences | Profile updated |
| MemberExperienceAddedDomainEvent | Member.AddExperience | EXP added |
| MemberLevelUpDomainEvent | Member.AddExperience | Level threshold crossed |
| MembershipLevelChangedDomainEvent | (defined but not currently raised in code) | Membership tier change |
| StampCardCreatedDomainEvent | StampCard constructor | New stamp card created |
| StampAddedDomainEvent | StampCard.AddStamp | Stamp added to card |
| StampCardCompletedDomainEvent | StampCard.AddStamp | All stamps collected |
| RewardClaimedDomainEvent | StampCard.ClaimReward | Reward claimed |

---

## Architecture Notes

1. **Member.Id == IAM UserId**: Member entity uses the IAM Service's user ID as its primary key (ValueGeneratedNever). This ensures a 1:1 mapping without a separate FK.

2. **Dual Leveling Systems**: The service has two level concepts:
   - `MembershipLevel` (Enumeration: Free/Basic/Premium) -- legacy/unused, stored in `membership_levels` table
   - `LevelDefinition` (configurable admin-managed levels with EXP thresholds) -- active system used for level progression

3. **Experience Source as Enumeration**: ExperienceSource uses the Enumeration pattern (not stored as EF entity). The `source_id` integer is persisted; the source object is resolved in-memory via `FromValue<>()` in the repository layer.

4. **StampCard Active Card Logic**: `GetActiveCardAsync` returns the most recent card where `reward_claimed = false` (either in-progress or completed-but-unclaimed). After reward is claimed and card is reset, it becomes active again.

5. **Response Format Inconsistency**: MembersController returns raw DTOs; StampCardsController wraps responses in `{ success, data }` format. LevelsController returns raw DTOs.

6. **Missing Validators**: `AddExperienceCommand`, `CreateLevelDefinitionCommand`, `UpdateLevelDefinitionCommand`, and `DeactivateLevelDefinitionCommand` do not have FluentValidation validators (some use DataAnnotations instead).

7. **No RabbitMQ Integration**: Domain events are dispatched via MediatR (in-process) before SaveChanges. No cross-service event publishing to RabbitMQ is implemented.

8. **Dapper Registered but Unused**: Dapper is included as a dependency but no Dapper-based read queries are implemented; all queries use EF Core.
