Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
afc7aa0
Fix OPS family import when applicants/inventors are missing
srdco Apr 23, 2026
280eac9
Merge pull request #16 from srdco/codex/fix-error-when-creating-matte…
srdco Apr 23, 2026
cbab030
Fix MySQL SSL option constant for PHP compatibility
srdco Apr 23, 2026
0ed826f
Merge pull request #17 from srdco/codex/fix-error-when-creating-matte…
srdco Apr 23, 2026
5da49a0
Replace Auth::routes macro with explicit auth routes
srdco Apr 23, 2026
bc4199d
Merge pull request #18 from srdco/codex/fix-error-when-creating-matte…
srdco Apr 23, 2026
e0db37e
Add USPTO ODP setup and usage manual
srdco Apr 23, 2026
e34c4a9
Clarify ODP manual scope and remove PDO troubleshooting
srdco Apr 23, 2026
0f79768
Refocus USPTO guide on end-user usage only
srdco Apr 23, 2026
9aa9000
Merge pull request #19 from srdco/codex/fix-error-when-creating-matte…
srdco Apr 23, 2026
c671652
Merge branch 'jjdejong:master' into master
srdco Apr 23, 2026
7fb0096
Default USPTO ODP endpoints and remove mandatory endpoint setup
srdco Apr 23, 2026
4540049
Merge branch 'master' into codex/fix-error-when-creating-matter-from-…
srdco Apr 23, 2026
f50ddcf
Merge pull request #20 from srdco/codex/fix-error-when-creating-matte…
srdco Apr 23, 2026
05b0f3a
Make login controller version-agnostic across Laravel releases
srdco Apr 24, 2026
0db3e67
Merge pull request #21 from srdco/codex/fix-login-error-in-logincontr…
srdco Apr 24, 2026
64b54fe
Handle duplicate matter-actor links when adding actors
srdco May 5, 2026
85fce5f
Merge pull request #22 from srdco/codex/fix-unique-constraint-violati…
srdco May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/Http/Controllers/ActorPivotController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +37 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This check introduces a "look-before-you-leap" pattern which is susceptible to race conditions. While it reduces the likelihood of duplicate entries, it doesn't prevent them if two requests occur simultaneously. To truly handle unique constraint violations gracefully as mentioned in the PR description, consider wrapping the eventual insertion logic in a try-catch block for UniqueConstraintViolationException or using atomic operations like firstOrCreate if the subsequent logic allows.


// Fix display order indexes if wrong
$roleGroup = ActorPivot::where('matter_id', $request->matter_id)->where('role', $request->role);
$max = $roleGroup->max('display_order');
Expand Down
128 changes: 110 additions & 18 deletions app/Http/Controllers/Auth/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -45,14 +34,117 @@ 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);
}
Comment on lines +54 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The manual implementation of the login logic lacks rate limiting, which was previously provided by the AuthenticatesUsers trait (via the ThrottlesLogins trait it uses). This leaves the login endpoint vulnerable to brute-force attacks. Since you are avoiding framework traits for stability, consider implementing rate limiting manually using Laravel's RateLimiter facade within the login method to maintain security parity.


$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
*/
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')],
]);
}
}
18 changes: 9 additions & 9 deletions app/Http/Controllers/MatterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -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) == ',') {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
80 changes: 80 additions & 0 deletions app/Services/FamilyDataService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Services;

/**
* Family data orchestrator with dynamic provider selection.
*
* Default behavior uses OPS for full family retrieval, then enriches US members
* from USPTO ODP when enabled/configured.
*/
class FamilyDataService
{
public function __construct(
private OPSService $opsService,
private USPTOService $usptoService
) {
}

/**
* Get family members with OPS primary source and USPTO fallback/enrichment.
*
* @param string $docnum
* @return array
*/
public function getFamilyMembers(string $docnum): array
{
$apps = $this->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'] ?? [],
];
}
}

26 changes: 20 additions & 6 deletions app/Services/OPSService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +168 to +174
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The EPO OPS API may return a single object instead of an array when there is only one inventor. In such cases, collect() will create a collection of the object's properties (keys and values), and the subsequent where() filter will fail to find the data because it expects a list of objects. Normalizing the data to a list using Arr::isList ensures the collection logic works correctly for both single and multiple inventors.

                $inventorData = $member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? [];
                $inventors = collect(Arr::isList($inventorData) ? $inventorData : [$inventorData])
                    ->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();
Comment on lines +180 to +186
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similar to the inventors collection, the applicants data can also be returned as a single object by the API. It should be normalized to a list before filtering to avoid issues with single-applicant patents.

                $applicantData = $member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? [];
                $applicants = collect(Arr::isList($applicantData) ? $applicantData : [$applicantData])
                    ->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)) {
Expand Down
Loading