diff --git a/ai/backend-context.md b/ai/backend-context.md index dd69678..34729b2 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -20,6 +20,9 @@ intentionally omitted here - update this section as entities land. - Do not write unit tests for concrete repository implementations (e.g. `Postgres*Repository`). They are exercised by e2e tests. Use cases are tested with fake repositories. + - Do not write reflection-only contract tests for repository interface + method names, parameter counts, parameter names, or type hints. PHP + interfaces already enforce those signatures; test behavior instead. - Repository methods that find records by a foreign key should accept the related entity, not a raw id (e.g. `findBySet(Set $set)`, not `findBySetId(int $setId)`). diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 1c346c6..6a63a85 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -2,16 +2,22 @@ namespace App\Controllers; +use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElementRequest; +use App\Element\UseCases\UpdateElement\UpdateElement; +use App\Element\UseCases\UpdateElement\UpdateElementRequest; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class ElementController { - public function __construct(private GetElement $getElement) - { + public function __construct( + private GetElement $getElement, + private UpdateElement $updateElement, + ) { } public function show(?int $id): JsonResponse @@ -42,15 +48,73 @@ class ElementController return new JsonResponse([ 'childElements' => $childElements, - 'element' => [ - 'id' => $element->getId(), - 'title' => $element->getTitle(), - 'description' => $element->getDescription(), - 'iconImageUrl' => $element->getIconImageUrl(), - 'richText' => $element->getRichText(), - 'pdfPath' => $element->getPdfPath(), - 'youtubeUrl' => $element->getYoutubeUrl(), - ], + 'element' => $this->buildElementPayload($element), ], 200); } + + public function update(?int $id, Request $request): JsonResponse + { + try { + $element = $this->updateElement->execute( + new UpdateElementRequest( + id: $id, + title: $this->stringInput($request, 'title'), + description: $this->stringInput($request, 'description'), + iconImageUrl: $this->stringInput( + $request, + 'iconImageUrl', + ), + richText: $this->stringInput($request, 'richText'), + pdfPath: $this->stringInput($request, 'pdfPath'), + youtubeUrl: $this->stringInput($request, 'youtubeUrl'), + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'element' => $this->buildElementPayload($element), + ], 200); + } + + private function stringInput(Request $request, string $key): ?string + { + $value = $request->input($key); + if (! is_string($value)) { + return null; + } + + return $value; + } + + /** + * @return array{ + * id: int, + * title: string, + * description: string, + * iconImageUrl: string|null, + * richText: string, + * pdfPath: string|null, + * youtubeUrl: string|null, + * } + */ + private function buildElementPayload(Element $element): array + { + return [ + 'id' => $element->getId(), + 'title' => $element->getTitle(), + 'description' => $element->getDescription(), + 'iconImageUrl' => $element->getIconImageUrl(), + 'richText' => $element->getRichText(), + 'pdfPath' => $element->getPdfPath(), + 'youtubeUrl' => $element->getYoutubeUrl(), + ]; + } } diff --git a/backend/app/Element/Element.php b/backend/app/Element/Element.php index 41fec0d..37be364 100644 --- a/backend/app/Element/Element.php +++ b/backend/app/Element/Element.php @@ -29,31 +29,61 @@ class Element return $this->title; } + public function setTitle(string $title): void + { + $this->title = $title; + } + public function getDescription(): string { return $this->description; } + public function setDescription(string $description): void + { + $this->description = $description; + } + public function getIconImageUrl(): ?string { return $this->iconImageUrl; } + public function setIconImageUrl(?string $iconImageUrl): void + { + $this->iconImageUrl = $iconImageUrl; + } + public function getRichText(): string { return $this->richText; } + public function setRichText(string $richText): void + { + $this->richText = $richText; + } + public function getPdfPath(): ?string { return $this->pdfPath; } + public function setPdfPath(?string $pdfPath): void + { + $this->pdfPath = $pdfPath; + } + public function getYoutubeUrl(): ?string { return $this->youtubeUrl; } + public function setYoutubeUrl(?string $youtubeUrl): void + { + $this->youtubeUrl = $youtubeUrl; + } + public function getSet(): Set { return $this->set; diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index 3e89628..e4fa74e 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -8,6 +8,8 @@ interface ElementRepository { public function create(CreateElementDto $dto): Element; + public function update(Element $element): Element; + public function find(int $id): ?Element; public function findRootBySet(DomainSet $set): ?Element; diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index b8c06d7..379abe0 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -38,6 +38,28 @@ class EloquentElementRepository implements ElementRepository ); } + public function update(Element $element): Element + { + $model = ElementModel::find($element->getId()); + if ($model === null) { + throw new DomainException( + "Element with id: {$element->getId()} doesnt exist" + ); + } + + $model->set_id = $element->getSet()->getId(); + $model->title = $element->getTitle(); + $model->description = $element->getDescription(); + $model->icon_image_url = $element->getIconImageUrl(); + $model->rich_text = $element->getRichText(); + $model->pdf_path = $element->getPdfPath(); + $model->youtube_url = $element->getYoutubeUrl(); + $model->parent_element_id = $element->getParentElement()?->getId(); + $model->save(); + + return $this->toDomain($model); + } + public function find(int $id): ?Element { $model = ElementModel::find($id); diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateDescription.php b/backend/app/Element/UseCases/UpdateElement/UpdateDescription.php new file mode 100644 index 0000000..6e277b9 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateDescription.php @@ -0,0 +1,39 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->description === null) { + throw new BadRequestException('description is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setDescription($request->description); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateDescriptionRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateDescriptionRequest.php new file mode 100644 index 0000000..882d4ff --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateDescriptionRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + $hasNoFields = $request->title === null + && $request->description === null + && $request->iconImageUrl === null + && $request->richText === null + && $request->pdfPath === null + && $request->youtubeUrl === null; + if ($hasNoFields) { + throw new BadRequestException('nothing to update'); + } + + if ($request->title !== null) { + $this->updateTitle->execute(new UpdateTitleRequest( + id: $request->id, + title: $request->title, + )); + } + if ($request->description !== null) { + $this->updateDescription->execute(new UpdateDescriptionRequest( + id: $request->id, + description: $request->description, + )); + } + if ($request->iconImageUrl !== null) { + $this->updateIconImageUrl->execute( + new UpdateIconImageUrlRequest( + id: $request->id, + iconImageUrl: $request->iconImageUrl, + ) + ); + } + if ($request->richText !== null) { + $this->updateRichText->execute(new UpdateRichTextRequest( + id: $request->id, + richText: $request->richText, + )); + } + if ($request->pdfPath !== null) { + $this->updatePdfPath->execute(new UpdatePdfPathRequest( + id: $request->id, + pdfPath: $request->pdfPath, + )); + } + if ($request->youtubeUrl !== null) { + $this->updateYoutubeUrl->execute(new UpdateYoutubeUrlRequest( + id: $request->id, + youtubeUrl: $request->youtubeUrl, + )); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + return $element; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php new file mode 100644 index 0000000..0b5e766 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementRequest.php @@ -0,0 +1,17 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->iconImageUrl === null) { + throw new BadRequestException('iconImageUrl is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setIconImageUrl($this->nullableString( + $request->iconImageUrl, + )); + + return $this->elementRepository->update($element); + } + + private function nullableString(string $value): ?string + { + if ($value === '') { + return null; + } + + return $value; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateIconImageUrlRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateIconImageUrlRequest.php new file mode 100644 index 0000000..d328af3 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateIconImageUrlRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->pdfPath === null) { + throw new BadRequestException('pdfPath is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setPdfPath($this->nullableString($request->pdfPath)); + + return $this->elementRepository->update($element); + } + + private function nullableString(string $value): ?string + { + if ($value === '') { + return null; + } + + return $value; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdatePdfPathRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdatePdfPathRequest.php new file mode 100644 index 0000000..c48c47d --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdatePdfPathRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->richText === null) { + throw new BadRequestException('richText is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setRichText($request->richText); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateRichTextRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateRichTextRequest.php new file mode 100644 index 0000000..fe29972 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateRichTextRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->title === null || trim($request->title) === '') { + throw new BadRequestException('title is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setTitle(trim($request->title)); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateTitleRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateTitleRequest.php new file mode 100644 index 0000000..5a13ea9 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateTitleRequest.php @@ -0,0 +1,12 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ($request->youtubeUrl === null) { + throw new BadRequestException('youtubeUrl is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $element->setYoutubeUrl($this->nullableString( + $request->youtubeUrl, + )); + + return $this->elementRepository->update($element); + } + + private function nullableString(string $value): ?string + { + if ($value === '') { + return null; + } + + return $value; + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateYoutubeUrlRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateYoutubeUrlRequest.php new file mode 100644 index 0000000..3a7e06a --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateYoutubeUrlRequest.php @@ -0,0 +1,12 @@ + ['api/*'], - 'allowed_methods' => ['GET', 'POST', 'OPTIONS'], + 'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS'], 'allowed_origins' => $allowedOrigins, 'allowed_origins_patterns' => [], 'allowed_headers' => [ diff --git a/backend/routes/api.php b/backend/routes/api.php index f32b2ca..9d253d0 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -12,3 +12,5 @@ Route::get('/me', [AuthController::class, 'me']) ->middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); Route::get('/elements/{id}', [ElementController::class, 'show']); +Route::patch('/elements/{id}', [ElementController::class, 'update']) + ->middleware(AuthMiddleware::class); diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index 132a526..9c08e1c 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -33,6 +33,14 @@ class FakeElementRepository implements ElementRepository return $element; } + public function update(Element $element): Element + { + $updatedElement = $this->cloneElement($element); + $this->elementsById[$updatedElement->getId()] = $updatedElement; + + return $this->cloneElement($updatedElement); + } + public function find(int $id): ?Element { if (! isset($this->elementsById[$id])) { diff --git a/backend/tests/Feature/CorsTest.php b/backend/tests/Feature/CorsTest.php index b5ab81d..52273e4 100644 --- a/backend/tests/Feature/CorsTest.php +++ b/backend/tests/Feature/CorsTest.php @@ -21,4 +21,23 @@ class CorsTest extends TestCase ); $response->assertHeader('Access-Control-Allow-Credentials', 'true'); } + + public function testAllowsPatchPreflight(): void + { + $response = $this->withHeaders([ + 'Origin' => 'http://localhost:5173', + 'Access-Control-Request-Method' => 'PATCH', + 'Access-Control-Request-Headers' => 'content-type', + ])->options('/api/elements/1'); + + $response->assertNoContent(); + $response->assertHeader( + 'Access-Control-Allow-Origin', + 'http://localhost:5173' + ); + $this->assertStringContainsString( + 'PATCH', + $response->headers->get('Access-Control-Allow-Methods') + ); + } } diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 33725c8..2ff9089 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -2,10 +2,17 @@ namespace Tests\Feature; +use App\Auth\CreateSessionDto; +use App\Auth\SessionRepository; use App\Element\CreateElementDto; use App\Element\ElementRepository; use App\Set\CreateSetDto; use App\Set\SetRepository; +use App\Shared\ValueObject\EmailAddress; +use App\User\CreateUserDto; +use App\User\UserRepository; +use DateTimeImmutable; +use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -92,4 +99,116 @@ class ElementsEndpointTest extends TestCase 'error' => 'Element not found', ]); } + + public function testUpdateRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + richText: '

