add family logic
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user