From 3f282c85802f3a89f864154636103e5f02d51885 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:09:24 +0200 Subject: [PATCH 1/3] feat: preview mode for the public form API Creating a session with preview: true needs a member of the form's team and returns the token "preview" without storing a session. Submitting or uploading with that token checks the team again and stores, counts and sends nothing. Stored tokens have 32 characters, so they never match it. Co-Authored-By: Claude Opus 5.5 --- .../Api/CreateFormSessionController.php | 14 ++- .../Controllers/Api/FormSubmitController.php | 10 +- .../Controllers/Api/FormUploadController.php | 8 ++ app/Models/FormSession.php | 3 + tests/Feature/PreviewModeTest.php | 97 +++++++++++++++++++ 5 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/PreviewModeTest.php diff --git a/app/Http/Controllers/Api/CreateFormSessionController.php b/app/Http/Controllers/Api/CreateFormSessionController.php index 311fbf5e..0e2a20a3 100644 --- a/app/Http/Controllers/Api/CreateFormSessionController.php +++ b/app/Http/Controllers/Api/CreateFormSessionController.php @@ -16,6 +16,8 @@ class CreateFormSessionController extends Controller * Create a new form session * * This endpoint creates a new form session and returns the session token. This token is required to submit the form. Using the same token on multiple submissions will result in the old submission being overwritten. + * + * With `preview`, a member of the form's team gets the token `preview` instead. Submitting or uploading with it stores nothing, counts nothing and sends no webhooks or emails. */ #[Group('Public Form Endpoints')] public function __invoke(Request $request, Form $form) @@ -23,15 +25,25 @@ public function __invoke(Request $request, Form $form) $request->validate([ // params come from the form link, and nginx stops URLs at 8 KB 'params' => ['array', 'nullable', new MaxSize(8192, json: true)], + // only for the form's team: try the form without storing anything + 'preview' => 'boolean', ]); - $session = FormSession::create([ + $session = new FormSession([ 'form_id' => $form->id, 'token' => Str::random(32), 'params' => $request->input('params', []), 'has_data_privacy' => $form->has_data_privacy, ]); + if ($request->boolean('preview')) { + $this->authorize('view', $form); + + return response()->json($session->fill(['token' => FormSession::PREVIEW_TOKEN]), 201); + } + + $session->save(); + return response()->json($session, 201); } } diff --git a/app/Http/Controllers/Api/FormSubmitController.php b/app/Http/Controllers/Api/FormSubmitController.php index 2122b04c..b403e3a2 100644 --- a/app/Http/Controllers/Api/FormSubmitController.php +++ b/app/Http/Controllers/Api/FormSubmitController.php @@ -5,6 +5,7 @@ use App\Events\FormSessionCompletedEvent; use App\Http\Controllers\Controller; use App\Models\Form; +use App\Models\FormSession; use App\Rules\MaxSize; use Illuminate\Http\Request; use Knuckles\Scribe\Attributes\BodyParam; @@ -17,7 +18,7 @@ class FormSubmitController extends Controller * * This endpoint submits a form. It requires a valid session token, which can be obtained by creating a new session. Using the same token will result in the old submitted data being overwritten. */ - #[BodyParam('token', 'string', 'The session token', example: 'rIt8id8JgiyExtmb2hkcbuWjI9izUyRp')] + #[BodyParam('token', 'string', 'The session token, or `preview` for a preview session', example: 'rIt8id8JgiyExtmb2hkcbuWjI9izUyRp')] #[BodyParam('payload', 'object', <<<'DESC' The user input that you want to submit for the form. The payload is a key-value object, where the key is the form block ID and the value is the user input for this form block. @@ -44,6 +45,13 @@ public function __invoke(Request $request, Form $form) 'payload.*' => [new MaxSize(30000)], ]); + // a preview stores and sends nothing, see CreateFormSessionController + if ($request->input('token') === FormSession::PREVIEW_TOKEN) { + $this->authorize('view', $form); + + return response()->noContent(); + } + $session = $form->formSessions() ->where('token', $request->input('token')) ->firstOrFail(); diff --git a/app/Http/Controllers/Api/FormUploadController.php b/app/Http/Controllers/Api/FormUploadController.php index a843f7a2..d079eb36 100644 --- a/app/Http/Controllers/Api/FormUploadController.php +++ b/app/Http/Controllers/Api/FormUploadController.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Controller; use App\Models\Form; use App\Models\FormBlockInteraction; +use App\Models\FormSession; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -31,6 +32,13 @@ public function __invoke(Request $request, Form $form) 'file' => 'required|file', ]); + // a preview stores no files, see CreateFormSessionController + if ($request->input('token') === FormSession::PREVIEW_TOKEN) { + $this->authorize('view', $form); + + return response()->noContent(); + } + // only look at the interactions of this form $interaction = FormBlockInteraction::withUuid($request->input('actionId')) ->whereHas('formBlock', fn ($query) => $query->where('form_id', $form->id)) diff --git a/app/Models/FormSession.php b/app/Models/FormSession.php index ea19bd79..e4ee99a7 100644 --- a/app/Models/FormSession.php +++ b/app/Models/FormSession.php @@ -12,6 +12,9 @@ class FormSession extends Model { use HasFactory; + // a preview has no stored session, and a stored token has 32 characters, so it never matches this one + public const PREVIEW_TOKEN = 'preview'; + protected $guarded = []; protected $casts = [ diff --git a/tests/Feature/PreviewModeTest.php b/tests/Feature/PreviewModeTest.php new file mode 100644 index 00000000..3f9aa212 --- /dev/null +++ b/tests/Feature/PreviewModeTest.php @@ -0,0 +1,97 @@ +form = Form::factory()->create(); + FormWebhook::factory()->for($this->form)->create(); + + $this->text = FormBlock::factory()->for($this->form)->create(['type' => FormBlockType::short, 'sequence' => 0]); + FormBlockInteraction::factory()->for($this->text, 'formBlock')->input()->create(); + + $file = FormBlock::factory()->for($this->form)->create(['type' => FormBlockType::file, 'sequence' => 1]); + $this->fileAnswer = FormBlockInteraction::factory()->for($file, 'formBlock')->create(['type' => FormBlockInteractionType::file]); + + $this->routes = fn () => [ + 'session' => ['post', route('api.public.forms.session.create', ['form' => $this->form->uuid]), ['preview' => true]], + 'submit' => ['post', route('api.public.forms.submit', ['form' => $this->form->uuid]), ['token' => 'preview', 'payload' => $this->text->fresh()->getSubmitPayload('Hello')]], + 'upload' => ['post', route('api.public.forms.file-upload', ['form' => $this->form->uuid]), ['token' => 'preview', 'actionId' => $this->fileAnswer->uuid, 'file' => UploadedFile::fake()->create('cv.pdf', 10)]], + ]; + + $this->assertNothingStored = function () { + expect([FormSession::count(), FormSessionResponse::count(), FormSessionUpload::count(), Storage::allFiles()])->toBe([0, 0, 0, []]); + + Event::assertNotDispatched(FormSessionCompletedEvent::class); + }; +}); + +test('a team member can try a draft form in preview mode, and nothing is stored, counted or sent', function () { + $this->form->update(['published_at' => null]); + $member = User::factory()->withTeam($this->form->team)->create(); + + $this->actingAs($member) + ->json('post', route('api.public.forms.session.create', ['form' => $this->form->uuid]), ['preview' => true, 'params' => ['ref' => 'editor']]) + ->assertCreated() + ->assertJson(['token' => 'preview', 'params' => ['ref' => 'editor'], 'is_completed' => false]); + + // the form page's order: answers, then files, then the final submit + $submit = fn (bool $isUploading, ?array $payload) => $this->json('post', route('api.public.forms.submit', ['form' => $this->form->uuid]), [ + 'token' => 'preview', + 'is_uploading' => $isUploading, + 'payload' => $payload, + ])->assertNoContent(); + + $submit(true, [ + ...$this->text->fresh()->getSubmitPayload('Hello'), + $this->fileAnswer->formBlock->uuid => ['actionId' => $this->fileAnswer->uuid, 'payload' => 'upload'], + ]); + + $this->json('post', route('api.public.forms.file-upload', ['form' => $this->form->uuid]), [ + 'token' => 'preview', + 'actionId' => $this->fileAnswer->uuid, + 'file' => UploadedFile::fake()->create('cv.pdf', 10), + ])->assertNoContent(); + + $submit(false, null); + + ($this->assertNothingStored)(); +}); + +test('only the form\'s team can use preview mode', function (string $route) { + [$method, $uri, $data] = ($this->routes)()[$route]; + + $this->json($method, $uri, $data)->assertForbidden(); + + $this->actingAs(User::factory()->withTeam()->create()) + ->json($method, $uri, $data) + ->assertForbidden(); + + ($this->assertNothingStored)(); +})->with(['session', 'submit', 'upload']); + +test('a form session without preview is stored as before', function () { + $this->json('post', route('api.public.forms.session.create', ['form' => $this->form->uuid]), ['preview' => false]) + ->assertCreated(); + + expect(FormSession::count())->toBe(1) + ->and(FormSession::first()->token)->toHaveLength(32); +}); From 2993fc93255cc63feea82c579ebedeaa997c17fe Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 01:11:44 +0200 Subject: [PATCH 2/3] docs: add changelog line for #266 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc3c0b4..7080ce72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Lines marked **Upgrade step** need you to act after updating. - The forms API (`GET /api/forms`) returns when each form last got a submission (`latest_submission_at`) and can sort by it, newest first. Sorting now works only by `name`, `created_at`, `updated_at` or `latest_submission_at`. Other sort values that worked before, like `published_at`, `id` or `uuid`, now give the default order. A dashboard link with an unknown filter or sort now shows your forms. Before, it showed no forms at all. (#242) - Search your forms by name, also without accents ("muller" finds "Müller"), and press "/" to jump to the search. The form list has status tabs with counts and sorts by latest submission, last edit, newest or name. Your choice stays in the address, so Back undoes it and a link shows the same list. Dashboard links with the old `?filter=` show all forms. (#248) - The form list shows when each form last got a submission, with a blue dot for the last 24 hours, and now opens sorted by latest submission. Its date column shows the date the list is sorted by. (#250) +- The form's team can try a form in preview mode, also while it's a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266) ### Changed From 2fe20374ed8ae0c6938808e3bee382b57598a535 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 12:27:37 +0200 Subject: [PATCH 3/3] docs: name the public form API in the preview changelog line The editor's Preview link still opens the real form until BITS-285, so the line names the API that has the preview mode. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc01a95..195ba8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Lines marked **Upgrade step** need you to act after updating. - Search your forms by name, also without accents ("muller" finds "Müller"), and press "/" to jump to the search. The form list has status tabs with counts and sorts by latest submission, last edit, newest or name. Your choice stays in the address, so Back undoes it and a link shows the same list. Dashboard links with the old `?filter=` show all forms. (#248) - The form list shows when each form last got a submission, with a blue dot for the last 24 hours, and now opens sorted by latest submission. Its date column shows the date the list is sorted by. (#250) - The API can copy a question (`POST /api/blocks/{block}/duplicate`) with its text, settings, answers and Required, but not its rules or its Identifier, so links that fill in answers and webhooks can't mix the two up. The copy goes right after the original. (#262) -- The form's team can try a form in preview mode, also while it's a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266) +- The public form API has a preview mode for the form's team: start a session with `preview: true`, also for a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266) ### Changed