docs(01): research phase domain
This commit is contained in:
727
.planning/phases/01-database-schema-backend-api/01-RESEARCH.md
Normal file
727
.planning/phases/01-database-schema-backend-api/01-RESEARCH.md
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
# Phase 01: Database Schema & Backend API - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-02-13
|
||||||
|
**Domain:** Laravel 10 polymorphic relationships, database schema design, RESTful API endpoints
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 1 involves creating a polymorphic `notes` table that can attach to multiple entity types (starting with Members, extensible to future entities like Issues or Finance documents). The implementation follows Laravel 10's standard morphMany/morphTo relationship pattern with proper composite indexing for performance. Admin endpoints will return Blade views (consistent with existing admin controllers), not JSON API responses.
|
||||||
|
|
||||||
|
**Primary recommendation:** Use Laravel's native polymorphic relationship methods (`morphMany`/`morphTo`) with composite index on `[notable_type, notable_id]` columns, implement eager loading with `withCount('notes')` to prevent N+1 queries, and use Form Request classes for validation following existing codebase patterns.
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| Laravel Framework | 10.50.0 | Backend framework | Existing codebase version |
|
||||||
|
| PHP | 8.1+ | Runtime | Project requirement |
|
||||||
|
| Spatie Laravel Permission | ^5.10 | RBAC authorization | Already integrated for role/permission checks |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| Laravel Debugbar | dev only | N+1 query detection | Development environment to verify no performance issues |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| Polymorphic relationship | Separate notes tables per entity | Polymorphic provides extensibility; separate tables harder to maintain as features grow |
|
||||||
|
| Form Request classes | Inline validation | Form Requests align with existing codebase pattern (see `StoreMemberRequest`) |
|
||||||
|
| Blade views | API JSON responses | Admin controllers return views (consistent with `ArticleController`, `DocumentController` patterns) |
|
||||||
|
|
||||||
|
**Installation:**
|
||||||
|
No new packages required - uses Laravel core features.
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Models/
|
||||||
|
│ ├── Note.php # New polymorphic child model
|
||||||
|
│ └── Member.php # Add morphMany relationship
|
||||||
|
├── Http/
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ │ └── Admin/
|
||||||
|
│ │ └── MemberNoteController.php # New resource controller
|
||||||
|
│ └── Requests/
|
||||||
|
│ ├── StoreNoteRequest.php # Validation for creating notes
|
||||||
|
│ └── UpdateNoteRequest.php # Validation for updating notes
|
||||||
|
└── database/
|
||||||
|
└── migrations/
|
||||||
|
└── YYYY_MM_DD_HHMMSS_create_notes_table.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
└── Admin/
|
||||||
|
└── MemberNoteTest.php # Feature tests for note CRUD
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Polymorphic One-to-Many Relationship
|
||||||
|
**What:** Notes belong to multiple entity types (Member, future Issue, etc.) using `notable_type` and `notable_id` columns
|
||||||
|
**When to use:** When a child model (Note) can attach to multiple parent model types
|
||||||
|
**Example:**
|
||||||
|
```php
|
||||||
|
// Source: https://laravel.com/docs/10.x/eloquent-relationships (Polymorphic Relationships section)
|
||||||
|
|
||||||
|
// app/Models/Note.php
|
||||||
|
class Note extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'notable_type',
|
||||||
|
'notable_id',
|
||||||
|
'content',
|
||||||
|
'author_user_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function notable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'author_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/Models/Member.php
|
||||||
|
class Member extends Model
|
||||||
|
{
|
||||||
|
public function notes(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Note::class, 'notable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
$member = Member::find(1);
|
||||||
|
$notes = $member->notes; // Returns all notes for this member
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Composite Index for Polymorphic Lookups
|
||||||
|
**What:** Single index on both `notable_type` and `notable_id` columns together
|
||||||
|
**When to use:** Always for polymorphic relationships - both columns are required for queries
|
||||||
|
**Example:**
|
||||||
|
```php
|
||||||
|
// Source: https://roelofjanelsinga.com/articles/improve-performance-polymorphic-relationships-laravel/
|
||||||
|
|
||||||
|
Schema::create('notes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('notable'); // Creates notable_type, notable_id, and composite index
|
||||||
|
// Alternatively, manual approach:
|
||||||
|
// $table->string('notable_type');
|
||||||
|
// $table->unsignedBigInteger('notable_id');
|
||||||
|
// $table->index(['notable_type', 'notable_id']); // Composite index - order matters!
|
||||||
|
|
||||||
|
$table->longText('content');
|
||||||
|
$table->foreignId('author_user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Additional indexes for sorting/filtering
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Prevent N+1 with `withCount()`
|
||||||
|
**What:** Eager load relationship counts in a single query
|
||||||
|
**When to use:** When displaying lists with relationship counts (member index showing note count)
|
||||||
|
**Example:**
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase app/Http/Controllers/Admin/ArticleCategoryController.php
|
||||||
|
|
||||||
|
// Admin controller - member list
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
// withCount prevents N+1 by adding notes_count column via subquery
|
||||||
|
$members = Member::withCount('notes')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
return view('admin.members.index', compact('members'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Blade view
|
||||||
|
@foreach($members as $member)
|
||||||
|
<td>{{ $member->notes_count }}</td> <!-- No additional query -->
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Form Request Validation
|
||||||
|
**What:** Separate validation classes with `authorize()` and `rules()` methods
|
||||||
|
**When to use:** All admin controller actions that modify data
|
||||||
|
**Example:**
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase app/Http/Requests/StoreMemberRequest.php
|
||||||
|
|
||||||
|
// app/Http/Requests/StoreNoteRequest.php
|
||||||
|
class StoreNoteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
// Reuse existing admin middleware - all admin users can write notes
|
||||||
|
return $this->user()->hasRole('admin')
|
||||||
|
|| !$this->user()->getAllPermissions()->isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'content' => 'required|string|min:1',
|
||||||
|
'notable_type' => 'required|string|in:App\Models\Member',
|
||||||
|
'notable_id' => 'required|integer|exists:members,id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller usage
|
||||||
|
public function store(StoreNoteRequest $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
// Validation already passed, data is clean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: Audit Logging for Note Actions
|
||||||
|
**What:** Manual audit log calls in controller actions (not observers)
|
||||||
|
**When to use:** After successful data mutations
|
||||||
|
**Example:**
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase app/Http/Controllers/BudgetController.php
|
||||||
|
|
||||||
|
use App\Support\AuditLogger;
|
||||||
|
|
||||||
|
public function store(StoreNoteRequest $request)
|
||||||
|
{
|
||||||
|
$note = Note::create([
|
||||||
|
'notable_type' => $request->notable_type,
|
||||||
|
'notable_id' => $request->notable_id,
|
||||||
|
'content' => $request->content,
|
||||||
|
'author_user_id' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::log('note.created', $note, [
|
||||||
|
'notable_type' => $request->notable_type,
|
||||||
|
'notable_id' => $request->notable_id,
|
||||||
|
'author' => $request->user()->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back()->with('status', '備忘錄已新增');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 6: Admin Route Registration
|
||||||
|
**What:** Group admin routes under `/admin` prefix with `auth` and `admin` middleware
|
||||||
|
**When to use:** All administrative functionality
|
||||||
|
**Example:**
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase routes/web.php
|
||||||
|
|
||||||
|
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
|
||||||
|
// Nested resource route for member notes
|
||||||
|
Route::resource('members.notes', MemberNoteController::class)
|
||||||
|
->only(['store', 'update', 'destroy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes generated:
|
||||||
|
// POST /admin/members/{member}/notes → admin.members.notes.store
|
||||||
|
// PATCH /admin/members/{member}/notes/{note} → admin.members.notes.update
|
||||||
|
// DELETE /admin/members/{member}/notes/{note} → admin.members.notes.destroy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
- **Magic strings for status values:** Use class constants like `Note::STATUS_ACTIVE`, not hardcoded strings
|
||||||
|
- **Single-column indexes on polymorphic tables:** Always use composite `[type, id]` index, not separate indexes
|
||||||
|
- **Lazy loading in lists:** Always use `withCount()` or `with()` to eager load relationships
|
||||||
|
- **Inline validation:** Use Form Request classes consistently with existing codebase
|
||||||
|
- **API resources for admin endpoints:** Admin controllers return Blade views, not JSON (only `/api/v1/*` routes use API resources)
|
||||||
|
- **Automatic audit logging:** This codebase calls `AuditLogger::log()` manually in controllers, not via model observers
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Polymorphic relationships | Custom join tables with type columns | Laravel `morphMany`/`morphTo` | Handles fully qualified class names, supports eager loading, integrates with query builder |
|
||||||
|
| N+1 query prevention | Manual caching or query optimization | `withCount()`, `with()` eager loading | Laravel optimizes to single subquery, works with pagination |
|
||||||
|
| Polymorphic indexes | Separate indexes on `_type` and `_id` | `$table->morphs('notable')` or composite `index(['type', 'id'])` | MySQL/PostgreSQL can only use composite index when querying both columns together |
|
||||||
|
| Request validation | Try/catch with manual validation | Form Request classes with `authorize()` and `rules()` | Automatic 422 responses, clean controller code, reusable validation logic |
|
||||||
|
| Audit logging | Custom logging to text files | `AuditLogger::log($action, $model, $metadata)` | Already implemented, stores structured JSON metadata, searchable in database |
|
||||||
|
|
||||||
|
**Key insight:** Laravel's polymorphic relationship methods handle edge cases like proper class name storage (can use morph map to decouple), query scoping, and eager loading optimizations. Composite indexing is critical because MySQL/PostgreSQL cannot use separate indexes for queries requiring both `notable_type = 'X' AND notable_id = Y`.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Missing Composite Index on Polymorphic Columns
|
||||||
|
**What goes wrong:** Queries for notes on a specific member become slow (full table scans) when notes table grows beyond ~10,000 records
|
||||||
|
**Why it happens:** Developers create separate indexes on `notable_type` and `notable_id` instead of a single composite index
|
||||||
|
**How to avoid:** Use `$table->morphs('notable')` which automatically creates the composite index, or manually use `$table->index(['notable_type', 'notable_id'])`
|
||||||
|
**Warning signs:** Slow member detail pages when loading notes, database query time over 100ms for note lookups
|
||||||
|
|
||||||
|
**Source:** [Roelof Jan Elsinga - Improve query performance for polymorphic relationships](https://roelofjanelsinga.com/articles/improve-performance-polymorphic-relationships-laravel/)
|
||||||
|
|
||||||
|
### Pitfall 2: N+1 Queries When Displaying Note Counts
|
||||||
|
**What goes wrong:** Member list page makes 1 query to fetch members + N queries to count notes for each member (21 queries for 20 members)
|
||||||
|
**Why it happens:** Blade views access `$member->notes->count()` without eager loading the count in the controller
|
||||||
|
**How to avoid:** Use `Member::withCount('notes')` in controller, then access `$member->notes_count` in Blade (single subquery added to initial fetch)
|
||||||
|
**Warning signs:** Laravel Debugbar shows N queries all with same structure (SELECT COUNT(*) FROM notes WHERE notable_id = ?), page load time increases linearly with pagination size
|
||||||
|
|
||||||
|
**Source:** [Yellow Duck - Laravel's Eloquent withCount method](https://www.yellowduck.be/posts/laravels-eloquent-withcount-method)
|
||||||
|
|
||||||
|
### Pitfall 3: Wrong Column Order in Composite Index
|
||||||
|
**What goes wrong:** Composite index `['notable_id', 'notable_type']` is not used by queries filtering on both columns
|
||||||
|
**Why it happens:** MySQL uses leftmost prefix rule - index only applies if query filters leftmost column first
|
||||||
|
**How to avoid:** Always put `notable_type` first in composite index: `['notable_type', 'notable_id']` because queries always filter by type AND id together
|
||||||
|
**Warning signs:** EXPLAIN query shows "type: ALL" (full table scan) even with composite index present
|
||||||
|
|
||||||
|
**Source:** [René Roth - Composite indexes in Laravel & MySQL](https://reneroth.xyz/composite-indices-in-laravel/)
|
||||||
|
|
||||||
|
### Pitfall 4: Self-Approval in Author Verification
|
||||||
|
**What goes wrong:** Note author can modify or delete their own notes without proper authorization checks
|
||||||
|
**Why it happens:** Controller checks `if($user->can('edit_notes'))` but doesn't verify `$note->author_user_id != $user->id`
|
||||||
|
**How to avoid:** Based on existing codebase pattern with `HasApprovalWorkflow` trait, implement similar check in controller authorization or use Laravel policies
|
||||||
|
**Warning signs:** Audit trail shows notes modified by original author when business rule requires separate approver
|
||||||
|
|
||||||
|
**Source:** Existing codebase pattern in `app/Traits/HasApprovalWorkflow.php` (self-approval prevention)
|
||||||
|
|
||||||
|
### Pitfall 5: Missing Database Transactions for Multi-Step Operations
|
||||||
|
**What goes wrong:** Note created successfully but audit log fails, leaving incomplete audit trail
|
||||||
|
**Why it happens:** Controller creates note and logs audit in separate operations without transaction wrapper
|
||||||
|
**How to avoid:** Wrap related operations in `DB::transaction()` closure - both succeed or both rollback
|
||||||
|
**Warning signs:** Database contains notes without corresponding audit_logs entries
|
||||||
|
|
||||||
|
**Source:** [Laravel Daily - Database Transactions: 3 Practical Examples](https://laraveldaily.com/post/laravel-database-transactions-examples)
|
||||||
|
|
||||||
|
### Pitfall 6: Storing Full Class Names in notable_type Without Morph Map
|
||||||
|
**What goes wrong:** Refactoring model namespace from `App\Models\Member` to `App\Domain\Members\Member` breaks all existing polymorphic relationships
|
||||||
|
**Why it happens:** Laravel stores fully qualified class name by default; database contains hardcoded `App\Models\Member` strings
|
||||||
|
**How to avoid:** Define morph map in `AppServiceProvider::boot()` using `Relation::enforceMorphMap(['member' => Member::class])` - stores 'member' string instead of class name
|
||||||
|
**Warning signs:** After namespace refactor, queries return empty results for previously created polymorphic relations
|
||||||
|
|
||||||
|
**Source:** [Laravel 10 Eloquent Relationships - Custom Polymorphic Types](https://laravel.com/docs/10.x/eloquent-relationships)
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from official sources and existing codebase:
|
||||||
|
|
||||||
|
### Migration for Notes Table
|
||||||
|
```php
|
||||||
|
// Source: Laravel 10 migration patterns + existing codebase database/migrations/2026_02_07_120002_create_articles_table.php
|
||||||
|
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('notes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Polymorphic relationship columns with composite index
|
||||||
|
$table->morphs('notable'); // Creates notable_type, notable_id, and index(['notable_type', 'notable_id'])
|
||||||
|
|
||||||
|
$table->longText('content');
|
||||||
|
$table->foreignId('author_user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Additional index for chronological sorting
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notes');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note Model with Polymorphic Relationship
|
||||||
|
```php
|
||||||
|
// Source: https://laravel.com/docs/10.x/eloquent-relationships + existing codebase patterns
|
||||||
|
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class Note extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'notable_type',
|
||||||
|
'notable_id',
|
||||||
|
'content',
|
||||||
|
'author_user_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent notable model (Member, Issue, etc.)
|
||||||
|
*/
|
||||||
|
public function notable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user who authored this note
|
||||||
|
*/
|
||||||
|
public function author(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'author_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Member Model with Notes Relationship
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase app/Models/Member.php pattern
|
||||||
|
|
||||||
|
// Add to app/Models/Member.php
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
|
class Member extends Model
|
||||||
|
{
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all notes for this member
|
||||||
|
*/
|
||||||
|
public function notes(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Note::class, 'notable')->orderBy('created_at', 'desc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Request for Note Creation
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase app/Http/Requests/StoreMemberRequest.php pattern
|
||||||
|
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreNoteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
// Matches existing admin middleware pattern from app/Http/Middleware/EnsureUserIsAdmin.php
|
||||||
|
return $this->user()->hasRole('admin')
|
||||||
|
|| !$this->user()->getAllPermissions()->isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'content' => 'required|string|min:1|max:65535', // longText column limit
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'content.required' => '備忘錄內容為必填欄位',
|
||||||
|
'content.min' => '備忘錄內容不可為空白',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Controller for Member Notes
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase app/Http/Controllers/Admin/ArticleController.php pattern
|
||||||
|
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreNoteRequest;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Support\AuditLogger;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class MemberNoteController extends Controller
|
||||||
|
{
|
||||||
|
public function store(StoreNoteRequest $request, Member $member)
|
||||||
|
{
|
||||||
|
// Database transaction ensures both note creation and audit logging succeed together
|
||||||
|
$note = DB::transaction(function () use ($request, $member) {
|
||||||
|
$note = $member->notes()->create([
|
||||||
|
'content' => $request->content,
|
||||||
|
'author_user_id' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::log('note.created', $note, [
|
||||||
|
'member_id' => $member->id,
|
||||||
|
'member_name' => $member->full_name,
|
||||||
|
'author' => $request->user()->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $note;
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.members.show', $member)
|
||||||
|
->with('status', '備忘錄已新增');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Member $member, Note $note)
|
||||||
|
{
|
||||||
|
// Verify note belongs to this member (prevent URL manipulation)
|
||||||
|
if ($note->notable_id !== $member->id || $note->notable_type !== Member::class) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($note, $member) {
|
||||||
|
$note->delete();
|
||||||
|
|
||||||
|
AuditLogger::log('note.deleted', $member, [
|
||||||
|
'note_id' => $note->id,
|
||||||
|
'content_preview' => substr($note->content, 0, 50),
|
||||||
|
'deleted_by' => auth()->user()->name,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.members.show', $member)
|
||||||
|
->with('status', '備忘錄已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preventing N+1 in Member List
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase app/Http/Controllers/Admin/ArticleCategoryController.php pattern
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
// withCount adds notes_count via single subquery, preventing N+1
|
||||||
|
$members = Member::withCount('notes')
|
||||||
|
->orderBy('membership_status')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
return view('admin.members.index', compact('members'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blade View Showing Note Count
|
||||||
|
```blade
|
||||||
|
<!-- Source: Existing codebase pattern -->
|
||||||
|
|
||||||
|
@foreach($members as $member)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $member->full_name }}</td>
|
||||||
|
<td>{{ $member->membership_status_label }}</td>
|
||||||
|
<td>{{ $member->notes_count }}</td> {{-- No additional query fired --}}
|
||||||
|
<td>
|
||||||
|
<a href="{{ route('admin.members.show', $member) }}">查看</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
### Factory for Testing
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase database/factories/MemberFactory.php pattern
|
||||||
|
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class NoteFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Note::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'notable_type' => Member::class,
|
||||||
|
'notable_id' => Member::factory(),
|
||||||
|
'content' => $this->faker->paragraph(),
|
||||||
|
'author_user_id' => User::factory(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the note belongs to a specific member
|
||||||
|
*/
|
||||||
|
public function forMember(Member $member): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'notable_type' => Member::class,
|
||||||
|
'notable_id' => $member->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Test Pattern
|
||||||
|
```php
|
||||||
|
// Source: Existing codebase tests/Feature/MemberRegistrationTest.php pattern
|
||||||
|
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MemberNoteTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_note_for_member(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$member = Member::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($admin)
|
||||||
|
->post(route('admin.members.notes.store', $member), [
|
||||||
|
'content' => 'Test note content',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('admin.members.show', $member));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('notes', [
|
||||||
|
'notable_type' => Member::class,
|
||||||
|
'notable_id' => $member->id,
|
||||||
|
'content' => 'Test note content',
|
||||||
|
'author_user_id' => $admin->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify audit log created
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'action' => 'note.created',
|
||||||
|
'auditable_type' => Note::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_note_count_is_loaded_without_n_plus_1(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$members = Member::factory()->count(3)->create();
|
||||||
|
foreach ($members as $member) {
|
||||||
|
Note::factory()->count(2)->forMember($member)->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track queries
|
||||||
|
DB::enableQueryLog();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.members.index'));
|
||||||
|
|
||||||
|
$queries = DB::getQueryLog();
|
||||||
|
|
||||||
|
// Should be ~2 queries: 1 for members with count subquery, 1 for pagination count
|
||||||
|
$this->assertLessThan(5, count($queries), 'N+1 query detected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Separate notes tables per entity | Single polymorphic notes table | Laravel 5.0+ (2015) | Reduced schema complexity, easier to add new notable types |
|
||||||
|
| Manual composite indexes | `$table->morphs('notable')` helper | Laravel 5.0+ | Automatic index creation, less error-prone |
|
||||||
|
| Multiple queries for counts | `withCount()` method | Laravel 5.3 (2016) | Single subquery for all counts, eliminates N+1 |
|
||||||
|
| Inline controller validation | Form Request classes | Laravel 5.0+ | Cleaner controllers, reusable validation logic |
|
||||||
|
| Manual permission checks | Spatie Laravel Permission package | Package v5+ | Standardized RBAC, role inheritance, caching |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- **Separate foreign keys per entity type**: Use polymorphic `notable_type`/`notable_id` instead
|
||||||
|
- **`$table->integer('notable_id')->unsigned()`**: Use `$table->unsignedBigInteger()` or let `morphs()` handle it (auto uses bigIncrements)
|
||||||
|
- **Manual audit logging to text files**: Use structured database logging with `AuditLogger::log()` for searchability
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Should notes have soft deletes?**
|
||||||
|
- What we know: Existing models like Article use `softDeletes()`, but Member model does not
|
||||||
|
- What's unclear: Business requirement for note recovery vs. hard deletion
|
||||||
|
- Recommendation: Start without soft deletes (simpler), add if business requires note recovery. Can be added later via migration without data loss.
|
||||||
|
|
||||||
|
2. **Should notes support file attachments?**
|
||||||
|
- What we know: Requirements mention "text notes" only, existing Article model has separate `article_attachments` table
|
||||||
|
- What's unclear: Future extensibility vs. YAGNI principle
|
||||||
|
- Recommendation: Implement text-only per requirements. If attachments needed later, follow Article pattern with polymorphic `note_attachments` table.
|
||||||
|
|
||||||
|
3. **Should notes have access levels (who can view)?**
|
||||||
|
- What we know: Article model has `access_level` (public/members/board/admin), requirements specify "all admin roles can view and write"
|
||||||
|
- What's unclear: Whether different admin roles should have read/write separation
|
||||||
|
- Recommendation: Start with uniform admin access (simpler, matches requirements). If granular permissions needed, add `viewNotes` and `writeNotes` permissions via Spatie package.
|
||||||
|
|
||||||
|
4. **Should notable_type use morph map or full class names?**
|
||||||
|
- What we know: Morph map decouples database from class names, but adds configuration overhead
|
||||||
|
- What's unclear: Likelihood of model namespace refactoring in this codebase
|
||||||
|
- Recommendation: Use morph map defensively - add `Relation::enforceMorphMap(['member' => Member::class])` in `AppServiceProvider::boot()`. Low cost, high protection against future refactoring pain.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- [Laravel 10.x Eloquent Relationships - Polymorphic](https://laravel.com/docs/10.x/eloquent-relationships) - Official documentation for morphMany/morphTo patterns
|
||||||
|
- [Laravel 10.x Form Request Validation](https://laravel.com/docs/10.x/validation) - Official documentation for Form Request classes
|
||||||
|
- Existing codebase patterns:
|
||||||
|
- `app/Models/Member.php` - Model structure with constants, relationships
|
||||||
|
- `app/Models/CustomFieldValue.php` - Polymorphic relationship example
|
||||||
|
- `app/Http/Requests/StoreMemberRequest.php` - Form Request validation pattern
|
||||||
|
- `app/Http/Controllers/Admin/ArticleController.php` - Admin controller return patterns
|
||||||
|
- `app/Support/AuditLogger.php` - Audit logging implementation
|
||||||
|
- `database/migrations/2026_02_07_120002_create_articles_table.php` - Modern migration structure
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- [Roelof Jan Elsinga - Improve query performance for polymorphic relationships](https://roelofjanelsinga.com/articles/improve-performance-polymorphic-relationships-laravel/) - Composite index performance validation
|
||||||
|
- [Yellow Duck - Laravel's Eloquent withCount method](https://www.yellowduck.be/posts/laravels-eloquent-withcount-method) - N+1 prevention with withCount
|
||||||
|
- [René Roth - Composite indexes in Laravel & MySQL](https://reneroth.xyz/composite-indices-in-laravel/) - Index column ordering best practices
|
||||||
|
- [Laravel Daily - Database Transactions: 3 Practical Examples](https://laraveldaily.com/post/laravel-database-transactions-examples) - Transaction usage patterns
|
||||||
|
- [Gergő Tar - Handling API Controllers and JSON Responses](https://gergotar.com/blog/posts/handling-api-controllers-and-json-responses-in-laravel/) - API response patterns (verified admin uses Blade, not JSON)
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- [LogRocket - Polymorphic relationships in Laravel and their use cases](https://blog.logrocket.com/polymorphic-relationships-laravel/) - General conceptual overview
|
||||||
|
- [LinkedIn - Implementing and Seeding Polymorphic Relationships](https://www.linkedin.com/pulse/implementing-seeding-polymorphic-relationships-laravel-faizan-kamal-8hutf) - Factory patterns for testing
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH - Laravel 10.50.0 confirmed via `php artisan --version`, existing patterns verified in codebase
|
||||||
|
- Architecture: HIGH - Polymorphic relationships, Form Requests, and admin patterns all verified in existing codebase
|
||||||
|
- Pitfalls: HIGH - Composite indexing and N+1 prevention verified in Laravel 10 docs and performance articles from authoritative sources
|
||||||
|
- Code examples: HIGH - All examples derived from official Laravel 10 docs or existing codebase patterns
|
||||||
|
|
||||||
|
**Research date:** 2026-02-13
|
||||||
|
**Valid until:** 2026-03-15 (30 days - Laravel 10 is stable, patterns unlikely to change)
|
||||||
Reference in New Issue
Block a user