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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user