Files
ghassanandClaude Opus 4.7 1cefc6ad2d feat(mail): make notification failures non-fatal
Add App\Support\SafeNotifier that wraps $notifiable->notify() in a
Throwable guard with a logged warning, so SMTP outages (the current
Gmail 535 errors) no longer propagate as a 500 to the user. Apply to
the two hot paths that were still unprotected: CommentController
(new comment, reply, like) and RegisteredUserController (super-admin
registration alert). User::sendEmailVerificationNotification() is
wrapped inline for the same reason.

Also add docs/mail-setup.md covering the three delivery options:
Gmail app password (quick), Mailgun, Postmark — with the exact .env
keys for each.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:35:41 +03:00

373 lines
11 KiB
PHP
Executable File

<?php
namespace App\Models;
use App\Notifications\VerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name',
'username',
'email',
'password',
'avatar',
'role',
'bio',
'website',
'twitter',
'instagram',
'facebook',
'youtube',
'linkedin',
'tiktok',
'birthday',
'location',
'gender',
'nationality',
'phone_code',
'phone_number',
'timezone',
'whatsapp',
'google_location',
'social_phone',
'social_email',
'two_factor_secret',
'two_factor_enabled',
'banner',
'notification_preferences',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array',
// TAKEONE account link. The token is a live credential for that
// platform, so it is encrypted at rest and never sent back to a browser.
'takeone_token' => 'encrypted',
'takeone_connected_at' => 'datetime',
];
/**
* Can this user run TAKEONE lookups?
*
* A super admin always can: the platform holds a TAKEONE super-admin token
* on their behalf, so they are connected with full reach without ever
* pasting anything. Everyone else links their own account, and sees only
* what that account may see.
*/
public function takeoneLinked(): bool
{
return $this->takeoneToken() !== null;
}
/** True when the connection is the platform's, not one this user pasted. */
public function takeoneManagedLink(): bool
{
$own = $this->takeone_token;
return ($own === null || $own === '')
&& $this->isSuperAdmin()
&& ! empty(config('services.takeone.admin_token'));
}
/**
* The token to talk to TAKEONE with. A personally linked account always
* wins, so a super admin who deliberately links their own identity is not
* silently upgraded back to the platform credential.
*/
public function takeoneToken(): ?string
{
$own = $this->takeone_token;
if ($own !== null && $own !== '') {
return $own;
}
if ($this->isSuperAdmin()) {
$platform = config('services.takeone.admin_token');
return ! empty($platform) ? $platform : null;
}
return null;
}
// Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [
'notif_new_comment' => true,
'notif_new_reply' => true,
'notif_comment_like' => true,
'notif_video_like' => true,
'notif_new_subscriber' => true,
'notif_new_video_from_sub' => true,
'notif_new_post_from_sub' => true,
'notif_new_user_reg' => true,
'email_new_comment' => true,
'email_new_reply' => true,
'email_comment_like' => false,
'email_video_like' => false,
'email_new_subscriber' => true,
'email_new_video_from_sub' => true,
'email_new_post_from_sub' => false,
'email_video_processed' => true,
'email_new_user_reg' => true,
'email_weekly_digest' => true,
];
public function notificationPref(string $key): bool
{
$prefs = $this->notification_preferences ?? [];
$default = self::NOTIF_DEFAULTS[$key] ?? true;
return isset($prefs[$key]) ? (bool) $prefs[$key] : $default;
}
public static function notifDefaults(): array
{
return self::NOTIF_DEFAULTS;
}
protected $appends = ['avatar_url', 'banner_url'];
// Auto-generate a unique slug-based username when creating a user without one
protected static function boot(): void
{
parent::boot();
static::creating(function (User $user) {
if (empty($user->username)) {
$user->username = static::generateUniqueUsername($user->name);
}
});
}
/**
* Resolve the most-frequent ISO2 country for each user id, aggregating
* video_views + profile_visits and falling back to users.nationality.
* Returns [user_id => 'BH', …]; keys without data are omitted.
*/
public static function topCountriesFor(array $userIds): array
{
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
if (! $userIds) return [];
$tally = [];
$rows = DB::table('video_views')
->selectRaw('user_id, country, COUNT(*) as c')
->whereIn('user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
if (Schema::hasTable('profile_visits')) {
$rows = DB::table('profile_visits')
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
->whereIn('visitor_user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('visitor_user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
}
$out = [];
foreach ($tally as $uid => $counts) {
arsort($counts);
$out[$uid] = array_key_first($counts);
}
// Fallback to users.nationality for anyone still unresolved.
$missing = array_values(array_diff($userIds, array_keys($out)));
if ($missing) {
$nats = static::whereIn('id', $missing)
->whereNotNull('nationality')->where('nationality', '!=', '')
->pluck('nationality', 'id');
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
}
return $out;
}
public static function generateUniqueUsername(string $name): string
{
$base = substr(Str::slug(trim($name)), 0, 18);
if ($base === '') {
$base = 'user';
}
do {
$slug = $base . '-' . Str::lower(Str::random(6));
} while (static::where('username', $slug)->exists());
return $slug;
}
// Returns the channel URL slug — the username, auto-generated and saved if missing
public function getChannelAttribute(): string
{
if (empty($this->username)) {
$this->username = static::generateUniqueUsername($this->name);
$this->saveQuietly();
}
return $this->username;
}
// Relationships
public function videos()
{
return $this->hasMany(Video::class);
}
public function likes()
{
return $this->belongsToMany(Video::class, 'video_likes')->withTimestamps();
}
public function views()
{
return $this->belongsToMany(Video::class, 'video_views')->withTimestamps();
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function playlists()
{
return $this->hasMany(Playlist::class);
}
public function posts()
{
return $this->hasMany(\App\Models\Post::class);
}
public function getAvatarUrlAttribute(): string
{
if ($this->avatar) {
// ?v busts the browser cache when the avatar is replaced. The path is
// always .../profile/avatar.<ext>, so without a version every page would
// keep showing the previously cached image after an upload.
return route('media.avatar', $this->avatar).'?v='.($this->updated_at?->timestamp ?? '0');
}
return asset('images/default-avatar.svg');
}
public function getBannerUrlAttribute(): ?string
{
if ($this->banner) {
return route('media.banner', $this->banner).'?v='.($this->updated_at?->timestamp ?? '0');
}
return null;
}
public function sendEmailVerificationNotification(): void
{
// Mail failures must never bubble up as a 500 to the user — the account
// exists, the HTTP request was fine, only the delivery pipe is broken.
// We log the failure (ops picks it up) and move on. Re-sending via
// `php artisan app:resend-verification` fixes the user once SMTP recovers.
try {
$this->notify(new VerifyEmail);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('verify-email send failed', [
'user_id' => $this->id,
'email' => $this->email,
'error' => $e->getMessage(),
]);
}
}
// Role helper methods
public function isSuperAdmin()
{
return $this->role === 'super_admin';
}
public function isAdmin()
{
return in_array($this->role, ['admin', 'super_admin']);
}
public function isUser()
{
return $this->role === 'user' || $this->role === null;
}
// Users who subscribe TO this channel
public function subscribers()
{
return $this->belongsToMany(
User::class,
'user_subscriptions',
'channel_id',
'subscriber_id'
)->withPivot(['created_at', 'source_video_id']);
}
// Channels this user subscribes to
public function subscriptions()
{
return $this->belongsToMany(
User::class,
'user_subscriptions',
'subscriber_id',
'channel_id'
)->withPivot(['created_at', 'source_video_id']);
}
public function isSubscribedTo(User $channel): bool
{
return $this->subscriptions()->where('channel_id', $channel->id)->exists();
}
public function getSubscriberCountAttribute(): int
{
return $this->subscribers()->count();
}
// Check if user has any social links
public function socialLinks()
{
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
}
public function hasSocialLinks()
{
return $this->socialLinks()->exists();
}
// Get formatted website URL
public function getWebsiteUrlAttribute()
{
if (! $this->website) {
return null;
}
// Add https:// if no protocol is specified
if (! preg_match('/^https?:\/\//', $this->website)) {
return 'https://'.$this->website;
}
return $this->website;
}
}