Files
calvana/temp_files/v3/CustomerResource.php
Omair Saleh b477dc30d1 Role-based access control: guards on all critical APIs + redirects
INTEGRATION AUDIT — Fixed all gaps:

1. LOGIN REDIRECT
   - Community leaders → /dashboard/community (not /dashboard)
   - Fetches session after login to check role before redirect
   - Auth0 callback still goes to /dashboard (handled by #2)

2. DASHBOARD HOME REDIRECT
   - If role === community_leader or volunteer → router.replace(/community)
   - Prevents them from seeing the admin home page

3. API ROLE GUARDS (server-side)
   New: src/lib/roles.ts — permission matrix:
   - settings.write: super_admin, org_admin only
   - pledges.write: super_admin, org_admin only (status changes)
   - events.create: super_admin, org_admin only
   - imports.upload: super_admin, org_admin only (bank statements)
   - links.create: super_admin, org_admin, community_leader (they can create)
   - pledges.read: everyone except volunteer
   - dashboard.read: everyone except volunteer

   New: requirePermission() in session.ts
   Applied to:
   - PATCH /api/settings → settings.write
   - PUT /api/settings → settings.write
   - PATCH /api/pledges/[id] → pledges.write
   - POST /api/events → events.create
   - POST /api/imports/bank-statement → imports.upload

   Community leader attempting these gets 403 'Admin access required'

4. LAYOUT NAV (already done in previous commit)
   - community_leader sees: My Community, Share Links, Reports
   - No Money, No Settings, No 'New Appeal' button

WHAT COMMUNITY LEADER CAN DO:
✓ View /dashboard/community (their scoped dashboard)
✓ View /dashboard/collect (share links — they can create new links)
✓ View /dashboard/reports (financial summary)
✓ Create QR sources / links (POST /api/events/[id]/qr)
✓ Read pledges and dashboard data

WHAT COMMUNITY LEADER CANNOT DO:
✗ Change pledge statuses (PATCH /api/pledges/[id] → 403)
✗ Change settings (PATCH/PUT /api/settings → 403)
✗ Create appeals (POST /api/events → 403)
✗ Upload bank statements (POST /api/imports/bank-statement → 403)
✗ Manage team (POST/PATCH/DELETE /api/team → 403, already guarded)
✗ See /dashboard/money, /dashboard/settings (not in nav, home redirects)
2026-03-04 21:58:25 +08:00

287 lines
11 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Definitions\DonationOccurrence;
use App\Filament\Resources\CustomerResource\Pages\EditCustomer;
use App\Filament\Resources\CustomerResource\Pages\ListCustomers;
use App\Filament\Resources\CustomerResource\RelationManagers\AddressesRelationManager;
use App\Filament\RelationManagers\InternalNotesRelationManager;
use App\Filament\Resources\CustomerResource\RelationManagers\DonationsRelationManager;
use App\Filament\Resources\CustomerResource\RelationManagers\ScheduledGivingDonationsRelationManager;
use App\Models\Customer;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
class CustomerResource extends Resource
{
protected static ?string $model = Customer::class;
protected static ?string $navigationIcon = 'heroicon-o-user-circle';
protected static ?string $navigationGroup = 'Supporter Care';
protected static ?string $navigationLabel = 'Donors';
protected static ?string $modelLabel = 'Donor';
protected static ?string $pluralModelLabel = 'Donors';
protected static ?int $navigationSort = 1;
protected static ?string $recordTitleAttribute = 'email';
// ─── Global Search (Cmd+K) ────────────────────────────────────
// This is how Sahibah finds a donor when they call or email.
// She might type an email, a name, or a phone number.
public static function getGloballySearchableAttributes(): array
{
return ['first_name', 'last_name', 'email', 'phone'];
}
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
{
return $record->name;
}
public static function getGlobalSearchResultDetails(Model $record): array
{
$donationCount = $record->donations()->count();
$totalDonated = $record->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->sum('amount') / 100;
$activeSG = $record->scheduledGivingDonations()->where('is_active', true)->count();
$details = [
'Email' => $record->email,
];
if ($record->phone) {
$details['Phone'] = $record->phone;
}
if ($donationCount > 0) {
$details['Donations'] = $donationCount . ' (£' . number_format($totalDonated, 0) . ' total)';
}
if ($activeSG > 0) {
$details['Monthly Giving'] = $activeSG . ' active';
}
return $details;
}
public static function getGlobalSearchResultActions(Model $record): array
{
return [
GlobalSearchAction::make('edit')
->label('Open Donor Profile')
->url(static::getUrl('edit', ['record' => $record])),
];
}
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()->latest('created_at');
}
public static function getGlobalSearchResultsLimit(): int
{
return 10;
}
// ─── Form (Edit screen) ──────────────────────────────────────
public static function form(Form $form): Form
{
return $form
->schema([
Select::make('user_id')
->relationship('user', 'email')
->searchable()
->live()
->helperText('The website login account linked to this donor (if any).')
->disabled(),
Grid::make()->schema([
Section::make('Personal Details')->schema([
Select::make('title')
->required()
->options(config('donate.titles'))
->columnSpan(1),
TextInput::make('first_name')
->required()
->maxLength(255)
->columnSpan(2),
TextInput::make('last_name')
->required()
->maxLength(255)
->columnSpan(2),
])
->columns(5)
->columnSpan(1),
Section::make('Contact Information')->schema([
TextInput::make('email')
->email()
->required()
->disabled(fn (\Filament\Forms\Get $get) => (bool) $get('user_id'))
->live()
->maxLength(255)
->copyable(),
TextInput::make('phone')
->tel()
->maxLength(32)
->copyable(),
])
->columns()
->columnSpan(1),
]),
]);
}
// ─── Table (List screen) ─────────────────────────────────────
// This is the "Donor Lookup" — staff search by any field and
// immediately see key context: are they a monthly giver? how many
// donations? This helps them triage before even clicking in.
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Donor')
->searchable(['first_name', 'last_name'])
->sortable(['first_name'])
->weight('bold')
->description(fn (Customer $record): ?string => $record->email),
TextColumn::make('phone')
->label('Phone')
->searchable()
->copyable()
->placeholder('—'),
TextColumn::make('donations_count')
->label('Donations')
->counts('donations')
->sortable()
->badge()
->color(fn ($state) => match(true) {
$state >= 50 => 'success',
$state >= 10 => 'info',
$state > 0 => 'gray',
default => 'danger',
}),
TextColumn::make('scheduled_giving_donations_count')
->label('Monthly')
->counts([
'scheduledGivingDonations' => fn (Builder $q) => $q->where('is_active', true),
])
->formatStateUsing(fn ($state) => $state > 0 ? 'Active' : null)
->badge()
->color('success')
->placeholder('—'),
TextColumn::make('created_at')
->label('Joined')
->since()
->sortable(),
])
->filters([
Filter::make('has_donations')
->label('Has donated')
->toggle()
->query(fn (Builder $q) => $q->has('donations')),
Filter::make('monthly_supporter')
->label('Monthly supporter')
->toggle()
->query(fn (Builder $q) => $q->whereHas(
'scheduledGivingDonations',
fn ($q2) => $q2->where('is_active', true)
)),
Filter::make('gift_aid')
->label('Gift Aid donors')
->toggle()
->query(fn (Builder $q) => $q->whereHas(
'donations',
fn ($q2) => $q2->whereHas('donationPreferences', fn ($q3) => $q3->where('is_gift_aid', true))
)),
Filter::make('major_donor')
->label('Major donors (£1000+)')
->toggle()
->query(function (Builder $q) {
$q->whereIn('id', function ($sub) {
$sub->select('customer_id')
->from('donations')
->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id')
->whereNotNull('donation_confirmations.confirmed_at')
->groupBy('customer_id')
->havingRaw('SUM(donations.amount) >= 100000');
});
}),
Filter::make('incomplete_donations')
->label('Has incomplete donations')
->toggle()
->query(fn (Builder $q) => $q->whereHas(
'donations',
fn ($q2) => $q2->whereDoesntHave('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
->where('created_at', '>=', now()->subDays(30))
)),
Filter::make('recent')
->label('Joined last 30 days')
->toggle()
->query(fn (Builder $q) => $q->where('created_at', '>=', now()->subDays(30))),
])
->actions([
EditAction::make()
->label('Open')
->icon('heroicon-o-arrow-right'),
])
->defaultSort('created_at', 'desc')
->searchPlaceholder('Search by name, email, or phone...');
}
public static function getRelations(): array
{
return [
DonationsRelationManager::class,
ScheduledGivingDonationsRelationManager::class,
AddressesRelationManager::class,
InternalNotesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListCustomers::route('/'),
'edit' => EditCustomer::route('/{record}/edit'),
];
}
}