A structured path for growth

', + pdfPath: '/assets/pdfs/baderech.pdf', + youtubeUrl: null, + parentElement: null, + )); + + $response = $this->patchJson("/api/elements/{$element->getId()}", [ + 'title' => 'Updated title', + 'description' => 'Updated description', + 'iconImageUrl' => '/assets/updated-icon.png', + 'richText' => '

Updated rich text

', + 'pdfPath' => '/assets/pdfs/updated.pdf', + 'youtubeUrl' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ]); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedUpdateReturnsElementPayload(): void + { + $sampleYoutubeUrl = 'https://www.youtube.com/watch?v=' + . 'dQw4w9WgXcQ'; + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + richText: '

A structured path for growth

', + pdfPath: '/assets/pdfs/baderech.pdf', + youtubeUrl: null, + parentElement: null, + )); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->patchJson("/api/elements/{$element->getId()}", [ + 'title' => 'Updated title', + 'description' => 'Updated description', + 'iconImageUrl' => '/assets/updated-icon.png', + 'richText' => '

Updated rich text

', + 'pdfPath' => '/assets/pdfs/updated.pdf', + 'youtubeUrl' => $sampleYoutubeUrl, + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'element' => [ + 'id' => $element->getId(), + 'title' => 'Updated title', + 'description' => 'Updated description', + 'iconImageUrl' => '/assets/updated-icon.png', + 'richText' => '

