docs: research member notes ecosystem
This commit is contained in:
285
.planning/research/ARCHITECTURE.md
Normal file
285
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# 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*
|
||||
Reference in New Issue
Block a user