Skip to content

fix(onboarding): stop step 3 from crashing on resubmit - #217

Open
jakub-przepiora wants to merge 1 commit into
mainfrom
fix/onboarding-duplicate-process-template
Open

fix(onboarding): stop step 3 from crashing on resubmit#217
jakub-przepiora wants to merge 1 commit into
mainfrom
fix/onboarding-duplicate-process-template

Conversation

@jakub-przepiora

@jakub-przepiora jakub-przepiora commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

POST /onboarding/step/3 returns a 500 whenever the step is submitted twice:

UniqueConstraintViolationException
duplicate key value violates unique constraint "process_templates_product_type_id_version_unique"
DETAIL:  Key (product_type_id, version)=(734, 1) already exists.

storeStep3() created the template with a hardcoded version => 1 through a plain create(). The first insert already satisfies the (product_type_id, version) unique index, so any replay of the step blows up:

  • double click on "Next"
  • browser back, then resubmit
  • Inertia retry on a slow connection
  • refresh after the redirect

The session still holds onboarding.product_type_id after step 3 (only template_id is added), so nothing stopped the controller from inserting a second row for the same product type.

Worth noting this was the only place in the codebase assuming v1ProcessTemplateManagementController::store and Api\V1\ProcessTemplateController::store both already derive the version with max('version') + 1, and the demo seeders use updateOrCreate. Onboarding was the odd one out.

Fix

  • Idempotent step — return early to step 4 when the session already holds onboarding.template_id, so a replay is a no-op instead of a second insert.
  • Correct versioning — derive the version with max('version') + 1, matching the admin UI and the API.
  • Transaction — wrap the template and its steps together; previously a failure while creating TemplateStep rows left an orphaned template behind.

Tests

Two regression tests added to OnboardingWizardTest, both verified to fail on main and pass with this change:

  • test_step3_resubmit_does_not_create_duplicate_template — replays the step in the same session, asserts exactly one template and one step
  • test_step3_assigns_next_version_when_product_type_already_has_template — pre-existing v1 template, asserts the new one lands on v2 rather than throwing

Full suite green locally: 1975 tests, 6284 assertions. Pint clean.

Impact

Not demo-specific — any installation running the onboarding wizard is affected. It surfaces most often on demo instances because every new tenant is routed through the wizard, so the double-submit window is hit far more often.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate templates when onboarding Step 3 is submitted more than once.
    • Ensured newly created templates receive the next available version for their product type.
    • Improved consistency by creating templates and their steps together.

storeStep3 inserted the process template with a hardcoded `version => 1`
via a plain `create()`. Because the (product_type_id, version) unique
index is already satisfied by the first insert, any replay of the step —
double click on "Next", browser back + resubmit, Inertia retry on a slow
connection — hit a UniqueConstraintViolationException and surfaced as a
500 to the user.

Three changes:

- Bail out early when the session already holds `onboarding.template_id`,
  making the step idempotent and redirecting to step 4 instead.
- Derive the version with `max('version') + 1`, the same rule the admin
  UI (ProcessTemplateManagementController) and the API already use.
  Onboarding was the only place assuming v1.
- Wrap the template and its steps in a transaction so a failure part way
  through no longer leaves an orphaned template behind.

Covered by two regression tests that fail without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Step 3 now skips duplicate template creation when a template ID exists in session, creates templates transactionally with incremented product-type versions, and adds feature tests for replay prevention and version assignment.

Changes

Onboarding template flow

