Complete dashboard UI overhaul: persona journeys + brand unification
Navigation: goal-oriented, not feature-oriented
- Overview → Home
- Campaigns → Collect ('I want people to pledge')
- Pledges → Money ('Where's the money?')
- Exports → Reports ('My treasurer needs numbers')
- Old routes still work via re-exports
Terminology: human language, not SaaS jargon
- new → Waiting
- initiated → Said they paid
- paid → Received ✓
- overdue → Needs a nudge
- Campaign → Appeal
- QR Source → Pledge link
- Reconcile → Match payments
- Rail → Payment method
- Pipeline by Status → How pledges are doing
- Conversion rate → % who pledged
- CRM Export Pack → Full data download
Visual identity: brand-consistent dashboard
- Sharp edges (no rounded-lg cards)
- Gap-px grids for stats (brand signature pattern)
- Left-border accents (brand signature pattern)
- Midnight/Paper/Promise Blue 60-30-10 color rule
- Typography as hero (big bold numbers, not card-heavy)
- No emoji in UI chrome
- Brand-consistent status badges (colored bg + text, not shadcn Badge)
- Consistent header typography (text-3xl font-black tracking-tight)
Pages rewritten: layout, home, events (collect), pledges (money),
exports (reports), reconcile, settings
Reconcile: auto-detects bank CSV format via presets + AI before upload
UX spec: docs/UX_OVERHAUL_SPEC.md
This commit is contained in:
276
temp_files/AIAppealReviewService.php
Normal file
276
temp_files/AIAppealReviewService.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Appeal;
|
||||
use App\Models\ApprovalQueue;
|
||||
use App\Models\DonationCountry;
|
||||
use App\Models\DonationType;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AIAppealReviewService
|
||||
{
|
||||
/**
|
||||
* Review an appeal and return a structured verdict.
|
||||
*/
|
||||
public function review(Appeal $appeal): array
|
||||
{
|
||||
// First, run fast deterministic checks (no AI needed)
|
||||
$preflightResult = $this->preflightChecks($appeal);
|
||||
if ($preflightResult !== null) {
|
||||
return $preflightResult;
|
||||
}
|
||||
|
||||
// Run AI review for content that passes preflight
|
||||
return $this->aiReview($appeal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk review all pending approvals.
|
||||
*/
|
||||
public function reviewAllPending(): array
|
||||
{
|
||||
$pending = ApprovalQueue::where('status', 'pending')
|
||||
->with('appeal')
|
||||
->get();
|
||||
|
||||
$stats = ['reviewed' => 0, 'approved' => 0, 'rejected' => 0, 'flagged' => 0];
|
||||
|
||||
foreach ($pending as $item) {
|
||||
if (!$item->appeal) {
|
||||
$item->update(['status' => 'confirmed', 'message' => 'Auto-closed: fundraiser was deleted']);
|
||||
$stats['reviewed']++;
|
||||
$stats['rejected']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->review($item->appeal);
|
||||
$stats['reviewed']++;
|
||||
|
||||
$item->update([
|
||||
'extra_data' => json_encode([
|
||||
'ai_review' => $result,
|
||||
'previous_extra_data' => $item->extra_data,
|
||||
]),
|
||||
]);
|
||||
|
||||
if ($result['decision'] === 'approve') {
|
||||
app(ApprovalQueueService::class)->approveAppeal($item);
|
||||
$stats['approved']++;
|
||||
} elseif ($result['decision'] === 'reject') {
|
||||
$item->update([
|
||||
'status' => 'change_requested',
|
||||
'message' => 'Auto-flagged: ' . implode('; ', $result['reasons']),
|
||||
]);
|
||||
|
||||
$appeal = $item->appeal;
|
||||
$appeal->status = 'change_requested';
|
||||
$appeal->save();
|
||||
|
||||
$stats['rejected']++;
|
||||
} else {
|
||||
$stats['flagged']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast deterministic checks that don't need AI.
|
||||
*/
|
||||
protected function preflightChecks(Appeal $appeal): ?array
|
||||
{
|
||||
$flags = [];
|
||||
$reasons = [];
|
||||
|
||||
// 1. Check for duplicate names by same user
|
||||
$duplicateCount = Appeal::where('id', '!=', $appeal->id)
|
||||
->where('name', $appeal->name)
|
||||
->where('user_id', $appeal->user_id)
|
||||
->count();
|
||||
|
||||
if ($duplicateCount > 0) {
|
||||
return [
|
||||
'decision' => 'reject',
|
||||
'confidence' => 0.99,
|
||||
'reasons' => ['Duplicate fundraiser — same person already created an identical page'],
|
||||
'summary' => 'This is a duplicate of an existing fundraiser by the same person.',
|
||||
'flags' => ['duplicate'],
|
||||
];
|
||||
}
|
||||
|
||||
// 2. Check for obvious spam keywords
|
||||
$spamKeywords = [
|
||||
'buy adderall', 'buy xanax', 'buy viagra', 'buy oxycodone', 'buy tramadol',
|
||||
'casino', 'gambling', 'poker online', 'sports betting',
|
||||
'forex trading', 'crypto trading', 'bitcoin investment',
|
||||
'weight loss pill', 'diet pill',
|
||||
'seo service', 'web development service', 'digital marketing agency',
|
||||
];
|
||||
|
||||
$nameLower = strtolower($appeal->name ?? '');
|
||||
$storyLower = strtolower(strip_tags($appeal->story ?? ''));
|
||||
|
||||
foreach ($spamKeywords as $keyword) {
|
||||
if (str_contains($nameLower, $keyword) || str_contains($storyLower, $keyword)) {
|
||||
return [
|
||||
'decision' => 'reject',
|
||||
'confidence' => 0.99,
|
||||
'reasons' => ["Contains prohibited content: {$keyword}"],
|
||||
'summary' => 'This fundraiser contains spam/prohibited content.',
|
||||
'flags' => ['spam', 'prohibited-content'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for excessive external links (SEO spam pattern)
|
||||
$externalLinkCount = preg_match_all(
|
||||
'/href=["\'](?!https?:\/\/(www\.)?charityright)/i',
|
||||
$appeal->story ?? '',
|
||||
$matches
|
||||
);
|
||||
|
||||
if ($externalLinkCount >= 3) {
|
||||
$flags[] = 'excessive-external-links';
|
||||
$reasons[] = "Contains {$externalLinkCount} external links (possible SEO spam)";
|
||||
}
|
||||
|
||||
// 4. Check if story is too short
|
||||
$plainStory = trim(strip_tags($appeal->story ?? ''));
|
||||
if (strlen($plainStory) < 50) {
|
||||
$flags[] = 'story-too-short';
|
||||
$reasons[] = 'Story is very short (' . strlen($plainStory) . ' characters)';
|
||||
}
|
||||
|
||||
// 5. Check if donation type exists
|
||||
if (!$appeal->donation_type_id || !DonationType::find($appeal->donation_type_id)) {
|
||||
$flags[] = 'invalid-donation-type';
|
||||
$reasons[] = 'No valid cause selected';
|
||||
}
|
||||
|
||||
if (count($flags) > 0) {
|
||||
return [
|
||||
'decision' => 'review',
|
||||
'confidence' => 0.7,
|
||||
'reasons' => $reasons,
|
||||
'summary' => 'Automatic checks flagged potential issues that need human review.',
|
||||
'flags' => $flags,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-powered content review using Claude.
|
||||
*/
|
||||
protected function aiReview(Appeal $appeal): array
|
||||
{
|
||||
$apiKey = config('services.anthropic.api_key');
|
||||
if (!$apiKey) {
|
||||
Log::warning('AIAppealReview: No Anthropic API key configured');
|
||||
return $this->fallbackResult('No API key configured');
|
||||
}
|
||||
|
||||
$donationTypes = DonationType::pluck('display_name')->join(', ');
|
||||
$donationCountries = DonationCountry::pluck('name')->join(', ');
|
||||
$plainStory = strip_tags($appeal->story ?? '');
|
||||
$appealOwner = $appeal->user?->name ?? 'Unknown';
|
||||
$appealOwnerEmail = $appeal->user?->email ?? 'Unknown';
|
||||
$isInMemory = $appeal->is_in_memory ? 'Yes' : 'No';
|
||||
|
||||
$prompt = "You are a compliance reviewer for CharityRight, a UK-registered Muslim charity (Charity Commission regulated).\n\n";
|
||||
$prompt .= "CharityRight's mission: Providing food (school meals, family meals), emergency humanitarian relief, and Islamic religious observance programs (Qurbani, Zakat, Fidya, Kaffarah) across Muslim-majority countries affected by poverty and conflict.\n\n";
|
||||
$prompt .= "CHARITY'S ACTIVE PROGRAMS: {$donationTypes}\n";
|
||||
$prompt .= "COUNTRIES OF OPERATION: {$donationCountries}\n\n";
|
||||
$prompt .= "A member of the public has created a fundraising page on our platform. You must decide if this fundraising page should be approved to go live on our charity's website.\n\n";
|
||||
$prompt .= "FUNDRAISER DETAILS:\n";
|
||||
$prompt .= "- Title: {$appeal->name}\n";
|
||||
$prompt .= "- Description: {$appeal->description}\n";
|
||||
$prompt .= "- Created by: {$appealOwner} ({$appealOwnerEmail})\n";
|
||||
$prompt .= "- Cause: " . ($appeal->donationType?->display_name ?? 'None') . "\n";
|
||||
$prompt .= "- Target amount: £{$appeal->amount_to_raise}\n";
|
||||
$prompt .= "- In memory of someone: {$isInMemory}\n";
|
||||
$prompt .= "- Story/content:\n{$plainStory}\n\n";
|
||||
$prompt .= "REVIEW CRITERIA — Check ALL of these:\n\n";
|
||||
$prompt .= "1. RELEVANCE: Is this fundraiser related to CharityRight's charitable purposes? (food aid, emergency relief, Islamic programs, humanitarian causes)\n";
|
||||
$prompt .= "2. LEGITIMACY: Does this look like a genuine fundraising effort, not spam, SEO content, or marketing?\n";
|
||||
$prompt .= "3. COMPLIANCE: Does the content comply with UK Charity Commission regulations? No political campaigning, no commercial activity, no personal enrichment.\n";
|
||||
$prompt .= "4. CONTENT QUALITY: Is the story coherent and appropriate for a charity website?\n";
|
||||
$prompt .= "5. SAFETY: No hate speech, no extremism, no content that could damage the charity's reputation.\n";
|
||||
$prompt .= "6. LINKS: Are there suspicious external links that suggest SEO spam or affiliate marketing?\n\n";
|
||||
$prompt .= "RESPOND IN THIS EXACT JSON FORMAT (no markdown, no code blocks, just raw JSON):\n";
|
||||
$prompt .= "{\"decision\": \"approve\" or \"reject\" or \"review\", \"confidence\": 0.0 to 1.0, \"reasons\": [\"reason 1\"], \"summary\": \"One sentence\", \"flags\": [\"flag1\"]}\n\n";
|
||||
$prompt .= "Rules:\n";
|
||||
$prompt .= "- \"approve\" = clearly legitimate fundraiser aligned with charity's mission\n";
|
||||
$prompt .= "- \"reject\" = clearly spam, prohibited content, or violates charity regulations\n";
|
||||
$prompt .= "- \"review\" = uncertain, needs human judgment (use this when confidence < 0.7)\n";
|
||||
$prompt .= "- Be strict about spam but generous with genuine supporters who may have imperfect English\n";
|
||||
$prompt .= "- A sincere person raising money for food/meals/emergency relief = approve\n";
|
||||
$prompt .= "- Blog-style content with external links about travel/umrah packages/quran apps = reject (SEO spam)\n";
|
||||
$prompt .= "- Drug sales, commercial products, gambling = reject immediately\n";
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
])
|
||||
->timeout(30)
|
||||
->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => 'claude-sonnet-4-20250514',
|
||||
'max_tokens' => 500,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
Log::error('AIAppealReview: API error', ['status' => $response->status(), 'body' => $response->body()]);
|
||||
return $this->fallbackResult('API error: ' . $response->status());
|
||||
}
|
||||
|
||||
$content = $response->json('content.0.text', '');
|
||||
$content = trim($content);
|
||||
$content = preg_replace('/^```json?\s*/m', '', $content);
|
||||
$content = preg_replace('/```\s*$/m', '', $content);
|
||||
$content = trim($content);
|
||||
|
||||
$result = json_decode($content, true);
|
||||
|
||||
if (!$result || !isset($result['decision'])) {
|
||||
Log::warning('AIAppealReview: Could not parse response', ['content' => $content]);
|
||||
return $this->fallbackResult('Could not parse AI response');
|
||||
}
|
||||
|
||||
if (!in_array($result['decision'], ['approve', 'reject', 'review'])) {
|
||||
$result['decision'] = 'review';
|
||||
}
|
||||
|
||||
return [
|
||||
'decision' => $result['decision'],
|
||||
'confidence' => (float) ($result['confidence'] ?? 0.5),
|
||||
'reasons' => (array) ($result['reasons'] ?? []),
|
||||
'summary' => (string) ($result['summary'] ?? ''),
|
||||
'flags' => (array) ($result['flags'] ?? []),
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AIAppealReview: Exception', ['message' => $e->getMessage()]);
|
||||
return $this->fallbackResult($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function fallbackResult(string $reason): array
|
||||
{
|
||||
return [
|
||||
'decision' => 'review',
|
||||
'confidence' => 0.0,
|
||||
'reasons' => ["AI review failed: {$reason}"],
|
||||
'summary' => 'Automated review unavailable. Needs manual review.',
|
||||
'flags' => ['ai-error'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user