Add team, developers, PDF type, and QR rendering dependency.
Deploy Ladill QR Plus / deploy (push) Successful in 42s

Enables account collaboration, API tokens, hosted PDF codes, and fixes QR detail 500s by declaring chillerlan/php-qrcode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 06:16:13 +00:00
co-authored by Cursor
parent bcd1cf5d28
commit 8c3e9d3c26
23 changed files with 892 additions and 28 deletions
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -81,6 +82,8 @@ class SsoLoginController extends Controller
],
);
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
@@ -30,7 +30,7 @@ class AccountController extends Controller
public function wallet(): View
{
$user = auth()->user();
$user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.wallet', [
@@ -43,7 +43,7 @@ class AccountController extends Controller
public function billing(): View
{
$user = auth()->user();
$user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.billing', [
@@ -56,6 +56,6 @@ class AccountController extends Controller
public function settings(): View
{
return view('qr.account.settings', ['account' => auth()->user()]);
return view('qr.account.settings', ['account' => ladill_account()]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Developers personal API tokens for the Ladill QR Plus API (Sanctum). Tokens
* belong to the signed-in user and authorize calls to /api/v1/*. The plaintext
* token is shown once on create.
*/
class DeveloperController extends Controller
{
public function index(Request $request): View
{
return view('qr.account.developers', [
'tokens' => $request->user()->tokens()->latest()->get(),
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
'newToken' => session('new_token'),
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:60'],
]);
$token = $request->user()->createToken($data['name'], ['qr:read']);
return redirect()->route('account.developers')
->with('new_token', $token->plainTextToken)
->with('success', 'Token created — copy it now, it wont be shown again.');
}
public function destroy(Request $request, int $token): RedirectResponse
{
$request->user()->tokens()->whereKey($token)->delete();
return redirect()->route('account.developers')->with('success', 'Token revoked.');
}
}
@@ -21,20 +21,20 @@ class OverviewController extends Controller
public function index(Request $request): View
{
$user = $request->user();
$wallet = $this->manager->walletFor($user);
$codes = $user->qrCodes()
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$codes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->latest()
->limit(5)
->get();
$activeCount = $user->qrCodes()
$activeCount = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->where('is_active', true)
->count();
$scans30d = $user->qrCodes()
$scans30d = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->withSum(['scanEvents as scans_30d' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))], 'id')
->get()
@@ -42,10 +42,10 @@ class OverviewController extends Controller
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($user->public_id);
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('QR Plus dashboard could not load wallet balance', [
'user' => $user->public_id,
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
+23 -11
View File
@@ -40,19 +40,19 @@ class QrCodeController extends Controller
public function index(Request $request): View
{
$user = $request->user();
$wallet = $this->manager->walletFor($user);
$qrCodes = $user->qrCodes()
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrCodes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->latest()
->get();
$ladillWalletBalance = 0.0;
try {
$ladillWalletBalance = $this->platformBilling->balanceMinor($user->public_id) / 100;
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
} catch (Throwable $e) {
Log::warning('QR Plus index could not load Ladill wallet balance', [
'user' => $user->public_id,
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
@@ -69,7 +69,8 @@ class QrCodeController extends Controller
public function create(Request $request): View
{
$wallet = $this->manager->walletFor($request->user());
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
return view('qr-codes.create', [
'wallet' => $wallet,
@@ -80,7 +81,7 @@ class QrCodeController extends Controller
'frameStyles' => QrFrameStyleCatalog::visible(),
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($request->user()->public_id) / 100,
'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
@@ -134,7 +135,7 @@ class QrCodeController extends Controller
}
try {
$qrCode = $this->manager->create($request->user(), array_merge(
$qrCode = $this->manager->create(ladill_account(), array_merge(
$request->all(),
[
'style' => $validated['style'] ?? [],
@@ -175,13 +176,24 @@ class QrCodeController extends Controller
$logoDataUri = ! empty($qrStyle['logo_path'])
? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
: null;
$wallet = $this->manager->walletFor($request->user());
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$summary = $this->analytics->summaryFor($qrCode);
$dailyScans = $this->analytics->dailyScans($qrCode, 30);
$devices = $this->analytics->breakdown($qrCode, 'device_type');
$browsers = $this->analytics->breakdown($qrCode, 'browser');
$recentScans = $this->analytics->recentScans($qrCode);
$ladillWalletBalance = 0.0;
try {
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
} catch (Throwable $e) {
Log::warning('QR Plus show could not load Ladill wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('qr-codes.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
@@ -199,7 +211,7 @@ class QrCodeController extends Controller
'recentScans' => $recentScans,
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($request->user()->public_id) / 100,
'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
@@ -281,7 +293,7 @@ class QrCodeController extends Controller
$style['logo_path'] = $tempLogoPath;
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
$existingPath = $validated['existing_logo_path'];
$userPrefix = $request->user()->id . '/';
$userPrefix = ladill_account()->id . '/';
if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
$style['logo_path'] = $existingPath;
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$members = QrTeamMember::query()
->where('account_id', $account->id)
->with('member')
->orderBy('status')->orderBy('email')
->get();
return view('qr.account.team', [
'account' => $account,
'members' => $members,
'canManage' => $this->canManage($request),
'isOwner' => $request->user()->id === $account->id,
]);
}
public function store(Request $request): RedirectResponse
{
abort_unless($this->canManage($request), 403);
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'in:admin,member'],
]);
$account = ladill_account();
$email = strtolower($validated['email']);
if ($email === strtolower($account->email)) {
return back()->withErrors(['email' => 'The owner is already on the account.']);
}
$member = QrTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
$member->role = $validated['role'];
if (! $member->exists) {
$member->status = QrTeamMember::STATUS_INVITED;
$member->token = Str::random(40);
}
$member->save();
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
QrTeamMember::linkPendingInvitesFor($existing);
}
return back()->with('success', $email.' invited.');
}
public function updateRole(Request $request, QrTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
$member->update(['role' => $validated['role']]);
return back()->with('success', 'Role updated.');
}
public function destroy(Request $request, QrTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$member->delete();
return back()->with('success', 'Member removed.');
}
public function switchAccount(Request $request): RedirectResponse
{
$validated = $request->validate(['account' => ['required', 'integer']]);
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
$request->session()->put('ladill_account', (int) $validated['account']);
return redirect()->route('qr.dashboard');
}
private function canManage(Request $request): bool
{
$user = $request->user();
$account = ladill_account();
if ($user->id === $account->id) {
return true;
}
return $user->memberships()
->where('account_id', $account->id)
->where('role', QrTeamMember::ROLE_ADMIN)
->exists();
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
$accountId = (int) $request->session()->get('ladill_account', $user->id);
if (! $user->canAccessAccount($accountId)) {
$accountId = $user->id;
$request->session()->put('ladill_account', $accountId);
}
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
$request->attributes->set('actingAccount', $account);
View::share('actingAccount', $account);
View::share('accessibleAccounts', $user->accessibleAccounts());
}
return $next($request);
}
}