Skip to content

Commit ec8e16b

Browse files
authored
Merge pull request #3497 from codeeu/dev
Added cli php user-update-email
2 parents 574891f + 3da2b6f commit ec8e16b

File tree

5 files changed

+256
-32
lines changed

5 files changed

+256
-32
lines changed

app/Console/Commands/Support/GmailAuthorizeCommand.php

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Console\Commands\Support;
44

5+
use App\Services\Support\Gmail\GmailOAuthConfig;
56
use Google\Client as GoogleClient;
67
use Google\Service\Gmail as GmailService;
78
use Illuminate\Console\Command;
@@ -18,22 +19,22 @@ class GmailAuthorizeCommand extends Command
1819

1920
public function handle(): int
2021
{
21-
$credentialsPath = (string) ($this->option('credentials') ?: config('support_gmail.credentials_json'));
22-
$tokenPath = (string) ($this->option('token') ?: config('support_gmail.token_json'));
23-
24-
if (!$credentialsPath || !is_file($credentialsPath)) {
25-
$this->error('OAuth credentials JSON not found. Set SUPPORT_GMAIL_CREDENTIALS_JSON or pass --credentials=');
26-
return self::FAILURE;
27-
}
28-
if (!$tokenPath) {
29-
$this->error('Token path not set. Set SUPPORT_GMAIL_TOKEN_JSON or pass --token=');
30-
return self::FAILURE;
31-
}
22+
$credentialsPath = $this->option('credentials');
23+
$tokenPath = $this->option('token') ?: config('support_gmail.token_json');
3224

3325
$client = new GoogleClient();
3426
$client->setApplicationName('Codeweek Internal Support Copilot');
3527
$client->setScopes([GmailService::GMAIL_READONLY]);
36-
$client->setAuthConfig($credentialsPath);
28+
if ($credentialsPath) {
29+
if (!is_file((string) $credentialsPath)) {
30+
$this->error('OAuth credentials file not found: '.$credentialsPath);
31+
32+
return self::FAILURE;
33+
}
34+
$client->setAuthConfig((string) $credentialsPath);
35+
} else {
36+
GmailOAuthConfig::applyClientSecrets($client);
37+
}
3738
$client->setAccessType('offline');
3839
$client->setPrompt('consent');
3940

@@ -62,12 +63,16 @@ public function handle(): int
6263
return self::FAILURE;
6364
}
6465

65-
// Ensure folder exists and write token json.
66-
File::ensureDirectoryExists(dirname($tokenPath));
67-
File::put($tokenPath, json_encode($token, JSON_PRETTY_PRINT));
68-
@chmod($tokenPath, 0600);
66+
if ($tokenPath) {
67+
File::ensureDirectoryExists(dirname((string) $tokenPath));
68+
File::put((string) $tokenPath, json_encode($token, JSON_PRETTY_PRINT));
69+
@chmod((string) $tokenPath, 0600);
70+
$this->info('Token saved to '.$tokenPath);
71+
} else {
72+
$this->warn('No SUPPORT_GMAIL_TOKEN_JSON / --token path — paste this JSON into Forge as SUPPORT_GMAIL_TOKEN:');
73+
$this->line(json_encode($token, JSON_PRETTY_PRINT));
74+
}
6975

70-
$this->info('Token saved to '.$tokenPath);
7176
$this->line('Next: set SUPPORT_GMAIL_ENABLED=true and run `php artisan support:gmail:poll`.');
7277

