From 3715aae2eb93a39e023d2709cc6261ae6401599e Mon Sep 17 00:00:00 2001 From: gbanyan Date: Fri, 13 Feb 2026 11:51:56 +0800 Subject: [PATCH] docs(01): research phase domain --- .../01-RESEARCH.md | 727 ++++++++++++++++++ 1 file changed, 727 insertions(+) create mode 100644 .planning/phases/01-database-schema-backend-api/01-RESEARCH.md diff --git a/.planning/phases/01-database-schema-backend-api/01-RESEARCH.md b/.planning/phases/01-database-schema-backend-api/01-RESEARCH.md new file mode 100644 index 0000000..a5f954b --- /dev/null +++ b/.planning/phases/01-database-schema-backend-api/01-RESEARCH.md @@ -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) + {{ $member->notes_count }} +@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 + +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 + +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 + +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 + +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 + + +@foreach($members as $member) + + {{ $member->full_name }} + {{ $member->membership_status_label }} + {{ $member->notes_count }} {{-- No additional query fired --}} + + 查看 + + +@endforeach +``` + +### Factory for Testing +```php +// Source: Existing codebase database/factories/MemberFactory.php pattern + + 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 + +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)