From 246c8ba95197da939112f0f016baa931ba5c6f7b Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 12:40:34 +0530 Subject: [PATCH 01/11] Store customer documents encrypted --- lang/hi.json | 1 + src/CustomerDocuments.php | 226 ++++++++++++++++++++++++ src/IdNumber.php | 86 +++++++++ src/StoredFile.php | 53 ++++++ tests/Feature/CustomerDocumentsTest.php | 158 +++++++++++++++++ tests/Feature/IdNumberTest.php | 47 +++++ tests/Pest.php | 35 ++++ 7 files changed, 606 insertions(+) create mode 100644 src/CustomerDocuments.php create mode 100644 src/IdNumber.php create mode 100644 src/StoredFile.php create mode 100644 tests/Feature/CustomerDocumentsTest.php create mode 100644 tests/Feature/IdNumberTest.php diff --git a/lang/hi.json b/lang/hi.json index 11f851b..bb68b59 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -6,6 +6,7 @@ "Last 4 characters only": "केवल आखिरी 4 अक्षर", "No ID number": "कोई आईडी नंबर नहीं", "Restore deleted documents": "हटाए गए दस्तावेज़ वापस लाएँ", + "That type already has a file. Delete that one first.": "इस प्रकार की फ़ाइल पहले से है। पहले उसे हटाएँ।", "Upload documents": "दस्तावेज़ अपलोड करें", "View customer documents": "ग्राहक दस्तावेज़ देखें" } diff --git a/src/CustomerDocuments.php b/src/CustomerDocuments.php new file mode 100644 index 0000000..16b918e --- /dev/null +++ b/src/CustomerDocuments.php @@ -0,0 +1,226 @@ +files->put($account->id, $file); + + try { + // A second save that raced past the lock hits the unique current_key; the retry hides its file. + [$document, $replaced] = retry(2, fn (): array => DB::connection('documents')->transaction( + fn (): array => $this->storeRow($account, $type, $stored, $typedNumber, $by), + ), 0, fn (Throwable $e): bool => $e instanceof UniqueConstraintViolationException); + } catch (Throwable $exception) { + $this->files->erase($stored['path']); + + throw $exception; + } + + $this->log($replaced ? 'document-replaced' : 'document-uploaded', $account, $type->name, $by); + + return $document; + } + + /** + * Moves a document to another type and/or changes its number: null keeps the number, '' clears it. The number + * then follows the type's setting (kept, cut to the last 4, or dropped). + */ + public function edit(Document $document, DocumentType $to, ?string $typedNumber, User $by): void + { + $from = $document->type; + $number = $typedNumber === null ? $document->number : IdNumber::normalise($typedNumber); + $number = $number === null || $number === '' ? null : IdNumber::forMode($number, $to->number_mode); + + $this->refuseTaken($document, $to->id, 'type', function () use ($document, $to, $number): void { + $document->forceFill([ + 'document_type_id' => $to->id, + 'number' => $number, + 'number_fingerprint' => self::fingerprintFor($number, $to->number_mode), + ])->save(); + }); + + $this->log('document-edited', $this->account($document), $to->name, $by, ['from' => $from->name]); + } + + public function delete(Document $document, User $by): void + { + $document->forceFill(['deleted_by' => $by->id])->save(); + $document->delete(); + + $this->log('document-deleted', $this->account($document), $document->type->name, $by); + } + + public function restore(Document $document, User $by): void + { + $this->refuseTaken($document, $document->document_type_id, 'document', function () use ($document): void { + $document->forceFill(['deleted_by' => null])->restore(); + }); + + $this->log('document-restored', $this->account($document), $document->type->name, $by); + } + + /** + * Removes the file and its row for good. Backups taken before still hold it until they age out. + */ + public function erase(Document $document, User $by): void + { + $account = $this->account($document); + $typeName = $document->type->name; + $document->forceDelete(); + $this->files->erase($document->path); + + $this->log('document-erased', $account, $typeName, $by); + } + + /** + * Other customers whose current document holds the same full number (compared on letters and digits, ignoring + * case). Types that keep only the last 4 are never compared: 4 characters match far too often. + * + * @return Collection + */ + public function duplicates(Account $account, DocumentType $type, string $typedNumber): Collection + { + if ($type->number_mode !== NumberMode::Full || IdNumber::normalise($typedNumber) === '') { + return new Collection; + } + + $ids = Document::query() + ->where('number_fingerprint', IdNumber::fingerprint($typedNumber)) + ->where('account_id', '!=', $account->id) + ->distinct() + ->pluck('account_id'); + + return Account::withTrashed()->whereIn('id', $ids)->orderBy('username')->get(); + } + + /** + * Active required types this customer has no current file for, in order. + * + * @return Collection + */ + public function missingTypes(Account $account): Collection + { + return DocumentType::query() + ->where('active', true) + ->where('required', true) + ->whereNotIn('id', Document::query()->where('account_id', $account->id)->select('document_type_id')) + ->orderBy('position') + ->get(); + } + + /** + * An Activity log entry on the customer: who, which type and the event, never a number or a file. + * + * @param array $properties + */ + public function log(string $event, ?Account $account, string $typeName, ?User $by, array $properties = []): void + { + $entry = activity()->event($event)->withProperties(['type' => $typeName, ...$properties]); + + if ($account !== null) { + $entry->performedOn($account); + } + + if ($by !== null) { + $entry->causedBy($by); + } + + $entry->log($event); + } + + /** + * @param array{path: string, mime: string, size: int, original_name: string} $stored + * @return array{0: Document, 1: bool} + */ + private function storeRow(Account $account, DocumentType $type, array $stored, ?string $typedNumber, User $by): array + { + $current = Document::query() + ->where('account_id', $account->id) + ->where('document_type_id', $type->id) + ->lockForUpdate() + ->first(); + + $number = $typedNumber === null ? null : IdNumber::forMode($typedNumber, $type->number_mode); + $fingerprint = self::fingerprintFor($number, $type->number_mode); + + if ($current !== null) { + if ($number === null) { + [$number, $fingerprint] = [$current->number, $current->number_fingerprint]; + } + + $current->forceFill(['deleted_by' => $by->id])->save(); + $current->delete(); + } + + $document = Document::query()->create([ + ...$stored, + 'account_id' => $account->id, + 'document_type_id' => $type->id, + 'number' => $number, + 'number_fingerprint' => $fingerprint, + 'uploaded_by' => $by->id, + ]); + + return [$document, $current !== null]; + } + + /** + * Runs the change unless the customer already has another current file of that type, which the unique + * current_key also refuses when two saves race. + */ + private function refuseTaken(Document $document, int $typeId, string $key, callable $change): void + { + $taken = Document::query() + ->where('account_id', $document->account_id) + ->where('document_type_id', $typeId) + ->whereKeyNot($document->id) + ->exists(); + + $refusal = ValidationException::withMessages([$key => __('That type already has a file. Delete that one first.')]); + + if ($taken) { + throw $refusal; + } + + try { + $change(); + } catch (UniqueConstraintViolationException) { + throw $refusal; + } + } + + private static function fingerprintFor(?string $number, NumberMode $mode): ?string + { + return $number !== null && $mode === NumberMode::Full ? IdNumber::fingerprint($number) : null; + } + + private function account(Document $document): ?Account + { + return Account::withTrashed()->find($document->account_id); + } +} diff --git a/src/IdNumber.php b/src/IdNumber.php new file mode 100644 index 0000000..263eb25 --- /dev/null +++ b/src/IdNumber.php @@ -0,0 +1,86 @@ + preg_match('/[\p{L}\p{N}]/u', $c) === 1)) - 4; + + return implode('', array_map(function (string $c) use (&$hide): string { + if ($hide > 0 && preg_match('/[\p{L}\p{N}]/u', $c) === 1) { + $hide--; + + return 'x'; + } + + return $c; + }, $characters)); + } + + /** + * A keyed hash of the letters and digits, ignoring case, so the same card typed differently is found again + * without decrypting any stored number. + */ + public static function fingerprint(string $number): string + { + preg_match_all('/[\p{L}\p{N}]/u', mb_strtolower($number), $matches); + + return hash_hmac('sha256', implode('', $matches[0]), (string) config('app.key')); + } + + /** + * What a type with this setting keeps of a typed number; null when it keeps nothing. + */ + public static function forMode(string $typed, NumberMode $mode): ?string + { + $number = self::normalise($typed); + + return match (true) { + $number === '', $mode === NumberMode::None => null, + $mode === NumberMode::LastFour => self::lastFour($number), + default => $number, + }; + } +} diff --git a/src/StoredFile.php b/src/StoredFile.php new file mode 100644 index 0000000..b74c5b9 --- /dev/null +++ b/src/StoredFile.php @@ -0,0 +1,53 @@ +/<40 + * random characters>. + */ +final class StoredFile +{ + public const DISK = 'addons'; + + /** + * Encrypts and stores an upload, then deletes Livewire's readable temporary copy. + * + * @return array{path: string, mime: string, size: int, original_name: string} + */ + public function put(int $accountId, UploadedFile $file): array + { + $bytes = (string) $file->get(); + $path = "documents/{$accountId}/".Str::random(40); + Storage::disk(self::DISK)->put($path, Crypt::encryptString($bytes)); + + if ($file instanceof TemporaryUploadedFile) { + $file->delete(); + } + + return [ + 'path' => $path, + 'mime' => (string) (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes), + 'size' => strlen($bytes), + 'original_name' => Str::limit($file->getClientOriginalName(), 250, ''), + ]; + } + + public function read(Document $document): string + { + return Crypt::decryptString((string) Storage::disk(self::DISK)->get($document->path)); + } + + public function erase(string $path): void + { + Storage::disk(self::DISK)->delete($path); + } +} diff --git a/tests/Feature/CustomerDocumentsTest.php b/tests/Feature/CustomerDocumentsTest.php new file mode 100644 index 0000000..f9d6b9d --- /dev/null +++ b/tests/Feature/CustomerDocumentsTest.php @@ -0,0 +1,158 @@ +documents = app(CustomerDocuments::class); + $this->staff = User::factory()->create(); + $this->account = Account::factory()->create(); +}); + +it('stores the file encrypted and reads it back', function () { + $document = $this->documents->upload($this->account, documentType(), pngUpload(), null, $this->staff); + + expect($document->path)->toMatch('#^documents/'.$this->account->id.'/[A-Za-z0-9]{40}$#') + ->and(Storage::disk('addons')->get($document->path))->not->toBe(pngBytes()) + ->and(app(StoredFile::class)->read($document))->toBe(pngBytes()) + ->and($document->mime)->toBe('image/png') + ->and($document->original_name)->toBe('aadhaar.png') + ->and($document->uploaded_by)->toBe($this->staff->id); +}); + +it('stores a full number encrypted and finds it by fingerprint', function () { + $document = $this->documents->upload($this->account, documentType(), pngUpload(), '1234 5678 4821', $this->staff); + + $raw = Document::query()->toBase()->where('id', $document->id)->value('number'); + + expect($document->fresh()->number)->toBe('1234 5678 4821') + ->and($raw)->not->toContain('4821') + ->and($document->fresh()->number_fingerprint)->not->toBeNull(); +}); + +it('hides the old file on replace and carries the number over', function () { + $type = documentType(); + $first = $this->documents->upload($this->account, $type, pngUpload(), '1234 5678 4821', $this->staff); + $second = $this->documents->upload($this->account, $type, pngUpload('new.png'), null, $this->staff); + + expect($first->fresh()->trashed())->toBeTrue() + ->and($first->fresh()->deleted_by)->toBe($this->staff->id) + ->and($first->fresh()->number)->toBe('1234 5678 4821') + ->and($second->number)->toBe('1234 5678 4821') + ->and($second->number_fingerprint)->toBe($first->fresh()->number_fingerprint) + ->and(Storage::disk('addons')->exists($first->path))->toBeTrue(); +}); + +it('keeps one current file when two uploads race', function () { + $type = documentType(); + Document::query()->create(['account_id' => $this->account->id, 'document_type_id' => $type->id, 'path' => 'documents/x', 'original_name' => 'x.png', 'mime' => 'image/png', 'size' => 1]); + + $this->documents->upload($this->account, $type, pngUpload(), null, $this->staff); + + expect(Document::query()->where('account_id', $this->account->id)->count())->toBe(1) + ->and(Document::withTrashed()->where('account_id', $this->account->id)->count())->toBe(2); +}); + +it('refuses to move a document onto a taken type', function () { + $aadhaar = documentType(); + $pan = documentType('PAN card'); + $this->documents->upload($this->account, $pan, pngUpload(), null, $this->staff); + $document = $this->documents->upload($this->account, $aadhaar, pngUpload(), null, $this->staff); + + expect(fn () => $this->documents->edit($document, $pan, null, $this->staff))->toThrow(ValidationException::class); + expect($document->fresh()->document_type_id)->toBe($aadhaar->id); +}); + +it('cuts, keeps or drops the number by the target type', function (NumberMode $mode, ?string $expected) { + $document = $this->documents->upload($this->account, documentType(), pngUpload(), '1234 5678 4821', $this->staff); + + $this->documents->edit($document, documentType('Other', ['number_mode' => $mode]), null, $this->staff); + + expect($document->fresh()->number)->toBe($expected); +})->with([ + 'full' => [NumberMode::Full, '1234 5678 4821'], + 'last 4' => [NumberMode::LastFour, '4821'], + 'none' => [NumberMode::None, null], +]); + +it('changes or clears the number on edit, and keeps it when left out', function () { + $type = documentType(); + $document = $this->documents->upload($this->account, $type, pngUpload(), '1234 5678 4821', $this->staff); + + $this->documents->edit($document, $type, null, $this->staff); + expect($document->fresh()->number)->toBe('1234 5678 4821'); + + $this->documents->edit($document, $type, '9999 8888 7777', $this->staff); + expect($document->fresh()->number)->toBe('9999 8888 7777'); + + $this->documents->edit($document, $type, '', $this->staff); + expect($document->fresh()->number)->toBeNull()->and($document->fresh()->number_fingerprint)->toBeNull(); +}); + +it('hides on delete and restores unless the type is taken', function () { + $type = documentType(); + $document = $this->documents->upload($this->account, $type, pngUpload(), null, $this->staff); + + $this->documents->delete($document, $this->staff); + expect($document->fresh()->trashed())->toBeTrue()->and($document->fresh()->deleted_by)->toBe($this->staff->id); + + $this->documents->restore($document->fresh(), $this->staff); + expect($document->fresh()->trashed())->toBeFalse(); + + $this->documents->delete($document->fresh(), $this->staff); + $this->documents->upload($this->account, $type, pngUpload(), null, $this->staff); + + expect(fn () => $this->documents->restore($document->fresh(), $this->staff))->toThrow(ValidationException::class); +}); + +it('erases the file and the row', function () { + $document = $this->documents->upload($this->account, documentType(), pngUpload(), null, $this->staff); + $this->documents->delete($document, $this->staff); + + $this->documents->erase($document->fresh(), $this->staff); + + expect(Document::withTrashed()->find($document->id))->toBeNull() + ->and(Storage::disk('addons')->exists($document->path))->toBeFalse(); +}); + +it('logs each change on the customer without the number', function () { + $type = documentType(); + $document = $this->documents->upload($this->account, $type, pngUpload(), 'ABCDE1234F', $this->staff); + $this->documents->upload($this->account, $type, pngUpload(), null, $this->staff); + $this->documents->delete($document->fresh(), $this->staff); + $this->documents->erase($document->fresh(), $this->staff); + + $entries = Activity::query()->where('subject_id', $this->account->id)->where('subject_type', $this->account->getMorphClass())->where('event', 'like', 'document-%')->get(); + + expect($entries->pluck('event')->all())->toBe(['document-uploaded', 'document-replaced', 'document-deleted', 'document-erased']) + ->and($entries->pluck('causer_id')->unique()->all())->toBe([$this->staff->id]) + ->and($entries->first()->properties['type'])->toBe('Aadhaar card') + ->and(json_encode($entries->pluck('properties')))->not->toContain('1234'); +}); + +it('finds a duplicate full number on another customer only', function () { + $type = documentType(); + $other = Account::factory()->create(); + $this->documents->upload($other, $type, pngUpload(), 'ABCDE1234F', $this->staff); + $this->documents->upload($this->account, $type, pngUpload(), 'ABCDE1234F', $this->staff); + + expect($this->documents->duplicates($this->account, $type, 'abcde 1234-f')->pluck('id')->all())->toBe([$other->id]) + ->and($this->documents->duplicates($this->account, documentType('Agreement', ['number_mode' => NumberMode::LastFour]), '1234F'))->toBeEmpty(); +}); + +it('lists missing required active types', function () { + $aadhaar = documentType(); + documentType('Photo'); + documentType('Agreement', ['required' => false]); + documentType('Old form', ['active' => false]); + $this->documents->upload($this->account, $aadhaar, pngUpload(), null, $this->staff); + + expect($this->documents->missingTypes($this->account)->pluck('name')->all())->toBe(['Photo']); +}); diff --git a/tests/Feature/IdNumberTest.php b/tests/Feature/IdNumberTest.php new file mode 100644 index 0000000..a1ecd09 --- /dev/null +++ b/tests/Feature/IdNumberTest.php @@ -0,0 +1,47 @@ +toBe('ABCDE 1234F') + ->and(IdNumber::normalise('mÑo&1'))->toBe('mÑo&1'); +}); + +it('takes one line of up to 40 printable characters', function (string $typed, bool $valid) { + expect(IdNumber::isValid($typed))->toBe($valid); +})->with([ + 'line break' => ["12\n34", false], + 'control character' => ["12\x0034", false], + 'too long' => [str_repeat('a', 41), false], + 'only spaces' => [' ', false], + 'Chile RUT' => ['12.345.678-K', true], + 'Mexico RFC' => ['MÑO&1', true], + 'Hong Kong ID' => ['A123456(7)', true], + 'forty' => [str_repeat('a', 40), true], +]); + +it('keeps the last 4 letters or digits', function () { + expect(IdNumber::lastFour('RJ-13/DLC/12/123456'))->toBe('3456') + ->and(IdNumber::lastFour('ABCDE1234F'))->toBe('234F') + ->and(IdNumber::lastFour('1234 5678 4821'))->toBe('4821'); +}); + +it('masks all but the last 4 letters or digits, keeping spaces and symbols', function () { + expect(IdNumber::mask('1234 5678 4821'))->toBe('xxxx xxxx 4821') + ->and(IdNumber::mask('A123456(7)'))->toBe('xxxx456(7)') + ->and(IdNumber::mask('4821'))->toBe('4821'); +}); + +it('fingerprints on letters and digits only, ignoring case', function () { + expect(IdNumber::fingerprint('abcde1234f'))->toBe(IdNumber::fingerprint('ABCDE 1234-F')) + ->and(IdNumber::fingerprint('ABCDE1234F'))->not->toBe(IdNumber::fingerprint('ABCDE1234G')) + ->and(IdNumber::fingerprint('ABCDE1234F'))->not->toContain('1234'); +}); + +it('keeps what the type allows', function () { + expect(IdNumber::forMode(' 1234 5678 4821 ', NumberMode::Full))->toBe('1234 5678 4821') + ->and(IdNumber::forMode('1234 5678 4821', NumberMode::LastFour))->toBe('4821') + ->and(IdNumber::forMode('1234 5678 4821', NumberMode::None))->toBeNull() + ->and(IdNumber::forMode(' ', NumberMode::Full))->toBeNull(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index e80833d..afa4d5f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,8 +1,11 @@ in(__DIR__); + +/** + * The smallest valid PNG and PDF, so a file's type is detected from its bytes. + */ +function pngBytes(): string +{ + return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='); +} + +function pdfBytes(): string +{ + return "%PDF-1.4\n1 0 obj<>endobj\ntrailer<>\n%%EOF\n"; +} + +function pngUpload(string $name = 'aadhaar.png'): UploadedFile +{ + return UploadedFile::fake()->createWithContent($name, pngBytes()); +} + +/** + * @param array $attributes + */ +function documentType(string $name = 'Aadhaar card', array $attributes = []): DocumentType +{ + return DocumentType::query()->create([ + 'name' => $name, + 'required' => true, + 'number_mode' => NumberMode::Full, + 'position' => DocumentType::query()->count() + 1, + ...$attributes, + ]); +} From 09c2f850c63e8a4bd74910918e6d496165beda57 Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 12:43:22 +0530 Subject: [PATCH 02/11] Let the ISP write its document types --- lang/hi.json | 19 ++ .../livewire/settings/settings.blade.php | 73 ++++++ .../views/livewire/settings/settings.php | 232 ++++++++++++++++++ src/DocumentsServiceProvider.php | 3 + tests/Feature/SettingsTest.php | 122 +++++++++ tests/Pest.php | 26 ++ 6 files changed, 475 insertions(+) create mode 100644 resources/views/livewire/settings/settings.blade.php create mode 100644 resources/views/livewire/settings/settings.php create mode 100644 tests/Feature/SettingsTest.php diff --git a/lang/hi.json b/lang/hi.json index bb68b59..43484a3 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -1,12 +1,31 @@ { + ":count stored number will be cut to its last 4 characters. This cannot be undone.|:count stored numbers will be cut to their last 4 characters. This cannot be undone.": ":count सेव किया गया नंबर उसके आखिरी 4 अक्षरों तक छोटा हो जाएगा। यह वापस नहीं हो सकता।|:count सेव किए गए नंबर उनके आखिरी 4 अक्षरों तक छोटे हो जाएँगे। यह वापस नहीं हो सकता।", + "A customer without it still saves; staff see a warning and the customer is listed under Missing documents.": "इसके बिना भी ग्राहक सेव होता है; स्टाफ़ को चेतावनी दिखती है और ग्राहक कमी वाले दस्तावेज़ों की सूची में आता है।", + "Add document type": "दस्तावेज़ का प्रकार जोड़ें", + "Cut the numbers and save": "नंबर छोटे करें और सेव करें", + "Document type": "दस्तावेज़ का प्रकार", + "Document type saved.": "दस्तावेज़ का प्रकार सेव हो गया।", "Document types": "दस्तावेज़ के प्रकार", "Edit and delete documents": "दस्तावेज़ बदलें और हटाएँ", + "Edit document type": "दस्तावेज़ का प्रकार बदलें", "Erase deleted documents": "हटाए गए दस्तावेज़ हमेशा के लिए मिटाएँ", "Full ID number": "पूरा आईडी नंबर", + "Help text": "मदद का टेक्स्ट", + "ID number": "आईडी नंबर", "Last 4 characters only": "केवल आखिरी 4 अक्षर", + "Move down": "नीचे ले जाएँ", + "Move up": "ऊपर ले जाएँ", "No ID number": "कोई आईडी नंबर नहीं", + "Off": "बंद", + "Required": "ज़रूरी", + "Required for every customer": "हर ग्राहक के लिए ज़रूरी", "Restore deleted documents": "हटाए गए दस्तावेज़ वापस लाएँ", + "Shown to staff when they upload, e.g. Upload a masked Aadhaar (first 8 digits hidden).": "अपलोड करते समय स्टाफ़ को दिखता है, जैसे: मास्क्ड आधार अपलोड करें (पहले 8 अंक छिपे हुए)।", + "Switch off": "बंद करें", + "Switch on": "चालू करें", "That type already has a file. Delete that one first.": "इस प्रकार की फ़ाइल पहले से है। पहले उसे हटाएँ।", + "The documents your staff keep for each customer. A type that has documents can be switched off but not deleted.": "हर ग्राहक के लिए आपका स्टाफ़ जो दस्तावेज़ रखता है। जिस प्रकार के दस्तावेज़ हैं उसे बंद किया जा सकता है, हटाया नहीं।", + "This type has documents. Switch it off instead.": "इस प्रकार के दस्तावेज़ हैं। इसकी जगह इसे बंद करें।", "Upload documents": "दस्तावेज़ अपलोड करें", "View customer documents": "ग्राहक दस्तावेज़ देखें" } diff --git a/resources/views/livewire/settings/settings.blade.php b/resources/views/livewire/settings/settings.blade.php new file mode 100644 index 0000000..8c92249 --- /dev/null +++ b/resources/views/livewire/settings/settings.blade.php @@ -0,0 +1,73 @@ +
+ {{ __('The documents your staff keep for each customer. A type that has documents can be switched off but not deleted.') }} + + @error('types') + {{ $message }} + @enderror + + @if ($this->rows !== []) + + + {{ __('Document type') }} + {{ __('ID number') }} + {{ __('Actions') }} + + + @foreach ($this->rows as $row) + + + {{ $row['name'] }} + @if ($row['required']) + {{ __('Required') }} + @endif + @unless ($row['active']) + {{ __('Off') }} + @endunless + + {{ $row['mode'] }} + + {{ __('Move up') }} + {{ __('Move down') }} + {{ __('Edit') }} + {{ $row['active'] ? __('Switch off') : __('Switch on') }} + {{ __('Delete') }} + + + @endforeach + + @endif + +
+ {{ $editingId === null ? __('Add document type') : __('Edit document type') }} +
+ + +
+ + + + @if ($cutCount > 0) + + {{ trans_choice(':count stored number will be cut to its last 4 characters. This cannot be undone.|:count stored numbers will be cut to their last 4 characters. This cannot be undone.', $cutCount) }} +
+ {{ __('Cut the numbers and save') }} + {{ __('Cancel') }} +
+
+ @endif + +
+ {{ __('Save') }} + @if ($editingId !== null) + {{ __('Cancel') }} + @endif +
+ +
diff --git a/resources/views/livewire/settings/settings.php b/resources/views/livewire/settings/settings.php new file mode 100644 index 0000000..8c4ab33 --- /dev/null +++ b/resources/views/livewire/settings/settings.php @@ -0,0 +1,232 @@ +authorizeAccess(); + } + + protected function authorizeAccess(): void + { + $this->authorize(Abilities::TYPES); + } + + /** + * @return list + */ + #[Computed] + public function rows(): array + { + $types = DocumentType::query()->orderBy('position')->orderBy('id')->get(); + + return $types->values()->map(fn (DocumentType $type, int $i): array => [ + 'id' => $type->id, + 'name' => $type->name, + 'required' => $type->required, + 'mode' => $type->number_mode->label(), + 'active' => $type->active, + 'first' => $i === 0, + 'last' => $i === $types->count() - 1, + ])->all(); + } + + /** + * @return array + */ + #[Computed] + public function modes(): array + { + $modes = []; + + foreach (NumberMode::cases() as $mode) { + $modes[$mode->value] = $mode->label(); + } + + return $modes; + } + + public function edit(int $id): void + { + $this->authorize(Abilities::TYPES); + $type = DocumentType::query()->findOrFail($id); + $this->editingId = $type->id; + $this->name = $type->name; + $this->help = (string) $type->help; + $this->required = $type->required; + $this->numberMode = $type->number_mode->value; + $this->cutCount = 0; + $this->resetErrorBag(); + } + + public function cancel(): void + { + $this->reset('editingId', 'name', 'help', 'required', 'numberMode', 'cutCount'); + $this->resetErrorBag(); + } + + /** + * Adds or updates the type. Switching a type from full numbers to the last 4 first asks, because it cuts every + * number already stored for it. + */ + public function save(): void + { + $this->authorize(Abilities::TYPES); + $this->validate($this->rules()); + $type = $this->editingId === null ? null : DocumentType::query()->findOrFail($this->editingId); + + if ($type?->number_mode === NumberMode::Full && $this->numberMode === NumberMode::LastFour->value) { + $this->cutCount = Document::withTrashed()->where('document_type_id', $type->id)->whereNotNull('number')->count(); + + if ($this->cutCount > 0) { + return; + } + } + + $this->store($type); + } + + /** + * After the confirm: cuts every stored number of the type (hidden documents too) to its last 4 and saves. + */ + public function cutNumbers(): void + { + $this->authorize(Abilities::TYPES); + $this->validate($this->rules()); + $type = DocumentType::query()->findOrFail($this->editingId); + $count = 0; + + DB::connection('documents')->transaction(function () use ($type, &$count): void { + Document::withTrashed()->where('document_type_id', $type->id)->whereNotNull('number')->lazyById() + ->each(function (Document $document) use (&$count): void { + $document->forceFill(['number' => IdNumber::lastFour((string) $document->number), 'number_fingerprint' => null])->save(); + $count++; + }); + $this->store($type); + }); + + app(CustomerDocuments::class)->log('document-numbers-cut', null, $type->name, auth()->user(), ['count' => $count]); + } + + public function moveUp(int $id): void + { + $this->move($id, -1); + } + + public function moveDown(int $id): void + { + $this->move($id, 1); + } + + public function toggleActive(int $id): void + { + $this->authorize(Abilities::TYPES); + $type = DocumentType::query()->findOrFail($id); + $type->update(['active' => ! $type->active]); + } + + /** + * Only a type that never had a document; one with documents (hidden ones too) is switched off instead. + */ + public function delete(int $id): void + { + $this->authorize(Abilities::TYPES); + $type = DocumentType::query()->findOrFail($id); + + if ($type->documents()->exists()) { + throw ValidationException::withMessages(['types' => __('This type has documents. Switch it off instead.')]); + } + + $type->delete(); + + if ($this->editingId === $id) { + $this->cancel(); + } + } + + /** + * @return array + */ + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:60', Rule::unique('documents.document_types', 'name')->ignore($this->editingId)], + 'help' => ['nullable', 'string', 'max:200'], + 'required' => ['boolean'], + 'numberMode' => ['required', Rule::enum(NumberMode::class)], + ]; + } + + private function store(?DocumentType $type): void + { + $values = [ + 'name' => trim($this->name), + 'help' => trim($this->help) === '' ? null : trim($this->help), + 'required' => $this->required, + 'number_mode' => $this->numberMode, + ]; + + if ($type === null) { + DocumentType::query()->create([...$values, 'position' => (int) DocumentType::query()->max('position') + 1]); + } else { + $type->update($values); + } + + $this->cancel(); + $this->toast(__('Document type saved.')); + } + + private function move(int $id, int $step): void + { + $this->authorize(Abilities::TYPES); + $types = DocumentType::query()->orderBy('position')->orderBy('id')->get()->values(); + $index = $types->search(fn (DocumentType $type): bool => $type->id === $id); + $swap = $index === false ? null : $types->get($index + $step); + + if ($swap === null) { + return; + } + + $order = $types->pluck('id')->all(); + [$order[$index], $order[$index + $step]] = [$order[$index + $step], $order[$index]]; + + foreach ($order as $position => $typeId) { + DocumentType::query()->whereKey($typeId)->update(['position' => $position + 1]); + } + } +}; diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index 912d7d1..3f5f3dc 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -2,6 +2,7 @@ namespace Ispgrid\Documents; +use App\Support\Addons\AddonSettings; use App\Support\Addons\PrivateFile; use App\Support\Permissions; use Illuminate\Support\ServiceProvider; @@ -44,5 +45,7 @@ public function boot(): void $this->loadViewsFrom(__DIR__.'/../resources/views', 'documents'); $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); Livewire::addNamespace('documents', viewPath: __DIR__.'/../resources/views/livewire'); + + $this->app->make(AddonSettings::class)->register('ispgrid/documents', 'documents::settings', Abilities::TYPES); } } diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php new file mode 100644 index 0000000..d3d9311 --- /dev/null +++ b/tests/Feature/SettingsTest.php @@ -0,0 +1,122 @@ +actingAs(User::factory()->create()); + $this->get(route('addons.index'))->assertOk()->assertSee(__('Add document type')); + + Role::create(['name' => 'Desk', 'guard_name' => 'web'])->givePermissionTo('manage-app-settings'); + $this->actingAs(User::factory()->role('Desk')->create()); + $this->get(route('addons.index'))->assertOk()->assertDontSee(__('Add document type')); + Livewire::test('documents::settings')->assertForbidden(); +}); + +it('adds and renames a type, keeping names unique and short', function () { + $this->actingAs(User::factory()->create()); + + Livewire::test('documents::settings') + ->set('name', 'Aadhaar card')->set('help', 'Upload a masked Aadhaar')->set('required', true)->set('numberMode', 'full') + ->call('save')->assertHasNoErrors() + ->set('name', 'Aadhaar card')->call('save')->assertHasErrors(['name']) + ->set('name', str_repeat('a', 61))->call('save')->assertHasErrors(['name']) + ->set('name', 'PAN')->set('help', str_repeat('a', 201))->call('save')->assertHasErrors(['help']) + ->set('numberMode', 'everything')->set('help', '')->call('save')->assertHasErrors(['numberMode']); + + $type = DocumentType::query()->sole(); + expect($type->only(['name', 'help', 'required']))->toBe(['name' => 'Aadhaar card', 'help' => 'Upload a masked Aadhaar', 'required' => true]) + ->and($type->number_mode)->toBe(NumberMode::Full); + + Livewire::test('documents::settings')->call('edit', $type->id)->assertSet('name', 'Aadhaar card') + ->set('name', 'Aadhaar')->call('save')->assertHasNoErrors(); + + expect($type->fresh()->name)->toBe('Aadhaar')->and(DocumentType::query()->count())->toBe(1); +}); + +it('orders the types', function () { + $this->actingAs(User::factory()->create()); + [$a, $b] = [documentType('A'), documentType('B')]; + + Livewire::test('documents::settings')->call('moveUp', $b->id); + + expect(DocumentType::query()->orderBy('position')->pluck('name')->all())->toBe(['B', 'A']); + + Livewire::test('documents::settings')->call('moveDown', $b->id); + + expect(DocumentType::query()->orderBy('position')->pluck('name')->all())->toBe(['A', 'B']); +}); + +it('deletes an unused type but only switches off a type that has documents', function () { + $staff = User::factory()->create(); + $this->actingAs($staff); + $unused = documentType('Unused'); + $used = documentType('Used'); + $document = app(CustomerDocuments::class)->upload(Account::factory()->create(), $used, pngUpload(), null, $staff); + app(CustomerDocuments::class)->delete($document, $staff); + + Livewire::test('documents::settings') + ->call('delete', $unused->id)->assertHasNoErrors() + ->call('delete', $used->id)->assertHasErrors(['types']) + ->call('toggleActive', $used->id); + + expect(DocumentType::query()->pluck('name')->all())->toBe(['Used']) + ->and($used->fresh()->active)->toBeFalse() + ->and(Document::withTrashed()->count())->toBe(1); +}); + +it('asks before cutting stored full numbers to the last 4, then cuts them all', function () { + $staff = User::factory()->create(); + $this->actingAs($staff); + $type = documentType(); + $documents = app(CustomerDocuments::class); + $old = $documents->upload(Account::factory()->create(), $type, pngUpload(), '1111 2222 3333', $staff); + $documents->delete($old, $staff); + $current = $documents->upload(Account::factory()->create(), $type, pngUpload(), '1234 5678 4821', $staff); + + $component = Livewire::test('documents::settings')->call('edit', $type->id)->set('numberMode', 'last4')->call('save') + ->assertSet('cutCount', 2); + + expect($type->fresh()->number_mode)->toBe(NumberMode::Full); + + $component->call('cutNumbers'); + + expect($type->fresh()->number_mode)->toBe(NumberMode::LastFour) + ->and($current->fresh()->number)->toBe('4821') + ->and($current->fresh()->number_fingerprint)->toBeNull() + ->and(Document::withTrashed()->find($old->id)->number)->toBe('3333') + ->and(Activity::query()->where('event', 'document-numbers-cut')->sole()->properties['count'])->toBe(2); +}); + +it('keeps stored numbers when a type stops taking numbers', function () { + $staff = User::factory()->create(); + $this->actingAs($staff); + $type = documentType(); + $document = app(CustomerDocuments::class)->upload(Account::factory()->create(), $type, pngUpload(), '1234 5678 4821', $staff); + + Livewire::test('documents::settings')->call('edit', $type->id)->set('numberMode', 'none')->call('save')->assertSet('cutCount', 0); + + expect($type->fresh()->number_mode)->toBe(NumberMode::None) + ->and($document->fresh()->number)->toBe('1234 5678 4821') + ->and(IdNumber::fingerprint('1234 5678 4821'))->toBe($document->fresh()->number_fingerprint); +}); + +it('stops an open settings section once the box is taken away', function () { + $staff = staffWith(Abilities::TYPES); + $this->actingAs($staff); + $component = Livewire::test('documents::settings')->assertOk(); + + revokeBox($staff, Abilities::TYPES); + + $component->set('name', 'Sneaky')->assertForbidden(); + expect(DocumentType::query()->count())->toBe(0); +}); diff --git a/tests/Pest.php b/tests/Pest.php index afa4d5f..6b0f0a3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,11 +1,16 @@ in(__DIR__); +/** + * A staff member whose role holds exactly these boxes (and the boxes they imply). + */ +function staffWith(string ...$boxes): User +{ + $role = Role::create(['name' => 'Desk '.Str::random(6), 'guard_name' => 'web']); + $role->givePermissionTo(app(Permissions::class)->withImplied($boxes)); + + return User::factory()->role($role->name)->create(); +} + +/** + * Takes a box away the way an Admin does on Roles; the next request loads the staff member fresh. + */ +function revokeBox(User $staff, string $box): void +{ + $staff->roles->each(fn (Role $role) => $role->revokePermissionTo($box)); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $staff->unsetRelation('roles')->unsetRelation('permissions'); +} + /** * The smallest valid PNG and PDF, so a file's type is detected from its bytes. */ From 5d3562c0ec5077c9de2c5244932cbefa3feba13d Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 12:46:47 +0530 Subject: [PATCH 03/11] Add the Documents tab to the customer form --- lang/hi.json | 16 +++ resources/views/customer-tab.blade.php | 56 ++++++++ src/ContentRule.php | 28 ++++ src/CustomerDocuments.php | 5 +- src/DocumentsServiceProvider.php | 2 + src/DocumentsTab.php | 168 ++++++++++++++++++++++++ src/NumberRule.php | 50 +++++++ src/StoredFile.php | 6 + tests/Feature/CustomerDocumentsTest.php | 10 ++ tests/Feature/DocumentsTabTest.php | 164 +++++++++++++++++++++++ 10 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 resources/views/customer-tab.blade.php create mode 100644 src/ContentRule.php create mode 100644 src/DocumentsTab.php create mode 100644 src/NumberRule.php create mode 100644 tests/Feature/DocumentsTabTest.php diff --git a/lang/hi.json b/lang/hi.json index 43484a3..b2d4f74 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -1,31 +1,47 @@ { ":count stored number will be cut to its last 4 characters. This cannot be undone.|:count stored numbers will be cut to their last 4 characters. This cannot be undone.": ":count सेव किया गया नंबर उसके आखिरी 4 अक्षरों तक छोटा हो जाएगा। यह वापस नहीं हो सकता।|:count सेव किए गए नंबर उनके आखिरी 4 अक्षरों तक छोटे हो जाएँगे। यह वापस नहीं हो सकता।", + ":file, uploaded :date by :name": ":file, :date को :name ने अपलोड की", + ":type :number is already on :customer.": ":type :number पहले से :customer के पास है।", "A customer without it still saves; staff see a warning and the customer is listed under Missing documents.": "इसके बिना भी ग्राहक सेव होता है; स्टाफ़ को चेतावनी दिखती है और ग्राहक कमी वाले दस्तावेज़ों की सूची में आता है।", "Add document type": "दस्तावेज़ का प्रकार जोड़ें", + "Add the file for this number.": "इस नंबर के लिए फ़ाइल जोड़ें।", + "Choose file": "फ़ाइल चुनें", "Cut the numbers and save": "नंबर छोटे करें और सेव करें", "Document type": "दस्तावेज़ का प्रकार", "Document type saved.": "दस्तावेज़ का प्रकार सेव हो गया।", "Document types": "दस्तावेज़ के प्रकार", + "Documents": "दस्तावेज़", "Edit and delete documents": "दस्तावेज़ बदलें और हटाएँ", "Edit document type": "दस्तावेज़ का प्रकार बदलें", "Erase deleted documents": "हटाए गए दस्तावेज़ हमेशा के लिए मिटाएँ", "Full ID number": "पूरा आईडी नंबर", "Help text": "मदद का टेक्स्ट", "ID number": "आईडी नंबर", + "JPG, PNG or PDF, up to 10 MB.": "JPG, PNG या PDF, 10 MB तक।", "Last 4 characters only": "केवल आखिरी 4 अक्षर", + "Leave empty to keep the number on file.": "पुराना नंबर रखने के लिए खाली छोड़ें।", + "Missing documents: :types.": "कमी वाले दस्तावेज़: :types।", "Move down": "नीचे ले जाएँ", "Move up": "ऊपर ले जाएँ", + "New ID number (with the new file)": "नया आईडी नंबर (नई फ़ाइल के साथ)", "No ID number": "कोई आईडी नंबर नहीं", + "No document types yet. Add them on System, then Add-ons.": "अभी कोई दस्तावेज़ प्रकार नहीं है। उन्हें सिस्टम, फिर ऐड-ऑन में जोड़ें।", "Off": "बंद", + "Replace with a file": "फ़ाइल से बदलें", + "Replace with a photo": "फ़ोटो से बदलें", "Required": "ज़रूरी", "Required for every customer": "हर ग्राहक के लिए ज़रूरी", "Restore deleted documents": "हटाए गए दस्तावेज़ वापस लाएँ", "Shown to staff when they upload, e.g. Upload a masked Aadhaar (first 8 digits hidden).": "अपलोड करते समय स्टाफ़ को दिखता है, जैसे: मास्क्ड आधार अपलोड करें (पहले 8 अंक छिपे हुए)।", "Switch off": "बंद करें", "Switch on": "चालू करें", + "Take photo": "फ़ोटो लें", "That type already has a file. Delete that one first.": "इस प्रकार की फ़ाइल पहले से है। पहले उसे हटाएँ।", "The documents your staff keep for each customer. A type that has documents can be switched off but not deleted.": "हर ग्राहक के लिए आपका स्टाफ़ जो दस्तावेज़ रखता है। जिस प्रकार के दस्तावेज़ हैं उसे बंद किया जा सकता है, हटाया नहीं।", "This type has documents. Switch it off instead.": "इस प्रकार के दस्तावेज़ हैं। इसकी जगह इसे बंद करें।", + "Type the number on one line, up to :max characters.": "नंबर एक ही लाइन में लिखें, ज़्यादा से ज़्यादा :max अक्षर।", + "Unknown": "अज्ञात", + "Upload a JPG, PNG or PDF.": "JPG, PNG या PDF अपलोड करें।", "Upload documents": "दस्तावेज़ अपलोड करें", "View customer documents": "ग्राहक दस्तावेज़ देखें" } diff --git a/resources/views/customer-tab.blade.php b/resources/views/customer-tab.blade.php new file mode 100644 index 0000000..12a0ea7 --- /dev/null +++ b/resources/views/customer-tab.blade.php @@ -0,0 +1,56 @@ +{{-- The Documents tab, included by core's customer form. It shows only what the tab's state() prepared. --}} +@if (($addonTabs['documents']['types'] ?? []) === []) + {{ __('No document types yet. Add them on System, then Add-ons.') }} +@endif + +@foreach ($addonTabs['documents']['types'] ?? [] as $typeId => $type) +
+
+ + {{ $type['name'] }} + @if ($type['required']) + {{ __('Required') }} + @endif + + @if ($type['help'] !== '') + {{ $type['help'] }} + @endif +
+ + @if ($type['current'] !== null) + + {{ __(':file, uploaded :date by :name', ['file' => $type['current']['name'], 'date' => $type['current']['date'], 'name' => $type['current']['by']]) }} + @if ($type['current']['number'] !== '') + · {{ $type['current']['number'] }} + @endif + + @endif + + @if ($type['numbered']) + + @endif + +
+ + +
+
+@endforeach diff --git a/src/ContentRule.php b/src/ContentRule.php new file mode 100644 index 0000000..004fd8d --- /dev/null +++ b/src/ContentRule.php @@ -0,0 +1,28 @@ +get())) { + $fail(__('Upload a JPG, PNG or PDF.')); + } + } + + public static function allowed(string $bytes): bool + { + return in_array((new finfo(FILEINFO_MIME_TYPE))->buffer($bytes), self::TYPES, true); + } +} diff --git a/src/CustomerDocuments.php b/src/CustomerDocuments.php index 16b918e..4b2b4a7 100644 --- a/src/CustomerDocuments.php +++ b/src/CustomerDocuments.php @@ -99,7 +99,8 @@ public function erase(Document $document, User $by): void /** * Other customers whose current document holds the same full number (compared on letters and digits, ignoring - * case). Types that keep only the last 4 are never compared: 4 characters match far too often. + * case); for a customer not saved yet (no id), every customer. Types that keep only the last 4 are never + * compared: 4 characters match far too often. * * @return Collection */ @@ -111,7 +112,7 @@ public function duplicates(Account $account, DocumentType $type, string $typedNu $ids = Document::query() ->where('number_fingerprint', IdNumber::fingerprint($typedNumber)) - ->where('account_id', '!=', $account->id) + ->when($account->id !== null, fn ($query) => $query->where('account_id', '!=', $account->id)) ->distinct() ->pluck('account_id'); diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index 3f5f3dc..6f37518 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -3,6 +3,7 @@ namespace Ispgrid\Documents; use App\Support\Addons\AddonSettings; +use App\Support\Addons\CustomerFormTabs; use App\Support\Addons\PrivateFile; use App\Support\Permissions; use Illuminate\Support\ServiceProvider; @@ -47,5 +48,6 @@ public function boot(): void Livewire::addNamespace('documents', viewPath: __DIR__.'/../resources/views/livewire'); $this->app->make(AddonSettings::class)->register('ispgrid/documents', 'documents::settings', Abilities::TYPES); + $this->app->make(CustomerFormTabs::class)->register(DocumentsTab::class); } } diff --git a/src/DocumentsTab.php b/src/DocumentsTab.php new file mode 100644 index 0000000..b66dfd2 --- /dev/null +++ b/src/DocumentsTab.php @@ -0,0 +1,168 @@ +can(Abilities::UPLOAD) && ($account === null || ! $account->trashed()); + } + + /** + * Empty file and number fields, and "types": what the tab shows (display only, it has no rule, so core drops it + * before saving). A stored number is only ever sent masked. + * + * @return array + */ + public function state(?Account $account): array + { + $current = $account === null + ? new Collection + : Document::query()->where('account_id', $account->id)->get()->keyBy('document_type_id'); + $uploaders = User::query()->whereIn('id', $current->pluck('uploaded_by')->filter())->pluck('name', 'id'); + + return [ + 'files' => [], + 'numbers' => [], + 'types' => $this->activeTypes()->mapWithKeys(fn (DocumentType $type): array => [$type->id => [ + 'name' => $type->name, + 'required' => $type->required, + 'help' => (string) $type->help, + 'numbered' => $type->number_mode !== NumberMode::None, + 'current' => ($document = $current->get($type->id)) === null ? null : [ + 'name' => $document->original_name, + 'date' => $document->created_at?->toFormattedDateString(), + 'by' => (string) ($uploaders[$document->uploaded_by] ?? __('Unknown')), + 'number' => $document->number === null ? '' : IdNumber::mask($document->number), + ], + ]])->all(), + ]; + } + + /** + * @return array + */ + public function rules(?Account $account): array + { + $types = $this->activeTypes(); + $numbered = $types->filter(fn (DocumentType $type): bool => $type->number_mode !== NumberMode::None); + + return [ + 'files' => ['array', self::keysIn($types)], + 'files.*' => ['nullable', 'file', 'mimetypes:'.implode(',', ContentRule::TYPES), 'max:10000', new ContentRule], + 'numbers' => ['array', self::keysIn($numbered)], + 'numbers.*' => ['nullable', 'string', new NumberRule], + ]; + } + + /** + * @param array $state + * @return list + */ + public function warnings(array $state, ?Account $account): array + { + $files = is_array($state['files'] ?? null) ? array_filter($state['files']) : []; + $numbers = is_array($state['numbers'] ?? null) ? $state['numbers'] : []; + $types = $this->activeTypes(); + $stored = $account === null ? [] : Document::query()->where('account_id', $account->id)->pluck('document_type_id')->all(); + + $missing = $types->filter(fn (DocumentType $type): bool => $type->required + && ! in_array($type->id, $stored, true) + && ! array_key_exists($type->id, $files)); + $warnings = $missing->isEmpty() ? [] : [__('Missing documents: :types.', ['types' => $missing->pluck('name')->implode(', ')])]; + + foreach ($types as $type) { + $typed = $numbers[$type->id] ?? null; + + if (! is_string($typed) || ! IdNumber::isValid($typed)) { + continue; + } + + foreach ($this->documents->duplicates($account ?? new Account, $type, $typed) as $other) { + $warnings[] = __(':type :number is already on :customer.', [ + 'type' => $type->name, + 'number' => IdNumber::mask(IdNumber::normalise($typed)), + 'customer' => $other->fullname ?: $other->username, + ]); + } + } + + return $warnings; + } + + /** + * Nothing goes into core's database: documents live in the add-on's own, written in saved(). + * + * @param array $state + */ + public function save(Account $account, array $state): void {} + + /** + * @param array $state + */ + public function saved(Account $account, array $state): void + { + $types = $this->activeTypes()->keyBy('id'); + $by = auth()->user(); + + foreach ((array) ($state['files'] ?? []) as $typeId => $file) { + $type = $types->get((int) $typeId); + + if (! $file instanceof UploadedFile || $type === null || ! $by instanceof User) { + continue; + } + + $number = $state['numbers'][$typeId] ?? null; + $this->documents->upload($account, $type, $file, is_string($number) ? $number : null, $by); + } + } + + /** + * @return Collection + */ + private function activeTypes(): Collection + { + return DocumentType::query()->where('active', true)->orderBy('position')->orderBy('id')->get(); + } + + /** + * Only these type ids may appear as keys; none at all when there are none. + * + * @param Collection $types + */ + private static function keysIn(Collection $types): string + { + return $types->isEmpty() ? 'max:0' : 'array:'.$types->pluck('id')->implode(','); + } +} diff --git a/src/NumberRule.php b/src/NumberRule.php new file mode 100644 index 0000000..86eb99c --- /dev/null +++ b/src/NumberRule.php @@ -0,0 +1,50 @@ + + */ + private array $data = []; + + /** + * @param string|null $fileField the field holding the file this number goes with; by default the number's own + * path with "numbers" turned into "files" + */ + public function __construct(private ?string $fileField = null) {} + + /** + * @param array $data + */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || ! IdNumber::isValid($value)) { + $fail(__('Type the number on one line, up to :max characters.', ['max' => IdNumber::MAX])); + + return; + } + + $fileField = $this->fileField ?? preg_replace('/\.numbers\.([^.]+)$/', '.files.$1', $attribute); + + if (data_get($this->data, (string) $fileField) === null) { + $fail(__('Add the file for this number.')); + } + } +} diff --git a/src/StoredFile.php b/src/StoredFile.php index b74c5b9..4e3671e 100644 --- a/src/StoredFile.php +++ b/src/StoredFile.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use InvalidArgumentException; use Ispgrid\Documents\Models\Document; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; @@ -26,6 +27,11 @@ final class StoredFile public function put(int $accountId, UploadedFile $file): array { $bytes = (string) $file->get(); + + if (! ContentRule::allowed($bytes)) { + throw new InvalidArgumentException('Only JPG, PNG or PDF files are stored.'); + } + $path = "documents/{$accountId}/".Str::random(40); Storage::disk(self::DISK)->put($path, Crypt::encryptString($bytes)); diff --git a/tests/Feature/CustomerDocumentsTest.php b/tests/Feature/CustomerDocumentsTest.php index f9d6b9d..cc3dfde 100644 --- a/tests/Feature/CustomerDocumentsTest.php +++ b/tests/Feature/CustomerDocumentsTest.php @@ -2,6 +2,7 @@ use App\Models\Account; use App\Models\User; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use Ispgrid\Documents\CustomerDocuments; @@ -156,3 +157,12 @@ expect($this->documents->missingTypes($this->account)->pluck('name')->all())->toBe(['Photo']); }); + +it('never stores anything but a JPG, PNG or PDF, whatever it is called', function () { + $html = UploadedFile::fake()->createWithContent('aadhaar.png', ''); + + expect(fn () => $this->documents->upload($this->account, documentType(), $html, null, $this->staff)) + ->toThrow(InvalidArgumentException::class); + expect(Document::withTrashed()->count())->toBe(0) + ->and(Storage::disk('addons')->allFiles())->toBe([]); +}); diff --git a/tests/Feature/DocumentsTabTest.php b/tests/Feature/DocumentsTabTest.php new file mode 100644 index 0000000..1afd049 --- /dev/null +++ b/tests/Feature/DocumentsTabTest.php @@ -0,0 +1,164 @@ +seed(RadiusAttributeSeeder::class); + AppSetting::put('phone_digits', '10'); + $this->staff = User::factory()->create(); + $this->actingAs($this->staff); + $this->aadhaar = documentType(); +}); + +function newCustomerForm(): Testable +{ + return Livewire::test('pages::customers.form') + ->set('fullname', 'Ramesh Kumar') + ->set('service_type', 'PPP') + ->set('username', 'ramesh') + ->set('password', 'secret12'); +} + +function ramesh(): Account +{ + return Account::query()->where('username', 'ramesh')->sole(); +} + +it('stores a photo taken while creating the customer, encrypted, and logs it', function () { + newCustomerForm() + ->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()) + ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", '1234 5678 4821') + ->call('save')->assertHasNoErrors(); + + $document = Document::query()->sole(); + expect($document->account_id)->toBe(ramesh()->id) + ->and($document->number)->toBe('1234 5678 4821') + ->and(app(StoredFile::class)->read($document))->toBe(pngBytes()) + ->and(Activity::query()->where('event', 'document-uploaded')->sole()->subject_id)->toBe(ramesh()->id); +}); + +it('warns about a missing required document and still saves the customer', function () { + $form = newCustomerForm(); + + expect($form->instance()->warningTabs())->toBe(['documents']); + + $form->call('save')->assertHasNoErrors(); + + expect(ramesh())->not->toBeNull() + ->and(session('toast'))->toContain('Missing documents: Aadhaar card.'); +}); + +it('warns about a number already on another customer', function () { + app(CustomerDocuments::class)->upload(Account::factory()->create(['fullname' => 'Sita Devi']), $this->aadhaar, pngUpload(), '1234 5678 4821', $this->staff); + + newCustomerForm() + ->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()) + ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", '123456784821') + ->call('save')->assertHasNoErrors(); + + expect(session('toast'))->toContain('Aadhaar card xxxxxxxx4821 is already on Sita Devi.'); +}); + +it('refuses a file that is not a picture or a PDF, whatever its name', function () { + newCustomerForm() + ->set("addonTabs.documents.files.{$this->aadhaar->id}", UploadedFile::fake()->createWithContent('aadhaar.png', '')) + ->call('save')->assertHasErrors(["addonTabs.documents.files.{$this->aadhaar->id}"]); + + expect(Account::query()->where('username', 'ramesh')->exists())->toBeFalse() + ->and(Document::query()->count())->toBe(0); +}); + +it('refuses an unknown or switched-off type and a bad number', function () { + $off = documentType('Old form', ['active' => false]); + + newCustomerForm() + ->set('addonTabs.documents.files.999', pngUpload()) + ->call('save')->assertHasErrors(['addonTabs.documents.files']); + + newCustomerForm() + ->set("addonTabs.documents.files.{$off->id}", pngUpload()) + ->call('save')->assertHasErrors(['addonTabs.documents.files']); + + newCustomerForm() + ->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()) + ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", "1234\n5678") + ->call('save')->assertHasErrors(["addonTabs.documents.numbers.{$this->aadhaar->id}"]); +}); + +it('refuses a number without a file', function () { + newCustomerForm() + ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", '1234 5678 4821') + ->call('save')->assertHasErrors(["addonTabs.documents.numbers.{$this->aadhaar->id}"]); +}); + +it('shows a stored full number only masked when editing', function () { + $account = Account::factory()->create(); + app(CustomerDocuments::class)->upload($account, $this->aadhaar, pngUpload(), '1234 5678 4821', $this->staff); + + $form = Livewire::test('pages::customers.form', ['account' => $account->id]); + + $form->assertSee('xxxx xxxx 4821')->assertDontSee('1234 5678 4821'); + expect(json_encode($form->get('addonTabs')))->not->toContain('5678'); +}); + +it('replaces a file, hiding the old one and keeping its number', function () { + $account = Account::factory()->create(); + $old = app(CustomerDocuments::class)->upload($account, $this->aadhaar, pngUpload(), '1234 5678 4821', $this->staff); + + Livewire::test('pages::customers.form', ['account' => $account->id]) + ->set("addonTabs.documents.files.{$this->aadhaar->id}", UploadedFile::fake()->createWithContent('scan.pdf', pdfBytes())) + ->call('save')->assertHasNoErrors(); + + $current = Document::query()->sole(); + expect($old->fresh()->trashed())->toBeTrue() + ->and($current->mime)->toBe('application/pdf') + ->and($current->number)->toBe('1234 5678 4821'); +}); + +it('deletes Livewire\'s readable copy once the file is stored', function () { + $form = newCustomerForm()->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()); + $temporary = $form->get("addonTabs.documents.files.{$this->aadhaar->id}"); + + expect($temporary)->toBeInstanceOf(TemporaryUploadedFile::class)->and($temporary->exists())->toBeTrue(); + + $form->call('save')->assertHasNoErrors(); + + expect($temporary->exists())->toBeFalse() + ->and(Storage::disk('addons')->allFiles('documents'))->toHaveCount(1); +}); + +it('hides the tab from staff without Upload documents and ignores what they send', function () { + $this->actingAs(staffWith('add-customers', Abilities::VIEW)); + + $form = newCustomerForm(); + expect($form->instance()->addonTabViews())->not->toHaveKey('documents'); + + $form->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload())->call('save')->assertHasNoErrors(); + + expect(ramesh())->not->toBeNull()->and(Document::query()->count())->toBe(0); +}); + +it('keeps the number setting of the type', function () { + $lastFour = documentType('Voter ID', ['number_mode' => NumberMode::LastFour]); + + newCustomerForm() + ->set("addonTabs.documents.files.{$lastFour->id}", pngUpload()) + ->set("addonTabs.documents.numbers.{$lastFour->id}", 'ABC/1234567') + ->call('save')->assertHasNoErrors(); + + expect(Document::query()->sole()->number)->toBe('4567'); +}); From 7e7436d1261999c294d308a7fb210c9c0c825008 Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 12:49:52 +0530 Subject: [PATCH 04/11] Show customer documents on the customer page --- lang/hi.json | 13 ++ .../customer-card/customer-card.blade.php | 91 ++++++++ .../livewire/customer-card/customer-card.php | 216 ++++++++++++++++++ routes/web.php | 5 +- src/DocumentsServiceProvider.php | 8 + src/Http/DocumentFileController.php | 33 +++ src/Http/DownloadAllController.php | 51 +++++ src/StoredFile.php | 15 ++ tests/Feature/CustomerCardTest.php | 155 +++++++++++++ 9 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 resources/views/livewire/customer-card/customer-card.blade.php create mode 100644 resources/views/livewire/customer-card/customer-card.php create mode 100644 src/Http/DocumentFileController.php create mode 100644 src/Http/DownloadAllController.php create mode 100644 tests/Feature/CustomerCardTest.php diff --git a/lang/hi.json b/lang/hi.json index b2d4f74..7570d42 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -2,16 +2,23 @@ ":count stored number will be cut to its last 4 characters. This cannot be undone.|:count stored numbers will be cut to their last 4 characters. This cannot be undone.": ":count सेव किया गया नंबर उसके आखिरी 4 अक्षरों तक छोटा हो जाएगा। यह वापस नहीं हो सकता।|:count सेव किए गए नंबर उनके आखिरी 4 अक्षरों तक छोटे हो जाएँगे। यह वापस नहीं हो सकता।", ":file, uploaded :date by :name": ":file, :date को :name ने अपलोड की", ":type :number is already on :customer.": ":type :number पहले से :customer के पास है।", + ":type saved.": ":type सेव हो गया।", "A customer without it still saves; staff see a warning and the customer is listed under Missing documents.": "इसके बिना भी ग्राहक सेव होता है; स्टाफ़ को चेतावनी दिखती है और ग्राहक कमी वाले दस्तावेज़ों की सूची में आता है।", "Add document type": "दस्तावेज़ का प्रकार जोड़ें", "Add the file for this number.": "इस नंबर के लिए फ़ाइल जोड़ें।", "Choose file": "फ़ाइल चुनें", + "Clear the number": "नंबर हटाएँ", "Cut the numbers and save": "नंबर छोटे करें और सेव करें", + "Delete the :type of :customer?": ":customer का :type हटाएँ?", + "Document deleted. It can be restored from Deleted documents.": "दस्तावेज़ हटा दिया गया। इसे हटाए गए दस्तावेज़ों से वापस लाया जा सकता है।", + "Document saved.": "दस्तावेज़ सेव हो गया।", "Document type": "दस्तावेज़ का प्रकार", "Document type saved.": "दस्तावेज़ का प्रकार सेव हो गया।", "Document types": "दस्तावेज़ के प्रकार", "Documents": "दस्तावेज़", + "Download all": "सब डाउनलोड करें", "Edit and delete documents": "दस्तावेज़ बदलें और हटाएँ", + "Edit document": "दस्तावेज़ बदलें", "Edit document type": "दस्तावेज़ का प्रकार बदलें", "Erase deleted documents": "हटाए गए दस्तावेज़ हमेशा के लिए मिटाएँ", "Full ID number": "पूरा आईडी नंबर", @@ -20,18 +27,21 @@ "JPG, PNG or PDF, up to 10 MB.": "JPG, PNG या PDF, 10 MB तक।", "Last 4 characters only": "केवल आखिरी 4 अक्षर", "Leave empty to keep the number on file.": "पुराना नंबर रखने के लिए खाली छोड़ें।", + "Missing": "कमी है", "Missing documents: :types.": "कमी वाले दस्तावेज़: :types।", "Move down": "नीचे ले जाएँ", "Move up": "ऊपर ले जाएँ", "New ID number (with the new file)": "नया आईडी नंबर (नई फ़ाइल के साथ)", "No ID number": "कोई आईडी नंबर नहीं", "No document types yet. Add them on System, then Add-ons.": "अभी कोई दस्तावेज़ प्रकार नहीं है। उन्हें सिस्टम, फिर ऐड-ऑन में जोड़ें।", + "Not uploaded": "अपलोड नहीं हुआ", "Off": "बंद", "Replace with a file": "फ़ाइल से बदलें", "Replace with a photo": "फ़ोटो से बदलें", "Required": "ज़रूरी", "Required for every customer": "हर ग्राहक के लिए ज़रूरी", "Restore deleted documents": "हटाए गए दस्तावेज़ वापस लाएँ", + "Show": "दिखाएँ", "Shown to staff when they upload, e.g. Upload a masked Aadhaar (first 8 digits hidden).": "अपलोड करते समय स्टाफ़ को दिखता है, जैसे: मास्क्ड आधार अपलोड करें (पहले 8 अंक छिपे हुए)।", "Switch off": "बंद करें", "Switch on": "चालू करें", @@ -41,7 +51,10 @@ "This type has documents. Switch it off instead.": "इस प्रकार के दस्तावेज़ हैं। इसकी जगह इसे बंद करें।", "Type the number on one line, up to :max characters.": "नंबर एक ही लाइन में लिखें, ज़्यादा से ज़्यादा :max अक्षर।", "Unknown": "अज्ञात", + "Upload": "अपलोड करें", "Upload a JPG, PNG or PDF.": "JPG, PNG या PDF अपलोड करें।", "Upload documents": "दस्तावेज़ अपलोड करें", + "Uploaded :date by :name": ":date को :name ने अपलोड किया", + "View": "देखें", "View customer documents": "ग्राहक दस्तावेज़ देखें" } diff --git a/resources/views/livewire/customer-card/customer-card.blade.php b/resources/views/livewire/customer-card/customer-card.blade.php new file mode 100644 index 0000000..5310b44 --- /dev/null +++ b/resources/views/livewire/customer-card/customer-card.blade.php @@ -0,0 +1,91 @@ +
+ +
+ {{ __('Documents') }} + @if ($this->allowed['view']) + {{ __('Download all') }} + @endif +
+ + @if ($this->rows === []) + {{ __('No document types yet. Add them on System, then Add-ons.') }} + @endif + +
+ @foreach ($this->rows as $row) +
+
+
+ {{ $row['name'] }} + @if ($row['required'] && $row['document'] === null) + {{ __('Missing') }} + @endif +
+ + @if ($row['document'] !== null) +
+ @if ($this->allowed['view']) + {{ __('View') }} + @endif + @if ($this->allowed['edit']) + {{ __('Edit') }} + {{ __('Delete') }} + @endif +
+ @endif +
+ + @if ($row['document'] !== null) + + {{ __('Uploaded :date by :name', ['date' => $row['document']['date'], 'name' => $row['document']['by']]) }} + @if ($row['document']['number'] !== '') + · {{ $row['document']['number'] }} + @if ($this->allowed['view'] && $shownId !== $row['document']['id']) + {{ __('Show') }} + @endif + @endif + + @elseif ($this->allowed['upload']) + @if ($row['numbered']) + + @endif +
+ + +
+
+ {{ __('Upload') }} +
+ @else + {{ __('Not uploaded') }} + @endif +
+ @endforeach +
+
+ + @if ($this->allowed['edit']) + +
+ {{ __('Edit document') }} + @error('type') + {{ $message }} + @enderror + + + +
+ {{ __('Save') }} + {{ __('Cancel') }} +
+ +
+ @endif +
diff --git a/resources/views/livewire/customer-card/customer-card.php b/resources/views/livewire/customer-card/customer-card.php new file mode 100644 index 0000000..09af444 --- /dev/null +++ b/resources/views/livewire/customer-card/customer-card.php @@ -0,0 +1,216 @@ + + */ + public array $uploads = []; + + /** + * @var array + */ + public array $numbers = []; + + /** + * The document whose full number is shown. + */ + #[Locked] + public ?int $shownId = null; + + #[Locked] + public ?int $editingId = null; + + public string $editTypeId = ''; + + public string $editNumber = ''; + + public bool $clearNumber = false; + + public function mount(int $accountId): void + { + $this->accountId = $accountId; + $this->authorizeAccess(); + } + + protected function authorizeAccess(): void + { + abort_unless($this->can(Abilities::VIEW) || $this->can(Abilities::UPLOAD), 403); + } + + #[Computed] + public function account(): Account + { + return Account::withTrashed()->findOrFail($this->accountId); + } + + /** + * @return array{view: bool, upload: bool, edit: bool} + */ + #[Computed] + public function allowed(): array + { + return [ + 'view' => $this->can(Abilities::VIEW), + 'upload' => $this->can(Abilities::UPLOAD) && ! $this->account->trashed(), + 'edit' => $this->can(Abilities::EDIT), + ]; + } + + /** + * One row per active type, and per switched-off type that still has a current file. + * + * @return list + */ + #[Computed] + public function rows(): array + { + $documents = Document::query()->where('account_id', $this->accountId)->get()->keyBy('document_type_id'); + $uploaders = User::query()->whereIn('id', $documents->pluck('uploaded_by')->filter())->pluck('name', 'id'); + $types = DocumentType::query() + ->where(fn ($query) => $query->where('active', true)->orWhereIn('id', $documents->keys())) + ->orderBy('position')->orderBy('id')->get(); + + return $types->map(function (DocumentType $type) use ($documents, $uploaders): array { + $document = $documents->get($type->id); + + return [ + 'type_id' => $type->id, + 'name' => $type->name, + 'required' => $type->required && $type->active, + 'numbered' => $type->number_mode !== NumberMode::None && $type->active, + 'document' => $document === null ? null : [ + 'id' => $document->id, + 'date' => $document->created_at?->toFormattedDateString(), + 'by' => (string) ($uploaders[$document->uploaded_by] ?? __('Unknown')), + 'number' => match (true) { + $document->number === null => '', + $this->shownId === $document->id => $document->number, + default => IdNumber::mask($document->number), + }, + 'url' => route('documents.file', $document->id), + ], + ]; + })->values()->all(); + } + + /** + * @return array + */ + #[Computed] + public function typeOptions(): array + { + return DocumentType::query()->where('active', true)->orderBy('position')->pluck('name', 'id')->all(); + } + + public function showNumber(int $id): void + { + $this->authorize(Abilities::VIEW); + $document = $this->document($id); + $this->shownId = $document->id; + app(CustomerDocuments::class)->log('document-number-shown', $this->account, $document->type->name, auth()->user()); + } + + public function upload(int $typeId): void + { + $this->authorize(Abilities::UPLOAD); + abort_if($this->account->trashed(), 403); + $type = DocumentType::query()->where('active', true)->findOrFail($typeId); + $this->validate([ + "uploads.{$typeId}" => ['required', 'file', 'mimetypes:'.implode(',', ContentRule::TYPES), 'max:10000', new ContentRule], + "numbers.{$typeId}" => ['nullable', 'string', new NumberRule("uploads.{$typeId}")], + ]); + $number = $this->numbers[$typeId] ?? null; + + app(CustomerDocuments::class)->upload($this->account, $type, $this->uploads[$typeId], is_string($number) ? $number : null, auth()->user()); + unset($this->uploads[$typeId], $this->numbers[$typeId]); + $this->toast(__(':type saved.', ['type' => $type->name])); + } + + public function edit(int $id): void + { + $this->authorize(Abilities::EDIT); + $document = $this->document($id); + $this->editingId = $document->id; + $this->editTypeId = (string) $document->document_type_id; + $this->editNumber = ''; + $this->clearNumber = false; + $this->resetErrorBag(); + $this->dispatch('modal-open', name: 'documents-edit'); + } + + /** + * Saves Edit: the type, and the number (empty keeps it, "Clear number" removes it). + */ + public function saveEdit(): void + { + $this->authorize(Abilities::EDIT); + $document = $this->document((int) $this->editingId); + $this->validate([ + 'editTypeId' => ['required', Rule::in(array_keys($this->typeOptions))], + 'editNumber' => ['nullable', 'string', fn (string $attribute, mixed $value, Closure $fail) => IdNumber::isValid((string) $value) + ? null + : $fail(__('Type the number on one line, up to :max characters.', ['max' => IdNumber::MAX]))], + ]); + $number = match (true) { + $this->clearNumber => '', + trim($this->editNumber) === '' => null, + default => $this->editNumber, + }; + + app(CustomerDocuments::class)->edit($document, DocumentType::query()->findOrFail((int) $this->editTypeId), $number, auth()->user()); + $this->reset('editingId', 'editTypeId', 'editNumber', 'clearNumber', 'shownId'); + $this->dispatch('modal-close', name: 'documents-edit'); + $this->toast(__('Document saved.')); + } + + public function delete(int $id): void + { + $this->authorize(Abilities::EDIT); + app(CustomerDocuments::class)->delete($this->document($id), auth()->user()); + $this->toast(__('Document deleted. It can be restored from Deleted documents.')); + } + + /** + * A current document of this customer; any other id is not found. + */ + private function document(int $id): Document + { + return Document::query()->with('type')->where('account_id', $this->accountId)->findOrFail($id); + } + + private function can(string $box): bool + { + return (bool) auth()->user()?->can($box); + } +}; diff --git a/routes/web.php b/routes/web.php index 2cb25ec..2f9b3e0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,10 @@ group(function () { - // + Route::get('documents/{document}/file', DocumentFileController::class)->whereNumber('document')->name('documents.file'); + Route::get('documents/customers/{account}/all', DownloadAllController::class)->whereNumber('account')->name('documents.zip'); }); diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index 6f37518..15c4578 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -2,7 +2,10 @@ namespace Ispgrid\Documents; +use App\Models\Account; +use App\Models\User; use App\Support\Addons\AddonSettings; +use App\Support\Addons\CustomerCards; use App\Support\Addons\CustomerFormTabs; use App\Support\Addons\PrivateFile; use App\Support\Permissions; @@ -49,5 +52,10 @@ public function boot(): void $this->app->make(AddonSettings::class)->register('ispgrid/documents', 'documents::settings', Abilities::TYPES); $this->app->make(CustomerFormTabs::class)->register(DocumentsTab::class); + $this->app->make(CustomerCards::class)->register( + 'documents::customer-card', + fn (User $user, Account $account): bool => $user->can(Abilities::VIEW) || $user->can(Abilities::UPLOAD), + 60, + ); } } diff --git a/src/Http/DocumentFileController.php b/src/Http/DocumentFileController.php new file mode 100644 index 0000000..4067478 --- /dev/null +++ b/src/Http/DocumentFileController.php @@ -0,0 +1,33 @@ +user()?->can(Abilities::VIEW), 403); + $row = Document::query()->with('type')->findOrFail($document); + $account = Account::withTrashed()->findOrFail($row->account_id); + + $documents->log('document-viewed', $account, $row->type->name, auth()->user()); + + return PrivateFile::contentResponse( + $files->read($row), + StoredFile::downloadName($account->fullname ?: $account->username, $row->type->name, $row->mime), + inline: true, + ); + } +} diff --git a/src/Http/DownloadAllController.php b/src/Http/DownloadAllController.php new file mode 100644 index 0000000..89d5966 --- /dev/null +++ b/src/Http/DownloadAllController.php @@ -0,0 +1,51 @@ +user()?->can(Abilities::VIEW), 403); + $customer = Account::withTrashed()->findOrFail($account); + $name = $customer->fullname ?: $customer->username; + $rows = Document::query()->with('type')->where('account_id', $customer->id)->get(); + abort_if($rows->isEmpty(), 404); + + $path = (string) tempnam(sys_get_temp_dir(), 'documents'); + + try { + $zip = new ZipArchive; + + if ($zip->open($path, ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('The zip could not be created.'); + } + + foreach ($rows as $row) { + $zip->addFromString(StoredFile::downloadName($name, $row->type->name, $row->mime), $files->read($row)); + } + + $zip->close(); + $contents = (string) file_get_contents($path); + } finally { + @unlink($path); + } + + $documents->log('documents-downloaded', $customer, $rows->pluck('type.name')->implode(', '), auth()->user(), ['count' => $rows->count()]); + + return PrivateFile::contentResponse($contents, str_replace(['/', '\\'], '-', $name).'_documents.zip'); + } +} diff --git a/src/StoredFile.php b/src/StoredFile.php index 4e3671e..2e20c07 100644 --- a/src/StoredFile.php +++ b/src/StoredFile.php @@ -52,6 +52,21 @@ public function read(Document $document): string return Crypt::decryptString((string) Storage::disk(self::DISK)->get($document->path)); } + /** + * The name the browser offers: "_.", without characters a file system refuses. + */ + public static function downloadName(string $customer, string $typeName, string $mime): string + { + $extension = match ($mime) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'application/pdf' => 'pdf', + default => 'bin', + }; + + return str_replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], '-', "{$customer}_{$typeName}").'.'.$extension; + } + public function erase(string $path): void { Storage::disk(self::DISK)->delete($path); diff --git a/tests/Feature/CustomerCardTest.php b/tests/Feature/CustomerCardTest.php new file mode 100644 index 0000000..24b33f7 --- /dev/null +++ b/tests/Feature/CustomerCardTest.php @@ -0,0 +1,155 @@ +admin = User::factory()->create(); + $this->account = Account::factory()->create(['fullname' => 'Ramesh Kumar']); + $this->aadhaar = documentType(); + $this->document = app(CustomerDocuments::class)->upload($this->account, $this->aadhaar, pngUpload(), '1234 5678 4821', $this->admin); +}); + +function card(Account $account): Testable +{ + return Livewire::test('documents::customer-card', ['accountId' => $account->id]); +} + +it('shows the card only to staff with View customer documents or Upload documents', function () { + $this->actingAs(staffWith('view-customers')); + $this->get(route('customers.show', $this->account))->assertOk()->assertDontSee('xxxx xxxx 4821'); + card($this->account)->assertForbidden(); + + $this->actingAs(staffWith(Abilities::VIEW)); + $this->get(route('customers.show', $this->account))->assertOk()->assertSee('xxxx xxxx 4821')->assertDontSee('1234 5678 4821'); +}); + +it('lets staff with Upload documents only add files, not open them', function () { + $this->actingAs(staffWith(Abilities::UPLOAD)); + + card($this->account)->assertOk()->assertDontSee(route('documents.file', $this->document))->call('showNumber', $this->document->id)->assertForbidden(); + $this->get(route('documents.file', $this->document))->assertForbidden(); + $this->get(route('documents.zip', $this->account))->assertForbidden(); +}); + +it('opens a document decrypted and logs the view', function () { + $this->actingAs($this->admin); + + $response = $this->get(route('documents.file', $this->document)); + + $response->assertOk()->assertHeader('Content-Type', 'image/png'); + expect($response->getContent())->toBe(pngBytes()) + ->and($response->headers->get('Content-Disposition'))->toContain('Ramesh Kumar_Aadhaar card.png') + ->and(Activity::query()->where('event', 'document-viewed')->sole()->subject_id)->toBe($this->account->id); +}); + +it('does not open a hidden document from the card route', function () { + $this->actingAs($this->admin); + app(CustomerDocuments::class)->delete($this->document, $this->admin); + + $this->get(route('documents.file', $this->document))->assertNotFound(); +}); + +it('shows the full number once and logs it', function () { + $this->actingAs($this->admin); + + card($this->account)->assertSee('xxxx xxxx 4821')->call('showNumber', $this->document->id)->assertSee('1234 5678 4821'); + + expect(Activity::query()->where('event', 'document-number-shown')->count())->toBe(1); +}); + +it('uploads a missing document from the card', function () { + $this->actingAs(staffWith(Abilities::UPLOAD)); + $photo = documentType('Customer photo'); + + card($this->account) + ->set("uploads.{$photo->id}", UploadedFile::fake()->createWithContent('a.png', ''))->call('upload', $photo->id) + ->assertHasErrors(["uploads.{$photo->id}"]) + ->set("uploads.{$photo->id}", pngUpload('me.png'))->call('upload', $photo->id)->assertHasNoErrors(); + + expect(Document::query()->where('document_type_id', $photo->id)->sole()->original_name)->toBe('me.png'); +}); + +it('edits the type and number, refusing a taken type', function () { + $this->actingAs(staffWith(Abilities::EDIT)); + $pan = documentType('PAN card'); + $agreement = documentType('Agreement'); + app(CustomerDocuments::class)->upload($this->account, $pan, pngUpload(), null, $this->admin); + + card($this->account)->call('edit', $this->document->id) + ->set('editTypeId', (string) $pan->id)->call('saveEdit')->assertHasErrors(['type']) + ->set('editTypeId', (string) $agreement->id)->set('editNumber', 'AB 99')->call('saveEdit')->assertHasNoErrors(); + + expect($this->document->fresh()->document_type_id)->toBe($agreement->id) + ->and($this->document->fresh()->number)->toBe('AB 99'); +}); + +it('needs Edit and delete documents to edit or delete', function () { + $this->actingAs(staffWith(Abilities::VIEW, Abilities::UPLOAD)); + + card($this->account)->call('edit', $this->document->id)->assertForbidden(); + card($this->account)->call('delete', $this->document->id)->assertForbidden(); +}); + +it('hides a deleted document and logs it', function () { + $this->actingAs(staffWith(Abilities::EDIT)); + + card($this->account)->call('delete', $this->document->id)->assertHasNoErrors()->assertDontSee('xxxx xxxx 4821'); + + expect($this->document->fresh()->trashed())->toBeTrue() + ->and(Activity::query()->where('event', 'document-deleted')->count())->toBe(1); +}); + +it('refuses a document of another customer', function () { + $this->actingAs($this->admin); + $other = Account::factory()->create(); + + card($other)->call('showNumber', $this->document->id)->assertNotFound(); + card($other)->call('delete', $this->document->id)->assertNotFound(); +}); + +it('still shows the documents of a deleted customer, without Upload', function () { + $this->actingAs($this->admin); + $photo = documentType('Customer photo'); + $this->account->delete(); + + card($this->account)->assertOk()->assertSee('xxxx xxxx 4821')->assertDontSee(__('Take photo')) + ->set("uploads.{$photo->id}", pngUpload())->call('upload', $photo->id)->assertForbidden(); +}); + +it('downloads all current documents as one zip and logs it', function () { + $this->actingAs($this->admin); + app(CustomerDocuments::class)->upload($this->account, documentType('Agreement'), UploadedFile::fake()->createWithContent('a.pdf', pdfBytes()), null, $this->admin); + + $response = $this->get(route('documents.zip', $this->account)); + + $response->assertOk(); + $path = tempnam(sys_get_temp_dir(), 'zip'); + file_put_contents($path, $response->getContent()); + $zip = new ZipArchive; + $zip->open($path); + $names = collect(range(0, $zip->numFiles - 1))->map(fn (int $i): string => $zip->getNameIndex($i))->sort()->values()->all(); + + expect($names)->toBe(['Ramesh Kumar_Aadhaar card.png', 'Ramesh Kumar_Agreement.pdf']) + ->and($zip->getFromName('Ramesh Kumar_Aadhaar card.png'))->toBe(pngBytes()) + ->and(Activity::query()->where('event', 'documents-downloaded')->count())->toBe(1); + $zip->close(); + unlink($path); +}); + +it('stops an open card once its box is taken away', function () { + $staff = staffWith(Abilities::VIEW); + $this->actingAs($staff); + $card = card($this->account)->assertOk(); + + revokeBox($staff, Abilities::VIEW); + + $card->call('showNumber', $this->document->id)->assertForbidden(); +}); From ee49f8bb92f15b61da5d10e3e0680862e04315ce Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 12:51:48 +0530 Subject: [PATCH 05/11] Add the Deleted documents and Missing documents pages --- lang/hi.json | 16 +++ .../views/livewire/deleted/deleted.blade.php | 53 +++++++++ resources/views/livewire/deleted/deleted.php | 102 ++++++++++++++++ .../views/livewire/missing/missing.blade.php | 38 ++++++ resources/views/livewire/missing/missing.php | 96 +++++++++++++++ routes/web.php | 2 + src/DocumentsServiceProvider.php | 22 ++++ tests/Feature/ListPagesTest.php | 111 ++++++++++++++++++ 8 files changed, 440 insertions(+) create mode 100644 resources/views/livewire/deleted/deleted.blade.php create mode 100644 resources/views/livewire/deleted/deleted.php create mode 100644 resources/views/livewire/missing/missing.blade.php create mode 100644 resources/views/livewire/missing/missing.php create mode 100644 tests/Feature/ListPagesTest.php diff --git a/lang/hi.json b/lang/hi.json index 7570d42..1b08a95 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -1,16 +1,24 @@ { ":count stored number will be cut to its last 4 characters. This cannot be undone.|:count stored numbers will be cut to their last 4 characters. This cannot be undone.": ":count सेव किया गया नंबर उसके आखिरी 4 अक्षरों तक छोटा हो जाएगा। यह वापस नहीं हो सकता।|:count सेव किए गए नंबर उनके आखिरी 4 अक्षरों तक छोटे हो जाएँगे। यह वापस नहीं हो सकता।", + ":date by :name": ":date, :name द्वारा", ":file, uploaded :date by :name": ":file, :date को :name ने अपलोड की", ":type :number is already on :customer.": ":type :number पहले से :customer के पास है।", ":type saved.": ":type सेव हो गया।", "A customer without it still saves; staff see a warning and the customer is listed under Missing documents.": "इसके बिना भी ग्राहक सेव होता है; स्टाफ़ को चेतावनी दिखती है और ग्राहक कमी वाले दस्तावेज़ों की सूची में आता है।", "Add document type": "दस्तावेज़ का प्रकार जोड़ें", "Add the file for this number.": "इस नंबर के लिए फ़ाइल जोड़ें।", + "Backups taken before an erase still hold the file for up to 14 days.": "मिटाने से पहले लिए गए बैकअप में फ़ाइल 14 दिन तक रहती है।", "Choose file": "फ़ाइल चुनें", "Clear the number": "नंबर हटाएँ", + "Customers without a document their ISP requires. Deleted customers are left out.": "वे ग्राहक जिनके पास ज़रूरी दस्तावेज़ नहीं है। हटाए गए ग्राहक शामिल नहीं हैं।", "Cut the numbers and save": "नंबर छोटे करें और सेव करें", "Delete the :type of :customer?": ":customer का :type हटाएँ?", + "Deleted": "हटाया गया", + "Deleted documents": "हटाए गए दस्तावेज़", + "Deleted documents are hidden from the customer, not removed. Restore one, or erase it for good.": "हटाए गए दस्तावेज़ ग्राहक से छिपाए जाते हैं, मिटाए नहीं जाते। किसी को वापस लाएँ, या हमेशा के लिए मिटाएँ।", "Document deleted. It can be restored from Deleted documents.": "दस्तावेज़ हटा दिया गया। इसे हटाए गए दस्तावेज़ों से वापस लाया जा सकता है।", + "Document erased.": "दस्तावेज़ मिटा दिया गया।", + "Document restored.": "दस्तावेज़ वापस आ गया।", "Document saved.": "दस्तावेज़ सेव हो गया।", "Document type": "दस्तावेज़ का प्रकार", "Document type saved.": "दस्तावेज़ का प्रकार सेव हो गया।", @@ -21,6 +29,9 @@ "Edit document": "दस्तावेज़ बदलें", "Edit document type": "दस्तावेज़ का प्रकार बदलें", "Erase deleted documents": "हटाए गए दस्तावेज़ हमेशा के लिए मिटाएँ", + "Erase for good": "हमेशा के लिए मिटाएँ", + "Erase the :type of :customer for good? This cannot be undone.": ":customer का :type हमेशा के लिए मिटाएँ? यह वापस नहीं हो सकता।", + "Every customer has the required documents.": "हर ग्राहक के पास ज़रूरी दस्तावेज़ हैं।", "Full ID number": "पूरा आईडी नंबर", "Help text": "मदद का टेक्स्ट", "ID number": "आईडी नंबर", @@ -28,11 +39,15 @@ "Last 4 characters only": "केवल आखिरी 4 अक्षर", "Leave empty to keep the number on file.": "पुराना नंबर रखने के लिए खाली छोड़ें।", "Missing": "कमी है", + "Missing documents": "कमी वाले दस्तावेज़", "Missing documents: :types.": "कमी वाले दस्तावेज़: :types।", "Move down": "नीचे ले जाएँ", "Move up": "ऊपर ले जाएँ", + "Name or username": "नाम या यूज़रनेम", "New ID number (with the new file)": "नया आईडी नंबर (नई फ़ाइल के साथ)", "No ID number": "कोई आईडी नंबर नहीं", + "No deleted documents.": "कोई हटाया गया दस्तावेज़ नहीं।", + "No document type is required. Mark one as required on System, then Add-ons.": "कोई दस्तावेज़ प्रकार ज़रूरी नहीं है। सिस्टम, फिर ऐड-ऑन में किसी को ज़रूरी चिह्नित करें।", "No document types yet. Add them on System, then Add-ons.": "अभी कोई दस्तावेज़ प्रकार नहीं है। उन्हें सिस्टम, फिर ऐड-ऑन में जोड़ें।", "Not uploaded": "अपलोड नहीं हुआ", "Off": "बंद", @@ -40,6 +55,7 @@ "Replace with a photo": "फ़ोटो से बदलें", "Required": "ज़रूरी", "Required for every customer": "हर ग्राहक के लिए ज़रूरी", + "Restore": "वापस लाएँ", "Restore deleted documents": "हटाए गए दस्तावेज़ वापस लाएँ", "Show": "दिखाएँ", "Shown to staff when they upload, e.g. Upload a masked Aadhaar (first 8 digits hidden).": "अपलोड करते समय स्टाफ़ को दिखता है, जैसे: मास्क्ड आधार अपलोड करें (पहले 8 अंक छिपे हुए)।", diff --git a/resources/views/livewire/deleted/deleted.blade.php b/resources/views/livewire/deleted/deleted.blade.php new file mode 100644 index 0000000..87b2963 --- /dev/null +++ b/resources/views/livewire/deleted/deleted.blade.php @@ -0,0 +1,53 @@ +
+
+ {{ __('Deleted documents') }} + {{ __('Deleted documents are hidden from the customer, not removed. Restore one, or erase it for good.') }} + {{ __('Backups taken before an erase still hold the file for up to 14 days.') }} +
+ + @error('document') + {{ $message }} + @enderror + +
+ +
+ + + + {{ __('Customer') }} + {{ __('Document type') }} + {{ __('Deleted') }} + {{ __('Actions') }} + + + @forelse ($this->rows as $row) + + + {{ $row['customer'] }} + + {{ $row['type'] }} + {{ __(':date by :name', ['date' => $row['deleted'], 'name' => $row['by']]) }} + + {{ __('Restore') }} + @if ($this->canErase) + {{ __('Erase for good') }} + @endif + + + @empty + + {{ __('No deleted documents.') }} + + @endforelse + + + {{ $this->documents->links() }} +
diff --git a/resources/views/livewire/deleted/deleted.php b/resources/views/livewire/deleted/deleted.php new file mode 100644 index 0000000..3b6cf17 --- /dev/null +++ b/resources/views/livewire/deleted/deleted.php @@ -0,0 +1,102 @@ +authorizeAccess(); + } + + protected function authorizeAccess(): void + { + $this->authorize(Abilities::RESTORE); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + #[Computed] + public function canErase(): bool + { + return (bool) auth()->user()?->can(Abilities::ERASE); + } + + #[Computed] + public function documents(): LengthAwarePaginator + { + $search = trim($this->search); + + return Document::onlyTrashed()->with('type') + ->when($search !== '', fn ($query) => $query->whereIn('account_id', Account::withTrashed() + ->where(fn ($accounts) => $accounts->where('username', 'like', "%{$search}%")->orWhere('fullname', 'like', "%{$search}%")) + ->pluck('id'))) + ->orderByDesc('deleted_at')->orderByDesc('id') + ->paginate(25); + } + + /** + * The page's rows, ready to show. + * + * @return list + */ + #[Computed] + public function rows(): array + { + $documents = collect($this->documents->items()); + $accounts = Account::withTrashed()->whereIn('id', $documents->pluck('account_id'))->get()->keyBy('id'); + $staff = User::query()->whereIn('id', $documents->pluck('deleted_by')->filter())->pluck('name', 'id'); + + return $documents->map(fn (Document $document): array => [ + 'id' => $document->id, + 'customer' => ($account = $accounts->get($document->account_id)) === null ? '#'.$document->account_id : ($account->fullname ?: $account->username), + 'account_id' => $document->account_id, + 'type' => $document->type->name, + 'deleted' => (string) $document->deleted_at?->toDayDateTimeString(), + 'by' => (string) ($staff[$document->deleted_by] ?? __('Unknown')), + ])->all(); + } + + public function restore(int $id): void + { + $this->authorize(Abilities::RESTORE); + app(CustomerDocuments::class)->restore($this->hidden($id), auth()->user()); + $this->toast(__('Document restored.')); + } + + public function erase(int $id): void + { + $this->authorize(Abilities::ERASE); + app(CustomerDocuments::class)->erase($this->hidden($id), auth()->user()); + $this->toast(__('Document erased.')); + } + + private function hidden(int $id): Document + { + return Document::onlyTrashed()->with('type')->findOrFail($id); + } +}; diff --git a/resources/views/livewire/missing/missing.blade.php b/resources/views/livewire/missing/missing.blade.php new file mode 100644 index 0000000..cacc0cb --- /dev/null +++ b/resources/views/livewire/missing/missing.blade.php @@ -0,0 +1,38 @@ +
+
+ {{ __('Missing documents') }} + {{ __('Customers without a document their ISP requires. Deleted customers are left out.') }} +
+ + @if ($this->accounts === null) + {{ __('No document type is required. Mark one as required on System, then Add-ons.') }} + @else +
+ +
+ + + + {{ __('Customer') }} + {{ __('Username') }} + {{ __('Missing') }} + + + @forelse ($this->rows as $row) + + + {{ $row['customer'] }} + + {{ $row['username'] }} + {{ $row['missing'] }} + + @empty + + {{ __('Every customer has the required documents.') }} + + @endforelse + + + {{ $this->accounts->links() }} + @endif +
diff --git a/resources/views/livewire/missing/missing.php b/resources/views/livewire/missing/missing.php new file mode 100644 index 0000000..9f5989b --- /dev/null +++ b/resources/views/livewire/missing/missing.php @@ -0,0 +1,96 @@ +authorizeAccess(); + } + + protected function authorizeAccess(): void + { + $this->authorize(Abilities::VIEW); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + /** + * @return Collection + */ + #[Computed] + public function requiredTypes(): Collection + { + return DocumentType::query()->where('active', true)->where('required', true)->orderBy('position')->get(); + } + + #[Computed] + public function accounts(): ?LengthAwarePaginator + { + $required = $this->requiredTypes; + + if ($required->isEmpty()) { + return null; + } + + // The customers that have every required type; the rest are missing something. + $complete = Document::query() + ->whereIn('document_type_id', $required->pluck('id')) + ->groupBy('account_id') + ->havingRaw('COUNT(DISTINCT document_type_id) = ?', [$required->count()]) + ->pluck('account_id'); + $search = trim($this->search); + + return Account::query() + ->whereNotIn('id', $complete) + ->when($search !== '', fn ($query) => $query->where(fn ($accounts) => $accounts->where('username', 'like', "%{$search}%")->orWhere('fullname', 'like', "%{$search}%"))) + ->orderBy('username') + ->paginate(25); + } + + /** + * @return list + */ + #[Computed] + public function rows(): array + { + if ($this->accounts === null) { + return []; + } + + $accounts = collect($this->accounts->items()); + $held = Document::query()->whereIn('account_id', $accounts->pluck('id'))->get(['account_id', 'document_type_id'])->groupBy('account_id'); + + return $accounts->map(fn (Account $account): array => [ + 'id' => $account->id, + 'customer' => (string) ($account->fullname ?: $account->username), + 'username' => $account->username, + 'missing' => $this->requiredTypes + ->whereNotIn('id', ($held->get($account->id) ?? collect())->pluck('document_type_id')) + ->pluck('name')->implode(', '), + ])->all(); + } +}; diff --git a/routes/web.php b/routes/web.php index 2f9b3e0..d4ce021 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,8 @@ use Ispgrid\Documents\Http\DownloadAllController; Route::middleware(['web', 'auth'])->group(function () { + Route::livewire('documents/missing', 'documents::missing')->name('documents.missing'); + Route::livewire('documents/deleted', 'documents::deleted')->name('documents.deleted'); Route::get('documents/{document}/file', DocumentFileController::class)->whereNumber('document')->name('documents.file'); Route::get('documents/customers/{account}/all', DownloadAllController::class)->whereNumber('account')->name('documents.zip'); }); diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index 15c4578..d3fcbf7 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -8,6 +8,8 @@ use App\Support\Addons\CustomerCards; use App\Support\Addons\CustomerFormTabs; use App\Support\Addons\PrivateFile; +use App\Support\Navigation\Navigation; +use App\Support\Navigation\NavItem; use App\Support\Permissions; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; @@ -57,5 +59,25 @@ public function boot(): void fn (User $user, Account $account): bool => $user->can(Abilities::VIEW) || $user->can(Abilities::UPLOAD), 60, ); + + $navigation = $this->app->make(Navigation::class); + $navigation->add(new NavItem( + group: 'addons', + label: fn (): string => __('Missing documents'), + route: 'documents.missing', + icon: 'clipboard-document-list', + active: ['documents.missing'], + visible: fn (User $user): bool => $user->can(Abilities::VIEW), + order: 20, + )); + $navigation->add(new NavItem( + group: 'addons', + label: fn (): string => __('Deleted documents'), + route: 'documents.deleted', + icon: 'trash', + active: ['documents.deleted'], + visible: fn (User $user): bool => $user->can(Abilities::RESTORE), + order: 21, + )); } } diff --git a/tests/Feature/ListPagesTest.php b/tests/Feature/ListPagesTest.php new file mode 100644 index 0000000..2e32444 --- /dev/null +++ b/tests/Feature/ListPagesTest.php @@ -0,0 +1,111 @@ +admin = User::factory()->create(); + $this->documents = app(CustomerDocuments::class); + $this->aadhaar = documentType(); +}); + +it('lists hidden documents newest first, with search', function () { + $this->actingAs($this->admin); + $ramesh = Account::factory()->create(['fullname' => 'Ramesh Kumar', 'username' => 'ramesh']); + $sita = Account::factory()->create(['fullname' => 'Sita Devi', 'username' => 'sita']); + $first = $this->documents->upload($ramesh, $this->aadhaar, pngUpload(), null, $this->admin); + $this->documents->delete($first, $this->admin); + $this->travel(1)->minute(); + $second = $this->documents->upload($sita, $this->aadhaar, pngUpload(), null, $this->admin); + $this->documents->delete($second, $this->admin); + + $this->get(route('documents.deleted'))->assertOk()->assertSeeInOrder(['Sita Devi', 'Ramesh Kumar']) + ->assertSee(__('Backups taken before an erase still hold the file for up to 14 days.')); + + Livewire::test('documents::deleted')->set('search', 'rame')->assertSee('Ramesh Kumar')->assertDontSee('Sita Devi'); +}); + +it('restores a hidden document unless its type is taken again', function () { + $this->actingAs(staffWith(Abilities::RESTORE)); + $account = Account::factory()->create(); + $document = $this->documents->upload($account, $this->aadhaar, pngUpload(), null, $this->admin); + $this->documents->delete($document, $this->admin); + + Livewire::test('documents::deleted')->call('restore', $document->id)->assertHasNoErrors(); + expect($document->fresh()->trashed())->toBeFalse(); + + $this->documents->upload($account, $this->aadhaar, pngUpload(), null, $this->admin); + + Livewire::test('documents::deleted')->call('restore', $document->id)->assertHasErrors(['document']); + expect(Activity::query()->where('event', 'document-restored')->count())->toBe(1); +}); + +it('erases for good only with Erase deleted documents', function () { + $document = $this->documents->upload(Account::factory()->create(), $this->aadhaar, pngUpload(), null, $this->admin); + $this->documents->delete($document, $this->admin); + + $this->actingAs(staffWith(Abilities::RESTORE)); + Livewire::test('documents::deleted')->assertDontSee(__('Erase for good'))->call('erase', $document->id)->assertForbidden(); + + $this->actingAs(staffWith(Abilities::ERASE)); + Livewire::test('documents::deleted')->assertSee(__('Erase for good'))->call('erase', $document->id)->assertHasNoErrors(); + + expect(Document::withTrashed()->count())->toBe(0) + ->and(Storage::disk('addons')->allFiles())->toBe([]) + ->and(Activity::query()->where('event', 'document-erased')->count())->toBe(1); +}); + +it('never restores or erases a current document from this page', function () { + $this->actingAs($this->admin); + $document = $this->documents->upload(Account::factory()->create(), $this->aadhaar, pngUpload(), null, $this->admin); + + Livewire::test('documents::deleted')->call('erase', $document->id)->assertNotFound(); + expect($document->fresh())->not->toBeNull(); +}); + +it('needs Restore deleted documents for the page', function () { + $this->actingAs(staffWith(Abilities::VIEW, Abilities::EDIT)); + + $this->get(route('documents.deleted'))->assertForbidden(); +}); + +it('lists customers missing a required active document', function () { + $this->actingAs(staffWith(Abilities::VIEW)); + $photo = documentType('Customer photo'); + documentType('Agreement', ['required' => false]); + documentType('Old form', ['active' => false]); + $complete = Account::factory()->create(['fullname' => 'Complete Customer']); + $this->documents->upload($complete, $this->aadhaar, pngUpload(), null, $this->admin); + $this->documents->upload($complete, $photo, pngUpload(), null, $this->admin); + $half = Account::factory()->create(['fullname' => 'Half Customer']); + $this->documents->upload($half, $this->aadhaar, pngUpload(), null, $this->admin); + Account::factory()->create(['fullname' => 'Empty Customer']); + Account::factory()->create(['fullname' => 'Gone Customer'])->delete(); + + $this->get(route('documents.missing'))->assertOk() + ->assertSee('Half Customer')->assertSee('Empty Customer') + ->assertDontSee('Complete Customer')->assertDontSee('Gone Customer')->assertDontSee('Old form'); + + Livewire::test('documents::missing')->set('search', 'half') + ->assertSee('Half Customer')->assertSee('Customer photo')->assertDontSee('Empty Customer'); +}); + +it('needs View customer documents for the missing list', function () { + $this->actingAs(staffWith(Abilities::UPLOAD)); + + $this->get(route('documents.missing'))->assertForbidden(); +}); + +it('shows both pages in the Add-ons menu to staff who may open them', function () { + $this->actingAs(staffWith(Abilities::VIEW)); + $this->get(route('dashboard'))->assertSee(route('documents.missing'))->assertDontSee(route('documents.deleted')); + + $this->actingAs($this->admin); + $this->get(route('dashboard'))->assertSee(route('documents.missing'))->assertSee(route('documents.deleted')); +}); From 94f9b8f07fbf45b433d7009995f66c0db8bfac5d Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 12:53:01 +0530 Subject: [PATCH 06/11] Add documents:export --- src/Console/ExportCommand.php | 100 ++++++++++++++++++++++++++++ src/DocumentsServiceProvider.php | 6 ++ tests/Feature/ExportCommandTest.php | 64 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/Console/ExportCommand.php create mode 100644 tests/Feature/ExportCommandTest.php diff --git a/src/Console/ExportCommand.php b/src/Console/ExportCommand.php new file mode 100644 index 0000000..ee3ec6a --- /dev/null +++ b/src/Console/ExportCommand.php @@ -0,0 +1,100 @@ +argument('folder'), '/\\'); + + if (is_dir($folder) && (File::files($folder) !== [] || File::directories($folder) !== [])) { + $this->error("{$folder} is not empty. Choose a new or empty folder."); + + return self::FAILURE; + } + + $accounts = Account::withTrashed() + ->when($this->option('customer'), fn ($query, string $username) => $query->where('username', $username)) + ->get()->keyBy('id'); + + if ($this->option('customer') && $accounts->isEmpty()) { + $this->error('No customer with that username.'); + + return self::FAILURE; + } + + $rows = Document::query()->with('type') + ->when($this->option('include-deleted'), fn ($query) => $query->withTrashed()) + ->whereIn('account_id', $accounts->keys()) + ->orderBy('account_id')->orderBy('id') + ->get(); + $staff = User::query()->whereIn('id', $rows->pluck('uploaded_by')->filter())->pluck('name', 'id'); + + File::ensureDirectoryExists($folder, 0700); + $index = fopen("{$folder}/documents.csv", 'w'); + fputcsv($index, ['customer', 'username', 'type', 'id_number', 'uploaded_at', 'uploaded_by', 'deleted'], escape: ''); + + foreach ($rows as $row) { + $account = $accounts[$row->account_id]; + $directory = "{$folder}/".$this->safe($account->username); + File::ensureDirectoryExists($directory, 0700); + $name = $row->trashed() ? "{$row->type->name} (deleted {$row->deleted_at->format('Y-m-d')})" : $row->type->name; + $target = $this->freeName($directory, $this->safe($name), pathinfo(StoredFile::downloadName('x', 'x', $row->mime), PATHINFO_EXTENSION)); + file_put_contents($target, $files->read($row)); + + fputcsv($index, [ + $account->fullname, + $account->username, + $row->type->name, + (string) $row->number, + $row->created_at?->toDateTimeString(), + (string) ($staff[$row->uploaded_by] ?? ''), + $row->trashed() ? 'yes' : 'no', + ], escape: ''); + } + + fclose($index); + $documents->log('documents-exported', null, '', null, ['count' => $rows->count()]); + $this->info("{$rows->count()} documents written to {$folder}. They are readable: keep the folder safe and delete it when done."); + + return self::SUCCESS; + } + + private function safe(string $name): string + { + return trim(str_replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], '-', $name)) ?: 'document'; + } + + /** + * ".", or " (2)." and so on when that is taken. + */ + private function freeName(string $directory, string $name, string $extension): string + { + $path = "{$directory}/{$name}.{$extension}"; + + for ($n = 2; file_exists($path); $n++) { + $path = "{$directory}/{$name} ({$n}).{$extension}"; + } + + return $path; + } +} diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index d3fcbf7..ed4031e 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -12,6 +12,7 @@ use App\Support\Navigation\NavItem; use App\Support\Permissions; use Illuminate\Support\ServiceProvider; +use Ispgrid\Documents\Console\ExportCommand; use Livewire\Livewire; /** @@ -43,6 +44,11 @@ public function boot(): void // Registered on every core: core 0.1.0's role form refuses to save a role holding a box it does not list. Abilities::register($this->app->make(Permissions::class)); + // Works on every core, so the documents can always be taken out. + if ($this->app->runningInConsole()) { + $this->commands([ExportCommand::class]); + } + // Sending a decrypted file needs core 0.1.2. On an older core the add-on only keeps its boxes and its data. if (! method_exists(PrivateFile::class, 'contentResponse')) { return; diff --git a/tests/Feature/ExportCommandTest.php b/tests/Feature/ExportCommandTest.php new file mode 100644 index 0000000..57cea76 --- /dev/null +++ b/tests/Feature/ExportCommandTest.php @@ -0,0 +1,64 @@ +folder = sys_get_temp_dir().'/documents-export-'.uniqid(); + $this->admin = User::factory()->create(['name' => 'Ravi']); + $documents = app(CustomerDocuments::class); + $this->ramesh = Account::factory()->create(['fullname' => 'Ramesh Kumar', 'username' => 'ramesh']); + $sita = Account::factory()->create(['fullname' => 'Sita Devi', 'username' => 'sita']); + $aadhaar = documentType(); + $agreement = documentType('Agreement', ['number_mode' => NumberMode::None]); + $old = $documents->upload($this->ramesh, $aadhaar, pngUpload(), '1111 2222 3333', $this->admin); + $documents->upload($this->ramesh, $aadhaar, pngUpload(), '1234 5678 4821', $this->admin); + $documents->upload($this->ramesh, $agreement, UploadedFile::fake()->createWithContent('a.pdf', pdfBytes()), null, $this->admin); + $documents->upload($sita, $aadhaar, pngUpload(), '9999 8888 7777', $this->admin); + $this->hiddenDate = $old->fresh()->deleted_at->format('Y-m-d'); +}); + +afterEach(fn () => File::deleteDirectory($this->folder)); + +it('writes readable copies of every current document and an index', function () { + $this->artisan('documents:export', ['folder' => $this->folder])->assertSuccessful(); + + expect(file_get_contents("{$this->folder}/ramesh/Aadhaar card.png"))->toBe(pngBytes()) + ->and(file_get_contents("{$this->folder}/ramesh/Agreement.pdf"))->toBe(pdfBytes()) + ->and(file_exists("{$this->folder}/sita/Aadhaar card.png"))->toBeTrue() + ->and(File::files("{$this->folder}/ramesh"))->toHaveCount(2); + + $csv = array_map('str_getcsv', file("{$this->folder}/documents.csv", FILE_IGNORE_NEW_LINES)); + expect($csv[0])->toBe(['customer', 'username', 'type', 'id_number', 'uploaded_at', 'uploaded_by', 'deleted']) + ->and(collect($csv)->firstWhere(2, 'Aadhaar card'))->toMatchArray([0 => 'Ramesh Kumar', 1 => 'ramesh', 3 => '1234 5678 4821', 5 => 'Ravi', 6 => 'no']) + ->and(Activity::query()->where('event', 'documents-exported')->sole()->properties['count'])->toBe(3); +}); + +it('adds hidden documents only when asked', function () { + $this->artisan('documents:export', ['folder' => $this->folder, '--include-deleted' => true])->assertSuccessful(); + + expect(file_get_contents("{$this->folder}/ramesh/Aadhaar card (deleted {$this->hiddenDate}).png"))->toBe(pngBytes()) + ->and(file_get_contents("{$this->folder}/documents.csv"))->toContain('1111 2222 3333'); +}); + +it('exports one customer', function () { + $this->artisan('documents:export', ['folder' => $this->folder, '--customer' => 'ramesh'])->assertSuccessful(); + + expect(is_dir("{$this->folder}/ramesh"))->toBeTrue()->and(is_dir("{$this->folder}/sita"))->toBeFalse(); + + $this->artisan('documents:export', ['folder' => $this->folder.'-x', '--customer' => 'nobody'])->assertFailed(); +}); + +it('refuses a folder that is not empty', function () { + File::ensureDirectoryExists($this->folder); + file_put_contents("{$this->folder}/keep.txt", 'mine'); + + $this->artisan('documents:export', ['folder' => $this->folder])->assertFailed(); + + expect(File::files($this->folder))->toHaveCount(1); +}); From ce1b9905f20cd16f986e4691ea611c87916c8a71 Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 12:54:55 +0530 Subject: [PATCH 07/11] Add activity labels and the README --- README.md | 112 ++++++++++++++++++++++++++- lang/hi.json | 11 +++ src/DocumentsServiceProvider.php | 17 ++++ tests/Feature/ActivityLabelsTest.php | 14 ++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/ActivityLabelsTest.php diff --git a/README.md b/README.md index efcfc2c..6dedb0b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,113 @@ # Customer documents for ISPGrid -Customer documents (ID proofs, agreements) for [ISPGrid](https://github.com/ispgrid/ispgrid). Work in progress. +An add-on for [ISPGrid](https://github.com/ispgrid/ispgrid) that keeps each customer's documents: ID proofs, a +photo, a signed agreement. Staff photograph a card with the phone's camera or upload a PDF while they create the +customer. Indian ISPs must keep ID proof for every customer; ISPs elsewhere can keep whatever they need. + +> [!WARNING] +> **Preview: not ready for production yet.** Like ISPGrid itself, it can bring breaking changes until v1.0.0. Read the +> release notes before you upgrade. + +## What it does + +- **Document types you write:** on System, then Add-ons, add the types your ISP keeps (nothing is prefilled): a + name, required or not, a help text for staff (for example "Upload a masked Aadhaar"), and whether to keep an ID + number: none, the full number, or only the last 4 characters. One file per type per customer. +- **Documents tab in the customer form:** for each type, **Take photo** (opens the camera on a phone) or **Choose + file**. JPG, PNG or PDF, up to 10 MB, checked by the file's content. A missing required document does not stop the + save: the tab shows an amber dot and the customer appears under Missing documents. +- **Documents card on the customer page:** view a document, upload a missing one, edit its type or number, delete + it, or download all of a customer's documents as one zip (for an audit). ID numbers are shown masked + (`xxxx xxxx 4821`); **Show** reveals the full number. +- **Nothing is lost by accident:** Delete hides a document; **Deleted documents** (menu Add-ons) restores it or + erases it for good. Replacing a file hides the old one the same way. Documents stay when a customer is deleted. +- **Missing documents** (menu Add-ons): customers without a required document. +- **Everything is on record:** every view, Show, download, upload, change, delete, restore, erase and export is + written to the Activity log (never the number or the file). +- **Permissions** on the Roles screen, none given to a role on install (Admin has them all): + "View customer documents", "Upload documents", "Edit and delete documents", "Restore deleted documents", "Erase + deleted documents" and "Document types". Ticking one ticks what it needs (Erase ticks Restore, which ticks View). +- English and Hindi. + +Not yet: blocking a customer's activation until the required documents are in, shrinking photos on the phone. + +## Aadhaar and ID numbers + +- **Full number or last 4:** the choice is yours, per type. A full number is stored encrypted and shown masked. + UIDAI's Aadhaar Data Vault rule (a separate vault for Aadhaar numbers) applies to bodies that check Aadhaar online + with UIDAI ("requesting entities", the ones doing Aadhaar e-KYC), as UIDAI's 2025 circular is summarised by law + firms; an ISP that keeps a photo of the card and types the number is not one. If your ISP becomes one, switch the + type to "Last 4 characters only": the panel then cuts every stored number of that type to its last 4 (it asks + first; this cannot be undone). Check the current rules with your adviser. +- **Masked Aadhaar:** where you can, ask customers for a masked Aadhaar (first 8 digits hidden) and say so in the + type's help text. +- **Duplicates:** a full number already on another customer gives a warning (compared on letters and digits, + ignoring case), never a block. + +## Encryption, backups and erasing + +- Files and full ID numbers are encrypted with the panel's `APP_KEY` (in `/opt/ispgrid/.env`). Without that key they + cannot be read. **Never change `APP_KEY` by hand.** For a planned change, keep the old key in `APP_PREVIOUS_KEYS`. +- The nightly ISPGrid backup holds the documents, the database and `.env` with the key, so restoring a backup (also + on a new server) brings everything back readable. A stolen backup holds the key too: keep backups safe. +- **Erase for good** removes the file at once, but backups taken before still hold it until they age out (14 days). +- While staff pick a file, Livewire keeps it unencrypted in its temporary folder; the add-on deletes it as soon as + the document is saved. A file picked but never saved stays there until Livewire's cleanup (about a day). + +## Taking documents out + +- **One customer, for an audit:** Download all on the customer's Documents card (one zip, logged). +- **Everything, for leaving ISPGrid:** on the server, + + ```bash + cd /opt/ispgrid && sudo -u ispgrid php artisan documents:export /tmp/documents-export + ``` + + writes readable copies, one folder per customer, and `documents.csv` (customer, type, ID number as stored, who + uploaded it and when). `--customer=` exports one customer, `--include-deleted` adds hidden documents. + The folder must be new or empty, and the files in it are readable: keep it safe and delete it when done. +- **Moving to another ISPGrid server:** no export needed. Restore the backup; it carries the key. + +## Install + +It needs ISPGrid 0.1.2 or newer (on an older ISPGrid it only adds its permissions and keeps its data). On a server +set up with the ISPGrid installer, as root: + +```bash +sudo /opt/ispgrid/deploy/install.sh --addon=ispgrid/documents +``` + +The installer keeps the add-on on every upgrade, and the nightly backup keeps its database `ispgrid_documents` and +its files (`storage/app/private/addons/documents`). + +## Remove + +```bash +sudo /opt/ispgrid/deploy/install.sh --remove-addon=ispgrid/documents +``` + +The tab, card and pages go away, but the documents are kept: installing the add-on again shows them as they were. To +delete every document too: + +```bash +sudo /opt/ispgrid/deploy/install.sh --remove-addon=ispgrid/documents --delete-data +``` + +The backup taken at the start of that run keeps a copy for 14 days; after that the documents cannot be brought back. + +## Development + +In an ISPGrid checkout: + +```bash +composer config repositories.ispgrid-documents '{"type": "path", "url": "../path/to/ispgrid-documents", "options": {"symlink": true}}' +composer require ispgrid/documents:@dev +ISPGRID_ADDONS=on php vendor/bin/pest --test-directory=vendor/ispgrid/documents/tests vendor/ispgrid/documents/tests +``` + +Do not commit the changed `composer.json` and `composer.lock` in the ISPGrid checkout. The tests use the database +`ispgrid_documents_test` next to ISPGrid's `radius_test`. + +## License + +MIT diff --git a/lang/hi.json b/lang/hi.json index 1b08a95..7cd2614 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -16,14 +16,23 @@ "Deleted": "हटाया गया", "Deleted documents": "हटाए गए दस्तावेज़", "Deleted documents are hidden from the customer, not removed. Restore one, or erase it for good.": "हटाए गए दस्तावेज़ ग्राहक से छिपाए जाते हैं, मिटाए नहीं जाते। किसी को वापस लाएँ, या हमेशा के लिए मिटाएँ।", + "Document changed": "दस्तावेज़ में बदलाव", + "Document deleted": "दस्तावेज़ हटाया गया", "Document deleted. It can be restored from Deleted documents.": "दस्तावेज़ हटा दिया गया। इसे हटाए गए दस्तावेज़ों से वापस लाया जा सकता है।", + "Document erased": "दस्तावेज़ मिटाया गया", "Document erased.": "दस्तावेज़ मिटा दिया गया।", + "Document replaced": "दस्तावेज़ बदला गया", + "Document restored": "दस्तावेज़ वापस लाया गया", "Document restored.": "दस्तावेज़ वापस आ गया।", "Document saved.": "दस्तावेज़ सेव हो गया।", "Document type": "दस्तावेज़ का प्रकार", "Document type saved.": "दस्तावेज़ का प्रकार सेव हो गया।", "Document types": "दस्तावेज़ के प्रकार", + "Document uploaded": "दस्तावेज़ अपलोड हुआ", + "Document viewed": "दस्तावेज़ देखा गया", "Documents": "दस्तावेज़", + "Documents downloaded": "दस्तावेज़ डाउनलोड हुए", + "Documents exported": "दस्तावेज़ निर्यात हुए", "Download all": "सब डाउनलोड करें", "Edit and delete documents": "दस्तावेज़ बदलें और हटाएँ", "Edit document": "दस्तावेज़ बदलें", @@ -35,6 +44,8 @@ "Full ID number": "पूरा आईडी नंबर", "Help text": "मदद का टेक्स्ट", "ID number": "आईडी नंबर", + "ID number shown": "आईडी नंबर दिखाया गया", + "ID numbers cut to the last 4": "आईडी नंबर आखिरी 4 तक छोटे किए गए", "JPG, PNG or PDF, up to 10 MB.": "JPG, PNG या PDF, 10 MB तक।", "Last 4 characters only": "केवल आखिरी 4 अक्षर", "Leave empty to keep the number on file.": "पुराना नंबर रखने के लिए खाली छोड़ें।", diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index ed4031e..185fe90 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -4,6 +4,7 @@ use App\Models\Account; use App\Models\User; +use App\Support\ActivityFormatter; use App\Support\Addons\AddonSettings; use App\Support\Addons\CustomerCards; use App\Support\Addons\CustomerFormTabs; @@ -44,6 +45,22 @@ public function boot(): void // Registered on every core: core 0.1.0's role form refuses to save a role holding a box it does not list. Abilities::register($this->app->make(Permissions::class)); + foreach ([ + 'document-uploaded' => fn (): string => __('Document uploaded'), + 'document-replaced' => fn (): string => __('Document replaced'), + 'document-edited' => fn (): string => __('Document changed'), + 'document-deleted' => fn (): string => __('Document deleted'), + 'document-restored' => fn (): string => __('Document restored'), + 'document-erased' => fn (): string => __('Document erased'), + 'document-viewed' => fn (): string => __('Document viewed'), + 'document-number-shown' => fn (): string => __('ID number shown'), + 'documents-downloaded' => fn (): string => __('Documents downloaded'), + 'documents-exported' => fn (): string => __('Documents exported'), + 'document-numbers-cut' => fn (): string => __('ID numbers cut to the last 4'), + ] as $event => $label) { + ActivityFormatter::addEventLabel($event, $label); + } + // Works on every core, so the documents can always be taken out. if ($this->app->runningInConsole()) { $this->commands([ExportCommand::class]); diff --git a/tests/Feature/ActivityLabelsTest.php b/tests/Feature/ActivityLabelsTest.php new file mode 100644 index 0000000..43446a2 --- /dev/null +++ b/tests/Feature/ActivityLabelsTest.php @@ -0,0 +1,14 @@ + $event]); + + expect(ActivityFormatter::eventLabel($activity))->not->toBe($event); +})->with([ + 'document-uploaded', 'document-replaced', 'document-edited', 'document-deleted', 'document-restored', + 'document-erased', 'document-viewed', 'document-number-shown', 'documents-downloaded', 'documents-exported', + 'document-numbers-cut', +]); From 6ddaf272cb6aab25332484f30a7c09eb0cca9819 Mon Sep 17 00:00:00 2001 From: Karan Date: Sat, 17 Oct 2026 13:09:45 +0530 Subject: [PATCH 08/11] Serialise uploads per type and close the review findings Replace on the card, the cut via none, masking after View is taken away, the zip size cap, the export folder and CSV cells. --- lang/hi.json | 2 + .../customer-card/customer-card.blade.php | 42 +++++++++---- .../livewire/customer-card/customer-card.php | 20 +++++- .../views/livewire/settings/settings.php | 11 +++- src/Console/ExportCommand.php | 23 +++++-- src/CustomerDocuments.php | 12 +++- src/Http/DownloadAllController.php | 4 ++ src/IdNumber.php | 4 +- src/StoredFile.php | 8 +-- tests/Feature/CustomerCardTest.php | 62 +++++++++++++++++++ tests/Feature/DocumentsTabTest.php | 17 +++++ tests/Feature/ExportCommandTest.php | 18 ++++++ tests/Feature/IdNumberTest.php | 5 ++ tests/Feature/SettingsTest.php | 25 ++++++++ tests/Feature/UploadRaceTest.php | 42 +++++++++++++ 15 files changed, 264 insertions(+), 31 deletions(-) create mode 100644 tests/Feature/UploadRaceTest.php diff --git a/lang/hi.json b/lang/hi.json index 7cd2614..c85850a 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -62,6 +62,7 @@ "No document types yet. Add them on System, then Add-ons.": "अभी कोई दस्तावेज़ प्रकार नहीं है। उन्हें सिस्टम, फिर ऐड-ऑन में जोड़ें।", "Not uploaded": "अपलोड नहीं हुआ", "Off": "बंद", + "Replace": "बदलें", "Replace with a file": "फ़ाइल से बदलें", "Replace with a photo": "फ़ोटो से बदलें", "Required": "ज़रूरी", @@ -75,6 +76,7 @@ "Take photo": "फ़ोटो लें", "That type already has a file. Delete that one first.": "इस प्रकार की फ़ाइल पहले से है। पहले उसे हटाएँ।", "The documents your staff keep for each customer. A type that has documents can be switched off but not deleted.": "हर ग्राहक के लिए आपका स्टाफ़ जो दस्तावेज़ रखता है। जिस प्रकार के दस्तावेज़ हैं उसे बंद किया जा सकता है, हटाया नहीं।", + "These documents are too large for one download. Use documents:export on the server.": "ये दस्तावेज़ एक बार में डाउनलोड करने के लिए बहुत बड़े हैं। सर्वर पर documents:export चलाएँ।", "This type has documents. Switch it off instead.": "इस प्रकार के दस्तावेज़ हैं। इसकी जगह इसे बंद करें।", "Type the number on one line, up to :max characters.": "नंबर एक ही लाइन में लिखें, ज़्यादा से ज़्यादा :max अक्षर।", "Unknown": "अज्ञात", diff --git a/resources/views/livewire/customer-card/customer-card.blade.php b/resources/views/livewire/customer-card/customer-card.blade.php index 5310b44..1254d51 100644 --- a/resources/views/livewire/customer-card/customer-card.blade.php +++ b/resources/views/livewire/customer-card/customer-card.blade.php @@ -35,7 +35,7 @@ icon="trash" class="text-red-600 dark:text-red-400" wire:click="delete({{ $row['document']['id'] }})" - wire:confirm="{{ __('Delete the :type of :customer?', ['type' => $row['name'], 'customer' => $this->account->fullname ?: $this->account->username]) }}" + wire:confirm="{{ __('Delete the :type of :customer?', ['type' => $row['name'], 'customer' => $this->customerName]) }}" >{{ __('Delete') }} @endif @@ -52,20 +52,36 @@ class="text-red-600 dark:text-red-400" @endif @endif - @elseif ($this->allowed['upload']) - @if ($row['numbered']) - - @endif -
- - -
-
- {{ __('Upload') }} -
- @else + @elseif (! $this->allowed['upload']) {{ __('Not uploaded') }} @endif + + @if ($this->allowed['upload'] && $row['active']) +
+ {{ $row['document'] === null ? __('Upload') : __('Replace') }} +
+ @if ($row['help'] !== '') + {{ $row['help'] }} + @endif + @if ($row['numbered']) + + @endif +
+ + +
+
+ {{ $row['document'] === null ? __('Upload') : __('Replace') }} +
+
+
+ @endif @endforeach diff --git a/resources/views/livewire/customer-card/customer-card.php b/resources/views/livewire/customer-card/customer-card.php index 09af444..e6a5def 100644 --- a/resources/views/livewire/customer-card/customer-card.php +++ b/resources/views/livewire/customer-card/customer-card.php @@ -90,7 +90,7 @@ public function allowed(): array /** * One row per active type, and per switched-off type that still has a current file. * - * @return list + * @return list */ #[Computed] public function rows(): array @@ -107,6 +107,8 @@ public function rows(): array return [ 'type_id' => $type->id, 'name' => $type->name, + 'help' => (string) $type->help, + 'active' => $type->active, 'required' => $type->required && $type->active, 'numbered' => $type->number_mode !== NumberMode::None && $type->active, 'document' => $document === null ? null : [ @@ -115,7 +117,7 @@ public function rows(): array 'by' => (string) ($uploaders[$document->uploaded_by] ?? __('Unknown')), 'number' => match (true) { $document->number === null => '', - $this->shownId === $document->id => $document->number, + $this->allowed['view'] && $this->shownId === $document->id => $document->number, default => IdNumber::mask($document->number), }, 'url' => route('documents.file', $document->id), @@ -125,12 +127,24 @@ public function rows(): array } /** + * The types a document can be moved to in Edit: the active ones, and the type it has now even when switched off. + * * @return array */ #[Computed] public function typeOptions(): array { - return DocumentType::query()->where('active', true)->orderBy('position')->pluck('name', 'id')->all(); + $current = $this->editingId === null ? null : Document::query()->whereKey($this->editingId)->value('document_type_id'); + + return DocumentType::query() + ->where(fn ($query) => $query->where('active', true)->orWhere('id', $current)) + ->orderBy('position')->pluck('name', 'id')->all(); + } + + #[Computed] + public function customerName(): string + { + return (string) ($this->account->fullname ?: $this->account->username); } public function showNumber(int $id): void diff --git a/resources/views/livewire/settings/settings.php b/resources/views/livewire/settings/settings.php index 8c4ab33..73751eb 100644 --- a/resources/views/livewire/settings/settings.php +++ b/resources/views/livewire/settings/settings.php @@ -110,7 +110,8 @@ public function save(): void $this->validate($this->rules()); $type = $this->editingId === null ? null : DocumentType::query()->findOrFail($this->editingId); - if ($type?->number_mode === NumberMode::Full && $this->numberMode === NumberMode::LastFour->value) { + // Coming to last 4 from full or from none (numbers kept under none may be full ones). + if ($type !== null && $type->number_mode !== NumberMode::LastFour && $this->numberMode === NumberMode::LastFour->value) { $this->cutCount = Document::withTrashed()->where('document_type_id', $type->id)->whereNotNull('number')->count(); if ($this->cutCount > 0) { @@ -128,6 +129,14 @@ public function cutNumbers(): void { $this->authorize(Abilities::TYPES); $this->validate($this->rules()); + + // The choice was changed back after the warning: nothing to cut. + if ($this->numberMode !== NumberMode::LastFour->value || $this->editingId === null) { + $this->cutCount = 0; + + return; + } + $type = DocumentType::query()->findOrFail($this->editingId); $count = 0; diff --git a/src/Console/ExportCommand.php b/src/Console/ExportCommand.php index ee3ec6a..16a7158 100644 --- a/src/Console/ExportCommand.php +++ b/src/Console/ExportCommand.php @@ -61,15 +61,15 @@ public function handle(StoredFile $files, CustomerDocuments $documents): int $target = $this->freeName($directory, $this->safe($name), pathinfo(StoredFile::downloadName('x', 'x', $row->mime), PATHINFO_EXTENSION)); file_put_contents($target, $files->read($row)); - fputcsv($index, [ - $account->fullname, + fputcsv($index, array_map($this->cell(...), [ + (string) $account->fullname, $account->username, $row->type->name, (string) $row->number, - $row->created_at?->toDateTimeString(), + (string) $row->created_at?->toDateTimeString(), (string) ($staff[$row->uploaded_by] ?? ''), $row->trashed() ? 'yes' : 'no', - ], escape: ''); + ]), escape: ''); } fclose($index); @@ -79,9 +79,22 @@ public function handle(StoredFile $files, CustomerDocuments $documents): int return self::SUCCESS; } + /** + * A name that stays inside its folder: no slashes or other refused characters, and never "." or "..". + */ private function safe(string $name): string { - return trim(str_replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], '-', $name)) ?: 'document'; + $safe = trim(str_replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], '-', $name)); + + return $safe === '' || trim($safe, '.') === '' ? 'customer-'.bin2hex($safe) : $safe; + } + + /** + * A spreadsheet reads a cell starting with = + - @ (or a tab or return) as a formula; a leading ' keeps it text. + */ + private function cell(string $value): string + { + return preg_match('/^[=+\-@\t\r]/', $value) === 1 ? "'".$value : $value; } /** diff --git a/src/CustomerDocuments.php b/src/CustomerDocuments.php index 4b2b4a7..17a3dfb 100644 --- a/src/CustomerDocuments.php +++ b/src/CustomerDocuments.php @@ -12,6 +12,7 @@ use Ispgrid\Documents\Models\Document; use Ispgrid\Documents\Models\DocumentType; use Ispgrid\Documents\Models\NumberMode; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Throwable; /** @@ -31,9 +32,11 @@ public function upload(Account $account, DocumentType $type, UploadedFile $file, $stored = $this->files->put($account->id, $file); try { - // A second save that raced past the lock hits the unique current_key; the retry hides its file. + // storeRow() locks the type's row, so two saves of one type take turns (a deadlock is retried). A row + // written past that lock still hits the unique current_key, and the retry then hides it. [$document, $replaced] = retry(2, fn (): array => DB::connection('documents')->transaction( fn (): array => $this->storeRow($account, $type, $stored, $typedNumber, $by), + attempts: 3, ), 0, fn (Throwable $e): bool => $e instanceof UniqueConstraintViolationException); } catch (Throwable $exception) { $this->files->erase($stored['path']); @@ -41,6 +44,11 @@ public function upload(Account $account, DocumentType $type, UploadedFile $file, throw $exception; } + // Only now that the document is safe: the readable copy Livewire kept while staff picked the file. + if ($file instanceof TemporaryUploadedFile) { + $file->delete(); + } + $this->log($replaced ? 'document-replaced' : 'document-uploaded', $account, $type->name, $by); return $document; @@ -160,6 +168,8 @@ public function log(string $event, ?Account $account, string $typeName, ?User $b */ private function storeRow(Account $account, DocumentType $type, array $stored, ?string $typedNumber, User $by): array { + DocumentType::query()->whereKey($type->id)->lockForUpdate()->first(); + $current = Document::query() ->where('account_id', $account->id) ->where('document_type_id', $type->id) diff --git a/src/Http/DownloadAllController.php b/src/Http/DownloadAllController.php index 89d5966..f9e0d7a 100644 --- a/src/Http/DownloadAllController.php +++ b/src/Http/DownloadAllController.php @@ -17,6 +17,8 @@ */ final class DownloadAllController { + public const MAX_BYTES = 60 * 1024 * 1024; + public function __invoke(int $account, StoredFile $files, CustomerDocuments $documents): Response { abort_unless(auth()->user()?->can(Abilities::VIEW), 403); @@ -24,6 +26,8 @@ public function __invoke(int $account, StoredFile $files, CustomerDocuments $doc $name = $customer->fullname ?: $customer->username; $rows = Document::query()->with('type')->where('account_id', $customer->id)->get(); abort_if($rows->isEmpty(), 404); + // The files are decrypted in memory; past this, documents:export on the server is the way. + abort_if($rows->sum('size') > self::MAX_BYTES, 413, __('These documents are too large for one download. Use documents:export on the server.')); $path = (string) tempnam(sys_get_temp_dir(), 'documents'); diff --git a/src/IdNumber.php b/src/IdNumber.php index 263eb25..e48e02e 100644 --- a/src/IdNumber.php +++ b/src/IdNumber.php @@ -18,14 +18,14 @@ public static function normalise(string $typed): string } /** - * One line of printable characters (any script), 1 to 40 of them. + * One line of printable characters (any script), 1 to 40 of them, with at least one letter or digit. */ public static function isValid(string $typed): bool { $number = self::normalise($typed); return preg_match('/\p{C}/u', $typed) === 0 - && $number !== '' + && preg_match('/[\p{L}\p{N}]/u', $number) === 1 && mb_strlen($number) <= self::MAX; } diff --git a/src/StoredFile.php b/src/StoredFile.php index 2e20c07..272b58f 100644 --- a/src/StoredFile.php +++ b/src/StoredFile.php @@ -9,7 +9,6 @@ use Illuminate\Support\Str; use InvalidArgumentException; use Ispgrid\Documents\Models\Document; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; /** * Document files on the add-on disk, encrypted with the app key under a random name: documents//<40 @@ -20,7 +19,8 @@ final class StoredFile public const DISK = 'addons'; /** - * Encrypts and stores an upload, then deletes Livewire's readable temporary copy. + * Encrypts and stores an upload. Livewire's readable temporary copy is left for the caller to delete once the + * document's row is saved. * * @return array{path: string, mime: string, size: int, original_name: string} */ @@ -35,10 +35,6 @@ public function put(int $accountId, UploadedFile $file): array $path = "documents/{$accountId}/".Str::random(40); Storage::disk(self::DISK)->put($path, Crypt::encryptString($bytes)); - if ($file instanceof TemporaryUploadedFile) { - $file->delete(); - } - return [ 'path' => $path, 'mime' => (string) (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes), diff --git a/tests/Feature/CustomerCardTest.php b/tests/Feature/CustomerCardTest.php index 24b33f7..3448b19 100644 --- a/tests/Feature/CustomerCardTest.php +++ b/tests/Feature/CustomerCardTest.php @@ -144,6 +144,68 @@ function card(Account $account): Testable unlink($path); }); +it('lets staff with Upload documents replace a file from the card', function () { + $this->actingAs(staffWith(Abilities::UPLOAD)); + + card($this->account)->assertSee(__('Replace')) + ->set("uploads.{$this->aadhaar->id}", pngUpload('sharper.png'))->call('upload', $this->aadhaar->id)->assertHasNoErrors(); + + expect($this->document->fresh()->trashed())->toBeTrue() + ->and(Document::query()->sole()->original_name)->toBe('sharper.png') + ->and(Document::query()->sole()->number)->toBe('1234 5678 4821'); +}); + +it('masks the number again once View is taken away', function () { + $staff = staffWith(Abilities::VIEW, Abilities::UPLOAD); + $this->actingAs($staff); + $card = card($this->account)->call('showNumber', $this->document->id)->assertSee('1234 5678 4821'); + + revokeBox($staff, Abilities::VIEW); + + $card->call('$refresh')->assertDontSee('1234 5678 4821')->assertSee('xxxx xxxx 4821'); +}); + +it('edits the number of a document on a switched-off type', function () { + $this->actingAs($this->admin); + $this->aadhaar->update(['active' => false]); + + card($this->account)->call('edit', $this->document->id)->set('editNumber', '9999 8888 7777')->call('saveEdit')->assertHasNoErrors(); + + expect($this->document->fresh()->number)->toBe('9999 8888 7777'); +}); + +it('shows the type help text where staff upload', function () { + $this->actingAs($this->admin); + documentType('Customer photo', ['help' => 'Face clearly visible']); + + card($this->account)->assertSee('Face clearly visible'); +}); + +it('refuses a path or text where a file belongs', function () { + $this->actingAs($this->admin); + $photo = documentType('Customer photo'); + + card($this->account)->set("uploads.{$photo->id}", 'documents/1/secret')->call('upload', $photo->id)->assertHasErrors(["uploads.{$photo->id}"]); +}); + +it('refuses Upload once the customer is deleted while the card is open', function () { + $this->actingAs($this->admin); + $photo = documentType('Customer photo'); + $card = card($this->account)->assertOk(); + + $this->account->delete(); + + $card->set("uploads.{$photo->id}", pngUpload())->call('upload', $photo->id)->assertForbidden(); + expect(Document::query()->where('document_type_id', $photo->id)->count())->toBe(0); +}); + +it('refuses a zip too large for one download', function () { + $this->actingAs($this->admin); + Document::query()->update(['size' => 70 * 1024 * 1024]); + + $this->get(route('documents.zip', $this->account))->assertStatus(413); +}); + it('stops an open card once its box is taken away', function () { $staff = staffWith(Abilities::VIEW); $this->actingAs($staff); diff --git a/tests/Feature/DocumentsTabTest.php b/tests/Feature/DocumentsTabTest.php index 1afd049..c75230b 100644 --- a/tests/Feature/DocumentsTabTest.php +++ b/tests/Feature/DocumentsTabTest.php @@ -152,6 +152,23 @@ function ramesh(): Account expect(ramesh())->not->toBeNull()->and(Document::query()->count())->toBe(0); }); +it('refuses a path where a file belongs', function () { + newCustomerForm() + ->set("addonTabs.documents.files.{$this->aadhaar->id}", 'documents/1/secret') + ->call('save')->assertHasErrors(["addonTabs.documents.files.{$this->aadhaar->id}"]); +}); + +it('keeps the temporary copy when storing the document fails, so staff can save again', function () { + Document::creating(fn () => throw new RuntimeException('database down')); + $form = newCustomerForm()->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()); + $temporary = $form->get("addonTabs.documents.files.{$this->aadhaar->id}"); + + $form->call('save')->assertHasNoErrors(); + + expect($temporary->exists())->toBeTrue() + ->and(Storage::disk('addons')->allFiles())->toBe([]); +}); + it('keeps the number setting of the type', function () { $lastFour = documentType('Voter ID', ['number_mode' => NumberMode::LastFour]); diff --git a/tests/Feature/ExportCommandTest.php b/tests/Feature/ExportCommandTest.php index 57cea76..f5ab887 100644 --- a/tests/Feature/ExportCommandTest.php +++ b/tests/Feature/ExportCommandTest.php @@ -54,6 +54,24 @@ $this->artisan('documents:export', ['folder' => $this->folder.'-x', '--customer' => 'nobody'])->assertFailed(); }); +it('keeps every customer folder inside the export folder', function () { + $dots = Account::factory()->create(['username' => '..', 'fullname' => 'Dots']); + app(CustomerDocuments::class)->upload($dots, documentType('Photo'), pngUpload(), null, $this->admin); + + $this->artisan('documents:export', ['folder' => $this->folder, '--customer' => '..'])->assertSuccessful(); + + expect(file_exists(dirname($this->folder).'/Photo.png'))->toBeFalse() + ->and(glob("{$this->folder}/*/Photo.png"))->toHaveCount(1); +}); + +it('stops a cell from being read as a formula', function () { + $this->ramesh->update(['fullname' => '=HYPERLINK("http://x")']); + + $this->artisan('documents:export', ['folder' => $this->folder, '--customer' => 'ramesh'])->assertSuccessful(); + + expect(file_get_contents("{$this->folder}/documents.csv"))->toContain("'=HYPERLINK"); +}); + it('refuses a folder that is not empty', function () { File::ensureDirectoryExists($this->folder); file_put_contents("{$this->folder}/keep.txt", 'mine'); diff --git a/tests/Feature/IdNumberTest.php b/tests/Feature/IdNumberTest.php index a1ecd09..cf3389b 100644 --- a/tests/Feature/IdNumberTest.php +++ b/tests/Feature/IdNumberTest.php @@ -21,6 +21,11 @@ 'forty' => [str_repeat('a', 40), true], ]); +it('needs at least one letter or digit', function () { + expect(IdNumber::isValid('---'))->toBeFalse() + ->and(IdNumber::isValid('( )'))->toBeFalse(); +}); + it('keeps the last 4 letters or digits', function () { expect(IdNumber::lastFour('RJ-13/DLC/12/123456'))->toBe('3456') ->and(IdNumber::lastFour('ABCDE1234F'))->toBe('234F') diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index d3d9311..6b835f8 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -97,6 +97,31 @@ ->and(Activity::query()->where('event', 'document-numbers-cut')->sole()->properties['count'])->toBe(2); }); +it('cuts stored numbers when a type comes to last 4 by way of none', function () { + $staff = User::factory()->create(); + $this->actingAs($staff); + $type = documentType(); + $document = app(CustomerDocuments::class)->upload(Account::factory()->create(), $type, pngUpload(), '1234 5678 4821', $staff); + $type->update(['number_mode' => NumberMode::None]); + + Livewire::test('documents::settings')->call('edit', $type->id)->set('numberMode', 'last4')->call('save') + ->assertSet('cutCount', 1)->call('cutNumbers'); + + expect($document->fresh()->number)->toBe('4821')->and($type->fresh()->number_mode)->toBe(NumberMode::LastFour); +}); + +it('does not cut when the choice was changed back after the warning', function () { + $staff = User::factory()->create(); + $this->actingAs($staff); + $type = documentType(); + $document = app(CustomerDocuments::class)->upload(Account::factory()->create(), $type, pngUpload(), '1234 5678 4821', $staff); + + Livewire::test('documents::settings')->call('edit', $type->id)->set('numberMode', 'last4')->call('save') + ->set('numberMode', 'full')->call('cutNumbers'); + + expect($document->fresh()->number)->toBe('1234 5678 4821'); +}); + it('keeps stored numbers when a type stops taking numbers', function () { $staff = User::factory()->create(); $this->actingAs($staff); diff --git a/tests/Feature/UploadRaceTest.php b/tests/Feature/UploadRaceTest.php new file mode 100644 index 0000000..af89513 --- /dev/null +++ b/tests/Feature/UploadRaceTest.php @@ -0,0 +1,42 @@ +documents = app(CustomerDocuments::class); + $this->staff = User::factory()->create(); + $this->account = Account::factory()->create(); +}); + +it('serialises uploads of one type by locking the type row', function () { + $type = documentType(); + DB::connection('documents')->enableQueryLog(); + + $this->documents->upload($this->account, $type, pngUpload(), null, $this->staff); + + $locks = collect(DB::connection('documents')->getQueryLog())->pluck('query') + ->filter(fn (string $sql): bool => str_contains($sql, 'document_types') && str_contains($sql, 'for update')); + expect($locks)->not->toBeEmpty(); +}); + +it('retries when another save took the type first, and still stores the file', function () { + $type = documentType(); + $raced = false; + Document::creating(function (Document $document) use (&$raced, $type) { + if (! $raced) { + $raced = true; + DB::connection('documents')->table('documents')->insert(['account_id' => $document->account_id, 'document_type_id' => $type->id, 'path' => 'documents/other', 'original_name' => 'other.png', 'mime' => 'image/png', 'size' => 1, 'created_at' => now(), 'updated_at' => now()]); + } + }); + + $document = $this->documents->upload($this->account, $type, pngUpload(), null, $this->staff); + + expect($document->exists)->toBeTrue() + ->and(Storage::disk('addons')->exists($document->path))->toBeTrue() + ->and(Document::query()->where('account_id', $this->account->id)->count())->toBe(1); +}); From f255c93e431f4fb2ef12540a666e31f627261344 Mon Sep 17 00:00:00 2001 From: Karan Date: Mon, 19 Oct 2026 16:16:25 +0530 Subject: [PATCH 09/11] Use one upload box and a single upload-required setting Per-type required and the Missing documents page are gone: which papers complete KYC differs per customer. --- README.md | 32 ++-- ...6_10_17_000000_create_documents_tables.php | 1 - lang/hi.json | 28 ++- resources/views/customer-tab.blade.php | 96 ++++++----- .../customer-card/customer-card.blade.php | 116 ++++++------- .../livewire/customer-card/customer-card.php | 140 +++++++++------ .../views/livewire/deleted/deleted.blade.php | 1 + .../views/livewire/missing/missing.blade.php | 38 ----- resources/views/livewire/missing/missing.php | 96 ----------- .../livewire/settings/settings.blade.php | 16 +- .../views/livewire/settings/settings.php | 35 ++-- routes/web.php | 1 - src/CustomerDocuments.php | 26 ++- src/DocumentsServiceProvider.php | 22 --- src/DocumentsTab.php | 127 ++++++++------ src/Models/DocumentType.php | 4 +- src/NumberRule.php | 6 +- src/PairRule.php | 46 +++++ src/UploadRequired.php | 24 +++ tests/Feature/CustomerCardTest.php | 46 +++-- tests/Feature/CustomerDocumentsTest.php | 15 +- tests/Feature/DocumentsTabTest.php | 161 ++++++++++++------ tests/Feature/ListPagesTest.php | 38 +---- tests/Feature/SettingsTest.php | 26 ++- tests/Pest.php | 1 - 25 files changed, 610 insertions(+), 532 deletions(-) delete mode 100644 resources/views/livewire/missing/missing.blade.php delete mode 100644 resources/views/livewire/missing/missing.php create mode 100644 src/PairRule.php create mode 100644 src/UploadRequired.php diff --git a/README.md b/README.md index 6dedb0b..1bc430c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ An add-on for [ISPGrid](https://github.com/ispgrid/ispgrid) that keeps each customer's documents: ID proofs, a photo, a signed agreement. Staff photograph a card with the phone's camera or upload a PDF while they create the -customer. Indian ISPs must keep ID proof for every customer; ISPs elsewhere can keep whatever they need. +customer. Indian ISPs must keep ID proof for every customer; ISPs elsewhere can keep whatever they need. Which +documents complete a customer's KYC differs from customer to customer (Aadhaar for a person, PAN and GSTIN for a +business), so the add-on asks only whether a customer needs documents at all. > [!WARNING] > **Preview: not ready for production yet.** Like ISPGrid itself, it can bring breaking changes until v1.0.0. Read the @@ -11,17 +13,21 @@ customer. Indian ISPs must keep ID proof for every customer; ISPs elsewhere can ## What it does - **Document types you write:** on System, then Add-ons, add the types your ISP keeps (nothing is prefilled): a - name, required or not, a help text for staff (for example "Upload a masked Aadhaar"), and whether to keep an ID - number: none, the full number, or only the last 4 characters. One file per type per customer. -- **Documents tab in the customer form:** for each type, **Take photo** (opens the camera on a phone) or **Choose - file**. JPG, PNG or PDF, up to 10 MB, checked by the file's content. A missing required document does not stop the - save: the tab shows an amber dot and the customer appears under Missing documents. -- **Documents card on the customer page:** view a document, upload a missing one, edit its type or number, delete - it, or download all of a customer's documents as one zip (for an audit). ID numbers are shown masked - (`xxxx xxxx 4821`); **Show** reveals the full number. -- **Nothing is lost by accident:** Delete hides a document; **Deleted documents** (menu Add-ons) restores it or - erases it for good. Replacing a file hides the old one the same way. Documents stay when a customer is deleted. -- **Missing documents** (menu Add-ons): customers without a required document. + name, a help text for staff (for example "Upload a masked Aadhaar"), and whether to keep an ID number: none, the + full number, or only the last 4 characters. One file per type per customer. +- **Document upload is required** (same place): when ticked, a customer cannot be saved, when added or edited, + until at least one document is uploaded. Customers you had before ticking it need one the next time they are + edited. Off by default. +- **Documents tab in the customer form:** the customer's documents, and an upload box: pick the type, type the ID + number when the type keeps one, **Choose file** (on a phone this also offers the camera). **Add another document** + adds a box, so all of a new customer's papers go in with one save. JPG, PNG or PDF, up to 10 MB, checked by the + file's content. +- **Documents card on the customer page:** the documents, 3 to a row (1 on a phone), with View, Edit (type or + number) and Delete; one upload box; and Download all, one zip of the customer's documents for an audit. ID + numbers are shown masked (`xxxx xxxx 4821`); **Show** reveals the full number. +- **Nothing is lost by accident:** Delete hides a document; **Deleted documents** (a button in this add-on's + settings) restores it or erases it for good. Uploading a type the customer already has replaces the file (it asks + first on the customer page) and hides the old one the same way. Documents stay when a customer is deleted. - **Everything is on record:** every view, Show, download, upload, change, delete, restore, erase and export is written to the Activity log (never the number or the file). - **Permissions** on the Roles screen, none given to a role on install (Admin has them all): @@ -29,7 +35,7 @@ customer. Indian ISPs must keep ID proof for every customer; ISPs elsewhere can deleted documents" and "Document types". Ticking one ticks what it needs (Erase ticks Restore, which ticks View). - English and Hindi. -Not yet: blocking a customer's activation until the required documents are in, shrinking photos on the phone. +Not yet: shrinking photos on the phone before upload. ## Aadhaar and ID numbers diff --git a/database/migrations/2026_10_17_000000_create_documents_tables.php b/database/migrations/2026_10_17_000000_create_documents_tables.php index db1d1e2..1a33796 100644 --- a/database/migrations/2026_10_17_000000_create_documents_tables.php +++ b/database/migrations/2026_10_17_000000_create_documents_tables.php @@ -25,7 +25,6 @@ public function up(): void $table->id(); $table->string('name', 60)->unique(); $table->string('help', 200)->nullable(); - $table->boolean('required')->default(false); $table->string('number_mode', 5)->default('none'); $table->unsignedInteger('position')->default(0); $table->boolean('active')->default(true); diff --git a/lang/hi.json b/lang/hi.json index c85850a..f7d9a7b 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -1,16 +1,20 @@ { ":count stored number will be cut to its last 4 characters. This cannot be undone.|:count stored numbers will be cut to their last 4 characters. This cannot be undone.": ":count सेव किया गया नंबर उसके आखिरी 4 अक्षरों तक छोटा हो जाएगा। यह वापस नहीं हो सकता।|:count सेव किए गए नंबर उनके आखिरी 4 अक्षरों तक छोटे हो जाएँगे। यह वापस नहीं हो सकता।", ":date by :name": ":date, :name द्वारा", - ":file, uploaded :date by :name": ":file, :date को :name ने अपलोड की", + ":type (replaces the current file)": ":type (मौजूदा फ़ाइल की जगह लेगा)", ":type :number is already on :customer.": ":type :number पहले से :customer के पास है।", ":type saved.": ":type सेव हो गया।", - "A customer without it still saves; staff see a warning and the customer is listed under Missing documents.": "इसके बिना भी ग्राहक सेव होता है; स्टाफ़ को चेतावनी दिखती है और ग्राहक कमी वाले दस्तावेज़ों की सूची में आता है।", + "A customer cannot be saved, when added or edited, until at least one document is uploaded.": "जब तक कम से कम एक दस्तावेज़ अपलोड न हो, ग्राहक को जोड़ते या बदलते समय सेव नहीं किया जा सकता।", + "Add another document": "एक और दस्तावेज़ जोड़ें", "Add document type": "दस्तावेज़ का प्रकार जोड़ें", "Add the file for this number.": "इस नंबर के लिए फ़ाइल जोड़ें।", "Backups taken before an erase still hold the file for up to 14 days.": "मिटाने से पहले लिए गए बैकअप में फ़ाइल 14 दिन तक रहती है।", + "Choose a type": "प्रकार चुनें", + "Choose each document type once.": "हर दस्तावेज़ प्रकार एक ही बार चुनें।", "Choose file": "फ़ाइल चुनें", + "Choose the document type.": "दस्तावेज़ का प्रकार चुनें।", + "Choose the file.": "फ़ाइल चुनें।", "Clear the number": "नंबर हटाएँ", - "Customers without a document their ISP requires. Deleted customers are left out.": "वे ग्राहक जिनके पास ज़रूरी दस्तावेज़ नहीं है। हटाए गए ग्राहक शामिल नहीं हैं।", "Cut the numbers and save": "नंबर छोटे करें और सेव करें", "Delete the :type of :customer?": ":customer का :type हटाएँ?", "Deleted": "हटाया गया", @@ -28,6 +32,7 @@ "Document type": "दस्तावेज़ का प्रकार", "Document type saved.": "दस्तावेज़ का प्रकार सेव हो गया।", "Document types": "दस्तावेज़ के प्रकार", + "Document upload is required": "दस्तावेज़ अपलोड करना ज़रूरी है", "Document uploaded": "दस्तावेज़ अपलोड हुआ", "Document viewed": "दस्तावेज़ देखा गया", "Documents": "दस्तावेज़", @@ -40,7 +45,6 @@ "Erase deleted documents": "हटाए गए दस्तावेज़ हमेशा के लिए मिटाएँ", "Erase for good": "हमेशा के लिए मिटाएँ", "Erase the :type of :customer for good? This cannot be undone.": ":customer का :type हमेशा के लिए मिटाएँ? यह वापस नहीं हो सकता।", - "Every customer has the required documents.": "हर ग्राहक के पास ज़रूरी दस्तावेज़ हैं।", "Full ID number": "पूरा आईडी नंबर", "Help text": "मदद का टेक्स्ट", "ID number": "आईडी नंबर", @@ -49,31 +53,22 @@ "JPG, PNG or PDF, up to 10 MB.": "JPG, PNG या PDF, 10 MB तक।", "Last 4 characters only": "केवल आखिरी 4 अक्षर", "Leave empty to keep the number on file.": "पुराना नंबर रखने के लिए खाली छोड़ें।", - "Missing": "कमी है", - "Missing documents": "कमी वाले दस्तावेज़", - "Missing documents: :types.": "कमी वाले दस्तावेज़: :types।", "Move down": "नीचे ले जाएँ", "Move up": "ऊपर ले जाएँ", "Name or username": "नाम या यूज़रनेम", - "New ID number (with the new file)": "नया आईडी नंबर (नई फ़ाइल के साथ)", "No ID number": "कोई आईडी नंबर नहीं", "No deleted documents.": "कोई हटाया गया दस्तावेज़ नहीं।", - "No document type is required. Mark one as required on System, then Add-ons.": "कोई दस्तावेज़ प्रकार ज़रूरी नहीं है। सिस्टम, फिर ऐड-ऑन में किसी को ज़रूरी चिह्नित करें।", "No document types yet. Add them on System, then Add-ons.": "अभी कोई दस्तावेज़ प्रकार नहीं है। उन्हें सिस्टम, फिर ऐड-ऑन में जोड़ें।", - "Not uploaded": "अपलोड नहीं हुआ", + "No documents yet.": "अभी कोई दस्तावेज़ नहीं।", "Off": "बंद", - "Replace": "बदलें", - "Replace with a file": "फ़ाइल से बदलें", - "Replace with a photo": "फ़ोटो से बदलें", - "Required": "ज़रूरी", - "Required for every customer": "हर ग्राहक के लिए ज़रूरी", + "Replace the :type of :customer? The current file can be restored from Deleted documents.": ":customer का :type बदलें? मौजूदा फ़ाइल हटाए गए दस्तावेज़ों से वापस लाई जा सकती है।", "Restore": "वापस लाएँ", "Restore deleted documents": "हटाए गए दस्तावेज़ वापस लाएँ", + "Saved.": "सेव हो गया।", "Show": "दिखाएँ", "Shown to staff when they upload, e.g. Upload a masked Aadhaar (first 8 digits hidden).": "अपलोड करते समय स्टाफ़ को दिखता है, जैसे: मास्क्ड आधार अपलोड करें (पहले 8 अंक छिपे हुए)।", "Switch off": "बंद करें", "Switch on": "चालू करें", - "Take photo": "फ़ोटो लें", "That type already has a file. Delete that one first.": "इस प्रकार की फ़ाइल पहले से है। पहले उसे हटाएँ।", "The documents your staff keep for each customer. A type that has documents can be switched off but not deleted.": "हर ग्राहक के लिए आपका स्टाफ़ जो दस्तावेज़ रखता है। जिस प्रकार के दस्तावेज़ हैं उसे बंद किया जा सकता है, हटाया नहीं।", "These documents are too large for one download. Use documents:export on the server.": "ये दस्तावेज़ एक बार में डाउनलोड करने के लिए बहुत बड़े हैं। सर्वर पर documents:export चलाएँ।", @@ -82,6 +77,7 @@ "Unknown": "अज्ञात", "Upload": "अपलोड करें", "Upload a JPG, PNG or PDF.": "JPG, PNG या PDF अपलोड करें।", + "Upload at least one document.": "कम से कम एक दस्तावेज़ अपलोड करें।", "Upload documents": "दस्तावेज़ अपलोड करें", "Uploaded :date by :name": ":date को :name ने अपलोड किया", "View": "देखें", diff --git a/resources/views/customer-tab.blade.php b/resources/views/customer-tab.blade.php index 12a0ea7..0921db0 100644 --- a/resources/views/customer-tab.blade.php +++ b/resources/views/customer-tab.blade.php @@ -1,56 +1,60 @@ {{-- The Documents tab, included by core's customer form. It shows only what the tab's state() prepared. --}} -@if (($addonTabs['documents']['types'] ?? []) === []) - {{ __('No document types yet. Add them on System, then Add-ons.') }} -@endif +@php($tab = $addonTabs['documents'] ?? []) -@foreach ($addonTabs['documents']['types'] ?? [] as $typeId => $type) -
-
- - {{ $type['name'] }} - @if ($type['required']) - {{ __('Required') }} - @endif - - @if ($type['help'] !== '') - {{ $type['help'] }} - @endif -
+@error('addonTabs.documents.uploads') + {{ $message }} +@enderror - @if ($type['current'] !== null) - - {{ __(':file, uploaded :date by :name', ['file' => $type['current']['name'], 'date' => $type['current']['date'], 'name' => $type['current']['by']]) }} - @if ($type['current']['number'] !== '') - · {{ $type['current']['number'] }} - @endif - - @endif - - @if ($type['numbered']) - - @endif +@if (($tab['documents'] ?? []) !== []) +
+ @foreach ($tab['documents'] as $document) +
+
{{ $document['type'] }}
+ {{ $document['file'] }} + + {{ __('Uploaded :date by :name', ['date' => $document['date'], 'name' => $document['by']]) }} + @if ($document['number'] !== '') + · {{ $document['number'] }} + @endif + +
+ @endforeach +
+@endif -
- {{ __('No document types yet. Add them on System, then Add-ons.') }} +@else + @foreach ($tab['uploads'] ?? [] as $index => $row) + @continue(! is_array($row)) +
+ + @if ($tab['types'][$row['type'] ?? '']['numbered'] ?? false) + + @endif + @if ($index > 0) +
+ {{ __('Remove') }} +
+ @endif
-
-@endforeach + @endforeach + + @if (count($tab['uploads'] ?? []) < \Ispgrid\Documents\DocumentsTab::MAX_ROWS) +
+ {{ __('Add another document') }} +
+ @endif +@endif diff --git a/resources/views/livewire/customer-card/customer-card.blade.php b/resources/views/livewire/customer-card/customer-card.blade.php index 1254d51..ab379b8 100644 --- a/resources/views/livewire/customer-card/customer-card.blade.php +++ b/resources/views/livewire/customer-card/customer-card.blade.php @@ -2,89 +2,77 @@
{{ __('Documents') }} - @if ($this->allowed['view']) + @if ($this->allowed['view'] && $this->documents !== []) {{ __('Download all') }} @endif
- @if ($this->rows === []) - {{ __('No document types yet. Add them on System, then Add-ons.') }} - @endif - -
- @foreach ($this->rows as $row) -
-
-
- {{ $row['name'] }} - @if ($row['required'] && $row['document'] === null) - {{ __('Missing') }} - @endif -
- - @if ($row['document'] !== null) -
+ @if ($this->documents === []) + {{ __('No documents yet.') }} + @else +
+ @foreach ($this->documents as $document) +
+
{{ $document['type'] }}
+ {{ $document['file'] }} + {{ __('Uploaded :date by :name', ['date' => $document['date'], 'name' => $document['by']]) }} + @if ($document['number'] !== '') + + {{ $document['number'] }} + @if ($this->allowed['view'] && $shownId !== $document['id']) + {{ __('Show') }} + @endif + + @endif + @if ($this->allowed['view'] || $this->allowed['edit']) +
@if ($this->allowed['view']) - {{ __('View') }} + {{ __('View') }} @endif @if ($this->allowed['edit']) - {{ __('Edit') }} + {{ __('Edit') }} {{ __('Delete') }} @endif
@endif
+ @endforeach +
+ @endif - @if ($row['document'] !== null) - - {{ __('Uploaded :date by :name', ['date' => $row['document']['date'], 'name' => $row['document']['by']]) }} - @if ($row['document']['number'] !== '') - · {{ $row['document']['number'] }} - @if ($this->allowed['view'] && $shownId !== $row['document']['id']) - {{ __('Show') }} - @endif - @endif - - @elseif (! $this->allowed['upload']) - {{ __('Not uploaded') }} - @endif - - @if ($this->allowed['upload'] && $row['active']) -
- {{ $row['document'] === null ? __('Upload') : __('Replace') }} -
- @if ($row['help'] !== '') - {{ $row['help'] }} - @endif - @if ($row['numbered']) - - @endif -
- - -
-
- {{ $row['document'] === null ? __('Upload') : __('Replace') }} -
-
-
+ @if ($this->allowed['upload']) + @if ($this->uploadTypes === []) + {{ __('No document types yet. Add them on System, then Add-ons.') }} + @else +
+ + @if ($this->chosen['numbered']) + @endif -
- @endforeach -
+ +
+ @if ($this->chosen['confirm'] !== null) + {{ __('Upload') }} + @else + {{ __('Upload') }} + @endif +
+ + @endif + @endif @if ($this->allowed['edit']) diff --git a/resources/views/livewire/customer-card/customer-card.php b/resources/views/livewire/customer-card/customer-card.php index e6a5def..6c71700 100644 --- a/resources/views/livewire/customer-card/customer-card.php +++ b/resources/views/livewire/customer-card/customer-card.php @@ -3,7 +3,7 @@ use App\Livewire\Concerns\AuthorizesEveryRequest; use App\Livewire\Concerns\ShowsToasts; use App\Models\Account; -use App\Models\User; +use Illuminate\Support\Collection; use Illuminate\Validation\Rule; use Ispgrid\Documents\Abilities; use Ispgrid\Documents\ContentRule; @@ -19,9 +19,10 @@ use Livewire\WithFileUploads; /** - * The Documents card on the customer page (deleted customers too). What each staff member may do follows their - * boxes: View customer documents opens files and numbers, Upload documents adds files, Edit and delete documents - * changes or hides them. + * The Documents card on the customer page (deleted customers too): the current documents, and one upload box (a + * type, its ID number when the type keeps one, a file). What each staff member may do follows their boxes: View + * customer documents opens files and numbers, Upload documents adds files, Edit and delete documents changes or + * hides them. */ new class extends Component { @@ -30,17 +31,11 @@ #[Locked] public int $accountId; - /** - * New files by type id, and the number typed with each. - * - * @var array - */ - public array $uploads = []; + public string $uploadType = ''; - /** - * @var array - */ - public array $numbers = []; + public string $uploadNumber = ''; + + public mixed $uploadFile = null; /** * The document whose full number is shown. @@ -88,42 +83,64 @@ public function allowed(): array } /** - * One row per active type, and per switched-off type that still has a current file. + * The current documents, in the order of their types (switched-off types too). + * + * @return list + */ + #[Computed] + public function documents(): array + { + $documents = app(CustomerDocuments::class)->current($this->accountId); + $uploaders = app(CustomerDocuments::class)->uploaders($documents); + + return $documents->map(fn (Document $document): array => [ + 'id' => $document->id, + 'type' => $document->type->name, + 'file' => $document->original_name, + 'date' => $document->created_at?->toFormattedDateString(), + 'by' => (string) ($uploaders[$document->uploaded_by] ?? __('Unknown')), + 'number' => match (true) { + $document->number === null => '', + $this->allowed['view'] && $this->shownId === $document->id => $document->number, + default => IdNumber::mask($document->number), + }, + 'url' => route('documents.file', $document->id), + ])->all(); + } + + /** + * The active types for the upload box; a type that already has a file says it is replaced. + * + * @return array + */ + #[Computed] + public function uploadTypes(): array + { + $taken = Document::query()->where('account_id', $this->accountId)->pluck('document_type_id')->all(); + + return $this->activeTypes()->mapWithKeys(fn (DocumentType $type): array => [$type->id => in_array($type->id, $taken, true) + ? __(':type (replaces the current file)', ['type' => $type->name]) + : $type->name, + ])->all(); + } + + /** + * The type chosen in the upload box: its help text, whether it keeps an ID number, and the confirm when it + * replaces a file. * - * @return list + * @return array{help: ?string, numbered: bool, confirm: ?string} */ #[Computed] - public function rows(): array - { - $documents = Document::query()->where('account_id', $this->accountId)->get()->keyBy('document_type_id'); - $uploaders = User::query()->whereIn('id', $documents->pluck('uploaded_by')->filter())->pluck('name', 'id'); - $types = DocumentType::query() - ->where(fn ($query) => $query->where('active', true)->orWhereIn('id', $documents->keys())) - ->orderBy('position')->orderBy('id')->get(); - - return $types->map(function (DocumentType $type) use ($documents, $uploaders): array { - $document = $documents->get($type->id); - - return [ - 'type_id' => $type->id, - 'name' => $type->name, - 'help' => (string) $type->help, - 'active' => $type->active, - 'required' => $type->required && $type->active, - 'numbered' => $type->number_mode !== NumberMode::None && $type->active, - 'document' => $document === null ? null : [ - 'id' => $document->id, - 'date' => $document->created_at?->toFormattedDateString(), - 'by' => (string) ($uploaders[$document->uploaded_by] ?? __('Unknown')), - 'number' => match (true) { - $document->number === null => '', - $this->allowed['view'] && $this->shownId === $document->id => $document->number, - default => IdNumber::mask($document->number), - }, - 'url' => route('documents.file', $document->id), - ], - ]; - })->values()->all(); + public function chosen(): array + { + $type = $this->activeTypes()->firstWhere('id', (int) $this->uploadType); + $replaces = $type !== null && Document::query()->where('account_id', $this->accountId)->where('document_type_id', $type->id)->exists(); + + return [ + 'help' => $type?->help, + 'numbered' => $type !== null && $type->number_mode !== NumberMode::None, + 'confirm' => $replaces ? __('Replace the :type of :customer? The current file can be restored from Deleted documents.', ['type' => $type->name, 'customer' => $this->customerName]) : null, + ]; } /** @@ -155,19 +172,26 @@ public function showNumber(int $id): void app(CustomerDocuments::class)->log('document-number-shown', $this->account, $document->type->name, auth()->user()); } - public function upload(int $typeId): void + /** + * Stores the file as the customer's document of the chosen type, replacing (hiding) one already there. + */ + public function upload(): void { $this->authorize(Abilities::UPLOAD); abort_if($this->account->trashed(), 403); - $type = DocumentType::query()->where('active', true)->findOrFail($typeId); $this->validate([ - "uploads.{$typeId}" => ['required', 'file', 'mimetypes:'.implode(',', ContentRule::TYPES), 'max:10000', new ContentRule], - "numbers.{$typeId}" => ['nullable', 'string', new NumberRule("uploads.{$typeId}")], + 'uploadType' => ['required', Rule::in($this->activeTypes()->pluck('id')->all())], + 'uploadFile' => ['required', 'file', 'mimetypes:'.implode(',', ContentRule::TYPES), 'max:10000', new ContentRule], + 'uploadNumber' => ['nullable', 'string', new NumberRule('uploadFile')], + ], [ + 'uploadType.required' => __('Choose the document type.'), + 'uploadFile.required' => __('Choose the file.'), ]); - $number = $this->numbers[$typeId] ?? null; + $type = DocumentType::query()->findOrFail((int) $this->uploadType); + $number = $type->number_mode === NumberMode::None || trim($this->uploadNumber) === '' ? null : $this->uploadNumber; - app(CustomerDocuments::class)->upload($this->account, $type, $this->uploads[$typeId], is_string($number) ? $number : null, auth()->user()); - unset($this->uploads[$typeId], $this->numbers[$typeId]); + app(CustomerDocuments::class)->upload($this->account, $type, $this->uploadFile, $number, auth()->user()); + $this->reset('uploadType', 'uploadNumber', 'uploadFile'); $this->toast(__(':type saved.', ['type' => $type->name])); } @@ -223,6 +247,14 @@ private function document(int $id): Document return Document::query()->with('type')->where('account_id', $this->accountId)->findOrFail($id); } + /** + * @return Collection + */ + private function activeTypes(): Collection + { + return DocumentType::query()->where('active', true)->orderBy('position')->orderBy('id')->get(); + } + private function can(string $box): bool { return (bool) auth()->user()?->can($box); diff --git a/resources/views/livewire/deleted/deleted.blade.php b/resources/views/livewire/deleted/deleted.blade.php index 87b2963..83fa10d 100644 --- a/resources/views/livewire/deleted/deleted.blade.php +++ b/resources/views/livewire/deleted/deleted.blade.php @@ -1,5 +1,6 @@
+ {{ __('Add-ons') }} {{ __('Deleted documents') }} {{ __('Deleted documents are hidden from the customer, not removed. Restore one, or erase it for good.') }} {{ __('Backups taken before an erase still hold the file for up to 14 days.') }} diff --git a/resources/views/livewire/missing/missing.blade.php b/resources/views/livewire/missing/missing.blade.php deleted file mode 100644 index cacc0cb..0000000 --- a/resources/views/livewire/missing/missing.blade.php +++ /dev/null @@ -1,38 +0,0 @@ -
-
- {{ __('Missing documents') }} - {{ __('Customers without a document their ISP requires. Deleted customers are left out.') }} -
- - @if ($this->accounts === null) - {{ __('No document type is required. Mark one as required on System, then Add-ons.') }} - @else -
- -
- - - - {{ __('Customer') }} - {{ __('Username') }} - {{ __('Missing') }} - - - @forelse ($this->rows as $row) - - - {{ $row['customer'] }} - - {{ $row['username'] }} - {{ $row['missing'] }} - - @empty - - {{ __('Every customer has the required documents.') }} - - @endforelse - - - {{ $this->accounts->links() }} - @endif -
diff --git a/resources/views/livewire/missing/missing.php b/resources/views/livewire/missing/missing.php deleted file mode 100644 index 9f5989b..0000000 --- a/resources/views/livewire/missing/missing.php +++ /dev/null @@ -1,96 +0,0 @@ -authorizeAccess(); - } - - protected function authorizeAccess(): void - { - $this->authorize(Abilities::VIEW); - } - - public function updatedSearch(): void - { - $this->resetPage(); - } - - /** - * @return Collection - */ - #[Computed] - public function requiredTypes(): Collection - { - return DocumentType::query()->where('active', true)->where('required', true)->orderBy('position')->get(); - } - - #[Computed] - public function accounts(): ?LengthAwarePaginator - { - $required = $this->requiredTypes; - - if ($required->isEmpty()) { - return null; - } - - // The customers that have every required type; the rest are missing something. - $complete = Document::query() - ->whereIn('document_type_id', $required->pluck('id')) - ->groupBy('account_id') - ->havingRaw('COUNT(DISTINCT document_type_id) = ?', [$required->count()]) - ->pluck('account_id'); - $search = trim($this->search); - - return Account::query() - ->whereNotIn('id', $complete) - ->when($search !== '', fn ($query) => $query->where(fn ($accounts) => $accounts->where('username', 'like', "%{$search}%")->orWhere('fullname', 'like', "%{$search}%"))) - ->orderBy('username') - ->paginate(25); - } - - /** - * @return list - */ - #[Computed] - public function rows(): array - { - if ($this->accounts === null) { - return []; - } - - $accounts = collect($this->accounts->items()); - $held = Document::query()->whereIn('account_id', $accounts->pluck('id'))->get(['account_id', 'document_type_id'])->groupBy('account_id'); - - return $accounts->map(fn (Account $account): array => [ - 'id' => $account->id, - 'customer' => (string) ($account->fullname ?: $account->username), - 'username' => $account->username, - 'missing' => $this->requiredTypes - ->whereNotIn('id', ($held->get($account->id) ?? collect())->pluck('document_type_id')) - ->pluck('name')->implode(', '), - ])->all(); - } -}; diff --git a/resources/views/livewire/settings/settings.blade.php b/resources/views/livewire/settings/settings.blade.php index 8c92249..6e40286 100644 --- a/resources/views/livewire/settings/settings.blade.php +++ b/resources/views/livewire/settings/settings.blade.php @@ -1,4 +1,16 @@
+ + + @if ($this->canRestore) +
+ {{ __('Deleted documents') }} +
+ @endif + {{ __('The documents your staff keep for each customer. A type that has documents can be switched off but not deleted.') }} @error('types') @@ -17,9 +29,6 @@ {{ $row['name'] }} - @if ($row['required']) - {{ __('Required') }} - @endif @unless ($row['active']) {{ __('Off') }} @endunless @@ -51,7 +60,6 @@ class="text-red-600 dark:text-red-400"
- @if ($cutCount > 0) diff --git a/resources/views/livewire/settings/settings.php b/resources/views/livewire/settings/settings.php index 73751eb..2e41cf3 100644 --- a/resources/views/livewire/settings/settings.php +++ b/resources/views/livewire/settings/settings.php @@ -11,25 +11,27 @@ use Ispgrid\Documents\Models\Document; use Ispgrid\Documents\Models\DocumentType; use Ispgrid\Documents\Models\NumberMode; +use Ispgrid\Documents\UploadRequired; use Livewire\Attributes\Computed; use Livewire\Component; /** - * The add-on's section on System, then Add-ons: the document types the ISP keeps, written by the ISP (nothing is - * prefilled). A type that ever had a document is switched off, never deleted. + * The add-on's section on System, then Add-ons: whether a customer needs a document to save, and the document types + * the ISP keeps, written by the ISP (nothing is prefilled). A type that ever had a document is switched off, never + * deleted. */ new class extends Component { use AuthorizesEveryRequest, ShowsToasts; + public bool $uploadRequired = false; + public ?int $editingId = null; public string $name = ''; public string $help = ''; - public bool $required = false; - public string $numberMode = 'none'; /** @@ -40,6 +42,7 @@ public function mount(): void { $this->authorizeAccess(); + $this->uploadRequired = UploadRequired::isOn(); } protected function authorizeAccess(): void @@ -47,8 +50,24 @@ protected function authorizeAccess(): void $this->authorize(Abilities::TYPES); } + #[Computed] + public function canRestore(): bool + { + return (bool) auth()->user()?->can(Abilities::RESTORE); + } + + /** + * Saved as soon as it is ticked or cleared. + */ + public function updatedUploadRequired(): void + { + $this->authorize(Abilities::TYPES); + UploadRequired::put($this->uploadRequired); + $this->toast(__('Saved.')); + } + /** - * @return list + * @return list */ #[Computed] public function rows(): array @@ -58,7 +77,6 @@ public function rows(): array return $types->values()->map(fn (DocumentType $type, int $i): array => [ 'id' => $type->id, 'name' => $type->name, - 'required' => $type->required, 'mode' => $type->number_mode->label(), 'active' => $type->active, 'first' => $i === 0, @@ -88,7 +106,6 @@ public function edit(int $id): void $this->editingId = $type->id; $this->name = $type->name; $this->help = (string) $type->help; - $this->required = $type->required; $this->numberMode = $type->number_mode->value; $this->cutCount = 0; $this->resetErrorBag(); @@ -96,7 +113,7 @@ public function edit(int $id): void public function cancel(): void { - $this->reset('editingId', 'name', 'help', 'required', 'numberMode', 'cutCount'); + $this->reset('editingId', 'name', 'help', 'numberMode', 'cutCount'); $this->resetErrorBag(); } @@ -196,7 +213,6 @@ protected function rules(): array return [ 'name' => ['required', 'string', 'max:60', Rule::unique('documents.document_types', 'name')->ignore($this->editingId)], 'help' => ['nullable', 'string', 'max:200'], - 'required' => ['boolean'], 'numberMode' => ['required', Rule::enum(NumberMode::class)], ]; } @@ -206,7 +222,6 @@ private function store(?DocumentType $type): void $values = [ 'name' => trim($this->name), 'help' => trim($this->help) === '' ? null : trim($this->help), - 'required' => $this->required, 'number_mode' => $this->numberMode, ]; diff --git a/routes/web.php b/routes/web.php index d4ce021..9597c8c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,7 +5,6 @@ use Ispgrid\Documents\Http\DownloadAllController; Route::middleware(['web', 'auth'])->group(function () { - Route::livewire('documents/missing', 'documents::missing')->name('documents.missing'); Route::livewire('documents/deleted', 'documents::deleted')->name('documents.deleted'); Route::get('documents/{document}/file', DocumentFileController::class)->whereNumber('document')->name('documents.file'); Route::get('documents/customers/{account}/all', DownloadAllController::class)->whereNumber('account')->name('documents.zip'); diff --git a/src/CustomerDocuments.php b/src/CustomerDocuments.php index 17a3dfb..d40df70 100644 --- a/src/CustomerDocuments.php +++ b/src/CustomerDocuments.php @@ -128,18 +128,26 @@ public function duplicates(Account $account, DocumentType $type, string $typedNu } /** - * Active required types this customer has no current file for, in order. + * The customer's current documents (switched-off types too), in the order of their types. * - * @return Collection + * @return Collection */ - public function missingTypes(Account $account): Collection + public function current(int $accountId): Collection { - return DocumentType::query() - ->where('active', true) - ->where('required', true) - ->whereNotIn('id', Document::query()->where('account_id', $account->id)->select('document_type_id')) - ->orderBy('position') - ->get(); + return Document::query()->with('type')->where('account_id', $accountId)->get() + ->sortBy(fn (Document $document): array => [$document->type->position, $document->type->id]) + ->values(); + } + + /** + * Who uploaded each of these documents, by user id. + * + * @param Collection $documents + * @return Collection + */ + public function uploaders(Collection $documents): Collection + { + return User::query()->whereIn('id', $documents->pluck('uploaded_by')->filter())->pluck('name', 'id'); } /** diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index 185fe90..b64e0c7 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -9,8 +9,6 @@ use App\Support\Addons\CustomerCards; use App\Support\Addons\CustomerFormTabs; use App\Support\Addons\PrivateFile; -use App\Support\Navigation\Navigation; -use App\Support\Navigation\NavItem; use App\Support\Permissions; use Illuminate\Support\ServiceProvider; use Ispgrid\Documents\Console\ExportCommand; @@ -82,25 +80,5 @@ public function boot(): void fn (User $user, Account $account): bool => $user->can(Abilities::VIEW) || $user->can(Abilities::UPLOAD), 60, ); - - $navigation = $this->app->make(Navigation::class); - $navigation->add(new NavItem( - group: 'addons', - label: fn (): string => __('Missing documents'), - route: 'documents.missing', - icon: 'clipboard-document-list', - active: ['documents.missing'], - visible: fn (User $user): bool => $user->can(Abilities::VIEW), - order: 20, - )); - $navigation->add(new NavItem( - group: 'addons', - label: fn (): string => __('Deleted documents'), - route: 'documents.deleted', - icon: 'trash', - active: ['documents.deleted'], - visible: fn (User $user): bool => $user->can(Abilities::RESTORE), - order: 21, - )); } } diff --git a/src/DocumentsTab.php b/src/DocumentsTab.php index b66dfd2..d408f29 100644 --- a/src/DocumentsTab.php +++ b/src/DocumentsTab.php @@ -5,18 +5,24 @@ use App\Contracts\CustomerFormTab; use App\Models\Account; use App\Models\User; +use Closure; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; +use Illuminate\Validation\Rule; use Ispgrid\Documents\Models\Document; use Ispgrid\Documents\Models\DocumentType; use Ispgrid\Documents\Models\NumberMode; /** - * The Documents tab in the customer form: one block per active type, with Take photo and Choose file, saved with the - * customer. A missing required document only warns. Files are stored after the customer is saved (saved()). + * The Documents tab in the customer form: the customer's documents, and upload rows (a type, its ID number when the + * type keeps one, and a file) saved with the customer. A type that already has a file is replaced. With "Document + * upload is required" on, a customer without any document does not save. Files are stored after the customer is + * saved (saved()). */ final class DocumentsTab implements CustomerFormTab { + public const MAX_ROWS = 10; + public function __construct(private CustomerDocuments $documents) {} public function key(): string @@ -40,32 +46,34 @@ public function visible(User $user, ?Account $account): bool } /** - * Empty file and number fields, and "types": what the tab shows (display only, it has no rule, so core drops it - * before saving). A stored number is only ever sent masked. + * One empty upload row, and what the tab shows (display only: no rule, so core drops it before saving). A stored + * number is only ever sent masked. * * @return array */ public function state(?Account $account): array { - $current = $account === null - ? new Collection - : Document::query()->where('account_id', $account->id)->get()->keyBy('document_type_id'); - $uploaders = User::query()->whereIn('id', $current->pluck('uploaded_by')->filter())->pluck('name', 'id'); + $current = $account === null ? new Collection : $this->documents->current($account->id); + $uploaders = $this->documents->uploaders($current); + $taken = $current->pluck('document_type_id')->all(); + $types = $this->activeTypes(); return [ - 'files' => [], - 'numbers' => [], - 'types' => $this->activeTypes()->mapWithKeys(fn (DocumentType $type): array => [$type->id => [ - 'name' => $type->name, - 'required' => $type->required, + 'uploads' => [self::emptyRow()], + 'documents' => $current->map(fn (Document $document): array => [ + 'type' => $document->type->name, + 'file' => $document->original_name, + 'date' => $document->created_at?->toFormattedDateString(), + 'by' => (string) ($uploaders[$document->uploaded_by] ?? __('Unknown')), + 'number' => $document->number === null ? '' : IdNumber::mask($document->number), + ])->all(), + 'typeOptions' => $types->mapWithKeys(fn (DocumentType $type): array => [$type->id => in_array($type->id, $taken, true) + ? __(':type (replaces the current file)', ['type' => $type->name]) + : $type->name, + ])->all(), + 'types' => $types->mapWithKeys(fn (DocumentType $type): array => [$type->id => [ 'help' => (string) $type->help, 'numbered' => $type->number_mode !== NumberMode::None, - 'current' => ($document = $current->get($type->id)) === null ? null : [ - 'name' => $document->original_name, - 'date' => $document->created_at?->toFormattedDateString(), - 'by' => (string) ($uploaders[$document->uploaded_by] ?? __('Unknown')), - 'number' => $document->number === null ? '' : IdNumber::mask($document->number), - ], ]])->all(), ]; } @@ -75,14 +83,12 @@ public function state(?Account $account): array */ public function rules(?Account $account): array { - $types = $this->activeTypes(); - $numbered = $types->filter(fn (DocumentType $type): bool => $type->number_mode !== NumberMode::None); - return [ - 'files' => ['array', self::keysIn($types)], - 'files.*' => ['nullable', 'file', 'mimetypes:'.implode(',', ContentRule::TYPES), 'max:10000', new ContentRule], - 'numbers' => ['array', self::keysIn($numbered)], - 'numbers.*' => ['nullable', 'string', new NumberRule], + 'uploads' => ['present', 'array', 'max:'.self::MAX_ROWS, $this->uploadsRule($account)], + 'uploads.*' => ['nullable', 'array:type,number,file'], + 'uploads.*.type' => [new PairRule('file', __('Choose the document type.')), 'nullable', Rule::in($this->activeTypes()->pluck('id')->all())], + 'uploads.*.file' => [new PairRule('type', __('Choose the file.')), 'nullable', 'file', 'mimetypes:'.implode(',', ContentRule::TYPES), 'max:10000', new ContentRule], + 'uploads.*.number' => ['nullable', 'string', new NumberRule], ]; } @@ -92,20 +98,14 @@ public function rules(?Account $account): array */ public function warnings(array $state, ?Account $account): array { - $files = is_array($state['files'] ?? null) ? array_filter($state['files']) : []; - $numbers = is_array($state['numbers'] ?? null) ? $state['numbers'] : []; - $types = $this->activeTypes(); - $stored = $account === null ? [] : Document::query()->where('account_id', $account->id)->pluck('document_type_id')->all(); - - $missing = $types->filter(fn (DocumentType $type): bool => $type->required - && ! in_array($type->id, $stored, true) - && ! array_key_exists($type->id, $files)); - $warnings = $missing->isEmpty() ? [] : [__('Missing documents: :types.', ['types' => $missing->pluck('name')->implode(', ')])]; + $types = $this->activeTypes()->keyBy('id'); + $warnings = []; - foreach ($types as $type) { - $typed = $numbers[$type->id] ?? null; + foreach (self::rows($state) as $row) { + $type = $types->get((int) ($row['type'] ?? 0)); + $typed = $row['number'] ?? null; - if (! is_string($typed) || ! IdNumber::isValid($typed)) { + if ($type === null || ! is_string($typed) || ! IdNumber::isValid($typed)) { continue; } @@ -136,33 +136,64 @@ public function saved(Account $account, array $state): void $types = $this->activeTypes()->keyBy('id'); $by = auth()->user(); - foreach ((array) ($state['files'] ?? []) as $typeId => $file) { - $type = $types->get((int) $typeId); + foreach (self::rows($state) as $row) { + $type = $types->get((int) ($row['type'] ?? 0)); + $file = $row['file'] ?? null; if (! $file instanceof UploadedFile || $type === null || ! $by instanceof User) { continue; } - $number = $state['numbers'][$typeId] ?? null; + $number = $type->number_mode === NumberMode::None ? null : ($row['number'] ?? null); $this->documents->upload($account, $type, $file, is_string($number) ? $number : null, $by); } } /** - * @return Collection + * @return array{type: string, number: string, file: null} */ - private function activeTypes(): Collection + public static function emptyRow(): array { - return DocumentType::query()->where('active', true)->orderBy('position')->orderBy('id')->get(); + return ['type' => '', 'number' => '', 'file' => null]; } /** - * Only these type ids may appear as keys; none at all when there are none. + * Each type at most once in a save, and, when uploads are required, at least one document on the customer or + * in this save. + */ + private function uploadsRule(?Account $account): Closure + { + return function (string $attribute, mixed $value, Closure $fail) use ($account): void { + $chosen = array_filter(array_map( + fn (array $row): ?string => filled($row['file'] ?? null) && filled($row['type'] ?? null) ? (string) $row['type'] : null, + self::rows(['uploads' => $value]), + )); + + if (count($chosen) !== count(array_unique($chosen))) { + $fail(__('Choose each document type once.')); + } elseif ($chosen === [] && UploadRequired::isOn() + && ($account === null || ! Document::query()->where('account_id', $account->id)->exists())) { + $fail(__('Upload at least one document.')); + } + }; + } + + /** + * The upload rows of a state, skipping anything that is not a row. * - * @param Collection $types + * @param array $state + * @return list> + */ + private static function rows(array $state): array + { + return array_values(array_filter(is_array($state['uploads'] ?? null) ? $state['uploads'] : [], 'is_array')); + } + + /** + * @return Collection */ - private static function keysIn(Collection $types): string + private function activeTypes(): Collection { - return $types->isEmpty() ? 'max:0' : 'array:'.$types->pluck('id')->implode(','); + return DocumentType::query()->where('active', true)->orderBy('position')->orderBy('id')->get(); } } diff --git a/src/Models/DocumentType.php b/src/Models/DocumentType.php index b5d1838..7423bca 100644 --- a/src/Models/DocumentType.php +++ b/src/Models/DocumentType.php @@ -11,7 +11,6 @@ * @property int $id * @property string $name * @property ?string $help - * @property bool $required * @property NumberMode $number_mode * @property int $position * @property bool $active @@ -20,12 +19,11 @@ class DocumentType extends Model { protected $connection = 'documents'; - protected $fillable = ['name', 'help', 'required', 'number_mode', 'position', 'active']; + protected $fillable = ['name', 'help', 'number_mode', 'position', 'active']; protected function casts(): array { return [ - 'required' => 'boolean', 'active' => 'boolean', 'position' => 'integer', 'number_mode' => NumberMode::class, diff --git a/src/NumberRule.php b/src/NumberRule.php index 86eb99c..23c6ad6 100644 --- a/src/NumberRule.php +++ b/src/NumberRule.php @@ -18,8 +18,8 @@ final class NumberRule implements DataAwareRule, ValidationRule private array $data = []; /** - * @param string|null $fileField the field holding the file this number goes with; by default the number's own - * path with "numbers" turned into "files" + * @param string|null $fileField the field holding the file this number goes with; by default "file" next to + * the number in the same upload row */ public function __construct(private ?string $fileField = null) {} @@ -41,7 +41,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - $fileField = $this->fileField ?? preg_replace('/\.numbers\.([^.]+)$/', '.files.$1', $attribute); + $fileField = $this->fileField ?? preg_replace('/[^.]+$/', 'file', $attribute); if (data_get($this->data, (string) $fileField) === null) { $fail(__('Add the file for this number.')); diff --git a/src/PairRule.php b/src/PairRule.php new file mode 100644 index 0000000..eaee56e --- /dev/null +++ b/src/PairRule.php @@ -0,0 +1,46 @@ + + */ + private array $data = []; + + /** + * @param string $partner the partner's key in the same row + * @param string $message shown when this field is empty and the partner is not + */ + public function __construct(private string $partner, private string $message) {} + + /** + * @param array $data + */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $partner = data_get($this->data, (string) preg_replace('/[^.]+$/', $this->partner, $attribute)); + + if (blank($value) && filled($partner)) { + $fail($this->message); + } + } +} diff --git a/src/UploadRequired.php b/src/UploadRequired.php new file mode 100644 index 0000000..f076c82 --- /dev/null +++ b/src/UploadRequired.php @@ -0,0 +1,24 @@ +where('event', 'document-number-shown')->count())->toBe(1); }); -it('uploads a missing document from the card', function () { +it('uploads a document of the chosen type from the card', function () { $this->actingAs(staffWith(Abilities::UPLOAD)); $photo = documentType('Customer photo'); card($this->account) - ->set("uploads.{$photo->id}", UploadedFile::fake()->createWithContent('a.png', ''))->call('upload', $photo->id) - ->assertHasErrors(["uploads.{$photo->id}"]) - ->set("uploads.{$photo->id}", pngUpload('me.png'))->call('upload', $photo->id)->assertHasNoErrors(); + ->set('uploadType', (string) $photo->id) + ->set('uploadFile', UploadedFile::fake()->createWithContent('a.png', ''))->call('upload') + ->assertHasErrors(['uploadFile']) + ->set('uploadFile', pngUpload('me.png'))->call('upload')->assertHasNoErrors() + ->assertSet('uploadType', '')->assertSet('uploadFile', null); expect(Document::query()->where('document_type_id', $photo->id)->sole()->original_name)->toBe('me.png'); }); +it('needs the type and the file in the upload box', function () { + $this->actingAs($this->admin); + + card($this->account)->call('upload') + ->assertHasErrors(['uploadType' => __('Choose the document type.'), 'uploadFile' => __('Choose the file.')]); + card($this->account)->set('uploadType', (string) documentType('Old form', ['active' => false])->id)->set('uploadFile', pngUpload()) + ->call('upload')->assertHasErrors(['uploadType']); +}); + it('edits the type and number, refusing a taken type', function () { $this->actingAs(staffWith(Abilities::EDIT)); $pan = documentType('PAN card'); @@ -120,8 +132,8 @@ function card(Account $account): Testable $photo = documentType('Customer photo'); $this->account->delete(); - card($this->account)->assertOk()->assertSee('xxxx xxxx 4821')->assertDontSee(__('Take photo')) - ->set("uploads.{$photo->id}", pngUpload())->call('upload', $photo->id)->assertForbidden(); + card($this->account)->assertOk()->assertSee('xxxx xxxx 4821')->assertDontSee(__('Choose file')) + ->set('uploadType', (string) $photo->id)->set('uploadFile', pngUpload())->call('upload')->assertForbidden(); }); it('downloads all current documents as one zip and logs it', function () { @@ -147,8 +159,10 @@ function card(Account $account): Testable it('lets staff with Upload documents replace a file from the card', function () { $this->actingAs(staffWith(Abilities::UPLOAD)); - card($this->account)->assertSee(__('Replace')) - ->set("uploads.{$this->aadhaar->id}", pngUpload('sharper.png'))->call('upload', $this->aadhaar->id)->assertHasNoErrors(); + card($this->account)->assertSee(__(':type (replaces the current file)', ['type' => 'Aadhaar card'])) + ->set('uploadType', (string) $this->aadhaar->id) + ->assertSeeHtml('wire:confirm="'.e(__('Replace the :type of :customer? The current file can be restored from Deleted documents.', ['type' => 'Aadhaar card', 'customer' => 'Ramesh Kumar'])).'"') + ->set('uploadFile', pngUpload('sharper.png'))->call('upload')->assertHasNoErrors(); expect($this->document->fresh()->trashed())->toBeTrue() ->and(Document::query()->sole()->original_name)->toBe('sharper.png') @@ -178,14 +192,16 @@ function card(Account $account): Testable $this->actingAs($this->admin); documentType('Customer photo', ['help' => 'Face clearly visible']); - card($this->account)->assertSee('Face clearly visible'); + $photo = DocumentType::query()->where('name', 'Customer photo')->sole(); + + card($this->account)->assertDontSee('Face clearly visible')->set('uploadType', (string) $photo->id)->assertSee('Face clearly visible'); }); it('refuses a path or text where a file belongs', function () { $this->actingAs($this->admin); $photo = documentType('Customer photo'); - card($this->account)->set("uploads.{$photo->id}", 'documents/1/secret')->call('upload', $photo->id)->assertHasErrors(["uploads.{$photo->id}"]); + card($this->account)->set('uploadType', (string) $photo->id)->set('uploadFile', 'documents/1/secret')->call('upload')->assertHasErrors(['uploadFile']); }); it('refuses Upload once the customer is deleted while the card is open', function () { @@ -195,7 +211,7 @@ function card(Account $account): Testable $this->account->delete(); - $card->set("uploads.{$photo->id}", pngUpload())->call('upload', $photo->id)->assertForbidden(); + $card->set('uploadType', (string) $photo->id)->set('uploadFile', pngUpload())->call('upload')->assertForbidden(); expect(Document::query()->where('document_type_id', $photo->id)->count())->toBe(0); }); @@ -215,3 +231,11 @@ function card(Account $account): Testable $card->call('showNumber', $this->document->id)->assertForbidden(); }); + +it('offers Download all only when the customer has documents', function () { + $this->actingAs($this->admin); + $empty = Account::factory()->create(); + + card($this->account)->assertSee(route('documents.zip', $this->account->id)); + card($empty)->assertDontSee(route('documents.zip', $empty->id))->assertSee(__('No documents yet.')); +}); diff --git a/tests/Feature/CustomerDocumentsTest.php b/tests/Feature/CustomerDocumentsTest.php index cc3dfde..5cc5f4b 100644 --- a/tests/Feature/CustomerDocumentsTest.php +++ b/tests/Feature/CustomerDocumentsTest.php @@ -148,14 +148,17 @@ ->and($this->documents->duplicates($this->account, documentType('Agreement', ['number_mode' => NumberMode::LastFour]), '1234F'))->toBeEmpty(); }); -it('lists missing required active types', function () { - $aadhaar = documentType(); - documentType('Photo'); - documentType('Agreement', ['required' => false]); - documentType('Old form', ['active' => false]); +it('lists the current documents in the order of their types, switched-off types too', function () { + $agreement = documentType('Agreement'); + $aadhaar = documentType('Aadhaar card'); $this->documents->upload($this->account, $aadhaar, pngUpload(), null, $this->staff); + $this->documents->upload($this->account, $agreement, pngUpload(), null, $this->staff); + $hidden = $this->documents->upload($this->account, documentType('Photo'), pngUpload(), null, $this->staff); + $this->documents->delete($hidden, $this->staff); + $agreement->update(['active' => false]); - expect($this->documents->missingTypes($this->account)->pluck('name')->all())->toBe(['Photo']); + expect($this->documents->current($this->account->id)->map(fn (Document $document): string => $document->type->name)->all()) + ->toBe(['Agreement', 'Aadhaar card']); }); it('never stores anything but a JPG, PNG or PDF, whatever it is called', function () { diff --git a/tests/Feature/DocumentsTabTest.php b/tests/Feature/DocumentsTabTest.php index c75230b..6623e1f 100644 --- a/tests/Feature/DocumentsTabTest.php +++ b/tests/Feature/DocumentsTabTest.php @@ -11,6 +11,7 @@ use Ispgrid\Documents\Models\Document; use Ispgrid\Documents\Models\NumberMode; use Ispgrid\Documents\StoredFile; +use Ispgrid\Documents\UploadRequired; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; @@ -33,15 +34,23 @@ function newCustomerForm(): Testable ->set('password', 'secret12'); } +/** + * Fills upload row $index of the Documents tab. + */ +function uploadRow(Testable $form, int $index, int|string $typeId, mixed $file, string $number = ''): Testable +{ + return $form + ->set("addonTabs.documents.uploads.{$index}", ['type' => (string) $typeId, 'number' => $number, 'file' => null]) + ->set("addonTabs.documents.uploads.{$index}.file", $file); +} + function ramesh(): Account { return Account::query()->where('username', 'ramesh')->sole(); } -it('stores a photo taken while creating the customer, encrypted, and logs it', function () { - newCustomerForm() - ->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()) - ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", '1234 5678 4821') +it('stores a document chosen while creating the customer, encrypted, and logs it', function () { + uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload(), '1234 5678 4821') ->call('save')->assertHasNoErrors(); $document = Document::query()->sole(); @@ -51,67 +60,116 @@ function ramesh(): Account ->and(Activity::query()->where('event', 'document-uploaded')->sole()->subject_id)->toBe(ramesh()->id); }); -it('warns about a missing required document and still saves the customer', function () { - $form = newCustomerForm(); +it('stores several documents from one save', function () { + $pan = documentType('PAN card'); - expect($form->instance()->warningTabs())->toBe(['documents']); + $form = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + uploadRow($form, 1, $pan->id, UploadedFile::fake()->createWithContent('pan.pdf', pdfBytes()), 'ABCDE1234F') + ->call('save')->assertHasNoErrors(); - $form->call('save')->assertHasNoErrors(); + expect(Document::query()->orderBy('document_type_id')->pluck('mime')->all())->toBe(['image/png', 'application/pdf']) + ->and(Document::query()->where('document_type_id', $pan->id)->sole()->number)->toBe('ABCDE1234F'); +}); + +it('saves a customer without documents while upload is not required', function () { + newCustomerForm()->call('save')->assertHasNoErrors(); + + expect(ramesh())->not->toBeNull(); +}); + +it('refuses a customer without any document once upload is required, when adding and when editing', function () { + UploadRequired::put(true); + + newCustomerForm()->call('save')->assertHasErrors(['addonTabs.documents.uploads']); + expect(Account::query()->where('username', 'ramesh')->exists())->toBeFalse(); + + $old = Account::factory()->create(); + Livewire::test('pages::customers.form', ['account' => $old->id])->set('fullname', 'New name') + ->call('save')->assertHasErrors(['addonTabs.documents.uploads']); + expect($old->fresh()->fullname)->not->toBe('New name'); + + uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload())->call('save')->assertHasNoErrors(); +}); + +it('lets a customer who has a document save without a new one when upload is required', function () { + UploadRequired::put(true); + $account = Account::factory()->create(); + app(CustomerDocuments::class)->upload($account, $this->aadhaar, pngUpload(), null, $this->staff); + + Livewire::test('pages::customers.form', ['account' => $account->id])->set('fullname', 'New name') + ->call('save')->assertHasNoErrors(); + + expect($account->fresh()->fullname)->toBe('New name'); +}); + +it('needs the type and the file of a row together', function () { + newCustomerForm()->set('addonTabs.documents.uploads.0.file', pngUpload()) + ->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.type' => __('Choose the document type.')]); + + newCustomerForm()->set('addonTabs.documents.uploads.0.type', (string) $this->aadhaar->id) + ->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.file' => __('Choose the file.')]); +}); - expect(ramesh())->not->toBeNull() - ->and(session('toast'))->toContain('Missing documents: Aadhaar card.'); +it('refuses the same type twice in one save', function () { + $form = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + uploadRow($form, 1, $this->aadhaar->id, pngUpload()) + ->call('save')->assertHasErrors(['addonTabs.documents.uploads' => __('Choose each document type once.')]); + + expect(Document::query()->count())->toBe(0); }); it('warns about a number already on another customer', function () { app(CustomerDocuments::class)->upload(Account::factory()->create(['fullname' => 'Sita Devi']), $this->aadhaar, pngUpload(), '1234 5678 4821', $this->staff); - newCustomerForm() - ->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()) - ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", '123456784821') + uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload(), '123456784821') ->call('save')->assertHasNoErrors(); expect(session('toast'))->toContain('Aadhaar card xxxxxxxx4821 is already on Sita Devi.'); }); it('refuses a file that is not a picture or a PDF, whatever its name', function () { - newCustomerForm() - ->set("addonTabs.documents.files.{$this->aadhaar->id}", UploadedFile::fake()->createWithContent('aadhaar.png', '')) - ->call('save')->assertHasErrors(["addonTabs.documents.files.{$this->aadhaar->id}"]); + uploadRow(newCustomerForm(), 0, $this->aadhaar->id, UploadedFile::fake()->createWithContent('aadhaar.png', '')) + ->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.file']); expect(Account::query()->where('username', 'ramesh')->exists())->toBeFalse() ->and(Document::query()->count())->toBe(0); }); -it('refuses an unknown or switched-off type and a bad number', function () { +it('refuses an unknown or switched-off type, a bad number and a tampered row', function () { $off = documentType('Old form', ['active' => false]); - newCustomerForm() - ->set('addonTabs.documents.files.999', pngUpload()) - ->call('save')->assertHasErrors(['addonTabs.documents.files']); - - newCustomerForm() - ->set("addonTabs.documents.files.{$off->id}", pngUpload()) - ->call('save')->assertHasErrors(['addonTabs.documents.files']); - - newCustomerForm() - ->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()) - ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", "1234\n5678") - ->call('save')->assertHasErrors(["addonTabs.documents.numbers.{$this->aadhaar->id}"]); + uploadRow(newCustomerForm(), 0, 999, pngUpload())->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.type']); + uploadRow(newCustomerForm(), 0, $off->id, pngUpload())->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.type']); + uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload(), "1234\n5678") + ->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.number']); + newCustomerForm()->set('addonTabs.documents.uploads.0.path', 'documents/1/secret') + ->call('save')->assertHasErrors(['addonTabs.documents.uploads.0']); + newCustomerForm()->set('addonTabs.documents.uploads', array_fill(0, 11, ['type' => '', 'number' => '', 'file' => null])) + ->call('save')->assertHasErrors(['addonTabs.documents.uploads']); }); it('refuses a number without a file', function () { - newCustomerForm() - ->set("addonTabs.documents.numbers.{$this->aadhaar->id}", '1234 5678 4821') - ->call('save')->assertHasErrors(["addonTabs.documents.numbers.{$this->aadhaar->id}"]); + newCustomerForm()->set('addonTabs.documents.uploads.0.number', '1234 5678 4821') + ->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.number']); +}); + +it('ignores a removed row', function () { + $form = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + uploadRow($form, 1, documentType('PAN card')->id, pngUpload()) + ->set('addonTabs.documents.uploads.1', null) + ->call('save')->assertHasNoErrors(); + + expect(Document::query()->sole()->document_type_id)->toBe($this->aadhaar->id); }); -it('shows a stored full number only masked when editing', function () { +it('lists the stored documents with the full number only masked when editing', function () { $account = Account::factory()->create(); - app(CustomerDocuments::class)->upload($account, $this->aadhaar, pngUpload(), '1234 5678 4821', $this->staff); + app(CustomerDocuments::class)->upload($account, $this->aadhaar, pngUpload('front.png'), '1234 5678 4821', $this->staff); $form = Livewire::test('pages::customers.form', ['account' => $account->id]); - $form->assertSee('xxxx xxxx 4821')->assertDontSee('1234 5678 4821'); + $form->assertSee('front.png')->assertSee('xxxx xxxx 4821')->assertDontSee('1234 5678 4821') + ->assertSee(__(':type (replaces the current file)', ['type' => 'Aadhaar card'])); expect(json_encode($form->get('addonTabs')))->not->toContain('5678'); }); @@ -119,8 +177,7 @@ function ramesh(): Account $account = Account::factory()->create(); $old = app(CustomerDocuments::class)->upload($account, $this->aadhaar, pngUpload(), '1234 5678 4821', $this->staff); - Livewire::test('pages::customers.form', ['account' => $account->id]) - ->set("addonTabs.documents.files.{$this->aadhaar->id}", UploadedFile::fake()->createWithContent('scan.pdf', pdfBytes())) + uploadRow(Livewire::test('pages::customers.form', ['account' => $account->id]), 0, $this->aadhaar->id, UploadedFile::fake()->createWithContent('scan.pdf', pdfBytes())) ->call('save')->assertHasNoErrors(); $current = Document::query()->sole(); @@ -130,8 +187,8 @@ function ramesh(): Account }); it('deletes Livewire\'s readable copy once the file is stored', function () { - $form = newCustomerForm()->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()); - $temporary = $form->get("addonTabs.documents.files.{$this->aadhaar->id}"); + $form = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + $temporary = $form->get('addonTabs.documents.uploads.0.file'); expect($temporary)->toBeInstanceOf(TemporaryUploadedFile::class)->and($temporary->exists())->toBeTrue(); @@ -141,27 +198,27 @@ function ramesh(): Account ->and(Storage::disk('addons')->allFiles('documents'))->toHaveCount(1); }); -it('hides the tab from staff without Upload documents and ignores what they send', function () { +it('hides the tab from staff without Upload documents and ignores what they send, even when upload is required', function () { + UploadRequired::put(true); $this->actingAs(staffWith('add-customers', Abilities::VIEW)); $form = newCustomerForm(); expect($form->instance()->addonTabViews())->not->toHaveKey('documents'); - $form->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload())->call('save')->assertHasNoErrors(); + uploadRow($form, 0, $this->aadhaar->id, pngUpload())->call('save')->assertHasNoErrors(); expect(ramesh())->not->toBeNull()->and(Document::query()->count())->toBe(0); }); it('refuses a path where a file belongs', function () { - newCustomerForm() - ->set("addonTabs.documents.files.{$this->aadhaar->id}", 'documents/1/secret') - ->call('save')->assertHasErrors(["addonTabs.documents.files.{$this->aadhaar->id}"]); + uploadRow(newCustomerForm(), 0, $this->aadhaar->id, 'documents/1/secret') + ->call('save')->assertHasErrors(['addonTabs.documents.uploads.0.file']); }); it('keeps the temporary copy when storing the document fails, so staff can save again', function () { Document::creating(fn () => throw new RuntimeException('database down')); - $form = newCustomerForm()->set("addonTabs.documents.files.{$this->aadhaar->id}", pngUpload()); - $temporary = $form->get("addonTabs.documents.files.{$this->aadhaar->id}"); + $form = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + $temporary = $form->get('addonTabs.documents.uploads.0.file'); $form->call('save')->assertHasNoErrors(); @@ -169,13 +226,13 @@ function ramesh(): Account ->and(Storage::disk('addons')->allFiles())->toBe([]); }); -it('keeps the number setting of the type', function () { +it('keeps the number setting of the type, and no number for a type without one', function () { $lastFour = documentType('Voter ID', ['number_mode' => NumberMode::LastFour]); + $plain = documentType('Agreement', ['number_mode' => NumberMode::None]); - newCustomerForm() - ->set("addonTabs.documents.files.{$lastFour->id}", pngUpload()) - ->set("addonTabs.documents.numbers.{$lastFour->id}", 'ABC/1234567') - ->call('save')->assertHasNoErrors(); + $form = uploadRow(newCustomerForm(), 0, $lastFour->id, pngUpload(), 'ABC/1234567'); + uploadRow($form, 1, $plain->id, pngUpload(), 'XYZ 99')->call('save')->assertHasNoErrors(); - expect(Document::query()->sole()->number)->toBe('4567'); + expect(Document::query()->where('document_type_id', $lastFour->id)->sole()->number)->toBe('4567') + ->and(Document::query()->where('document_type_id', $plain->id)->sole()->number)->toBeNull(); }); diff --git a/tests/Feature/ListPagesTest.php b/tests/Feature/ListPagesTest.php index 2e32444..1df2eb5 100644 --- a/tests/Feature/ListPagesTest.php +++ b/tests/Feature/ListPagesTest.php @@ -75,37 +75,11 @@ $this->get(route('documents.deleted'))->assertForbidden(); }); -it('lists customers missing a required active document', function () { - $this->actingAs(staffWith(Abilities::VIEW)); - $photo = documentType('Customer photo'); - documentType('Agreement', ['required' => false]); - documentType('Old form', ['active' => false]); - $complete = Account::factory()->create(['fullname' => 'Complete Customer']); - $this->documents->upload($complete, $this->aadhaar, pngUpload(), null, $this->admin); - $this->documents->upload($complete, $photo, pngUpload(), null, $this->admin); - $half = Account::factory()->create(['fullname' => 'Half Customer']); - $this->documents->upload($half, $this->aadhaar, pngUpload(), null, $this->admin); - Account::factory()->create(['fullname' => 'Empty Customer']); - Account::factory()->create(['fullname' => 'Gone Customer'])->delete(); - - $this->get(route('documents.missing'))->assertOk() - ->assertSee('Half Customer')->assertSee('Empty Customer') - ->assertDontSee('Complete Customer')->assertDontSee('Gone Customer')->assertDontSee('Old form'); - - Livewire::test('documents::missing')->set('search', 'half') - ->assertSee('Half Customer')->assertSee('Customer photo')->assertDontSee('Empty Customer'); -}); - -it('needs View customer documents for the missing list', function () { - $this->actingAs(staffWith(Abilities::UPLOAD)); - - $this->get(route('documents.missing'))->assertForbidden(); -}); - -it('shows both pages in the Add-ons menu to staff who may open them', function () { - $this->actingAs(staffWith(Abilities::VIEW)); - $this->get(route('dashboard'))->assertSee(route('documents.missing'))->assertDontSee(route('documents.deleted')); - +it('links Deleted documents from the documents settings, not the menu', function () { $this->actingAs($this->admin); - $this->get(route('dashboard'))->assertSee(route('documents.missing'))->assertSee(route('documents.deleted')); + $this->get(route('dashboard'))->assertDontSee(route('documents.deleted')); + Livewire::test('documents::settings')->assertSee(route('documents.deleted')); + + $this->actingAs(staffWith(Abilities::TYPES)); + Livewire::test('documents::settings')->assertDontSee(route('documents.deleted')); }); diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index 6b835f8..16b3a96 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -9,6 +9,7 @@ use Ispgrid\Documents\Models\Document; use Ispgrid\Documents\Models\DocumentType; use Ispgrid\Documents\Models\NumberMode; +use Ispgrid\Documents\UploadRequired; use Livewire\Livewire; use Spatie\Activitylog\Models\Activity; @@ -26,7 +27,7 @@ $this->actingAs(User::factory()->create()); Livewire::test('documents::settings') - ->set('name', 'Aadhaar card')->set('help', 'Upload a masked Aadhaar')->set('required', true)->set('numberMode', 'full') + ->set('name', 'Aadhaar card')->set('help', 'Upload a masked Aadhaar')->set('numberMode', 'full') ->call('save')->assertHasNoErrors() ->set('name', 'Aadhaar card')->call('save')->assertHasErrors(['name']) ->set('name', str_repeat('a', 61))->call('save')->assertHasErrors(['name']) @@ -34,7 +35,7 @@ ->set('numberMode', 'everything')->set('help', '')->call('save')->assertHasErrors(['numberMode']); $type = DocumentType::query()->sole(); - expect($type->only(['name', 'help', 'required']))->toBe(['name' => 'Aadhaar card', 'help' => 'Upload a masked Aadhaar', 'required' => true]) + expect($type->only(['name', 'help']))->toBe(['name' => 'Aadhaar card', 'help' => 'Upload a masked Aadhaar']) ->and($type->number_mode)->toBe(NumberMode::Full); Livewire::test('documents::settings')->call('edit', $type->id)->assertSet('name', 'Aadhaar card') @@ -145,3 +146,24 @@ $component->set('name', 'Sneaky')->assertForbidden(); expect(DocumentType::query()->count())->toBe(0); }); + +it('switches Document upload is required on and off', function () { + $this->actingAs(User::factory()->create()); + + Livewire::test('documents::settings')->assertSet('uploadRequired', false)->set('uploadRequired', true); + expect(UploadRequired::isOn())->toBeTrue(); + + Livewire::test('documents::settings')->assertSet('uploadRequired', true)->set('uploadRequired', false); + expect(UploadRequired::isOn())->toBeFalse(); +}); + +it('refuses the switch once the Document types box is taken away', function () { + $staff = staffWith(Abilities::TYPES); + $this->actingAs($staff); + $settings = Livewire::test('documents::settings'); + + revokeBox($staff, Abilities::TYPES); + + $settings->set('uploadRequired', true)->assertForbidden(); + expect(UploadRequired::isOn())->toBeFalse(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 6b0f0a3..66ab650 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -74,7 +74,6 @@ function documentType(string $name = 'Aadhaar card', array $attributes = []): Do { return DocumentType::query()->create([ 'name' => $name, - 'required' => true, 'number_mode' => NumberMode::Full, 'position' => DocumentType::query()->count() + 1, ...$attributes, From 1e946f3b30693821a3998921fab59fb34385ff5b Mon Sep 17 00:00:00 2001 From: Karan Date: Mon, 19 Oct 2026 19:20:47 +0530 Subject: [PATCH 10/11] Show the documents on their own customer tab --- src/DocumentsServiceProvider.php | 14 +++++++++----- tests/Feature/CustomerCardTest.php | 9 ++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index b64e0c7..fc1be14 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -8,6 +8,7 @@ use App\Support\Addons\AddonSettings; use App\Support\Addons\CustomerCards; use App\Support\Addons\CustomerFormTabs; +use App\Support\Addons\CustomerTabs; use App\Support\Addons\PrivateFile; use App\Support\Permissions; use Illuminate\Support\ServiceProvider; @@ -75,10 +76,13 @@ public function boot(): void $this->app->make(AddonSettings::class)->register('ispgrid/documents', 'documents::settings', Abilities::TYPES); $this->app->make(CustomerFormTabs::class)->register(DocumentsTab::class); - $this->app->make(CustomerCards::class)->register( - 'documents::customer-card', - fn (User $user, Account $account): bool => $user->can(Abilities::VIEW) || $user->can(Abilities::UPLOAD), - 60, - ); + $visible = fn (User $user, Account $account): bool => $user->can(Abilities::VIEW) || $user->can(Abilities::UPLOAD); + + // A tab on the customer page from core 0.1.2 on (after Connection), a card before it. + if (class_exists(CustomerTabs::class)) { + $this->app->make(CustomerTabs::class)->register('documents', fn (): string => __('Documents'), 'documents::customer-card', $visible, 50); + } else { + $this->app->make(CustomerCards::class)->register('documents::customer-card', $visible, 60); + } } } diff --git a/tests/Feature/CustomerCardTest.php b/tests/Feature/CustomerCardTest.php index 4a68be3..5531b26 100644 --- a/tests/Feature/CustomerCardTest.php +++ b/tests/Feature/CustomerCardTest.php @@ -23,13 +23,16 @@ function card(Account $account): Testable return Livewire::test('documents::customer-card', ['accountId' => $account->id]); } -it('shows the card only to staff with View customer documents or Upload documents', function () { +it('shows the Documents tab only to staff with View customer documents or Upload documents', function () { + $documentsTab = route('customers.show', [$this->account, 'tab' => 'documents']); + $this->actingAs(staffWith('view-customers')); - $this->get(route('customers.show', $this->account))->assertOk()->assertDontSee('xxxx xxxx 4821'); + $this->get($documentsTab)->assertOk()->assertDontSee('$set(\'tab\', \'documents\')', false)->assertDontSee('xxxx xxxx 4821'); card($this->account)->assertForbidden(); $this->actingAs(staffWith(Abilities::VIEW)); - $this->get(route('customers.show', $this->account))->assertOk()->assertSee('xxxx xxxx 4821')->assertDontSee('1234 5678 4821'); + $this->get(route('customers.show', $this->account))->assertOk()->assertSee('$set(\'tab\', \'documents\')', false)->assertDontSee('xxxx xxxx 4821'); + $this->get($documentsTab)->assertOk()->assertSee('xxxx xxxx 4821')->assertDontSee('1234 5678 4821'); }); it('lets staff with Upload documents only add files, not open them', function () { From 470dc05508e2b1492dd324c2f5a7993c9c7ba137 Mon Sep 17 00:00:00 2001 From: Karan Date: Tue, 20 Oct 2026 10:59:02 +0530 Subject: [PATCH 11/11] Call the customer page documents a tab in the README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1bc430c..bba5a1e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ business), so the add-on asks only whether a customer needs documents at all. number when the type keeps one, **Choose file** (on a phone this also offers the camera). **Add another document** adds a box, so all of a new customer's papers go in with one save. JPG, PNG or PDF, up to 10 MB, checked by the file's content. -- **Documents card on the customer page:** the documents, 3 to a row (1 on a phone), with View, Edit (type or +- **Documents tab on the customer page:** the documents, 3 to a row (1 on a phone), with View, Edit (type or number) and Delete; one upload box; and Download all, one zip of the customer's documents for an audit. ID numbers are shown masked (`xxxx xxxx 4821`); **Show** reveals the full number. - **Nothing is lost by accident:** Delete hides a document; **Deleted documents** (a button in this add-on's @@ -62,7 +62,7 @@ Not yet: shrinking photos on the phone before upload. ## Taking documents out -- **One customer, for an audit:** Download all on the customer's Documents card (one zip, logged). +- **One customer, for an audit:** Download all on the customer's Documents tab (one zip, logged). - **Everything, for leaving ISPGrid:** on the server, ```bash @@ -92,7 +92,7 @@ its files (`storage/app/private/addons/documents`). sudo /opt/ispgrid/deploy/install.sh --remove-addon=ispgrid/documents ``` -The tab, card and pages go away, but the documents are kept: installing the add-on again shows them as they were. To +The tabs and pages go away, but the documents are kept: installing the add-on again shows them as they were. To delete every document too: ```bash