7378
return self::SUCCESS;
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<?php
2+
3+
namespace App\Console\Commands\Support;
4+
5+
use App\Models\Support\SupportCase;
6+
use App\Services\Support\SupportJson;
7+
use App\User;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Support\Facades\DB;
10+
11+
class UserUpdateEmailCommand extends Command
12+
{
13+
protected $signature = 'support:user-update-email {from} {to} {--dry-run} {--json}';
14+
15+
protected $description = 'Support tool: update a user email address (dry-run supported)';
16+
17+
public function handle(): int
18+
{
19+
$from = $this->normalizeEmail((string) $this->argument('from'));
20+
$to = $this->normalizeEmail((string) $this->argument('to'));
21+
$dryRun = (bool) $this->option('dry-run');
22+
23+
$input = [
24+
'from' => $from,
25+
'to' => $to,
26+
'dry_run' => $dryRun,
27+
];
28+
29+
$case = SupportCase::create([
30+
'source_channel' => 'manual',
31+
'processing_mode' => 'manual',
32+
'subject' => 'CLI: support:user-update-email',
33+
'raw_message' => 'CLI invocation',
34+
'normalized_message' => null,
35+
'status' => 'investigating',
36+
'risk_level' => 'high',
37+
'correlation_id' => SupportJson::correlationId(),
38+
]);
39+
40+
try {
41+
if (!$this->isValidEmail($from)) {
42+
throw new \InvalidArgumentException('Invalid FROM email.');
43+
}
44+
if (!$this->isValidEmail($to)) {
45+
throw new \InvalidArgumentException('Invalid TO email.');
46+
}
47+
if ($from === $to) {
48+
throw new \InvalidArgumentException('FROM and TO emails are identical.');
49+
}
50+
51+
/** @var \Illuminate\Database\Eloquent\Collection<int, User> $matches */
52+
$matches = User::withTrashed()
53+
->whereRaw('LOWER(email) = ?', [$from])
54+
->orWhereRaw('LOWER(email_display) = ?', [$from])
55+
->get();
56+
57+
if ($matches->count() === 0) {
58+
$payload = SupportJson::fail('user_update_email', $input, 'No user found for FROM email (email or email_display).');
59+
$this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES));
60+
return self::FAILURE;
61+
}
62+
63+
if ($matches->count() > 1) {
64+
$payload = SupportJson::fail('user_update_email', $input, [
65+
'Multiple users match FROM email; refusing to update.',
66+
'Matches: '.implode(', ', $matches->map(fn (User $u) => (string) $u->id)->all()),
67+
]);
68+
$this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES));
69+
return self::FAILURE;
70+
}
71+
72+
$user = $matches->first();
73+
if (!$user) {
74+
throw new \RuntimeException('Unexpected: missing matched user.');
75+
}
76+
77+
$conflict = User::withTrashed()
78+
->where('id', '<>', $user->id)
79+
->where(function ($q) use ($to) {
80+
$q->whereRaw('LOWER(email) = ?', [$to])
81+
->orWhereRaw('LOWER(email_display) = ?', [$to]);
82+
})
83+
->exists();
84+
85+
if ($conflict) {
86+
$payload = SupportJson::fail('user_update_email', $input, 'TO email already exists on another user (email or email_display).');
87+
$this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES));
88+
return self::FAILURE;
89+
}
90+
91+
$before = [
92+
'id' => $user->id,
93+
'email' => $user->email,
94+
'email_display' => $user->email_display,
95+
'deleted_at' => $user->deleted_at?->toISOString(),
96+
'email_verified_at' => optional($user->email_verified_at)->toISOString(),
97+
];
98+
99+
$wouldUpdateEmailDisplay = ($this->normalizeEmail((string) ($user->email_display ?? '')) === $from);
100+
101+
if (!$dryRun) {
102+
DB::transaction(function () use ($user, $to, $wouldUpdateEmailDisplay) {
103+
$user->email = $to;
104+
if ($wouldUpdateEmailDisplay) {
105+
$user->email_display = $to;
106+
}
107+
108+
// Email changed: require re-verification in case this is used for auth flows.
109+
if (property_exists($user, 'email_verified_at')) {
110+
$user->email_verified_at = null;
111+
}
112+
113+
$user->save();
114+
});
115+
116+
$user->refresh();
117+
}
118+
119+
$after = [
120+
'id' => $user->id,
121+
'email' => $dryRun ? $to : $user->email,
122+
'email_display' => $dryRun
123+
? ($wouldUpdateEmailDisplay ? $to : $user->email_display)
124+
: $user->email_display,
125+
'email_verified_at' => $dryRun ? null : optional($user->email_verified_at)->toISOString(),
126+
];
127+
128+
$result = [
129+
'support_case_id' => $case->id,
130+
'updated' => !$dryRun,
131+
'would_update_email_display' => $wouldUpdateEmailDisplay,
132+
'before' => $before,
133+
'after' => $after,
134+
];
135+
136+
$payload = SupportJson::ok('user_update_email', $input, $result);
137+
} catch (\Throwable $e) {
138+
$payload = SupportJson::fail('user_update_email', $input, $e->getMessage());
139+
}
140+
141+
$this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES));
142+
143+
return $payload['ok'] ? self::SUCCESS : self::FAILURE;
144+
}
145+
146+
private function normalizeEmail(string $email): string
147+
{
148+
return strtolower(trim($email));
149+
}
150+
151+
private function isValidEmail(string $email): bool
152+
{
153+
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
154+
}
155+
}
156+
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
namespace App\Services\Support\Gmail;
4+
5+
use Google\Client as GoogleClient;
6+
7+
/**
8+
* Loads OAuth client credentials and tokens from env (inline JSON) or disk paths.
9+
* Use SUPPORT_GMAIL_CREDENTIALS + SUPPORT_GMAIL_TOKEN on Forge so deploys do not rely on gitignored files.
10+
*/
11+
final class GmailOAuthConfig
12+
{
13+
public static function applyClientSecrets(GoogleClient $client): void
14+
{
15+
$inline = config('support_gmail.credentials');
16+
if (self::nonEmptyString($inline)) {
17+
$decoded = json_decode($inline, true);
18+
if (!is_array($decoded)) {
19+
throw new \RuntimeException('SUPPORT_GMAIL_CREDENTIALS must be valid JSON (Google OAuth client secret JSON).');
20+
}
21+
$client->setAuthConfig($decoded);
22+
23+
return;
24+
}
25+
26+
$path = config('support_gmail.credentials_json');
27+
if ($path && is_file($path)) {
28+
$client->setAuthConfig($path);
29+
30+
return;
31+
}
32+
33+
if ($path) {
34+
throw new \RuntimeException(
35+
'Gmail OAuth credentials file not found: '.$path.'. Set SUPPORT_GMAIL_CREDENTIALS (paste client JSON in env) or upload the file to that path.'
36+
);
37+
}
38+
39+
throw new \RuntimeException(
40+
'Gmail OAuth credentials missing. Set SUPPORT_GMAIL_CREDENTIALS (client JSON) or SUPPORT_GMAIL_CREDENTIALS_JSON (path to client JSON).'
41+
);
42+
}
43+
44+
public static function applyAccessToken(GoogleClient $client): void
45+
{
46+
$inline = config('support_gmail.token');
47+
if (self::nonEmptyString($inline)) {
48+
$decoded = json_decode($inline, true);
49+
if (!is_array($decoded)) {
50+
throw new \RuntimeException('SUPPORT_GMAIL_TOKEN must be valid JSON.');
51+
}
52+
$client->setAccessToken($decoded);
53+
54+
return;
55+
}
56+
57+
$tokenJson = config('support_gmail.token_json');
58+
if ($tokenJson && is_file($tokenJson)) {
59+
$client->setAccessToken(json_decode((string) file_get_contents($tokenJson), true));
60+
}
61+
}
62+
63+
private static function nonEmptyString(mixed $v): bool
64+
{
65+
return is_string($v) && trim($v) !== '';
66+
}
67+
}

