reviewd and fixed epic 1 & 2

This commit is contained in:
Naser Mansour 2025-12-20 21:14:15 +02:00
parent 8f95089814
commit b93b9363a6
9 changed files with 1040 additions and 66 deletions

View File

@ -49,9 +49,13 @@ So that **the foundation is established for all subsequent feature development**
## Technical Notes
- **Database:** Use SQLite for development (configurable for MariaDB in production)
- **Environment:** Set `DB_CONNECTION=sqlite` in `.env` for development
- **Reference:** PRD Section 16.1 for complete schema details
- **Pattern:** Follow existing Volt class-based component pattern
- **Structure:** Use Laravel 12 streamlined file structure (no app/Http/Middleware, bootstrap/app.php for config)
- **Testing:** Use Pest 4 for all tests (`php artisan make:test --pest`)
- **Models:** Create with `php artisan make:model ModelName -mf` (migration + factory)
- **Notifications table:** Custom implementation per PRD schema (not Laravel's built-in notifications)
### Database Schema Reference
@ -100,6 +104,40 @@ admin_logs:
- [ ] Code formatted with Pint
- [ ] No errors on fresh install
## Test Scenarios
All tests should use Pest 4 (`php artisan make:test --pest`).
### Migration Tests
- [ ] All migrations run successfully: `php artisan migrate:fresh`
- [ ] Migrations can rollback without errors: `php artisan migrate:rollback`
- [ ] Foreign key constraints are properly created (timeline_updates.timeline_id, timeline_updates.admin_id, consultations.user_id, timelines.user_id, admin_logs.admin_id)
### Factory Tests
- [ ] UserFactory creates valid User with all user_type enum values (admin, individual, company)
- [ ] ConsultationFactory creates valid Consultation with proper user relationship
- [ ] TimelineFactory creates valid Timeline with proper user relationship
- [ ] TimelineUpdateFactory creates valid TimelineUpdate with timeline and admin relationships
- [ ] PostFactory creates valid Post with bilingual fields
- [ ] WorkingHoursFactory creates valid WorkingHours for each day_of_week (0-6)
- [ ] BlockedTimeFactory creates valid BlockedTime entries
- [ ] NotificationFactory creates valid Notification entries
- [ ] AdminLogFactory creates valid AdminLog with JSON fields
### Enum Validation Tests
- [ ] User.user_type only accepts: admin, individual, company
- [ ] User.status only accepts: active, deactivated
- [ ] User.preferred_language only accepts: ar, en
- [ ] Consultation.status only accepts: pending, approved, completed, cancelled, no_show
- [ ] Consultation.type only accepts: free, paid
- [ ] Timeline.status only accepts: active, archived
- [ ] Post.status only accepts: draft, published
### Edge Case Tests
- [ ] Nullable fields accept null values (national_id, company_name, case_reference, etc.)
- [ ] JSON fields (old_values, new_values) store and retrieve correctly
- [ ] Unique constraint on case_reference allows multiple nulls but no duplicate values
## Dependencies
- None (this is the foundation story)
@ -110,6 +148,12 @@ admin_logs:
- **Mitigation:** Follow PRD specifications closely, validate with stakeholder
- **Rollback:** Fresh migration reset possible in development
### Error Handling
- **Migration failures:** Run `php artisan migrate:status` to identify failed migrations, fix the issue, then `php artisan migrate` to continue
- **Foreign key errors:** Ensure referenced tables are created before dependent tables (migrations run in filename order)
- **Factory errors:** Ensure factories define all required (non-nullable) fields and use valid enum values
## Estimation
**Complexity:** Medium

View File

@ -35,15 +35,18 @@ So that **only authorized users can access the platform with appropriate permiss
- [ ] Remember me functionality (optional)
### Integration Requirements
- [ ] Login redirects to appropriate dashboard (admin vs client)
- [ ] Logout clears session properly
- [ ] Middleware protects admin-only routes
- [ ] Failed login attempts logged
- [ ] Login redirects to appropriate dashboard:
- Admin users → `/admin/dashboard` (to be created in Epic 2)
- Client users → `/dashboard` (to be created in Epic 2)
- For now, redirect to a placeholder route or home page
- [ ] Logout clears session properly and redirects to login page
- [ ] Middleware protects admin-only routes (return 403 for unauthorized)
- [ ] Failed login attempts logged to `admin_logs` table
### Quality Requirements
- [ ] Login form validates inputs properly
- [ ] Error messages are clear and bilingual
- [ ] Tests cover authentication flow
- [ ] All test scenarios in "Test Scenarios" section pass
- [ ] No security vulnerabilities
## Technical Notes
@ -84,20 +87,78 @@ Gate::define('client', fn (User $user) => $user->user_type !== 'admin');
- `can:admin` - Require admin role
- Custom middleware for session timeout if needed
### Environment Configuration
```env
SESSION_LIFETIME=120 # 2 hours in minutes
SESSION_EXPIRE_ON_CLOSE=false
```
### Edge Case Behavior
**Rate Limiting (5 attempts per minute):**
- After 5 failed attempts, show message: "Too many login attempts. Please try again in 60 seconds."
- Use bilingual message from translation files
- Log rate limit events for security monitoring
**Session Timeout (2 hours inactivity):**
- When session expires, redirect to login page
- Show flash message: "Your session has expired. Please log in again."
- Preserve intended URL for redirect after re-authentication
**Deactivated Account Login Attempt:**
- Check `status` field during authentication
- If `status === 'deactivated'`, reject login with message: "Your account has been deactivated. Please contact the administrator."
## Test Scenarios
### Feature Tests (tests/Feature/Auth/)
**Login Flow:**
- [ ] `test_login_page_renders_correctly` - Login page displays in default language
- [ ] `test_user_can_login_with_valid_credentials` - Valid credentials redirect to dashboard
- [ ] `test_admin_redirects_to_admin_dashboard` - Admin user goes to admin dashboard
- [ ] `test_client_redirects_to_client_dashboard` - Client user goes to client dashboard
- [ ] `test_invalid_credentials_show_error` - Wrong password shows bilingual error
- [ ] `test_nonexistent_user_shows_error` - Unknown email shows generic error (no user enumeration)
- [ ] `test_deactivated_user_cannot_login` - Deactivated account rejected with message
**Rate Limiting:**
- [ ] `test_rate_limiting_blocks_after_five_attempts` - 6th attempt blocked
- [ ] `test_rate_limit_message_is_bilingual` - Error message respects locale
- [ ] `test_rate_limit_resets_after_one_minute` - Can retry after cooldown
**Session Management:**
- [ ] `test_logout_clears_session` - Logout destroys session data
- [ ] `test_remember_me_extends_session` - Remember token works (if implemented)
**Authorization:**
- [ ] `test_admin_can_access_admin_routes` - Admin passes `can:admin` middleware
- [ ] `test_client_cannot_access_admin_routes` - Client gets 403 on admin routes
- [ ] `test_unauthenticated_user_redirected_to_login` - Guest redirected from protected routes
### Unit Tests (tests/Unit/)
**Gate Definitions:**
- [ ] `test_admin_gate_returns_true_for_admin_user` - Gate check passes
- [ ] `test_admin_gate_returns_false_for_client_user` - Gate check fails
- [ ] `test_client_gate_returns_true_for_individual_user` - Client gate passes
- [ ] `test_client_gate_returns_true_for_company_user` - Client gate passes
## Definition of Done
- [ ] Login page renders correctly in both languages
- [ ] Users can log in with valid credentials
- [ ] Invalid credentials show proper error
- [ ] Rate limiting prevents brute force
- [ ] Invalid credentials show proper error (bilingual)
- [ ] Deactivated users cannot log in
- [ ] Rate limiting prevents brute force (5 attempts/minute)
- [ ] Session expires after 2 hours inactivity
- [ ] Admin routes protected from clients
- [ ] Tests pass for authentication flow
- [ ] Admin routes protected from clients (403 response)
- [ ] All Feature and Unit tests from "Test Scenarios" section pass
- [ ] Code formatted with Pint
## Dependencies
- **Story 1.1:** Database schema (users table)
- **Story 1.1:** Database schema - creates `users` table with `user_type` enum (`admin`, `individual`, `company`), `status` enum (`active`, `deactivated`), and `preferred_language` enum (`ar`, `en`)
- **Story 1.3:** Bilingual infrastructure (for login page translations)
## Risk Assessment

View File

@ -16,15 +16,22 @@ So that **I can use the platform in my preferred language with proper RTL/LTR la
- **Follows pattern:** Laravel localization best practices
- **Touch points:** All views, navigation, date/time formatting
### Reference Documents
- **PRD Section 4.2:** Language Support requirements (RTL/LTR, numerals, date/time formats)
- **PRD Section 7.1.C:** Typography specifications (Arabic: Cairo/Tajawal, English: Montserrat/Lato)
## Acceptance Criteria
### Functional Requirements
- [ ] Language files for Arabic (ar) and English (en)
- [ ] Language toggle in navigation (visible on all pages)
- [ ] User language preference stored in `users.preferred_language`
- [ ] Guest language stored in session
- [ ] Guest language stored in session (persists across page loads)
- [ ] RTL layout for Arabic, LTR for English
- [ ] All UI elements translatable via `__()` helper
- [ ] Language switch preserves current page (redirect back to same URL)
- [ ] Form validation messages display in current locale
- [ ] Missing translations fall back to key name (not break page)
### Date/Time Formatting
- [ ] Arabic: DD/MM/YYYY format
@ -52,9 +59,28 @@ So that **I can use the platform in my preferred language with proper RTL/LTR la
## Technical Notes
### Language Toggle Route & Controller
```php
// routes/web.php
Route::get('/language/{locale}', function (string $locale) {
if (!in_array($locale, ['ar', 'en'])) {
abort(400);
}
session(['locale' => $locale]);
if (auth()->check()) {
auth()->user()->update(['preferred_language' => $locale]);
}
return redirect()->back();
})->name('language.switch');
```
### Language Middleware
```php
// Middleware to set locale
// app/Http/Middleware/SetLocale.php
// Register in bootstrap/app.php: ->withMiddleware(fn($m) => $m->web(append: SetLocale::class))
public function handle($request, Closure $next)
{
$locale = session('locale',
@ -115,7 +141,7 @@ resources/lang/
### Date Formatting Helper
```php
// In a helper or service
// In a helper or service (app/Helpers/DateHelper.php or as a service)
public function formatDate($date, $locale = null): string
{
$locale = $locale ?? app()->getLocale();
@ -124,6 +150,59 @@ public function formatDate($date, $locale = null): string
}
```
### Language Toggle Component
```blade
<!-- resources/views/components/language-toggle.blade.php -->
<div class="flex items-center gap-2">
<a
href="{{ route('language.switch', 'ar') }}"
@class([
'text-sm px-2 py-1 rounded',
'bg-gold text-navy font-bold' => app()->getLocale() === 'ar',
'text-gold hover:text-gold-light' => app()->getLocale() !== 'ar',
])
>
العربية
</a>
<span class="text-gold/50">|</span>
<a
href="{{ route('language.switch', 'en') }}"
@class([
'text-sm px-2 py-1 rounded',
'bg-gold text-navy font-bold' => app()->getLocale() === 'en',
'text-gold hover:text-gold-light' => app()->getLocale() !== 'en',
])
>
English
</a>
</div>
```
## Testing Requirements
### Feature Tests
- [ ] Test language toggle stores preference in session for guests
- [ ] Test language toggle stores preference in database for authenticated users
- [ ] Test locale middleware sets correct locale from user preference
- [ ] Test locale middleware falls back to session for guests
- [ ] Test locale middleware defaults to 'ar' when no preference set
- [ ] Test date formatting helper returns DD/MM/YYYY for Arabic locale
- [ ] Test date formatting helper returns MM/DD/YYYY for English locale
- [ ] Test language switch redirects back to same page
### Browser Tests (Pest v4)
- [ ] Test RTL layout renders correctly for Arabic (dir="rtl" on html)
- [ ] Test LTR layout renders correctly for English (dir="ltr" on html)
- [ ] Test no layout breaks when toggling languages
- [ ] Test Arabic font (Cairo) loads for Arabic locale
- [ ] Test English font (Montserrat) loads for English locale
### Manual Testing Checklist
- [ ] Verify RTL alignment in navigation, forms, and content
- [ ] Verify LTR alignment in navigation, forms, and content
- [ ] Test on Chrome, Firefox, Safari for RTL rendering
- [ ] Verify no horizontal scroll appears in either direction
## Definition of Done
- [ ] Language toggle works in navigation

View File

@ -16,6 +16,12 @@ So that **I can easily navigate the platform on any device**.
- **Follows pattern:** Flux UI navbar patterns, mobile-first design
- **Touch points:** All pages (layout component)
### Referenced Documents
- **PRD Section 5.2:** Navigation System - menu structure, language toggle requirements
- **PRD Section 7.1:** Brand Identity & Visual Guidelines - color palette, typography, spacing
- **PRD Section 7.2-7.4:** Design principles, UI/UX requirements, responsive breakpoints
- **Story 1.3:** Bilingual Infrastructure - RTL/LTR detection via `app()->getLocale()`, font configuration
## Acceptance Criteria
### Color Scheme
@ -139,7 +145,8 @@ So that **I can easily navigate the platform on any device**.
<!-- resources/views/components/logo.blade.php -->
@props(['size' => 'default'])
<img
@if(file_exists(public_path('images/logo.svg')))
<img
src="{{ asset('images/logo.svg') }}"
alt="{{ __('Libra Law Firm') }}"
@class([
@ -147,9 +154,97 @@ So that **I can easily navigate the platform on any device**.
'h-12' => $size === 'default',
'h-16' => $size === 'large',
])
/>
/>
@else
<span @class([
'font-bold text-gold',
'text-lg' => $size === 'small',
'text-2xl' => $size === 'default',
'text-3xl' => $size === 'large',
])>Libra</span>
@endif
```
### Asset Dependencies
- **Logo SVG:** Required at `public/images/logo.svg`
- **Fallback:** Text "Libra" in gold displayed if logo asset not available
- **Logo Specs:** SVG format preferred, min 120px width desktop, 80px mobile
- **Note:** Logo asset to be provided by client; use text fallback during development
### Auth-Aware Navigation
```blade
<!-- Guest vs Authenticated menu items -->
<flux:navbar.links class="text-gold">
<flux:navbar.link href="/" :active="request()->is('/')">
{{ __('navigation.home') }}
</flux:navbar.link>
<flux:navbar.link href="/booking" :active="request()->is('booking*')">
{{ __('navigation.booking') }}
</flux:navbar.link>
<flux:navbar.link href="/posts" :active="request()->is('posts*')">
{{ __('navigation.posts') }}
</flux:navbar.link>
@auth
<flux:navbar.link href="/dashboard" :active="request()->is('dashboard*')">
{{ __('navigation.dashboard') }}
</flux:navbar.link>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<flux:navbar.link
href="{{ route('logout') }}"
onclick="event.preventDefault(); this.closest('form').submit();"
>
{{ __('navigation.logout') }}
</flux:navbar.link>
</form>
@else
<flux:navbar.link href="/login" :active="request()->is('login')">
{{ __('navigation.login') }}
</flux:navbar.link>
@endauth
</flux:navbar.links>
```
## Test Scenarios
### Navigation Rendering Tests
- [ ] Navigation component renders on all pages
- [ ] Logo displays correctly (or text fallback if SVG missing)
- [ ] All menu links are visible and clickable
- [ ] Active page is visually indicated in navigation
- [ ] Navigation has correct navy background and gold text
### Mobile Menu Tests
- [ ] Hamburger menu icon visible on mobile viewports
- [ ] Mobile menu toggles open on click
- [ ] Mobile menu closes on outside click
- [ ] Mobile menu closes when navigating to a link
- [ ] Touch targets are at least 44px height
### Authentication State Tests
- [ ] Guest users see: Home, Booking, Posts, Login
- [ ] Authenticated users see: Home, Booking, Posts, Dashboard, Logout
- [ ] Logout form submits correctly and logs user out
### Language Toggle Tests
- [ ] Language toggle visible in navigation
- [ ] Switching to Arabic applies RTL layout
- [ ] Switching to English applies LTR layout
- [ ] Language preference persists across page loads
### Footer Tests
- [ ] Footer renders at bottom of viewport (sticky footer)
- [ ] Footer contains logo (smaller version)
- [ ] Footer contains Terms of Service and Privacy Policy links
- [ ] Copyright year displays current year dynamically
### Responsive Tests
- [ ] No horizontal scroll on mobile (320px+)
- [ ] No horizontal scroll on tablet (768px)
- [ ] Layout adapts correctly at all breakpoints
- [ ] Logo centered on mobile, left-aligned on desktop
## Definition of Done
- [ ] Navigation renders correctly on all viewports

View File

@ -15,6 +15,12 @@ So that **I can manage client information and provide them platform access**.
- **Technology:** Livewire Volt, Flux UI forms
- **Follows pattern:** Admin CRUD patterns, class-based Volt components
- **Touch points:** User model, admin dashboard
- **PRD Reference:** Section 5.3 (User Management System), Section 16.1 (Database Schema)
### Prerequisites from Epic 1
- Users table with fields: `name`, `email`, `password`, `user_type`, `national_id`, `phone`, `preferred_language`, `status`
- AdminLog model and `admin_logs` table for audit logging
- Bilingual infrastructure (lang files, `__()` helper)
## Acceptance Criteria
@ -63,6 +69,15 @@ So that **I can manage client information and provide them platform access**.
## Technical Notes
### Files to Create
```
resources/views/livewire/admin/clients/individual/
├── index.blade.php # List view with search/filter/pagination
├── create.blade.php # Create form
├── edit.blade.php # Edit form
└── show.blade.php # View profile page
```
### User Model Scope
```php
// In User model
@ -136,6 +151,80 @@ AdminLog::create([
]);
```
### Edge Cases & Error Handling
- **Validation failure:** Display inline field errors using Flux UI error states
- **Duplicate email:** Show "Email already exists" error on email field
- **Duplicate National ID:** Show "National ID already registered" error
- **Empty search results:** Display "No clients found" message with clear filters option
## Testing Requirements
### Test File Location
`tests/Feature/Admin/IndividualClientTest.php`
### Test Scenarios
#### Create Client Tests
- [ ] Can create individual client with all valid data
- [ ] Cannot create client without required name field
- [ ] Cannot create client without required email field
- [ ] Cannot create client without required national_id field
- [ ] Cannot create client without required phone field
- [ ] Cannot create client with invalid email format
- [ ] Cannot create client with duplicate email (existing user)
- [ ] Cannot create client with duplicate national_id
- [ ] Cannot create client with password less than 8 characters
- [ ] Created client has user_type set to 'individual'
- [ ] AdminLog entry created on successful creation
#### List View Tests
- [ ] Index page displays only individual clients (not company/admin)
- [ ] Pagination works with 10/25/50 per page options
- [ ] Clients sorted by created_at desc by default
#### Search & Filter Tests
- [ ] Can search clients by name (partial match)
- [ ] Can search clients by email (partial match)
- [ ] Can search clients by national_id (partial match)
- [ ] Can filter clients by active status
- [ ] Can filter clients by deactivated status
- [ ] Clear filters resets search and filter
#### Edit Client Tests
- [ ] Can edit existing client information
- [ ] Edit form pre-populates with current values
- [ ] Validation rules apply on edit (except unique for own record)
- [ ] AdminLog entry created on successful update
- [ ] Cannot change user_type via edit form
#### View Profile Tests
- [ ] Profile page displays all client information
- [ ] Profile shows consultation count/summary
- [ ] Profile shows timeline count/summary
### Testing Approach
```php
use Livewire\Volt\Volt;
test('admin can create individual client', function () {
$admin = User::factory()->admin()->create();
Volt::actingAs($admin)
->test('admin.clients.individual.create')
->set('name', 'Test Client')
->set('email', 'client@example.com')
->set('national_id', '123456789')
->set('phone', '+970599123456')
->set('password', 'password123')
->set('preferred_language', 'ar')
->call('create')
->assertHasNoErrors();
expect(User::where('email', 'client@example.com')->exists())->toBeTrue();
expect(AdminLog::where('action_type', 'create')->exists())->toBeTrue();
});
```
## Definition of Done
- [ ] Create individual client form works
@ -151,7 +240,10 @@ AdminLog::create([
## Dependencies
- **Epic 1:** Authentication system, database schema, bilingual support
- **Story 1.1:** Database schema with `users` table (user_type, national_id, status fields) and `admin_logs` table
- **Story 1.2:** Authentication system with admin role, AdminLog model
- **Story 1.3:** Bilingual infrastructure (translation files, `__()` helper)
- **Story 1.4:** Base admin layout and navigation
## Risk Assessment

View File

@ -11,10 +11,15 @@ So that **I can serve corporate clients with their unique data requirements**.
## Story Context
### Existing System Integration
- **Integrates with:** Users table, potential contact_persons table
- **Technology:** Livewire Volt, Flux UI forms
- **Follows pattern:** Same CRUD pattern as individual clients
- **Touch points:** User model, admin dashboard
- **Integrates with:** `users` table (company-specific fields), `admin_logs` table
- **Technology:** Livewire Volt (class-based), Flux UI forms, Pest tests
- **Follows pattern:** Same CRUD pattern as Story 2.1 Individual Clients
- **Key Files to Create/Modify:**
- `resources/views/livewire/admin/users/company/` - Volt components (index, create, edit, show)
- `app/Models/User.php` - Add `scopeCompany()` method
- `resources/lang/ar/messages.php` - Arabic translations
- `resources/lang/en/messages.php` - English translations
- `tests/Feature/Admin/CompanyClientTest.php` - Feature tests
## Acceptance Criteria
@ -32,11 +37,8 @@ So that **I can serve corporate clients with their unique data requirements**.
- [ ] Duplicate email/registration number prevention
- [ ] Success message on creation
### Multiple Contact Persons (Optional Enhancement)
- [ ] Support unlimited contact persons per company
- [ ] Each contact: Name, ID, Phone, Email
- [ ] Primary contact indicator
- [ ] Add/remove contacts dynamically
### Multiple Contact Persons (OUT OF SCOPE)
> **Note:** Multiple contact persons support is deferred to a future enhancement story. This story implements single contact person stored directly on the `users` table. The `contact_persons` table migration in Technical Notes is for reference only if this feature is later prioritized.
### List View
- [ ] Display all company clients (user_type = 'company')
@ -56,8 +58,7 @@ So that **I can serve corporate clients with their unique data requirements**.
- [ ] Success message on update
### View Company Profile
- [ ] Display all company information
- [ ] List all contact persons
- [ ] Display all company information (including contact person details)
- [ ] Show consultation history summary
- [ ] Show timeline history summary
@ -86,9 +87,11 @@ users table:
- contact_person_id (nullable, required for company)
```
### Alternative: Separate Contact Persons Table
### Future Reference: Separate Contact Persons Table
> **Note:** This migration is NOT part of this story. It is preserved here for future reference if multiple contact persons feature is prioritized.
```php
// contact_persons migration
// contact_persons migration (FUTURE - NOT IN SCOPE)
Schema::create('contact_persons', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
@ -119,10 +122,13 @@ public function rules(): array
```
### Volt Component for Create
Follow the same component structure as Story 2.1 (`docs/stories/story-2.1-individual-client-account-management.md`).
```php
<?php
use App\Models\User;
use App\Models\AdminLog; // Assumes AdminLog model exists from Story 1.1
use Livewire\Volt\Component;
use Illuminate\Support\Facades\Hash;
@ -148,8 +154,17 @@ new class extends Component {
'status' => 'active',
]);
// Log action
// Send welcome email
// Log action (same pattern as Story 2.1)
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'create',
'target_type' => 'user',
'target_id' => $user->id,
'new_values' => $user->only(['company_name', 'email', 'company_registration']),
'ip_address' => request()->ip(),
]);
// Send welcome email (depends on Story 2.5)
session()->flash('success', __('messages.company_created'));
$this->redirect(route('admin.users.index'));
@ -157,6 +172,51 @@ new class extends Component {
};
```
## Testing Requirements
### Test Approach
- Feature tests using Pest with `Volt::test()` for Livewire components
- Factory-based test data generation
### Key Test Scenarios
#### Create Company Client
- [ ] Successfully create company with all valid required fields
- [ ] Validation fails when required fields are missing
- [ ] Validation fails for duplicate email address
- [ ] Validation fails for duplicate company registration number
- [ ] Preferred language defaults to Arabic when not specified
- [ ] Audit log entry created on successful creation
#### List View
- [ ] List displays only company type users (excludes individual/admin)
- [ ] Pagination works correctly (10/25/50 per page)
- [ ] Default sort is by created date descending
#### Search & Filter
- [ ] Search by company name returns correct results
- [ ] Search by email returns correct results
- [ ] Search by registration number returns correct results
- [ ] Filter by active status works
- [ ] Filter by deactivated status works
- [ ] Combined search and filter works correctly
#### Edit Company
- [ ] Successfully update company information
- [ ] Validation fails for duplicate email (excluding current record)
- [ ] Validation fails for duplicate registration number (excluding current record)
- [ ] Audit log entry created on successful update
#### View Profile
- [ ] Profile displays all company information correctly
- [ ] Consultation history summary displays (empty state if none)
- [ ] Timeline history summary displays (empty state if none)
#### Bilingual Support
- [ ] Form labels display correctly in Arabic
- [ ] Form labels display correctly in English
- [ ] Validation messages display in user's preferred language
## Definition of Done
- [ ] Create company client form works
@ -173,7 +233,17 @@ new class extends Component {
## Dependencies
- **Epic 1:** Authentication system, database schema
- **Story 2.1:** Same CRUD patterns
- **Story 1.1:** Database schema must include the following columns in `users` table:
- `user_type` (enum: 'individual', 'company', 'admin')
- `status` (enum: 'active', 'deactivated')
- `phone` (string)
- `preferred_language` (enum: 'ar', 'en')
- `company_name` (nullable string)
- `company_registration` (nullable string, unique when not null)
- `contact_person_name` (nullable string)
- `contact_person_id` (nullable string)
- `national_id` (nullable string, unique when not null)
- **Story 2.1:** CRUD patterns established in `docs/stories/story-2.1-individual-client-account-management.md`
## Risk Assessment

View File

@ -12,9 +12,17 @@ So that **I can accommodate clients whose business structure changes**.
### Existing System Integration
- **Integrates with:** Users table, consultations, timelines
- **Technology:** Livewire Volt, modal dialogs
- **Follows pattern:** Admin action pattern with confirmation
- **Touch points:** User model, related records
- **Technology:** Livewire Volt (class-based), Flux UI modals, Pest tests
- **Follows pattern:** Admin action pattern with confirmation modal (same as Story 2.1/2.2 CRUD pattern)
- **Touch points:** User model, AdminLog model, related records
- **PRD Reference:** Section 5.3 (User Management - "Admin can convert individual to company or vice versa")
### Prerequisites from Story 2.1 & 2.2
- Individual client show page exists at `resources/views/livewire/admin/users/individual/show.blade.php`
- Company client show page exists at `resources/views/livewire/admin/users/company/show.blade.php`
- User model has `scopeIndividual()` and `scopeCompany()` methods
- AdminLog model exists with fields: `admin_id`, `action_type`, `target_type`, `target_id`, `old_values`, `new_values`, `ip_address`
- Users table has all required fields: `user_type`, `name`, `national_id`, `company_name`, `company_registration`, `contact_person_name`, `contact_person_id`
## Acceptance Criteria
@ -61,11 +69,52 @@ So that **I can accommodate clients whose business structure changes**.
## Technical Notes
### Files to Create
```
resources/views/livewire/admin/users/
├── convert-to-company-modal.blade.php # Modal component for individual→company
└── convert-to-individual-modal.blade.php # Modal component for company→individual
app/Notifications/
└── AccountTypeChangedNotification.php # Email notification for account conversion
resources/lang/ar/
└── messages.php (add keys: account_converted, convert_to_company, convert_to_individual)
resources/lang/en/
└── messages.php (add same keys)
resources/lang/ar/
└── emails.php (add keys: account_type_changed.title, account_type_changed.body)
resources/lang/en/
└── emails.php (add same keys)
```
### Files to Modify
```
resources/views/livewire/admin/users/individual/show.blade.php
- Add "Convert to Company" button that opens convert-to-company-modal
resources/views/livewire/admin/users/company/show.blade.php
- Add "Convert to Individual" button that opens convert-to-individual-modal
```
### UI Placement
The conversion action should be a **secondary button** on the user profile show page:
- Located in the action buttons area (near Edit/Deactivate buttons)
- Opens a Flux UI modal with the conversion form
- Individual show page → "Convert to Company" button (gold outline style)
- Company show page → "Convert to Individual" button (gold outline style)
### Conversion Logic
```php
<?php
use App\Models\User;
use App\Models\AdminLog;
use App\Notifications\AccountTypeChangedNotification;
use Illuminate\Support\Facades\DB;
use Livewire\Volt\Component;
new class extends Component {
@ -99,9 +148,11 @@ new class extends Component {
$oldValues = $this->user->only([
'user_type', 'name', 'national_id',
'company_name', 'company_registration'
'company_name', 'company_registration',
'contact_person_name', 'contact_person_id'
]);
DB::transaction(function () use ($oldValues) {
$this->user->update([
'user_type' => 'company',
'name' => $this->company_name,
@ -114,13 +165,42 @@ new class extends Component {
$this->logConversion($oldValues);
$this->notifyUser();
});
session()->flash('success', __('messages.account_converted'));
$this->redirect(route('admin.users.company.show', $this->user));
}
public function convertToIndividual(): void
{
// Similar logic for company -> individual
$this->validate([
'name' => 'required|string|max:255',
'national_id' => 'required|string|unique:users,national_id',
]);
$oldValues = $this->user->only([
'user_type', 'name', 'national_id',
'company_name', 'company_registration',
'contact_person_name', 'contact_person_id'
]);
DB::transaction(function () {
$this->user->update([
'user_type' => 'individual',
'name' => $this->name,
'national_id' => $this->national_id,
'company_name' => null,
'company_registration' => null,
'contact_person_name' => null,
'contact_person_id' => null,
]);
$this->logConversion($oldValues);
$this->notifyUser();
});
session()->flash('success', __('messages.account_converted'));
$this->redirect(route('admin.users.individual.show', $this->user));
}
private function logConversion(array $oldValues): void
@ -182,6 +262,231 @@ public function testConversionPreservesRelationships(): void
@endcomponent
```
### Edge Cases & Error Handling
| Scenario | Handling |
|----------|----------|
| **Duplicate company_registration** | Validation error: "Company registration number already exists" - display on field |
| **Duplicate national_id (company→individual)** | Validation error: "National ID already registered" - display on field |
| **Database error mid-conversion** | DB::transaction rollback ensures atomic operation - no partial updates |
| **User has pending consultations** | Conversion allowed - consultations are linked by user_id, not user_type |
| **User has active timelines** | Conversion allowed - timelines are linked by user_id, not user_type |
| **Email notification fails** | Log error but don't rollback conversion - email is non-critical |
| **Modal closed without saving** | No changes made - standard modal behavior |
### Notification Class Structure
```php
// app/Notifications/AccountTypeChangedNotification.php
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AccountTypeChangedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public string $newUserType
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$locale = $notifiable->preferred_language ?? 'ar';
$typeName = $this->newUserType === 'company'
? __('messages.company', [], $locale)
: __('messages.individual', [], $locale);
return (new MailMessage)
->subject(__('emails.account_type_changed.subject', [], $locale))
->view('emails.account-type-changed', [
'user' => $notifiable,
'newType' => $typeName,
'locale' => $locale,
]);
}
}
```
## Testing Requirements
### Test File Location
`tests/Feature/Admin/AccountConversionTest.php`
### Test Scenarios
#### Individual to Company Conversion
- [ ] Can convert individual to company with all valid data
- [ ] Form pre-fills contact_person_name with user's current name
- [ ] Form pre-fills contact_person_id with user's national_id
- [ ] Cannot convert without required company_name field
- [ ] Cannot convert without required company_registration field
- [ ] Cannot convert with duplicate company_registration (existing company)
- [ ] Converted user has user_type set to 'company'
- [ ] Original email, phone, password are preserved after conversion
- [ ] Consultations are preserved and accessible after conversion
- [ ] Timelines are preserved and accessible after conversion
- [ ] AdminLog entry created with old_values and new_values
- [ ] Email notification sent to user after conversion
#### Company to Individual Conversion
- [ ] Can convert company to individual with all valid data
- [ ] Form pre-fills name with contact_person_name or company_name
- [ ] Form pre-fills national_id with contact_person_id if available
- [ ] Cannot convert without required name field
- [ ] Cannot convert without required national_id field
- [ ] Cannot convert with duplicate national_id (existing individual)
- [ ] Converted user has user_type set to 'individual'
- [ ] Company fields (company_name, company_registration, contact_person_*) are nulled
- [ ] Original email, phone, password are preserved after conversion
- [ ] Consultations are preserved and accessible after conversion
- [ ] Timelines are preserved and accessible after conversion
- [ ] AdminLog entry created with old_values and new_values
- [ ] Email notification sent to user after conversion
#### UI/UX Tests
- [ ] "Convert to Company" button visible only on individual profile show page
- [ ] "Convert to Individual" button visible only on company profile show page
- [ ] Modal opens when conversion button clicked
- [ ] Modal has confirmation step before conversion
- [ ] Success message displayed after conversion
- [ ] User redirected to correct show page (company/individual) after conversion
#### Bilingual Tests
- [ ] Modal labels display correctly in Arabic
- [ ] Modal labels display correctly in English
- [ ] Success message displays in current locale
- [ ] Email notification sent in user's preferred_language
### Testing Approach
```php
use App\Models\User;
use App\Models\AdminLog;
use App\Models\Consultation;
use App\Models\Timeline;
use App\Notifications\AccountTypeChangedNotification;
use Illuminate\Support\Facades\Notification;
use Livewire\Volt\Volt;
test('admin can convert individual to company', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$individual = User::factory()->individual()->create([
'name' => 'John Doe',
'national_id' => '123456789',
'email' => 'john@example.com',
]);
// Create related records to verify preservation
$consultation = Consultation::factory()->for($individual)->create();
$timeline = Timeline::factory()->for($individual)->create();
Volt::actingAs($admin)
->test('admin.users.convert-to-company-modal', ['user' => $individual])
->assertSet('contact_person_name', 'John Doe')
->assertSet('contact_person_id', '123456789')
->set('company_name', 'Doe Corp')
->set('company_registration', 'REG-12345')
->call('convertToCompany')
->assertHasNoErrors()
->assertRedirect(route('admin.users.company.show', $individual));
// Verify conversion
$individual->refresh();
expect($individual->user_type)->toBe('company');
expect($individual->company_name)->toBe('Doe Corp');
expect($individual->email)->toBe('john@example.com'); // Preserved
// Verify relationships preserved
expect($individual->consultations)->toHaveCount(1);
expect($individual->timelines)->toHaveCount(1);
// Verify audit log
expect(AdminLog::where('action_type', 'convert_account')->exists())->toBeTrue();
// Verify notification
Notification::assertSentTo($individual, AccountTypeChangedNotification::class);
});
test('admin can convert company to individual', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$company = User::factory()->company()->create([
'company_name' => 'Acme Corp',
'contact_person_name' => 'Jane Smith',
'contact_person_id' => '987654321',
'email' => 'contact@acme.com',
]);
Volt::actingAs($admin)
->test('admin.users.convert-to-individual-modal', ['user' => $company])
->assertSet('name', 'Jane Smith')
->assertSet('national_id', '987654321')
->call('convertToIndividual')
->assertHasNoErrors();
$company->refresh();
expect($company->user_type)->toBe('individual');
expect($company->company_name)->toBeNull();
expect($company->email)->toBe('contact@acme.com'); // Preserved
});
test('conversion fails with duplicate company registration', function () {
$admin = User::factory()->admin()->create();
$existingCompany = User::factory()->company()->create([
'company_registration' => 'REG-EXISTING',
]);
$individual = User::factory()->individual()->create();
Volt::actingAs($admin)
->test('admin.users.convert-to-company-modal', ['user' => $individual])
->set('company_name', 'New Corp')
->set('company_registration', 'REG-EXISTING') // Duplicate!
->set('contact_person_name', 'Test Person')
->set('contact_person_id', '111222333')
->call('convertToCompany')
->assertHasErrors(['company_registration']);
});
test('conversion preserves consultations and timelines', function () {
$admin = User::factory()->admin()->create();
$individual = User::factory()->individual()->create();
// Create multiple related records
$consultations = Consultation::factory(3)->for($individual)->create();
$timelines = Timeline::factory(2)->for($individual)->create();
Volt::actingAs($admin)
->test('admin.users.convert-to-company-modal', ['user' => $individual])
->set('company_name', 'Test Corp')
->set('company_registration', 'REG-TEST')
->set('contact_person_name', 'Test Person')
->set('contact_person_id', '123123123')
->call('convertToCompany')
->assertHasNoErrors();
$individual->refresh();
expect($individual->consultations)->toHaveCount(3);
expect($individual->timelines)->toHaveCount(2);
// Verify foreign keys still work
foreach ($consultations as $consultation) {
expect($consultation->fresh()->user_id)->toBe($individual->id);
}
});
```
## Definition of Done
- [ ] Convert individual to company works
@ -198,10 +503,25 @@ public function testConversionPreservesRelationships(): void
## Dependencies
- **Story 2.1:** Individual client management
- **Story 2.2:** Company client management
- **Epic 3:** Consultations (for preservation testing)
- **Epic 4:** Timelines (for preservation testing)
### Required from Story 2.1 (Individual Client Management)
- Individual client show page: `resources/views/livewire/admin/users/individual/show.blade.php`
- User model scope: `scopeIndividual()` method
- User factory state: `User::factory()->individual()`
### Required from Story 2.2 (Company Client Management)
- Company client show page: `resources/views/livewire/admin/users/company/show.blade.php`
- User model scope: `scopeCompany()` method
- User factory state: `User::factory()->company()`
### Required from Story 1.1/1.2 (Database & Auth)
- AdminLog model with `create()` method accepting: `admin_id`, `action_type`, `target_type`, `target_id`, `old_values`, `new_values`, `ip_address`
- Users table columns: `user_type`, `name`, `national_id`, `company_name`, `company_registration`, `contact_person_name`, `contact_person_id`
- User factory state: `User::factory()->admin()`
### For Preservation Testing (if models exist)
- **Epic 3 (Consultations):** `Consultation` model with `user_id` foreign key, `Consultation::factory()`
- **Epic 4 (Timelines):** `Timeline` model with `user_id` foreign key, `Timeline::factory()`
- Note: If Epic 3/4 not implemented yet, skip those specific test scenarios and mark as N/A
## Risk Assessment

View File

@ -11,10 +11,17 @@ So that **I can manage the full lifecycle of client relationships**.
## Story Context
### Existing System Integration
- **Integrates with:** Users table, consultations, timelines, notifications
- **Technology:** Livewire Volt, confirmation modals
- **Follows pattern:** Soft deactivation, hard deletion with cascade
- **Touch points:** User model, all related models
- **Integrates with:** Users table, consultations, timelines, notifications, sessions
- **Technology:** Livewire Volt (class-based), Flux UI modals, Pest tests
- **Follows pattern:** Same admin action patterns as Story 2.1/2.2, soft deactivation via status field, hard deletion with cascade
- **Touch points:** User model, AdminLog model, FortifyServiceProvider (authentication check)
- **PRD Reference:** Section 5.3 (User Management System - Account Lifecycle)
- **Key Files to Create/Modify:**
- `resources/views/livewire/admin/users/partials/` - Lifecycle action components
- `app/Notifications/` - Reactivation and password reset notifications
- `app/Providers/FortifyServiceProvider.php` - Add deactivation check to authenticateUsing
- `app/Models/User.php` - Add lifecycle methods and deleting event
- `tests/Feature/Admin/AccountLifecycleTest.php` - Feature tests
## Acceptance Criteria
@ -69,6 +76,19 @@ So that **I can manage the full lifecycle of client relationships**.
## Technical Notes
### Files to Create
```
resources/views/livewire/admin/users/
├── partials/
│ ├── lifecycle-actions.blade.php # Deactivate/Reactivate/Delete action buttons
│ └── password-reset-modal.blade.php # Password reset modal component
app/Notifications/
├── AccountReactivatedNotification.php
└── PasswordResetByAdminNotification.php
tests/Feature/Admin/
└── AccountLifecycleTest.php
```
### User Status Management
```php
// User model
@ -252,6 +272,142 @@ new class extends Component {
</flux:modal>
```
### Edge Cases & Error Handling
- **Active sessions on deactivation:** Invalidate all user sessions when deactivated (use `DB::table('sessions')->where('user_id', $user->id)->delete()`)
- **Deactivated user login attempt:** Show bilingual message: "Your account has been deactivated. Please contact the administrator."
- **Delete with related data:** Cascade deletion handles consultations/timelines automatically via model `deleting` event
- **Delete confirmation mismatch:** Display validation error, do not proceed with deletion
- **Password reset email failure:** Queue the email, log failure, show success message (email queued)
- **Reactivation email failure:** Queue the email, log failure, still complete reactivation
### Session Invalidation on Deactivation
```php
// Add to deactivate() method in Volt component
use Illuminate\Support\Facades\DB;
public function deactivate(): void
{
$this->user->deactivate();
// Invalidate all active sessions for this user
DB::table('sessions')
->where('user_id', $this->user->id)
->delete();
// Log action...
}
```
## Testing Requirements
### Test File Location
`tests/Feature/Admin/AccountLifecycleTest.php`
### Test Scenarios
#### Deactivation Tests
- [ ] Admin can deactivate an active user
- [ ] Deactivated user cannot login (returns null from authenticateUsing)
- [ ] Deactivated user shows visual indicator in user list
- [ ] User sessions are invalidated on deactivation
- [ ] AdminLog entry created with old/new status values
- [ ] Deactivation preserves all user data (consultations, timelines intact)
#### Reactivation Tests
- [ ] Admin can reactivate a deactivated user
- [ ] Reactivated user can login successfully
- [ ] Reactivated user status changes to 'active'
- [ ] Email notification queued on reactivation
- [ ] AdminLog entry created for reactivation
#### Permanent Deletion Tests
- [ ] Delete button shows danger styling
- [ ] Delete requires typing user email for confirmation
- [ ] Incorrect email confirmation shows validation error
- [ ] Successful deletion removes user record permanently
- [ ] Cascade deletion removes user's consultations
- [ ] Cascade deletion removes user's timelines and timeline_updates
- [ ] Cascade deletion removes user's notifications
- [ ] AdminLog entry preserved after user deletion (for audit trail)
- [ ] Redirect to users index after successful deletion
#### Password Reset Tests
- [ ] Admin can reset user password with random generation
- [ ] New password meets minimum requirements (8+ characters)
- [ ] Password reset email sent to user with new credentials
- [ ] AdminLog entry created for password reset
- [ ] User can login with new password
#### Authorization Tests
- [ ] Non-admin users cannot access lifecycle actions
- [ ] Admin cannot deactivate their own account
- [ ] Admin cannot delete their own account
#### Bilingual Tests
- [ ] Confirmation dialogs display in Arabic when locale is 'ar'
- [ ] Confirmation dialogs display in English when locale is 'en'
- [ ] Success/error messages respect user's preferred language
### Testing Approach
```php
use App\Models\User;
use App\Models\AdminLog;
use Livewire\Volt\Volt;
use Illuminate\Support\Facades\Notification;
use App\Notifications\AccountReactivatedNotification;
test('admin can deactivate active user', function () {
$admin = User::factory()->admin()->create();
$client = User::factory()->individual()->create(['status' => 'active']);
Volt::actingAs($admin)
->test('admin.users.show', ['user' => $client])
->call('deactivate')
->assertHasNoErrors();
expect($client->fresh()->status)->toBe('deactivated');
expect(AdminLog::where('action_type', 'deactivate')->exists())->toBeTrue();
});
test('deactivated user cannot login', function () {
$user = User::factory()->create(['status' => 'deactivated']);
$this->post('/login', [
'email' => $user->email,
'password' => 'password',
])->assertSessionHasErrors();
$this->assertGuest();
});
test('delete requires email confirmation', function () {
$admin = User::factory()->admin()->create();
$client = User::factory()->individual()->create();
Volt::actingAs($admin)
->test('admin.users.show', ['user' => $client])
->set('deleteConfirmation', 'wrong@email.com')
->call('delete')
->assertHasErrors(['deleteConfirmation']);
expect(User::find($client->id))->not->toBeNull();
});
test('reactivation sends email notification', function () {
Notification::fake();
$admin = User::factory()->admin()->create();
$client = User::factory()->individual()->create(['status' => 'deactivated']);
Volt::actingAs($admin)
->test('admin.users.show', ['user' => $client])
->call('reactivate')
->assertHasNoErrors();
Notification::assertSentTo($client, AccountReactivatedNotification::class);
});
```
## Definition of Done
- [ ] Deactivate prevents login but preserves data
@ -268,10 +424,25 @@ new class extends Component {
## Dependencies
- **Story 2.1:** Individual client management
### Required Before Implementation
- **Story 1.1:** Database schema must include:
- `users.status` column (enum: 'active', 'deactivated')
- `users.user_type` column (enum: 'individual', 'company', 'admin')
- `admin_logs` table with columns: `admin_id`, `action_type`, `target_type`, `target_id`, `old_values`, `new_values`, `ip_address`
- **Story 1.2:** Authentication system with admin role, AdminLog model
- **Story 2.1:** Individual client management (establishes CRUD patterns)
- **Story 2.2:** Company client management
- **Epic 3:** Consultations (cascade delete)
- **Epic 4:** Timelines (cascade delete)
### Soft Dependencies (Can Be Empty/Stubbed)
- **Epic 3:** Consultations table (cascade delete - can be empty if not yet implemented)
- **Epic 4:** Timelines table (cascade delete - can be empty if not yet implemented)
### Notification Classes (Created in This Story)
This story creates the following notification classes:
- `App\Notifications\AccountReactivatedNotification` - Sent when account is reactivated
- `App\Notifications\PasswordResetByAdminNotification` - Sent with new credentials after admin password reset
> **Note:** These are simple notifications. Full email infrastructure (templates, queuing) is handled in Epic 8. These notifications will use Laravel's default mail driver.
## Risk Assessment

View File

@ -48,6 +48,13 @@ So that **clients receive their login credentials and know how to access the pla
- [ ] From Name: Libra Law Firm / مكتب ليبرا للمحاماة
- [ ] Reply-To: (firm contact email)
### Environment Variables
Ensure the following are configured in `.env`:
```
MAIL_FROM_ADDRESS=no-reply@libra.ps
MAIL_FROM_NAME="Libra Law Firm"
```
### Language Support
- [ ] Email in user's preferred_language
- [ ] Arabic email for Arabic preference
@ -65,6 +72,11 @@ So that **clients receive their login credentials and know how to access the pla
- [ ] Password visible but not overly prominent
- [ ] Tests verify email sending
### Error Handling
- [ ] Email queued with retry on failure (Laravel default: 3 attempts)
- [ ] Failed emails logged to `failed_jobs` table
- [ ] User creation succeeds even if email fails (non-blocking)
## Technical Notes
### Mailable Class
@ -245,6 +257,34 @@ it('sends email in user preferred language', function () {
return str_contains($mail->envelope()->subject, 'مرحباً');
});
});
it('queues welcome email instead of sending synchronously', function () {
Mail::fake();
$user = User::factory()->create();
Mail::to($user)->queue(new WelcomeEmail($user, 'password123'));
Mail::assertQueued(WelcomeEmail::class);
Mail::assertNothingSent(); // Confirms it was queued, not sent sync
});
it('does not send email for admin accounts', function () {
Mail::fake();
// Admin creation flow should NOT trigger welcome email
$admin = User::factory()->admin()->create();
Mail::assertNothingQueued();
});
it('generates plain text version of email', function () {
$user = User::factory()->create(['preferred_language' => 'en']);
$mailable = new WelcomeEmail($user, 'password123');
// Verify mailable can render (catches template errors)
expect($mailable->render())->toBeString();
});
```
## Definition of Done
@ -257,6 +297,8 @@ it('sends email in user preferred language', function () {
- [ ] English email for English preference
- [ ] Plain text fallback works
- [ ] Email queued (not blocking)
- [ ] No email sent for admin accounts
- [ ] Failed emails logged (non-blocking to user creation)
- [ ] Tests verify email sending
- [ ] Code formatted with Pint