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,102 @@
<?php
namespace App\Http\Controllers\Api\Family;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Families\FamilyAdminCardRequest;
use App\Http\Requests\Families\FamilyAdminIndexRequest;
use App\Http\Requests\Families\FamilyAdminSearchRequest;
use App\Http\Requests\Families\FamilyComposeEmailRequest;
use App\Http\Resources\Families\FamilyResource;
use App\Http\Resources\Families\FamilySearchItemResource;
use App\Models\Configuration;
use App\Services\Families\FamilyNotificationService;
use App\Services\Families\FamilyQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class FamilyAdminController extends BaseApiController
{
public function __construct(
private FamilyQueryService $queryService,
private FamilyNotificationService $notificationService
) {
parent::__construct();
}
public function index(FamilyAdminIndexRequest $request): JsonResponse
{
$payload = $request->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.');
}
}
@@ -0,0 +1,169 @@
<?php
namespace App\Http\Controllers\Api\Family;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Families\FamiliesByStudentRequest;
use App\Http\Requests\Families\FamilyAttachSecondByEmailRequest;
use App\Http\Requests\Families\FamilyAttachSecondByUserRequest;
use App\Http\Requests\Families\FamilyBootstrapRequest;
use App\Http\Requests\Families\FamilyImportLegacyRequest;
use App\Http\Requests\Families\FamilySetGuardianFlagsRequest;
use App\Http\Requests\Families\FamilySetPrimaryHomeRequest;
use App\Http\Requests\Families\FamilyUnlinkGuardianRequest;
use App\Http\Requests\Families\FamilyUnlinkStudentRequest;
use App\Http\Requests\Families\GuardiansByFamilyRequest;
use App\Http\Resources\Families\FamilyGuardianResource;
use App\Http\Resources\Families\FamilyResource;
use App\Services\Families\FamilyMutationService;
use App\Services\Families\FamilyQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class FamilyController extends BaseApiController
{
public function __construct(
private FamilyQueryService $queryService,
private FamilyMutationService $mutationService
) {
parent::__construct();
}
public function familiesByStudent(FamiliesByStudentRequest $request, int $studentId): JsonResponse
{
$rows = $this->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.');
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamiliesByStudentRequest extends ApiFormRequest
{
protected function prepareForValidation(): void
{
parent::prepareForValidation();
$this->merge(['student_id' => $this->route('studentId')]);
}
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'student_id' => ['required', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyAdminCardRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'student_id' => ['nullable', 'integer', 'min:1'],
'guardian_id' => ['nullable', 'integer', 'min:1'],
'family_id' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyAdminIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'student_id' => ['nullable', 'integer', 'min:1'],
'guardian_id' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyAdminSearchRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'q' => ['required', 'string', 'max:100'],
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyAttachSecondByEmailRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyAttachSecondByUserRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'student_id' => ['required', 'integer', 'min:1'],
'user_id' => ['required', 'integer', 'min:1'],
'relation' => ['nullable', 'string', 'max:50'],
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyBootstrapRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyComposeEmailRequest extends ApiFormRequest
{
protected function prepareForValidation(): void
{
parent::prepareForValidation();
if (!$this->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'],
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyImportLegacyRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilySetGuardianFlagsRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->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'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilySetPrimaryHomeRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'family_id' => ['required', 'integer', 'min:1'],
'student_id' => ['required', 'integer', 'min:1'],
'is_primary_home' => ['required', 'boolean'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyUnlinkGuardianRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'family_id' => ['required', 'integer', 'min:1'],
'user_id' => ['required', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class FamilyUnlinkStudentRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'family_id' => ['required', 'integer', 'min:1'],
'student_id' => ['required', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Families;
use App\Http\Requests\ApiFormRequest;
class GuardiansByFamilyRequest extends ApiFormRequest
{
protected function prepareForValidation(): void
{
parent::prepareForValidation();
$this->merge(['family_id' => $this->route('familyId')]);
}
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'family_id' => ['required', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Families;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilyEmergencyContactResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (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,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Families;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilyGuardianResource extends JsonResource
{
public function toArray($request): array
{
return [
'user_id' => (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),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources\Families;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilyInvoiceResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (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,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources\Families;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilyPaymentResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (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'] ?? ''),
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Resources\Families;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilyResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (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'] ?? [],
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources\Families;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilySearchItemResource extends JsonResource
{
public function toArray($request): array
{
return [
'type' => (string) ($this['type'] ?? ''),
'id' => (int) ($this['id'] ?? 0),
'label' => (string) ($this['label'] ?? ''),
'sub' => (string) ($this['sub'] ?? ''),
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources\Families;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilyStudentResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this['id'] ?? 0),
'firstname' => (string) ($this['firstname'] ?? ''),
'lastname' => (string) ($this['lastname'] ?? ''),
'grade' => (string) ($this['grade'] ?? ''),
];
}
}
@@ -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;
}
}
+20
View File
@@ -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']);
+5 -2
View File
@@ -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');
BIN
View File
Binary file not shown.
@@ -0,0 +1,245 @@
<?php
namespace Tests\Feature\Api\V1\Family;
use App\Models\User;
use App\Services\Families\FamilyNotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class FamilyAdminControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_returns_family_admin_payload(): 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);
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' => '<p>Test</p>',
]);
$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(),
]);
}
}
@@ -0,0 +1,326 @@
<?php
namespace Tests\Feature\Api\V1\Family;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class FamilyControllerTest extends TestCase
{
use RefreshDatabase;
public function test_families_by_student_returns_families(): void
{
$user = $this->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(),
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Unit\Services\Families;
use App\Services\Families\FamilyFinanceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class FamilyFinanceServiceTest extends TestCase
{
use RefreshDatabase;
public function test_load_financials_for_parents_returns_summary(): void
{
DB::table('invoices')->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']);
}
}
@@ -0,0 +1,114 @@
<?php
namespace Tests\Unit\Services\Families;
use App\Services\Families\FamilyMutationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class FamilyMutationServiceTest extends TestCase
{
use RefreshDatabase;
public function test_bootstrap_creates_family(): void
{
$this->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,
]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace Tests\Unit\Services\Families;
use App\Services\Email\EmailDispatchService;
use App\Services\Families\FamilyNotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FamilyNotificationServiceTest extends TestCase
{
use RefreshDatabase;
public function test_send_compose_email_delegates_to_dispatcher(): void
{
$this->mock(EmailDispatchService::class, function ($mock) {
$mock->shouldReceive('send')
->once()
->andReturn(true);
});
$service = $this->app->make(FamilyNotificationService::class);
$result = $service->sendComposeEmail(
'recipient@example.com',
'Subject',
'<p>Body</p>',
null,
null,
null
);
$this->assertTrue($result);
}
}
@@ -0,0 +1,137 @@
<?php
namespace Tests\Unit\Services\Families;
use App\Services\Families\FamilyFinanceService;
use App\Services\Families\FamilyQueryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class FamilyQueryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_admin_index_returns_families(): 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());
$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(),
]);
}
}