diff --git a/README.md b/README.md index efcfc2c..bba5a1e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,119 @@ # 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. 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 +> 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, 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 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 + 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): + "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: shrinking photos on the phone before upload. + +## 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 tab (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 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 +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/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 11f851b..f7d9a7b 100644 --- a/lang/hi.json +++ b/lang/hi.json @@ -1,11 +1,85 @@ { + ":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 द्वारा", + ":type (replaces the current file)": ":type (मौजूदा फ़ाइल की जगह लेगा)", + ":type :number is already on :customer.": ":type :number पहले से :customer के पास है।", + ":type saved.": ":type सेव हो गया।", + "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": "नंबर हटाएँ", + "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 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 upload is required": "दस्तावेज़ अपलोड करना ज़रूरी है", + "Document uploaded": "दस्तावेज़ अपलोड हुआ", + "Document viewed": "दस्तावेज़ देखा गया", + "Documents": "दस्तावेज़", + "Documents downloaded": "दस्तावेज़ डाउनलोड हुए", + "Documents exported": "दस्तावेज़ निर्यात हुए", + "Download all": "सब डाउनलोड करें", "Edit and delete documents": "दस्तावेज़ बदलें और हटाएँ", + "Edit document": "दस्तावेज़ बदलें", + "Edit document type": "दस्तावेज़ का प्रकार बदलें", "Erase deleted documents": "हटाए गए दस्तावेज़ हमेशा के लिए मिटाएँ", + "Erase for good": "हमेशा के लिए मिटाएँ", + "Erase the :type of :customer for good? This cannot be undone.": ":customer का :type हमेशा के लिए मिटाएँ? यह वापस नहीं हो सकता।", "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.": "पुराना नंबर रखने के लिए खाली छोड़ें।", + "Move down": "नीचे ले जाएँ", + "Move up": "ऊपर ले जाएँ", + "Name or username": "नाम या यूज़रनेम", "No ID number": "कोई आईडी नंबर नहीं", + "No deleted documents.": "कोई हटाया गया दस्तावेज़ नहीं।", + "No document types yet. Add them on System, then Add-ons.": "अभी कोई दस्तावेज़ प्रकार नहीं है। उन्हें सिस्टम, फिर ऐड-ऑन में जोड़ें।", + "No documents yet.": "अभी कोई दस्तावेज़ नहीं।", + "Off": "बंद", + "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": "चालू करें", + "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": "अज्ञात", + "Upload": "अपलोड करें", + "Upload a JPG, PNG or PDF.": "JPG, PNG या PDF अपलोड करें।", + "Upload at least one document.": "कम से कम एक दस्तावेज़ अपलोड करें।", "Upload documents": "दस्तावेज़ अपलोड करें", + "Uploaded :date by :name": ":date को :name ने अपलोड किया", + "View": "देखें", "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..0921db0 --- /dev/null +++ b/resources/views/customer-tab.blade.php @@ -0,0 +1,60 @@ +{{-- The Documents tab, included by core's customer form. It shows only what the tab's state() prepared. --}} +@php($tab = $addonTabs['documents'] ?? []) + +@error('addonTabs.documents.uploads') + {{ $message }} +@enderror + +@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 + +@if (($tab['typeOptions'] ?? []) === []) + {{ __('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 + + @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 new file mode 100644 index 0000000..ab379b8 --- /dev/null +++ b/resources/views/livewire/customer-card/customer-card.blade.php @@ -0,0 +1,95 @@ +
+ +
+ {{ __('Documents') }} + @if ($this->allowed['view'] && $this->documents !== []) + {{ __('Download all') }} + @endif +
+ + @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') }} + @endif + @if ($this->allowed['edit']) + {{ __('Edit') }} + {{ __('Delete') }} + @endif +
+ @endif +
+ @endforeach +
+ @endif + + @if ($this->allowed['upload']) + @if ($this->uploadTypes === []) + {{ __('No document types yet. Add them on System, then Add-ons.') }} + @else +
+ + @if ($this->chosen['numbered']) + + @endif + +
+ @if ($this->chosen['confirm'] !== null) + {{ __('Upload') }} + @else + {{ __('Upload') }} + @endif +
+ + @endif + @endif +
+ + @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..6c71700 --- /dev/null +++ b/resources/views/livewire/customer-card/customer-card.php @@ -0,0 +1,262 @@ +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), + ]; + } + + /** + * 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 array{help: ?string, numbered: bool, confirm: ?string} + */ + #[Computed] + 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, + ]; + } + + /** + * 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 + { + $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 + { + $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()); + } + + /** + * 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); + $this->validate([ + '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.'), + ]); + $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->uploadFile, $number, auth()->user()); + $this->reset('uploadType', 'uploadNumber', 'uploadFile'); + $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); + } + + /** + * @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 new file mode 100644 index 0000000..83fa10d --- /dev/null +++ b/resources/views/livewire/deleted/deleted.blade.php @@ -0,0 +1,54 @@ +
+
+ {{ __('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.') }} +
+ + @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/settings/settings.blade.php b/resources/views/livewire/settings/settings.blade.php new file mode 100644 index 0000000..6e40286 --- /dev/null +++ b/resources/views/livewire/settings/settings.blade.php @@ -0,0 +1,81 @@ +
+ + + @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') + {{ $message }} + @enderror + + @if ($this->rows !== []) + + + {{ __('Document type') }} + {{ __('ID number') }} + {{ __('Actions') }} + + + @foreach ($this->rows as $row) + + + {{ $row['name'] }} + @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..2e41cf3 --- /dev/null +++ b/resources/views/livewire/settings/settings.php @@ -0,0 +1,256 @@ +authorizeAccess(); + $this->uploadRequired = UploadRequired::isOn(); + } + + 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 + */ + #[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, + '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->numberMode = $type->number_mode->value; + $this->cutCount = 0; + $this->resetErrorBag(); + } + + public function cancel(): void + { + $this->reset('editingId', 'name', 'help', '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); + + // 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) { + 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()); + + // 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; + + 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'], + '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), + '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/routes/web.php b/routes/web.php index 2cb25ec..9597c8c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,11 @@ group(function () { - // + 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/Console/ExportCommand.php b/src/Console/ExportCommand.php new file mode 100644 index 0000000..16a7158 --- /dev/null +++ b/src/Console/ExportCommand.php @@ -0,0 +1,113 @@ +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, array_map($this->cell(...), [ + (string) $account->fullname, + $account->username, + $row->type->name, + (string) $row->number, + (string) $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; + } + + /** + * A name that stays inside its folder: no slashes or other refused characters, and never "." or "..". + */ + private function safe(string $name): string + { + $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; + } + + /** + * ".", 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/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 new file mode 100644 index 0000000..d40df70 --- /dev/null +++ b/src/CustomerDocuments.php @@ -0,0 +1,245 @@ +files->put($account->id, $file); + + try { + // 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']); + + 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; + } + + /** + * 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); 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 + */ + 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)) + ->when($account->id !== null, fn ($query) => $query->where('account_id', '!=', $account->id)) + ->distinct() + ->pluck('account_id'); + + return Account::withTrashed()->whereIn('id', $ids)->orderBy('username')->get(); + } + + /** + * The customer's current documents (switched-off types too), in the order of their types. + * + * @return Collection + */ + public function current(int $accountId): Collection + { + 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'); + } + + /** + * 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 + { + DocumentType::query()->whereKey($type->id)->lockForUpdate()->first(); + + $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/DocumentsServiceProvider.php b/src/DocumentsServiceProvider.php index 912d7d1..fc1be14 100644 --- a/src/DocumentsServiceProvider.php +++ b/src/DocumentsServiceProvider.php @@ -2,9 +2,17 @@ namespace Ispgrid\Documents; +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; +use App\Support\Addons\CustomerTabs; use App\Support\Addons\PrivateFile; use App\Support\Permissions; use Illuminate\Support\ServiceProvider; +use Ispgrid\Documents\Console\ExportCommand; use Livewire\Livewire; /** @@ -36,6 +44,27 @@ 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]); + } + // 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; @@ -44,5 +73,16 @@ 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); + $this->app->make(CustomerFormTabs::class)->register(DocumentsTab::class); + $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/src/DocumentsTab.php b/src/DocumentsTab.php new file mode 100644 index 0000000..d408f29 --- /dev/null +++ b/src/DocumentsTab.php @@ -0,0 +1,199 @@ +can(Abilities::UPLOAD) && ($account === null || ! $account->trashed()); + } + + /** + * 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 : $this->documents->current($account->id); + $uploaders = $this->documents->uploaders($current); + $taken = $current->pluck('document_type_id')->all(); + $types = $this->activeTypes(); + + return [ + '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, + ]])->all(), + ]; + } + + /** + * @return array + */ + public function rules(?Account $account): array + { + return [ + '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], + ]; + } + + /** + * @param array $state + * @return list + */ + public function warnings(array $state, ?Account $account): array + { + $types = $this->activeTypes()->keyBy('id'); + $warnings = []; + + foreach (self::rows($state) as $row) { + $type = $types->get((int) ($row['type'] ?? 0)); + $typed = $row['number'] ?? null; + + if ($type === null || ! 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 (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 = $type->number_mode === NumberMode::None ? null : ($row['number'] ?? null); + $this->documents->upload($account, $type, $file, is_string($number) ? $number : null, $by); + } + } + + /** + * @return array{type: string, number: string, file: null} + */ + public static function emptyRow(): array + { + return ['type' => '', 'number' => '', 'file' => null]; + } + + /** + * 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 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 function activeTypes(): Collection + { + return DocumentType::query()->where('active', true)->orderBy('position')->orderBy('id')->get(); + } +} 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..f9e0d7a --- /dev/null +++ b/src/Http/DownloadAllController.php @@ -0,0 +1,55 @@ +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); + // 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'); + + 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/IdNumber.php b/src/IdNumber.php new file mode 100644 index 0000000..e48e02e --- /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/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 new file mode 100644 index 0000000..23c6ad6 --- /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 "file" next to + * the number in the same upload row + */ + 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('/[^.]+$/', '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/StoredFile.php b/src/StoredFile.php new file mode 100644 index 0000000..272b58f --- /dev/null +++ b/src/StoredFile.php @@ -0,0 +1,70 @@ +/<40 + * random characters>. + */ +final class StoredFile +{ + public const DISK = 'addons'; + + /** + * 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} + */ + 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)); + + 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)); + } + + /** + * 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/src/UploadRequired.php b/src/UploadRequired.php new file mode 100644 index 0000000..f076c82 --- /dev/null +++ b/src/UploadRequired.php @@ -0,0 +1,24 @@ + $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', +]); diff --git a/tests/Feature/CustomerCardTest.php b/tests/Feature/CustomerCardTest.php new file mode 100644 index 0000000..5531b26 --- /dev/null +++ b/tests/Feature/CustomerCardTest.php @@ -0,0 +1,244 @@ +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 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($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('$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 () { + $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 document of the chosen type from the card', function () { + $this->actingAs(staffWith(Abilities::UPLOAD)); + $photo = documentType('Customer photo'); + + card($this->account) + ->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'); + $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(__('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 () { + $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('lets staff with Upload documents replace a file from the card', function () { + $this->actingAs(staffWith(Abilities::UPLOAD)); + + 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') + ->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']); + + $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('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 () { + $this->actingAs($this->admin); + $photo = documentType('Customer photo'); + $card = card($this->account)->assertOk(); + + $this->account->delete(); + + $card->set('uploadType', (string) $photo->id)->set('uploadFile', pngUpload())->call('upload')->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); + $card = card($this->account)->assertOk(); + + revokeBox($staff, Abilities::VIEW); + + $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 new file mode 100644 index 0000000..5cc5f4b --- /dev/null +++ b/tests/Feature/CustomerDocumentsTest.php @@ -0,0 +1,171 @@ +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 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->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 () { + $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..6623e1f --- /dev/null +++ b/tests/Feature/DocumentsTabTest.php @@ -0,0 +1,238 @@ +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'); +} + +/** + * 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 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(); + 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('stores several documents from one save', function () { + $pan = documentType('PAN card'); + + $form = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + uploadRow($form, 1, $pan->id, UploadedFile::fake()->createWithContent('pan.pdf', pdfBytes()), 'ABCDE1234F') + ->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.')]); +}); + +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); + + 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 () { + 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, a bad number and a tampered row', function () { + $off = documentType('Old form', ['active' => false]); + + 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.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('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('front.png'), '1234 5678 4821', $this->staff); + + $form = Livewire::test('pages::customers.form', ['account' => $account->id]); + + $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'); +}); + +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); + + 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(); + 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 = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + $temporary = $form->get('addonTabs.documents.uploads.0.file'); + + 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, 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'); + + 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 () { + 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 = uploadRow(newCustomerForm(), 0, $this->aadhaar->id, pngUpload()); + $temporary = $form->get('addonTabs.documents.uploads.0.file'); + + $form->call('save')->assertHasNoErrors(); + + expect($temporary->exists())->toBeTrue() + ->and(Storage::disk('addons')->allFiles())->toBe([]); +}); + +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]); + + $form = uploadRow(newCustomerForm(), 0, $lastFour->id, pngUpload(), 'ABC/1234567'); + uploadRow($form, 1, $plain->id, pngUpload(), 'XYZ 99')->call('save')->assertHasNoErrors(); + + 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/ExportCommandTest.php b/tests/Feature/ExportCommandTest.php new file mode 100644 index 0000000..f5ab887 --- /dev/null +++ b/tests/Feature/ExportCommandTest.php @@ -0,0 +1,82 @@ +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('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'); + + $this->artisan('documents:export', ['folder' => $this->folder])->assertFailed(); + + expect(File::files($this->folder))->toHaveCount(1); +}); diff --git a/tests/Feature/IdNumberTest.php b/tests/Feature/IdNumberTest.php new file mode 100644 index 0000000..cf3389b --- /dev/null +++ b/tests/Feature/IdNumberTest.php @@ -0,0 +1,52 @@ +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('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') + ->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/Feature/ListPagesTest.php b/tests/Feature/ListPagesTest.php new file mode 100644 index 0000000..1df2eb5 --- /dev/null +++ b/tests/Feature/ListPagesTest.php @@ -0,0 +1,85 @@ +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('links Deleted documents from the documents settings, not the menu', function () { + $this->actingAs($this->admin); + $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 new file mode 100644 index 0000000..16b3a96 --- /dev/null +++ b/tests/Feature/SettingsTest.php @@ -0,0 +1,169 @@ +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('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']))->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') + ->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('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); + $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); +}); + +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/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); +}); diff --git a/tests/Pest.php b/tests/Pest.php index e80833d..66ab650 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,8 +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. + */ +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, + 'number_mode' => NumberMode::Full, + 'position' => DocumentType::query()->count() + 1, + ...$attributes, + ]); +}