# Architecture Patterns **Domain:** CRM/Admin Member Note Systems **Researched:** 2026-02-13 ## Recommended Architecture **Pattern:** Polymorphic Note System with Inline AJAX UI ``` ┌─────────────────────────────────────────────────────────────────┐ │ Member List Page (Blade) │ │ /admin/members │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Member Row (Alpine.js Component) │ │ │ │ │ │ │ │ Name │ Status │ [Note Badge: 3] │ Actions │ │ │ │ ↓ (click to expand) │ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ │ │ Expandable Note Panel (x-show) │ │ │ │ │ │ │ │ │ │ │ │ [Quick Add Form] │ │ │ │ │ │ Textarea: "新增備註..." │ │ │ │ │ │ [儲存] [取消] │ │ │ │ │ │ │ │ │ │ │ │ Note History (chronological, most recent first): │ │ │ │ │ │ • 2026-02-13 14:30 - Admin Name │ │ │ │ │ │ "Contacted about membership renewal..." │ │ │ │ │ │ • 2026-02-11 09:15 - Secretary Name │ │ │ │ │ │ "Follow-up needed on payment issue" │ │ │ │ │ └────────────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ↕ AJAX (Axios) ┌─────────────────────────────────────────────────────────────────┐ │ Laravel Backend API │ │ │ │ MemberNoteController │ │ ├─ index(Member $member): GET /admin/members/{id}/notes │ │ │ → Returns NoteResource::collection($member->notes) │ │ └─ store(Member $member, Request): POST /admin/members/{id}/notes│ │ → Validates, creates Note, logs audit, returns NoteResource │ └─────────────────────────────────────────────────────────────────┘ ↕ Eloquent ORM ┌─────────────────────────────────────────────────────────────────┐ │ Database Layer │ │ │ │ members table notes table (polymorphic) │ │ ├─ id ├─ id │ │ ├─ name ├─ notable_id ──┐ │ │ ├─ ... ├─ notable_type ──┼─> Polymorphic │ │ ├─ content │ Relationship │ │ ├─ user_id (author)│ │ │ ├─ created_at │ │ │ └─ updated_at ────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### Component Boundaries | Component | Responsibility | Communicates With | |-----------|---------------|-------------------| | **Member List Page (Blade)** | Renders member table, embeds Alpine.js components per row | Alpine.js components | | **Alpine.js Note Component** | Manages note UI state (expanded, loading, form data), handles AJAX calls | MemberNoteController via Axios | | **MemberNoteController** | Validates requests, orchestrates note CRUD, returns JSON | Note model, AuditLogger | | **Note Model (Eloquent)** | Manages polymorphic relationship, defines fillable fields, relationships | members table, users table (author) | | **NoteResource** | Transforms Note model to consistent JSON structure | MemberNoteController | | **AuditLogger** | Records note creation events for compliance | Audit logs table | ### Data Flow **Creating a Note (Inline):** 1. User types note in textarea, clicks "儲存" 2. Alpine.js component calls `addNote()` method 3. Axios sends POST `/admin/members/{id}/notes` with CSRF token 4. MemberNoteController validates request (required content, max length) 5. Controller creates Note record with `notable_type='App\Models\Member'`, `notable_id={id}`, `user_id=auth()->id()` 6. AuditLogger logs event: `note_created` with member ID, author ID, note ID 7. Controller returns `{ data: NoteResource($note) }` (201 Created) 8. Alpine.js component prepends new note to `this.notes` array 9. UI updates instantly (no page reload), badge count increments **Loading Note History (Expandable):** 1. User clicks note badge (count) 2. Alpine.js component triggers `x-show` toggle (if first click, already loaded via `x-init`) 3. If not lazy-loaded: Axios sends GET `/admin/members/{id}/notes` 4. MemberNoteController queries `$member->notes()->with('author')->get()` 5. Controller returns `{ data: NoteResource::collection($notes) }` 6. Alpine.js component sets `this.notes = response.data.data` 7. UI renders note list with author names, timestamps (relative + tooltip absolute) ## Patterns to Follow ### Pattern 1: Polymorphic Relationship for Extensibility **What:** Use Laravel's polymorphic `morphMany`/`morphTo` to attach notes to any entity. **When:** Notes can eventually apply to Members, Issues, Payments, Approvals, etc. **Example:** \`\`\`php // Note model public function notable(): MorphTo { return $this->morphTo(); } // Member model public function notes(): MorphMany { return $this->morphMany(Note::class, 'notable')->latest(); } // Later: Issue model can add same relationship without schema changes public function notes(): MorphMany { return $this->morphMany(Note::class, 'notable')->latest(); } \`\`\` **Benefits:** - Single notes table for all entities (DRY) - Zero schema changes to add notes to new entities - Consistent query API: `$member->notes`, `$issue->notes` ### Pattern 2: Eager Loading with Count Aggregation **What:** Load note counts on member list without N+1 queries. **When:** Displaying note count badges on member list page. **Example:** \`\`\`php // In MemberController@index $members = Member::query() ->withCount('notes') // Adds 'notes_count' attribute ->paginate(15); // In Blade template @if ($member->notes_count > 0) {{ $member->notes_count }} @endif \`\`\` **Benefits:** - Single query for all note counts (vs. N queries for N members) - No caching needed for counts (eager load is fast) - Automatic when using Eloquent relationships ### Pattern 3: Alpine.js Component State Isolation **What:** Each member row has isolated Alpine.js state (not global). **When:** Multiple member rows on page, each with expandable notes. **Example:** \`\`\`html {{ $member->name }} \`\`\` **Benefits:** - No state collision between rows - Each row manages own loading/expanded state - No global store needed (Alpine.js magic) ### Pattern 4: Optimistic UI Updates **What:** Update UI immediately after POST, before server confirms success. **When:** Creating new note (AJAX). **Example:** \`\`\`javascript async addNote() { const tempNote = { id: Date.now(), // Temporary ID content: this.newNote, author: { name: '{{ auth()->user()->name }}' }, created_at: new Date().toISOString() }; this.notes.unshift(tempNote); // Instant UI update this.newNote = ''; try { const response = await axios.post('/admin/members/1/notes', { content: tempNote.content }); // Replace temp note with server-confirmed note this.notes[0] = response.data.data; } catch (error) { // Revert on error this.notes.shift(); alert('新增失敗'); } } \`\`\` **Benefits:** - Instant feedback (no spinner needed) - Perceived performance improvement - Graceful degradation on error ## Anti-Patterns to Avoid ### Anti-Pattern 1: Global Alpine.js Store for Notes **What:** Using Alpine.store() to share notes across components. **Why bad:** - State mutations in one row affect other rows - Memory leak if notes accumulate in global store - Harder to debug (implicit dependencies) **Instead:** Component-scoped state with `x-data` per row (Pattern 3). ### Anti-Pattern 2: Soft Deletes on Notes **What:** Adding `deleted_at` column for "hiding" notes. **Why bad:** - Violates append-only audit principle - "Deleted" notes still in database (confusion) - Soft-deleted records pollute queries (need `withTrashed()` everywhere) **Instead:** Hard constraint: no delete operation at all. Use addendum pattern for corrections. ### Anti-Pattern 3: Lazy Loading Notes on Member List **What:** Loading `$member->notes` in Blade loop without `with()`. **Why bad:** - N+1 query problem (1 query per member) - Slow page load with 15 members = 15+ queries - Database connection pool exhaustion **Instead:** Eager load with `withCount('notes')` or `with('notes')` in controller query (Pattern 2). ### Anti-Pattern 4: Full Page Reload After Note Creation **What:** Traditional form submit that redirects back to member list. **Why bad:** - Loses scroll position - Slower (re-renders entire table) - Poor UX for quick note-taking **Instead:** AJAX with Alpine.js, update only the note panel (Pattern 4). ## Scalability Considerations | Concern | At 100 notes total | At 1,000 notes total | At 10,000 notes total | |---------|-------------------|----------------------|----------------------| | **Query performance** | No optimization needed | Index on `notable_id` + `created_at` (already planned) | Same indexes sufficient; consider pagination per member if >100 notes/member | | **Badge count** | `withCount('notes')` is fast | Same (single GROUP BY query) | Same (scales to 100K+ members) | | **Note history load** | Load all notes on expand | Load all notes on expand | Paginate if member has >50 notes (unlikely in NPO) | | **Search** | LIKE query on `content` | MySQL FULLTEXT index | Same (FULLTEXT scales to 100K+ notes) | | **Storage** | ~10KB total (100 notes × 100 chars) | ~100KB total | ~1MB total (negligible) | **NPO Context:** With ~200 members, unlikely to exceed 1,000 total notes even with active chairman usage. Current architecture scales 100x beyond realistic usage. ## Sources - [Laravel 10.x Polymorphic Relationships](https://laravel.com/docs/10.x/eloquent-relationships#polymorphic-relationships) - Official documentation - [Eloquent N+1 Query Detection](https://laravel.com/docs/10.x/eloquent-relationships#eager-loading) - Performance optimization - [Alpine.js Component Patterns](https://alpinejs.dev/essentials/state) - State management - [RESTful API Design Best Practices](https://www.smashingmagazine.com/2018/01/understanding-using-rest-api/) - AJAX endpoint patterns - [Healthcare Audit Trail Standards](https://support.sessionshealth.com/article/393-addendum) - Append-only pattern - [MySQL FULLTEXT Search](https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html) - Search optimization --- *Architecture research for: Member Notes System (會員備註系統)* *Researched: 2026-02-13*