Skip to content

Commit ded5486

Browse files
authored
feat: Contact Form (Feature 022) — backend & frontend (#4132)
1 parent fec5f7b commit ded5486

85 files changed

Lines changed: 4609 additions & 75 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Controllers\Contact;
10+
11+
use App\Http\Requests\Contact\ContactMessagesListRequest;
12+
use App\Http\Requests\Contact\DeleteContactMessageRequest;
13+
use App\Http\Requests\Contact\StoreContactMessageRequest;
14+
use App\Http\Requests\Contact\UpdateContactMessageRequest;
15+
use App\Http\Resources\Collections\ContactMessageCollectionResource;
16+
use App\Http\Resources\GalleryConfigs\ContactConfig;
17+
use App\Http\Resources\Models\ContactMessageResource;
18+
use App\Models\ContactMessage;
19+
use Illuminate\Http\Response;
20+
use Illuminate\Routing\Controller;
21+
22+
class ContactController extends Controller
23+
{
24+
/**
25+
* Return the contact form configuration values.
26+
*
27+
* @return ContactConfig
28+
*/
29+
public function init(): ContactConfig
30+
{
31+
return new ContactConfig();
32+
}
33+
34+
/**
35+
* Store a new contact message from a public visitor.
36+
*
37+
* @param StoreContactMessageRequest $request
38+
*
39+
* @return array{success:bool,message:string}
40+
*/
41+
public function store(StoreContactMessageRequest $request): array
42+
{
43+
// Validate security answer if configured
44+
$security_question = $request->configs()->getValueAsString('contact_form_security_question');
45+
$security_answer = $request->configs()->getValueAsString('contact_form_security_answer');
46+
if ($security_question !== '' && $security_answer !== '') {
47+
if (strcasecmp(trim($request->securityAnswer()), trim($security_answer)) !== 0) {
48+
abort(422, 'Incorrect answer to the security question.');
49+
}
50+
}
51+
52+
// Validate consent if configured
53+
$is_consent_required = $request->configs()->getValueAsBool('contact_form_custom_consent_required');
54+
if ($is_consent_required && !$request->consentAgreed()) {
55+
abort(422, 'You must agree to the privacy policy.');
56+
}
57+
58+
ContactMessage::create([
59+
'name' => $request->senderName(),
60+
'email' => $request->senderEmail(),
61+
'message' => $request->senderMessage(),
62+
'ip_address' => $request->ip(),
63+
'user_agent' => $request->userAgent(),
64+
]);
65+
66+
return ['success' => true, 'message' => 'Thank you for your message. We will get back to you soon.'];
67+
}
68+
69+
/**
70+
* List all contact messages (admin only).
71+
*
72+
* @param ContactMessagesListRequest $request
73+
*
74+
* @return ContactMessageCollectionResource
75+
*/
76+
public function index(ContactMessagesListRequest $request): ContactMessageCollectionResource
77+
{
78+
$per_page = min((int) $request->query('per_page', 20), 100);
79+
$page = max((int) $request->query('page', 1), 1);
80+
$search = $request->query('search', '');
81+
$is_read_filter = $request->query('is_read', null);
82+
83+
$query = ContactMessage::query()->orderBy('created_at', 'desc');
84+
85+
if (is_string($search) && $search !== '') {
86+
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $search);
87+
$query->where(function ($q) use ($escaped): void {
88+
$q->where('name', 'like', '%' . $escaped . '%')
89+
->orWhere('email', 'like', '%' . $escaped . '%')
90+
->orWhere('message', 'like', '%' . $escaped . '%');
91+
});
92+
}
93+
94+
if ($is_read_filter !== null) {
95+
$query->where('is_read', filter_var($is_read_filter, FILTER_VALIDATE_BOOLEAN));
96+
}
97+
98+
$total = $query->count();
99+
$messages = $query->offset(($page - 1) * $per_page)->limit($per_page)->get();
100+
101+
return new ContactMessageCollectionResource(
102+
$messages->map(fn (ContactMessage $m) => new ContactMessageResource($m)),
103+
$total,
104+
$per_page,
105+
$page,
106+
);
107+
}
108+
109+
/**
110+
* Update a contact message (mark as read/unread).
111+
*
112+
* @param UpdateContactMessageRequest $request
113+
*
114+
* @return ContactMessageResource
115+
*/
116+
public function update(UpdateContactMessageRequest $request): ContactMessageResource
117+
{
118+
$message = $request->contactMessage();
119+
$message->is_read = $request->isRead();
120+
$message->save();
121+
122+
return new ContactMessageResource($message);
123+
}
124+
125+
/**
126+
* Delete a contact message.
127+
*
128+
* @param DeleteContactMessageRequest $request
129+
*
130+
* @return Response
131+
*/
132+
public function destroy(DeleteContactMessageRequest $request): Response
133+
{
134+
$request->contactMessage()->delete();
135+
136+
return response()->noContent();
137+
}
138+
}

app/Http/Middleware/ConfigIntegrity.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ class ConfigIntegrity
101101
'best_pictures_count',
102102
'enable_my_best_pictures',
103103
'my_best_pictures_count',
104+
'contact_form_enabled',
105+
'contact_form_security_question',
106+
'contact_form_security_answer',
107+
'contact_form_custom_consent_required',
104108
];
105109

106110
public const PRO_FIELDS = [
@@ -118,9 +122,18 @@ class ConfigIntegrity
118122
'webshop_auto_fulfill_enabled',
119123
'webshop_manual_fulfill_enabled',
120124
'photos_star_visibility',
125+
'contact_form_custom_consent_text',
126+
'contact_form_custom_privacy_url',
127+
'contact_form_custom_submit_button_text',
121128
'album_enhanced_display_enabled',
122129
'album_header_size',
123130
'album_header_landing_title_enabled',
131+
'contact_form_header',
132+
'contact_form_headline',
133+
'contact_form_contact_method',
134+
'contact_form_message_label',
135+
'contact_form_message_answer',
136+
'contact_form_thank_you_message',
124137
];
125138