Updated rich text

', + 'pdfPath' => '/assets/pdfs/updated.pdf', + 'youtubeUrl' => $sampleYoutubeUrl, + ], + ]); + $updatedElement = $elementRepository->find($element->getId()); + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + '

Updated rich text

', + $updatedElement->getRichText(), + ); + } + + private function createSession(string $token): void + { + $userRepository = app(UserRepository::class); + $sessionRepository = app(SessionRepository::class); + $user = $userRepository->create(new CreateUserDto( + email: new EmailAddress('admin@example.com'), + passwordHash: 'password-hash', + )); + $now = new DateTimeImmutable( + '2026-05-01T00:00:00', + new DateTimeZone('UTC'), + ); + $sessionRepository->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $now, + expiresAt: new DateTimeImmutable( + '2099-01-01T00:00:00', + new DateTimeZone('UTC'), + ), + )); + } } diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index d06c0f7..99bfa3e 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -6,6 +6,13 @@ use App\Controllers\ElementController; use App\Element\CreateElementDto; use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; +use App\Element\UseCases\UpdateElement\UpdateDescription; +use App\Element\UseCases\UpdateElement\UpdateElement; +use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; +use App\Element\UseCases\UpdateElement\UpdatePdfPath; +use App\Element\UseCases\UpdateElement\UpdateRichText; +use App\Element\UseCases\UpdateElement\UpdateTitle; +use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl; use App\Set\Set as DomainSet; use Tests\Fakes\FakeElementRepository; use Tests\TestCase; @@ -20,7 +27,16 @@ class ElementControllerTest extends TestCase { $this->elementRepo = new FakeElementRepository(); $getElement = new GetElement($this->elementRepo); - $this->controller = new ElementController($getElement); + $updateElement = new UpdateElement( + new UpdateTitle($this->elementRepo), + new UpdateDescription($this->elementRepo), + new UpdateIconImageUrl($this->elementRepo), + new UpdateRichText($this->elementRepo), + new UpdatePdfPath($this->elementRepo), + new UpdateYoutubeUrl($this->elementRepo), + $this->elementRepo, + ); + $this->controller = new ElementController($getElement, $updateElement); } public function testShowReturnsElementPayload(): void diff --git a/backend/tests/Unit/Element/ElementTest.php b/backend/tests/Unit/Element/ElementTest.php index 16c08a7..89e9c84 100644 --- a/backend/tests/Unit/Element/ElementTest.php +++ b/backend/tests/Unit/Element/ElementTest.php @@ -68,4 +68,39 @@ class ElementTest extends TestCase $this->assertNull($rootElement->getYoutubeUrl()); $this->assertNull($rootElement->getParentElement()); } + + public function testUpdatesEditableFields(): void + { + $set = new DomainSet( + id: 1, + name: 'Daily learning', + description: 'Daily learning description', + iconImageUrl: '/assets/daily-learning-icon.svg', + ); + $element = new Element( + id: 1, + title: 'Original', + description: 'Original description', + iconImageUrl: '/assets/original.svg', + richText: '

Original

', + pdfPath: '/assets/pdfs/original.pdf', + youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU', + set: $set, + parentElement: null, + ); + + $element->setTitle('Updated'); + $element->setDescription('Updated description'); + $element->setIconImageUrl(null); + $element->setRichText('

Updated

'); + $element->setPdfPath(null); + $element->setYoutubeUrl(null); + + $this->assertSame('Updated', $element->getTitle()); + $this->assertSame('Updated description', $element->getDescription()); + $this->assertNull($element->getIconImageUrl()); + $this->assertSame('

Updated

', $element->getRichText()); + $this->assertNull($element->getPdfPath()); + $this->assertNull($element->getYoutubeUrl()); + } } diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php new file mode 100644 index 0000000..7c27c9c --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php @@ -0,0 +1,175 @@ +elementRepo = new FakeElementRepository(); + } + + public function testUpdateTitleUpdatesOnlyTitle(): void + { + $element = $this->createFilledElement(); + $updateTitle = new UpdateTitle($this->elementRepo); + + $updatedElement = $updateTitle->execute(new UpdateTitleRequest( + id: $element->getId(), + title: 'Updated title', + )); + + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + $element->getDescription(), + $updatedElement->getDescription(), + ); + $this->assertSame( + $element->getRichText(), + $updatedElement->getRichText(), + ); + } + + public function testUpdateTitleRejectsBlankTitle(): void + { + $this->createFilledElement(); + $updateTitle = new UpdateTitle($this->elementRepo); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('title is required'); + + $updateTitle->execute(new UpdateTitleRequest(id: 1, title: '')); + } + + public function testUpdateDescriptionUpdatesOnlyDescription(): void + { + $element = $this->createFilledElement(); + $updateDescription = new UpdateDescription($this->elementRepo); + + $updatedElement = $updateDescription->execute( + new UpdateDescriptionRequest( + id: $element->getId(), + description: 'Updated description', + ) + ); + + $this->assertSame( + 'Updated description', + $updatedElement->getDescription(), + ); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + $this->assertSame( + $element->getYoutubeUrl(), + $updatedElement->getYoutubeUrl(), + ); + } + + public function testUpdateIconImageUrlClearsEmptyString(): void + { + $element = $this->createFilledElement(); + $updateIconImageUrl = new UpdateIconImageUrl($this->elementRepo); + + $updatedElement = $updateIconImageUrl->execute( + new UpdateIconImageUrlRequest( + id: $element->getId(), + iconImageUrl: '', + ) + ); + + $this->assertNull($updatedElement->getIconImageUrl()); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + } + + public function testUpdateRichTextUpdatesOnlyRichText(): void + { + $element = $this->createFilledElement(); + $updateRichText = new UpdateRichText($this->elementRepo); + + $updatedElement = $updateRichText->execute( + new UpdateRichTextRequest( + id: $element->getId(), + richText: '

Updated rich text

', + ) + ); + + $this->assertSame( + '

Updated rich text

', + $updatedElement->getRichText(), + ); + $this->assertSame( + $element->getDescription(), + $updatedElement->getDescription(), + ); + } + + public function testUpdatePdfPathClearsEmptyString(): void + { + $element = $this->createFilledElement(); + $updatePdfPath = new UpdatePdfPath($this->elementRepo); + + $updatedElement = $updatePdfPath->execute( + new UpdatePdfPathRequest(id: $element->getId(), pdfPath: '') + ); + + $this->assertNull($updatedElement->getPdfPath()); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + } + + public function testUpdateYoutubeUrlClearsEmptyString(): void + { + $element = $this->createFilledElement(); + $updateYoutubeUrl = new UpdateYoutubeUrl($this->elementRepo); + + $updatedElement = $updateYoutubeUrl->execute( + new UpdateYoutubeUrlRequest( + id: $element->getId(), + youtubeUrl: '', + ) + ); + + $this->assertNull($updatedElement->getYoutubeUrl()); + $this->assertSame($element->getTitle(), $updatedElement->getTitle()); + } + + private function createFilledElement(): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: 'Original title', + description: 'Original description', + iconImageUrl: '/assets/original-icon.png', + richText: '

Original rich text

', + pdfPath: '/assets/pdfs/original.pdf', + youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU', + parentElement: null, + )); + } +} diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementTest.php new file mode 100644 index 0000000..0ce0d88 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateElementTest.php @@ -0,0 +1,270 @@ +elementRepo = new FakeElementRepository(); + $this->updateElement = new UpdateElement( + new UpdateTitle($this->elementRepo), + new UpdateDescription($this->elementRepo), + new UpdateIconImageUrl($this->elementRepo), + new UpdateRichText($this->elementRepo), + new UpdatePdfPath($this->elementRepo), + new UpdateYoutubeUrl($this->elementRepo), + $this->elementRepo, + ); + } + + public function testUpdatesContentFields(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Original title', + 'Original description', + '/assets/original-icon.png', + '

Original rich text

', + '/assets/pdfs/original.pdf', + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + null, + ); + + $updatedElement = $this->updateElement->execute( + new UpdateElementRequest( + id: $element->getId(), + title: 'Updated title', + description: 'Updated description', + iconImageUrl: '/assets/updated-icon.png', + richText: '

Updated rich text

', + pdfPath: '/assets/pdfs/updated.pdf', + youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ) + ); + + $this->assertSame($element->getId(), $updatedElement->getId()); + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + 'Updated description', + $updatedElement->getDescription(), + ); + $this->assertSame( + '/assets/updated-icon.png', + $updatedElement->getIconImageUrl(), + ); + $this->assertSame( + '

Updated rich text

', + $updatedElement->getRichText(), + ); + $this->assertSame( + '/assets/pdfs/updated.pdf', + $updatedElement->getPdfPath(), + ); + $this->assertSame( + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + $updatedElement->getYoutubeUrl(), + ); + + $persistedElement = $this->elementRepo->find($element->getId()); + $this->assertInstanceOf(Element::class, $persistedElement); + $this->assertSame('Updated title', $persistedElement->getTitle()); + } + + public function testConvertsEmptyNullableFieldsToNull(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Original title', + 'Original description', + '/assets/original-icon.png', + '

Original rich text

', + '/assets/pdfs/original.pdf', + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + null, + ); + + $updatedElement = $this->updateElement->execute( + new UpdateElementRequest( + id: $element->getId(), + title: 'Updated title', + description: 'Updated description', + iconImageUrl: '', + richText: '

Updated rich text

', + pdfPath: '', + youtubeUrl: '', + ) + ); + + $this->assertNull($updatedElement->getIconImageUrl()); + $this->assertNull($updatedElement->getPdfPath()); + $this->assertNull($updatedElement->getYoutubeUrl()); + } + + public function testUpdatesOnlySuppliedFields(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Original title', + 'Original description', + '/assets/original-icon.png', + '

Original rich text

', + '/assets/pdfs/original.pdf', + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + null, + ); + + $updatedElement = $this->updateElement->execute( + new UpdateElementRequest( + id: $element->getId(), + title: 'Updated title', + description: null, + iconImageUrl: null, + richText: null, + pdfPath: null, + youtubeUrl: null, + ) + ); + + $this->assertSame('Updated title', $updatedElement->getTitle()); + $this->assertSame( + 'Original description', + $updatedElement->getDescription(), + ); + $this->assertSame( + '/assets/original-icon.png', + $updatedElement->getIconImageUrl(), + ); + $this->assertSame( + '

Original rich text

', + $updatedElement->getRichText(), + ); + $this->assertSame( + '/assets/pdfs/original.pdf', + $updatedElement->getPdfPath(), + ); + $this->assertSame( + 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', + $updatedElement->getYoutubeUrl(), + ); + } + + public function testThrowsWhenIdMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('id is required'); + + $this->updateElement->execute(new UpdateElementRequest( + id: null, + title: 'Updated title', + description: 'Updated description', + iconImageUrl: null, + richText: '

Updated rich text

', + pdfPath: null, + youtubeUrl: null, + )); + } + + public function testThrowsWhenNothingProvided(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('nothing to update'); + + $this->updateElement->execute(new UpdateElementRequest( + id: 1, + title: null, + description: null, + iconImageUrl: null, + richText: null, + pdfPath: null, + youtubeUrl: null, + )); + } + + public function testThrowsWhenTitleIsBlank(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('title is required'); + + $this->updateElement->execute(new UpdateElementRequest( + id: 1, + title: '', + description: 'Updated description', + iconImageUrl: null, + richText: '

Updated rich text

', + pdfPath: null, + youtubeUrl: null, + )); + } + + public function testThrowsWhenElementDoesNotExist(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Element not found'); + + $this->updateElement->execute(new UpdateElementRequest( + id: 999, + title: 'Updated title', + description: 'Updated description', + iconImageUrl: null, + richText: '

Updated rich text

', + pdfPath: null, + youtubeUrl: null, + )); + } + + private function createSet(int $id, string $name): DomainSet + { + return new DomainSet( + id: $id, + name: $name, + description: "$name description", + iconImageUrl: '/assets/baderech-icon.png', + ); + } + + private function createElement( + DomainSet $set, + string $title, + string $description, + ?string $iconImageUrl, + string $richText, + ?string $pdfPath, + ?string $youtubeUrl, + ?Element $parentElement, + ): Element { + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: $title, + description: $description, + iconImageUrl: $iconImageUrl, + richText: $richText, + pdfPath: $pdfPath, + youtubeUrl: $youtubeUrl, + parentElement: $parentElement, + )); + } +} diff --git a/frontend/rabbi_gerzi/cypress.config.mjs b/frontend/rabbi_gerzi/cypress.config.mjs deleted file mode 100644 index d215a17..0000000 --- a/frontend/rabbi_gerzi/cypress.config.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'cypress' -import createBundler from '@bahmutov/cypress-esbuild-preprocessor' - -export default defineConfig({ - e2e: { - baseUrl: 'http://localhost:5173', - specPattern: 'cypress/e2e/**/*.cy.ts', - supportFile: 'cypress/support/e2e.ts', - fixturesFolder: false, - video: false, - screenshotOnRunFailure: false, - setupNodeEvents(onEvent) { - onEvent('file:preprocessor', createBundler()) - }, - }, -}) diff --git a/frontend/rabbi_gerzi/cypress.config.ts b/frontend/rabbi_gerzi/cypress.config.ts new file mode 100644 index 0000000..82a9470 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress.config.ts @@ -0,0 +1,97 @@ +import { execSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'cypress' +import createBundler from '@bahmutov/cypress-esbuild-preprocessor' +import { Client } from 'pg' + +const configDirectory = dirname(fileURLToPath(import.meta.url)) +const backendDirectory = resolve(configDirectory, '../../backend') +const socketDirectory = resolve(configDirectory, '../../.postgres') +const appDatabase = 'postgres' +const templateDatabase = 'postgres_cypress_template' + +function controlClient(): Client { + return new Client({ + host: socketDirectory, + database: 'template1', + user: 'postgres', + }) +} + +async function buildTemplate(): Promise { + const setupClient = controlClient() + await setupClient.connect() + await setupClient.query( + `UPDATE pg_database SET datistemplate = false + WHERE datname = $1`, + [templateDatabase], + ) + await setupClient.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [templateDatabase], + ) + await setupClient.query(`DROP DATABASE IF EXISTS ${templateDatabase}`) + await setupClient.query(`CREATE DATABASE ${templateDatabase}`) + await setupClient.end() + + const seedEnvironment = { ...process.env, DB_DATABASE: templateDatabase } + execSync('php artisan migrate:fresh --force', { + cwd: backendDirectory, + stdio: 'pipe', + env: seedEnvironment, + }) + execSync('php artisan db:seed --force', { + cwd: backendDirectory, + stdio: 'pipe', + env: seedEnvironment, + }) + + const markClient = controlClient() + await markClient.connect() + await markClient.query( + `UPDATE pg_database SET datistemplate = true + WHERE datname = $1`, + [templateDatabase], + ) + await markClient.end() +} + +async function resetFromTemplate(): Promise { + const client = controlClient() + await client.connect() + await client.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname IN ($1, $2) AND pid <> pg_backend_pid()`, + [appDatabase, templateDatabase], + ) + await client.query(`DROP DATABASE IF EXISTS ${appDatabase}`) + await client.query( + `CREATE DATABASE ${appDatabase} + TEMPLATE ${templateDatabase}`, + ) + await client.end() + + return null +} + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:5173', + specPattern: 'cypress/e2e/**/*.cy.ts', + supportFile: 'cypress/support/e2e.ts', + fixturesFolder: false, + video: false, + screenshotOnRunFailure: false, + setupNodeEvents(onEvent) { + onEvent('before:run', async function () { + await buildTemplate() + }) + onEvent('file:preprocessor', createBundler()) + onEvent('task', { + 'db:reset': resetFromTemplate, + }) + }, + }, +}) diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts new file mode 100644 index 0000000..8678ec2 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -0,0 +1,123 @@ +function loginAsAdmin(): void { + cy.visit('/login') + + cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test') + cy.get('[data-cy="login-password"]').type('password123!@#') + cy.get('[data-cy="login-submit"]').click() + + cy.url().should('eq', Cypress.config().baseUrl + '/') + cy.getCookie('auth_token').should('exist') +} + +function fillElementForm( + title: string, + description: string, + iconImageUrl: string, + richText: string, + pdfPath: string, + youtubeUrl: string, +): void { + cy.get('[data-cy="admin-element-title"]').clear().type(title) + cy.get('[data-cy="admin-element-description"]').clear().type(description) + cy.get('[data-cy="admin-element-icon-image-url"]') + .clear() + .type(iconImageUrl) + cy.get('[data-cy="admin-element-rich-text"]').clear() + if (richText !== '') { + cy.get('[data-cy="admin-element-rich-text"]').type(richText, { + parseSpecialCharSequences: false, + }) + } + cy.get('[data-cy="admin-element-pdf-path"]').clear() + if (pdfPath !== '') { + cy.get('[data-cy="admin-element-pdf-path"]').type(pdfPath) + } + cy.get('[data-cy="admin-element-youtube-url"]').clear() + if (youtubeUrl !== '') { + cy.get('[data-cy="admin-element-youtube-url"]').type(youtubeUrl) + } +} + +describe('admin element editing', () => { + it('does not show the edit link to logged-out users', () => { + cy.visit('/element/1') + + cy.contains('header.site-header a', 'Edit Element').should('not.exist') + }) + + it('shows the edit link on public element pages to logged-in users', () => { + loginAsAdmin() + + cy.visit('/element/1') + + cy.contains('header.site-header a', 'Edit Element') + .should('be.visible') + .and('have.attr', 'href', '/admin/element/1') + .click() + cy.location('pathname').should('eq', '/admin/element/1') + }) + + it('redirects logged-out admin element visits to login', () => { + cy.visit('/admin/element/1') + + cy.location('pathname').should('eq', '/login') + }) + + it('shows the view link on admin element pages', () => { + loginAsAdmin() + + cy.visit('/admin/element/1') + + cy.contains('header.site-header a', 'View Element') + .should('be.visible') + .and('have.attr', 'href', '/element/1') + cy.contains('header.site-header a', 'Edit Element').should('not.exist') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', 'Baderech HaAvodah') + }) + + it('saves element edits and restores the seed content through the UI', () => { + const originalTitle = 'Baderech HaAvodah' + const originalDescription = 'a structured path for inner and outer growth' + const originalIconImageUrl = '/assets/baderech-haavodah-icon.png' + const updatedTitle = 'Baderech HaAvodah Updated' + const updatedDescription = 'Updated admin description' + const updatedRichText = '

Updated admin rich text

' + + loginAsAdmin() + cy.visit('/admin/element/1') + + fillElementForm( + updatedTitle, + updatedDescription, + originalIconImageUrl, + updatedRichText, + '', + '', + ) + cy.get('[data-cy="admin-element-save"]').click() + + cy.get('[data-cy="admin-element-status"]') + .should('be.visible') + .and('contain.text', 'Saved') + cy.contains('header.site-header a', 'View Element').click() + cy.location('pathname').should('eq', '/element/1') + cy.contains('h1', updatedTitle).should('be.visible') + cy.get('[data-cy="element-rich-text"]') + .should('contain.text', 'Updated admin rich text') + + cy.visit('/admin/element/1') + fillElementForm( + originalTitle, + originalDescription, + originalIconImageUrl, + '', + '', + '', + ) + cy.get('[data-cy="admin-element-save"]').click() + cy.get('[data-cy="admin-element-status"]') + .should('be.visible') + .and('contain.text', 'Saved') + }) +}) diff --git a/frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts new file mode 100644 index 0000000..dd8ec44 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts @@ -0,0 +1,41 @@ +function loginAsAdmin(): void { + cy.visit('/login') + + cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test') + cy.get('[data-cy="login-password"]').type('password123!@#') + cy.get('[data-cy="login-submit"]').click() + + cy.url().should('eq', Cypress.config().baseUrl + '/') + cy.getCookie('auth_token').should('exist') +} + +describe('cypress database reset', () => { + it('can dirty element seed data', () => { + loginAsAdmin() + + cy.visit('/admin/element/1') + cy.get('[data-cy="admin-element-title"]') + .clear() + .type('Dirty Cypress Element') + cy.get('[data-cy="admin-element-description"]') + .clear() + .type('Dirty Cypress description') + cy.get('[data-cy="admin-element-rich-text"]') + .clear() + .type('

Dirty Cypress rich text

', { + parseSpecialCharSequences: false, + }) + cy.get('[data-cy="admin-element-save"]').click() + + cy.get('[data-cy="admin-element-status"]') + .should('be.visible') + .and('contain.text', 'Saved') + }) + + it('starts the next test from seeded element data', () => { + cy.visit('/element/1') + + cy.contains('h1', 'Baderech HaAvodah').should('be.visible') + cy.get('[data-cy="element-rich-text"]').should('not.exist') + }) +}) diff --git a/frontend/rabbi_gerzi/cypress/support/commands.ts b/frontend/rabbi_gerzi/cypress/support/commands.ts new file mode 100644 index 0000000..65696c1 --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/support/commands.ts @@ -0,0 +1,16 @@ +/// +/* eslint-disable @typescript-eslint/no-namespace */ + +declare global { + namespace Cypress { + interface Chainable { + resetDb(): Chainable + } + } +} + +Cypress.Commands.add('resetDb', function () { + return cy.task('db:reset') +}) + +export {} diff --git a/frontend/rabbi_gerzi/cypress/support/e2e.ts b/frontend/rabbi_gerzi/cypress/support/e2e.ts index 9b15a88..c8f94e6 100644 --- a/frontend/rabbi_gerzi/cypress/support/e2e.ts +++ b/frontend/rabbi_gerzi/cypress/support/e2e.ts @@ -1,3 +1,9 @@ -// Loaded automatically before every spec file in cypress/e2e/. -// Intentionally minimal - add shared commands here when needed. +import './commands' + +beforeEach(() => { + cy.resetDb() + cy.clearCookies() + cy.clearLocalStorage() +}) + export {} diff --git a/frontend/rabbi_gerzi/cypress/tsconfig.json b/frontend/rabbi_gerzi/cypress/tsconfig.json new file mode 100644 index 0000000..ddfb30e --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.app.json", + "include": ["./**/*.ts"], + "compilerOptions": { + "types": ["cypress", "node"], + "isolatedModules": false, + "noUncheckedIndexedAccess": false, + "noEmit": false, + "allowImportingTsExtensions": false, + "ignoreDeprecations": "6.0" + } +} diff --git a/frontend/rabbi_gerzi/package-lock.json b/frontend/rabbi_gerzi/package-lock.json index b1578ed..cd5beb4 100644 --- a/frontend/rabbi_gerzi/package-lock.json +++ b/frontend/rabbi_gerzi/package-lock.json @@ -16,6 +16,7 @@ "@bahmutov/cypress-esbuild-preprocessor": "^2.2.8", "@tsconfig/node24": "^24.0.4", "@types/node": "^24.12.2", + "@types/pg": "^8.20.0", "@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue-jsx": "^5.1.5", "@vue/eslint-config-typescript": "^14.7.0", @@ -30,6 +31,7 @@ "npm-run-all2": "^8.0.4", "oxfmt": "^0.45.0", "oxlint": "~1.60.0", + "pg": "^8.21.0", "typescript": "~6.0.0", "vite": "^8.0.8", "vite-plugin-vue-devtools": "^8.1.1", @@ -2344,6 +2346,18 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -6495,6 +6509,103 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6611,6 +6722,49 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7101,6 +7255,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -8226,6 +8390,16 @@ "node": ">=12" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/rabbi_gerzi/package.json b/frontend/rabbi_gerzi/package.json index 18bec3a..0506b7b 100644 --- a/frontend/rabbi_gerzi/package.json +++ b/frontend/rabbi_gerzi/package.json @@ -25,6 +25,7 @@ "@bahmutov/cypress-esbuild-preprocessor": "^2.2.8", "@tsconfig/node24": "^24.0.4", "@types/node": "^24.12.2", + "@types/pg": "^8.20.0", "@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue-jsx": "^5.1.5", "@vue/eslint-config-typescript": "^14.7.0", @@ -39,6 +40,7 @@ "npm-run-all2": "^8.0.4", "oxfmt": "^0.45.0", "oxlint": "~1.60.0", + "pg": "^8.21.0", "typescript": "~6.0.0", "vite": "^8.0.8", "vite-plugin-vue-devtools": "^8.1.1", diff --git a/frontend/rabbi_gerzi/src/components/SiteHeader.vue b/frontend/rabbi_gerzi/src/components/SiteHeader.vue index 65497a5..0ecdc90 100644 --- a/frontend/rabbi_gerzi/src/components/SiteHeader.vue +++ b/frontend/rabbi_gerzi/src/components/SiteHeader.vue @@ -1,9 +1,15 @@