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'] ?? ''),
];
}
}