126139
/**
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Contact;
10+
11+
use App\Http\Requests\AbstractEmptyRequest;
12+
use App\Models\User;
13+
use App\Policies\UserPolicy;
14+
use Illuminate\Support\Facades\Gate;
15+
16+
class ContactMessagesListRequest extends AbstractEmptyRequest
17+
{
18+
/**
19+
* {@inheritDoc}
20+
*/
21+
public function authorize(): bool
22+
{
23+
return Gate::check(UserPolicy::CAN_CREATE_OR_EDIT_OR_DELETE, [User::class]);
24+
}
25+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Contact;
10+
11+
use App\Http\Requests\BaseApiRequest;
12+
use App\Models\ContactMessage;
13+
use App\Models\User;
14+
use App\Policies\UserPolicy;
15+
use Illuminate\Support\Facades\Gate;
16+
17+
class DeleteContactMessageRequest extends BaseApiRequest
18+
{
19+
protected ContactMessage $contact_message;
20+
21+
/**
22+
* {@inheritDoc}
23+
*/
24+
public function authorize(): bool
25+
{
26+
return Gate::check(UserPolicy::CAN_CREATE_OR_EDIT_OR_DELETE, [User::class]);
27+
}
28+
29+
/**
30+
* {@inheritDoc}
31+
*/
32+
public function rules(): array
33+
{
34+
return [
35+
'id' => ['required', 'integer'],
36+
];
37+
}
38+
39+
/**
40+
* {@inheritDoc}
41+
*/
42+
protected function processValidatedValues(array $values, array $files): void
43+
{
44+
/** @var int $id */
45+
$id = $values['id'];
46+
$this->contact_message = ContactMessage::query()->findOrFail($id);
47+
}
48+
49+
public function contactMessage(): ContactMessage
50+
{
51+
return $this->contact_message;
52+
}
53+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Contact;
10+
11+
use App\Http\Requests\BaseApiRequest;
12+
13+
class StoreContactMessageRequest extends BaseApiRequest
14+
{
15+
protected string $name = '';
16+
protected string $email = '';
17+
protected string $message_body = '';
18+
protected string $security_answer = '';
19+
protected bool $consent_agreed = false;
20+
21+
/**
22+
* {@inheritDoc}
23+
*/
24+
public function authorize(): bool
25+
{
26+
return true;
27+
}
28+
29+
/**
30+
* {@inheritDoc}
31+
*/
32+
public function rules(): array
33+
{
34+
return [
35+
'name' => ['required', 'string', 'max:255'],
36+
'email' => ['required', 'string', 'max:255'],
37+
'message' => ['required', 'string', 'min:10', 'max:5000'],
38+
'security_answer' => ['sometimes', 'nullable', 'string'],
39+
'consent_agreed' => ['sometimes', 'boolean'],
40+
];
41+
}
42+
43+
/**
44+
* {@inheritDoc}
45+
*/
46+
protected function processValidatedValues(array $values, array $files): void
47+
{
48+
$this->name = $values['name'];
49+
$this->email = $values['email'];
50+
$this->message_body = $values['message'];
51+
$this->security_answer = $values['security_answer'] ?? '';
52+
$this->consent_agreed = self::toBoolean($values['consent_agreed'] ?? false);
53+
}
54+
55+
public function senderName(): string
56+
{
57+
return $this->name;
58+
}
59+
60+
public function senderEmail(): string
61+
{
62+
return $this->email;
63+
}
64+
65+
public function senderMessage(): string
66+
{
67+
return $this->message_body;
68+
}
69+
70+
public function securityAnswer(): string
71+
{
72+
return $this->security_answer;
73+
}
74+
75+
public function consentAgreed(): bool
76+
{
77+
return $this->consent_agreed;
78+
}
79+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Requests\Contact;
10+
11+
use App\Http\Requests\BaseApiRequest;
12+
use App\Models\ContactMessage;
13+
use App\Models\User;
14+
use App\Policies\UserPolicy;
15+
use Illuminate\Support\Facades\Gate;
16+
17+
class UpdateContactMessageRequest extends BaseApiRequest
18+
{
19+
protected ContactMessage $contact_message;
20+
protected bool $is_read = false;
21+
22+
/**
23+
* {@inheritDoc}
24+
*/
25+
public function authorize(): bool
26+
{
27+
return Gate::check(UserPolicy::CAN_CREATE_OR_EDIT_OR_DELETE, [User::class]);
28+
}
29+
30+
/**
31+
* {@inheritDoc}
32+
*/
33+
public function rules(): array
34+
{
35+
return [
36+
'id' => ['required', 'integer'],
37+
'is_read' => ['required', 'boolean'],
38+
];
39+
}
40+
41+
/**
42+
* {@inheritDoc}
43+
*/
44+
protected function processValidatedValues(array $values, array $files): void
45+
{
46+
/** @var int $id */
47+
$id = $values['id'];
48+
$this->contact_message = ContactMessage::query()->findOrFail($id);
49+
$this->is_read = self::toBoolean($values['is_read']);
50+
}
51+
52+
public function contactMessage(): ContactMessage
53+
{
54+
return $this->contact_message;
55+
}
56+
57+
public function isRead(): bool
58+
{
59+
return $this->is_read;
60+
}
61+
}

0 commit comments

Comments
 (0)