Layer / File(s) Summary
Prevent duplicate Step 3 submissions
backend/app/Http/Controllers/Web/OnboardingController.php, backend/tests/Feature/OnboardingWizardTest.php
Step 3 redirects to Step 4 when a template ID is already stored, and tests confirm replay creates no additional template or step.
Create versioned templates transactionally
backend/app/Http/Controllers/Web/OnboardingController.php, backend/tests/Feature/OnboardingWizardTest.php
Template and step creation now runs in a transaction using the next version for the product type, with coverage for version increments.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Step3Form
  participant OnboardingController
  participant Session
  participant Database
  Step3Form->>OnboardingController: Submit Step 3
  OnboardingController->>Session: Read onboarding.template_id
  alt Template already exists
    Session-->>OnboardingController: Existing template ID
    OnboardingController-->>Step3Form: Redirect to onboarding.step4
  else No existing template
    OnboardingController->>Database: Read max version for product_type_id
    OnboardingController->>Database: Create template and steps transactionally
    Database-->>OnboardingController: Created template
    OnboardingController->>Session: Store template ID
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: making onboarding step 3 safe on resubmit.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/onboarding-duplicate-process-template

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/Http/Controllers/Web/OnboardingController.php`:
- Around line 122-128: The Step 3 replay guard and template creation must be
atomic: acquire the shared per-product-type lock before checking
onboarding.template_id, then perform version allocation, template insertion, and
session update while holding it. Apply the same lock to Onboarding, API, and
template-allocator writers, and ensure the Step 3 POST route/session is blocked
before the guard so concurrent requests cannot bypass this protection.

In `@backend/tests/Feature/OnboardingWizardTest.php`:
- Around line 113-159: Expand Step 3 contract coverage in OnboardingWizardTest:
capture onboarding.template_id from the first successful POST response/session
state and reuse it for the duplicate submission instead of reading
ProcessTemplate::first(). Add tests for validation-422, guest access, and
unauthorized-role access, and add the no-existing-template case asserting the
created template receives version 1.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 04be96b8-362b-441d-b30c-1c59af62a3ae

📥 Commits

Reviewing files that changed from the base of the PR and between d3afa47 and 75c6fb3.

📒 Files selected for processing (2)
  • backend/app/Http/Controllers/Web/OnboardingController.php
  • backend/tests/Feature/OnboardingWizardTest.php

Comment on lines +122 to +128
// The step is not repeatable: a resubmit (double click, browser back,
// Inertia retry) would otherwise insert a second template and violate
// the (product_type_id, version) unique index.
if ($request->session()->has('onboarding.template_id')) {
return redirect()->route('onboarding.step4');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect Step 3 route blocking and every ProcessTemplate creation/version path.
fd -t f -e php . backend |
  xargs rg -n -C3 'onboarding\.step3|storeStep3|->block\(|ProcessTemplate::create|lockForUpdate\(|max\(.version.'

Repository: Mes-Open/OpenMes

Length of output: 23398


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "## Relevant controller snippets"
sed -n '70,170p' backend/app/Http/Controllers/Web/OnboardingController.php | cat -n
echo
sed -n '35,85p' backend/app/Http/Controllers/Web/Admin/ProcessTemplateManagementController.php | cat -n
echo
sed -n '40,55p' backend/app/Http/Controllers/Api/V1/ProcessTemplateController.php | cat -n

echo
echo "## Model migrations and ProcessTemplate schema"
fd -t f '.*\.php$' backend/database/migrations | sed -n '1,40p'
rg -n -C3 "create_process_templates|product_types|process_templates|unique|product_type_id.*version|version.*product_type_id" backend/database/migrations frontend backend -g '*.php' | sed -n '1,220p'

echo
echo "## Route/session blocking patterns"
rg -n -C2 "Route::block|csrf|middleware|web\(\)|StoreRequests|Form Request|Onboarding.*Step3|storeStep3" backend/routes backend/app backend/tests -g '*.php'

Repository: Mes-Open/OpenMes

Length of output: 33996


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "## Behavioral probe of the current guard ordering"
python3 - <<'PY'
from dataclasses import dataclass
from datetime import datetime
from enum import Enum

class State(Enum):
    SESSION_ABSENT = 0
    SESSION_PRESENT = 1
    TEMPLATE_CREATED = 2

`@dataclass`
class RequestState:
    session_present_before_guard: bool
    max_version_before_alias_compute: int = 1
    commit_time: float = 0.0

def process3(r: RequestState):
    # guard read
    if r.session_present_before_guard:
        return "redirect_step4"
    # transactional alias compute uses database value before this request writes
    next_version = r.max_version_before_alias_compute + 1
    # request commits unique insert after this line
    r.session_present_before_guard = True
    r.commit_time = datetime.now().timestamp()
    return next_version

# Concurrent same-session submissions without route/session lock.
r1, r2 = RequestState(False), RequestState(False)
v1, v2 = process3(r1), process3(r2)
print("r1_result", v1, "r2_result", v2, "same_version", v1 == v2)

# Sequential after one replay guard works.
r1, r2 = RequestState(False), RequestState(True)
v1, r_state_after_r1 = process3(r1), True
r2.session_present_before_guard = r_state_after_r1
v2 = process3(r2)
print("r1_result", v1, "r2_result", v2, "same_version", v1 == v2)
PY

Repository: Mes-Open/OpenMes

Length of output: 302


Make the Step 3 replay guard and template creation atomic.

The guard reads the session before the transaction writes it, so two concurrent same-session POSTs can both select the same MAX(version) + 1, and the duplicate insert violates the unique (product_type_id, version) index. Block the Step 3 POST route/session before the guard, and coordinate the Onboarding/API/template-allocator writers against a shared product-type lock so no future writer bypasses this protection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/Http/Controllers/Web/OnboardingController.php` around lines 122 -
128, The Step 3 replay guard and template creation must be atomic: acquire the
shared per-product-type lock before checking onboarding.template_id, then
perform version allocation, template insertion, and session update while holding
it. Apply the same lock to Onboarding, API, and template-allocator writers, and
ensure the Step 3 POST route/session is blocked before the guard so concurrent
requests cannot bypass this protection.