app/Services/Support/Gmail/GoogleGmailConnector.php

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,8 @@ public function __construct()
2020
$client->setScopes([GmailService::GMAIL_READONLY]);
2121
$client->setAccessType('offline');
2222

23-
$credentials = config('support_gmail.credentials_json');
24-
if (!$credentials) {
25-
throw new \RuntimeException('SUPPORT_GMAIL_CREDENTIALS_JSON not set');
26-
}
27-
28-
$client->setAuthConfig($credentials);
29-
30-
// Optional OAuth token json (installed-app flows).
31-
$tokenJson = config('support_gmail.token_json');
32-
if ($tokenJson && is_file($tokenJson)) {
33-
$client->setAccessToken(json_decode((string) file_get_contents($tokenJson), true));
34-
}
23+
GmailOAuthConfig::applyClientSecrets($client);
24+
GmailOAuthConfig::applyAccessToken($client);
3525

3626
$this->client = $client;
3727
$this->gmail = new GmailService($client);
@@ -123,7 +113,7 @@ private function ensureValidToken(): void
123113
{
124114
$token = $this->client->getAccessToken();
125115
if (empty($token)) {
126-
throw new \RuntimeException('Gmail token missing. Run support:gmail:authorize and set SUPPORT_GMAIL_TOKEN_JSON.');
116+
throw new \RuntimeException('Gmail token missing. Run support:gmail:authorize and set SUPPORT_GMAIL_TOKEN or SUPPORT_GMAIL_TOKEN_JSON.');
127117
}
128118

129119
if (!$this->client->isAccessTokenExpired()) {

config/support_gmail.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,16 @@
1919
// Example: 'is:unread newer_than:7d -category:promotions'
2020
'query' => env('SUPPORT_GMAIL_QUERY', 'newer_than:7d'),
2121

22-
// Google service account or OAuth client credentials JSON path.
22+
// Google OAuth client JSON: paste full JSON from Google Cloud (preferred on Forge; survives deploys).
23+
'credentials' => env('SUPPORT_GMAIL_CREDENTIALS'),
24+
25+
// Alternative: path to the same JSON on disk (e.g. storage/app/google/support-gmail-credentials.json).
2326
'credentials_json' => env('SUPPORT_GMAIL_CREDENTIALS_JSON', null),
2427

25-
// Token JSON path for OAuth installed-app flows (if used).
28+
// OAuth token JSON: paste token from support:gmail:authorize (preferred on Forge).
29+
'token' => env('SUPPORT_GMAIL_TOKEN'),
30+
31+
// Alternative: path to token JSON (e.g. storage/app/google/support-gmail-token.json).
2632
'token_json' => env('SUPPORT_GMAIL_TOKEN_JSON', null),
2733

2834
// When true, mark ingested messages as read and/or apply a label.

0 commit comments

Comments
 (0)