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
277 lines
12 KiB
PHP
277 lines
12 KiB
PHP
<?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'],
|
|
];
|
|
}
|
|
}
|