diff --git a/app/Http/Controllers/Api/Family/FamilyAdminController.php b/app/Http/Controllers/Api/Family/FamilyAdminController.php new file mode 100644 index 00000000..54d1d606 --- /dev/null +++ b/app/Http/Controllers/Api/Family/FamilyAdminController.php @@ -0,0 +1,102 @@ +validated(); + $schoolYear = trim((string) (Configuration::getConfig('school_year') ?? '')); + + $data = $this->queryService->adminIndexData( + $payload['student_id'] ?? null, + $payload['guardian_id'] ?? null, + $schoolYear + ); + + return $this->success([ + 'student' => $data['student'], + 'families' => FamilyResource::collection($data['families']), + 'guardians' => $data['guardians'], + 'students' => $data['students'], + 'searchStudents' => $data['searchStudents'], + 'searchGuardians' => $data['searchGuardians'], + 'resolved_student_id' => $data['resolved_student_id'], + ]); + } + + public function search(FamilyAdminSearchRequest $request): JsonResponse + { + $payload = $request->validated(); + $items = $this->queryService->searchSuggestions( + (string) $payload['q'], + (int) ($payload['limit'] ?? 8) + ); + + return $this->success([ + 'items' => FamilySearchItemResource::collection($items), + ]); + } + + public function card(FamilyAdminCardRequest $request): JsonResponse + { + $payload = $request->validated(); + $schoolYear = trim((string) (Configuration::getConfig('school_year') ?? '')); + + $family = $this->queryService->familyCard( + $payload['student_id'] ?? null, + $payload['guardian_id'] ?? null, + $payload['family_id'] ?? null, + $schoolYear + ); + + if (!$family) { + return $this->error('Family not found.', Response::HTTP_NOT_FOUND); + } + + return $this->success(new FamilyResource($family)); + } + + public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse + { + $payload = $request->validated(); + + $ok = $this->notificationService->sendComposeEmail( + (string) $payload['recipient'], + (string) $payload['subject'], + (string) $payload['html_message'], + $payload['profile'] ?? null, + $payload['reply_to_email'] ?? null, + $payload['reply_to_name'] ?? null + ); + + if (!$ok) { + Log::warning('Compose email failed for family admin', ['recipient' => $payload['recipient']]); + return $this->error('Unable to send email.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success(['recipient' => $payload['recipient']], 'Email sent.'); + } +} diff --git a/app/Http/Controllers/Api/Family/FamilyController.php b/app/Http/Controllers/Api/Family/FamilyController.php new file mode 100644 index 00000000..ac45d7c9 --- /dev/null +++ b/app/Http/Controllers/Api/Family/FamilyController.php @@ -0,0 +1,169 @@ +queryService->listFamiliesByStudent($studentId); + + return $this->success([ + 'families' => FamilyResource::collection($rows), + ]); + } + + public function guardiansByFamily(GuardiansByFamilyRequest $request, int $familyId): JsonResponse + { + $rows = $this->queryService->listGuardiansByFamily($familyId); + + return $this->success([ + 'guardians' => FamilyGuardianResource::collection($rows), + ]); + } + + public function bootstrap(FamilyBootstrapRequest $request): JsonResponse + { + try { + $result = $this->mutationService->bootstrapFamilies(); + } catch (\Throwable $e) { + Log::error('Family bootstrap failed: ' . $e->getMessage()); + return $this->error('Family bootstrap failed.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->success($result, 'Bootstrap completed.'); + } + + public function attachSecondByUser(FamilyAttachSecondByUserRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->mutationService->attachSecondByUser( + (int) $payload['student_id'], + (int) $payload['user_id'], + (string) ($payload['relation'] ?? 'secondary') + ); + + if (!($result['ok'] ?? false)) { + return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success($result, 'Guardian attached.'); + } + + public function attachSecondByEmail(FamilyAttachSecondByEmailRequest $request): JsonResponse + { + $payload = $request->validated(); + try { + $result = $this->mutationService->attachSecondByEmail( + (int) $payload['student_id'], + (string) $payload['email'], + $payload['firstname'] ?? null, + $payload['lastname'] ?? null, + (string) ($payload['relation'] ?? 'secondary') + ); + } catch (\Throwable $e) { + Log::error('Attach guardian by email failed: ' . $e->getMessage()); + return $this->error('Unable to attach guardian.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + if (!($result['ok'] ?? false)) { + return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success($result, 'Guardian attached.'); + } + + public function setPrimaryHome(FamilySetPrimaryHomeRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->mutationService->setPrimaryHome( + (int) $payload['family_id'], + (int) $payload['student_id'], + (bool) $payload['is_primary_home'] + ); + + return $this->success(null, 'Primary home updated.'); + } + + public function setGuardianFlags(FamilySetGuardianFlagsRequest $request): JsonResponse + { + $payload = $request->validated(); + $flags = array_intersect_key($payload, array_flip(['receive_emails', 'is_primary', 'receive_sms', 'relation'])); + + if (empty($flags)) { + return $this->error('No flags provided.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $this->mutationService->setGuardianFlags( + (int) $payload['family_id'], + (int) $payload['user_id'], + $flags + ); + + return $this->success(null, 'Guardian flags updated.'); + } + + public function unlinkGuardian(FamilyUnlinkGuardianRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->mutationService->unlinkGuardian( + (int) $payload['family_id'], + (int) $payload['user_id'] + ); + + return $this->success(null, 'Guardian unlinked.'); + } + + public function unlinkStudent(FamilyUnlinkStudentRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->mutationService->unlinkStudent( + (int) $payload['family_id'], + (int) $payload['student_id'] + ); + + return $this->success(null, 'Student unlinked.'); + } + + public function importSecondParentsFromLegacy(FamilyImportLegacyRequest $request): JsonResponse + { + try { + $result = $this->mutationService->importSecondParentsFromLegacy(); + } catch (\Throwable $e) { + Log::error('Legacy second parent import failed: ' . $e->getMessage()); + return $this->error('Legacy import failed.', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + if (!($result['ok'] ?? true)) { + return $this->error($result['message'] ?? 'Legacy import failed.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $this->success($result, 'Legacy import completed.'); + } +} diff --git a/app/Http/Requests/Families/FamiliesByStudentRequest.php b/app/Http/Requests/Families/FamiliesByStudentRequest.php new file mode 100644 index 00000000..070fe4fd --- /dev/null +++ b/app/Http/Requests/Families/FamiliesByStudentRequest.php @@ -0,0 +1,26 @@ +merge(['student_id' => $this->route('studentId')]); + } + + public function authorize(): bool + { + return auth()->check(); + } + + public function rules(): array + { + return [ + 'student_id' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyAdminCardRequest.php b/app/Http/Requests/Families/FamilyAdminCardRequest.php new file mode 100644 index 00000000..a9582988 --- /dev/null +++ b/app/Http/Requests/Families/FamilyAdminCardRequest.php @@ -0,0 +1,22 @@ +check(); + } + + public function rules(): array + { + return [ + 'student_id' => ['nullable', 'integer', 'min:1'], + 'guardian_id' => ['nullable', 'integer', 'min:1'], + 'family_id' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyAdminIndexRequest.php b/app/Http/Requests/Families/FamilyAdminIndexRequest.php new file mode 100644 index 00000000..442051e6 --- /dev/null +++ b/app/Http/Requests/Families/FamilyAdminIndexRequest.php @@ -0,0 +1,21 @@ +check(); + } + + public function rules(): array + { + return [ + 'student_id' => ['nullable', 'integer', 'min:1'], + 'guardian_id' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyAdminSearchRequest.php b/app/Http/Requests/Families/FamilyAdminSearchRequest.php new file mode 100644 index 00000000..23078cdc --- /dev/null +++ b/app/Http/Requests/Families/FamilyAdminSearchRequest.php @@ -0,0 +1,21 @@ +check(); + } + + public function rules(): array + { + return [ + 'q' => ['required', 'string', 'max:100'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyAttachSecondByEmailRequest.php b/app/Http/Requests/Families/FamilyAttachSecondByEmailRequest.php new file mode 100644 index 00000000..45585264 --- /dev/null +++ b/app/Http/Requests/Families/FamilyAttachSecondByEmailRequest.php @@ -0,0 +1,24 @@ +check(); + } + + public function rules(): array + { + return [ + 'student_id' => ['required', 'integer', 'min:1'], + 'email' => ['required', 'email', 'max:255'], + 'firstname' => ['nullable', 'string', 'max:255'], + 'lastname' => ['nullable', 'string', 'max:255'], + 'relation' => ['nullable', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyAttachSecondByUserRequest.php b/app/Http/Requests/Families/FamilyAttachSecondByUserRequest.php new file mode 100644 index 00000000..7d7d7bb0 --- /dev/null +++ b/app/Http/Requests/Families/FamilyAttachSecondByUserRequest.php @@ -0,0 +1,22 @@ +check(); + } + + public function rules(): array + { + return [ + 'student_id' => ['required', 'integer', 'min:1'], + 'user_id' => ['required', 'integer', 'min:1'], + 'relation' => ['nullable', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyBootstrapRequest.php b/app/Http/Requests/Families/FamilyBootstrapRequest.php new file mode 100644 index 00000000..0004eeae --- /dev/null +++ b/app/Http/Requests/Families/FamilyBootstrapRequest.php @@ -0,0 +1,18 @@ +check(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Families/FamilyComposeEmailRequest.php b/app/Http/Requests/Families/FamilyComposeEmailRequest.php new file mode 100644 index 00000000..3058a88c --- /dev/null +++ b/app/Http/Requests/Families/FamilyComposeEmailRequest.php @@ -0,0 +1,34 @@ +has('html_message') && $this->has('html')) { + $this->merge(['html_message' => $this->input('html')]); + } + } + + public function authorize(): bool + { + return auth()->check(); + } + + public function rules(): array + { + return [ + 'recipient' => ['required', 'email', 'max:255'], + 'subject' => ['required', 'string', 'max:255'], + 'html_message' => ['required', 'string'], + 'profile' => ['nullable', 'string', 'max:50'], + 'reply_to_email' => ['nullable', 'email', 'max:255'], + 'reply_to_name' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyImportLegacyRequest.php b/app/Http/Requests/Families/FamilyImportLegacyRequest.php new file mode 100644 index 00000000..96c0f571 --- /dev/null +++ b/app/Http/Requests/Families/FamilyImportLegacyRequest.php @@ -0,0 +1,18 @@ +check(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Families/FamilySetGuardianFlagsRequest.php b/app/Http/Requests/Families/FamilySetGuardianFlagsRequest.php new file mode 100644 index 00000000..d19a71f8 --- /dev/null +++ b/app/Http/Requests/Families/FamilySetGuardianFlagsRequest.php @@ -0,0 +1,25 @@ +check(); + } + + public function rules(): array + { + return [ + 'family_id' => ['required', 'integer', 'min:1'], + 'user_id' => ['required', 'integer', 'min:1'], + 'receive_emails' => ['nullable', 'boolean'], + 'is_primary' => ['nullable', 'boolean'], + 'receive_sms' => ['nullable', 'boolean'], + 'relation' => ['nullable', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilySetPrimaryHomeRequest.php b/app/Http/Requests/Families/FamilySetPrimaryHomeRequest.php new file mode 100644 index 00000000..ec385fd3 --- /dev/null +++ b/app/Http/Requests/Families/FamilySetPrimaryHomeRequest.php @@ -0,0 +1,22 @@ +check(); + } + + public function rules(): array + { + return [ + 'family_id' => ['required', 'integer', 'min:1'], + 'student_id' => ['required', 'integer', 'min:1'], + 'is_primary_home' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyUnlinkGuardianRequest.php b/app/Http/Requests/Families/FamilyUnlinkGuardianRequest.php new file mode 100644 index 00000000..a42a69d7 --- /dev/null +++ b/app/Http/Requests/Families/FamilyUnlinkGuardianRequest.php @@ -0,0 +1,21 @@ +check(); + } + + public function rules(): array + { + return [ + 'family_id' => ['required', 'integer', 'min:1'], + 'user_id' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Families/FamilyUnlinkStudentRequest.php b/app/Http/Requests/Families/FamilyUnlinkStudentRequest.php new file mode 100644 index 00000000..696da6dd --- /dev/null +++ b/app/Http/Requests/Families/FamilyUnlinkStudentRequest.php @@ -0,0 +1,21 @@ +check(); + } + + public function rules(): array + { + return [ + 'family_id' => ['required', 'integer', 'min:1'], + 'student_id' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Families/GuardiansByFamilyRequest.php b/app/Http/Requests/Families/GuardiansByFamilyRequest.php new file mode 100644 index 00000000..d1f02354 --- /dev/null +++ b/app/Http/Requests/Families/GuardiansByFamilyRequest.php @@ -0,0 +1,26 @@ +merge(['family_id' => $this->route('familyId')]); + } + + public function authorize(): bool + { + return auth()->check(); + } + + public function rules(): array + { + return [ + 'family_id' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Resources/Families/FamilyEmergencyContactResource.php b/app/Http/Resources/Families/FamilyEmergencyContactResource.php new file mode 100644 index 00000000..382e640f --- /dev/null +++ b/app/Http/Resources/Families/FamilyEmergencyContactResource.php @@ -0,0 +1,25 @@ + (int) ($this['id'] ?? 0), + 'parent_id' => (int) ($this['parent_id'] ?? 0), + 'parent_label' => (string) ($this['parent_label'] ?? ''), + 'emergency_contact_name' => (string) ($this['emergency_contact_name'] ?? ''), + 'relation' => (string) ($this['relation'] ?? ''), + 'cellphone' => (string) ($this['cellphone'] ?? ''), + 'email' => (string) ($this['email'] ?? ''), + 'school_year' => (string) ($this['school_year'] ?? ''), + 'semester' => (string) ($this['semester'] ?? ''), + 'created_at' => $this['created_at'] ?? null, + 'updated_at' => $this['updated_at'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Families/FamilyGuardianResource.php b/app/Http/Resources/Families/FamilyGuardianResource.php new file mode 100644 index 00000000..435857df --- /dev/null +++ b/app/Http/Resources/Families/FamilyGuardianResource.php @@ -0,0 +1,27 @@ + (int) ($this['user_id'] ?? 0), + 'firstname' => (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'email' => (string) ($this['email'] ?? ''), + 'cellphone' => (string) ($this['cellphone'] ?? ''), + 'address_street' => (string) ($this['address_street'] ?? ''), + 'city' => (string) ($this['city'] ?? ''), + 'state' => (string) ($this['state'] ?? ''), + 'zip' => (string) ($this['zip'] ?? ''), + 'relation' => (string) ($this['relation'] ?? ''), + 'is_primary' => (bool) ($this['is_primary'] ?? false), + 'receive_emails' => (bool) ($this['receive_emails'] ?? false), + 'receive_sms' => (bool) ($this['receive_sms'] ?? false), + ]; + } +} diff --git a/app/Http/Resources/Families/FamilyInvoiceResource.php b/app/Http/Resources/Families/FamilyInvoiceResource.php new file mode 100644 index 00000000..d2fe4aa5 --- /dev/null +++ b/app/Http/Resources/Families/FamilyInvoiceResource.php @@ -0,0 +1,23 @@ + (int) ($this['id'] ?? 0), + 'parent_id' => (int) ($this['parent_id'] ?? 0), + 'invoice_number' => (string) ($this['invoice_number'] ?? ''), + 'status' => (string) ($this['status'] ?? ''), + 'total_amount' => (float) ($this['total_amount'] ?? 0), + 'paid_amount' => (float) ($this['paid_amount'] ?? 0), + 'balance' => (float) ($this['balance'] ?? 0), + 'issue_date' => $this['issue_date'] ?? null, + 'due_date' => $this['due_date'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Families/FamilyPaymentResource.php b/app/Http/Resources/Families/FamilyPaymentResource.php new file mode 100644 index 00000000..2cb3e952 --- /dev/null +++ b/app/Http/Resources/Families/FamilyPaymentResource.php @@ -0,0 +1,22 @@ + (int) ($this['id'] ?? 0), + 'parent_id' => (int) ($this['parent_id'] ?? 0), + 'invoice_id' => (int) ($this['invoice_id'] ?? 0), + 'paid_amount' => (float) ($this['paid_amount'] ?? 0), + 'balance' => (float) ($this['balance'] ?? 0), + 'payment_method' => (string) ($this['payment_method'] ?? ''), + 'payment_date' => $this['payment_date'] ?? null, + 'status' => (string) ($this['status'] ?? ''), + ]; + } +} diff --git a/app/Http/Resources/Families/FamilyResource.php b/app/Http/Resources/Families/FamilyResource.php new file mode 100644 index 00000000..59b37e4c --- /dev/null +++ b/app/Http/Resources/Families/FamilyResource.php @@ -0,0 +1,40 @@ + (int) ($this['id'] ?? 0), + 'family_code' => (string) ($this['family_code'] ?? ''), + 'household_name' => (string) ($this['household_name'] ?? ''), + 'address_line1' => (string) ($this['address_line1'] ?? ''), + 'address_line2' => (string) ($this['address_line2'] ?? ''), + 'city' => (string) ($this['city'] ?? ''), + 'state' => (string) ($this['state'] ?? ''), + 'postal_code' => (string) ($this['postal_code'] ?? ''), + 'country' => (string) ($this['country'] ?? ''), + 'primary_phone' => (string) ($this['primary_phone'] ?? ''), + 'preferred_lang' => (string) ($this['preferred_lang'] ?? ''), + 'preferred_contact_method' => (string) ($this['preferred_contact_method'] ?? ''), + 'is_active' => (bool) ($this['is_active'] ?? false), + 'is_primary_home' => (bool) ($this['is_primary_home'] ?? false), + 'guardians' => FamilyGuardianResource::collection($this['guardians'] ?? []), + 'students' => FamilyStudentResource::collection($this['students'] ?? []), + 'invoices' => FamilyInvoiceResource::collection($this['invoices'] ?? []), + 'payments' => FamilyPaymentResource::collection($this['payments'] ?? []), + 'emergency_contacts' => FamilyEmergencyContactResource::collection($this['emergency_contacts'] ?? []), + 'finance_summary' => $this['finance_summary'] ?? [ + 'invoices_count' => 0, + 'total_amount' => 0.0, + 'paid_amount' => 0.0, + 'balance' => 0.0, + ], + 'invoice_map' => $this['invoice_map'] ?? [], + ]; + } +} diff --git a/app/Http/Resources/Families/FamilySearchItemResource.php b/app/Http/Resources/Families/FamilySearchItemResource.php new file mode 100644 index 00000000..525ffe24 --- /dev/null +++ b/app/Http/Resources/Families/FamilySearchItemResource.php @@ -0,0 +1,18 @@ + (string) ($this['type'] ?? ''), + 'id' => (int) ($this['id'] ?? 0), + 'label' => (string) ($this['label'] ?? ''), + 'sub' => (string) ($this['sub'] ?? ''), + ]; + } +} diff --git a/app/Http/Resources/Families/FamilyStudentResource.php b/app/Http/Resources/Families/FamilyStudentResource.php new file mode 100644 index 00000000..fb0e2455 --- /dev/null +++ b/app/Http/Resources/Families/FamilyStudentResource.php @@ -0,0 +1,18 @@ + (int) ($this['id'] ?? 0), + 'firstname' => (string) ($this['firstname'] ?? ''), + 'lastname' => (string) ($this['lastname'] ?? ''), + 'grade' => (string) ($this['grade'] ?? ''), + ]; + } +} diff --git a/app/Services/Docs/OpenApiRouteExporter.php b/app/Services/Docs/OpenApiRouteExporter.php new file mode 100644 index 00000000..382d330c --- /dev/null +++ b/app/Services/Docs/OpenApiRouteExporter.php @@ -0,0 +1,91 @@ +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'; + } +} diff --git a/app/Services/Families/FamilyFinanceService.php b/app/Services/Families/FamilyFinanceService.php new file mode 100644 index 00000000..32835e93 --- /dev/null +++ b/app/Services/Families/FamilyFinanceService.php @@ -0,0 +1,66 @@ + [], + '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, + ]; + } +} diff --git a/app/Services/Families/FamilyMutationService.php b/app/Services/Families/FamilyMutationService.php new file mode 100644 index 00000000..78f188ab --- /dev/null +++ b/app/Services/Families/FamilyMutationService.php @@ -0,0 +1,340 @@ +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."); + } + } + } +} diff --git a/app/Services/Families/FamilyNotificationService.php b/app/Services/Families/FamilyNotificationService.php new file mode 100644 index 00000000..7b31cbc4 --- /dev/null +++ b/app/Services/Families/FamilyNotificationService.php @@ -0,0 +1,30 @@ +dispatch->send( + $recipient, + $subject, + $htmlMessage, + $profile, + $replyToEmail, + $replyToName + ); + } +} diff --git a/app/Services/Families/FamilyQueryService.php b/app/Services/Families/FamilyQueryService.php new file mode 100644 index 00000000..528bb667 --- /dev/null +++ b/app/Services/Families/FamilyQueryService.php @@ -0,0 +1,387 @@ +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; + } +} diff --git a/routes/api.php b/routes/api.php index dcec0f24..dfc46d98 100755 --- a/routes/api.php +++ b/routes/api.php @@ -306,6 +306,26 @@ Route::prefix('v1')->group(function () { Route::post('send', [CommunicationController::class, 'send']); }); + Route::prefix('family-admin')->group(function () { + Route::get('/', [FamilyAdminController::class, 'index']); + Route::get('search', [FamilyAdminController::class, 'search']); + Route::get('card', [FamilyAdminController::class, 'card']); + Route::post('compose-email', [FamilyAdminController::class, 'composeEmail']); + }); + + Route::prefix('families')->group(function () { + Route::get('by-student/{studentId}', [FamilyController::class, 'familiesByStudent']); + Route::get('{familyId}/guardians', [FamilyController::class, 'guardiansByFamily']); + Route::post('bootstrap', [FamilyController::class, 'bootstrap']); + Route::post('attach-second/by-user', [FamilyController::class, 'attachSecondByUser']); + Route::post('attach-second/by-email', [FamilyController::class, 'attachSecondByEmail']); + Route::post('set-primary-home', [FamilyController::class, 'setPrimaryHome']); + Route::post('set-guardian-flags', [FamilyController::class, 'setGuardianFlags']); + Route::post('unlink-guardian', [FamilyController::class, 'unlinkGuardian']); + Route::post('unlink-student', [FamilyController::class, 'unlinkStudent']); + Route::post('import-legacy-second-parents', [FamilyController::class, 'importSecondParentsFromLegacy']); + }); + Route::prefix('competition-scores')->group(function () { Route::get('/', [CompetitionScoresController::class, 'index']); Route::get('{id}', [CompetitionScoresController::class, 'edit']); diff --git a/routes/web.php b/routes/web.php index 0fcfd9f7..57ebdd58 100755 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\View\DashboardRedirectController; +use App\Services\Docs\OpenApiRouteExporter; Route::redirect('/', '/api/docs')->name('docs.home'); Route::view('/api/docs', 'docs.swagger')->name('docs.swagger'); @@ -11,8 +12,10 @@ Route::get('/api/documentation/swagger.json', function () { $path = resource_path('docs/openapi.json'); abort_unless(is_file($path), 404); - return response()->file($path, [ + $spec = app(OpenApiRouteExporter::class)->exportFromFile($path); + + return response()->json($spec, 200, [ 'Content-Type' => 'application/json', - 'Cache-Control' => 'public, max-age=300', + 'Cache-Control' => 'no-store, max-age=0', ]); })->name('docs.swagger-json'); diff --git a/school_api b/school_api index a6106036..0663b112 100644 Binary files a/school_api and b/school_api differ diff --git a/tests/Feature/Api/V1/Family/FamilyAdminControllerTest.php b/tests/Feature/Api/V1/Family/FamilyAdminControllerTest.php new file mode 100644 index 00000000..06903981 --- /dev/null +++ b/tests/Feature/Api/V1/Family/FamilyAdminControllerTest.php @@ -0,0 +1,245 @@ +seedConfig(); + $user = $this->seedUser(1); + $studentId = $this->seedStudent(10, $user->id); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + $this->seedFamilyGuardian($familyId, $user->id); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/family-admin?student_id=' . $studentId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.families')); + $this->assertNotEmpty($response->json('data.students')); + } + + public function test_search_returns_items(): void + { + $this->seedConfig(); + $user = $this->seedUser(1); + $this->seedStudent(10, $user->id); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/family-admin/search?q=Student'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.items')); + } + + public function test_card_returns_family_details(): void + { + $this->seedConfig(); + $user = $this->seedUser(1); + $studentId = $this->seedStudent(10, $user->id); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + $this->seedFamilyGuardian($familyId, $user->id); + $this->seedEmergencyContact($user->id); + $this->seedInvoice($user->id); + $this->seedPayment($user->id); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/family-admin/card?family_id=' . $familyId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.emergency_contacts')); + $this->assertNotEmpty($response->json('data.invoices')); + $this->assertNotEmpty($response->json('data.payments')); + } + + public function test_compose_email_returns_error_on_failure(): void + { + $this->seedConfig(); + $user = $this->seedUser(1); + + $this->mock(FamilyNotificationService::class, function ($mock) { + $mock->shouldReceive('sendComposeEmail')->once()->andReturn(false); + }); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/family-admin/compose-email', [ + 'recipient' => 'recipient@example.com', + 'subject' => 'Hello', + 'html_message' => '

Test

', + ]); + + $response->assertStatus(500); + $response->assertJsonPath('status', false); + } + + public function test_requires_authentication(): void + { + $response = $this->getJson('/api/v1/family-admin'); + + $response->assertStatus(401); + } + + public function test_compose_email_validation_fails(): void + { + $this->seedConfig(); + $user = $this->seedUser(1); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/family-admin/compose-email', [ + 'subject' => 'Hello', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedUser(int $id): User + { + DB::table('users')->insert([ + 'id' => $id, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'parent' . $id . '@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($id); + } + + private function seedStudent(int $id, int $parentId): int + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => 'SCH1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 10, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025-2026', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + ]); + + return $id; + } + + private function seedFamily(string $code): int + { + return DB::table('families')->insertGetId([ + 'family_code' => $code, + 'household_name' => 'Family ' . $code, + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedFamilyStudent(int $familyId, int $studentId): void + { + DB::table('family_students')->insert([ + 'family_id' => $familyId, + 'student_id' => $studentId, + 'is_primary_home' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedFamilyGuardian(int $familyId, int $userId): void + { + DB::table('family_guardians')->insert([ + 'family_id' => $familyId, + 'user_id' => $userId, + 'relation' => 'primary', + 'is_primary' => 1, + 'receive_emails' => 1, + 'receive_sms' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedEmergencyContact(int $parentId): void + { + DB::table('emergency_contacts')->insert([ + 'emergency_contact_name' => 'Contact One', + 'parent_id' => $parentId, + 'cellphone' => '5555555555', + 'email' => 'contact@example.com', + 'relation' => 'Friend', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedInvoice(int $parentId): void + { + DB::table('invoices')->insert([ + 'parent_id' => $parentId, + 'invoice_number' => 'INV-100', + 'status' => 'open', + 'total_amount' => 100, + 'paid_amount' => 0, + 'balance' => 100, + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(10)->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedPayment(int $parentId): void + { + DB::table('payments')->insert([ + 'parent_id' => $parentId, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 50, + 'balance' => 50, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => now()->toDateString(), + 'status' => 'completed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Feature/Api/V1/Family/FamilyControllerTest.php b/tests/Feature/Api/V1/Family/FamilyControllerTest.php new file mode 100644 index 00000000..f9b1a40c --- /dev/null +++ b/tests/Feature/Api/V1/Family/FamilyControllerTest.php @@ -0,0 +1,326 @@ +seedUser(1); + $studentId = $this->seedStudent(10, 1); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/families/by-student/' . $studentId); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.families')); + } + + public function test_guardians_by_family_returns_guardians(): void + { + $user = $this->seedUser(1); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyGuardian($familyId, $user->id); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/v1/families/' . $familyId . '/guardians'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.guardians')); + $this->assertSame($user->id, $response->json('data.guardians.0.user_id')); + } + + public function test_bootstrap_creates_family_and_links(): void + { + $user = $this->seedUser(1); + $this->seedStudent(10, $user->id); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/families/bootstrap', []); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertDatabaseHas('families', ['family_code' => 'FAM-1']); + $this->assertDatabaseHas('family_students', ['student_id' => 10]); + $this->assertDatabaseHas('family_guardians', ['user_id' => 1]); + } + + public function test_attach_second_by_user_links_guardian(): void + { + $primary = $this->seedUser(1); + $secondary = $this->seedUser(2, 'secondary@example.com'); + $studentId = $this->seedStudent(10, $primary->id); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + + Sanctum::actingAs($primary); + $response = $this->postJson('/api/v1/families/attach-second/by-user', [ + 'student_id' => $studentId, + 'user_id' => $secondary->id, + 'relation' => 'secondary', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('family_guardians', [ + 'family_id' => $familyId, + 'user_id' => $secondary->id, + ]); + } + + public function test_attach_second_by_email_creates_user_and_links(): void + { + $primary = $this->seedUser(1); + $studentId = $this->seedStudent(10, $primary->id); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + + Sanctum::actingAs($primary); + $response = $this->postJson('/api/v1/families/attach-second/by-email', [ + 'student_id' => $studentId, + 'email' => 'newguardian@example.com', + 'firstname' => 'New', + 'lastname' => 'Guardian', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('users', ['email' => 'newguardian@example.com']); + $this->assertDatabaseHas('family_guardians', ['family_id' => $familyId]); + } + + public function test_set_primary_home_updates_record(): void + { + $user = $this->seedUser(1); + $studentId = $this->seedStudent(10, $user->id); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId, 0); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/families/set-primary-home', [ + 'family_id' => $familyId, + 'student_id' => $studentId, + 'is_primary_home' => true, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('family_students', [ + 'family_id' => $familyId, + 'student_id' => $studentId, + 'is_primary_home' => 1, + ]); + } + + public function test_set_guardian_flags_updates_record(): void + { + $user = $this->seedUser(1); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyGuardian($familyId, $user->id); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/families/set-guardian-flags', [ + 'family_id' => $familyId, + 'user_id' => $user->id, + 'receive_emails' => false, + 'is_primary' => true, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('family_guardians', [ + 'family_id' => $familyId, + 'user_id' => $user->id, + 'receive_emails' => 0, + 'is_primary' => 1, + ]); + } + + public function test_set_guardian_flags_requires_payload(): void + { + $user = $this->seedUser(1); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyGuardian($familyId, $user->id); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/families/set-guardian-flags', [ + 'family_id' => $familyId, + 'user_id' => $user->id, + ]); + + $response->assertStatus(422); + } + + public function test_unlink_guardian_removes_record(): void + { + $user = $this->seedUser(1); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyGuardian($familyId, $user->id); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/families/unlink-guardian', [ + 'family_id' => $familyId, + 'user_id' => $user->id, + ]); + + $response->assertOk(); + $this->assertDatabaseMissing('family_guardians', [ + 'family_id' => $familyId, + 'user_id' => $user->id, + ]); + } + + public function test_unlink_student_removes_record(): void + { + $user = $this->seedUser(1); + $studentId = $this->seedStudent(10, $user->id); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/families/unlink-student', [ + 'family_id' => $familyId, + 'student_id' => $studentId, + ]); + + $response->assertOk(); + $this->assertDatabaseMissing('family_students', [ + 'family_id' => $familyId, + 'student_id' => $studentId, + ]); + } + + public function test_import_legacy_second_parents_links_guardian(): void + { + $primary = $this->seedUser(1); + $this->seedStudent(10, $primary->id); + + DB::table('parents')->insert([ + 'firstparent_id' => $primary->id, + 'secondparent_id' => 0, + 'secondparent_email' => 'legacy@example.com', + 'secondparent_firstname' => 'Legacy', + 'secondparent_lastname' => 'Guardian', + 'secondparent_phone' => '5555555555', + 'school_year' => '2025-2026', + ]); + + Sanctum::actingAs($primary); + $response = $this->postJson('/api/v1/families/import-legacy-second-parents', []); + + $response->assertOk(); + $this->assertDatabaseHas('family_guardians', [ + 'family_id' => 1, + ]); + $this->assertDatabaseHas('users', [ + 'email' => 'legacy@example.com', + ]); + } + + public function test_requires_authentication(): void + { + $response = $this->getJson('/api/v1/families/by-student/1'); + + $response->assertStatus(401); + } + + public function test_attach_second_by_user_validation_fails(): void + { + $user = $this->seedUser(1); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/v1/families/attach-second/by-user', [ + 'user_id' => 2, + ]); + + $response->assertStatus(422); + $response->assertJsonPath('message', 'Validation failed.'); + } + + private function seedUser(int $id, string $email = 'parent@example.com'): User + { + DB::table('users')->insert([ + 'id' => $id, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => $email, + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + return User::query()->findOrFail($id); + } + + private function seedStudent(int $id, int $parentId): int + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => 'SCH1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 10, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025-2026', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + ]); + + return $id; + } + + private function seedFamily(string $code): int + { + return DB::table('families')->insertGetId([ + 'family_code' => $code, + 'household_name' => 'Family ' . $code, + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedFamilyStudent(int $familyId, int $studentId, int $primary = 1): void + { + DB::table('family_students')->insert([ + 'family_id' => $familyId, + 'student_id' => $studentId, + 'is_primary_home' => $primary, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedFamilyGuardian(int $familyId, int $userId): void + { + DB::table('family_guardians')->insert([ + 'family_id' => $familyId, + 'user_id' => $userId, + 'relation' => 'primary', + 'is_primary' => 1, + 'receive_emails' => 1, + 'receive_sms' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/Families/FamilyFinanceServiceTest.php b/tests/Unit/Services/Families/FamilyFinanceServiceTest.php new file mode 100644 index 00000000..a394c682 --- /dev/null +++ b/tests/Unit/Services/Families/FamilyFinanceServiceTest.php @@ -0,0 +1,51 @@ +insert([ + 'parent_id' => 1, + 'invoice_number' => 'INV-1', + 'status' => 'open', + 'total_amount' => 100, + 'paid_amount' => 20, + 'balance' => 80, + 'issue_date' => now()->toDateString(), + 'due_date' => now()->addDays(5)->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payments')->insert([ + 'parent_id' => 1, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 20, + 'balance' => 80, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => now()->toDateString(), + 'status' => 'completed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new FamilyFinanceService(); + $result = $service->loadFinancialsForParents([1]); + + $this->assertSame(1, $result['summary']['invoices_count']); + $this->assertSame(100.0, $result['summary']['total_amount']); + $this->assertNotEmpty($result['invoices']); + $this->assertNotEmpty($result['payments']); + } +} diff --git a/tests/Unit/Services/Families/FamilyMutationServiceTest.php b/tests/Unit/Services/Families/FamilyMutationServiceTest.php new file mode 100644 index 00000000..7c23cadf --- /dev/null +++ b/tests/Unit/Services/Families/FamilyMutationServiceTest.php @@ -0,0 +1,114 @@ +seedUser(1); + $this->seedStudent(10, 1); + + $service = new FamilyMutationService(); + $result = $service->bootstrapFamilies(); + + $this->assertSame(1, $result['families_created']); + $this->assertDatabaseHas('families', ['family_code' => 'FAM-1']); + $this->assertDatabaseHas('family_students', ['student_id' => 10]); + } + + public function test_attach_second_by_email_creates_guardian(): void + { + $this->seedUser(1); + $this->seedStudent(10, 1); + DB::table('families')->insert([ + 'family_code' => 'FAM-1', + 'household_name' => 'Family 1', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new FamilyMutationService(); + $result = $service->attachSecondByEmail(10, 'secondary@example.com', 'Second', 'Parent'); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('users', ['email' => 'secondary@example.com']); + $this->assertDatabaseHas('family_guardians', ['user_id' => $result['user_id']]); + } + + public function test_set_primary_home_updates_flag(): void + { + $this->seedUser(1); + $this->seedStudent(10, 1); + $familyId = DB::table('families')->insertGetId([ + 'family_code' => 'FAM-1', + 'household_name' => 'Family 1', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('family_students')->insert([ + 'family_id' => $familyId, + 'student_id' => 10, + 'is_primary_home' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new FamilyMutationService(); + $service->setPrimaryHome($familyId, 10, true); + + $this->assertDatabaseHas('family_students', [ + 'family_id' => $familyId, + 'student_id' => 10, + 'is_primary_home' => 1, + ]); + } + + private function seedUser(int $id): void + { + DB::table('users')->insert([ + 'id' => $id, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'parent' . $id . '@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + } + + private function seedStudent(int $id, int $parentId): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => 'SCH1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 10, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025-2026', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + ]); + } +} diff --git a/tests/Unit/Services/Families/FamilyNotificationServiceTest.php b/tests/Unit/Services/Families/FamilyNotificationServiceTest.php new file mode 100644 index 00000000..6c6bd03c --- /dev/null +++ b/tests/Unit/Services/Families/FamilyNotificationServiceTest.php @@ -0,0 +1,34 @@ +mock(EmailDispatchService::class, function ($mock) { + $mock->shouldReceive('send') + ->once() + ->andReturn(true); + }); + + $service = $this->app->make(FamilyNotificationService::class); + $result = $service->sendComposeEmail( + 'recipient@example.com', + 'Subject', + '

Body

', + null, + null, + null + ); + + $this->assertTrue($result); + } +} diff --git a/tests/Unit/Services/Families/FamilyQueryServiceTest.php b/tests/Unit/Services/Families/FamilyQueryServiceTest.php new file mode 100644 index 00000000..40da91da --- /dev/null +++ b/tests/Unit/Services/Families/FamilyQueryServiceTest.php @@ -0,0 +1,137 @@ +seedConfig(); + $studentId = $this->seedStudent(10, 1); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + $this->seedFamilyGuardian($familyId, 1); + + $service = new FamilyQueryService(new FamilyFinanceService()); + $result = $service->adminIndexData($studentId, null, '2025-2026'); + + $this->assertNotEmpty($result['families']); + $this->assertSame($studentId, $result['student']['id']); + } + + public function test_search_suggestions_returns_items(): void + { + $this->seedStudent(10, 1); + + $service = new FamilyQueryService(new FamilyFinanceService()); + $items = $service->searchSuggestions('Student', 5); + + $this->assertNotEmpty($items); + $this->assertSame('student', $items[0]['type']); + } + + public function test_family_card_returns_payload(): void + { + $this->seedConfig(); + $studentId = $this->seedStudent(10, 1); + $familyId = $this->seedFamily('FAM-1'); + $this->seedFamilyStudent($familyId, $studentId); + $this->seedFamilyGuardian($familyId, 1); + + $service = new FamilyQueryService(new FamilyFinanceService()); + $family = $service->familyCard(null, null, $familyId, '2025-2026'); + + $this->assertNotNull($family); + $this->assertSame($familyId, $family['id']); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedStudent(int $id, int $parentId): int + { + DB::table('users')->insert([ + 'id' => $parentId, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '9999999999', + 'email' => 'parent' . $parentId . '@example.com', + 'address_street' => '123 Street', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('password'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ]); + + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => 'SCH1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 10, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025-2026', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_active' => 1, + ]); + + return $id; + } + + private function seedFamily(string $code): int + { + return DB::table('families')->insertGetId([ + 'family_code' => $code, + 'household_name' => 'Family ' . $code, + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedFamilyStudent(int $familyId, int $studentId): void + { + DB::table('family_students')->insert([ + 'family_id' => $familyId, + 'student_id' => $studentId, + 'is_primary_home' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function seedFamilyGuardian(int $familyId, int $userId): void + { + DB::table('family_guardians')->insert([ + 'family_id' => $familyId, + 'user_id' => $userId, + 'relation' => 'primary', + 'is_primary' => 1, + 'receive_emails' => 1, + 'receive_sms' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +}