diff --git a/app/Http/Controllers/BulkUserChangesController.php b/app/Http/Controllers/BulkUserChangesController.php index e39a328e2..6d6527bf8 100644 --- a/app/Http/Controllers/BulkUserChangesController.php +++ b/app/Http/Controllers/BulkUserChangesController.php @@ -5,6 +5,7 @@ use App\Services\BulkUserChanges\BulkUserChangesPlanner; use App\Services\BulkUserChanges\BulkUserChangesReadOptions; use App\Services\BulkUserChanges\BulkUserChangesSheetReader; +use App\Services\BulkUserChanges\BulkUserChangesTextParser; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -22,6 +23,94 @@ public function index(): View } public function validateUpload( + Request $request, + BulkUserChangesSheetReader $reader, + BulkUserChangesTextParser $textParser, + BulkUserChangesPlanner $planner, + ): RedirectResponse { + $hasFile = $request->hasFile('file') && $request->file('file')?->isValid(); + $hasPaste = trim((string) $request->input('paste', '')) !== ''; + + if ($hasFile && $hasPaste) { + return redirect()->route('admin.bulk-user-changes.index') + ->withErrors(['file' => 'Upload a file or paste text, not both.']) + ->withInput(); + } + + if (! $hasFile && ! $hasPaste) { + return redirect()->route('admin.bulk-user-changes.index') + ->withErrors(['file' => 'Upload a file or paste changes to continue.']); + } + + if ($hasPaste) { + return $this->validatePaste($request, $textParser, $planner); + } + + return $this->validateFile($request, $reader, $planner); + } + + public function preview(Request $request): View|RedirectResponse + { + $payload = $this->payloadFromSession($request); + if ($payload === null) { + return redirect()->route('admin.bulk-user-changes.index') + ->withErrors(['file' => 'No validated upload found. Please upload a file or paste changes first.']); + } + + return view('admin.bulk-user-changes.preview', [ + 'parsed' => $payload['parsed'], + 'plan' => $payload['plan'], + 'token' => $request->session()->get(self::SESSION_TOKEN), + ]); + } + + public function apply( + Request $request, + BulkUserChangesSheetReader $reader, + BulkUserChangesTextParser $textParser, + BulkUserChangesPlanner $planner, + ): RedirectResponse { + $token = (string) $request->input('token', $request->session()->get(self::SESSION_TOKEN, '')); + $payload = $token !== '' ? Cache::get('bulk_user_changes_'.$token) : null; + + if (! is_array($payload)) { + return redirect()->route('admin.bulk-user-changes.index') + ->withErrors(['file' => 'Session expired. Please upload or paste again.']); + } + + try { + $parsed = $this->readPayload($payload, $reader, $textParser); + $result = $planner->apply($parsed['rows']); + } catch (\Throwable $e) { + return redirect()->route('admin.bulk-user-changes.preview') + ->withErrors(['apply' => 'Apply failed: '.$e->getMessage()]); + } + + $this->cleanupPayload($payload); + Cache::forget('bulk_user_changes_'.$token); + $request->session()->forget(self::SESSION_TOKEN); + $request->session()->put('bulk_user_changes_report', [ + 'parsed' => $parsed, + 'result' => $result, + ]); + + return redirect()->route('admin.bulk-user-changes.report'); + } + + public function report(Request $request): View|RedirectResponse + { + $report = $request->session()->get('bulk_user_changes_report'); + if (! is_array($report)) { + return redirect()->route('admin.bulk-user-changes.index'); + } + + return view('admin.bulk-user-changes.report', [ + 'parsed' => $report['parsed'], + 'result' => $report['result'], + ]); + } + + private function validateFile( Request $request, BulkUserChangesSheetReader $reader, BulkUserChangesPlanner $planner, @@ -68,89 +157,109 @@ function ($attribute, $value, $fail) { ->withErrors(['file' => 'No actionable rows found after the header row. Check that the Changes sheet has email addresses and actions.']); } - $plan = $planner->plan($parsed['rows']); - $token = Str::random(64); - - Cache::put('bulk_user_changes_'.$token, [ + return $this->storePreviewSession($request, $planner, $parsed, [ + 'source' => 'file', 'path' => $path, 'disk' => $tempDisk, - 'parsed' => $parsed, - 'plan' => $plan, 'ignore_through_row' => $readOptions->ignoreThroughRow, - ], now()->addHours(2)); - - $request->session()->put(self::SESSION_TOKEN, $token); - - return redirect()->route('admin.bulk-user-changes.preview'); + ]); } - public function preview(Request $request): View|RedirectResponse - { - $payload = $this->payloadFromSession($request); - if ($payload === null) { + private function validatePaste( + Request $request, + BulkUserChangesTextParser $textParser, + BulkUserChangesPlanner $planner, + ): RedirectResponse { + $validated = $request->validate([ + 'paste' => ['required', 'string', 'min:10', 'max:100000'], + ], [ + 'paste.required' => 'Paste the change list to continue.', + ]); + + try { + $parsed = $textParser->parse($validated['paste']); + } catch (\Throwable $e) { return redirect()->route('admin.bulk-user-changes.index') - ->withErrors(['file' => 'No validated upload found. Please upload a file first.']); + ->withErrors(['paste' => $e->getMessage()]) + ->withInput(); } - return view('admin.bulk-user-changes.preview', [ - 'parsed' => $payload['parsed'], - 'plan' => $payload['plan'], - 'token' => $request->session()->get(self::SESSION_TOKEN), + return $this->storePreviewSession($request, $planner, $parsed, [ + 'source' => 'paste', + 'paste_text' => $validated['paste'], ]); } - public function apply( + /** + * @param array $parsed + * @param array $cacheMeta + */ + private function storePreviewSession( Request $request, - BulkUserChangesSheetReader $reader, BulkUserChangesPlanner $planner, + array $parsed, + array $cacheMeta, ): RedirectResponse { - $token = (string) $request->input('token', $request->session()->get(self::SESSION_TOKEN, '')); - $payload = $token !== '' ? Cache::get('bulk_user_changes_'.$token) : null; + $plan = $planner->plan($parsed['rows']); + $token = Str::random(64); - if (! is_array($payload)) { - return redirect()->route('admin.bulk-user-changes.index') - ->withErrors(['file' => 'Upload session expired. Please upload the file again.']); + Cache::put('bulk_user_changes_'.$token, [ + ...$cacheMeta, + 'parsed' => $parsed, + 'plan' => $plan, + ], now()->addHours(2)); + + $request->session()->put(self::SESSION_TOKEN, $token); + + return redirect()->route('admin.bulk-user-changes.preview'); + } + + /** + * @param array $payload + * @return array + */ + private function readPayload( + array $payload, + BulkUserChangesSheetReader $reader, + BulkUserChangesTextParser $textParser, + ): array { + if (($payload['source'] ?? 'file') === 'paste') { + $pasteText = (string) ($payload['paste_text'] ?? ''); + + if ($pasteText === '') { + throw new \RuntimeException('Pasted text no longer available. Please paste again.'); + } + + return $textParser->parse($pasteText); } $path = (string) ($payload['path'] ?? ''); $disk = (string) ($payload['disk'] ?? 'local'); if ($path === '' || ! Storage::disk($disk)->exists($path)) { - return redirect()->route('admin.bulk-user-changes.index') - ->withErrors(['file' => 'Uploaded file no longer available. Please upload again.']); + throw new \RuntimeException('Uploaded file no longer available. Please upload again.'); } - try { - $readOptions = BulkUserChangesReadOptions::fromInput($payload['ignore_through_row'] ?? null); - $parsed = $reader->read($path, $disk, $readOptions); - $result = $planner->apply($parsed['rows']); - } catch (\Throwable $e) { - return redirect()->route('admin.bulk-user-changes.preview') - ->withErrors(['apply' => 'Apply failed: '.$e->getMessage()]); - } - - Storage::disk($disk)->delete($path); - Cache::forget('bulk_user_changes_'.$token); - $request->session()->forget(self::SESSION_TOKEN); - $request->session()->put('bulk_user_changes_report', [ - 'parsed' => $parsed, - 'result' => $result, - ]); + $readOptions = BulkUserChangesReadOptions::fromInput($payload['ignore_through_row'] ?? null); - return redirect()->route('admin.bulk-user-changes.report'); + return $reader->read($path, $disk, $readOptions); } - public function report(Request $request): View|RedirectResponse + /** + * @param array $payload + */ + private function cleanupPayload(array $payload): void { - $report = $request->session()->get('bulk_user_changes_report'); - if (! is_array($report)) { - return redirect()->route('admin.bulk-user-changes.index'); + if (($payload['source'] ?? 'file') !== 'file') { + return; } - return view('admin.bulk-user-changes.report', [ - 'parsed' => $report['parsed'], - 'result' => $report['result'], - ]); + $path = (string) ($payload['path'] ?? ''); + $disk = (string) ($payload['disk'] ?? 'local'); + + if ($path !== '' && Storage::disk($disk)->exists($path)) { + Storage::disk($disk)->delete($path); + } } /** diff --git a/app/Services/BulkUserChanges/BulkUserChangesTextParser.php b/app/Services/BulkUserChanges/BulkUserChangesTextParser.php new file mode 100644 index 000000000..750aba9c4 --- /dev/null +++ b/app/Services/BulkUserChanges/BulkUserChangesTextParser.php @@ -0,0 +1,140 @@ +>, + * meta: array{ + * first_data_row: ?int, + * last_data_row: ?int, + * parsed_rows: int, + * skipped_blank_rows: int, + * skipped_no_email_rows: int, + * skipped_legacy_rows: int, + * skipped_ignored_range_rows: int, + * ignore_through_row: null, + * first_email: ?string, + * last_email: ?string, + * } + * } + */ + public function parse(string $text): array + { + $lines = $this->splitLines($text); + if ($lines === []) { + throw new InvalidArgumentException('Paste is empty.'); + } + + if (count($lines) % self::LINES_PER_RECORD !== 0) { + throw new InvalidArgumentException( + 'Paste must be groups of 5 lines (country, name, email, action, role or new email). Found ' + .count($lines).' non-empty lines.' + ); + } + + $rows = []; + $skippedNoEmail = 0; + $entryNumber = 1; + + for ($offset = 0; $offset < count($lines); $offset += self::LINES_PER_RECORD) { + $raw = $this->rawRecordFromLines(array_slice($lines, $offset, self::LINES_PER_RECORD)); + $normalized = $this->normalizer->normalize($raw); + + if ($normalized['email'] === null) { + $skippedNoEmail++; + + continue; + } + + $rows[] = [ + 'row_number' => $entryNumber, + ...$normalized, + ]; + $entryNumber++; + } + + if ($rows === []) { + throw new InvalidArgumentException('No valid email addresses found in pasted text.'); + } + + $firstRow = $rows[0]['row_number'] ?? null; + $lastRow = $rows !== [] ? $rows[array_key_last($rows)]['row_number'] : null; + + return [ + 'sheet_name' => 'Pasted list', + 'header_row' => null, + 'source' => 'paste', + 'rows' => $rows, + 'meta' => [ + 'first_data_row' => $firstRow, + 'last_data_row' => $lastRow, + 'parsed_rows' => count($rows), + 'skipped_blank_rows' => 0, + 'skipped_no_email_rows' => $skippedNoEmail, + 'skipped_legacy_rows' => 0, + 'skipped_ignored_range_rows' => 0, + 'ignore_through_row' => null, + 'first_email' => $rows[0]['email'] ?? null, + 'last_email' => $rows !== [] ? $rows[array_key_last($rows)]['email'] : null, + ], + ]; + } + + /** + * @return list + */ + private function splitLines(string $text): array + { + $text = str_replace("\r\n", "\n", $text); + $text = str_replace("\r", "\n", $text); + + $lines = []; + foreach (explode("\n", $text) as $line) { + $line = trim($line); + if ($line !== '') { + $lines[] = $line; + } + } + + return $lines; + } + + /** + * @param list $lines + * @return array + */ + private function rawRecordFromLines(array $lines): array + { + [$country, $fullName, $email, $action, $detail] = $lines; + $actionLower = mb_strtolower(trim($action)); + $detailLower = mb_strtolower(trim($detail)); + + $isEmailChange = str_contains($actionLower, 'email change') + || str_contains($actionLower, 'email update') + || str_contains($detailLower, 'new email'); + + return [ + 'country' => $country, + 'full_name' => $fullName, + 'email' => $email, + 'action' => $action, + 'role' => $isEmailChange ? $detail : $detail, + 'comments' => $isEmailChange ? $detail : null, + ]; + } +} diff --git a/resources/views/admin/bulk-user-changes/index.blade.php b/resources/views/admin/bulk-user-changes/index.blade.php index 31998c557..3ce8e5375 100644 --- a/resources/views/admin/bulk-user-changes/index.blade.php +++ b/resources/views/admin/bulk-user-changes/index.blade.php @@ -8,7 +8,7 @@
-

Upload the client Excel workbook. Only the sheet named Changes is read — all other tabs are ignored. If older batches are still on the sheet, set Ignore rows through so rows 2 up to that line are skipped (e.g. 149 to start at row 150). Only rows with an email address are listed. Missing users are never created.

+

Upload the client Excel workbook or paste a change list below. Missing users are never created. You get a preview summary before anything is applied.

@if ($errors->any())
@@ -20,26 +20,48 @@
@endif -
- @csrf -
-
- - -

Must include a tab named Changes (other tabs are not processed). Required columns on that tab: Country, Full name, Email address, ACTION, Role.

+
+

Excel upload

+

Only the sheet named Changes is read. If older batches are still on the sheet, set Ignore rows through (e.g. 149 to start at row 150).

+ + + @csrf +
+
+ + +
+
+ + +

Excel row number. Rows 2 through this line are skipped.

+
+
-
- - -

Excel row number. Rows 2 through this line are skipped; processing starts on the next row. Leave blank to read from row 2.

+ +
+ +
+

Or paste changes

+

Paste groups of 5 lines per person: country, full name, email, action, then role (or new email: … for email changes).

+ +
+ @csrf +
+
+ + +
+
- -
- + +
@endsection diff --git a/resources/views/admin/bulk-user-changes/preview.blade.php b/resources/views/admin/bulk-user-changes/preview.blade.php index 843597094..a6a51e8ad 100644 --- a/resources/views/admin/bulk-user-changes/preview.blade.php +++ b/resources/views/admin/bulk-user-changes/preview.blade.php @@ -29,9 +29,15 @@ $firstRow = $meta['first_data_row'] ?? null; $lastRow = $meta['last_data_row'] ?? null; $headerRow = $parsed['header_row'] ?? 1; + $isPaste = ($parsed['source'] ?? '') === 'paste'; + $rowLabel = $isPaste ? 'Entry' : 'Row'; @endphp -

Sheet: {{ $parsed['sheet_name'] ?? 'Changes' }} · header row {{ $headerRow }}

-

Rows in file: +

Source: {{ $parsed['sheet_name'] ?? 'Changes' }} + @if (! $isPaste) + · header row {{ $headerRow }} + @endif +

+

{{ $isPaste ? 'Entries' : 'Rows in file' }}: @if ($firstRow && $lastRow) {{ $firstRow }}–{{ $lastRow }} ({{ $meta['parsed_rows'] ?? 0 }} with email) @@ -59,7 +65,7 @@ @endif

@endif - @if ($firstRow && $firstRow > $headerRow + 1 && empty($meta['ignore_through_row'])) + @if ($firstRow && $firstRow > $headerRow + 1 && empty($meta['ignore_through_row']) && ! $isPaste)

Note: The first row is {{ $firstRow }}, not row {{ $headerRow + 1 }}. Set Ignore rows through on upload, or delete earlier batches in Excel.

@endif

Ready to apply: {{ $summary['would_apply'] ?? 0 }} · Skipped: {{ collect($summary)->except(['would_apply', 'applied'])->sum() }}

@@ -80,7 +86,7 @@ - + diff --git a/tests/Unit/BulkUserChanges/BulkUserChangesTextParserTest.php b/tests/Unit/BulkUserChanges/BulkUserChangesTextParserTest.php new file mode 100644 index 000000000..8026db6c9 --- /dev/null +++ b/tests/Unit/BulkUserChanges/BulkUserChangesTextParserTest.php @@ -0,0 +1,71 @@ +parse(self::SAMPLE_PASTE); + + $this->assertSame('paste', $parsed['source']); + $this->assertSame(5, $parsed['meta']['parsed_rows']); + $this->assertSame('anitakocunik@wp.pl', $parsed['meta']['first_email']); + $this->assertSame('joanna.lasek@malmo.se', $parsed['meta']['last_email']); + + $anita = $parsed['rows'][0]; + $this->assertSame('role_add', $anita['operation']); + $this->assertSame('leading teacher', $anita['role_name']); + + $anu = $parsed['rows'][1]; + $this->assertSame('email_update', $anu['operation']); + $this->assertSame('anu.kahri@gmail.com', $anu['new_email']); + + $rory = $parsed['rows'][2]; + $this->assertSame('role_add', $rory['operation']); + $this->assertSame('ambassador', $rory['role_name']); + + $veronica = $parsed['rows'][3]; + $this->assertSame('role_remove', $veronica['operation']); + $this->assertSame('ambassador', $veronica['role_name']); + } + + public function test_rejects_incomplete_paste(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('groups of 5 lines'); + + app(BulkUserChangesTextParser::class)->parse("Poland\nAnita\nanita@example.com\nadd\n"); + } +}
Row{{ $rowLabel ?? 'Row' }} Country Name Email