Show linked domains on cards and fix Email leftovers.
Deploy Ladill Hosting / deploy (push) Successful in 24s

Display hosted_sites on account cards, scope Afia and Settings to hosting,
add hosting_settings for notifications, and remove mailbox UI from the
hosting layout and account pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 17:30:40 +00:00
co-authored by Cursor
parent c4bd4efbed
commit 5703a00862
19 changed files with 202 additions and 329 deletions
@@ -3,29 +3,22 @@
namespace App\Http\Controllers\Hosting;
use App\Http\Controllers\Controller;
use App\Models\EmailSetting;
use App\Models\User;
use App\Models\HostingSetting;
use App\Services\Billing\BillingClient;
use App\Services\Identity\IdentityClient;
use App\Services\Mailbox\MailboxClient;
use App\Services\Payment\WalletPaymentService;
use App\Support\MailboxPricing;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Account Wallet, Billing, Settings. Money is read from the one platform
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
* Account Wallet, Billing, Settings for Ladill Hosting.
* Money is read from the one platform UserWallet via the Billing API.
*/
class AccountController extends Controller
{
public function __construct(
private WalletPaymentService $wallet,
private BillingClient $billing,
private MailboxClient $mailboxes,
private IdentityClient $identity,
) {}
private function topupUrl(): string
@@ -33,7 +26,6 @@ class AccountController extends Controller
return 'https://'.config('app.account_domain').'/wallet';
}
/** Wallet: balance + email spend summary + add-funds link. */
public function wallet(): View
{
$account = ladill_account();
@@ -47,34 +39,15 @@ class AccountController extends Controller
]);
}
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
public function billing(): View
{
$account = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
$paidMailboxes = [];
$monthlyTotalMinor = 0;
try {
foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) {
if (! ($m['is_paid'] ?? false)) {
continue;
}
$m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0));
$m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0));
$monthlyTotalMinor += $m['price_minor'];
$paidMailboxes[] = $m;
}
} catch (\Throwable $e) {
report($e);
}
return view('hosting.account.billing', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'paidMailboxes' => $paidMailboxes,
'monthlyTotalMinor' => $monthlyTotalMinor,
'topupUrl' => $this->topupUrl(),
]);
}
@@ -82,169 +55,26 @@ class AccountController extends Controller
public function settings(): View
{
$account = ladill_account();
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
$settings = HostingSetting::firstOrNew(['user_id' => $account?->id]);
return view('hosting.account.settings', [
'account' => $account,
'settings' => $settings,
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => $showMailboxLinkUi,
]);
}
public function linkMailbox(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'mailbox_address' => ['required', 'email', 'max:255'],
]);
try {
$result = $this->identity->linkMailbox(
(string) $account->public_id,
$data['mailbox_address'],
);
} catch (\Illuminate\Http\Client\RequestException $e) {
$message = (string) $e->response?->json('message', '');
if ($message !== '') {
return redirect()->route('account.settings')->with('error', $message);
}
report($e);
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
} catch (\Throwable $e) {
report($e);
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
}
$request->session()->forget('mailbox_link_banner_dismissed');
$connectUrl = (string) ($result['webmail_connect_url'] ?? '');
if ($connectUrl === '') {
$connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect';
}
return redirect()->away($connectUrl);
}
public function unlinkMailbox(Request $request): RedirectResponse
{
$account = ladill_account();
try {
$this->identity->unlinkMailbox((string) $account->public_id);
} catch (\Illuminate\Http\Client\RequestException $e) {
$message = (string) $e->response?->json('message', '');
if ($message !== '') {
return redirect()->route('account.settings')->with('error', $message);
}
report($e);
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
} catch (\Throwable $e) {
report($e);
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
}
$request->session()->forget('mailbox_link_banner_dismissed');
return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.');
}
/** @return array{linkStatus: array<string, mixed>, mailboxOptions: array<int, array<string, mixed>>, showMailboxLinkUi: bool} */
private function mailboxLinkContext(?User $account): array
{
$linkStatus = ['show_reminder' => false];
$mailboxOptions = [];
if (! $account?->public_id) {
return [
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => false,
];
}
try {
$mailboxOptions = $this->mailboxes->forUser((string) $account->public_id);
} catch (\Throwable $e) {
report($e);
}
try {
$linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id);
} catch (\Throwable $e) {
report($e);
$linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions);
}
$showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null)
|| ($linkStatus['show_reminder'] ?? false)
|| $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus);
return [
'linkStatus' => $linkStatus,
'mailboxOptions' => $mailboxOptions,
'showMailboxLinkUi' => $showMailboxLinkUi,
];
}
/** @param array<int, array<string, mixed>> $mailboxOptions */
private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool
{
if ($linkStatus['linked_mailbox'] ?? null) {
return false;
}
$email = strtolower(trim($account->email ?? ''));
if ($email === '') {
return false;
}
foreach ($mailboxOptions as $mailbox) {
if (strtolower((string) ($mailbox['address'] ?? '')) === $email) {
return false;
}
}
return count($mailboxOptions) > 0;
}
/** @param array<int, array<string, mixed>> $mailboxOptions */
/** @return array<string, mixed> */
private function localMailboxLinkStatus(User $account, array $mailboxOptions): array
{
$needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []);
return [
'show_reminder' => $needsLink,
'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox',
'linked_mailbox' => null,
'account_email' => $account->email,
];
}
public function updateSettings(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['nullable', 'boolean'],
]);
EmailSetting::updateOrCreate(
HostingSetting::updateOrCreate(
['user_id' => $account->id],
[
'default_quota_mb' => $data['default_quota_mb'] ?? null,
'notify_email' => $data['notify_email'] ?? null,
'product_updates' => $request->boolean('product_updates'),
],
@@ -265,7 +95,7 @@ class AccountController extends Controller
try {
return [
$this->wallet->balanceMinor(ladill_account()),
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')),
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'hosting')),
];
} catch (\Throwable $e) {
report($e);
+51 -26
View File
@@ -3,17 +3,17 @@
namespace App\Http\Controllers\Hosting;
use App\Http\Controllers\Controller;
use App\Models\CustomerHostingOrder;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Models\HostingAccountMember;
use App\Models\HostingProduct;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Services\EmailDomain\EmailDomainClient;
use App\Services\Mailbox\MailboxClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Ladill Email Afia chat endpoint. Grounds the assistant in the user's live
* mailboxes/domains so answers are specific.
*/
/** Afia chat for Ladill Hosting — grounded in the user's live hosting accounts. */
class AfiaController extends Controller
{
public function chat(Request $request, AfiaService $afia): JsonResponse
@@ -40,35 +40,60 @@ class AfiaController extends Controller
return response()->json(['reply' => $reply]);
}
/** @return array<string,mixed> Live email context for grounding. */
/** @return array<string,mixed> */
private function context(): array
{
$account = ladill_account();
$user = auth()->user();
if (! $account) {
if (! $user) {
return ['signed_in' => 'no'];
}
$pid = (string) $account->public_id;
$ctx = ['signed_in' => 'yes'];
$sharedTypes = [
HostingProduct::TYPE_SINGLE_DOMAIN,
HostingProduct::TYPE_MULTI_DOMAIN,
HostingProduct::TYPE_WORDPRESS,
];
try {
$mailboxes = app(MailboxClient::class)->forUser($pid);
$ctx['mailboxes_total'] = count($mailboxes);
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
} catch (\Throwable) {
}
$sharedAccountIds = HostingAccountMember::query()
->where('user_id', $user->id)
->pluck('hosting_account_id');
try {
$domains = app(EmailDomainClient::class)->forUser($pid);
$ctx['domains_total'] = count($domains);
$ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false)));
} catch (\Throwable) {
}
$accounts = HostingAccount::query()
->where(function ($query) use ($user, $sharedAccountIds) {
$query->where('user_id', $user->id);
if ($sharedAccountIds->isNotEmpty()) {
$query->orWhereIn('id', $sharedAccountIds);
}
})
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
->with('sites')
->get();
try {
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2);
} catch (\Throwable) {
$ctx = [
'signed_in' => 'yes',
'hosting_accounts_total' => $accounts->count(),
'hosting_accounts_active' => $accounts->where('status', HostingAccount::STATUS_ACTIVE)->count(),
'linked_domains' => HostedSite::query()
->whereIn('hosting_account_id', $accounts->pluck('id'))
->count(),
'pending_orders' => CustomerHostingOrder::query()
->forUser($user->id)
->whereIn('status', [
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
CustomerHostingOrder::STATUS_PENDING_APPROVAL,
CustomerHostingOrder::STATUS_APPROVED,
CustomerHostingOrder::STATUS_PROVISIONING,
])
->count(),
];
$account = ladill_account();
if ($account?->public_id) {
try {
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2);
} catch (\Throwable) {
}
}
return $ctx;
@@ -76,7 +76,7 @@ class HostingProductController extends Controller
}
})
->whereHas('product', fn ($q) => $q->ofType($type))
->with('product')
->with(['product', 'sites'])
->latest()
->get();
+20
View File
@@ -135,6 +135,26 @@ class HostingAccount extends Model
return $this->hasMany(HostedSite::class);
}
/** Primary label for UI lists — prefers linked hosted_sites over primary_domain. */
public function linkedDomainLabel(): ?string
{
if ($this->relationLoaded('sites') && $this->sites->isNotEmpty()) {
$domains = $this->sites->pluck('domain')->filter()->unique()->values();
if ($domains->count() === 1) {
return (string) $domains->first();
}
$preview = $domains->take(2)->implode(', ');
return $domains->count() > 2
? "{$preview} +".($domains->count() - 2)
: $preview;
}
return filled($this->primary_domain) ? (string) $this->primary_domain : null;
}
public function databases(): HasMany
{
return $this->hasMany(HostedDatabase::class);
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Per-account hosting preferences (notifications, product updates). */
class HostingSetting extends Model
{
protected $fillable = ['user_id', 'notify_email', 'product_updates'];
protected $casts = ['product_updates' => 'boolean'];
}
+36 -3
View File
@@ -6,9 +6,9 @@ use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia for Ladill Email an AI assistant scoped to buying & managing mailboxes
* and email domains. Self-contained: builds an email-specific system prompt +
* the user's live context and calls the configured LLM (OpenAI or Anthropic).
* Afia an AI assistant scoped to a Ladill product (hosting or email).
* Self-contained: builds a product-specific system prompt + live context
* and calls the configured LLM (OpenAI or Anthropic).
*/
class AfiaService
{
@@ -90,6 +90,39 @@ class AfiaService
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return match ((string) config('afia.product', 'hosting')) {
'email' => $this->emailSystemPrompt($ctx),
default => $this->hostingSystemPrompt($ctx),
};
}
private function hostingSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill Hosting Ladill's shared web hosting product (hosting.ladill.com).
Help the user buy hosting, manage accounts, link domains, use the control panel, and understand billing. Be concise,
friendly, and actionable: give short step-by-step answers and point to the relevant section by name.
What Ladill Hosting does and where things live:
- Dashboard: overview of all hosting accounts, pending orders, and linked domains.
- Product pages: Single Domain, Multi Domain, and WordPress hosting order new plans here.
- Account overview: per-account summary (plan, expiry, linked domains, email usage) with a Control Panel button.
- Control Panel: file manager, domains, databases, SSL, PHP settings, cron, terminal, and one-click apps.
- Domains: link a Ladill domain or an external domain to a hosting account; DNS may need updating for external domains.
- Billing: hosting renewals and upgrades are paid from the Ladill wallet (account portal Wallet).
- Team: invite teammates to co-manage hosting (sidebar Team).
Rules: Only answer questions about Ladill Hosting plans, accounts, domains on hosting, the control panel,
SSL, file manager, renewals, and wallet billing. If asked about Ladill Email, domains registration, or VPS/servers,
briefly redirect to the right Ladill app. Never invent passwords, DNS values, or prices tell the user where to find them.
Current user context:
{$ctx}
PROMPT;
}
private function emailSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill Email Ladill's mailbox product (email.ladill.com).
Help the user set up email domains, create and manage mailboxes, and understand billing. Be concise,