add family logic

This commit is contained in:
root
2026-03-10 17:52:47 -04:00
parent a82f7aedbc
commit 17d73e2f92
37 changed files with 2611 additions and 2 deletions
@@ -0,0 +1,91 @@
<?php
namespace App\Services\Docs;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
class OpenApiRouteExporter
{
public function exportFromFile(string $path): array
{
$base = [];
if (is_file($path)) {
$json = json_decode((string) file_get_contents($path), true);
if (is_array($json)) {
$base = $json;
}
}
$base['paths'] = $base['paths'] ?? [];
foreach (RouteFacade::getRoutes() as $route) {
$this->mergeRoute($base['paths'], $route);
}
return $base;
}
private function mergeRoute(array &$paths, Route $route): void
{
$uriRaw = ltrim($route->uri(), '/');
if (str_starts_with($uriRaw, 'api/')) {
$uri = '/' . $uriRaw;
} elseif (str_starts_with($uriRaw, 'v1/')) {
$uri = '/api/' . $uriRaw;
} elseif ($uriRaw === 'v1') {
$uri = '/api/v1';
} else {
return;
}
if (!str_starts_with($uri, '/api/v1')) {
return;
}
$methods = array_diff($route->methods(), ['HEAD']);
if (empty($methods)) {
return;
}
$tag = $this->resolveTag($uri);
foreach ($methods as $method) {
$lower = strtolower($method);
if (!isset($paths[$uri][$lower])) {
$paths[$uri][$lower] = [
'tags' => [$tag],
'summary' => $this->summaryFromAction($route),
'responses' => [
'200' => [
'description' => 'OK',
],
],
];
}
}
}
private function resolveTag(string $uri): string
{
$trimmed = trim(str_replace('/api/v1', '', $uri), '/');
if ($trimmed === '') {
return 'api';
}
$segments = explode('/', $trimmed);
return $segments[0] ?: 'api';
}
private function summaryFromAction(Route $route): string
{
$action = $route->getActionName();
if (is_string($action) && str_contains($action, '@')) {
[$controller, $method] = explode('@', $action, 2);
$base = class_basename($controller);
return $base . '::' . $method;
}
if (is_string($action)) {
return $action;
}
return 'Endpoint';
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Services\Families;
use Illuminate\Support\Facades\DB;
class FamilyFinanceService
{
public function loadFinancialsForParents(array $parentIds, int $paymentLimit = 10): array
{
$parentIds = array_values(array_filter(array_map('intval', $parentIds)));
if (empty($parentIds)) {
return [
'invoices' => [],
'payments' => [],
'invoice_map' => [],
'summary' => [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
],
];
}
$invRows = DB::table('invoices')
->select('id', 'parent_id', 'invoice_number', 'status', 'total_amount', 'paid_amount', 'balance', 'issue_date', 'due_date')
->whereIn('parent_id', $parentIds)
->orderBy('issue_date', 'DESC')
->get()
->map(fn ($r) => (array) $r)
->all();
$invoiceMap = [];
$summary = [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
];
foreach ($invRows as $ir) {
$invoiceMap[(int) ($ir['id'] ?? 0)] = (string) ($ir['invoice_number'] ?? '');
$summary['invoices_count']++;
$summary['total_amount'] += (float) ($ir['total_amount'] ?? 0);
$summary['paid_amount'] += (float) ($ir['paid_amount'] ?? 0);
$summary['balance'] += (float) ($ir['balance'] ?? 0);
}
$payRows = DB::table('payments')
->select('id', 'parent_id', 'invoice_id', 'paid_amount', 'balance', 'payment_method', 'payment_date', 'status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
->limit($paymentLimit)
->get()
->map(fn ($r) => (array) $r)
->all();
return [
'invoices' => $invRows,
'payments' => $payRows,
'invoice_map' => $invoiceMap,
'summary' => $summary,
];
}
}
@@ -0,0 +1,340 @@
<?php
namespace App\Services\Families;
use App\Models\Family;
use App\Models\FamilyGuardian;
use App\Models\FamilyStudent;
use App\Models\Student;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class FamilyMutationService
{
public function bootstrapFamilies(): array
{
$this->ensureTablesExist(['families', 'family_students', 'family_guardians']);
return DB::transaction(function () {
$primaryParents = DB::table('students')
->select('parent_id')
->whereNotNull('parent_id')
->distinct()
->get()
->map(fn ($r) => (array) $r)
->all();
$created = 0;
$linkedStudents = 0;
$linkedGuardians = 0;
foreach ($primaryParents as $row) {
$primaryUserId = (int) ($row['parent_id'] ?? 0);
if ($primaryUserId <= 0) {
continue;
}
$familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created);
$students = DB::table('students')
->select('id')
->where('parent_id', $primaryUserId)
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($students as $s) {
$linkedStudents += $this->attachStudentToFamily((int) ($s['id'] ?? 0), $familyId);
}
$linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0);
}
return [
'families_created' => $created,
'students_linked' => $linkedStudents,
'guardians_linked' => $linkedGuardians,
];
});
}
public function attachSecondByUser(int $studentId, int $userId, string $relation = 'secondary'): array
{
if ($studentId <= 0 || $userId <= 0) {
return ['ok' => false, 'message' => 'student_id and user_id required'];
}
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) {
return ['ok' => false, 'message' => 'No primary family found for this student'];
}
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
return ['ok' => true, 'attached' => $rows, 'family_id' => $familyId];
}
public function attachSecondByEmail(int $studentId, string $email, ?string $firstname, ?string $lastname, string $relation = 'secondary'): array
{
if ($studentId <= 0 || $email === '') {
return ['ok' => false, 'message' => 'student_id and email required'];
}
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) {
return ['ok' => false, 'message' => 'No primary family found for this student'];
}
return DB::transaction(function () use ($email, $firstname, $lastname, $relation, $familyId) {
$user = User::query()->where('email', $email)->first();
if (!$user) {
$user = User::query()->create([
'firstname' => (string) ($firstname ?? ''),
'lastname' => (string) ($lastname ?? ''),
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary',
'cellphone' => '',
'address_street' => '',
'city' => '',
'state' => '',
'zip' => '',
'accept_school_policy' => 1,
'password' => bcrypt('temporary'),
'semester' => '',
]);
}
$rows = $this->attachGuardianUser($familyId, (int) $user->id, $relation, false, 1, 0);
return [
'ok' => true,
'attached' => $rows,
'family_id' => $familyId,
'user_id' => (int) $user->id,
];
});
}
public function setPrimaryHome(int $familyId, int $studentId, bool $isPrimaryHome): void
{
FamilyStudent::query()
->where('family_id', $familyId)
->where('student_id', $studentId)
->update(['is_primary_home' => $isPrimaryHome ? 1 : 0]);
}
public function setGuardianFlags(int $familyId, int $userId, array $flags): void
{
$data = [];
foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $key) {
if (array_key_exists($key, $flags)) {
$value = $flags[$key];
$data[$key] = in_array($key, ['receive_emails', 'is_primary', 'receive_sms'], true)
? (int) $value
: (string) $value;
}
}
if (empty($data)) {
return;
}
FamilyGuardian::query()
->where('family_id', $familyId)
->where('user_id', $userId)
->update($data);
}
public function unlinkGuardian(int $familyId, int $userId): void
{
FamilyGuardian::query()
->where('family_id', $familyId)
->where('user_id', $userId)
->delete();
}
public function unlinkStudent(int $familyId, int $studentId): void
{
FamilyStudent::query()
->where('family_id', $familyId)
->where('student_id', $studentId)
->delete();
}
public function importSecondParentsFromLegacy(): array
{
$legacyTable = 'parents';
$this->ensureTablesExist(['families', 'family_students', 'family_guardians']);
if (!Schema::hasTable($legacyTable)) {
return ['ok' => false, 'message' => "Legacy table '{$legacyTable}' not found"];
}
$rows = DB::table($legacyTable)
->selectRaw('firstparent_id AS first_id, secondparent_id AS second_id, secondparent_email AS email, secondparent_firstname AS firstname, secondparent_lastname AS lastname')
->where(function ($q) {
$q->where('secondparent_id', '!=', 0)
->orWhere('secondparent_email', '!=', '');
})
->get()
->map(fn ($r) => (array) $r)
->all();
$createdUsers = 0;
$linked = 0;
$skipped = 0;
foreach ($rows as $r) {
$firstId = (int) ($r['first_id'] ?? 0);
$secondId = (int) ($r['second_id'] ?? 0);
$email = trim((string) ($r['email'] ?? ''));
if ($firstId <= 0 || ($secondId <= 0 && $email === '')) {
$skipped++;
continue;
}
$tmp = 0;
$familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp);
$userId = 0;
if ($secondId > 0) {
$userId = $secondId;
} else {
$user = User::query()->where('email', $email)->first();
if (!$user) {
$user = User::query()->create([
'firstname' => (string) ($r['firstname'] ?? ''),
'lastname' => (string) ($r['lastname'] ?? ''),
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary',
'cellphone' => '',
'address_street' => '',
'city' => '',
'state' => '',
'zip' => '',
'accept_school_policy' => 1,
'password' => bcrypt('temporary'),
'semester' => '',
]);
$createdUsers++;
}
$userId = (int) $user->id;
}
$students = DB::table('students')
->select('id')
->where('parent_id', $firstId)
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($students as $sr) {
$sid = (int) ($sr['id'] ?? 0);
if ($sid > 0) {
$this->attachStudentToFamily($sid, $familyId);
}
}
$linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0);
}
return [
'ok' => true,
'created_users' => $createdUsers,
'guardians_linked' => $linked,
'skipped' => $skipped,
'total_source' => count($rows),
];
}
private function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int
{
$code = 'FAM-' . $primaryUserId;
$row = Family::query()->where('family_code', $code)->first();
if ($row) {
return (int) $row->id;
}
$family = Family::query()->create([
'family_code' => $code,
'household_name' => 'Family of User ' . $primaryUserId,
'is_active' => 1,
]);
$createdCounter++;
return (int) $family->id;
}
private function attachStudentToFamily(int $studentId, int $familyId): int
{
if ($studentId <= 0 || $familyId <= 0) {
return 0;
}
return DB::table('family_students')->insertOrIgnore([
'family_id' => $familyId,
'student_id' => $studentId,
'is_primary_home' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function attachGuardianUser(
int $familyId,
int $userId,
string $relation = 'primary',
bool $isPrimary = false,
int $receiveEmails = 1,
int $receiveSms = 0
): int {
if ($familyId <= 0 || $userId <= 0) {
return 0;
}
return DB::table('family_guardians')->insertOrIgnore([
'family_id' => $familyId,
'user_id' => $userId,
'relation' => $relation,
'is_primary' => $isPrimary ? 1 : 0,
'receive_emails' => $receiveEmails,
'receive_sms' => $receiveSms,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function familyIdFromStudentPrimary(int $studentId): ?int
{
$student = Student::query()->select('parent_id')->find($studentId);
if (!$student || empty($student->parent_id)) {
return null;
}
$code = 'FAM-' . (int) $student->parent_id;
$row = Family::query()->select('id')->where('family_code', $code)->first();
if ($row) {
return (int) $row->id;
}
$fallback = DB::table('family_students')
->select('family_id')
->where('student_id', $studentId)
->orderByDesc('is_primary_home')
->first();
return $fallback?->family_id ? (int) $fallback->family_id : null;
}
private function ensureTablesExist(array $tables): void
{
foreach ($tables as $table) {
if (!Schema::hasTable($table)) {
Log::warning('Family tables missing', ['table' => $table]);
throw new \RuntimeException("Missing required table '{$table}'. Run migrations.");
}
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Services\Families;
use App\Services\Email\EmailDispatchService;
class FamilyNotificationService
{
public function __construct(private EmailDispatchService $dispatch)
{
}
public function sendComposeEmail(
string $recipient,
string $subject,
string $htmlMessage,
?string $profile = null,
?string $replyToEmail = null,
?string $replyToName = null
): bool {
return $this->dispatch->send(
$recipient,
$subject,
$htmlMessage,
$profile,
$replyToEmail,
$replyToName
);
}
}
@@ -0,0 +1,387 @@
<?php
namespace App\Services\Families;
use App\Models\Family;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class FamilyQueryService
{
public function __construct(private FamilyFinanceService $finance)
{
}
public function listStudentsForSelect(): array
{
return DB::table('students')
->select('id', DB::raw("CONCAT(lastname, ', ', firstname) AS name"))
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn ($r) => (array) $r)
->all();
}
public function searchSuggestions(string $query, int $limit = 8): array
{
$query = trim($query);
if ($query === '') {
return [];
}
$qs = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $query) . '%';
$students = DB::table('students')
->select('id', 'firstname', 'lastname')
->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs])
->orderBy('lastname')
->orderBy('firstname')
->limit($limit)
->get()
->map(fn ($r) => (array) $r)
->all();
$guardians = DB::table('users')
->select('id', 'firstname', 'lastname', 'email', 'cellphone')
->where(function ($q) use ($qs) {
$q->whereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", [$qs])
->orWhere('email', 'like', $qs)
->orWhere('cellphone', 'like', $qs);
})
->orderBy('lastname')
->orderBy('firstname')
->limit($limit)
->get()
->map(fn ($r) => (array) $r)
->all();
$items = [];
foreach ($students as $s) {
$items[] = [
'type' => 'student',
'id' => (int) ($s['id'] ?? 0),
'label' => trim(($s['firstname'] ?? '') . ' ' . ($s['lastname'] ?? '')),
'sub' => 'Student',
];
}
foreach ($guardians as $g) {
$items[] = [
'type' => 'guardian',
'id' => (int) ($g['id'] ?? 0),
'label' => trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? '')),
'sub' => trim(($g['email'] ?? '') . ' ' . ($g['cellphone'] ?? '')),
];
}
return $items;
}
public function resolveStudentIdByGuardian(int $guardianId): ?int
{
if ($guardianId <= 0) {
return null;
}
$row = DB::table('family_guardians as fg')
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
->join('students as s', 's.id', '=', 'fs.student_id')
->where('fg.user_id', $guardianId)
->orderBy('s.lastname')
->orderBy('s.firstname')
->select('s.id as student_id')
->first();
if ($row && !empty($row->student_id)) {
return (int) $row->student_id;
}
$fallback = Student::query()
->select('id')
->where('parent_id', $guardianId)
->orderBy('lastname')
->orderBy('firstname')
->first();
return $fallback?->id ? (int) $fallback->id : null;
}
public function listFamiliesByStudent(int $studentId): array
{
return DB::table('family_students as fs')
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('fs.student_id', $studentId)
->where('f.is_active', 1)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->select('f.*', 'fs.is_primary_home')
->get()
->map(fn ($r) => (array) $r)
->all();
}
public function listGuardiansByFamily(int $familyId): array
{
return DB::table('family_guardians as fg')
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $familyId)
->orderByDesc('fg.is_primary')
->orderBy('u.lastname')
->orderBy('u.firstname')
->select(
'u.id as user_id',
'u.firstname',
'u.lastname',
'u.email',
'u.cellphone',
'u.address_street',
'u.city',
'u.state',
'u.zip',
'fg.relation',
'fg.is_primary',
'fg.receive_emails',
'fg.receive_sms'
)
->get()
->map(fn ($r) => (array) $r)
->all();
}
public function adminIndexData(?int $studentId, ?int $guardianId, string $schoolYear): array
{
$studentId = (int) ($studentId ?? 0);
$guardianId = (int) ($guardianId ?? 0);
$resolvedStudentId = null;
if ($studentId <= 0 && $guardianId > 0) {
$resolvedStudentId = $this->resolveStudentIdByGuardian($guardianId);
if ($resolvedStudentId) {
$studentId = $resolvedStudentId;
}
}
$data = [
'student' => null,
'families' => [],
'guardians' => [],
'students' => $this->listStudentsForSelect(),
'searchStudents' => DB::table('students')
->select('id', 'firstname', 'lastname')
->orderBy('lastname')
->orderBy('firstname')
->get()
->map(fn ($r) => (array) $r)
->all(),
'searchGuardians' => DB::table('family_guardians as fg')
->join('users as u', 'u.id', '=', 'fg.user_id')
->distinct()
->select('u.id', 'u.firstname', 'u.lastname', 'u.email', 'u.cellphone')
->orderBy('u.lastname')
->orderBy('u.firstname')
->get()
->map(fn ($r) => (array) $r)
->all(),
'resolved_student_id' => $resolvedStudentId,
];
if ($studentId > 0) {
$data['student'] = DB::table('students')
->select('id', 'firstname', 'lastname')
->where('id', $studentId)
->first();
$data['student'] = $data['student'] ? (array) $data['student'] : null;
$families = DB::table('family_students as fs')
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('fs.student_id', $studentId)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->select(
'f.id',
'f.family_code',
'f.household_name',
'f.address_line1',
'f.address_line2',
'f.city',
'f.state',
'f.postal_code',
'f.country',
'f.primary_phone',
'f.preferred_lang',
'f.preferred_contact_method',
'f.is_active',
'fs.is_primary_home'
)
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($families as &$fam) {
$familyId = (int) ($fam['id'] ?? 0);
$fam['guardians'] = $this->listGuardiansByFamily($familyId);
$fam['students'] = $this->studentsForFamily($familyId, $schoolYear);
$parentIds = array_values(array_filter(array_map(
static fn ($g) => (int) ($g['user_id'] ?? 0),
$fam['guardians']
)));
$finance = $this->finance->loadFinancialsForParents($parentIds);
$fam['invoices'] = $finance['invoices'];
$fam['payments'] = $finance['payments'];
$fam['finance_summary'] = $finance['summary'];
$fam['invoice_map'] = $finance['invoice_map'];
}
unset($fam);
$data['families'] = $families;
if (!empty($families)) {
$familyId = (int) ($families[0]['id'] ?? 0);
$data['guardians'] = $familyId ? $this->listGuardiansByFamily($familyId) : [];
}
}
return $data;
}
public function familyCard(?int $studentId, ?int $guardianId, ?int $familyId, string $schoolYear): ?array
{
$studentId = (int) ($studentId ?? 0);
$guardianId = (int) ($guardianId ?? 0);
$familyId = (int) ($familyId ?? 0);
if ($familyId <= 0) {
if ($studentId > 0) {
$row = DB::table('family_students as fs')
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('fs.student_id', $studentId)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->select('f.id')
->first();
$familyId = $row?->id ? (int) $row->id : 0;
} elseif ($guardianId > 0) {
$row = DB::table('family_guardians as fg')
->join('families as f', 'f.id', '=', 'fg.family_id')
->where('fg.user_id', $guardianId)
->orderBy('f.household_name')
->select('f.id')
->first();
if ($row?->id) {
$familyId = (int) $row->id;
} else {
$fallback = DB::table('students as s')
->join('family_students as fs', 'fs.student_id', '=', 's.id')
->join('families as f', 'f.id', '=', 'fs.family_id')
->where('s.parent_id', $guardianId)
->orderByDesc('fs.is_primary_home')
->orderBy('f.household_name')
->select('f.id')
->first();
$familyId = $fallback?->id ? (int) $fallback->id : 0;
}
}
}
if ($familyId <= 0) {
return null;
}
$family = Family::query()
->select(
'id',
'family_code',
'household_name',
'address_line1',
'address_line2',
'city',
'state',
'postal_code',
'country',
'primary_phone',
'preferred_lang',
'preferred_contact_method',
'is_active'
)
->find($familyId);
if (!$family) {
return null;
}
$payload = $family->toArray();
$payload['guardians'] = $this->listGuardiansByFamily($familyId);
$payload['students'] = $this->studentsForFamily($familyId, $schoolYear);
$payload['emergency_contacts'] = $this->emergencyContactsForParents($payload['guardians']);
$parentIds = array_values(array_filter(array_map(
static fn ($g) => (int) ($g['user_id'] ?? 0),
$payload['guardians']
)));
$finance = $this->finance->loadFinancialsForParents($parentIds);
$payload['invoices'] = $finance['invoices'];
$payload['payments'] = $finance['payments'];
$payload['finance_summary'] = $finance['summary'];
$payload['invoice_map'] = $finance['invoice_map'];
return $payload;
}
private function studentsForFamily(int $familyId, string $schoolYear): array
{
$rows = DB::table('family_students as fs')
->join('students as s', 's.id', '=', 'fs.student_id')
->where('fs.family_id', $familyId)
->orderBy('s.lastname')
->orderBy('s.firstname')
->select('s.id', 's.firstname', 's.lastname')
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($rows as &$row) {
$sid = (int) ($row['id'] ?? 0);
$row['grade'] = $sid ? (string) (StudentClass::getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
}
unset($row);
return $rows;
}
private function emergencyContactsForParents(array $guardians): array
{
$parentIds = array_values(array_filter(array_map(
static fn ($g) => (int) ($g['user_id'] ?? 0),
$guardians
)));
if (empty($parentIds)) {
return [];
}
$guardianMap = [];
foreach ($guardians as $g) {
$gid = (int) ($g['user_id'] ?? 0);
if ($gid > 0) {
$guardianMap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
}
}
$rows = DB::table('emergency_contacts')
->select('id', 'parent_id', 'emergency_contact_name', 'relation', 'cellphone', 'email', 'school_year', 'semester', 'created_at', 'updated_at')
->whereIn('parent_id', $parentIds)
->orderBy('updated_at', 'DESC')
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($rows as &$row) {
$pid = (int) ($row['parent_id'] ?? 0);
$row['parent_label'] = $guardianMap[$pid] ?? ('Parent #' . $pid);
}
unset($row);
return $rows;
}
}