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

286 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
<span class="badge">{{ $member->notes_count }}</span>
@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
<!-- Each member row gets isolated state -->
<tr x-data="{
expanded: false,
notes: [],
newNote: '',
async fetchNotes() { /* ... */ }
}" x-init="fetchNotes()">
<td>{{ $member->name }}</td>
<td>
<button @click="expanded = !expanded">
{{ $member->notes_count }}
</button>
</td>
<td x-show="expanded">
<!-- Note history for THIS member only -->
<template x-for="note in notes">
<div x-text="note.content"></div>
</template>
</td>
</tr>
\`\`\`
**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*