Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AppInstallation extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'hosted_site_id',
|
||||
'app_type',
|
||||
'app_version',
|
||||
'admin_path',
|
||||
'admin_username',
|
||||
'admin_email',
|
||||
'admin_password_encrypted',
|
||||
'database_name',
|
||||
'database_username',
|
||||
'database_password_encrypted',
|
||||
'install_url',
|
||||
'config',
|
||||
'status',
|
||||
'progress',
|
||||
'progress_message',
|
||||
'current_step',
|
||||
'total_steps',
|
||||
'error_message',
|
||||
'installed_at',
|
||||
'last_updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
'installed_at' => 'datetime',
|
||||
'last_updated_at' => 'datetime',
|
||||
'progress' => 'integer',
|
||||
'total_steps' => 'integer',
|
||||
];
|
||||
|
||||
public const APP_WORDPRESS = 'wordpress';
|
||||
public const APP_JOOMLA = 'joomla';
|
||||
public const APP_DRUPAL = 'drupal';
|
||||
public const APP_OPENCART = 'opencart';
|
||||
public const APP_MAGENTO = 'magento';
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostedSite::class, 'hosted_site_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
}
|
||||
|
||||
public function getAdminPasswordAttribute(): ?string
|
||||
{
|
||||
return $this->admin_password_encrypted ? decrypt($this->admin_password_encrypted) : null;
|
||||
}
|
||||
|
||||
public function getDatabasePasswordAttribute(): ?string
|
||||
{
|
||||
return $this->database_password_encrypted ? decrypt($this->database_password_encrypted) : null;
|
||||
}
|
||||
|
||||
public function getAdminUrlAttribute(): ?string
|
||||
{
|
||||
if (!$this->install_url || !$this->admin_path) {
|
||||
return null;
|
||||
}
|
||||
return rtrim($this->install_url, '/') . '/' . ltrim($this->admin_path, '/');
|
||||
}
|
||||
|
||||
public function updateProgress(int $step, int $totalSteps, string $message, ?string $currentStep = null): void
|
||||
{
|
||||
$progress = $totalSteps > 0 ? (int) round(($step / $totalSteps) * 100) : 0;
|
||||
|
||||
$this->update([
|
||||
'progress' => min($progress, 100),
|
||||
'progress_message' => $message,
|
||||
'current_step' => $currentStep,
|
||||
'total_steps' => $totalSteps,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markFailed(string $errorMessage): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => $errorMessage,
|
||||
'progress_message' => 'Installation failed',
|
||||
]);
|
||||
}
|
||||
|
||||
public function markCompleted(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'active',
|
||||
'progress' => 100,
|
||||
'progress_message' => 'Installation complete',
|
||||
'current_step' => null,
|
||||
'installed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isInstalling(): bool
|
||||
{
|
||||
return $this->status === 'installing';
|
||||
}
|
||||
|
||||
public function hasFailed(): bool
|
||||
{
|
||||
return $this->status === 'failed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ContaboInfrastructureProvision extends Model
|
||||
{
|
||||
public const PURPOSE_HOSTING_NODE = 'hosting_node';
|
||||
|
||||
public const PURPOSE_EMAIL_PRIMARY = 'email_primary';
|
||||
|
||||
public const PURPOSE_EMAIL_EXTENSION = 'email_extension';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_CREATING = 'creating';
|
||||
|
||||
public const STATUS_WAITING_IP = 'waiting_ip';
|
||||
|
||||
public const STATUS_BOOTSTRAPPING = 'bootstrapping';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'purpose',
|
||||
'status',
|
||||
'name',
|
||||
'contabo_product_id',
|
||||
'region',
|
||||
'image_id',
|
||||
'contabo_instance_id',
|
||||
'completion_token',
|
||||
'payload',
|
||||
'log',
|
||||
'ssh_public_key',
|
||||
'ssh_private_key',
|
||||
'generated_mail_db_password',
|
||||
'hosting_node_id',
|
||||
'email_server_id',
|
||||
'created_by',
|
||||
'error_message',
|
||||
'instance_created_at',
|
||||
'ip_assigned_at',
|
||||
'completed_at',
|
||||
'failed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'log' => 'array',
|
||||
'instance_created_at' => 'datetime',
|
||||
'ip_assigned_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'ssh_private_key',
|
||||
'generated_mail_db_password',
|
||||
'completion_token',
|
||||
];
|
||||
|
||||
public function hostingNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class);
|
||||
}
|
||||
|
||||
public function emailServer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailServer::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function completionUrl(): string
|
||||
{
|
||||
return url('/api/infrastructure/contabo/callback/'.$this->id.'?token='.$this->completion_token);
|
||||
}
|
||||
|
||||
public function purposeLabel(): string
|
||||
{
|
||||
return match ($this->purpose) {
|
||||
self::PURPOSE_HOSTING_NODE => 'Hosting node',
|
||||
self::PURPOSE_EMAIL_PRIMARY => 'Mail server (primary)',
|
||||
self::PURPOSE_EMAIL_EXTENSION => 'Mail server (extension)',
|
||||
default => $this->purpose,
|
||||
};
|
||||
}
|
||||
|
||||
public function addLog(string $message): void
|
||||
{
|
||||
$log = $this->log ?? [];
|
||||
$log[] = ['at' => now()->toIso8601String(), 'message' => $message];
|
||||
$this->update(['log' => $log]);
|
||||
}
|
||||
|
||||
public function markFailed(string $message): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'error_message' => $message,
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
$this->addLog('Failed: '.$message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CustomerHostingOrder extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
public const STATUS_PENDING_APPROVAL = 'pending_approval';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const CYCLE_MONTHLY = 'monthly';
|
||||
public const CYCLE_QUARTERLY = 'quarterly';
|
||||
public const CYCLE_SEMIANNUAL = 'semiannual';
|
||||
public const CYCLE_YEARLY = 'yearly';
|
||||
public const CYCLE_BIENNIAL = 'biennial';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'guest_email',
|
||||
'hosting_product_id',
|
||||
'domain_id',
|
||||
'domain_name',
|
||||
'billing_cycle',
|
||||
'amount_paid',
|
||||
'currency',
|
||||
'payment_reference',
|
||||
'status',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'approval_notes',
|
||||
'hosting_node_id',
|
||||
'hosting_account_id',
|
||||
'vps_instance_id',
|
||||
'server_username',
|
||||
'server_ip',
|
||||
'control_panel_url',
|
||||
'provisioned_at',
|
||||
'expires_at',
|
||||
'suspended_at',
|
||||
'cancelled_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_paid' => 'decimal:2',
|
||||
'approved_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'suspended_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withDefault();
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'hosting_product_id');
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function hostingNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class);
|
||||
}
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function vpsInstance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(VpsInstance::class);
|
||||
}
|
||||
|
||||
public function provisioningQueue(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProvisioningQueueItem::class);
|
||||
}
|
||||
|
||||
public function serverAgent(): HasOne
|
||||
{
|
||||
return $this->hasOne(ServerAgent::class);
|
||||
}
|
||||
|
||||
public function serverTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerTask::class);
|
||||
}
|
||||
|
||||
public function serverSites(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSite::class);
|
||||
}
|
||||
|
||||
public function serverDatabases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class);
|
||||
}
|
||||
|
||||
public function serverFiles(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerFile::class);
|
||||
}
|
||||
|
||||
public function scopePendingApproval($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING_APPROVAL);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function isPendingApproval(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING_APPROVAL;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isProvisioning(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROVISIONING;
|
||||
}
|
||||
|
||||
public function canBeApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING_APPROVAL;
|
||||
}
|
||||
|
||||
public function canBeCancelled(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_PENDING_PAYMENT,
|
||||
self::STATUS_PENDING_APPROVAL,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_ACTIVE,
|
||||
self::STATUS_SUSPENDED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(User $admin, ?string $notes = null): bool
|
||||
{
|
||||
if (!$this->canBeApproved()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_APPROVED,
|
||||
'approved_by' => $admin->id,
|
||||
'approved_at' => now(),
|
||||
'approval_notes' => $notes,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function markAsProvisioning(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_PROVISIONING]);
|
||||
}
|
||||
|
||||
public function markAsActive(array $provisioningDetails = []): void
|
||||
{
|
||||
$this->update(array_merge([
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'provisioned_at' => now(),
|
||||
], $provisioningDetails));
|
||||
}
|
||||
|
||||
public function markAsFailed(?string $reason = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['failure_reason'] = $reason;
|
||||
$meta['failed_at'] = now()->toIso8601String();
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a paid order to the admin queue after upstream provisioning could not complete
|
||||
* (e.g. Contabo account balance). Customer still sees a generic pending state.
|
||||
*/
|
||||
public function holdForAdminProvisioning(string $reason, ?int $httpStatus = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['provisioning_hold'] = [
|
||||
'reason' => $reason,
|
||||
'http_status' => $httpStatus,
|
||||
'at' => now()->toIso8601String(),
|
||||
];
|
||||
unset($meta['failure_reason'], $meta['failed_at']);
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_PENDING_APPROVAL,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
public function suspend(?string $reason = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['suspension_reason'] = $reason;
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_SUSPENDED,
|
||||
'suspended_at' => now(),
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(?string $reason = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['cancellation_reason'] = $reason;
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_CANCELLED,
|
||||
'cancelled_at' => now(),
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCycleLengthInMonths(): int
|
||||
{
|
||||
return match ($this->billing_cycle) {
|
||||
self::CYCLE_MONTHLY => 1,
|
||||
self::CYCLE_QUARTERLY => 3,
|
||||
self::CYCLE_SEMIANNUAL => 6,
|
||||
self::CYCLE_YEARLY => 12,
|
||||
self::CYCLE_BIENNIAL => 24,
|
||||
default => 12,
|
||||
};
|
||||
}
|
||||
|
||||
public function calculateExpiryDate(): \Carbon\Carbon
|
||||
{
|
||||
$startDate = $this->provisioned_at ?? now();
|
||||
return $startDate->copy()->addMonths($this->getCycleLengthInMonths());
|
||||
}
|
||||
|
||||
public function customerFacingStatusLabel(): string
|
||||
{
|
||||
return self::customerFacingStatusLabelFor($this->status);
|
||||
}
|
||||
|
||||
public static function customerFacingStatusLabelFor(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::STATUS_PENDING_PAYMENT => 'Pending Payment',
|
||||
self::STATUS_PENDING_APPROVAL,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_PROVISIONING => 'Pending',
|
||||
self::STATUS_ACTIVE => 'Active',
|
||||
self::STATUS_SUSPENDED => 'Suspended',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
self::STATUS_EXPIRED => 'Expired',
|
||||
self::STATUS_FAILED => 'Failed',
|
||||
default => 'Pending',
|
||||
};
|
||||
}
|
||||
|
||||
public function isCustomerPending(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_PENDING_APPROVAL,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_PROVISIONING,
|
||||
], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Domain extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const MODE_NS_AUTO = 'ns_auto';
|
||||
public const MODE_MANUAL_DNS = 'manual_dns';
|
||||
public const MODE_RESELLER_AUTO = 'reseller_auto';
|
||||
|
||||
public const STATE_PENDING_NS = 'pending_ns';
|
||||
public const STATE_VERIFYING_NS = 'verifying_ns';
|
||||
public const STATE_CONNECTED = 'connected';
|
||||
public const STATE_MAIL_READY = 'mail_ready';
|
||||
public const STATE_ACTIVE = 'active';
|
||||
public const STATE_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'website_id',
|
||||
'hosting_account_id',
|
||||
'email_domain_id',
|
||||
'email_server_id',
|
||||
'host',
|
||||
'type',
|
||||
'source',
|
||||
'onboarding_mode',
|
||||
'verification_token',
|
||||
'verified_at',
|
||||
'status',
|
||||
'onboarding_state',
|
||||
'dns_mode',
|
||||
'ns_expected',
|
||||
'ns_observed',
|
||||
'ns_checked_at',
|
||||
'connected_at',
|
||||
'mail_ready_at',
|
||||
'active_at',
|
||||
'manual_dns_verified_at',
|
||||
'verification_meta',
|
||||
'ssl_status',
|
||||
'ssl_provisioned_at',
|
||||
'ssl_expires_at',
|
||||
'registrar',
|
||||
'registrar_order_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'verified_at' => 'datetime',
|
||||
'ns_expected' => 'array',
|
||||
'ns_observed' => 'array',
|
||||
'ns_checked_at' => 'datetime',
|
||||
'connected_at' => 'datetime',
|
||||
'mail_ready_at' => 'datetime',
|
||||
'active_at' => 'datetime',
|
||||
'manual_dns_verified_at' => 'datetime',
|
||||
'verification_meta' => 'array',
|
||||
'ssl_provisioned_at' => 'datetime',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function emailDomain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailDomain::class);
|
||||
}
|
||||
|
||||
public function emailServer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailServer::class);
|
||||
}
|
||||
|
||||
public function dnsRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(DomainDnsRecord::class);
|
||||
}
|
||||
|
||||
public function dkimKeys(): HasMany
|
||||
{
|
||||
return $this->hasMany(DomainDkimKey::class);
|
||||
}
|
||||
|
||||
public function nsChecks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DomainNsCheck::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class);
|
||||
}
|
||||
|
||||
public function hostedSites(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedSite::class);
|
||||
}
|
||||
|
||||
public function isActiveForMail(): bool
|
||||
{
|
||||
return (string) $this->status === 'verified'
|
||||
&& (string) $this->onboarding_state === self::STATE_ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DomainDkimKey extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'domain_id',
|
||||
'selector',
|
||||
'public_key_txt',
|
||||
'private_key_path',
|
||||
'status',
|
||||
'generated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'generated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DomainDnsRecord extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'domain_id',
|
||||
'name',
|
||||
'type',
|
||||
'value',
|
||||
'ttl',
|
||||
'priority',
|
||||
'source',
|
||||
'status',
|
||||
'last_checked_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ttl' => 'integer',
|
||||
'priority' => 'integer',
|
||||
'last_checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DomainNsCheck extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'domain_id',
|
||||
'check_type',
|
||||
'result',
|
||||
'expected_nameservers',
|
||||
'observed_nameservers',
|
||||
'has_authoritative_answer',
|
||||
'manual_records_total',
|
||||
'manual_records_verified',
|
||||
'status_before',
|
||||
'status_after',
|
||||
'state_before',
|
||||
'state_after',
|
||||
'notes',
|
||||
'checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expected_nameservers' => 'array',
|
||||
'observed_nameservers' => 'array',
|
||||
'has_authoritative_answer' => 'boolean',
|
||||
'manual_records_total' => 'integer',
|
||||
'manual_records_verified' => 'integer',
|
||||
'checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class DomainOrder extends Model
|
||||
{
|
||||
public const TYPE_REGISTRATION = 'registration';
|
||||
public const TYPE_TRANSFER = 'transfer';
|
||||
public const TYPE_RENEWAL = 'renewal';
|
||||
|
||||
public const REGISTRAR_DYNADOT = 'dynadot';
|
||||
public const REGISTRAR_RESELLERCLUB = 'resellerclub';
|
||||
|
||||
public const STATUS_CART = 'cart';
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PENDING_CUSTOMER_REGISTRATION = 'pending_customer_registration';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const PAYMENT_UNPAID = 'unpaid';
|
||||
public const PAYMENT_PENDING = 'pending';
|
||||
public const PAYMENT_PAID = 'paid';
|
||||
public const PAYMENT_FAILED = 'failed';
|
||||
public const PAYMENT_REFUNDED = 'refunded';
|
||||
|
||||
public const FULFILLMENT_CART = 'cart';
|
||||
public const FULFILLMENT_PAYMENT_PENDING = 'payment_pending';
|
||||
public const FULFILLMENT_PAYMENT_RECEIVED = 'payment_received';
|
||||
public const FULFILLMENT_SUBMITTING = 'submitting';
|
||||
public const FULFILLMENT_AWAITING_REGISTRAR = 'awaiting_registrar';
|
||||
public const FULFILLMENT_FULFILLED = 'fulfilled';
|
||||
public const FULFILLMENT_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'domain_name',
|
||||
'order_type',
|
||||
'registrar',
|
||||
'status',
|
||||
'payment_status',
|
||||
'fulfillment_status',
|
||||
'amount_minor',
|
||||
'currency',
|
||||
'years',
|
||||
'payment_reference',
|
||||
'auth_code_encrypted',
|
||||
'contact_info',
|
||||
'nameservers',
|
||||
'registrar_order_id',
|
||||
'meta',
|
||||
'paid_at',
|
||||
'submitted_at',
|
||||
'completed_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_minor' => 'integer',
|
||||
'years' => 'integer',
|
||||
'contact_info' => 'array',
|
||||
'nameservers' => 'array',
|
||||
'meta' => 'array',
|
||||
'paid_at' => 'datetime',
|
||||
'submitted_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function setAuthCode(string $authCode): void
|
||||
{
|
||||
$this->auth_code_encrypted = Crypt::encryptString($authCode);
|
||||
}
|
||||
|
||||
public function getAuthCode(): ?string
|
||||
{
|
||||
if (empty($this->auth_code_encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->auth_code_encrypted);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getTld(): string
|
||||
{
|
||||
$parts = explode('.', $this->domain_name, 2);
|
||||
return $parts[1] ?? '';
|
||||
}
|
||||
|
||||
public function formatAmount(): string
|
||||
{
|
||||
$amount = $this->amount_minor / 100;
|
||||
$symbol = $this->currency === 'GHS' ? 'GH₵' : $this->currency;
|
||||
return $symbol . number_format($amount, 2);
|
||||
}
|
||||
|
||||
public function getOrderTypeLabel(): string
|
||||
{
|
||||
return match ($this->order_type) {
|
||||
self::TYPE_REGISTRATION => 'Registration',
|
||||
self::TYPE_TRANSFER => 'Transfer',
|
||||
self::TYPE_RENEWAL => 'Renewal',
|
||||
default => ucfirst($this->order_type),
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusLabel(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_CART => 'In Cart',
|
||||
self::STATUS_PENDING_PAYMENT => 'Awaiting Payment',
|
||||
self::STATUS_PENDING => 'Pending',
|
||||
self::STATUS_PENDING_CUSTOMER_REGISTRATION => 'Awaiting Registration',
|
||||
self::STATUS_PROCESSING => 'Processing',
|
||||
self::STATUS_COMPLETED => 'Completed',
|
||||
self::STATUS_FAILED => 'Failed',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
default => ucfirst($this->status),
|
||||
};
|
||||
}
|
||||
|
||||
public function isInCart(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]);
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->payment_status === self::PAYMENT_PAID;
|
||||
}
|
||||
|
||||
public function scopeInCart($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, User $user)
|
||||
{
|
||||
return $query->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
public function scopePendingFulfillment($query)
|
||||
{
|
||||
return $query->where('payment_status', self::PAYMENT_PAID)
|
||||
->whereIn('fulfillment_status', [
|
||||
self::FULFILLMENT_PAYMENT_RECEIVED,
|
||||
self::FULFILLMENT_SUBMITTING,
|
||||
self::FULFILLMENT_AWAITING_REGISTRAR,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Domain pricing overrides.
|
||||
*
|
||||
* When a TLD has an entry here with is_override=true, the manually set GHS price
|
||||
* will be used instead of the live-converted Dynadot price.
|
||||
*
|
||||
* Prices are stored in pesewas (minor units).
|
||||
*/
|
||||
class DomainPricing extends Model
|
||||
{
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $columnExists = [];
|
||||
|
||||
protected $fillable = [
|
||||
'tld',
|
||||
'register_price',
|
||||
'renew_price',
|
||||
'transfer_price',
|
||||
'currency',
|
||||
'is_override',
|
||||
'is_featured',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'register_price' => 'integer',
|
||||
'renew_price' => 'integer',
|
||||
'transfer_price' => 'integer',
|
||||
'is_override' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get active pricing override for a TLD.
|
||||
*/
|
||||
public static function forTld(string $tld): ?self
|
||||
{
|
||||
$tld = ltrim(strtolower(trim($tld)), '.');
|
||||
|
||||
return Cache::remember(
|
||||
"domain_pricing:{$tld}",
|
||||
now()->addHours(1),
|
||||
function () use ($tld) {
|
||||
$query = static::where('tld', $tld);
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active price overrides.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int, is_override: bool}>
|
||||
*/
|
||||
public static function getOverrides(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
'domain_pricing:overrides',
|
||||
now()->addHours(1),
|
||||
function () {
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
// Older installs may have used this table only for manual overrides.
|
||||
if (static::hasColumn('is_override')) {
|
||||
$query->where('is_override', true);
|
||||
}
|
||||
|
||||
return $query->get()
|
||||
->keyBy('tld')
|
||||
->map(fn ($p) => [
|
||||
'register' => $p->register_price,
|
||||
'renew' => $p->renew_price,
|
||||
'transfer' => $p->transfer_price,
|
||||
'is_override' => static::hasColumn('is_override') ? (bool) $p->is_override : true,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active TLDs with pricing.
|
||||
*/
|
||||
public static function activeTlds(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
'domain_pricing:active_tlds',
|
||||
now()->addHours(1),
|
||||
function () {
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (static::hasColumn('sort_order')) {
|
||||
$query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
return $query->orderBy('tld')->pluck('tld')->all();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured TLDs for search.
|
||||
*/
|
||||
public static function featuredTlds(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
'domain_pricing:featured_tlds',
|
||||
now()->addHours(1),
|
||||
function () {
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (static::hasColumn('is_featured')) {
|
||||
$query->where('is_featured', true);
|
||||
}
|
||||
|
||||
if (static::hasColumn('sort_order')) {
|
||||
$query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
$tlds = $query->orderBy('tld')->pluck('tld')->all();
|
||||
|
||||
return $tlds !== [] ? $tlds : static::activeTlds();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing map for multiple TLDs.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
|
||||
*/
|
||||
public static function pricingMap(array $tlds = []): array
|
||||
{
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (! empty($tlds)) {
|
||||
$query->whereIn('tld', array_map(fn ($t) => ltrim(strtolower($t), '.'), $tlds));
|
||||
}
|
||||
|
||||
return $query->get()->keyBy('tld')->map(fn ($p) => [
|
||||
'register' => $p->register_price,
|
||||
'renew' => $p->renew_price,
|
||||
'transfer' => $p->transfer_price,
|
||||
'currency' => $p->currency,
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pricing cache.
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
Cache::forget('domain_pricing:active_tlds');
|
||||
Cache::forget('domain_pricing:featured_tlds');
|
||||
Cache::forget('domain_pricing:overrides');
|
||||
|
||||
foreach (static::pluck('tld') as $tld) {
|
||||
Cache::forget("domain_pricing:{$tld}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for display.
|
||||
*/
|
||||
public function formatRegisterPrice(): string
|
||||
{
|
||||
return $this->formatPrice($this->register_price);
|
||||
}
|
||||
|
||||
public function formatRenewPrice(): string
|
||||
{
|
||||
return $this->formatPrice($this->renew_price);
|
||||
}
|
||||
|
||||
public function formatTransferPrice(): string
|
||||
{
|
||||
return $this->formatPrice($this->transfer_price);
|
||||
}
|
||||
|
||||
private function formatPrice(int $amountMinor): string
|
||||
{
|
||||
$symbol = $this->currency === 'GHS' ? 'GH₵' : $this->currency;
|
||||
return $symbol . ' ' . number_format($amountMinor / 100, 2);
|
||||
}
|
||||
|
||||
private static function hasColumn(string $column): bool
|
||||
{
|
||||
$cacheKey = static::class . ':' . $column;
|
||||
|
||||
if (! array_key_exists($cacheKey, self::$columnExists)) {
|
||||
self::$columnExists[$cacheKey] = Schema::hasColumn((new static)->getTable(), $column);
|
||||
}
|
||||
|
||||
return self::$columnExists[$cacheKey];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class EmailDomain extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_VERIFYING = 'verifying';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
public const METHOD_NAMESERVER = 'nameserver';
|
||||
public const METHOD_DNS_RECORD = 'dns_record';
|
||||
|
||||
public const FREE_MAILBOXES_LIMIT = 5;
|
||||
public const PAID_MAILBOX_PRICE_CEDIS = 5;
|
||||
public const SUBSCRIPTION_MONTHLY_PRICE_CEDIS = 5;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'email_server_id',
|
||||
'domain',
|
||||
'status',
|
||||
'verification_method',
|
||||
'verification_token',
|
||||
'ns_expected',
|
||||
'ns_observed',
|
||||
'ns_checked_at',
|
||||
'verified_at',
|
||||
'mail_ready_at',
|
||||
'free_mailboxes_limit',
|
||||
'paid_mailboxes_count',
|
||||
'dns_records',
|
||||
'dns_verification_status',
|
||||
'dns_last_checked_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ns_expected' => 'array',
|
||||
'ns_observed' => 'array',
|
||||
'ns_checked_at' => 'datetime',
|
||||
'verified_at' => 'datetime',
|
||||
'mail_ready_at' => 'datetime',
|
||||
'dns_records' => 'array',
|
||||
'dns_verification_status' => 'array',
|
||||
'dns_last_checked_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function emailServer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailServer::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class, 'email_domain_id');
|
||||
}
|
||||
|
||||
public function dkimKeys(): HasMany
|
||||
{
|
||||
return $this->hasMany(EmailDomainDkimKey::class);
|
||||
}
|
||||
|
||||
public function activeDkimKey(): HasOne
|
||||
{
|
||||
return $this->hasOne(EmailDomainDkimKey::class)->where('status', 'deployed')->latest();
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isVerifying(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VERIFYING;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isSuspended(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUSPENDED;
|
||||
}
|
||||
|
||||
public function isReadyForMail(): bool
|
||||
{
|
||||
// Domain is ready for mail if it's active (verified)
|
||||
// mail_ready_at is set when infrastructure is provisioned, but we allow
|
||||
// mailbox creation as soon as domain is verified
|
||||
return $this->isActive();
|
||||
}
|
||||
|
||||
public function usesNameserverVerification(): bool
|
||||
{
|
||||
return $this->verification_method === self::METHOD_NAMESERVER;
|
||||
}
|
||||
|
||||
public function usesDnsRecordVerification(): bool
|
||||
{
|
||||
return $this->verification_method === self::METHOD_DNS_RECORD;
|
||||
}
|
||||
|
||||
public function getMailboxCountAttribute(): int
|
||||
{
|
||||
return $this->mailboxes()->count();
|
||||
}
|
||||
|
||||
public function getFreeMailboxesLimitAttribute($value): int
|
||||
{
|
||||
$base = $value ?? self::FREE_MAILBOXES_LIMIT;
|
||||
|
||||
// Check if user has hosting accounts with higher email quotas
|
||||
$hostingAllowance = $this->getHostingEmailAllowance();
|
||||
|
||||
return max($base, $hostingAllowance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the email allowance from the user's hosting accounts for this domain.
|
||||
*/
|
||||
public function getHostingEmailAllowance(): int
|
||||
{
|
||||
$hostingAccount = HostingAccount::query()
|
||||
->where('user_id', $this->user_id)
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->whereHas('sites', fn ($q) => $q->where('domain', $this->domain))
|
||||
->with('product')
|
||||
->first();
|
||||
|
||||
if (! $hostingAccount) {
|
||||
// Also check primary_domain
|
||||
$hostingAccount = HostingAccount::query()
|
||||
->where('user_id', $this->user_id)
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->where('primary_domain', $this->domain)
|
||||
->with('product')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $hostingAccount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $hostingAccount->freeEmailAllowance() ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-record DNS verification results.
|
||||
*/
|
||||
public function getDnsRecordStatuses(): array
|
||||
{
|
||||
return $this->dns_verification_status ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an authentication record (spf|dkim|dmarc) should display as
|
||||
* verified. The per-record dns_verification_status map is only populated by
|
||||
* the DNS-record verification path; nameserver-managed / Ladill-owned domains
|
||||
* (we publish their DNS) leave it empty even when fully configured. So an
|
||||
* active domain necessarily has SPF + DKIM in place (required to activate),
|
||||
* and DMARC is published for nameserver-managed domains.
|
||||
*/
|
||||
public function authVerified(string $key): bool
|
||||
{
|
||||
if (($this->getDnsRecordStatuses()[$key]['status'] ?? null) === 'verified') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($key) {
|
||||
'spf', 'dkim' => $this->isActive(),
|
||||
'dmarc' => $this->isActive() && $this->usesNameserverVerification(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required DNS records are verified.
|
||||
*/
|
||||
public function allDnsRecordsVerified(): bool
|
||||
{
|
||||
$statuses = $this->getDnsRecordStatuses();
|
||||
|
||||
if (empty($statuses)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All required records must be verified
|
||||
foreach ($statuses as $record) {
|
||||
if (($record['required'] ?? true) && ($record['status'] ?? 'pending') !== 'verified') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count verified and total required DNS records.
|
||||
*/
|
||||
public function dnsVerificationProgress(): array
|
||||
{
|
||||
$statuses = $this->getDnsRecordStatuses();
|
||||
$required = collect($statuses)->filter(fn ($r) => $r['required'] ?? true);
|
||||
|
||||
return [
|
||||
'total' => $required->count(),
|
||||
'verified' => $required->where('status', 'verified')->count(),
|
||||
'pending' => $required->where('status', '!=', 'verified')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getFreeMailboxesUsedAttribute(): int
|
||||
{
|
||||
return $this->mailboxes()->where('is_paid', false)->count();
|
||||
}
|
||||
|
||||
public function getPaidMailboxesUsedAttribute(): int
|
||||
{
|
||||
return $this->mailboxes()->where('is_paid', true)->count();
|
||||
}
|
||||
|
||||
public function getFreeMailboxesRemainingAttribute(): int
|
||||
{
|
||||
return max(0, $this->free_mailboxes_limit - $this->free_mailboxes_used);
|
||||
}
|
||||
|
||||
public function canCreateFreeMailbox(): bool
|
||||
{
|
||||
return $this->free_mailboxes_remaining > 0;
|
||||
}
|
||||
|
||||
public function requiresPaymentForNextMailbox(): bool
|
||||
{
|
||||
return !$this->canCreateFreeMailbox();
|
||||
}
|
||||
|
||||
public function monthlySubscriptionPrice(): float
|
||||
{
|
||||
return self::SUBSCRIPTION_MONTHLY_PRICE_CEDIS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class EmailServer extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const ROLE_PRIMARY = 'primary';
|
||||
|
||||
public const ROLE_EXTENSION = 'extension';
|
||||
|
||||
public const PROVIDER_LOCAL = 'local';
|
||||
|
||||
public const PROVIDER_CONTABO = 'contabo';
|
||||
|
||||
public const PROVIDER_MANUAL = 'manual';
|
||||
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_MAINTENANCE = 'maintenance';
|
||||
|
||||
public const STATUS_FULL = 'full';
|
||||
|
||||
public const STATUS_DECOMMISSIONED = 'decommissioned';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'hostname',
|
||||
'ip_address',
|
||||
'ipv6_address',
|
||||
'mail_host',
|
||||
'role',
|
||||
'primary_email_server_id',
|
||||
'pool_vmail_root',
|
||||
'provider',
|
||||
'provider_instance_id',
|
||||
'region',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'disk_gb',
|
||||
'max_domains',
|
||||
'max_mailboxes',
|
||||
'current_domains',
|
||||
'current_mailboxes',
|
||||
'allocated_storage_mb',
|
||||
'used_storage_mb',
|
||||
'last_usage_sync_at',
|
||||
'db_host',
|
||||
'db_port',
|
||||
'db_database',
|
||||
'db_username',
|
||||
'db_password',
|
||||
'ssh_host',
|
||||
'ssh_user',
|
||||
'ssh_port',
|
||||
'ssh_private_key',
|
||||
'status',
|
||||
'is_default',
|
||||
'last_health_check_at',
|
||||
'health_status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'health_status' => 'array',
|
||||
'last_health_check_at' => 'datetime',
|
||||
'last_usage_sync_at' => 'datetime',
|
||||
'is_default' => 'boolean',
|
||||
'db_port' => 'integer',
|
||||
'ssh_port' => 'integer',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'db_password',
|
||||
'ssh_private_key',
|
||||
];
|
||||
|
||||
public function primary(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'primary_email_server_id');
|
||||
}
|
||||
|
||||
public function extensions(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'primary_email_server_id');
|
||||
}
|
||||
|
||||
public function emailDomains(): HasMany
|
||||
{
|
||||
return $this->hasMany(EmailDomain::class);
|
||||
}
|
||||
|
||||
public function domains(): HasMany
|
||||
{
|
||||
return $this->hasMany(Domain::class);
|
||||
}
|
||||
|
||||
public function capacityAlerts(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailServerCapacityAlert::class);
|
||||
}
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_PRIMARY;
|
||||
}
|
||||
|
||||
public function isExtension(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_EXTENSION;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function poolRoot(): self
|
||||
{
|
||||
return $this->isExtension() && $this->primary
|
||||
? $this->primary
|
||||
: $this;
|
||||
}
|
||||
|
||||
public function mailDatabaseServer(): self
|
||||
{
|
||||
return $this->poolRoot();
|
||||
}
|
||||
|
||||
public function scopePrimaries(Builder $query): Builder
|
||||
{
|
||||
return $query->where('role', self::ROLE_PRIMARY);
|
||||
}
|
||||
|
||||
public function scopeAvailable(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Per-account email preferences (defaults applied to new mailboxes). */
|
||||
class EmailSetting extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'default_quota_mb', 'notify_email', 'product_updates'];
|
||||
|
||||
protected $casts = ['product_updates' => 'boolean'];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** Membership linking a user to an account (owner) they manage email for. */
|
||||
class EmailTeamMember extends Model
|
||||
{
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_MEMBER = 'member';
|
||||
|
||||
public const STATUS_INVITED = 'invited';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
|
||||
|
||||
protected $casts = ['accepted_at' => 'datetime'];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'account_id');
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link any pending invites for this email to the user (called on SSO login),
|
||||
* so invited teammates gain access the first time they sign in.
|
||||
*/
|
||||
public static function linkPendingInvitesFor(User $user): void
|
||||
{
|
||||
static::query()
|
||||
->whereNull('user_id')
|
||||
->where('status', self::STATUS_INVITED)
|
||||
->whereRaw('LOWER(email) = ?', [strtolower($user->email)])
|
||||
->update([
|
||||
'user_id' => $user->id,
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'accepted_at' => now(),
|
||||
'token' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostedDatabase extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id', 'hosted_site_id', 'name', 'username',
|
||||
'password_encrypted', 'type', 'size_bytes', 'status',
|
||||
];
|
||||
|
||||
protected $casts = ['size_bytes' => 'integer'];
|
||||
protected $hidden = ['password_encrypted'];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostedSite::class, 'hosted_site_id');
|
||||
}
|
||||
|
||||
public function getHostAttribute(): string
|
||||
{
|
||||
return 'localhost';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostedSite extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id',
|
||||
'domain_id',
|
||||
'domain',
|
||||
'document_root',
|
||||
'type',
|
||||
'status',
|
||||
'php_version',
|
||||
'ssl_enabled',
|
||||
'ssl_status',
|
||||
'ssl_error',
|
||||
'ssl_expires_at',
|
||||
'ssl_provisioned_at',
|
||||
'installed_app',
|
||||
'installed_app_version',
|
||||
'app_config',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ssl_enabled' => 'boolean',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
'ssl_provisioned_at' => 'datetime',
|
||||
'app_config' => 'array',
|
||||
];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedDatabase::class);
|
||||
}
|
||||
|
||||
public function appInstallation(): HasOne
|
||||
{
|
||||
return $this->hasOne(AppInstallation::class)->where('status', 'active')->latestOfMany();
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingAccount extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const TYPE_SHARED = 'shared';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_TERMINATED = 'terminated';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const RESOURCE_STATUS_ACTIVE = 'active';
|
||||
public const RESOURCE_STATUS_THROTTLED = 'throttled';
|
||||
public const RESOURCE_STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
public const GRACE_PERIOD_MONTHS = 3;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_product_id',
|
||||
'hosting_node_id',
|
||||
'username',
|
||||
'primary_domain',
|
||||
'type',
|
||||
'status',
|
||||
'provider_account_id',
|
||||
'home_directory',
|
||||
'allocated_disk_gb',
|
||||
'disk_used_bytes',
|
||||
'inode_count',
|
||||
'cpu_usage_percent',
|
||||
'memory_used_mb',
|
||||
'process_count',
|
||||
'io_usage_mb',
|
||||
'last_usage_sync_at',
|
||||
'bandwidth_used_bytes',
|
||||
'bandwidth_reset_at',
|
||||
'php_version',
|
||||
'cpu_limit_percent',
|
||||
'memory_limit_mb',
|
||||
'process_limit',
|
||||
'io_limit_mb',
|
||||
'inode_limit',
|
||||
'resource_status',
|
||||
'uploads_restricted',
|
||||
'is_flagged',
|
||||
'warning_count',
|
||||
'cpu_breach_streak',
|
||||
'memory_breach_streak',
|
||||
'process_breach_streak',
|
||||
'io_breach_streak',
|
||||
'inode_breach_streak',
|
||||
'last_warning_at',
|
||||
'last_resource_breach_at',
|
||||
'throttled_at',
|
||||
'features_enabled',
|
||||
'resource_limits',
|
||||
'suspension_reason',
|
||||
'suspended_at',
|
||||
'provisioned_at',
|
||||
'expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'features_enabled' => 'array',
|
||||
'resource_limits' => 'array',
|
||||
'metadata' => 'array',
|
||||
'allocated_disk_gb' => 'integer',
|
||||
'inode_count' => 'integer',
|
||||
'cpu_usage_percent' => 'decimal:2',
|
||||
'memory_used_mb' => 'integer',
|
||||
'process_count' => 'integer',
|
||||
'io_usage_mb' => 'decimal:2',
|
||||
'cpu_limit_percent' => 'integer',
|
||||
'memory_limit_mb' => 'integer',
|
||||
'process_limit' => 'integer',
|
||||
'io_limit_mb' => 'integer',
|
||||
'inode_limit' => 'integer',
|
||||
'uploads_restricted' => 'boolean',
|
||||
'is_flagged' => 'boolean',
|
||||
'warning_count' => 'integer',
|
||||
'cpu_breach_streak' => 'integer',
|
||||
'memory_breach_streak' => 'integer',
|
||||
'process_breach_streak' => 'integer',
|
||||
'io_breach_streak' => 'integer',
|
||||
'inode_breach_streak' => 'integer',
|
||||
'last_usage_sync_at' => 'datetime',
|
||||
'last_warning_at' => 'datetime',
|
||||
'last_resource_breach_at' => 'datetime',
|
||||
'throttled_at' => 'datetime',
|
||||
'bandwidth_reset_at' => 'datetime',
|
||||
'suspended_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'hosting_product_id');
|
||||
}
|
||||
|
||||
public function node(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class, 'hosting_node_id');
|
||||
}
|
||||
|
||||
public function sites(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedSite::class);
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedDatabase::class);
|
||||
}
|
||||
|
||||
public function teamMembers(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccountMember::class);
|
||||
}
|
||||
|
||||
public function developers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'hosting_account_members')
|
||||
->withPivot(['role', 'invited_by_user_id', 'invited_at'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function alerts(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccountAlert::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function billingInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingBillingInvoice::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function planChanges(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingPlanChange::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function latestOrder(): HasOne
|
||||
{
|
||||
return $this->hasOne(CustomerHostingOrder::class, 'hosting_account_id')->latestOfMany();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active');
|
||||
}
|
||||
|
||||
public function isThrottled(): bool
|
||||
{
|
||||
return $this->resource_status === self::RESOURCE_STATUS_THROTTLED;
|
||||
}
|
||||
|
||||
public function uploadsAreRestricted(): bool
|
||||
{
|
||||
return (bool) $this->uploads_restricted || (
|
||||
$this->inode_limit > 0 && $this->inode_count >= $this->inode_limit
|
||||
);
|
||||
}
|
||||
|
||||
public function assignedDurationMonths(): ?int
|
||||
{
|
||||
$months = data_get($this->metadata, 'assigned_duration_months');
|
||||
|
||||
return is_numeric($months) && (int) $months > 0 ? (int) $months : null;
|
||||
}
|
||||
|
||||
public function canBeRenewed(): bool
|
||||
{
|
||||
return $this->assignedDurationMonths() !== null;
|
||||
}
|
||||
|
||||
public function requestedDiskGb(): int
|
||||
{
|
||||
if ($this->allocated_disk_gb > 0) {
|
||||
return (int) $this->allocated_disk_gb;
|
||||
}
|
||||
|
||||
return (int) ($this->product?->disk_gb
|
||||
?? data_get($this->resource_limits, 'disk_gb')
|
||||
?? 0);
|
||||
}
|
||||
|
||||
public function diskLimitBytes(): int
|
||||
{
|
||||
return max($this->requestedDiskGb(), 0) * 1073741824;
|
||||
}
|
||||
|
||||
public function diskUsagePercent(): float
|
||||
{
|
||||
$limit = $this->diskLimitBytes();
|
||||
|
||||
if ($limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->disk_used_bytes / $limit) * 100, 2);
|
||||
}
|
||||
|
||||
public function inodeUsagePercent(): float
|
||||
{
|
||||
if (! $this->inode_limit || $this->inode_limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->inode_count / $this->inode_limit) * 100, 2);
|
||||
}
|
||||
|
||||
public function includedEmailAccounts(): ?int
|
||||
{
|
||||
$limit = $this->product?->max_email_accounts;
|
||||
|
||||
if ($limit === null) {
|
||||
$limit = data_get($this->resource_limits, 'max_email_accounts');
|
||||
}
|
||||
|
||||
return $limit === null ? null : (int) $limit;
|
||||
}
|
||||
|
||||
public function freeEmailAllowance(): ?int
|
||||
{
|
||||
$included = $this->includedEmailAccounts();
|
||||
|
||||
if ($included === null || $included < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max($included, 0);
|
||||
}
|
||||
|
||||
public function paidMailboxCount(): int
|
||||
{
|
||||
return $this->relationLoaded('mailboxes')
|
||||
? $this->mailboxes->where('is_paid', true)->count()
|
||||
: $this->mailboxes()->where('is_paid', true)->count();
|
||||
}
|
||||
|
||||
public function renew(?int $durationMonths = null, array $metadata = []): void
|
||||
{
|
||||
$months = $durationMonths ?? $this->assignedDurationMonths();
|
||||
|
||||
if (! is_int($months) || $months < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$baseDate = $this->expires_at instanceof CarbonInterface && $this->expires_at->isFuture()
|
||||
? $this->expires_at->copy()
|
||||
: now();
|
||||
|
||||
$currentMetadata = (array) ($this->metadata ?? []);
|
||||
|
||||
$mergedMetadata = array_merge($currentMetadata, $metadata, [
|
||||
'assigned_duration_months' => $months,
|
||||
]);
|
||||
unset($mergedMetadata['expiry_alerts_sent']);
|
||||
|
||||
$this->update([
|
||||
'expires_at' => $baseDate->addMonthsNoOverflow($months),
|
||||
'metadata' => $mergedMetadata,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at instanceof CarbonInterface && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isInGracePeriod(): bool
|
||||
{
|
||||
if (! $this->isExpired()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->gracePeriodEndsAt()->isFuture();
|
||||
}
|
||||
|
||||
public function gracePeriodEndsAt(): ?CarbonInterface
|
||||
{
|
||||
if (! $this->expires_at instanceof CarbonInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->expires_at->copy()->addMonths(self::GRACE_PERIOD_MONTHS);
|
||||
}
|
||||
|
||||
public function isPastGracePeriod(): bool
|
||||
{
|
||||
$endsAt = $this->gracePeriodEndsAt();
|
||||
|
||||
return $endsAt !== null && $endsAt->isPast();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HostingAccountAlert extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_WARNING = 'warning';
|
||||
public const TYPE_LIMIT_EXCEEDED = 'limit_exceeded';
|
||||
public const TYPE_THROTTLED = 'throttled';
|
||||
public const TYPE_SUSPENDED = 'suspended';
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id',
|
||||
'alert_type',
|
||||
'severity',
|
||||
'message',
|
||||
'resource_usage',
|
||||
'is_resolved',
|
||||
'resolved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'resource_usage' => 'array',
|
||||
'is_resolved' => 'boolean',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function scopeUnresolved($query)
|
||||
{
|
||||
return $query->where('is_resolved', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HostingAccountMember extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const ROLE_DEVELOPER = 'developer';
|
||||
|
||||
private const SSH_KEY_PATTERN = '/^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\\.com|sk-ecdsa-sha2-nistp256@openssh\\.com) [A-Za-z0-9+\\/]+={0,3}(?: .+)?$/';
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id',
|
||||
'user_id',
|
||||
'invited_by_user_id',
|
||||
'role',
|
||||
'invited_at',
|
||||
'ssh_public_key',
|
||||
'ssh_key_installed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'invited_at' => 'datetime',
|
||||
'ssh_key_installed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_user_id');
|
||||
}
|
||||
|
||||
public function sshKeyMarker(): string
|
||||
{
|
||||
return 'ladill-team-member-'.$this->id;
|
||||
}
|
||||
|
||||
public function hasSshAccessKey(): bool
|
||||
{
|
||||
return filled($this->ssh_public_key);
|
||||
}
|
||||
|
||||
public static function normalizeSshPublicKey(string $value): string
|
||||
{
|
||||
return trim(preg_replace('/\s+/', ' ', trim($value)) ?? '');
|
||||
}
|
||||
|
||||
public static function looksLikeSshPublicKey(?string $value): bool
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = static::normalizeSshPublicKey($value);
|
||||
|
||||
return $normalized !== '' && preg_match(self::SSH_KEY_PATTERN, $normalized) === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HostingBillingInvoice extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_PLAN_CHANGE = 'plan_change';
|
||||
public const TYPE_EMAIL_ADDON = 'email_addon';
|
||||
public const TYPE_RENEWAL = 'renewal';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_CREDITED = 'credited';
|
||||
public const STATUS_WAIVED = 'waived';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_account_id',
|
||||
'hosting_plan_change_id',
|
||||
'invoice_type',
|
||||
'status',
|
||||
'currency',
|
||||
'subtotal_amount',
|
||||
'credit_amount',
|
||||
'total_amount',
|
||||
'description',
|
||||
'provider_reference',
|
||||
'paid_at',
|
||||
'line_items',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'subtotal_amount' => 'decimal:2',
|
||||
'credit_amount' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2',
|
||||
'paid_at' => 'datetime',
|
||||
'line_items' => 'array',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function planChange(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingPlanChange::class, 'hosting_plan_change_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingNode extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'hostname',
|
||||
'ip_address',
|
||||
'ipv6_address',
|
||||
'type',
|
||||
'role',
|
||||
'primary_hosting_node_id',
|
||||
'segment',
|
||||
'provider',
|
||||
'provider_instance_id',
|
||||
'region',
|
||||
'datacenter',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'disk_gb',
|
||||
'oversell_ratio',
|
||||
'allocated_disk_gb',
|
||||
'used_disk_gb',
|
||||
'bandwidth_tb',
|
||||
'max_accounts',
|
||||
'current_accounts',
|
||||
'current_load_percent',
|
||||
'status',
|
||||
'features',
|
||||
'installed_software',
|
||||
'ssh_port',
|
||||
'ssh_private_key',
|
||||
'control_panel',
|
||||
'last_health_check_at',
|
||||
'health_status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'features' => 'array',
|
||||
'installed_software' => 'array',
|
||||
'health_status' => 'array',
|
||||
'last_health_check_at' => 'datetime',
|
||||
'cpu_cores' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'disk_gb' => 'integer',
|
||||
'oversell_ratio' => 'decimal:2',
|
||||
'allocated_disk_gb' => 'integer',
|
||||
'used_disk_gb' => 'integer',
|
||||
'bandwidth_tb' => 'integer',
|
||||
'max_accounts' => 'integer',
|
||||
'current_accounts' => 'integer',
|
||||
'current_load_percent' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'ssh_private_key',
|
||||
];
|
||||
|
||||
public const ROLE_PRIMARY = 'primary';
|
||||
|
||||
public const ROLE_EXTENSION = 'extension';
|
||||
|
||||
public const TYPE_SHARED = 'shared';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
|
||||
public const SEGMENT_GENERAL = 'general';
|
||||
public const SEGMENT_STARTER = 'starter';
|
||||
public const SEGMENT_WORDPRESS = 'wordpress';
|
||||
public const SEGMENT_HIGH_RESOURCE = 'high_resource';
|
||||
|
||||
public const PROVIDER_CONTABO = 'contabo';
|
||||
public const PROVIDER_LOCAL = 'local';
|
||||
public const PROVIDER_MANUAL = 'manual';
|
||||
public const PROVIDER_LEGACY_RC = 'legacy_rc';
|
||||
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_MAINTENANCE = 'maintenance';
|
||||
public const STATUS_FULL = 'full';
|
||||
public const STATUS_DECOMMISSIONED = 'decommissioned';
|
||||
|
||||
public function primary(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'primary_hosting_node_id');
|
||||
}
|
||||
|
||||
public function extensions(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'primary_hosting_node_id');
|
||||
}
|
||||
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return ($this->role ?? self::ROLE_PRIMARY) === self::ROLE_PRIMARY;
|
||||
}
|
||||
|
||||
public function isExtension(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_EXTENSION;
|
||||
}
|
||||
|
||||
public function poolRoot(): self
|
||||
{
|
||||
return $this->isExtension() && $this->primary
|
||||
? $this->primary
|
||||
: $this;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->max_accounts && $this->current_accounts >= $this->max_accounts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function supportsSegment(?string $segment): bool
|
||||
{
|
||||
if ($segment === null || $segment === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->segment, [$segment, self::SEGMENT_GENERAL], true);
|
||||
}
|
||||
|
||||
public function logicalCapacityGb(): float
|
||||
{
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ratio = (float) ($this->oversell_ratio ?: 1);
|
||||
|
||||
return round($this->disk_gb * max($ratio, 1), 2);
|
||||
}
|
||||
|
||||
public function logicalCapacityPercent(): float
|
||||
{
|
||||
$logicalCapacity = $this->logicalCapacityGb();
|
||||
|
||||
if ($logicalCapacity <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->allocated_disk_gb / $logicalCapacity) * 100, 2);
|
||||
}
|
||||
|
||||
public function physicalDiskPercent(): float
|
||||
{
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->used_disk_gb / $this->disk_gb) * 100, 2);
|
||||
}
|
||||
|
||||
public function accountCapacityPercent(): float
|
||||
{
|
||||
if (! $this->max_accounts || $this->max_accounts <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->current_accounts / $this->max_accounts) * 100, 2);
|
||||
}
|
||||
|
||||
public function hasLogicalCapacityFor(int $requestedDiskGb = 0): bool
|
||||
{
|
||||
if ($requestedDiskGb <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($this->used_disk_gb + $requestedDiskGb) <= $this->disk_gb;
|
||||
}
|
||||
|
||||
public function availableLogicalDiskGb(): float
|
||||
{
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max($this->disk_gb - $this->used_disk_gb, 0);
|
||||
}
|
||||
|
||||
public function canProvision(int $requestedDiskGb = 0, ?string $segment = null): bool
|
||||
{
|
||||
if (! $this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->supportsSegment($segment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasLogicalCapacityFor($requestedDiskGb);
|
||||
}
|
||||
|
||||
public function incrementAccountCount(): void
|
||||
{
|
||||
$this->increment('current_accounts');
|
||||
|
||||
if ($this->max_accounts && $this->current_accounts >= $this->max_accounts) {
|
||||
$this->update(['status' => self::STATUS_FULL]);
|
||||
}
|
||||
}
|
||||
|
||||
public function decrementAccountCount(): void
|
||||
{
|
||||
$this->decrement('current_accounts');
|
||||
|
||||
if ($this->status === self::STATUS_FULL && $this->current_accounts < $this->max_accounts) {
|
||||
$this->update(['status' => self::STATUS_ACTIVE]);
|
||||
}
|
||||
}
|
||||
|
||||
public function scopeAvailable($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE)
|
||||
->whereRaw('current_accounts < COALESCE(max_accounts, 999999)');
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class HostingOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const TYPE_SINGLE_DOMAIN = 'single_domain';
|
||||
public const TYPE_MULTI_DOMAIN = 'multi_domain';
|
||||
public const TYPE_RESELLER = 'reseller';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'domain_id',
|
||||
'domain_name',
|
||||
'rc_order_id',
|
||||
'rc_customer_id',
|
||||
'plan_name',
|
||||
'hosting_type',
|
||||
'status',
|
||||
'ip_address',
|
||||
'cpanel_url',
|
||||
'cpanel_username',
|
||||
'bandwidth_mb',
|
||||
'disk_space_mb',
|
||||
'expires_at',
|
||||
'provisioned_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXPIRED
|
||||
|| ($this->expires_at && $this->expires_at->isPast());
|
||||
}
|
||||
|
||||
public function getDomainNameAttribute($value): string
|
||||
{
|
||||
return $value ?: $this->metaValue([
|
||||
'domain_name',
|
||||
'domainname',
|
||||
'domain-name',
|
||||
'domain',
|
||||
'hostname',
|
||||
'description',
|
||||
], '');
|
||||
}
|
||||
|
||||
public function getPlanNameAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'plan_name',
|
||||
'planname',
|
||||
'productcategory',
|
||||
'productkey',
|
||||
'description',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
public function getPlanIdAttribute(): ?string
|
||||
{
|
||||
$resolved = $this->metaValue([
|
||||
'plan_id',
|
||||
'planid',
|
||||
'plan-id',
|
||||
'productkey',
|
||||
'plan.name',
|
||||
'entitytype.entitytypekey',
|
||||
]);
|
||||
|
||||
return $resolved !== null && $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getRcOrderIdAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'rc_order_id',
|
||||
'order_id',
|
||||
'orderid',
|
||||
'order-id',
|
||||
'entityid',
|
||||
'entity-id',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getRcCustomerIdAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'rc_customer_id',
|
||||
'customer_id',
|
||||
'customerid',
|
||||
'customer-id',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getIpAddressAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'ip_address',
|
||||
'ipaddress',
|
||||
'ip',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
public function getCpanelUrlAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'cpanel_url',
|
||||
'cpanelurl',
|
||||
'controlpanelurl',
|
||||
'access_url',
|
||||
'url',
|
||||
]);
|
||||
|
||||
if ($this->looksLikeTemporaryHostingUrl($resolved)) {
|
||||
$resolved = null;
|
||||
}
|
||||
|
||||
if (! $resolved && $preferredDomain = $this->preferredCpanelDomain()) {
|
||||
return 'https://'.$preferredDomain.'/cpanel';
|
||||
}
|
||||
|
||||
if (! $resolved && $this->ip_address) {
|
||||
return 'https://'.$this->ip_address.':2083';
|
||||
}
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
public function getCpanelUsernameAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'cpanel_username',
|
||||
'cpanelusername',
|
||||
'access_username',
|
||||
'username',
|
||||
'siteadminusername',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getNameserversAttribute($value): array
|
||||
{
|
||||
$resolved = [];
|
||||
|
||||
if (is_array($value)) {
|
||||
$resolved = $value;
|
||||
} else {
|
||||
$candidate = $this->metaValue([
|
||||
'nameservers',
|
||||
'nameserver',
|
||||
'name_servers',
|
||||
'ns',
|
||||
'dns.nameservers',
|
||||
'server.nameservers',
|
||||
]);
|
||||
|
||||
if (is_array($candidate)) {
|
||||
$resolved = $candidate;
|
||||
} else {
|
||||
$resolved = array_filter([
|
||||
$this->metaValue(['ns1', 'nameserver1', 'name_server_1', 'server.ns1']),
|
||||
$this->metaValue(['ns2', 'nameserver2', 'name_server_2', 'server.ns2']),
|
||||
$this->metaValue(['ns3', 'nameserver3', 'name_server_3', 'server.ns3']),
|
||||
$this->metaValue(['ns4', 'nameserver4', 'name_server_4', 'server.ns4']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return collect($resolved)
|
||||
->map(fn ($item) => strtolower(trim((string) $item, " \t\n\r\0\x0B.")))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getWebsiteUrlAttribute(): ?string
|
||||
{
|
||||
$domain = strtolower(trim((string) $this->domain_name));
|
||||
|
||||
if ($domain === '' || filter_var($domain, FILTER_VALIDATE_IP)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'https://'.$domain;
|
||||
}
|
||||
|
||||
public function getDirectUrlAttribute(): ?string
|
||||
{
|
||||
$resolved = $this->metaValue([
|
||||
'tempurl',
|
||||
'temp_url',
|
||||
'temporary_url',
|
||||
'server.tempurl',
|
||||
'server.temporary_url',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getDiskSpaceMbAttribute($value): ?int
|
||||
{
|
||||
$resolved = $value ?? $this->metaValue([
|
||||
'disk_space_mb',
|
||||
'diskspace',
|
||||
'disk_space',
|
||||
]);
|
||||
|
||||
return is_numeric($resolved) && (int) $resolved >= 0 ? (int) $resolved : null;
|
||||
}
|
||||
|
||||
public function getBandwidthMbAttribute($value): ?int
|
||||
{
|
||||
$resolved = $value ?? $this->metaValue([
|
||||
'bandwidth_mb',
|
||||
'bandwidth',
|
||||
]);
|
||||
|
||||
return is_numeric($resolved) && (int) $resolved >= 0 ? (int) $resolved : null;
|
||||
}
|
||||
|
||||
public function getStatusAttribute($value): string
|
||||
{
|
||||
$candidate = $value;
|
||||
|
||||
if ($candidate === null || $candidate === '' || $candidate === self::STATUS_PENDING) {
|
||||
$candidate = $this->metaValue([
|
||||
'status',
|
||||
'currentstatus',
|
||||
'orderstatus',
|
||||
], $candidate ?: self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
return $this->normalizeStatus((string) $candidate);
|
||||
}
|
||||
|
||||
public function getProvisionedAtAttribute($value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value) {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
$timestamp = $this->metaValue([
|
||||
'provisioned_at',
|
||||
'creation_time',
|
||||
'creationtime',
|
||||
'creation-date',
|
||||
]);
|
||||
|
||||
if ($timestamp === null || $timestamp === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return is_numeric($timestamp)
|
||||
? Carbon::createFromTimestamp((int) $timestamp)
|
||||
: Carbon::parse((string) $timestamp);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getExpiresAtAttribute($value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value) {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
$timestamp = $this->metaValue([
|
||||
'expires_at',
|
||||
'end_time',
|
||||
'endtime',
|
||||
'expirytime',
|
||||
'expiry-date',
|
||||
]);
|
||||
|
||||
if ($timestamp === null || $timestamp === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return is_numeric($timestamp)
|
||||
? Carbon::createFromTimestamp((int) $timestamp)
|
||||
: Carbon::parse((string) $timestamp);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function metaValue(array $keys, mixed $default = null): mixed
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$sources = [];
|
||||
|
||||
if (is_array($meta)) {
|
||||
$sources[] = $meta;
|
||||
|
||||
if (is_array($meta['raw'] ?? null)) {
|
||||
$sources[] = $meta['raw'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sources as $source) {
|
||||
foreach ($keys as $key) {
|
||||
$nested = Arr::get($source, $key);
|
||||
if ($nested !== null && $nested !== '') {
|
||||
return $nested;
|
||||
}
|
||||
|
||||
if (array_key_exists($key, $source) && $source[$key] !== null && $source[$key] !== '') {
|
||||
return $source[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function normalizeStatus(string $status): string
|
||||
{
|
||||
return match (strtolower(trim($status))) {
|
||||
'active', 'active (paid)' => self::STATUS_ACTIVE,
|
||||
'suspended', 'inactive' => self::STATUS_SUSPENDED,
|
||||
'deleted', 'cancelled' => self::STATUS_CANCELLED,
|
||||
'expired' => self::STATUS_EXPIRED,
|
||||
'failed' => self::STATUS_FAILED,
|
||||
default => self::STATUS_PENDING,
|
||||
};
|
||||
}
|
||||
|
||||
private function looksLikeTemporaryHostingUrl(?string $value): bool
|
||||
{
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains($normalized, 'tempwebhost.net')
|
||||
|| str_contains($normalized, '/~');
|
||||
}
|
||||
|
||||
private function preferredCpanelDomain(): ?string
|
||||
{
|
||||
$domain = strtolower(trim((string) $this->domain_name));
|
||||
|
||||
if ($domain === '' || filter_var($domain, FILTER_VALIDATE_IP)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->relationLoaded('domain') && $this->domain) {
|
||||
return $this->domainIsActive($this->domain) ? $domain : null;
|
||||
}
|
||||
|
||||
if ($this->domain_id) {
|
||||
$linkedDomain = $this->domain()->first();
|
||||
|
||||
if ($linkedDomain) {
|
||||
return $this->domainIsActive($linkedDomain) ? $domain : null;
|
||||
}
|
||||
}
|
||||
|
||||
$matchingDomain = Domain::query()
|
||||
->where('host', $domain)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($matchingDomain) {
|
||||
return $this->domainIsActive($matchingDomain) ? $domain : null;
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
private function domainIsActive(Domain $domain): bool
|
||||
{
|
||||
return (string) $domain->status === 'verified'
|
||||
&& (string) $domain->onboarding_state === Domain::STATE_ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingPlan extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'type',
|
||||
'price_monthly',
|
||||
'price_yearly',
|
||||
'currency',
|
||||
'disk_gb',
|
||||
'bandwidth_gb',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'max_domains',
|
||||
'max_databases',
|
||||
'max_email_accounts',
|
||||
'max_ftp_accounts',
|
||||
'ssl_included',
|
||||
'backups_included',
|
||||
'backup_retention_days',
|
||||
'features',
|
||||
'php_versions',
|
||||
'is_active',
|
||||
'is_featured',
|
||||
'sort_order',
|
||||
'contabo_product_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price_monthly' => 'decimal:2',
|
||||
'price_yearly' => 'decimal:2',
|
||||
'disk_gb' => 'integer',
|
||||
'bandwidth_gb' => 'integer',
|
||||
'cpu_cores' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'max_domains' => 'integer',
|
||||
'max_databases' => 'integer',
|
||||
'max_email_accounts' => 'integer',
|
||||
'max_ftp_accounts' => 'integer',
|
||||
'ssl_included' => 'boolean',
|
||||
'backups_included' => 'boolean',
|
||||
'backup_retention_days' => 'integer',
|
||||
'features' => 'array',
|
||||
'php_versions' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public const TYPE_SHARED = 'shared';
|
||||
public const TYPE_WORDPRESS = 'wordpress';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
public const TYPE_EMAIL = 'email';
|
||||
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function vpsInstances(): HasMany
|
||||
{
|
||||
return $this->hasMany(VpsInstance::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeFeatured($query)
|
||||
{
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('price_monthly');
|
||||
}
|
||||
|
||||
public function getYearlyDiscount(): ?float
|
||||
{
|
||||
if (!$this->price_yearly || !$this->price_monthly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$monthlyTotal = $this->price_monthly * 12;
|
||||
return round((($monthlyTotal - $this->price_yearly) / $monthlyTotal) * 100, 1);
|
||||
}
|
||||
|
||||
public function formatDiskSize(): string
|
||||
{
|
||||
if ($this->disk_gb >= 1000) {
|
||||
return round($this->disk_gb / 1000, 1) . ' TB';
|
||||
}
|
||||
return $this->disk_gb . ' GB';
|
||||
}
|
||||
|
||||
public function formatRam(): string
|
||||
{
|
||||
if ($this->ram_mb >= 1024) {
|
||||
return round($this->ram_mb / 1024, 1) . ' GB';
|
||||
}
|
||||
return $this->ram_mb . ' MB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class HostingPlanChange extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const DIRECTION_UPGRADE = 'upgrade';
|
||||
public const DIRECTION_DOWNGRADE = 'downgrade';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_account_id',
|
||||
'from_hosting_product_id',
|
||||
'to_hosting_product_id',
|
||||
'direction',
|
||||
'billing_cycle',
|
||||
'cycle_months',
|
||||
'remaining_days',
|
||||
'proration_ratio',
|
||||
'old_cycle_price',
|
||||
'new_cycle_price',
|
||||
'charge_amount',
|
||||
'credit_amount',
|
||||
'net_amount',
|
||||
'currency',
|
||||
'status',
|
||||
'applied_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cycle_months' => 'integer',
|
||||
'remaining_days' => 'integer',
|
||||
'proration_ratio' => 'decimal:4',
|
||||
'old_cycle_price' => 'decimal:2',
|
||||
'new_cycle_price' => 'decimal:2',
|
||||
'charge_amount' => 'decimal:2',
|
||||
'credit_amount' => 'decimal:2',
|
||||
'net_amount' => 'decimal:2',
|
||||
'applied_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function fromProduct(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'from_hosting_product_id');
|
||||
}
|
||||
|
||||
public function toProduct(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'to_hosting_product_id');
|
||||
}
|
||||
|
||||
public function invoice(): HasOne
|
||||
{
|
||||
return $this->hasOne(HostingBillingInvoice::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Hosting\HostingPricingService;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingProduct extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const CATEGORY_SHARED = 'shared';
|
||||
public const CATEGORY_VPS = 'vps';
|
||||
public const CATEGORY_DEDICATED = 'dedicated';
|
||||
public const CATEGORY_EMAIL = 'email';
|
||||
|
||||
public const TYPE_SINGLE_DOMAIN = 'single_domain';
|
||||
public const TYPE_MULTI_DOMAIN = 'multi_domain';
|
||||
public const TYPE_WORDPRESS = 'wordpress';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
public const TYPE_EMAIL_STANDALONE = 'email_standalone';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'category',
|
||||
'type',
|
||||
'price_monthly',
|
||||
'price_quarterly',
|
||||
'price_yearly',
|
||||
'price_biennial',
|
||||
'setup_fee',
|
||||
'currency',
|
||||
'disk_gb',
|
||||
'bandwidth_gb',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'max_domains',
|
||||
'max_databases',
|
||||
'max_email_accounts',
|
||||
'max_ftp_accounts',
|
||||
'ssl_included',
|
||||
'backups_included',
|
||||
'backup_retention_days',
|
||||
'features',
|
||||
'php_versions',
|
||||
'contabo_product_id',
|
||||
'contabo_region',
|
||||
'is_active',
|
||||
'is_featured',
|
||||
'is_visible',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'currency' => 'GHS',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price_monthly' => 'decimal:2',
|
||||
'price_quarterly' => 'decimal:2',
|
||||
'price_yearly' => 'decimal:2',
|
||||
'price_biennial' => 'decimal:2',
|
||||
'setup_fee' => 'decimal:2',
|
||||
'disk_gb' => 'integer',
|
||||
'bandwidth_gb' => 'integer',
|
||||
'cpu_cores' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'max_domains' => 'integer',
|
||||
'max_databases' => 'integer',
|
||||
'max_email_accounts' => 'integer',
|
||||
'max_ftp_accounts' => 'integer',
|
||||
'ssl_included' => 'boolean',
|
||||
'backups_included' => 'boolean',
|
||||
'backup_retention_days' => 'integer',
|
||||
'features' => 'array',
|
||||
'php_versions' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_visible' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomerHostingOrder::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeVisible($query)
|
||||
{
|
||||
return $query->where('is_visible', true);
|
||||
}
|
||||
|
||||
public function scopeOfCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeSharedHosting($query)
|
||||
{
|
||||
return $query->where('category', self::CATEGORY_SHARED);
|
||||
}
|
||||
|
||||
public function scopeServers($query)
|
||||
{
|
||||
return $query->whereIn('category', [self::CATEGORY_VPS, self::CATEGORY_DEDICATED]);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('price_monthly');
|
||||
}
|
||||
|
||||
public function isSharedHosting(): bool
|
||||
{
|
||||
return $this->category === self::CATEGORY_SHARED;
|
||||
}
|
||||
|
||||
public function isVps(): bool
|
||||
{
|
||||
return $this->category === self::CATEGORY_VPS;
|
||||
}
|
||||
|
||||
public function isDedicated(): bool
|
||||
{
|
||||
return $this->category === self::CATEGORY_DEDICATED;
|
||||
}
|
||||
|
||||
public function requiresContabo(): bool
|
||||
{
|
||||
return in_array($this->category, [self::CATEGORY_VPS, self::CATEGORY_DEDICATED]);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return (bool) $this->is_active;
|
||||
}
|
||||
|
||||
public function isVisible(): bool
|
||||
{
|
||||
return (bool) $this->is_visible;
|
||||
}
|
||||
|
||||
public function getPriceForCycle(string $cycle): ?float
|
||||
{
|
||||
// Use dynamic pricing for VPS/Dedicated
|
||||
if ($this->requiresContabo()) {
|
||||
// Contabo VPS does not offer quarterly billing
|
||||
if ($this->isVps() && $cycle === 'quarterly') {
|
||||
return null;
|
||||
}
|
||||
$pricing = $this->getDynamicPricing();
|
||||
return match ($cycle) {
|
||||
'monthly' => $pricing['monthly'],
|
||||
'quarterly' => $pricing['quarterly'],
|
||||
'semiannual' => $pricing['semiannual'],
|
||||
'yearly' => $pricing['yearly'],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($cycle) {
|
||||
'monthly' => (float) $this->price_monthly,
|
||||
'quarterly' => (float) $this->price_quarterly,
|
||||
'semiannual' => null,
|
||||
'yearly' => (float) $this->price_yearly,
|
||||
'biennial' => (float) $this->price_biennial,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function getDynamicPricing(): array
|
||||
{
|
||||
$pricingService = app(HostingPricingService::class);
|
||||
return $pricingService->getDynamicPriceForProduct($this);
|
||||
}
|
||||
|
||||
public function getPricingBreakdown(): array
|
||||
{
|
||||
$pricingService = app(HostingPricingService::class);
|
||||
return $pricingService->getPricingBreakdown($this);
|
||||
}
|
||||
|
||||
public function getDisplayPriceMonthlyAttribute(): float
|
||||
{
|
||||
if ($this->requiresContabo()) {
|
||||
return $this->getDynamicPricing()['monthly'];
|
||||
}
|
||||
return (float) $this->price_monthly;
|
||||
}
|
||||
|
||||
public function getDisplayPriceYearlyAttribute(): float
|
||||
{
|
||||
if ($this->requiresContabo()) {
|
||||
return $this->getDynamicPricing()['yearly'];
|
||||
}
|
||||
return (float) $this->price_yearly;
|
||||
}
|
||||
|
||||
public function getDisplayCurrencyAttribute(): string
|
||||
{
|
||||
return 'GHS';
|
||||
}
|
||||
|
||||
public function getYearlyDiscount(): ?float
|
||||
{
|
||||
$monthlyPrice = $this->display_price_monthly;
|
||||
$yearlyPrice = $this->display_price_yearly;
|
||||
|
||||
if (!$yearlyPrice || !$monthlyPrice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$monthlyTotal = $monthlyPrice * 12;
|
||||
return round((($monthlyTotal - $yearlyPrice) / $monthlyTotal) * 100, 1);
|
||||
}
|
||||
|
||||
public function catalogSummary(): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if ($this->disk_gb !== null) {
|
||||
$parts[] = $this->formatDiskSize();
|
||||
}
|
||||
|
||||
$domainLimit = $this->formatDomainLimit();
|
||||
if ($domainLimit !== null) {
|
||||
$parts[] = $domainLimit;
|
||||
}
|
||||
|
||||
return $parts !== [] ? implode(' · ', $parts) : null;
|
||||
}
|
||||
|
||||
public function formatDiskSize(): string
|
||||
{
|
||||
if (!$this->disk_gb) {
|
||||
return 'Unlimited';
|
||||
}
|
||||
if ($this->disk_gb >= 1000) {
|
||||
return round($this->disk_gb / 1000, 1) . ' TB';
|
||||
}
|
||||
return $this->disk_gb . ' GB';
|
||||
}
|
||||
|
||||
public function formatRam(): string
|
||||
{
|
||||
if (!$this->ram_mb) {
|
||||
return '-';
|
||||
}
|
||||
if ($this->ram_mb >= 1024) {
|
||||
return round($this->ram_mb / 1024, 1) . ' GB';
|
||||
}
|
||||
return $this->ram_mb . ' MB';
|
||||
}
|
||||
|
||||
public function formatBandwidth(): string
|
||||
{
|
||||
if (!$this->bandwidth_gb) {
|
||||
return 'Unlimited';
|
||||
}
|
||||
if ($this->bandwidth_gb >= 1000) {
|
||||
return round($this->bandwidth_gb / 1000, 1) . ' TB';
|
||||
}
|
||||
return $this->bandwidth_gb . ' GB';
|
||||
}
|
||||
|
||||
public function formatDomainLimit(): ?string
|
||||
{
|
||||
if ($this->max_domains === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->max_domains === -1) {
|
||||
return 'Unlimited sites';
|
||||
}
|
||||
|
||||
return $this->max_domains.' site'.($this->max_domains === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
public function preferredNodeSegment(): ?string
|
||||
{
|
||||
if (! $this->isSharedHosting()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($this->type) {
|
||||
self::TYPE_WORDPRESS => HostingNode::SEGMENT_WORDPRESS,
|
||||
self::TYPE_MULTI_DOMAIN => HostingNode::SEGMENT_HIGH_RESOURCE,
|
||||
self::TYPE_SINGLE_DOMAIN => HostingNode::SEGMENT_STARTER,
|
||||
default => HostingNode::SEGMENT_GENERAL,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** Membership linking a user to an account (owner) they manage hosting for. */
|
||||
class HostingTeamMember extends Model
|
||||
{
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_MEMBER = 'member';
|
||||
|
||||
public const STATUS_INVITED = 'invited';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
|
||||
|
||||
protected $casts = ['accepted_at' => 'datetime'];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'account_id');
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link any pending invites for this email to the user (called on SSO login),
|
||||
* so invited teammates gain access the first time they sign in.
|
||||
*/
|
||||
public static function linkPendingInvitesFor(User $user): void
|
||||
{
|
||||
static::query()
|
||||
->whereNull('user_id')
|
||||
->where('status', self::STATUS_INVITED)
|
||||
->whereRaw('LOWER(email) = ?', [strtolower($user->email)])
|
||||
->update([
|
||||
'user_id' => $user->id,
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'accepted_at' => now(),
|
||||
'token' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Mailbox extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public static function defaultQuotaMb(): int
|
||||
{
|
||||
return (int) config('mailinfra.default_mailbox_quota_mb', 10240);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'website_id',
|
||||
'hosting_account_id',
|
||||
'domain_id',
|
||||
'email_domain_id',
|
||||
'display_name',
|
||||
'local_part',
|
||||
'address',
|
||||
'password_hash',
|
||||
'quota_mb',
|
||||
'disk_used_bytes',
|
||||
'last_usage_sync_at',
|
||||
'status',
|
||||
'is_paid',
|
||||
'payment_reference',
|
||||
'paid_at',
|
||||
'billing_type',
|
||||
'monthly_price',
|
||||
'subscription_started_at',
|
||||
'subscription_expires_at',
|
||||
'subscription_status',
|
||||
'maildir_path',
|
||||
'credentials_issued_at',
|
||||
'incoming_token',
|
||||
'inbound_enabled',
|
||||
'outbound_enabled',
|
||||
'outbound_provider',
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_encryption',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'from_name',
|
||||
'from_address',
|
||||
'metadata',
|
||||
'webmail_secret',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'inbound_enabled' => 'boolean',
|
||||
'outbound_enabled' => 'boolean',
|
||||
'is_paid' => 'boolean',
|
||||
'paid_at' => 'datetime',
|
||||
'monthly_price' => 'decimal:2',
|
||||
'subscription_started_at' => 'datetime',
|
||||
'subscription_expires_at' => 'datetime',
|
||||
'smtp_password' => 'encrypted',
|
||||
'quota_mb' => 'integer',
|
||||
'disk_used_bytes' => 'integer',
|
||||
'last_usage_sync_at' => 'datetime',
|
||||
'credentials_issued_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password_hash',
|
||||
'webmail_secret',
|
||||
'incoming_token',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function emailDomain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailDomain::class);
|
||||
}
|
||||
|
||||
public function threads(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailThread::class);
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailMessage::class);
|
||||
}
|
||||
|
||||
public function isStandalone(): bool
|
||||
{
|
||||
return $this->website_id === null && $this->email_domain_id !== null;
|
||||
}
|
||||
|
||||
public function isWebsiteLinked(): bool
|
||||
{
|
||||
return $this->website_id !== null;
|
||||
}
|
||||
|
||||
public function getDomainHostAttribute(): ?string
|
||||
{
|
||||
if ($this->emailDomain) {
|
||||
return $this->emailDomain->domain;
|
||||
}
|
||||
|
||||
if ($this->domain) {
|
||||
return $this->domain->host;
|
||||
}
|
||||
|
||||
$parts = explode('@', $this->address ?? '', 2);
|
||||
return $parts[1] ?? null;
|
||||
}
|
||||
|
||||
public function getOwnerIdAttribute(): ?int
|
||||
{
|
||||
if ($this->user_id) {
|
||||
return $this->user_id;
|
||||
}
|
||||
|
||||
if ($this->website) {
|
||||
return $this->website->owner_user_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NodeCapacityAlert extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_CAPACITY_WARNING = 'capacity_warning';
|
||||
public const TYPE_CAPACITY_CRITICAL = 'capacity_critical';
|
||||
public const TYPE_RESOURCE_HIGH = 'resource_high';
|
||||
public const TYPE_NEEDS_EXPANSION = 'needs_expansion';
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_node_id',
|
||||
'alert_type',
|
||||
'current_accounts',
|
||||
'max_accounts',
|
||||
'capacity_percent',
|
||||
'resource_usage',
|
||||
'message',
|
||||
'is_resolved',
|
||||
'resolved_by',
|
||||
'resolved_at',
|
||||
'resolution_notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'current_accounts' => 'integer',
|
||||
'max_accounts' => 'integer',
|
||||
'capacity_percent' => 'decimal:2',
|
||||
'resource_usage' => 'array',
|
||||
'is_resolved' => 'boolean',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function hostingNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class);
|
||||
}
|
||||
|
||||
public function resolvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'resolved_by');
|
||||
}
|
||||
|
||||
public function scopeUnresolved($query)
|
||||
{
|
||||
return $query->where('is_resolved', false);
|
||||
}
|
||||
|
||||
public function scopeCritical($query)
|
||||
{
|
||||
return $query->whereIn('alert_type', [self::TYPE_CAPACITY_CRITICAL, self::TYPE_NEEDS_EXPANSION]);
|
||||
}
|
||||
|
||||
public function resolve(User $admin, ?string $notes = null): void
|
||||
{
|
||||
$this->update([
|
||||
'is_resolved' => true,
|
||||
'resolved_by' => $admin->id,
|
||||
'resolved_at' => now(),
|
||||
'resolution_notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isCritical(): bool
|
||||
{
|
||||
return in_array($this->alert_type, [self::TYPE_CAPACITY_CRITICAL, self::TYPE_NEEDS_EXPANSION]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PlatformSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'label',
|
||||
'description',
|
||||
'type',
|
||||
'value',
|
||||
'is_secret',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
'is_secret' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class PromoBanner extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_DOMAINS = 'domains';
|
||||
public const TYPE_HOSTING = 'hosting';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
public const TYPE_EMAIL = 'email';
|
||||
public const TYPE_GENERAL = 'general';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'product_type',
|
||||
'title',
|
||||
'description',
|
||||
'featured_image',
|
||||
'badge_text',
|
||||
'desktop_text',
|
||||
'mobile_text',
|
||||
'cta_text',
|
||||
'cta_url',
|
||||
'background_color',
|
||||
'text_color',
|
||||
'cta_background_color',
|
||||
'cta_text_color',
|
||||
'discount_percent',
|
||||
'package_ids',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'is_active',
|
||||
'show_topbar',
|
||||
'show_on_homepage',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'package_ids' => 'array',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
'show_topbar' => 'boolean',
|
||||
'show_on_homepage' => 'boolean',
|
||||
'discount_percent' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('starts_at')
|
||||
->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ends_at')
|
||||
->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForTopbar(Builder $query): Builder
|
||||
{
|
||||
return $query->active()->where('show_topbar', true);
|
||||
}
|
||||
|
||||
public static function getActiveTopbar(): ?self
|
||||
{
|
||||
return static::forTopbar()->latest()->first();
|
||||
}
|
||||
|
||||
public function scopeForHomepage(Builder $query): Builder
|
||||
{
|
||||
return $query->active()->where('show_on_homepage', true)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public static function getHomepagePromos()
|
||||
{
|
||||
return static::forHomepage()->get();
|
||||
}
|
||||
|
||||
public static function getProductTypes(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_DOMAINS => 'Domains',
|
||||
self::TYPE_HOSTING => 'Hosting',
|
||||
self::TYPE_VPS => 'VPS',
|
||||
self::TYPE_DEDICATED => 'Dedicated Servers',
|
||||
self::TYPE_EMAIL => 'Email',
|
||||
self::TYPE_GENERAL => 'General',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFeaturedImageUrlAttribute(): ?string
|
||||
{
|
||||
if (!$this->featured_image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($this->featured_image, 'http')) {
|
||||
return $this->featured_image;
|
||||
}
|
||||
|
||||
return asset('storage/' . $this->featured_image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProvisioningJob extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'type',
|
||||
'provisionable_type',
|
||||
'provisionable_id',
|
||||
'provider',
|
||||
'status',
|
||||
'payload',
|
||||
'result',
|
||||
'error_message',
|
||||
'attempts',
|
||||
'max_attempts',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'next_retry_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'result' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'next_retry_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_RUNNING = 'running';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(function ($model) {
|
||||
$model->uuid = $model->uuid ?? Str::uuid()->toString();
|
||||
});
|
||||
}
|
||||
|
||||
public function provisionable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function markRunning(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_RUNNING,
|
||||
'started_at' => now(),
|
||||
'attempts' => $this->attempts + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markCompleted(array $result = []): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markFailed(string $error, bool $canRetry = true): void
|
||||
{
|
||||
$status = ($canRetry && $this->attempts < $this->max_attempts)
|
||||
? self::STATUS_PENDING
|
||||
: self::STATUS_FAILED;
|
||||
|
||||
$this->update([
|
||||
'status' => $status,
|
||||
'error_message' => $error,
|
||||
'next_retry_at' => $status === self::STATUS_PENDING ? now()->addMinutes(5) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('next_retry_at')
|
||||
->orWhere('next_retry_at', '<=', now());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProvisioningQueueItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'provisioning_queue';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const PROVIDER_SHARED_NODE = 'shared_node';
|
||||
public const PROVIDER_CONTABO_VPS = 'contabo_vps';
|
||||
public const PROVIDER_CONTABO_DEDICATED = 'contabo_dedicated';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'status',
|
||||
'provider',
|
||||
'attempts',
|
||||
'max_attempts',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'error_message',
|
||||
'provisioning_log',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'attempts' => 'integer',
|
||||
'max_attempts' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'provisioning_log' => 'array',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function scopeQueued($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_QUEUED);
|
||||
}
|
||||
|
||||
public function scopeProcessing($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PROCESSING);
|
||||
}
|
||||
|
||||
public function scopeFailed($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_FAILED);
|
||||
}
|
||||
|
||||
public function canRetry(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED && $this->attempts < $this->max_attempts;
|
||||
}
|
||||
|
||||
public function markAsProcessing(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_PROCESSING,
|
||||
'started_at' => now(),
|
||||
'attempts' => $this->attempts + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsCompleted(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsFailed(string $error): void
|
||||
{
|
||||
$log = $this->provisioning_log ?? [];
|
||||
$log[] = [
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'attempt' => $this->attempts,
|
||||
'error' => $error,
|
||||
];
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'error_message' => $error,
|
||||
'provisioning_log' => $log,
|
||||
]);
|
||||
}
|
||||
|
||||
public function addLog(string $message, string $level = 'info'): void
|
||||
{
|
||||
$log = $this->provisioning_log ?? [];
|
||||
$log[] = [
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
$this->update(['provisioning_log' => $log]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class RcServiceOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_SERVICE = 'service';
|
||||
public const TYPE_DOMAIN_REGISTRATION = 'domain_registration';
|
||||
public const TYPE_DOMAIN_TRANSFER = 'domain_transfer';
|
||||
public const TYPE_RENEWAL = 'renewal';
|
||||
|
||||
public const STATUS_CART = 'cart';
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const PAYMENT_STATUS_UNPAID = 'unpaid';
|
||||
public const PAYMENT_STATUS_PENDING = 'pending';
|
||||
public const PAYMENT_STATUS_PAID = 'paid';
|
||||
public const PAYMENT_STATUS_FAILED = 'failed';
|
||||
public const PAYMENT_STATUS_REFUNDED = 'refunded';
|
||||
|
||||
public const FULFILLMENT_CART = 'cart';
|
||||
public const FULFILLMENT_PAYMENT_PENDING = 'payment_pending';
|
||||
public const FULFILLMENT_PAYMENT_RECEIVED = 'payment_received';
|
||||
public const FULFILLMENT_SUBMITTING = 'submitting';
|
||||
public const FULFILLMENT_AWAITING_VENDOR = 'awaiting_vendor';
|
||||
public const FULFILLMENT_MANUAL_REVIEW = 'manual_review';
|
||||
public const FULFILLMENT_FULFILLED = 'fulfilled';
|
||||
public const FULFILLMENT_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'category',
|
||||
'order_type',
|
||||
'domain_name',
|
||||
'rc_order_id',
|
||||
'rc_customer_id',
|
||||
'plan_id',
|
||||
'plan_name',
|
||||
'status',
|
||||
'payment_status',
|
||||
'fulfillment_status',
|
||||
'payment_reference',
|
||||
'amount_minor',
|
||||
'currency',
|
||||
'term_months',
|
||||
'term_years',
|
||||
'server_location',
|
||||
'ip_address',
|
||||
'access_url',
|
||||
'access_username',
|
||||
'expires_at',
|
||||
'paid_at',
|
||||
'submitted_at',
|
||||
'last_synced_at',
|
||||
'provisioned_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_minor' => 'integer',
|
||||
'term_months' => 'integer',
|
||||
'term_years' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
'submitted_at' => 'datetime',
|
||||
'last_synced_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeVisibleToCustomer($query)
|
||||
{
|
||||
return $query->whereNotIn('status', [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServerAgent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING_REGISTRATION = 'pending_registration';
|
||||
public const STATUS_ONLINE = 'online';
|
||||
public const STATUS_OFFLINE = 'offline';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'uuid',
|
||||
'status',
|
||||
'hostname',
|
||||
'agent_version',
|
||||
'platform',
|
||||
'capabilities',
|
||||
'metadata',
|
||||
'bootstrap_token_hash',
|
||||
'access_token_hash',
|
||||
'access_token_issued_at',
|
||||
'registered_at',
|
||||
'last_heartbeat_at',
|
||||
'last_seen_ip',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'capabilities' => 'array',
|
||||
'metadata' => 'array',
|
||||
'access_token_issued_at' => 'datetime',
|
||||
'registered_at' => 'datetime',
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'bootstrap_token_hash',
|
||||
'access_token_hash',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function tasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerTask::class);
|
||||
}
|
||||
|
||||
public function sites(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSite::class);
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class);
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerFile::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServerDatabase extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'latest_server_task_id',
|
||||
'server_site_id',
|
||||
'name',
|
||||
'username',
|
||||
'password_encrypted',
|
||||
'engine',
|
||||
'status',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password_encrypted',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function latestTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerTask::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerSite::class, 'server_site_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServerFile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'latest_server_task_id',
|
||||
'path',
|
||||
'kind',
|
||||
'exists',
|
||||
'size_bytes',
|
||||
'content_hash',
|
||||
'content_preview',
|
||||
'metadata',
|
||||
'last_synced_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'exists' => 'boolean',
|
||||
'size_bytes' => 'integer',
|
||||
'metadata' => 'array',
|
||||
'last_synced_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function latestTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerTask::class, 'latest_server_task_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServerSite extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'latest_server_task_id',
|
||||
'domain_id',
|
||||
'domain',
|
||||
'document_root',
|
||||
'php_version',
|
||||
'php_socket',
|
||||
'status',
|
||||
'ssl_status',
|
||||
'ssl_provisioned_at',
|
||||
'ssl_expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'ssl_provisioned_at' => 'datetime',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function latestTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerTask::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function domainModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class, 'domain_id');
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServerTask extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
public const STATUS_DISPATCHED = 'dispatched';
|
||||
public const STATUS_RUNNING = 'running';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const TYPE_DOMAIN_ADD = 'domain.add';
|
||||
public const TYPE_SSL_REQUEST = 'ssl.request';
|
||||
public const TYPE_SSL_RENEW = 'ssl.renew';
|
||||
public const TYPE_DATABASE_CREATE = 'database.create';
|
||||
public const TYPE_FILE_LIST = 'file.list';
|
||||
public const TYPE_FILE_READ = 'file.read';
|
||||
public const TYPE_FILE_WRITE = 'file.write';
|
||||
public const TYPE_SITE_DELETE = 'site.delete';
|
||||
public const TYPE_PHP_CONFIGURE = 'php.configure';
|
||||
public const TYPE_CRON_LIST = 'cron.list';
|
||||
public const TYPE_CRON_UPSERT = 'cron.upsert';
|
||||
public const TYPE_CRON_REMOVE = 'cron.remove';
|
||||
public const TYPE_LOG_READ = 'log.read';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'lock_token',
|
||||
'type',
|
||||
'status',
|
||||
'payload',
|
||||
'result',
|
||||
'progress',
|
||||
'attempt_count',
|
||||
'max_attempts',
|
||||
'retry_backoff_seconds',
|
||||
'stale_after_seconds',
|
||||
'queued_at',
|
||||
'available_at',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'failed_at',
|
||||
'last_heartbeat_at',
|
||||
'locked_at',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'result' => 'array',
|
||||
'queued_at' => 'datetime',
|
||||
'available_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
'locked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function sites(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSite::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerFile::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function canRetry(bool $retryable = true): bool
|
||||
{
|
||||
return $retryable && $this->attempt_count < max(1, (int) $this->max_attempts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* Thin local mirror of the platform identity (auth.ladill.com owns users).
|
||||
* Keyed by public_id (the OIDC `sub`), upserted from SSO claims on login.
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password'];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
|
||||
}
|
||||
|
||||
/** Active team memberships (accounts this user can act within as a member). */
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingTeamMember::class, 'user_id')
|
||||
->where('status', HostingTeamMember::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
/** Can this user act within the given account (own it, or active member)? */
|
||||
public function canAccessAccount(int $accountId): bool
|
||||
{
|
||||
return $accountId === $this->id
|
||||
|| $this->memberships()->where('account_id', $accountId)->exists();
|
||||
}
|
||||
|
||||
/** Owner Users for every account this user can act in (self first). */
|
||||
public function accessibleAccounts(): Collection
|
||||
{
|
||||
$ids = $this->memberships()->pluck('account_id')->all();
|
||||
|
||||
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class VpsInstance extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_plan_id',
|
||||
'name',
|
||||
'hostname',
|
||||
'provider',
|
||||
'provider_instance_id',
|
||||
'ip_address',
|
||||
'ipv6_address',
|
||||
'region',
|
||||
'datacenter',
|
||||
'image',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'disk_gb',
|
||||
'status',
|
||||
'power_status',
|
||||
'root_password_encrypted',
|
||||
'ssh_public_key',
|
||||
'tags',
|
||||
'metadata',
|
||||
'provisioned_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'metadata' => 'array',
|
||||
'provisioned_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = ['root_password_encrypted'];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingPlan::class, 'hosting_plan_id');
|
||||
}
|
||||
|
||||
public function snapshots(): HasMany
|
||||
{
|
||||
return $this->hasMany(VpsSnapshot::class);
|
||||
}
|
||||
|
||||
public function isRunning(): bool
|
||||
{
|
||||
return $this->status === 'running';
|
||||
}
|
||||
|
||||
public function scopeRunning($query)
|
||||
{
|
||||
return $query->where('status', 'running');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Website extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'owner_user_id',
|
||||
'name',
|
||||
'slug',
|
||||
'subdomain',
|
||||
'status',
|
||||
'theme_tokens',
|
||||
'logo_path',
|
||||
'footer_logo_path',
|
||||
'favicon_path',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'theme_tokens' => 'array',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function drafts(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebsiteDraft::class);
|
||||
}
|
||||
|
||||
public function domains(): HasMany
|
||||
{
|
||||
return $this->hasMany(Domain::class);
|
||||
}
|
||||
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebsiteMember::class);
|
||||
}
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subscription::class);
|
||||
}
|
||||
|
||||
public function leads(): HasMany
|
||||
{
|
||||
return $this->hasMany(Lead::class);
|
||||
}
|
||||
|
||||
public function mediaAssets(): HasMany
|
||||
{
|
||||
return $this->hasMany(MediaAsset::class);
|
||||
}
|
||||
|
||||
public function aiRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiRequest::class);
|
||||
}
|
||||
|
||||
public function publishes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Publish::class);
|
||||
}
|
||||
|
||||
public function paymentSetting()
|
||||
{
|
||||
return $this->hasOne(WebsitePaymentSetting::class);
|
||||
}
|
||||
|
||||
public function wallet()
|
||||
{
|
||||
return $this->hasOne(WebsiteWallet::class);
|
||||
}
|
||||
|
||||
public function ecommerceProducts(): HasMany
|
||||
{
|
||||
return $this->hasMany(EcommerceProduct::class);
|
||||
}
|
||||
|
||||
public function ecommerceOrders(): HasMany
|
||||
{
|
||||
return $this->hasMany(EcommerceOrder::class);
|
||||
}
|
||||
|
||||
public function blogPosts(): HasMany
|
||||
{
|
||||
return $this->hasMany(BlogPost::class);
|
||||
}
|
||||
|
||||
public function blogCategories(): HasMany
|
||||
{
|
||||
return $this->hasMany(BlogCategory::class);
|
||||
}
|
||||
|
||||
public function contactMessages(): HasMany
|
||||
{
|
||||
return $this->hasMany(ContactMessage::class);
|
||||
}
|
||||
|
||||
public function newsletterSubscribers(): HasMany
|
||||
{
|
||||
return $this->hasMany(NewsletterSubscriber::class);
|
||||
}
|
||||
|
||||
public function newsletterCampaigns(): HasMany
|
||||
{
|
||||
return $this->hasMany(NewsletterCampaign::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WebsiteMember extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'user_id',
|
||||
'role',
|
||||
];
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user