diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ac360f..f0b3cb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +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 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 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); +});