Files
usher-manage-stack/.planning/research/ARCHITECTURE.md

13 KiB
Raw Blame History

Architecture Patterns

Domain: CRM/Admin Member Note Systems Researched: 2026-02-13

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 }} {{ $member->notes_count }}
\`\`\`

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


Architecture research for: Member Notes System (會員備註系統) Researched: 2026-02-13