Comment on lines +113 to +159
public function test_step3_resubmit_does_not_create_duplicate_template(): void
{
$pt = ProductType::factory()->create();

$payload = [
'name' => 'Assembly Process',
'steps' => [['name' => 'Preparation', 'estimated_duration_minutes' => 10]],
];

$this->actingAs($this->admin)
->withSession(['onboarding.product_type_id' => $pt->id])
->post(route('onboarding.step3'), $payload)
->assertRedirect(route('onboarding.step4'));

// Same session replays the step (double click / browser back / Inertia retry).
$this->actingAs($this->admin)
->withSession([
'onboarding.product_type_id' => $pt->id,
'onboarding.template_id' => ProcessTemplate::first()->id,
])
->post(route('onboarding.step3'), $payload)
->assertRedirect(route('onboarding.step4'));

$this->assertEquals(1, ProcessTemplate::count());
$this->assertEquals(1, ProcessTemplate::first()->steps()->count());
}

public function test_step3_assigns_next_version_when_product_type_already_has_template(): void
{
$pt = ProductType::factory()->create();
ProcessTemplate::factory()->create(['product_type_id' => $pt->id, 'version' => 1]);

$response = $this->actingAs($this->admin)
->withSession(['onboarding.product_type_id' => $pt->id])
->post(route('onboarding.step3'), [
'name' => 'Second Process',
'steps' => [['name' => 'Preparation', 'estimated_duration_minutes' => 10]],
]);

$response->assertRedirect(route('onboarding.step4'));
$this->assertDatabaseHas('process_templates', [
'product_type_id' => $pt->id,
'name' => 'Second Process',
'version' => 2,
]);
$this->assertEquals(2, ProcessTemplate::where('product_type_id', $pt->id)->count());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg '(^frontend|backend/)' | rg 'OnboardingWizardTest|Onboarding|onboarding|ProcessTemplate|ProductType' | sed -n '1,200p'

echo
echo "== OnboardingWizardTest outline/lines =="
if [ -f backend/tests/Feature/OnboardingWizardTest.php ]; then
  wc -l backend/tests/Feature/OnboardingWizardTest.php
  sed -n '1,220p' backend/tests/Feature/OnboardingWizardTest.php | cat -n
else
  fd -i 'OnboardingWizardTest\.php' .
fi

echo
echo "== search step3 route/controller and ProcessTemplate =="
rg -n "step3|step[34]|onboarding/template|ProcessTemplate|product_type_id|onboarding\\." backend frontend -S | sed -n '1,260p'

Repository: Mes-Open/OpenMes

Length of output: 46376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== OnboardingController step3 implementation =="
sed -n '110,165p' backend/app/Http/Controllers/Web/OnboardingController.php | cat -n

echo
echo "== Step 3 tests beyond existing =="
sed -n '220,328p' backend/tests/Feature/OnboardingWizardTest.php | cat -n

echo
echo "== process template factory =="
sed -n '1,80p' backend/database/factories/ProcessTemplateFactory.php | cat -n

echo
echo "== process template model =="
sed -n '1,180p' backend/app/Models/ProcessTemplate.php | cat -n

echo
echo "== onboarding routes =="
rg -n "Route::|onboarding\\.|step3|step4" backend/routes -S | sed -n '1,220p'

echo
echo "== behavioral probe from source text =="
python3 - <<'PY'
from pathlib import Path
src = Path('backend/app/Http/Controllers/Web/OnboardingController.php').read_text()
test = Path('backend/tests/Feature/OnboardingWizardTest.php').read_text()
checks = {
    'step3_stores_template_id': "\$request->session()->put('onboarding.template_id');" in src,
    'test_replay_fetches_from_db': "ProcessTemplate::first()->id" in test and "\$request->session()->has('onboarding.template_id')" not in test.split("test_step3_resubmit_does_not_create_duplicate_template")[1].split("test_step3_assigns_next_version")[0],
    'test_asserts_step3_session_session_state': "\$response->withSession" in test or ".assertSessionHas('onboarding.template_id'" in test,
    'test_missing_validation_422': "\$response->assertStatus(422)" not in test and "assertStatus(422)" not in test,
    'test_missing_guest_authorization': ".actingAsGuest()" not in test and "guest())" not in test,
    'test_missing_wrong_role': "wrong role" not in test.lower() and "wrong-role" not in test.lower(),
    'test_missing_no_existing_template_version_1': "version' => 1" in test and "\$pt = ProductType::factory()->create();" in test and "ProcessTemplate::factory()->create([" not in test.split("no existing template", 1)[1] if "no existing template" in test.lower() else False,
}
for k, v in checks.items():
    print(f"{k}={v}")

start_step3_create = test.index("test_step3_creates_template_with_steps")
step3_block = test[start_step3_create:test.index("test_step4_creates_work_order")]
print("step3_block_processes_no_existing_template_to_version_one=", "version' => 1" in step3_block and "ProcessTemplate::factory()->create([" not in step3_block and "assertEquals(1, ProcessTemplate::where" not in step3_block)
PY

Repository: Mes-Open/OpenMes

Length of output: 48857


Complete Step 3 contract coverage.

Store onboarding.template_id during the first successful POST and replay the duplicate-submit case through that response state instead of deriving it from the database. Add Step 3 validation-422, guest, and wrong-role tests, and cover the no-existing-template path that should assign version 1.

🧰 Tools
🪛 PHPStan (2.2.6)

[error] 115-115: Call to an undefined static method App\Models\ProductType::factory().

(staticMethod.notFound)


[error] 131-131: Call to an undefined static method App\Models\ProcessTemplate::first().

(staticMethod.notFound)


[error] 136-136: Call to an undefined static method App\Models\ProcessTemplate::count().

(staticMethod.notFound)


[error] 137-137: Call to an undefined static method App\Models\ProcessTemplate::first().

(staticMethod.notFound)


[error] 142-142: Call to an undefined static method App\Models\ProductType::factory().

(staticMethod.notFound)


[error] 143-143: Call to an undefined static method App\Models\ProcessTemplate::factory().

(staticMethod.notFound)


[error] 158-158: Call to an undefined static method App\Models\ProcessTemplate::where().

(staticMethod.notFound)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/Feature/OnboardingWizardTest.php` around lines 113 - 159,
Expand Step 3 contract coverage in OnboardingWizardTest: capture
onboarding.template_id from the first successful POST response/session state and
reuse it for the duplicate submission instead of reading
ProcessTemplate::first(). Add tests for validation-422, guest access, and
unauthorized-role access, and add the no-existing-template case asserting the
created template receives version 1.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant