From afc7aa04d9645712ff5539f7ec2a68548533ca43 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 13:27:58 -0500 Subject: [PATCH 1/9] Fix OPS family import when applicants/inventors are missing --- app/Http/Controllers/MatterController.php | 4 ++-- app/Services/OPSService.php | 26 +++++++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..ad490b29 100644 --- a/app/Http/Controllers/MatterController.php +++ b/app/Http/Controllers/MatterController.php @@ -444,7 +444,7 @@ public function storeFamily(Request $request) $new_matter->classifiersNative()->create(['type_code' => 'TIT', 'value' => $app['title']]); } $new_matter->actorPivot()->create(['actor_id' => $request->client_id, 'role' => 'CLI', 'shared' => 1]); - if (array_key_exists('applicants', $app)) { + if (array_key_exists('applicants', $app) && !empty($app['applicants'])) { if (strtolower($app['applicants'][0]) == strtolower(Actor::find($request->client_id)->name)) { $new_matter->actorPivot()->create( [ @@ -489,7 +489,7 @@ public function storeFamily(Request $request) } $new_matter->notes = 'Applicants: ' . collect($app['applicants'])->implode('; '); } - if (array_key_exists('inventors', $app)) { + if (array_key_exists('inventors', $app) && !empty($app['inventors'])) { foreach ($app['inventors'] as $inventor) { // Search for phonetically equivalent in the actor table, and take first if (substr($inventor, -1) == ',') { diff --git a/app/Services/OPSService.php b/app/Services/OPSService.php index e688d4ad..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ public function getFamilyMembers(string $docnum): array ->last()['$']; // Each inventor is under [i]['inventor-name']['name']['$'] both in "epodoc" and "original" format - $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor']) - ->where('@data-format', 'original'); - $apps[0]['inventors'] = $inventors->values()->pluck('inventor-name.name.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('inventor-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($inventors)) { + $apps[0]['inventors'] = $inventors; + } // Each applicant is under [i]['applicant-name']['name']['$'] - $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant']) - ->where('@data-format', 'original'); - $apps[0]['applicants'] = $applicants->values()->pluck('applicant-name.name.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('applicant-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($applicants)) { + $apps[0]['applicants'] = $applicants; + } $procedureSteps = $this->getProceduralSteps($app_number); if (!empty($procedureSteps)) { From cbab030428320d9c6dfe846a02bbef08878ef207 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 13:58:32 -0500 Subject: [PATCH 2/9] Fix MySQL SSL option constant for PHP compatibility --- app/Http/Controllers/MatterController.php | 18 +-- app/Services/FamilyDataService.php | 80 +++++++++++ app/Services/OPSService.php | 26 +++- app/Services/USPTOService.php | 162 ++++++++++++++++++++++ config/database.php | 2 +- config/services.php | 10 ++ 6 files changed, 282 insertions(+), 16 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 100644 --- a/app/Http/Controllers/MatterController.php +++ b/app/Http/Controllers/MatterController.php @@ -8,8 +8,8 @@ use App\Models\ActorPivot; use App\Models\Matter; use App\Services\DocumentMergeService; +use App\Services\FamilyDataService; use App\Services\MatterExportService; -use App\Services\OPSService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -26,24 +26,24 @@ class MatterController extends Controller { protected DocumentMergeService $documentMergeService; protected MatterExportService $matterExportService; - protected OPSService $opsService; + protected FamilyDataService $familyDataService; /** * Initialize the controller with required services. * * @param DocumentMergeService $documentMergeService Service for merging matter data into documents. * @param MatterExportService $matterExportService Service for exporting matters to CSV. - * @param OPSService $opsService Service for interacting with EPO OPS API. + * @param FamilyDataService $familyDataService Service for retrieving family data from OPS/USPTO. */ public function __construct( DocumentMergeService $documentMergeService, MatterExportService $matterExportService, - OPSService $opsService + FamilyDataService $familyDataService ) { $this->documentMergeService = $documentMergeService; $this->matterExportService = $matterExportService; - $this->opsService = $opsService; + $this->familyDataService = $familyDataService; } /** @@ -359,7 +359,7 @@ public function storeFamily(Request $request) 'client_id' => 'required', ]); - $apps = collect($this->opsService->getFamilyMembers($request->docnum)); + $apps = collect($this->familyDataService->getFamilyMembers($request->docnum)); if ($apps->has('errors') || $apps->has('exception')) { return response()->json($apps); } @@ -444,7 +444,7 @@ public function storeFamily(Request $request) $new_matter->classifiersNative()->create(['type_code' => 'TIT', 'value' => $app['title']]); } $new_matter->actorPivot()->create(['actor_id' => $request->client_id, 'role' => 'CLI', 'shared' => 1]); - if (array_key_exists('applicants', $app)) { + if (array_key_exists('applicants', $app) && !empty($app['applicants'])) { if (strtolower($app['applicants'][0]) == strtolower(Actor::find($request->client_id)->name)) { $new_matter->actorPivot()->create( [ @@ -489,7 +489,7 @@ public function storeFamily(Request $request) } $new_matter->notes = 'Applicants: ' . collect($app['applicants'])->implode('; '); } - if (array_key_exists('inventors', $app)) { + if (array_key_exists('inventors', $app) && !empty($app['inventors'])) { foreach ($app['inventors'] as $inventor) { // Search for phonetically equivalent in the actor table, and take first if (substr($inventor, -1) == ',') { @@ -796,7 +796,7 @@ public function mergeFile(Matter $matter, MergeFileRequest $request) */ public function getOPSfamily(string $docnum) { - return $this->opsService->getFamilyMembers($docnum); + return $this->familyDataService->getFamilyMembers($docnum); } /** diff --git a/app/Services/FamilyDataService.php b/app/Services/FamilyDataService.php new file mode 100644 index 00000000..5f76c19c --- /dev/null +++ b/app/Services/FamilyDataService.php @@ -0,0 +1,80 @@ +opsService->getFamilyMembers($docnum); + if (array_key_exists('errors', $apps) || array_key_exists('exception', $apps)) { + // If the requested number looks US, return a synthetic single-member family + // from USPTO ODP when possible. + if ($this->isUSDocument($docnum)) { + $member = $this->buildUSMemberFromODP($docnum); + if (!empty($member)) { + return [$member]; + } + } + + return $apps; + } + + return $this->usptoService->enrichFamilyMembers($apps); + } + + private function isUSDocument(string $docnum): bool + { + return str_starts_with(strtoupper(trim($docnum)), 'US'); + } + + private function buildUSMemberFromODP(string $docnum): array + { + $number = preg_replace('/\D/', '', $docnum); + if (!$number) { + return []; + } + + $odData = $this->usptoService->getApplicationData($number); + if (empty($odData)) { + return []; + } + + return [ + 'id' => 'US' . $number, + 'app' => [ + 'country' => 'US', + 'number' => ltrim($number, '0'), + 'kind' => 'A', + 'date' => null, + ], + 'pri' => [], + 'pct' => null, + 'div' => null, + 'cnt' => null, + 'title' => $odData['title'] ?? null, + 'applicants' => $odData['applicants'] ?? [], + 'inventors' => $odData['inventors'] ?? [], + 'procedure' => $odData['procedure'] ?? [], + ]; + } +} + diff --git a/app/Services/OPSService.php b/app/Services/OPSService.php index e688d4ad..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ public function getFamilyMembers(string $docnum): array ->last()['$']; // Each inventor is under [i]['inventor-name']['name']['$'] both in "epodoc" and "original" format - $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor']) - ->where('@data-format', 'original'); - $apps[0]['inventors'] = $inventors->values()->pluck('inventor-name.name.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('inventor-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($inventors)) { + $apps[0]['inventors'] = $inventors; + } // Each applicant is under [i]['applicant-name']['name']['$'] - $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant']) - ->where('@data-format', 'original'); - $apps[0]['applicants'] = $applicants->values()->pluck('applicant-name.name.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('applicant-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($applicants)) { + $apps[0]['applicants'] = $applicants; + } $procedureSteps = $this->getProceduralSteps($app_number); if (!empty($procedureSteps)) { diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php new file mode 100644 index 00000000..42683dcb --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,162 @@ + $app) { + if (data_get($app, 'app.country') !== 'US') { + continue; + } + + $number = data_get($app, 'app.number'); + if (!$number) { + continue; + } + + $odData = $this->getApplicationData((string) $number); + if (empty($odData)) { + continue; + } + + if (empty($apps[0]['title']) && !empty($odData['title'])) { + $apps[0]['title'] = $odData['title']; + } + if (empty($apps[0]['applicants']) && !empty($odData['applicants'])) { + $apps[0]['applicants'] = $odData['applicants']; + } + if (empty($apps[0]['inventors']) && !empty($odData['inventors'])) { + $apps[0]['inventors'] = $odData['inventors']; + } + if (empty($apps[$index]['procedure']) && !empty($odData['procedure'])) { + $apps[$index]['procedure'] = $odData['procedure']; + } + } + + return $apps; + } + + /** + * Fetch a single US application using configured USPTO ODP endpoints. + * + * @param string $applicationNumber + * @return array + */ + public function getApplicationData(string $applicationNumber): array + { + if (!config('services.uspto.enabled')) { + return []; + } + + $normalizedNumber = preg_replace('/\D/', '', $applicationNumber); + if (!$normalizedNumber) { + return []; + } + + $apiKey = config('services.uspto.api_key'); + $headers = $apiKey ? ['X-Api-Key' => $apiKey] : []; + + // Preferred path: a direct endpoint template containing {applicationNumber}. + $template = config('services.uspto.application_endpoint'); + if (!empty($template)) { + $url = str_replace('{applicationNumber}', $normalizedNumber, $template); + $response = Http::withHeaders($headers)->get($url); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + } + + // Fallback path: generic search endpoint. + $searchEndpoint = config('services.uspto.search_endpoint'); + if (empty($searchEndpoint)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $payload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->get($searchEndpoint, $payload); + if (!$response->successful()) { + return []; + } + + return $this->normalizeRecord($response->json()); + } + + /** + * Normalize a USPTO payload to phpIP expected fields. + * + * @param mixed $payload + * @return array + */ + private function normalizeRecord($payload): array + { + $record = Arr::first(data_get($payload, 'hits.hits', []), null, []); + if (array_key_exists('_source', $record)) { + $record = $record['_source']; + } elseif (array_key_exists('record', $payload)) { + $record = $payload['record']; + } elseif (array_key_exists('results', $payload)) { + $record = Arr::first($payload['results'], []); + } elseif (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + $applicants = collect( + data_get($record, 'applicants', data_get($record, 'applicantName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'applicantName')); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get($record, 'inventors', data_get($record, 'inventorName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'inventorName')); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get($record, 'inventionTitle', data_get($record, 'title')), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get($record, 'events', data_get($record, 'legalEvents', [])), + ]; + } +} + diff --git a/config/database.php b/config/database.php index 673eabb6..cec243b8 100644 --- a/config/database.php +++ b/config/database.php @@ -59,7 +59,7 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/services.php b/config/services.php index 715f8284..af6a38b3 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,14 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Preferred endpoint template, e.g. https://api.uspto.gov/.../{applicationNumber} + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT'), + // Optional generic search endpoint fallback, e.g. https://api.uspto.gov/api/v1/.../search + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT'), + 'search_field' => env('USPTO_ODP_SEARCH_FIELD', 'applicationNumberText'), + ], + ]; From 5da49a0fb654242daad651b7180b520d35cedd45 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 14:48:50 -0500 Subject: [PATCH 3/9] Replace Auth::routes macro with explicit auth routes --- app/Http/Controllers/MatterController.php | 18 +-- app/Services/FamilyDataService.php | 80 +++++++++++ app/Services/OPSService.php | 26 +++- app/Services/USPTOService.php | 162 ++++++++++++++++++++++ composer.json | 1 - config/database.php | 2 +- config/services.php | 10 ++ routes/web.php | 28 +++- 8 files changed, 309 insertions(+), 18 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 100644 --- a/app/Http/Controllers/MatterController.php +++ b/app/Http/Controllers/MatterController.php @@ -8,8 +8,8 @@ use App\Models\ActorPivot; use App\Models\Matter; use App\Services\DocumentMergeService; +use App\Services\FamilyDataService; use App\Services\MatterExportService; -use App\Services\OPSService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -26,24 +26,24 @@ class MatterController extends Controller { protected DocumentMergeService $documentMergeService; protected MatterExportService $matterExportService; - protected OPSService $opsService; + protected FamilyDataService $familyDataService; /** * Initialize the controller with required services. * * @param DocumentMergeService $documentMergeService Service for merging matter data into documents. * @param MatterExportService $matterExportService Service for exporting matters to CSV. - * @param OPSService $opsService Service for interacting with EPO OPS API. + * @param FamilyDataService $familyDataService Service for retrieving family data from OPS/USPTO. */ public function __construct( DocumentMergeService $documentMergeService, MatterExportService $matterExportService, - OPSService $opsService + FamilyDataService $familyDataService ) { $this->documentMergeService = $documentMergeService; $this->matterExportService = $matterExportService; - $this->opsService = $opsService; + $this->familyDataService = $familyDataService; } /** @@ -359,7 +359,7 @@ public function storeFamily(Request $request) 'client_id' => 'required', ]); - $apps = collect($this->opsService->getFamilyMembers($request->docnum)); + $apps = collect($this->familyDataService->getFamilyMembers($request->docnum)); if ($apps->has('errors') || $apps->has('exception')) { return response()->json($apps); } @@ -444,7 +444,7 @@ public function storeFamily(Request $request) $new_matter->classifiersNative()->create(['type_code' => 'TIT', 'value' => $app['title']]); } $new_matter->actorPivot()->create(['actor_id' => $request->client_id, 'role' => 'CLI', 'shared' => 1]); - if (array_key_exists('applicants', $app)) { + if (array_key_exists('applicants', $app) && !empty($app['applicants'])) { if (strtolower($app['applicants'][0]) == strtolower(Actor::find($request->client_id)->name)) { $new_matter->actorPivot()->create( [ @@ -489,7 +489,7 @@ public function storeFamily(Request $request) } $new_matter->notes = 'Applicants: ' . collect($app['applicants'])->implode('; '); } - if (array_key_exists('inventors', $app)) { + if (array_key_exists('inventors', $app) && !empty($app['inventors'])) { foreach ($app['inventors'] as $inventor) { // Search for phonetically equivalent in the actor table, and take first if (substr($inventor, -1) == ',') { @@ -796,7 +796,7 @@ public function mergeFile(Matter $matter, MergeFileRequest $request) */ public function getOPSfamily(string $docnum) { - return $this->opsService->getFamilyMembers($docnum); + return $this->familyDataService->getFamilyMembers($docnum); } /** diff --git a/app/Services/FamilyDataService.php b/app/Services/FamilyDataService.php new file mode 100644 index 00000000..5f76c19c --- /dev/null +++ b/app/Services/FamilyDataService.php @@ -0,0 +1,80 @@ +opsService->getFamilyMembers($docnum); + if (array_key_exists('errors', $apps) || array_key_exists('exception', $apps)) { + // If the requested number looks US, return a synthetic single-member family + // from USPTO ODP when possible. + if ($this->isUSDocument($docnum)) { + $member = $this->buildUSMemberFromODP($docnum); + if (!empty($member)) { + return [$member]; + } + } + + return $apps; + } + + return $this->usptoService->enrichFamilyMembers($apps); + } + + private function isUSDocument(string $docnum): bool + { + return str_starts_with(strtoupper(trim($docnum)), 'US'); + } + + private function buildUSMemberFromODP(string $docnum): array + { + $number = preg_replace('/\D/', '', $docnum); + if (!$number) { + return []; + } + + $odData = $this->usptoService->getApplicationData($number); + if (empty($odData)) { + return []; + } + + return [ + 'id' => 'US' . $number, + 'app' => [ + 'country' => 'US', + 'number' => ltrim($number, '0'), + 'kind' => 'A', + 'date' => null, + ], + 'pri' => [], + 'pct' => null, + 'div' => null, + 'cnt' => null, + 'title' => $odData['title'] ?? null, + 'applicants' => $odData['applicants'] ?? [], + 'inventors' => $odData['inventors'] ?? [], + 'procedure' => $odData['procedure'] ?? [], + ]; + } +} + diff --git a/app/Services/OPSService.php b/app/Services/OPSService.php index e688d4ad..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ public function getFamilyMembers(string $docnum): array ->last()['$']; // Each inventor is under [i]['inventor-name']['name']['$'] both in "epodoc" and "original" format - $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor']) - ->where('@data-format', 'original'); - $apps[0]['inventors'] = $inventors->values()->pluck('inventor-name.name.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('inventor-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($inventors)) { + $apps[0]['inventors'] = $inventors; + } // Each applicant is under [i]['applicant-name']['name']['$'] - $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant']) - ->where('@data-format', 'original'); - $apps[0]['applicants'] = $applicants->values()->pluck('applicant-name.name.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('applicant-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($applicants)) { + $apps[0]['applicants'] = $applicants; + } $procedureSteps = $this->getProceduralSteps($app_number); if (!empty($procedureSteps)) { diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php new file mode 100644 index 00000000..42683dcb --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,162 @@ + $app) { + if (data_get($app, 'app.country') !== 'US') { + continue; + } + + $number = data_get($app, 'app.number'); + if (!$number) { + continue; + } + + $odData = $this->getApplicationData((string) $number); + if (empty($odData)) { + continue; + } + + if (empty($apps[0]['title']) && !empty($odData['title'])) { + $apps[0]['title'] = $odData['title']; + } + if (empty($apps[0]['applicants']) && !empty($odData['applicants'])) { + $apps[0]['applicants'] = $odData['applicants']; + } + if (empty($apps[0]['inventors']) && !empty($odData['inventors'])) { + $apps[0]['inventors'] = $odData['inventors']; + } + if (empty($apps[$index]['procedure']) && !empty($odData['procedure'])) { + $apps[$index]['procedure'] = $odData['procedure']; + } + } + + return $apps; + } + + /** + * Fetch a single US application using configured USPTO ODP endpoints. + * + * @param string $applicationNumber + * @return array + */ + public function getApplicationData(string $applicationNumber): array + { + if (!config('services.uspto.enabled')) { + return []; + } + + $normalizedNumber = preg_replace('/\D/', '', $applicationNumber); + if (!$normalizedNumber) { + return []; + } + + $apiKey = config('services.uspto.api_key'); + $headers = $apiKey ? ['X-Api-Key' => $apiKey] : []; + + // Preferred path: a direct endpoint template containing {applicationNumber}. + $template = config('services.uspto.application_endpoint'); + if (!empty($template)) { + $url = str_replace('{applicationNumber}', $normalizedNumber, $template); + $response = Http::withHeaders($headers)->get($url); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + } + + // Fallback path: generic search endpoint. + $searchEndpoint = config('services.uspto.search_endpoint'); + if (empty($searchEndpoint)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $payload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->get($searchEndpoint, $payload); + if (!$response->successful()) { + return []; + } + + return $this->normalizeRecord($response->json()); + } + + /** + * Normalize a USPTO payload to phpIP expected fields. + * + * @param mixed $payload + * @return array + */ + private function normalizeRecord($payload): array + { + $record = Arr::first(data_get($payload, 'hits.hits', []), null, []); + if (array_key_exists('_source', $record)) { + $record = $record['_source']; + } elseif (array_key_exists('record', $payload)) { + $record = $payload['record']; + } elseif (array_key_exists('results', $payload)) { + $record = Arr::first($payload['results'], []); + } elseif (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + $applicants = collect( + data_get($record, 'applicants', data_get($record, 'applicantName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'applicantName')); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get($record, 'inventors', data_get($record, 'inventorName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'inventorName')); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get($record, 'inventionTitle', data_get($record, 'title')), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get($record, 'events', data_get($record, 'legalEvents', [])), + ]; + } +} + diff --git a/composer.json b/composer.json index 861908af..8ace9ef2 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "require-dev": { "laravel/pint": "^1.17", "laravel/tinker": "^3.0", - "laravel/ui": "^4.2", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.1", "phpunit/phpunit": "^12.0" diff --git a/config/database.php b/config/database.php index 673eabb6..cec243b8 100644 --- a/config/database.php +++ b/config/database.php @@ -59,7 +59,7 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/services.php b/config/services.php index 715f8284..af6a38b3 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,14 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Preferred endpoint template, e.g. https://api.uspto.gov/.../{applicationNumber} + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT'), + // Optional generic search endpoint fallback, e.g. https://api.uspto.gov/api/v1/.../search + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT'), + 'search_field' => env('USPTO_ODP_SEARCH_FIELD', 'applicationNumberText'), + ], + ]; diff --git a/routes/web.php b/routes/web.php index 40612642..214a0eee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,12 +34,38 @@ use App\Http\Controllers\AutocompleteController; use App\Http\Controllers\MatterSearchController; use App\Http\Controllers\ClassifierController; +use App\Http\Controllers\Auth\LoginController; +use App\Http\Controllers\Auth\ForgotPasswordController; +use App\Http\Controllers\Auth\ResetPasswordController; +use App\Http\Controllers\Auth\ConfirmPasswordController; +use App\Http\Controllers\Auth\VerificationController; Route::get('/', function () { return view('welcome'); }); -Auth::routes(['register' => false]); +// Auth routes defined explicitly to avoid requiring the laravel/ui route macro. +Route::middleware('guest')->group(function () { + Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); + Route::post('login', [LoginController::class, 'login']); + + Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request'); + Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email'); + Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); + Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.update'); +}); + +Route::post('logout', [LoginController::class, 'logout'])->name('logout'); + +Route::middleware('auth')->group(function () { + Route::get('password/confirm', [ConfirmPasswordController::class, 'showConfirmForm'])->name('password.confirm'); + Route::post('password/confirm', [ConfirmPasswordController::class, 'confirm']); + Route::get('email/verify', [VerificationController::class, 'show'])->name('verification.notice'); + Route::get('email/verify/{id}/{hash}', [VerificationController::class, 'verify']) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + Route::post('email/resend', [VerificationController::class, 'resend'])->name('verification.resend'); +}); Route::get('/home', [HomeController::class, 'index'])->name('home'); From e0db37e6f7abf1037d901005e4ae4ecf7809c35c Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 14:56:04 -0500 Subject: [PATCH 4/9] Add USPTO ODP setup and usage manual --- app/Http/Controllers/MatterController.php | 18 +-- app/Services/FamilyDataService.php | 80 +++++++++++ app/Services/OPSService.php | 26 +++- app/Services/USPTOService.php | 162 ++++++++++++++++++++++ composer.json | 1 - config/database.php | 2 +- config/services.php | 10 ++ doc/README.md | 6 + docs/USPTO_ODP.md | 100 +++++++++++++ readme.md | 8 ++ routes/web.php | 28 +++- 11 files changed, 423 insertions(+), 18 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php create mode 100644 docs/USPTO_ODP.md diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 100644 --- a/app/Http/Controllers/MatterController.php +++ b/app/Http/Controllers/MatterController.php @@ -8,8 +8,8 @@ use App\Models\ActorPivot; use App\Models\Matter; use App\Services\DocumentMergeService; +use App\Services\FamilyDataService; use App\Services\MatterExportService; -use App\Services\OPSService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -26,24 +26,24 @@ class MatterController extends Controller { protected DocumentMergeService $documentMergeService; protected MatterExportService $matterExportService; - protected OPSService $opsService; + protected FamilyDataService $familyDataService; /** * Initialize the controller with required services. * * @param DocumentMergeService $documentMergeService Service for merging matter data into documents. * @param MatterExportService $matterExportService Service for exporting matters to CSV. - * @param OPSService $opsService Service for interacting with EPO OPS API. + * @param FamilyDataService $familyDataService Service for retrieving family data from OPS/USPTO. */ public function __construct( DocumentMergeService $documentMergeService, MatterExportService $matterExportService, - OPSService $opsService + FamilyDataService $familyDataService ) { $this->documentMergeService = $documentMergeService; $this->matterExportService = $matterExportService; - $this->opsService = $opsService; + $this->familyDataService = $familyDataService; } /** @@ -359,7 +359,7 @@ public function storeFamily(Request $request) 'client_id' => 'required', ]); - $apps = collect($this->opsService->getFamilyMembers($request->docnum)); + $apps = collect($this->familyDataService->getFamilyMembers($request->docnum)); if ($apps->has('errors') || $apps->has('exception')) { return response()->json($apps); } @@ -444,7 +444,7 @@ public function storeFamily(Request $request) $new_matter->classifiersNative()->create(['type_code' => 'TIT', 'value' => $app['title']]); } $new_matter->actorPivot()->create(['actor_id' => $request->client_id, 'role' => 'CLI', 'shared' => 1]); - if (array_key_exists('applicants', $app)) { + if (array_key_exists('applicants', $app) && !empty($app['applicants'])) { if (strtolower($app['applicants'][0]) == strtolower(Actor::find($request->client_id)->name)) { $new_matter->actorPivot()->create( [ @@ -489,7 +489,7 @@ public function storeFamily(Request $request) } $new_matter->notes = 'Applicants: ' . collect($app['applicants'])->implode('; '); } - if (array_key_exists('inventors', $app)) { + if (array_key_exists('inventors', $app) && !empty($app['inventors'])) { foreach ($app['inventors'] as $inventor) { // Search for phonetically equivalent in the actor table, and take first if (substr($inventor, -1) == ',') { @@ -796,7 +796,7 @@ public function mergeFile(Matter $matter, MergeFileRequest $request) */ public function getOPSfamily(string $docnum) { - return $this->opsService->getFamilyMembers($docnum); + return $this->familyDataService->getFamilyMembers($docnum); } /** diff --git a/app/Services/FamilyDataService.php b/app/Services/FamilyDataService.php new file mode 100644 index 00000000..5f76c19c --- /dev/null +++ b/app/Services/FamilyDataService.php @@ -0,0 +1,80 @@ +opsService->getFamilyMembers($docnum); + if (array_key_exists('errors', $apps) || array_key_exists('exception', $apps)) { + // If the requested number looks US, return a synthetic single-member family + // from USPTO ODP when possible. + if ($this->isUSDocument($docnum)) { + $member = $this->buildUSMemberFromODP($docnum); + if (!empty($member)) { + return [$member]; + } + } + + return $apps; + } + + return $this->usptoService->enrichFamilyMembers($apps); + } + + private function isUSDocument(string $docnum): bool + { + return str_starts_with(strtoupper(trim($docnum)), 'US'); + } + + private function buildUSMemberFromODP(string $docnum): array + { + $number = preg_replace('/\D/', '', $docnum); + if (!$number) { + return []; + } + + $odData = $this->usptoService->getApplicationData($number); + if (empty($odData)) { + return []; + } + + return [ + 'id' => 'US' . $number, + 'app' => [ + 'country' => 'US', + 'number' => ltrim($number, '0'), + 'kind' => 'A', + 'date' => null, + ], + 'pri' => [], + 'pct' => null, + 'div' => null, + 'cnt' => null, + 'title' => $odData['title'] ?? null, + 'applicants' => $odData['applicants'] ?? [], + 'inventors' => $odData['inventors'] ?? [], + 'procedure' => $odData['procedure'] ?? [], + ]; + } +} + diff --git a/app/Services/OPSService.php b/app/Services/OPSService.php index e688d4ad..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ public function getFamilyMembers(string $docnum): array ->last()['$']; // Each inventor is under [i]['inventor-name']['name']['$'] both in "epodoc" and "original" format - $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor']) - ->where('@data-format', 'original'); - $apps[0]['inventors'] = $inventors->values()->pluck('inventor-name.name.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('inventor-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($inventors)) { + $apps[0]['inventors'] = $inventors; + } // Each applicant is under [i]['applicant-name']['name']['$'] - $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant']) - ->where('@data-format', 'original'); - $apps[0]['applicants'] = $applicants->values()->pluck('applicant-name.name.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('applicant-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($applicants)) { + $apps[0]['applicants'] = $applicants; + } $procedureSteps = $this->getProceduralSteps($app_number); if (!empty($procedureSteps)) { diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php new file mode 100644 index 00000000..42683dcb --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,162 @@ + $app) { + if (data_get($app, 'app.country') !== 'US') { + continue; + } + + $number = data_get($app, 'app.number'); + if (!$number) { + continue; + } + + $odData = $this->getApplicationData((string) $number); + if (empty($odData)) { + continue; + } + + if (empty($apps[0]['title']) && !empty($odData['title'])) { + $apps[0]['title'] = $odData['title']; + } + if (empty($apps[0]['applicants']) && !empty($odData['applicants'])) { + $apps[0]['applicants'] = $odData['applicants']; + } + if (empty($apps[0]['inventors']) && !empty($odData['inventors'])) { + $apps[0]['inventors'] = $odData['inventors']; + } + if (empty($apps[$index]['procedure']) && !empty($odData['procedure'])) { + $apps[$index]['procedure'] = $odData['procedure']; + } + } + + return $apps; + } + + /** + * Fetch a single US application using configured USPTO ODP endpoints. + * + * @param string $applicationNumber + * @return array + */ + public function getApplicationData(string $applicationNumber): array + { + if (!config('services.uspto.enabled')) { + return []; + } + + $normalizedNumber = preg_replace('/\D/', '', $applicationNumber); + if (!$normalizedNumber) { + return []; + } + + $apiKey = config('services.uspto.api_key'); + $headers = $apiKey ? ['X-Api-Key' => $apiKey] : []; + + // Preferred path: a direct endpoint template containing {applicationNumber}. + $template = config('services.uspto.application_endpoint'); + if (!empty($template)) { + $url = str_replace('{applicationNumber}', $normalizedNumber, $template); + $response = Http::withHeaders($headers)->get($url); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + } + + // Fallback path: generic search endpoint. + $searchEndpoint = config('services.uspto.search_endpoint'); + if (empty($searchEndpoint)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $payload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->get($searchEndpoint, $payload); + if (!$response->successful()) { + return []; + } + + return $this->normalizeRecord($response->json()); + } + + /** + * Normalize a USPTO payload to phpIP expected fields. + * + * @param mixed $payload + * @return array + */ + private function normalizeRecord($payload): array + { + $record = Arr::first(data_get($payload, 'hits.hits', []), null, []); + if (array_key_exists('_source', $record)) { + $record = $record['_source']; + } elseif (array_key_exists('record', $payload)) { + $record = $payload['record']; + } elseif (array_key_exists('results', $payload)) { + $record = Arr::first($payload['results'], []); + } elseif (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + $applicants = collect( + data_get($record, 'applicants', data_get($record, 'applicantName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'applicantName')); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get($record, 'inventors', data_get($record, 'inventorName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'inventorName')); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get($record, 'inventionTitle', data_get($record, 'title')), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get($record, 'events', data_get($record, 'legalEvents', [])), + ]; + } +} + diff --git a/composer.json b/composer.json index 861908af..8ace9ef2 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "require-dev": { "laravel/pint": "^1.17", "laravel/tinker": "^3.0", - "laravel/ui": "^4.2", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.1", "phpunit/phpunit": "^12.0" diff --git a/config/database.php b/config/database.php index 673eabb6..cec243b8 100644 --- a/config/database.php +++ b/config/database.php @@ -59,7 +59,7 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/services.php b/config/services.php index 715f8284..af6a38b3 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,14 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Preferred endpoint template, e.g. https://api.uspto.gov/.../{applicationNumber} + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT'), + // Optional generic search endpoint fallback, e.g. https://api.uspto.gov/api/v1/.../search + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT'), + 'search_field' => env('USPTO_ODP_SEARCH_FIELD', 'applicationNumberText'), + ], + ]; diff --git a/doc/README.md b/doc/README.md index d573754a..f115e405 100644 --- a/doc/README.md +++ b/doc/README.md @@ -169,3 +169,9 @@ The software is under development, so many changes can occur. To stay up to date * `composer install` The database structure may be updated too, so you need to apply the new migration scripts in `database/migrations`. Just run `php artisan migrate` in the root folder, which will apply the latest scripts. + +## 3.4 USPTO ODP optional setup (US family enrichment/fallback) + +If you want phpIP to enrich/fallback US family data using USPTO ODP, configure the related `.env` variables and cache clear steps described in: + +* `docs/USPTO_ODP.md` diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md new file mode 100644 index 00000000..638a7e59 --- /dev/null +++ b/docs/USPTO_ODP.md @@ -0,0 +1,100 @@ +# USPTO ODP integration guide + +This guide explains how phpIP uses USPTO Open Data Portal (ODP) data together with EPO OPS for patent family import. + +## 1) How provider switching works + +phpIP now uses a provider orchestrator (`FamilyDataService`) for family retrieval: + +1. Try EPO OPS first (existing behavior). +2. If OPS succeeds, optionally enrich US members with USPTO ODP data (title/applicants/inventors/procedure). +3. If OPS fails and the input document number looks US, try building a synthetic single-member US record from USPTO ODP. + +This means **you still use the same UI action**: + +`Matters -> Create family from OPS` + +No new UI menu is required. + +## 2) Configuration + +Set the following variables in `.env`: + +```dotenv +# Enable/disable USPTO enrichment/fallback +USPTO_ODP_ENABLED=true + +# Optional API key (if your ODP dataset requires one) +USPTO_ODP_API_KEY= + +# Option A (preferred): direct endpoint template with placeholder +# Example shape: https:////{applicationNumber} +USPTO_ODP_APPLICATION_ENDPOINT= + +# Option B (fallback): search endpoint +# Example shape: https:////search +USPTO_ODP_SEARCH_ENDPOINT= +USPTO_ODP_SEARCH_FIELD=applicationNumberText +``` + +> Use endpoint URLs exactly as provided by your USPTO ODP API product page. +> phpIP does not hardcode a specific product URL because ODP products can differ. + +Then clear Laravel config cache: + +```bash +php artisan optimize:clear +``` + +## 3) Required existing OPS settings + +USPTO support does **not** replace OPS setup. Keep OPS credentials configured: + +```dotenv +OPS_APP_KEY=... +OPS_SECRET=... +``` + +The orchestrator depends on OPS as the primary family source. + +## 4) Validation checklist + +1. Log in to phpIP. +2. Open `Matters -> Create family from OPS`. +3. Enter a document number from a family containing US members. +4. Run import. +5. Confirm that import no longer fails when OPS has sparse US party data. + +If you have API access to USPTO ODP configured, US party/title/procedure fields may be enriched when missing in OPS. + +## 5) Troubleshooting + +### A) `Auth::routes()` / laravel-ui errors + +The app now defines auth routes explicitly and does not rely on the `Auth::routes()` macro. +If you still see old behavior, clear caches and redeploy updated code: + +```bash +php artisan optimize:clear +composer install --no-dev --optimize-autoloader +``` + +### B) `Class "Pdo\\Mysql" not found` + +Ensure `pdo` + `pdo_mysql` are installed on your PHP runtime. phpIP includes a compatibility fallback for SSL CA constant lookup, but DB drivers are still required. + +### C) OPS import works but US enrichment does not + +Check: + +- `USPTO_ODP_ENABLED=true` +- valid endpoint URL(s) +- API key requirements for your ODP dataset +- network egress to the endpoint host from your phpIP server + +## 6) Security notes + +- Keep API keys in `.env`, never in source files. +- Restrict outbound network access from the server to approved API hosts only. +- Consider request logging/redaction policy for external API errors. + diff --git a/readme.md b/readme.md index bd28da78..40233545 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,14 @@ Head for the [Wiki](https://github.com/jjdejong/phpip/wiki) for further informat # New features +## 2026-04-23 USPTO ODP fallback/enrichment for US family members + +Family import now uses OPS as primary source, with optional USPTO ODP enrichment/fallback for US applications. + +The existing UI entry point remains unchanged: `Matters -> Create family from OPS`. + +Setup instructions are documented in [USPTO ODP integration guide](docs/USPTO_ODP.md). + ## 2025-08-04 Countries Implemented translations for country names. diff --git a/routes/web.php b/routes/web.php index 40612642..214a0eee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,12 +34,38 @@ use App\Http\Controllers\AutocompleteController; use App\Http\Controllers\MatterSearchController; use App\Http\Controllers\ClassifierController; +use App\Http\Controllers\Auth\LoginController; +use App\Http\Controllers\Auth\ForgotPasswordController; +use App\Http\Controllers\Auth\ResetPasswordController; +use App\Http\Controllers\Auth\ConfirmPasswordController; +use App\Http\Controllers\Auth\VerificationController; Route::get('/', function () { return view('welcome'); }); -Auth::routes(['register' => false]); +// Auth routes defined explicitly to avoid requiring the laravel/ui route macro. +Route::middleware('guest')->group(function () { + Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); + Route::post('login', [LoginController::class, 'login']); + + Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request'); + Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email'); + Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); + Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.update'); +}); + +Route::post('logout', [LoginController::class, 'logout'])->name('logout'); + +Route::middleware('auth')->group(function () { + Route::get('password/confirm', [ConfirmPasswordController::class, 'showConfirmForm'])->name('password.confirm'); + Route::post('password/confirm', [ConfirmPasswordController::class, 'confirm']); + Route::get('email/verify', [VerificationController::class, 'show'])->name('verification.notice'); + Route::get('email/verify/{id}/{hash}', [VerificationController::class, 'verify']) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + Route::post('email/resend', [VerificationController::class, 'resend'])->name('verification.resend'); +}); Route::get('/home', [HomeController::class, 'index'])->name('home'); From e34c4a9441cd053e2f684273704ab9d08858123a Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 15:35:23 -0500 Subject: [PATCH 5/9] Clarify ODP manual scope and remove PDO troubleshooting --- docs/USPTO_ODP.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md index 638a7e59..6e7dfd11 100644 --- a/docs/USPTO_ODP.md +++ b/docs/USPTO_ODP.md @@ -79,11 +79,7 @@ php artisan optimize:clear composer install --no-dev --optimize-autoloader ``` -### B) `Class "Pdo\\Mysql" not found` - -Ensure `pdo` + `pdo_mysql` are installed on your PHP runtime. phpIP includes a compatibility fallback for SSL CA constant lookup, but DB drivers are still required. - -### C) OPS import works but US enrichment does not +### B) OPS import works but US enrichment does not Check: @@ -92,9 +88,13 @@ Check: - API key requirements for your ODP dataset - network egress to the endpoint host from your phpIP server -## 6) Security notes +## 6) Scope note (to avoid confusion) + +This guide is only about **OPS/USPTO family import behavior**. +Database/PDO runtime issues are separate deployment topics and are intentionally not covered here. + +## 7) Security notes - Keep API keys in `.env`, never in source files. - Restrict outbound network access from the server to approved API hosts only. - Consider request logging/redaction policy for external API errors. - From 0f797687f2dbdce638f404923f3e2e85ffeb59f6 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 15:35:36 -0500 Subject: [PATCH 6/9] Refocus USPTO guide on end-user usage only --- docs/USPTO_ODP.md | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md index 6e7dfd11..ee0fc39d 100644 --- a/docs/USPTO_ODP.md +++ b/docs/USPTO_ODP.md @@ -67,19 +67,9 @@ The orchestrator depends on OPS as the primary family source. If you have API access to USPTO ODP configured, US party/title/procedure fields may be enriched when missing in OPS. -## 5) Troubleshooting +## 5) Troubleshooting (USPTO ODP only) -### A) `Auth::routes()` / laravel-ui errors - -The app now defines auth routes explicitly and does not rely on the `Auth::routes()` macro. -If you still see old behavior, clear caches and redeploy updated code: - -```bash -php artisan optimize:clear -composer install --no-dev --optimize-autoloader -``` - -### B) OPS import works but US enrichment does not +### OPS import works but US enrichment does not Check: @@ -88,12 +78,7 @@ Check: - API key requirements for your ODP dataset - network egress to the endpoint host from your phpIP server -## 6) Scope note (to avoid confusion) - -This guide is only about **OPS/USPTO family import behavior**. -Database/PDO runtime issues are separate deployment topics and are intentionally not covered here. - -## 7) Security notes +## 6) Security notes - Keep API keys in `.env`, never in source files. - Restrict outbound network access from the server to approved API hosts only. From 7fb009685b2803b6219e1ae7cabe7010fd98c7fa Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 16:50:50 -0500 Subject: [PATCH 7/9] Default USPTO ODP endpoints and remove mandatory endpoint setup --- app/Http/Controllers/MatterController.php | 18 +- app/Services/FamilyDataService.php | 80 +++++++ app/Services/OPSService.php | 26 ++- app/Services/USPTOService.php | 253 ++++++++++++++++++++++ composer.json | 1 - config/database.php | 2 +- config/services.php | 14 ++ doc/README.md | 6 + docs/USPTO_ODP.md | 81 +++++++ readme.md | 8 + routes/web.php | 28 ++- 11 files changed, 499 insertions(+), 18 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php create mode 100644 docs/USPTO_ODP.md diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 100644 --- a/app/Http/Controllers/MatterController.php +++ b/app/Http/Controllers/MatterController.php @@ -8,8 +8,8 @@ use App\Models\ActorPivot; use App\Models\Matter; use App\Services\DocumentMergeService; +use App\Services\FamilyDataService; use App\Services\MatterExportService; -use App\Services\OPSService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -26,24 +26,24 @@ class MatterController extends Controller { protected DocumentMergeService $documentMergeService; protected MatterExportService $matterExportService; - protected OPSService $opsService; + protected FamilyDataService $familyDataService; /** * Initialize the controller with required services. * * @param DocumentMergeService $documentMergeService Service for merging matter data into documents. * @param MatterExportService $matterExportService Service for exporting matters to CSV. - * @param OPSService $opsService Service for interacting with EPO OPS API. + * @param FamilyDataService $familyDataService Service for retrieving family data from OPS/USPTO. */ public function __construct( DocumentMergeService $documentMergeService, MatterExportService $matterExportService, - OPSService $opsService + FamilyDataService $familyDataService ) { $this->documentMergeService = $documentMergeService; $this->matterExportService = $matterExportService; - $this->opsService = $opsService; + $this->familyDataService = $familyDataService; } /** @@ -359,7 +359,7 @@ public function storeFamily(Request $request) 'client_id' => 'required', ]); - $apps = collect($this->opsService->getFamilyMembers($request->docnum)); + $apps = collect($this->familyDataService->getFamilyMembers($request->docnum)); if ($apps->has('errors') || $apps->has('exception')) { return response()->json($apps); } @@ -444,7 +444,7 @@ public function storeFamily(Request $request) $new_matter->classifiersNative()->create(['type_code' => 'TIT', 'value' => $app['title']]); } $new_matter->actorPivot()->create(['actor_id' => $request->client_id, 'role' => 'CLI', 'shared' => 1]); - if (array_key_exists('applicants', $app)) { + if (array_key_exists('applicants', $app) && !empty($app['applicants'])) { if (strtolower($app['applicants'][0]) == strtolower(Actor::find($request->client_id)->name)) { $new_matter->actorPivot()->create( [ @@ -489,7 +489,7 @@ public function storeFamily(Request $request) } $new_matter->notes = 'Applicants: ' . collect($app['applicants'])->implode('; '); } - if (array_key_exists('inventors', $app)) { + if (array_key_exists('inventors', $app) && !empty($app['inventors'])) { foreach ($app['inventors'] as $inventor) { // Search for phonetically equivalent in the actor table, and take first if (substr($inventor, -1) == ',') { @@ -796,7 +796,7 @@ public function mergeFile(Matter $matter, MergeFileRequest $request) */ public function getOPSfamily(string $docnum) { - return $this->opsService->getFamilyMembers($docnum); + return $this->familyDataService->getFamilyMembers($docnum); } /** diff --git a/app/Services/FamilyDataService.php b/app/Services/FamilyDataService.php new file mode 100644 index 00000000..5f76c19c --- /dev/null +++ b/app/Services/FamilyDataService.php @@ -0,0 +1,80 @@ +opsService->getFamilyMembers($docnum); + if (array_key_exists('errors', $apps) || array_key_exists('exception', $apps)) { + // If the requested number looks US, return a synthetic single-member family + // from USPTO ODP when possible. + if ($this->isUSDocument($docnum)) { + $member = $this->buildUSMemberFromODP($docnum); + if (!empty($member)) { + return [$member]; + } + } + + return $apps; + } + + return $this->usptoService->enrichFamilyMembers($apps); + } + + private function isUSDocument(string $docnum): bool + { + return str_starts_with(strtoupper(trim($docnum)), 'US'); + } + + private function buildUSMemberFromODP(string $docnum): array + { + $number = preg_replace('/\D/', '', $docnum); + if (!$number) { + return []; + } + + $odData = $this->usptoService->getApplicationData($number); + if (empty($odData)) { + return []; + } + + return [ + 'id' => 'US' . $number, + 'app' => [ + 'country' => 'US', + 'number' => ltrim($number, '0'), + 'kind' => 'A', + 'date' => null, + ], + 'pri' => [], + 'pct' => null, + 'div' => null, + 'cnt' => null, + 'title' => $odData['title'] ?? null, + 'applicants' => $odData['applicants'] ?? [], + 'inventors' => $odData['inventors'] ?? [], + 'procedure' => $odData['procedure'] ?? [], + ]; + } +} + diff --git a/app/Services/OPSService.php b/app/Services/OPSService.php index e688d4ad..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ public function getFamilyMembers(string $docnum): array ->last()['$']; // Each inventor is under [i]['inventor-name']['name']['$'] both in "epodoc" and "original" format - $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor']) - ->where('@data-format', 'original'); - $apps[0]['inventors'] = $inventors->values()->pluck('inventor-name.name.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('inventor-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($inventors)) { + $apps[0]['inventors'] = $inventors; + } // Each applicant is under [i]['applicant-name']['name']['$'] - $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant']) - ->where('@data-format', 'original'); - $apps[0]['applicants'] = $applicants->values()->pluck('applicant-name.name.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->where('@data-format', 'original') + ->values() + ->pluck('applicant-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($applicants)) { + $apps[0]['applicants'] = $applicants; + } $procedureSteps = $this->getProceduralSteps($app_number); if (!empty($procedureSteps)) { diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php new file mode 100644 index 00000000..24d658d0 --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,253 @@ + $app) { + if (data_get($app, 'app.country') !== 'US') { + continue; + } + + $number = data_get($app, 'app.number'); + if (!$number) { + continue; + } + + $odData = $this->getApplicationData((string) $number); + if (empty($odData)) { + continue; + } + + if (empty($apps[0]['title']) && !empty($odData['title'])) { + $apps[0]['title'] = $odData['title']; + } + if (empty($apps[0]['applicants']) && !empty($odData['applicants'])) { + $apps[0]['applicants'] = $odData['applicants']; + } + if (empty($apps[0]['inventors']) && !empty($odData['inventors'])) { + $apps[0]['inventors'] = $odData['inventors']; + } + if (empty($apps[$index]['procedure']) && !empty($odData['procedure'])) { + $apps[$index]['procedure'] = $odData['procedure']; + } + } + + return $apps; + } + + /** + * Fetch a single US application using configured USPTO ODP endpoints. + * + * @param string $applicationNumber + * @return array + */ + public function getApplicationData(string $applicationNumber): array + { + if (!config('services.uspto.enabled')) { + return []; + } + + $normalizedNumber = preg_replace('/\D/', '', $applicationNumber); + if (!$normalizedNumber) { + return []; + } + + $apiKey = config('services.uspto.api_key'); + $headers = $apiKey ? ['X-Api-Key' => $apiKey] : []; + + // Preferred path: direct application endpoint (built-in default + optional override). + $templates = array_filter(array_unique([ + config('services.uspto.application_endpoint'), + '/api/v1/patent/applications/{applicationNumber}', + ])); + + foreach ($templates as $template) { + $url = $this->resolveEndpointUrl( + str_replace('{applicationNumber}', $normalizedNumber, $template) + ); + + if (empty($url)) { + continue; + } + + $response = Http::withHeaders($headers)->acceptJson()->get($url); + if ($response->successful()) { + $normalized = $this->normalizeRecord($response->json()); + if (!empty(array_filter($normalized))) { + return $normalized; + } + } + } + + // Fallback path: search endpoint (built-in default + optional override). + $searchEndpoint = config('services.uspto.search_endpoint'); + $searchUrl = $this->resolveEndpointUrl( + $searchEndpoint ?: '/api/v1/patent/applications/search' + ); + if (empty($searchUrl)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $queryStringPayload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->acceptJson()->get($searchUrl, $queryStringPayload); + if ($response->successful()) { + $normalized = $this->normalizeRecord($response->json()); + if (!empty(array_filter($normalized))) { + return $normalized; + } + } + + // Secondary fallback: POST JSON search payload. + $jsonSearchPayload = [ + 'query' => [ + 'bool' => [ + 'must' => [ + ['term' => [$queryField => $normalizedNumber]], + ], + ], + ], + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->acceptJson()->post($searchUrl, $jsonSearchPayload); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + + return []; + } + + /** + * Normalize a USPTO payload to phpIP expected fields. + * + * @param mixed $payload + * @return array + */ + private function normalizeRecord($payload): array + { + $record = Arr::first(data_get($payload, 'hits.hits', []), null, []); + if (array_key_exists('_source', $record)) { + $record = $record['_source']; + } elseif (array_key_exists('record', $payload)) { + $record = $payload['record']; + } elseif (array_key_exists('results', $payload)) { + $record = Arr::first($payload['results'], []); + } elseif (array_key_exists('items', $payload)) { + $record = Arr::first($payload['items'], []); + } elseif (array_key_exists('applications', $payload)) { + $record = Arr::first($payload['applications'], []); + } elseif (array_key_exists('data', $payload) && is_array($payload['data'])) { + $data = $payload['data']; + if (array_is_list($data)) { + $record = Arr::first($data, []); + } else { + $record = $data; + } + } elseif (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + if (array_key_exists('applicationMetaData', $record) && is_array($record['applicationMetaData'])) { + $record = array_merge($record['applicationMetaData'], $record); + } + + $applicants = collect( + data_get( + $record, + 'applicants', + data_get($record, 'applicantName', data_get($record, 'parties.applicants', [])) + ) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get( + $value, + 'name', + data_get($value, 'applicantName', data_get($value, 'partyName')) + ); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get( + $record, + 'inventors', + data_get($record, 'inventorName', data_get($record, 'parties.inventors', [])) + ) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get( + $value, + 'name', + data_get($value, 'inventorName', data_get($value, 'partyName')) + ); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get( + $record, + 'inventionTitle', + data_get($record, 'title', data_get($record, 'applicationTitleText')) + ), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get( + $record, + 'events', + data_get($record, 'legalEvents', data_get($record, 'transactions', [])) + ), + ]; + } + + private function resolveEndpointUrl(?string $endpoint): ?string + { + if (empty($endpoint)) { + return null; + } + + if (str_starts_with($endpoint, 'http://') || str_starts_with($endpoint, 'https://')) { + return $endpoint; + } + + $baseUrl = rtrim((string) config('services.uspto.base_url', 'https://api.uspto.gov'), '/'); + $path = '/' . ltrim($endpoint, '/'); + + return $baseUrl . $path; + } +} diff --git a/composer.json b/composer.json index 861908af..8ace9ef2 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "require-dev": { "laravel/pint": "^1.17", "laravel/tinker": "^3.0", - "laravel/ui": "^4.2", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.1", "phpunit/phpunit": "^12.0" diff --git a/config/database.php b/config/database.php index 673eabb6..cec243b8 100644 --- a/config/database.php +++ b/config/database.php @@ -59,7 +59,7 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/services.php b/config/services.php index 715f8284..c822b312 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,18 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Base USPTO ODP API URL; endpoint defaults below are resolved against this value. + 'base_url' => env('USPTO_ODP_BASE_URL', 'https://api.uspto.gov'), + // Optional override for direct application endpoint template. + // Leave empty to use the built-in default. + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT', '/api/v1/patent/applications/{applicationNumber}'), + // Optional override for search endpoint. + // Leave empty to use the built-in default. + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT', '/api/v1/patent/applications/search'), + 'search_field' => env('USPTO_ODP_SEARCH_FIELD', 'applicationNumberText'), + ], + ]; diff --git a/doc/README.md b/doc/README.md index d573754a..f115e405 100644 --- a/doc/README.md +++ b/doc/README.md @@ -169,3 +169,9 @@ The software is under development, so many changes can occur. To stay up to date * `composer install` The database structure may be updated too, so you need to apply the new migration scripts in `database/migrations`. Just run `php artisan migrate` in the root folder, which will apply the latest scripts. + +## 3.4 USPTO ODP optional setup (US family enrichment/fallback) + +If you want phpIP to enrich/fallback US family data using USPTO ODP, configure the related `.env` variables and cache clear steps described in: + +* `docs/USPTO_ODP.md` diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md new file mode 100644 index 00000000..34f023c6 --- /dev/null +++ b/docs/USPTO_ODP.md @@ -0,0 +1,81 @@ +# USPTO ODP integration guide + +This guide explains how phpIP uses USPTO Open Data Portal (ODP) data together with EPO OPS for patent family import. + +## 1) How provider switching works + +phpIP now uses a provider orchestrator (`FamilyDataService`) for family retrieval: + +1. Try EPO OPS first (existing behavior). +2. If OPS succeeds, optionally enrich US members with USPTO ODP data (title/applicants/inventors/procedure). +3. If OPS fails and the input document number looks US, try building a synthetic single-member US record from USPTO ODP. + +This means **you still use the same UI action**: + +`Matters -> Create family from OPS` + +No new UI menu is required. + +## 2) Configuration + +Set the following variables in `.env`: + +```dotenv +# Enable/disable USPTO enrichment/fallback +USPTO_ODP_ENABLED=true + +# Optional API key (if your ODP dataset requires one) +USPTO_ODP_API_KEY= + +# Optional override: USPTO API base URL (default already works for ODP) +# USPTO_ODP_BASE_URL=https://api.uspto.gov + +# Optional overrides (advanced only) +# USPTO_ODP_APPLICATION_ENDPOINT=/api/v1/patent/applications/{applicationNumber} +# USPTO_ODP_SEARCH_ENDPOINT=/api/v1/patent/applications/search +USPTO_ODP_SEARCH_FIELD=applicationNumberText +``` + +Then clear Laravel config cache: + +```bash +php artisan optimize:clear +``` + +## 3) Required existing OPS settings + +USPTO support does **not** replace OPS setup. Keep OPS credentials configured: + +```dotenv +OPS_APP_KEY=... +OPS_SECRET=... +``` + +The orchestrator depends on OPS as the primary family source. + +## 4) Validation checklist + +1. Log in to phpIP. +2. Open `Matters -> Create family from OPS`. +3. Enter a document number from a family containing US members. +4. Run import. +5. Confirm that import no longer fails when OPS has sparse US party data. + +If you have API access to USPTO ODP configured, US party/title/procedure fields may be enriched when missing in OPS. + +## 5) Troubleshooting (USPTO ODP only) + +### OPS import works but US enrichment does not + +Check: + +- `USPTO_ODP_ENABLED=true` +- valid API key (if required by your account/product) +- API key requirements for your ODP dataset +- network egress to the endpoint host from your phpIP server + +## 6) Security notes + +- Keep API keys in `.env`, never in source files. +- Restrict outbound network access from the server to approved API hosts only. +- Consider request logging/redaction policy for external API errors. diff --git a/readme.md b/readme.md index bd28da78..40233545 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,14 @@ Head for the [Wiki](https://github.com/jjdejong/phpip/wiki) for further informat # New features +## 2026-04-23 USPTO ODP fallback/enrichment for US family members + +Family import now uses OPS as primary source, with optional USPTO ODP enrichment/fallback for US applications. + +The existing UI entry point remains unchanged: `Matters -> Create family from OPS`. + +Setup instructions are documented in [USPTO ODP integration guide](docs/USPTO_ODP.md). + ## 2025-08-04 Countries Implemented translations for country names. diff --git a/routes/web.php b/routes/web.php index 40612642..214a0eee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,12 +34,38 @@ use App\Http\Controllers\AutocompleteController; use App\Http\Controllers\MatterSearchController; use App\Http\Controllers\ClassifierController; +use App\Http\Controllers\Auth\LoginController; +use App\Http\Controllers\Auth\ForgotPasswordController; +use App\Http\Controllers\Auth\ResetPasswordController; +use App\Http\Controllers\Auth\ConfirmPasswordController; +use App\Http\Controllers\Auth\VerificationController; Route::get('/', function () { return view('welcome'); }); -Auth::routes(['register' => false]); +// Auth routes defined explicitly to avoid requiring the laravel/ui route macro. +Route::middleware('guest')->group(function () { + Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); + Route::post('login', [LoginController::class, 'login']); + + Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request'); + Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email'); + Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); + Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.update'); +}); + +Route::post('logout', [LoginController::class, 'logout'])->name('logout'); + +Route::middleware('auth')->group(function () { + Route::get('password/confirm', [ConfirmPasswordController::class, 'showConfirmForm'])->name('password.confirm'); + Route::post('password/confirm', [ConfirmPasswordController::class, 'confirm']); + Route::get('email/verify', [VerificationController::class, 'show'])->name('verification.notice'); + Route::get('email/verify/{id}/{hash}', [VerificationController::class, 'verify']) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + Route::post('email/resend', [VerificationController::class, 'resend'])->name('verification.resend'); +}); Route::get('/home', [HomeController::class, 'index'])->name('home'); From 05b0f3a70ee93c02c75f400db2f8a13547ab69a3 Mon Sep 17 00:00:00 2001 From: srdco Date: Fri, 24 Apr 2026 12:01:15 -0500 Subject: [PATCH 8/9] Make login controller version-agnostic across Laravel releases --- app/Http/Controllers/Auth/LoginController.php | 128 +++++++++++++++--- 1 file changed, 110 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index e45b5fd8..27c48136 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -3,29 +3,18 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use Illuminate\Foundation\Auth\AuthenticatesUsers; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\ValidationException; /** * Handles user authentication and login. * - * Uses Laravel's AuthenticatesUsers trait to provide standard login functionality. - * Configured to use the 'login' field instead of 'email' for authentication. + * This implementation intentionally avoids framework-internal auth traits so it + * remains stable across Laravel versions. */ class LoginController extends Controller { - /* - |-------------------------------------------------------------------------- - | Login Controller - |-------------------------------------------------------------------------- - | - | This controller handles authenticating users for the application and - | redirecting them to your home screen. The controller uses a trait - | to conveniently provide its functionality to your applications. - | - */ - - use AuthenticatesUsers; - /** * Where to redirect users after login. * @@ -45,9 +34,87 @@ public function __construct() } /** - * Get the login username field. + * Display the login form. + * + * @return \Illuminate\View\View + */ + public function showLoginForm() + { + return view('auth.login'); + } + + /** + * Handle an authentication attempt. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + * + * @throws \Illuminate\Validation\ValidationException + */ + public function login(Request $request) + { + $this->validateLogin($request); + + if (! Auth::attempt($this->credentials($request), $request->has('remember'))) { + return $this->sendFailedLoginResponse($request); + } + + $request->session()->regenerate(); + + if (method_exists($this, 'authenticated')) { + $response = $this->authenticated($request, Auth::user()); + + if ($response) { + return $response; + } + } + + return redirect()->intended($this->redirectPath()); + } + + /** + * Log the user out of the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function logout(Request $request) + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } + + /** + * Validate the user login request. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function validateLogin(Request $request) + { + $request->validate([ + $this->username() => ['required', 'string'], + 'password' => ['required', 'string'], + ]); + } + + /** + * Get the needed authorization credentials from the request. * - * Uses the 'login' column instead of Laravel's default 'email' field. + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function credentials(Request $request) + { + return $request->only($this->username(), 'password'); + } + + /** + * Get the login username field. * * @return string */ @@ -55,4 +122,29 @@ public function username() { return 'login'; } + + /** + * Get the post-login redirect path. + * + * @return string + */ + protected function redirectPath() + { + return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; + } + + /** + * Get the failed login response instance. + * + * @param \Illuminate\Http\Request $request + * @return never + * + * @throws \Illuminate\Validation\ValidationException + */ + protected function sendFailedLoginResponse(Request $request) + { + throw ValidationException::withMessages([ + $this->username() => [trans('auth.failed')], + ]); + } } From 64b54fefb259eb5e8dc0af21e7d04d48ba376f8b Mon Sep 17 00:00:00 2001 From: srdco Date: Mon, 4 May 2026 20:10:10 -0500 Subject: [PATCH 9/9] Handle duplicate matter-actor links when adding actors --- app/Http/Controllers/ActorPivotController.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/Http/Controllers/ActorPivotController.php b/app/Http/Controllers/ActorPivotController.php index 4b23d8e1..ae3fa82a 100644 --- a/app/Http/Controllers/ActorPivotController.php +++ b/app/Http/Controllers/ActorPivotController.php @@ -34,6 +34,15 @@ public function store(Request $request) 'date' => 'date', ]); + $existingLink = ActorPivot::where('matter_id', $request->matter_id) + ->where('role', $request->role) + ->where('actor_id', $request->actor_id) + ->first(); + + if ($existingLink) { + return $existingLink; + } + // Fix display order indexes if wrong $roleGroup = ActorPivot::where('matter_id', $request->matter_id)->where('role', $request->role); $max = $roleGroup->max('display_order');