Initial Ladill Frontdesk release with deploy pipeline.
Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,46 @@
|
||||
APP_NAME="Ladill Frontdesk"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://frontdesk.ladill.com
|
||||
|
||||
PLATFORM_URL=https://ladill.com
|
||||
PLATFORM_DOMAIN=ladill.com
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ladill_frontdesk
|
||||
DB_USERNAME=ladill_frontdesk
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
CACHE_STORE=database
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) ---
|
||||
LADILL_SSO_CLIENT_ID=
|
||||
LADILL_SSO_CLIENT_SECRET=
|
||||
|
||||
# --- Platform APIs this app consumes (per-consumer service keys) ---
|
||||
BILLING_API_URL=https://ladill.com/api/billing
|
||||
BILLING_API_KEY_FRONTDESK=
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_FRONTDESK=
|
||||
|
||||
# Platform events webhook (user/org lifecycle from the monolith)
|
||||
SERVICE_EVENTS_INBOUND_SECRET=
|
||||
|
||||
# --- Inbound service API keys (sibling Ladill apps calling Frontdesk) ---
|
||||
FRONTDESK_API_KEY_POS=
|
||||
FRONTDESK_API_KEY_CRM=
|
||||
FRONTDESK_API_KEY_CARE=
|
||||
FRONTDESK_API_KEY_LAB=
|
||||
|
||||
# Optional: platform DB read for SSO user resolution (same host as monolith)
|
||||
# PLATFORM_DB_DATABASE=ladilldb
|
||||
# PLATFORM_DB_USERNAME=
|
||||
# PLATFORM_DB_PASSWORD=
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
@@ -0,0 +1,110 @@
|
||||
name: Deploy Ladill Frontdesk
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-frontdesk
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy
|
||||
env:
|
||||
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||
NODE_VERSION: "22.14.0"
|
||||
RELEASE_ARCHIVE: /tmp/ladill-frontdesk-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-frontdesk-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-frontdesk
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
|
||||
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||
rm -rf "$WORKSPACE"
|
||||
mkdir -p "$WORKSPACE"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
git \
|
||||
-c credential.helper= \
|
||||
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
|
||||
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||
"$REPO_URL" "$WORKSPACE"
|
||||
|
||||
- name: Setup Node.js
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||
rm -rf "$NODE_ROOT"
|
||||
mkdir -p "$NODE_ROOT"
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) NODE_ARCH="x64" ;;
|
||||
aarch64|arm64) NODE_ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
|
||||
fi
|
||||
"$NODE_ROOT/bin/node" -v
|
||||
|
||||
- name: Build frontend assets
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cd "$WORKSPACE"
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- name: Build release archive
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cd "$WORKSPACE"
|
||||
printf '%s\n' "${{ gitea.sha }}" > REVISION
|
||||
rm -f "$RELEASE_ARCHIVE"
|
||||
tar -czf "$RELEASE_ARCHIVE" \
|
||||
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
|
||||
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
|
||||
|
||||
- name: Deploy release
|
||||
shell: bash {0}
|
||||
env:
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-frontdesk-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||
bash "$WORKSPACE/deploy/deploy.sh"
|
||||
|
||||
- name: Ensure nginx vhost
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh"
|
||||
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||
NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
|
||||
fi
|
||||
if [ ! -f "$NGINX_SCRIPT" ]; then
|
||||
echo "WARN: setup-service-subdomain-nginx.sh not found — configure frontdesk.ladill.com vhost manually"
|
||||
exit 0
|
||||
fi
|
||||
if sudo -n bash "$NGINX_SCRIPT" frontdesk --app /var/www/ladill-frontdesk/current; then
|
||||
echo "nginx vhost updated for frontdesk.ladill.com"
|
||||
else
|
||||
echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned"
|
||||
fi
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: bash {0}
|
||||
run: |
|
||||
rm -rf "$WORKSPACE"
|
||||
rm -f "$RELEASE_ARCHIVE"
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
coverage: none
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- name: Run tests
|
||||
run: php artisan test
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/*
|
||||
!/storage/framework/views/.gitignore
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
# Native mobile apps — kept locally, not tracked in this repo
|
||||
/apps
|
||||
|
||||
/database/database.sqlite
|
||||
@@ -0,0 +1,65 @@
|
||||
# Ladill Frontdesk — deploy runbook
|
||||
|
||||
Visitor management at **frontdesk.ladill.com** (check-in, badges, kiosk, reports).
|
||||
|
||||
## 1. Gitea repo + CI
|
||||
|
||||
- Repo: **ladill-frontdesk** (`isaacclad/ladill-frontdesk`).
|
||||
- Push to `main` triggers `.gitea/workflows/deploy.yml` (on-host `deploy` runner).
|
||||
- App root: `/var/www/ladill-frontdesk`.
|
||||
|
||||
## 2. Server app-slot + database
|
||||
|
||||
```bash
|
||||
sudo install -d -o deploy -g www-data /var/www/ladill-frontdesk
|
||||
sudo install -d -o deploy -g www-data /var/www/ladill-frontdesk/{releases,shared}
|
||||
sudo mysql -e "CREATE DATABASE ladill_frontdesk CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
sudo mysql -e "CREATE USER 'ladill_frontdesk'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||
sudo mysql -e "GRANT ALL ON ladill_frontdesk.* TO 'ladill_frontdesk'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
## 3. Platform wiring (monolith)
|
||||
|
||||
On the Ladill platform host:
|
||||
|
||||
```bash
|
||||
cd /var/www/ladill.com/current
|
||||
php artisan ladill:onboard-app frontdesk --run-dns
|
||||
php artisan passport:client --name="Ladill Frontdesk" --redirect_uri=https://frontdesk.ladill.com/sso/callback
|
||||
```
|
||||
|
||||
Apply the generated `IDENTITY_API_KEY_FRONTDESK`, `BILLING_API_KEY_FRONTDESK`, and
|
||||
`SERVICE_EVENTS_FRONTDESK_*` values to the monolith `.env`, then:
|
||||
|
||||
```bash
|
||||
php artisan config:cache
|
||||
```
|
||||
|
||||
## 4. Shared `.env` (`/var/www/ladill-frontdesk/shared/.env`)
|
||||
|
||||
Copy `.env.example`, set `APP_KEY` (`php artisan key:generate --show`), DB creds, SSO
|
||||
client id/secret, billing + identity keys, and `SERVICE_EVENTS_INBOUND_SECRET`.
|
||||
|
||||
## 5. nginx + TLS
|
||||
|
||||
```bash
|
||||
sudo bash deployment/setup-service-subdomain-nginx.sh frontdesk --app /var/www/ladill-frontdesk/current
|
||||
```
|
||||
|
||||
DNS: `frontdesk.ladill.com` → app server (via `dns:sync-subdomains` on the monolith).
|
||||
|
||||
## 6. First deploy
|
||||
|
||||
Push to `main` or run the Gitea deploy workflow manually. Then verify:
|
||||
|
||||
```bash
|
||||
curl -sI https://frontdesk.ladill.com | head -1
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://frontdesk.ladill.com/login
|
||||
```
|
||||
|
||||
## 7. Optional queue worker
|
||||
|
||||
```bash
|
||||
sudo cp deployment/supervisor/ladill-frontdesk-worker.conf /etc/supervisor/conf.d/
|
||||
sudo supervisorctl reread && sudo supervisorctl update
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# Ladill CRM
|
||||
|
||||
The Ladill **sales CRM** at `crm.ladill.com` — a full SSO web app for managing
|
||||
**contacts, leads, deals, activities and products** — *and* the shared
|
||||
Customers/Leads/Products service consumed over HTTP by Ladill Invoice, Merchant,
|
||||
Mini and future apps, so contact + catalog data is owned in one place and reused
|
||||
everywhere.
|
||||
|
||||
Every record is scoped to one platform account via `owner_ref` (the user's
|
||||
`public_id` / OIDC `sub`).
|
||||
|
||||
## App (web UI — Sign in with Ladill)
|
||||
|
||||
Authenticates against `auth.ladill.com` (OIDC Authorization Code + PKCE; see
|
||||
`Auth\SsoLoginController`). Local session is subordinate to the platform session
|
||||
(`platform.session` middleware) and joins Single Logout via
|
||||
`/sso/logout-frontchannel`.
|
||||
|
||||
- **Dashboard** — KPIs (contacts, open leads, open deals, pipeline value),
|
||||
tasks due, recent activity.
|
||||
- **Contacts** — the contact book, with a per-contact **timeline** (notes,
|
||||
tasks, logged calls/meetings, sent email + SMS) and cross-product events
|
||||
(invoices, payments, orders) pushed from sibling apps.
|
||||
- **Leads** — list + **kanban board** (`new → contacted → qualified`), convert
|
||||
to a contact.
|
||||
- **Deals** — drag-free **pipeline** kanban across configurable stages
|
||||
(`config/crm.pipeline_stages`), won/lost tracking.
|
||||
- **Activities** — notes, tasks (with due dates / completion), calls, meetings.
|
||||
- **Products & services** — reusable catalog items.
|
||||
- **Comms** — email contacts (via the app mailer / Ladill Bird SMTP) and SMS
|
||||
(Termii); each send is logged on the timeline.
|
||||
- **Reports** — pipeline by stage, win rate, won value over time, leads by
|
||||
status, activity mix.
|
||||
|
||||
## Service API (first-party, service-key auth)
|
||||
|
||||
Authenticate with a per-consumer service key:
|
||||
`Authorization: Bearer <CRM_API_KEY_*>` (see `config/crm.php`,
|
||||
`App\Http\Middleware\AuthenticateService`, alias `auth.service:crm`). Every
|
||||
request must pass `owner` (query or body) to scope data to the end user.
|
||||
|
||||
```
|
||||
GET /api/customers?owner=<sub>&search=
|
||||
POST /api/customers {owner,name,email,phone,company,...}
|
||||
... (show/update/destroy)
|
||||
|
||||
GET /api/leads?owner=<sub>&status=
|
||||
POST /api/leads {owner,name,status,estimated_value_minor,...}
|
||||
POST /api/leads/{id}/convert {owner} -> creates/links a customer
|
||||
|
||||
GET /api/products?owner=<sub>&type=&active=
|
||||
POST /api/products {owner,name,type,unit_price_minor,currency,tax_rate}
|
||||
|
||||
POST /api/timeline {owner,event,title,external_id,amount_minor,
|
||||
currency,url,customer_id|customer_email,...}
|
||||
```
|
||||
|
||||
`POST /api/timeline` is how sibling products push "what happened" events onto a
|
||||
contact's timeline (`invoice.sent`, `payment.received`, `order.paid`). The
|
||||
`source` is derived from the service key (never client-supplied) and the write
|
||||
is idempotent per `(owner, source, external_id, event)`. Reference consumer:
|
||||
`ladill-invoice` `App\Services\Crm\CrmClient::pushTimeline()`.
|
||||
|
||||
No key → 401; missing `owner` → 422; cross-owner access → 404.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
composer install
|
||||
cp .env.example .env && php artisan key:generate
|
||||
# sqlite for local: set DB_CONNECTION=sqlite and `touch database/database.sqlite`
|
||||
php artisan migrate
|
||||
npm install && npm run build # or `npm run dev`
|
||||
php artisan serve
|
||||
php artisan test
|
||||
```
|
||||
|
||||
See `DEPLOY.md` for production cutover.
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Frontdesk\DeviceService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MarkDevicesOfflineCommand extends Command
|
||||
{
|
||||
protected $signature = 'frontdesk:mark-devices-offline {--minutes=10 : Minutes since last heartbeat}';
|
||||
|
||||
protected $description = 'Mark devices without recent heartbeat as offline';
|
||||
|
||||
public function handle(DeviceService $devices): int
|
||||
{
|
||||
$count = $devices->markStaleDevicesOffline((int) $this->option('minutes'));
|
||||
|
||||
$this->info("Marked {$count} device(s) as offline.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\NotificationDispatcher;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MarkExpiredBadgesCommand extends Command
|
||||
{
|
||||
protected $signature = 'frontdesk:mark-expired-badges';
|
||||
|
||||
protected $description = 'Log and alert on checked-in visits with expired badges';
|
||||
|
||||
public function handle(NotificationDispatcher $notifications): int
|
||||
{
|
||||
$visits = Visit::query()
|
||||
->where('status', Visit::STATUS_CHECKED_IN)
|
||||
->whereNotNull('badge_expires_at')
|
||||
->where('badge_expires_at', '<', now())
|
||||
->with(['visitor', 'organization'])
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($visits as $visit) {
|
||||
$alreadyLogged = AuditLog::query()
|
||||
->where('subject_type', Visit::class)
|
||||
->where('subject_id', $visit->id)
|
||||
->where('action', 'badge.expired')
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->exists();
|
||||
|
||||
if ($alreadyLogged) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AuditLog::record(
|
||||
$visit->owner_ref,
|
||||
'badge.expired',
|
||||
$visit->organization_id,
|
||||
null,
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
[
|
||||
'visitor' => $visit->visitor->full_name,
|
||||
'badge_code' => $visit->badge_code,
|
||||
'expired_at' => $visit->badge_expires_at?->toIso8601String(),
|
||||
],
|
||||
);
|
||||
|
||||
$notifications->badgeExpired($visit);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("Recorded {$count} expired badge alert(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Frontdesk\VisitLifecycleService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MarkOverdueVisitsCommand extends Command
|
||||
{
|
||||
protected $signature = 'frontdesk:mark-overdue-visits';
|
||||
|
||||
protected $description = 'Mark expected and scheduled visits past their scheduled time as overdue';
|
||||
|
||||
public function handle(VisitLifecycleService $lifecycle): int
|
||||
{
|
||||
$count = $lifecycle->markOverdueVisits();
|
||||
|
||||
$this->info("Marked {$count} visit(s) as overdue.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Services\Comms\EmailService;
|
||||
use App\Services\Frontdesk\ReportService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class SendDailyReportCommand extends Command
|
||||
{
|
||||
protected $signature = 'frontdesk:send-daily-reports';
|
||||
|
||||
protected $description = 'Email daily visitor summary reports to configured org admins';
|
||||
|
||||
public function handle(ReportService $reports, EmailService $email): int
|
||||
{
|
||||
$from = now()->subDay()->startOfDay();
|
||||
$to = now()->subDay()->endOfDay();
|
||||
$sent = 0;
|
||||
|
||||
Organization::query()->each(function (Organization $organization) use ($reports, $email, $from, $to, &$sent) {
|
||||
$recipients = data_get($organization->settings, 'report_daily_recipients', []);
|
||||
if ($recipients === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$summary = $reports->summary($organization->owner_ref, $organization, $from, $to);
|
||||
$subject = "Frontdesk daily summary — {$organization->name} ({$from->toDateString()})";
|
||||
$body = implode("\n", [
|
||||
"Visitors checked in: {$summary['checked_in']}",
|
||||
"Currently unique visitors: {$summary['unique_visitors']}",
|
||||
"Contractors: {$summary['contractors']}",
|
||||
"Deliveries: {$summary['deliveries']}",
|
||||
"Average visit duration (min): {$summary['avg_duration_minutes']}",
|
||||
]);
|
||||
|
||||
foreach ($recipients as $address) {
|
||||
if (is_string($address) && filter_var($address, FILTER_VALIDATE_EMAIL)) {
|
||||
$email->send($address, $subject, $body);
|
||||
$sent++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Sent {$sent} daily report email(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Printers;
|
||||
|
||||
use App\Models\Visit;
|
||||
|
||||
interface PrinterDriverInterface
|
||||
{
|
||||
public function name(): string;
|
||||
|
||||
/** @return array{format: string, content: string, filename?: string} */
|
||||
public function renderBadge(Visit $visit): array;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ScopesToOwner
|
||||
{
|
||||
protected function ownerRef(Request $request): string
|
||||
{
|
||||
$owner = trim((string) ($request->input('owner') ?? $request->query('owner', '')));
|
||||
abort_if($owner === '', 422, 'The owner parameter is required.');
|
||||
|
||||
return $owner;
|
||||
}
|
||||
|
||||
protected function authorizeOwner(Request $request, Model $model): void
|
||||
{
|
||||
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Frontdesk\DeviceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DeviceHeartbeatController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, DeviceService $devices): JsonResponse
|
||||
{
|
||||
$token = $request->header('X-Device-Token') ?? $request->input('token');
|
||||
abort_unless(is_string($token) && $token !== '', 422, 'Device token required.');
|
||||
|
||||
$device = $devices->findByToken($token);
|
||||
abort_unless($device, 404);
|
||||
|
||||
$devices->recordHeartbeat($device);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'online',
|
||||
'last_online_at' => $device->fresh()->last_online_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\ScopesToOwner;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\OfflineCheckIn;
|
||||
use App\Models\Organization;
|
||||
use App\Services\Frontdesk\DeviceService;
|
||||
use App\Services\Frontdesk\VisitCheckInService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OfflineSyncController extends Controller
|
||||
{
|
||||
public function store(Request $request, DeviceService $devices, VisitCheckInService $checkIn): JsonResponse
|
||||
{
|
||||
$token = $request->header('X-Device-Token') ?? $request->input('device_token');
|
||||
abort_unless(is_string($token) && $token !== '', 422);
|
||||
|
||||
$device = $devices->findByToken($token);
|
||||
abort_unless($device, 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'client_id' => ['required', 'uuid'],
|
||||
'payload' => ['required', 'array'],
|
||||
'payload.full_name' => ['required', 'string', 'max:255'],
|
||||
'payload.visitor_type' => ['required', 'string'],
|
||||
'payload.policies_accepted' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$existing = OfflineCheckIn::where('client_id', $validated['client_id'])->first();
|
||||
if ($existing?->visit_id) {
|
||||
return response()->json([
|
||||
'visit_id' => $existing->visit_id,
|
||||
'status' => 'already_synced',
|
||||
]);
|
||||
}
|
||||
|
||||
$organization = Organization::findOrFail($device->organization_id);
|
||||
$payload = array_merge($validated['payload'], [
|
||||
'branch_id' => $device->branch_id,
|
||||
'reception_desk_id' => $device->reception_desk_id,
|
||||
]);
|
||||
|
||||
$visit = $checkIn->checkIn($device->owner_ref, $organization, $payload);
|
||||
|
||||
OfflineCheckIn::updateOrCreate(
|
||||
['client_id' => $validated['client_id']],
|
||||
[
|
||||
'device_id' => $device->id,
|
||||
'payload' => $validated['payload'],
|
||||
'synced_at' => now(),
|
||||
'visit_id' => $visit->id,
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'visit_id' => $visit->id,
|
||||
'public_id' => $visit->public_id,
|
||||
'status' => 'synced',
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\ScopesToOwner;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\VisitCheckInService;
|
||||
use App\Services\Frontdesk\VisitCheckOutService;
|
||||
use App\Services\Frontdesk\VisitLifecycleService;
|
||||
use App\Services\Frontdesk\VisitScheduleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VisitController extends Controller
|
||||
{
|
||||
use ScopesToOwner;
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$visits = Visit::owned($owner)
|
||||
->with(['visitor', 'host'])
|
||||
->when($request->status, fn ($q, $s) => $q->where('status', $s))
|
||||
->latest()
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
||||
return response()->json($visits);
|
||||
}
|
||||
|
||||
public function store(Request $request, VisitCheckInService $checkIn, VisitScheduleService $scheduler): JsonResponse
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$organization = Organization::owned($owner)->findOrFail($request->integer('organization_id'));
|
||||
$source = (string) $request->attributes->get('service_caller', 'api');
|
||||
|
||||
if ($request->filled('external_ref')) {
|
||||
$existing = Visit::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('external_ref', $request->string('external_ref'))
|
||||
->where('source', $source)
|
||||
->with(['visitor', 'host'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json($existing);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->boolean('schedule')) {
|
||||
$validated = $request->validate([
|
||||
'organization_id' => ['required', 'integer'],
|
||||
'external_ref' => ['nullable', 'string', 'max:128'],
|
||||
'visitor_id' => ['nullable', 'integer'],
|
||||
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string'],
|
||||
'phone' => ['nullable', 'string'],
|
||||
'email' => ['nullable', 'email'],
|
||||
'host_id' => ['nullable', 'integer'],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
'visitor_type' => ['required', 'string'],
|
||||
'purpose' => ['nullable', 'string'],
|
||||
'scheduled_at' => ['required', 'date'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'integration_metadata' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$visit = $scheduler->schedule($owner, $organization, [
|
||||
...$validated,
|
||||
'source' => $source,
|
||||
]);
|
||||
|
||||
return response()->json($visit->load(['visitor', 'host']), 201);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'organization_id' => ['required', 'integer'],
|
||||
'external_ref' => ['nullable', 'string', 'max:128'],
|
||||
'full_name' => ['required', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string'],
|
||||
'phone' => ['nullable', 'string'],
|
||||
'email' => ['nullable', 'email'],
|
||||
'host_id' => ['nullable', 'integer'],
|
||||
'visitor_type' => ['required', 'string'],
|
||||
'purpose' => ['nullable', 'string'],
|
||||
'integration_metadata' => ['nullable', 'array'],
|
||||
'policies_accepted' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$visit = $checkIn->checkIn($owner, $organization, [
|
||||
...$validated,
|
||||
'source' => $source,
|
||||
'policies_accepted' => $validated['policies_accepted'] ?? true,
|
||||
]);
|
||||
|
||||
return response()->json($visit->load(['visitor', 'host']), 201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Visit $visit): JsonResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
return response()->json($visit->load(['visitor', 'host']));
|
||||
}
|
||||
|
||||
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): JsonResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
return response()->json($checkOut->checkOut($visit));
|
||||
}
|
||||
|
||||
public function activate(Request $request, Visit $visit, VisitLifecycleService $lifecycle): JsonResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$visit = $lifecycle->checkInFromSchedule($visit);
|
||||
|
||||
return response()->json($visit->load(['visitor', 'host']));
|
||||
}
|
||||
|
||||
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): JsonResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$visit = $lifecycle->approve($visit);
|
||||
|
||||
return response()->json($visit->load(['visitor', 'host']));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Visit $visit, VisitLifecycleService $lifecycle): JsonResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
$lifecycle->cancel($visit, null, $validated['reason'] ?? null)->load(['visitor', 'host']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\ScopesToOwner;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Visitor;
|
||||
use App\Services\Frontdesk\VisitorSearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VisitorController extends Controller
|
||||
{
|
||||
use ScopesToOwner;
|
||||
|
||||
public function index(Request $request, VisitorSearchService $search): JsonResponse
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$organizationId = (int) $request->query('organization_id');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
return response()->json(
|
||||
$search->search($owner, $organizationId, $request->string('q')->toString())
|
||||
);
|
||||
}
|
||||
|
||||
$visitors = Visitor::owned($owner)
|
||||
->when($organizationId, fn ($q) => $q->where('organization_id', $organizationId))
|
||||
->orderBy('full_name')
|
||||
->paginate(min((int) $request->query('per_page', 25), 100));
|
||||
|
||||
return response()->json($visitors);
|
||||
}
|
||||
|
||||
public function show(Request $request, Visitor $visitor): JsonResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $visitor);
|
||||
|
||||
return response()->json($visitor->load(['visits' => fn ($q) => $q->latest()->limit(10)]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||
*/
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* Max consecutive failed callbacks before we stop bouncing back into the
|
||||
* authorize flow and show an error page instead (prevents an infinite
|
||||
* /sso/callback ↔ /sso/connect redirect loop on a persistent upstream error).
|
||||
*/
|
||||
private const MAX_SSO_ATTEMPTS = 3;
|
||||
|
||||
public function connect(Request $request): RedirectResponse|View
|
||||
{
|
||||
$intended = (string) $request->query('redirect', route('frontdesk.dashboard'));
|
||||
|
||||
if (Auth::check()) {
|
||||
return $this->safeRedirect($intended, route('frontdesk.dashboard'));
|
||||
}
|
||||
|
||||
if (! $request->boolean('fallback')) {
|
||||
$request->session()->forget('sso.attempts');
|
||||
}
|
||||
|
||||
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||
return $this->safeRedirect($intended, route('frontdesk.dashboard'));
|
||||
}
|
||||
|
||||
$verifier = Str::random(64);
|
||||
$state = Str::random(40);
|
||||
$request->session()->put('sso.verifier', $verifier);
|
||||
$request->session()->put('sso.state', $state);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
];
|
||||
|
||||
$loginHint = (string) $request->session()->get('sso.login_hint', '');
|
||||
if ($loginHint !== '') {
|
||||
$query['login_hint'] = $loginHint;
|
||||
}
|
||||
|
||||
if (! $request->boolean('interactive')) {
|
||||
$query['prompt'] = 'none';
|
||||
}
|
||||
|
||||
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
|
||||
|
||||
return redirect()->away($authorizeUrl);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$intended = (string) $request->session()->get('sso.intended', route('frontdesk.dashboard'));
|
||||
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
|
||||
}
|
||||
|
||||
if (! $request->filled('code')
|
||||
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||
return $this->finishCallback($request, $intended, 'invalid_state');
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'code' => (string) $request->query('code'),
|
||||
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||
]);
|
||||
if ($tokenRes->failed()) {
|
||||
return $this->finishCallback($request, $intended, 'token_exchange_failed');
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
if (! $user) {
|
||||
return $this->finishCallback($request, $intended, 'userinfo_failed');
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('sso.attempts');
|
||||
|
||||
return $this->finishCallback($request, $intended, null);
|
||||
}
|
||||
|
||||
public function failed(Request $request): View
|
||||
{
|
||||
return view('auth.sso-error', [
|
||||
'reason' => (string) $request->session()->get('sso.error', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Per-app sign-out: end only this app's session and keep the platform
|
||||
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently.
|
||||
return redirect()->route('frontdesk.signed-out');
|
||||
}
|
||||
|
||||
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||
public function platformSignedOut(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => (string) $request->query('redirect', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutBridge(Request $request): View
|
||||
{
|
||||
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
|
||||
'embedded' => 1,
|
||||
'return' => $return,
|
||||
]);
|
||||
|
||||
return view('auth.sso-logout-bridge', [
|
||||
'hubUrl' => $hubUrl,
|
||||
'return' => $return,
|
||||
'authOrigin' => 'https://'.config('app.auth_domain'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$return = (string) $request->query('return', '');
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$host = parse_url($return, PHP_URL_HOST);
|
||||
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($return);
|
||||
}
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
private function attemptSilentRefresh(Request $request, string $intended): bool
|
||||
{
|
||||
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
|
||||
if ($refreshToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'scope' => 'openid profile email',
|
||||
]);
|
||||
|
||||
if ($tokenRes->failed()) {
|
||||
$request->session()->forget('sso.refresh_token');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
|
||||
{
|
||||
$refreshToken = (string) $tokenRes->json('refresh_token', '');
|
||||
if ($refreshToken !== '') {
|
||||
$request->session()->put('sso.refresh_token', $refreshToken);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||
if ($claims->failed() || ! $claims->json('sub')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = (string) ($claims->json('email') ?: '');
|
||||
if ($email !== '') {
|
||||
$request->session()->put('sso.login_hint', $email);
|
||||
}
|
||||
|
||||
return User::updateOrCreate(
|
||||
['public_id' => (string) $claims->json('sub')],
|
||||
[
|
||||
'name' => $claims->json('name'),
|
||||
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
|
||||
'avatar_url' => $claims->json('picture'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
|
||||
{
|
||||
if ($error) {
|
||||
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
|
||||
|
||||
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
|
||||
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
|
||||
$request->session()->flash('sso.error', $error);
|
||||
|
||||
return redirect()->route('sso.failed');
|
||||
}
|
||||
|
||||
$request->session()->put('sso.attempts', $attempts);
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
'fallback' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->safeRedirect($intended, route('frontdesk.dashboard'));
|
||||
}
|
||||
|
||||
private function safeReturnUrl(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return route('frontdesk.signed-out');
|
||||
}
|
||||
|
||||
private function safeRedirect(string $url, string $fallback): RedirectResponse
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return redirect()->away($fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Visit;
|
||||
use App\Models\Visitor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'audit.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$logs = $this->query($request, $organization)->paginate(50)->withQueryString();
|
||||
|
||||
return view('frontdesk.audit.index', [
|
||||
'logs' => $logs,
|
||||
'organization' => $organization,
|
||||
'actions' => config('frontdesk.audit_actions'),
|
||||
'canExport' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
|
||||
->can($this->member($request), 'audit.export'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'audit.export');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$filename = 'frontdesk-audit-'.now()->format('Y-m-d-His').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($request, $organization) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fputcsv($handle, ['Time', 'Action', 'Actor', 'Subject', 'Metadata', 'IP']);
|
||||
|
||||
$this->query($request, $organization)->chunk(200, function ($logs) use ($handle) {
|
||||
foreach ($logs as $log) {
|
||||
fputcsv($handle, [
|
||||
$log->created_at?->toDateTimeString(),
|
||||
$log->action,
|
||||
$log->actor_ref ?? '—',
|
||||
$log->subject_type ? class_basename($log->subject_type).' #'.$log->subject_id : '—',
|
||||
json_encode($log->metadata ?? []),
|
||||
$log->ip_address ?? '—',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
|
||||
protected function query(Request $request, $organization)
|
||||
{
|
||||
return AuditLog::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->when($request->action, fn ($q, $action) => $q->where('action', $action))
|
||||
->when($request->from, fn ($q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay()))
|
||||
->when($request->to, fn ($q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay()))
|
||||
->when($request->q, function ($q, $search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('action', 'like', "%{$search}%")
|
||||
->orWhere('metadata', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->orderByDesc('created_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\BadgeRenderService;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Services\Printers\PrinterManager;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BadgeController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function editTemplate(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$organization = $this->organization($request);
|
||||
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
|
||||
|
||||
return view('frontdesk.badges.template', [
|
||||
'organization' => $organization,
|
||||
'canManage' => $canManage,
|
||||
'template' => array_merge(
|
||||
config('frontdesk.default_badge_template', []),
|
||||
$organization->settings['badge_template'] ?? [],
|
||||
),
|
||||
'sampleVisit' => Visit::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('status', Visit::STATUS_CHECKED_IN)
|
||||
->with(['visitor', 'host'])
|
||||
->latest('checked_in_at')
|
||||
->first(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateTemplate(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'show_photo' => ['boolean'],
|
||||
'show_qr' => ['boolean'],
|
||||
'show_host' => ['boolean'],
|
||||
'show_company' => ['boolean'],
|
||||
'show_type' => ['boolean'],
|
||||
'primary_color' => ['nullable', 'string', 'max:7'],
|
||||
'footer_text' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$settings = $organization->settings ?? [];
|
||||
$settings['badge_template'] = [
|
||||
'show_photo' => $request->boolean('show_photo'),
|
||||
'show_qr' => $request->boolean('show_qr'),
|
||||
'show_host' => $request->boolean('show_host'),
|
||||
'show_company' => $request->boolean('show_company'),
|
||||
'show_type' => $request->boolean('show_type'),
|
||||
'primary_color' => $validated['primary_color'] ?? '#0d9488',
|
||||
'footer_text' => $validated['footer_text'] ?? '',
|
||||
];
|
||||
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
return back()->with('success', 'Badge template saved.');
|
||||
}
|
||||
|
||||
public function preview(Request $request, Visit $visit, BadgeRenderService $badges): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.view');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$visit->load(['visitor', 'host', 'organization']);
|
||||
|
||||
return view('frontdesk.badges.render', [
|
||||
'visit' => $visit,
|
||||
'template' => $badges->templateFor($visit->organization),
|
||||
'photoUrl' => $badges->photoUrl($visit),
|
||||
'qrSvg' => $visit->qr_token ? app(\App\Services\Frontdesk\QrCodeService::class)->svg($visit, 120) : null,
|
||||
'autoPrint' => false,
|
||||
'preview' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(Request $request, Visit $visit, PrinterManager $printers)
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.view');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$rendered = $printers->renderBadge($visit, $request->query('driver'));
|
||||
|
||||
return response($rendered['content'], 200, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BranchController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->withCount(['buildings'])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('frontdesk.admin.branches.index', compact('branches', 'organization'));
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
return view('frontdesk.admin.branches.create', ['organization' => $this->organization($request)]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'code' => ['nullable', 'string', 'max:50'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
]);
|
||||
|
||||
Branch::create([
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'organization_id' => $organization->id,
|
||||
...$validated,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return redirect()->route('frontdesk.branches.index')->with('success', 'Branch created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Branch $branch): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$this->authorizeOwner($request, $branch);
|
||||
|
||||
return view('frontdesk.admin.branches.edit', compact('branch'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Branch $branch): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$this->authorizeOwner($request, $branch);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'code' => ['nullable', 'string', 'max:50'],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$branch->update([
|
||||
...$validated,
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
return redirect()->route('frontdesk.branches.index')->with('success', 'Branch updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Building;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BuildingController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request, Branch $branch): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.view');
|
||||
$this->authorizeOwner($request, $branch);
|
||||
|
||||
$buildings = $branch->buildings()->withCount('receptionDesks')->orderBy('name')->get();
|
||||
|
||||
return view('frontdesk.admin.buildings.index', compact('branch', 'buildings'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Branch $branch): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$this->authorizeOwner($request, $branch);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'floor_count' => ['nullable', 'string', 'max:20'],
|
||||
]);
|
||||
|
||||
Building::create([
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'branch_id' => $branch->id,
|
||||
...$validated,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Building added.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Branch $branch, Building $building): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$this->authorizeOwner($request, $building);
|
||||
abort_unless($building->branch_id === $branch->id, 404);
|
||||
|
||||
$building->delete();
|
||||
|
||||
return back()->with('success', 'Building removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Visit;
|
||||
use App\Models\Visitor;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ComplianceController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'compliance.restore');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$deletedVisitors = Visitor::owned($owner)
|
||||
->onlyTrashed()
|
||||
->where('organization_id', $organization->id)
|
||||
->latest('deleted_at')
|
||||
->limit(25)
|
||||
->get();
|
||||
|
||||
$deletedVisits = Visit::owned($owner)
|
||||
->onlyTrashed()
|
||||
->where('organization_id', $organization->id)
|
||||
->with('visitor')
|
||||
->latest('deleted_at')
|
||||
->limit(25)
|
||||
->get();
|
||||
|
||||
return view('frontdesk.compliance.recovery', compact('deletedVisitors', 'deletedVisits', 'organization'));
|
||||
}
|
||||
|
||||
public function restoreVisitor(Request $request, int $visitorId): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'compliance.restore');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$visitor = Visitor::owned($this->ownerRef($request))
|
||||
->onlyTrashed()
|
||||
->where('organization_id', $organization->id)
|
||||
->findOrFail($visitorId);
|
||||
|
||||
$visitor->restore();
|
||||
|
||||
AuditLog::record(
|
||||
$this->ownerRef($request),
|
||||
'visitor.restored',
|
||||
$organization->id,
|
||||
$this->ownerRef($request),
|
||||
Visitor::class,
|
||||
$visitor->id,
|
||||
['full_name' => $visitor->full_name],
|
||||
);
|
||||
|
||||
return back()->with('success', 'Visitor restored.');
|
||||
}
|
||||
|
||||
public function restoreVisit(Request $request, int $visitId): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'compliance.restore');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$visit = Visit::owned($this->ownerRef($request))
|
||||
->onlyTrashed()
|
||||
->where('organization_id', $organization->id)
|
||||
->findOrFail($visitId);
|
||||
|
||||
$visit->restore();
|
||||
|
||||
AuditLog::record(
|
||||
$this->ownerRef($request),
|
||||
'visit.restored',
|
||||
$organization->id,
|
||||
$this->ownerRef($request),
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor_id' => $visit->visitor_id],
|
||||
);
|
||||
|
||||
return back()->with('success', 'Visit restored.');
|
||||
}
|
||||
|
||||
public function destroyVisitor(Request $request, Visitor $visitor): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visitors.manage');
|
||||
$this->authorizeOwner($request, $visitor);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$visitor->delete();
|
||||
|
||||
AuditLog::record(
|
||||
$this->ownerRef($request),
|
||||
'visitor.deleted',
|
||||
$organization->id,
|
||||
$this->ownerRef($request),
|
||||
Visitor::class,
|
||||
$visitor->id,
|
||||
['full_name' => $visitor->full_name],
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visitors.index')
|
||||
->with('success', 'Visitor archived.');
|
||||
}
|
||||
|
||||
public function destroyVisit(Request $request, Visit $visit): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
abort_if($visit->isInside(), 422, 'Check out the visitor before archiving the visit.');
|
||||
|
||||
$visit->delete();
|
||||
|
||||
AuditLog::record(
|
||||
$this->ownerRef($request),
|
||||
'visit.deleted',
|
||||
$organization->id,
|
||||
$this->ownerRef($request),
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor_id' => $visit->visitor_id],
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visits.index')
|
||||
->with('success', 'Visit archived.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk\Concerns;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ScopesToAccount
|
||||
{
|
||||
protected function ownerRef(Request $request): string
|
||||
{
|
||||
return (string) $request->user()->public_id;
|
||||
}
|
||||
|
||||
protected function organization(Request $request): Organization
|
||||
{
|
||||
$organization = $request->attributes->get('frontdesk.organization')
|
||||
?? app(OrganizationResolver::class)->resolveForUser($request->user());
|
||||
|
||||
abort_unless($organization, 404);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
|
||||
protected function member(Request $request): ?Member
|
||||
{
|
||||
return $request->attributes->get('frontdesk.member')
|
||||
?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
|
||||
}
|
||||
|
||||
protected function authorizeAbility(Request $request, string $ability): void
|
||||
{
|
||||
abort_unless(
|
||||
app(FrontdeskPermissions::class)->can($this->member($request), $ability),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizeOwner(Request $request, Model $model): void
|
||||
{
|
||||
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
|
||||
}
|
||||
|
||||
protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder
|
||||
{
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
|
||||
if ($branchId !== null) {
|
||||
$query->where($column, $branchId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'dashboard.view');
|
||||
$owner = $this->ownerRef($request);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$visitQuery = Visit::owned($owner)->where('organization_id', $organization->id);
|
||||
$this->scopeToBranch($request, $visitQuery);
|
||||
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
$cacheKey = "fd:dashboard:{$owner}:{$organization->id}:".($branchScope ?? 'all');
|
||||
|
||||
$stats = Cache::remember($cacheKey, 60, function () use ($visitQuery) {
|
||||
$today = (clone $visitQuery)->where(function ($q) {
|
||||
$q->whereDate('checked_in_at', today())
|
||||
->orWhereDate('scheduled_at', today());
|
||||
});
|
||||
|
||||
return [
|
||||
'visitors_today' => (clone $today)->count(),
|
||||
'currently_inside' => (clone $visitQuery)->currentlyInside()->count(),
|
||||
'expected_arrivals' => (clone $visitQuery)->whereIn('status', [
|
||||
Visit::STATUS_EXPECTED,
|
||||
Visit::STATUS_SCHEDULED,
|
||||
Visit::STATUS_OVERDUE,
|
||||
])->whereDate('scheduled_at', today())->count(),
|
||||
'waiting' => (clone $visitQuery)->where('status', Visit::STATUS_WAITING)->count(),
|
||||
'overdue' => (clone $visitQuery)->where('status', Visit::STATUS_OVERDUE)->count(),
|
||||
'checked_out_today' => (clone $visitQuery)->where('status', Visit::STATUS_CHECKED_OUT)
|
||||
->whereDate('checked_out_at', today())->count(),
|
||||
'deliveries_today' => (clone $visitQuery)->where('visitor_type', 'delivery')
|
||||
->whereDate('checked_in_at', today())->count(),
|
||||
'contractors_today' => (clone $visitQuery)->where('visitor_type', 'contractor')
|
||||
->whereDate('checked_in_at', today())->count(),
|
||||
'pending_approvals' => (clone $visitQuery)->where('status', Visit::STATUS_WAITING)
|
||||
->whereNull('checked_in_at')
|
||||
->where(function ($q) {
|
||||
$q->whereJsonContains('contractor_details', ['_awaiting_approval' => true])
|
||||
->orWhereJsonContains('delivery_details', ['_awaiting_approval' => true]);
|
||||
})->count(),
|
||||
];
|
||||
});
|
||||
|
||||
$currentVisitors = (clone $visitQuery)->currentlyInside()
|
||||
->with(['visitor', 'host'])
|
||||
->latest('checked_in_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$expectedVisitors = (clone $visitQuery)
|
||||
->whereIn('status', [Visit::STATUS_EXPECTED, Visit::STATUS_SCHEDULED, Visit::STATUS_WAITING, Visit::STATUS_OVERDUE])
|
||||
->whereDate('scheduled_at', today())
|
||||
->with(['visitor', 'host'])
|
||||
->orderBy('scheduled_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$pendingApprovals = (clone $visitQuery)
|
||||
->where('status', Visit::STATUS_WAITING)
|
||||
->whereNull('checked_in_at')
|
||||
->where(function ($q) {
|
||||
$q->whereJsonContains('contractor_details', ['_awaiting_approval' => true])
|
||||
->orWhereJsonContains('delivery_details', ['_awaiting_approval' => true]);
|
||||
})
|
||||
->with(['visitor', 'host'])
|
||||
->latest()
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('frontdesk.dashboard', compact('stats', 'currentVisitors', 'expectedVisitors', 'pendingApprovals', 'organization'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Device;
|
||||
use App\Models\ReceptionDesk;
|
||||
use App\Services\Frontdesk\DeviceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'devices.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$devices = Device::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->with(['branch', 'receptionDesk'])
|
||||
->orderBy('name')
|
||||
->paginate(25);
|
||||
|
||||
return view('frontdesk.devices.index', [
|
||||
'organization' => $organization,
|
||||
'devices' => $devices,
|
||||
'deviceTypes' => config('frontdesk.device_types'),
|
||||
'canManage' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
|
||||
->can($this->member($request), 'devices.manage'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'devices.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
return view('frontdesk.devices.create', [
|
||||
'organization' => $organization,
|
||||
'deviceTypes' => config('frontdesk.device_types'),
|
||||
'branches' => $this->branches($request, $organization->id),
|
||||
'desks' => $this->desks($request, $organization->id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, DeviceService $devices): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'devices.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.device_types')))],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
'reception_desk_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$token = in_array($validated['type'], ['kiosk', 'badge_printer', 'qr_scanner'], true)
|
||||
? $devices->generateToken()
|
||||
: null;
|
||||
|
||||
Device::create([
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'organization_id' => $organization->id,
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'branch_id' => $validated['branch_id'] ?? null,
|
||||
'reception_desk_id' => $validated['reception_desk_id'] ?? null,
|
||||
'device_token' => $token,
|
||||
'status' => 'offline',
|
||||
'config' => $devices->defaultConfigForType($validated['type']),
|
||||
]);
|
||||
|
||||
return redirect()->route('frontdesk.devices.index')->with('success', 'Device registered.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Device $device): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'devices.manage');
|
||||
$this->authorizeOwner($request, $device);
|
||||
|
||||
return view('frontdesk.devices.edit', [
|
||||
'device' => $device,
|
||||
'deviceTypes' => config('frontdesk.device_types'),
|
||||
'branches' => $this->branches($request, $device->organization_id),
|
||||
'desks' => $this->desks($request, $device->organization_id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Device $device): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'devices.manage');
|
||||
$this->authorizeOwner($request, $device);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.device_types')))],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
'reception_desk_id' => ['nullable', 'integer'],
|
||||
'status' => ['nullable', 'string', 'in:online,offline,maintenance'],
|
||||
]);
|
||||
|
||||
$device->update($validated);
|
||||
|
||||
return redirect()->route('frontdesk.devices.index')->with('success', 'Device updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Device $device): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'devices.manage');
|
||||
$this->authorizeOwner($request, $device);
|
||||
$device->delete();
|
||||
|
||||
return redirect()->route('frontdesk.devices.index')->with('success', 'Device removed.');
|
||||
}
|
||||
|
||||
public function regenerateToken(Request $request, Device $device, DeviceService $devices): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'devices.manage');
|
||||
$this->authorizeOwner($request, $device);
|
||||
|
||||
$device->update(['device_token' => $devices->generateToken()]);
|
||||
|
||||
return back()->with('success', 'Device token regenerated.');
|
||||
}
|
||||
|
||||
/** @return \Illuminate\Database\Eloquent\Collection<int, Branch> */
|
||||
protected function branches(Request $request, int $organizationId)
|
||||
{
|
||||
return Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organizationId)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return \Illuminate\Database\Eloquent\Collection<int, ReceptionDesk> */
|
||||
protected function desks(Request $request, int $organizationId)
|
||||
{
|
||||
return ReceptionDesk::owned($this->ownerRef($request))
|
||||
->whereHas('building.branch', fn ($q) => $q->where('organization_id', $organizationId))
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Host;
|
||||
use App\Models\Visitor;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HostController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'hosts.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$hosts = Host::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id);
|
||||
$this->scopeToBranch($request, $hosts);
|
||||
$hosts = $hosts->orderBy('name')->paginate(25);
|
||||
|
||||
return view('frontdesk.hosts.index', compact('hosts', 'organization'));
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'hosts.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('frontdesk.hosts.create', compact('organization', 'branches'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'hosts.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'department' => ['nullable', 'string', 'max:255'],
|
||||
'office' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'extension' => ['nullable', 'string', 'max:20'],
|
||||
'user_ref' => ['nullable', 'string', 'max:64'],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
Host::create([
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'organization_id' => $organization->id,
|
||||
...$validated,
|
||||
]);
|
||||
|
||||
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host added.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Host $host): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'hosts.manage');
|
||||
$this->authorizeOwner($request, $host);
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $host->organization_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('frontdesk.hosts.edit', compact('host', 'branches'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Host $host): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'hosts.manage');
|
||||
$this->authorizeOwner($request, $host);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'department' => ['nullable', 'string', 'max:255'],
|
||||
'office' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'extension' => ['nullable', 'string', 'max:20'],
|
||||
'user_ref' => ['nullable', 'string', 'max:64'],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
'is_available' => ['boolean'],
|
||||
]);
|
||||
|
||||
$host->update([
|
||||
...$validated,
|
||||
'is_available' => $request->boolean('is_available', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Host $host): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'hosts.manage');
|
||||
$this->authorizeOwner($request, $host);
|
||||
$host->delete();
|
||||
|
||||
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Host;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use App\Services\Frontdesk\VisitLifecycleService;
|
||||
use App\Services\Frontdesk\VisitScheduleService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HostPortalController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$host = $this->resolveLinkedHost($request);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$pending = Visit::owned($host->owner_ref)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('host_id', $host->id)
|
||||
->where('status', Visit::STATUS_WAITING)
|
||||
->with('visitor')
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->filter(fn (Visit $v) => $v->awaitingApproval());
|
||||
|
||||
$upcoming = Visit::owned($host->owner_ref)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('host_id', $host->id)
|
||||
->whereIn('status', [Visit::STATUS_SCHEDULED, Visit::STATUS_EXPECTED, Visit::STATUS_OVERDUE])
|
||||
->with('visitor')
|
||||
->orderBy('scheduled_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$recent = Visit::owned($host->owner_ref)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('host_id', $host->id)
|
||||
->whereIn('status', [Visit::STATUS_CHECKED_IN, Visit::STATUS_CHECKED_OUT, Visit::STATUS_CANCELLED])
|
||||
->with('visitor')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(15)
|
||||
->get();
|
||||
|
||||
return view('frontdesk.host-portal.index', compact('host', 'organization', 'pending', 'upcoming', 'recent'));
|
||||
}
|
||||
|
||||
public function scheduleForm(Request $request): View
|
||||
{
|
||||
$host = $this->resolveLinkedHost($request);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
return view('frontdesk.host-portal.schedule', compact('host', 'organization'));
|
||||
}
|
||||
|
||||
public function scheduleStore(Request $request, VisitScheduleService $scheduler): RedirectResponse
|
||||
{
|
||||
$host = $this->resolveLinkedHost($request);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'full_name' => ['required', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
|
||||
'purpose' => ['nullable', 'string', 'max:500'],
|
||||
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
|
||||
'scheduled_at' => ['required', 'date'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$scheduler->schedule(
|
||||
$host->owner_ref,
|
||||
$organization,
|
||||
[
|
||||
...$validated,
|
||||
'host_id' => $host->id,
|
||||
'branch_id' => $host->branch_id,
|
||||
],
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return redirect()->route('frontdesk.host.index')->with('success', 'Visit scheduled.');
|
||||
}
|
||||
|
||||
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
|
||||
{
|
||||
$host = $this->resolveLinkedHost($request);
|
||||
$this->authorizeOwner($request, $visit);
|
||||
abort_unless($visit->host_id === $host->id, 403);
|
||||
abort_unless($visit->awaitingApproval(), 422);
|
||||
|
||||
$lifecycle->approve($visit, $this->ownerRef($request));
|
||||
|
||||
return redirect()->route('frontdesk.host.index')->with('success', 'Visit approved.');
|
||||
}
|
||||
|
||||
public function toggleAvailability(Request $request): RedirectResponse
|
||||
{
|
||||
$host = $this->resolveLinkedHost($request);
|
||||
$host->update(['is_available' => ! $host->is_available]);
|
||||
|
||||
return back()->with('success', $host->is_available ? 'You are now available.' : 'You are marked unavailable.');
|
||||
}
|
||||
|
||||
protected function resolveLinkedHost(Request $request): Host
|
||||
{
|
||||
$permissions = app(FrontdeskPermissions::class);
|
||||
$member = $this->member($request);
|
||||
|
||||
abort_unless(
|
||||
$permissions->can($member, 'host.portal') || app(OrganizationResolver::class)->hostFor($request->user()) !== null,
|
||||
403,
|
||||
);
|
||||
|
||||
$host = app(OrganizationResolver::class)->hostFor($request->user());
|
||||
|
||||
abort_unless($host, 403, 'No host profile linked to your account.');
|
||||
|
||||
$this->authorizeOwner($request, $host);
|
||||
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class IntegrationController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$organization = $this->organization($request);
|
||||
$canManage = app(\App\Services\Frontdesk\FrontdeskPermissions::class)
|
||||
->isAdmin($this->member($request));
|
||||
|
||||
$webhook = WebhookEndpoint::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->first();
|
||||
|
||||
return view('frontdesk.integrations.edit', [
|
||||
'organization' => $organization,
|
||||
'canManage' => $canManage,
|
||||
'webhook' => $webhook,
|
||||
'webhookEvents' => config('frontdesk.webhook_events', []),
|
||||
'integrations' => config('frontdesk.integrations', []),
|
||||
'icalUrl' => $this->signedIcalUrl($organization->id, $this->ownerRef($request)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'webhook_url' => ['nullable', 'url', 'max:500'],
|
||||
'webhook_secret' => ['nullable', 'string', 'max:128'],
|
||||
'webhook_events' => ['nullable', 'array'],
|
||||
'webhook_events.*' => ['string'],
|
||||
'webhook_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
if (empty($validated['webhook_url'])) {
|
||||
WebhookEndpoint::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->delete();
|
||||
|
||||
return back()->with('success', 'Integration settings saved.');
|
||||
}
|
||||
|
||||
WebhookEndpoint::updateOrCreate(
|
||||
[
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'organization_id' => $organization->id,
|
||||
],
|
||||
[
|
||||
'url' => $validated['webhook_url'],
|
||||
'secret' => $validated['webhook_secret'] ?? null,
|
||||
'events' => array_values($validated['webhook_events'] ?? config('frontdesk.webhook_events')),
|
||||
'is_active' => $request->boolean('webhook_active', true),
|
||||
],
|
||||
);
|
||||
|
||||
return back()->with('success', 'Integration settings saved.');
|
||||
}
|
||||
|
||||
protected function signedIcalUrl(int $organizationId, string $ownerRef): string
|
||||
{
|
||||
$token = hash_hmac('sha256', "{$organizationId}:{$ownerRef}", (string) config('app.key'));
|
||||
|
||||
return route('frontdesk.integrations.ical', [
|
||||
'organization' => $organizationId,
|
||||
'owner' => $ownerRef,
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Host;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\VisitCheckInService;
|
||||
use App\Services\Frontdesk\VisitCheckOutService;
|
||||
use App\Services\Frontdesk\VisitorTypeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class KioskController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function show(Request $request, VisitorTypeService $visitorTypes): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'kiosk.use');
|
||||
$organization = $this->organization($request);
|
||||
$settings = $organization->settings ?? [];
|
||||
|
||||
$hosts = Host::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_available', true);
|
||||
$this->scopeToBranch($request, $hosts);
|
||||
$hosts = $hosts->orderBy('name')->get();
|
||||
|
||||
return view('frontdesk.kiosk.index', [
|
||||
'organization' => $organization,
|
||||
'hosts' => $hosts,
|
||||
'visitorTypes' => config('frontdesk.visitor_types'),
|
||||
'typeConfigs' => $visitorTypes->configsForFrontend(),
|
||||
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
|
||||
'visitorPolicy' => $settings['visitor_policy'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'kiosk.use');
|
||||
$organization = $this->organization($request);
|
||||
$visitorType = $request->input('visitor_type', 'visitor');
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
'full_name' => ['required', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'host_id' => ['nullable', 'integer'],
|
||||
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
|
||||
'purpose' => ['nullable', 'string', 'max:500'],
|
||||
'expected_duration_minutes' => ['nullable', 'integer'],
|
||||
'policies_accepted' => ['accepted'],
|
||||
'photo_data' => ['nullable', 'string'],
|
||||
'signature_path' => ['nullable', 'string'],
|
||||
], $visitorTypes->validationRules($visitorType)));
|
||||
|
||||
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization);
|
||||
if (! empty($validated['photo_data'])) {
|
||||
$payload['photo_data'] = $validated['photo_data'];
|
||||
}
|
||||
|
||||
$visit = $checkIn->checkIn($this->ownerRef($request), $organization, $payload);
|
||||
|
||||
return response()->json([
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'public_id' => $visit->public_id,
|
||||
'badge_code' => $visit->badge_code,
|
||||
'visitor_name' => $visit->visitor->full_name,
|
||||
'host_name' => $visit->host?->name,
|
||||
'status' => $visit->status,
|
||||
'awaiting_approval' => $visit->awaitingApproval(),
|
||||
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
|
||||
'badge_url' => $visit->isInside() ? route('frontdesk.visits.badge', $visit) : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\Host;
|
||||
use App\Models\Organization;
|
||||
use App\Services\Frontdesk\VisitCheckInService;
|
||||
use App\Services\Frontdesk\VisitorTypeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class KioskDeviceController extends Controller
|
||||
{
|
||||
public function show(Request $request, VisitorTypeService $visitorTypes): View
|
||||
{
|
||||
$device = $this->device($request);
|
||||
$organization = Organization::findOrFail($device->organization_id);
|
||||
$settings = $organization->settings ?? [];
|
||||
|
||||
$hosts = Host::owned($device->owner_ref)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_available', true);
|
||||
|
||||
if ($device->branch_id) {
|
||||
$hosts->where(function ($q) use ($device) {
|
||||
$q->whereNull('branch_id')->orWhere('branch_id', $device->branch_id);
|
||||
});
|
||||
}
|
||||
|
||||
$hosts = $hosts->orderBy('name')->get();
|
||||
|
||||
return view('frontdesk.kiosk.index', [
|
||||
'organization' => $organization,
|
||||
'hosts' => $hosts,
|
||||
'visitorTypes' => config('frontdesk.visitor_types'),
|
||||
'typeConfigs' => $visitorTypes->configsForFrontend(),
|
||||
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
|
||||
'visitorPolicy' => $settings['visitor_policy'] ?? null,
|
||||
'checkInUrl' => route('frontdesk.kiosk.device.check-in', $device->device_token),
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse
|
||||
{
|
||||
$device = $this->device($request);
|
||||
$organization = Organization::findOrFail($device->organization_id);
|
||||
$visitorType = $request->input('visitor_type', 'visitor');
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
'full_name' => ['required', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'host_id' => ['nullable', 'integer'],
|
||||
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
|
||||
'purpose' => ['nullable', 'string', 'max:500'],
|
||||
'expected_duration_minutes' => ['nullable', 'integer'],
|
||||
'policies_accepted' => ['accepted'],
|
||||
'photo_data' => ['nullable', 'string'],
|
||||
'vehicle_info' => ['nullable', 'array'],
|
||||
'signature_path' => ['nullable', 'string'],
|
||||
], $visitorTypes->validationRules($visitorType)));
|
||||
|
||||
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization);
|
||||
if (! empty($validated['photo_data'])) {
|
||||
$payload['photo_data'] = $validated['photo_data'];
|
||||
}
|
||||
$payload['branch_id'] = $device->branch_id;
|
||||
$payload['reception_desk_id'] = $device->reception_desk_id;
|
||||
|
||||
$visit = $checkIn->checkIn($device->owner_ref, $organization, $payload);
|
||||
|
||||
return response()->json([
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'public_id' => $visit->public_id,
|
||||
'badge_code' => $visit->badge_code,
|
||||
'visitor_name' => $visit->visitor->full_name,
|
||||
'host_name' => $visit->host?->name,
|
||||
'status' => $visit->status,
|
||||
'awaiting_approval' => $visit->awaitingApproval(),
|
||||
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
|
||||
'badge_url' => $visit->isInside() ? route('frontdesk.visits.badge', $visit) : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function device(Request $request): Device
|
||||
{
|
||||
return $request->attributes->get('frontdesk.device')
|
||||
?? abort(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.members.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$members = Member::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->with('branch')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
return view('frontdesk.admin.members.index', [
|
||||
'members' => $members,
|
||||
'organization' => $organization,
|
||||
'roles' => config('frontdesk.roles'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.members.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('frontdesk.admin.members.create', [
|
||||
'organization' => $organization,
|
||||
'branches' => $branches,
|
||||
'roles' => config('frontdesk.roles'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.members.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'user_ref' => ['required', 'string', 'max:255'],
|
||||
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.roles')))],
|
||||
'branch_id' => ['nullable', 'integer', 'exists:frontdesk_branches,id'],
|
||||
]);
|
||||
|
||||
Member::updateOrCreate(
|
||||
[
|
||||
'organization_id' => $organization->id,
|
||||
'user_ref' => $validated['user_ref'],
|
||||
],
|
||||
[
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'role' => $validated['role'],
|
||||
'branch_id' => $validated['branch_id'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('frontdesk.members.index')->with('success', 'Member saved.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Member $member): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.members.manage');
|
||||
$this->authorizeOwner($request, $member);
|
||||
|
||||
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
|
||||
|
||||
$member->delete();
|
||||
|
||||
return redirect()->route('frontdesk.members.index')->with('success', 'Member removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OnboardingController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected OrganizationResolver $organizations,
|
||||
) {}
|
||||
|
||||
public function show(Request $request): View|RedirectResponse
|
||||
{
|
||||
if ($this->organizations->isOnboarded($request->user())) {
|
||||
return redirect()->route('frontdesk.dashboard');
|
||||
}
|
||||
|
||||
return view('frontdesk.onboarding.show', [
|
||||
'user' => $request->user(),
|
||||
'timezones' => timezone_identifiers_list(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($this->organizations->isOnboarded($request->user())) {
|
||||
return redirect()->route('frontdesk.dashboard');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'organization_name' => ['required', 'string', 'max:255'],
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
'branch_address' => ['nullable', 'string', 'max:500'],
|
||||
'timezone' => ['required', 'timezone'],
|
||||
'visitor_policy' => ['nullable', 'string', 'max:5000'],
|
||||
'badge_expiry_hours' => ['nullable', 'integer', 'min:1', 'max:24'],
|
||||
'kiosk_reset_seconds' => ['nullable', 'integer', 'min:30', 'max:600'],
|
||||
]);
|
||||
|
||||
$this->organizations->completeOnboarding($request->user(), $validated);
|
||||
|
||||
return redirect()->route('frontdesk.dashboard')->with('success', 'Welcome to Ladill Frontdesk!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\QrCodeService;
|
||||
use App\Services\Frontdesk\VisitCheckOutService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class QrScanController extends Controller
|
||||
{
|
||||
public function show(string $token, QrCodeService $qr): View
|
||||
{
|
||||
$visit = Visit::where('qr_token', $token)->with(['visitor', 'host', 'organization'])->firstOrFail();
|
||||
|
||||
return view('frontdesk.qr.show', [
|
||||
'visit' => $visit,
|
||||
'qrSvg' => $qr->svg($visit),
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkOut(string $token, VisitCheckOutService $checkOut): View
|
||||
{
|
||||
$visit = Visit::where('qr_token', $token)->firstOrFail();
|
||||
$checkOut->checkOut($visit);
|
||||
|
||||
return view('frontdesk.qr.checked-out', ['visit' => $visit->load('visitor')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Building;
|
||||
use App\Models\ReceptionDesk;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ReceptionDeskController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request, Building $building): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.desks.view');
|
||||
$this->authorizeOwner($request, $building);
|
||||
|
||||
$desks = $building->receptionDesks()->orderBy('name')->get();
|
||||
|
||||
return view('frontdesk.admin.desks.index', compact('building', 'desks'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Building $building): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$this->authorizeOwner($request, $building);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
ReceptionDesk::create([
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'building_id' => $building->id,
|
||||
...$validated,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Reception desk added.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Building $building, ReceptionDesk $desk): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'admin.branches.manage');
|
||||
$this->authorizeOwner($request, $desk);
|
||||
abort_unless($desk->building_id === $building->id, 404);
|
||||
|
||||
$desk->delete();
|
||||
|
||||
return back()->with('success', 'Reception desk removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\BadgeRenderService;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use App\Services\Frontdesk\ReportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request, ReportService $reports): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'reports.view');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
[$from, $to] = $this->dateRange($request);
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
|
||||
return view('frontdesk.reports.index', [
|
||||
'organization' => $organization,
|
||||
'from' => $from->toDateString(),
|
||||
'to' => $to->toDateString(),
|
||||
'summary' => $reports->summary($owner, $organization, $from, $to, $branchId),
|
||||
'peakHours' => $reports->peakHours($owner, $organization, $from, $to, $branchId),
|
||||
'departments' => $reports->visitsByDepartment($owner, $organization, $from, $to, $branchId),
|
||||
'frequentVisitors' => $reports->frequentVisitors($owner, $organization),
|
||||
'security' => $reports->securityIncidents($owner, $organization, $from, $to),
|
||||
'dailyCounts' => $reports->dailyCounts($owner, $organization, $from, $to, $branchId),
|
||||
'canExport' => app(FrontdeskPermissions::class)->can($this->member($request), 'reports.export'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request, ReportService $reports): StreamedResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'reports.export');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
[$from, $to] = $this->dateRange($request);
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
$summary = $reports->summary($owner, $organization, $from, $to, $branchId);
|
||||
$departments = $reports->visitsByDepartment($owner, $organization, $from, $to, $branchId);
|
||||
|
||||
$filename = 'frontdesk-report-'.$from->format('Y-m-d').'-'.$to->format('Y-m-d').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($summary, $departments, $from, $to) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fputcsv($handle, ['Frontdesk report', $from->toDateString(), 'to', $to->toDateString()]);
|
||||
fputcsv($handle, []);
|
||||
fputcsv($handle, ['Metric', 'Value']);
|
||||
foreach ($summary as $key => $value) {
|
||||
fputcsv($handle, [str_replace('_', ' ', $key), $value]);
|
||||
}
|
||||
fputcsv($handle, []);
|
||||
fputcsv($handle, ['Department', 'Visits']);
|
||||
foreach ($departments as $row) {
|
||||
fputcsv($handle, [$row->department, $row->count]);
|
||||
}
|
||||
fclose($handle);
|
||||
}, $filename, ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
|
||||
/** @return array{0: Carbon, 1: Carbon} */
|
||||
protected function dateRange(Request $request): array
|
||||
{
|
||||
$from = $request->filled('from')
|
||||
? Carbon::parse($request->string('from'))->startOfDay()
|
||||
: now()->subDays(30)->startOfDay();
|
||||
$to = $request->filled('to')
|
||||
? Carbon::parse($request->string('to'))->endOfDay()
|
||||
: now()->endOfDay();
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\VisitCheckOutService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SecurityController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'security.view');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$occupancy = Visit::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->currentlyInside()
|
||||
->with(['visitor', 'host']);
|
||||
$this->scopeToBranch($request, $occupancy);
|
||||
$occupancy = $occupancy->orderBy('checked_in_at')->get();
|
||||
|
||||
$expiredBadges = $occupancy->filter(fn (Visit $v) => $v->isBadgeExpired());
|
||||
|
||||
return view('frontdesk.security.index', [
|
||||
'occupancy' => $occupancy,
|
||||
'expiredBadges' => $expiredBadges,
|
||||
'organization' => $organization,
|
||||
'canCheckout' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
|
||||
->can($this->member($request), 'security.checkout'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function evacuation(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'security.view');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$occupancy = Visit::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->currentlyInside()
|
||||
->with(['visitor', 'host', 'branch']);
|
||||
$this->scopeToBranch($request, $occupancy);
|
||||
$occupancy = $occupancy->orderBy('checked_in_at')->get();
|
||||
|
||||
return view('frontdesk.security.evacuation', compact('occupancy', 'organization'));
|
||||
}
|
||||
|
||||
public function evacuationBadges(Request $request, \App\Services\Frontdesk\BadgeRenderService $badges): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'security.view');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$visits = Visit::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->currentlyInside()
|
||||
->with(['visitor', 'host', 'organization']);
|
||||
$this->scopeToBranch($request, $visits);
|
||||
$visits = $visits->orderBy('checked_in_at')->get();
|
||||
|
||||
$rendered = $visits->map(fn (Visit $visit) => $badges->renderHtml($visit));
|
||||
|
||||
return view('frontdesk.security.evacuation-badges', compact('visits', 'rendered', 'organization'));
|
||||
}
|
||||
|
||||
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'security.checkout');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
$checkOut->checkOut($visit, $this->ownerRef($request));
|
||||
|
||||
return back()->with('success', 'Visitor checked out.');
|
||||
}
|
||||
|
||||
public function verifyForm(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'security.verify');
|
||||
|
||||
return view('frontdesk.security.verify', [
|
||||
'organization' => $this->organization($request),
|
||||
]);
|
||||
}
|
||||
|
||||
public function verify(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'security.verify');
|
||||
$organization = $this->organization($request);
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'lookup' => ['required', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$lookup = trim($validated['lookup']);
|
||||
|
||||
$visit = Visit::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->where(function ($q) use ($lookup) {
|
||||
$q->where('badge_code', strtoupper($lookup))
|
||||
->orWhere('qr_token', $lookup);
|
||||
})
|
||||
->with(['visitor', 'host', 'branch'])
|
||||
->latest('checked_in_at')
|
||||
->first();
|
||||
|
||||
return view('frontdesk.security.verify', [
|
||||
'organization' => $organization,
|
||||
'lookup' => $lookup,
|
||||
'visit' => $visit,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.view');
|
||||
$organization = $this->organization($request);
|
||||
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->count();
|
||||
|
||||
return view('frontdesk.settings.edit', [
|
||||
'organization' => $organization,
|
||||
'canManage' => $canManage,
|
||||
'branchCount' => $branches,
|
||||
'roles' => config('frontdesk.roles'),
|
||||
'deviceTypes' => config('frontdesk.device_types'),
|
||||
'notificationChannels' => config('frontdesk.notification_channels'),
|
||||
'notificationEvents' => config('frontdesk.notification_events'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'settings.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'timezone' => ['required', 'timezone'],
|
||||
'badge_expiry_hours' => ['required', 'integer', 'min:1', 'max:24'],
|
||||
'kiosk_reset_seconds' => ['required', 'integer', 'min:30', 'max:600'],
|
||||
'contractor_badge_expiry_hours' => ['nullable', 'integer', 'min:1', 'max:24'],
|
||||
'visitor_policy' => ['nullable', 'string', 'max:5000'],
|
||||
'notification_channels' => ['nullable', 'array'],
|
||||
'notification_channels.*' => ['string', 'in:'.implode(',', array_keys(config('frontdesk.notification_channels')))],
|
||||
'notification_events' => ['nullable', 'array'],
|
||||
'report_daily_recipients' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$settings = $organization->settings ?? [];
|
||||
$settings['onboarded'] = true;
|
||||
$settings['badge_expiry_hours'] = $validated['badge_expiry_hours'];
|
||||
$settings['kiosk_reset_seconds'] = $validated['kiosk_reset_seconds'];
|
||||
$settings['visitor_policy'] = $validated['visitor_policy'] ?? null;
|
||||
$settings['notification_channels'] = $validated['notification_channels'] ?? ['email'];
|
||||
|
||||
$eventKeys = array_keys(config('frontdesk.notification_events', []));
|
||||
$submittedEvents = $validated['notification_events'] ?? [];
|
||||
$settings['notification_events'] = [];
|
||||
foreach ($eventKeys as $key) {
|
||||
$settings['notification_events'][$key] = (bool) ($submittedEvents[$key] ?? false);
|
||||
}
|
||||
|
||||
if (array_key_exists('report_daily_recipients', $validated)) {
|
||||
$settings['report_daily_recipients'] = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) ($validated['report_daily_recipients'] ?? '')),
|
||||
)));
|
||||
}
|
||||
|
||||
if (! empty($validated['contractor_badge_expiry_hours'])) {
|
||||
$settings['type_badge_expiry_hours'] = array_merge(
|
||||
$settings['type_badge_expiry_hours'] ?? [],
|
||||
['contractor' => (int) $validated['contractor_badge_expiry_hours']],
|
||||
);
|
||||
}
|
||||
|
||||
$organization->update([
|
||||
'name' => $validated['name'],
|
||||
'timezone' => $validated['timezone'],
|
||||
'settings' => $settings,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Settings saved.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Host;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Services\Frontdesk\VisitCheckInService;
|
||||
use App\Services\Frontdesk\VisitCheckOutService;
|
||||
use App\Services\Frontdesk\VisitLifecycleService;
|
||||
use App\Services\Frontdesk\VisitScheduleService;
|
||||
use App\Services\Frontdesk\VisitorSearchService;
|
||||
use App\Services\Frontdesk\VisitorTypeService;
|
||||
use App\Services\Printers\PrinterManager;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VisitController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$visits = Visit::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->with(['visitor', 'host']);
|
||||
$this->scopeToBranch($request, $visits);
|
||||
$visits = $visits
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->q, function ($q, $search) {
|
||||
$q->whereHas('visitor', fn ($v) => $v->where('full_name', 'like', "%{$search}%"));
|
||||
})
|
||||
->latest()
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('frontdesk.visits.index', compact('visits', 'organization'));
|
||||
}
|
||||
|
||||
public function calendar(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$start = $request->filled('start')
|
||||
? Carbon::parse($request->string('start')->toString())->startOfWeek()
|
||||
: now()->startOfWeek();
|
||||
$end = $start->copy()->endOfWeek();
|
||||
|
||||
$visits = Visit::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->whereNotNull('scheduled_at')
|
||||
->whereBetween('scheduled_at', [$start, $end])
|
||||
->whereNotIn('status', [Visit::STATUS_CANCELLED])
|
||||
->with(['visitor', 'host']);
|
||||
$this->scopeToBranch($request, $visits);
|
||||
$visits = $visits->orderBy('scheduled_at')->get()->groupBy(
|
||||
fn (Visit $visit) => $visit->scheduled_at->toDateString(),
|
||||
);
|
||||
|
||||
return view('frontdesk.visits.calendar', compact('visits', 'organization', 'start', 'end'));
|
||||
}
|
||||
|
||||
public function create(Request $request, VisitorSearchService $search): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$hosts = Host::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id);
|
||||
$this->scopeToBranch($request, $hosts);
|
||||
$hosts = $hosts->orderBy('name')->get();
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$returningVisitors = $request->filled('q')
|
||||
? $search->search($this->ownerRef($request), $organization->id, $request->string('q')->toString())
|
||||
: collect();
|
||||
|
||||
return view('frontdesk.visits.create', [
|
||||
'organization' => $organization,
|
||||
'hosts' => $hosts,
|
||||
'branches' => $branches,
|
||||
'returningVisitors' => $returningVisitors,
|
||||
'typeConfigs' => app(VisitorTypeService::class)->configsForFrontend(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function scheduleForm(Request $request, VisitorSearchService $search): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$hosts = Host::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id);
|
||||
$this->scopeToBranch($request, $hosts);
|
||||
$hosts = $hosts->orderBy('name')->get();
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$returningVisitors = $request->filled('q')
|
||||
? $search->search($this->ownerRef($request), $organization->id, $request->string('q')->toString())
|
||||
: collect();
|
||||
|
||||
return view('frontdesk.visits.schedule', compact('organization', 'hosts', 'branches', 'returningVisitors'));
|
||||
}
|
||||
|
||||
public function store(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$organization = $this->organization($request);
|
||||
$visitorType = $request->input('visitor_type', 'visitor');
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
'visitor_id' => ['nullable', 'integer'],
|
||||
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'host_id' => ['nullable', 'integer'],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
|
||||
'purpose' => ['nullable', 'string', 'max:500'],
|
||||
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
|
||||
'policies_accepted' => ['accepted'],
|
||||
'photo_data' => ['nullable', 'string'],
|
||||
], $visitorTypes->validationRules($visitorType)));
|
||||
|
||||
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
|
||||
->branchScope($this->member($request));
|
||||
if ($branchScope !== null) {
|
||||
$validated['branch_id'] = $branchScope;
|
||||
}
|
||||
|
||||
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization, $request);
|
||||
|
||||
$visit = $checkIn->checkIn(
|
||||
$this->ownerRef($request),
|
||||
$organization,
|
||||
$payload,
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
$message = $visit->awaitingApproval()
|
||||
? 'Visit submitted for approval.'
|
||||
: 'Visitor checked in successfully.';
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visits.show', $visit)
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function scheduleStore(Request $request, VisitScheduleService $scheduler): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'visitor_id' => ['nullable', 'integer'],
|
||||
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'host_id' => ['nullable', 'integer'],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
|
||||
'purpose' => ['nullable', 'string', 'max:500'],
|
||||
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
|
||||
'scheduled_at' => ['required', 'date'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
|
||||
->branchScope($this->member($request));
|
||||
if ($branchScope !== null) {
|
||||
$validated['branch_id'] = $branchScope;
|
||||
}
|
||||
|
||||
$visit = $scheduler->schedule(
|
||||
$this->ownerRef($request),
|
||||
$organization,
|
||||
$validated,
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visits.show', $visit)
|
||||
->with('success', 'Visit scheduled successfully.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Visit $visit): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.view');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
$visit->load(['visitor', 'host', 'organization', 'branch']);
|
||||
|
||||
$canManage = app(FrontdeskPermissions::class)->can(
|
||||
$this->member($request),
|
||||
'visits.manage',
|
||||
);
|
||||
|
||||
return view('frontdesk.visits.show', compact('visit', 'canManage'));
|
||||
}
|
||||
|
||||
public function activate(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$request->validate(['policies_accepted' => ['accepted']]);
|
||||
|
||||
$lifecycle->checkInFromSchedule(
|
||||
$visit,
|
||||
$this->ownerRef($request),
|
||||
true,
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visits.show', $visit)
|
||||
->with('success', 'Visitor checked in successfully.');
|
||||
}
|
||||
|
||||
public function markWaiting(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$lifecycle->markWaiting($visit, $this->ownerRef($request));
|
||||
|
||||
return back()->with('success', 'Visitor marked as waiting.');
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$lifecycle->cancel($visit, $this->ownerRef($request), $validated['reason'] ?? null);
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visits.index', ['status' => 'cancelled'])
|
||||
->with('success', 'Visit cancelled.');
|
||||
}
|
||||
|
||||
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
|
||||
$lifecycle->approve($visit, $this->ownerRef($request));
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visits.show', $visit)
|
||||
->with('success', 'Visit approved and visitor checked in.');
|
||||
}
|
||||
|
||||
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
$checkOut->checkOut($visit, $this->ownerRef($request));
|
||||
|
||||
return back()->with('success', 'Visitor checked out.');
|
||||
}
|
||||
|
||||
public function badge(Request $request, Visit $visit, PrinterManager $printers)
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.view');
|
||||
$this->authorizeOwner($request, $visit);
|
||||
$rendered = $printers->renderBadge($visit, $request->query('driver'));
|
||||
|
||||
if ($rendered['format'] === 'html') {
|
||||
return response($rendered['content'])->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
return response($rendered['content'])
|
||||
->header('Content-Type', 'text/plain')
|
||||
->header('Content-Disposition', 'attachment; filename="'.$rendered['filename'].'"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Host;
|
||||
use App\Models\Visitor;
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Services\Frontdesk\VisitCheckInService;
|
||||
use App\Services\Frontdesk\WatchlistService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VisitorController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visitors.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$visitors = Visitor::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->when($request->q, fn ($q, $search) => $q->where('full_name', 'like', "%{$search}%"))
|
||||
->orderByDesc('is_frequent')
|
||||
->orderBy('full_name')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('frontdesk.visitors.index', compact('visitors', 'organization'));
|
||||
}
|
||||
|
||||
public function show(Request $request, Visitor $visitor): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visitors.view');
|
||||
$this->authorizeOwner($request, $visitor);
|
||||
$visitor->load(['visits' => fn ($q) => $q->latest()->limit(20), 'visits.host']);
|
||||
|
||||
$visitIds = $visitor->visits()->pluck('id');
|
||||
|
||||
$activity = AuditLog::owned($this->ownerRef($request))
|
||||
->where('organization_id', $visitor->organization_id)
|
||||
->where(function ($q) use ($visitor, $visitIds) {
|
||||
$q->where(function ($inner) use ($visitor) {
|
||||
$inner->where('subject_type', Visitor::class)
|
||||
->where('subject_id', $visitor->id);
|
||||
});
|
||||
|
||||
if ($visitIds->isNotEmpty()) {
|
||||
$q->orWhere(function ($inner) use ($visitIds) {
|
||||
$inner->where('subject_type', Visit::class)
|
||||
->whereIn('subject_id', $visitIds);
|
||||
});
|
||||
}
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->limit(30)
|
||||
->get();
|
||||
|
||||
return view('frontdesk.visitors.show', [
|
||||
'visitor' => $visitor,
|
||||
'activity' => $activity,
|
||||
'auditActions' => config('frontdesk.audit_actions'),
|
||||
'watchlistStatuses' => config('frontdesk.watchlist_statuses'),
|
||||
'canManage' => app(FrontdeskPermissions::class)->can(
|
||||
$this->member($request),
|
||||
'visitors.manage'
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkInForm(Request $request, Visitor $visitor): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visitor);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$hosts = Host::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id);
|
||||
$this->scopeToBranch($request, $hosts);
|
||||
$hosts = $hosts->orderBy('name')->get();
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('frontdesk.visitors.check-in', compact('visitor', 'organization', 'hosts', 'branches'));
|
||||
}
|
||||
|
||||
public function checkIn(Request $request, Visitor $visitor, VisitCheckInService $checkIn): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visits.manage');
|
||||
$this->authorizeOwner($request, $visitor);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'host_id' => ['nullable', 'integer'],
|
||||
'branch_id' => ['nullable', 'integer'],
|
||||
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
|
||||
'purpose' => ['nullable', 'string', 'max:500'],
|
||||
'policies_accepted' => ['accepted'],
|
||||
]);
|
||||
|
||||
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
|
||||
->branchScope($this->member($request));
|
||||
if ($branchScope !== null) {
|
||||
$validated['branch_id'] = $branchScope;
|
||||
}
|
||||
|
||||
$validated['visitor_id'] = $visitor->id;
|
||||
|
||||
$visit = $checkIn->checkIn(
|
||||
$this->ownerRef($request),
|
||||
$organization,
|
||||
$validated,
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.visits.show', $visit)
|
||||
->with('success', 'Returning visitor checked in.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Visitor $visitor): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visitors.manage');
|
||||
$this->authorizeOwner($request, $visitor);
|
||||
|
||||
$validated = $request->validate([
|
||||
'full_name' => ['required', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'photo' => ['nullable', 'image', 'max:5120'],
|
||||
'id_document' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
|
||||
]);
|
||||
|
||||
if ($request->hasFile('photo')) {
|
||||
if ($visitor->photo_path) {
|
||||
Storage::disk('public')->delete($visitor->photo_path);
|
||||
}
|
||||
$validated['photo_path'] = $request->file('photo')->store('frontdesk/visitors/photos', 'public');
|
||||
}
|
||||
|
||||
if ($request->hasFile('id_document')) {
|
||||
if ($visitor->id_document_path) {
|
||||
Storage::disk('public')->delete($visitor->id_document_path);
|
||||
}
|
||||
$validated['id_document_path'] = $request->file('id_document')->store('frontdesk/visitors/documents', 'public');
|
||||
}
|
||||
|
||||
unset($validated['photo'], $validated['id_document']);
|
||||
|
||||
$visitor->update($validated);
|
||||
|
||||
return back()->with('success', 'Visitor profile updated.');
|
||||
}
|
||||
|
||||
public function updateWatchlist(Request $request, Visitor $visitor, WatchlistService $watchlist): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'visitors.manage');
|
||||
$this->authorizeOwner($request, $visitor);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'watchlist_status' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.watchlist_statuses')))],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$visitor->update($validated);
|
||||
|
||||
$watchlist->syncEntryForVisitor(
|
||||
$visitor,
|
||||
$validated['watchlist_status'],
|
||||
$validated['notes'] ?? null,
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
AuditLog::record(
|
||||
$this->ownerRef($request),
|
||||
'watchlist.entry_created',
|
||||
$organization->id,
|
||||
$this->ownerRef($request),
|
||||
Visitor::class,
|
||||
$visitor->id,
|
||||
['watchlist_status' => $validated['watchlist_status']],
|
||||
);
|
||||
|
||||
return back()->with('success', 'Visitor watchlist status updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\WatchlistEntry;
|
||||
use App\Services\Frontdesk\WatchlistService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WatchlistController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'watchlist.view');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$entries = WatchlistEntry::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->with('visitor')
|
||||
->when($request->status, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($request->q, function ($q, $search) {
|
||||
$q->where(function ($inner) use ($search) {
|
||||
$inner->where('full_name', 'like', "%{$search}%")
|
||||
->orWhere('company', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('frontdesk.watchlist.index', [
|
||||
'entries' => $entries,
|
||||
'organization' => $organization,
|
||||
'statuses' => config('frontdesk.watchlist_statuses'),
|
||||
'canManage' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
|
||||
->can($this->member($request), 'watchlist.manage'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->authorizeAbility($request, 'watchlist.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$visitors = Visitor::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->orderBy('full_name')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return view('frontdesk.watchlist.create', [
|
||||
'organization' => $organization,
|
||||
'visitors' => $visitors,
|
||||
'statuses' => config('frontdesk.watchlist_statuses'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, WatchlistService $watchlist): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'watchlist.manage');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'visitor_id' => ['nullable', 'integer'],
|
||||
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.watchlist_statuses')))],
|
||||
'reason' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
if ($validated['status'] === Visitor::WATCHLIST_ALLOWED) {
|
||||
return back()->withErrors(['status' => 'Choose requires approval or blacklisted for watchlist entries.']);
|
||||
}
|
||||
|
||||
$visitor = null;
|
||||
if (! empty($validated['visitor_id'])) {
|
||||
$visitor = Visitor::owned($this->ownerRef($request))->findOrFail($validated['visitor_id']);
|
||||
$validated['full_name'] = $visitor->full_name;
|
||||
$validated['company'] = $visitor->company;
|
||||
}
|
||||
|
||||
$entry = WatchlistEntry::create([
|
||||
'owner_ref' => $this->ownerRef($request),
|
||||
'organization_id' => $organization->id,
|
||||
'visitor_id' => $visitor?->id,
|
||||
'full_name' => $validated['full_name'],
|
||||
'company' => $validated['company'] ?? null,
|
||||
'status' => $validated['status'],
|
||||
'reason' => $validated['reason'] ?? null,
|
||||
'created_by' => $this->ownerRef($request),
|
||||
]);
|
||||
|
||||
if ($visitor) {
|
||||
$visitor->update(['watchlist_status' => $validated['status'], 'notes' => $validated['reason'] ?? $visitor->notes]);
|
||||
}
|
||||
|
||||
AuditLog::record(
|
||||
$this->ownerRef($request),
|
||||
'watchlist.entry_created',
|
||||
$organization->id,
|
||||
$this->ownerRef($request),
|
||||
WatchlistEntry::class,
|
||||
$entry->id,
|
||||
['full_name' => $entry->full_name, 'status' => $entry->status],
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('frontdesk.watchlist.index')
|
||||
->with('success', 'Watchlist entry added.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WatchlistEntry $entry, WatchlistService $watchlist): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'watchlist.manage');
|
||||
$this->authorizeOwner($request, $entry);
|
||||
$organization = $this->organization($request);
|
||||
|
||||
if ($entry->visitor_id) {
|
||||
$visitor = Visitor::owned($this->ownerRef($request))->find($entry->visitor_id);
|
||||
$visitor?->update(['watchlist_status' => Visitor::WATCHLIST_ALLOWED]);
|
||||
}
|
||||
|
||||
AuditLog::record(
|
||||
$this->ownerRef($request),
|
||||
'watchlist.entry_removed',
|
||||
$organization->id,
|
||||
$this->ownerRef($request),
|
||||
WatchlistEntry::class,
|
||||
$entry->id,
|
||||
['full_name' => $entry->full_name],
|
||||
);
|
||||
|
||||
$entry->delete();
|
||||
|
||||
return back()->with('success', 'Watchlist entry removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class IcalFeedController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Organization $organization): Response
|
||||
{
|
||||
$owner = (string) $request->query('owner');
|
||||
$token = (string) $request->query('token');
|
||||
|
||||
abort_unless($owner !== '' && $token !== '', 403);
|
||||
abort_unless(
|
||||
hash_equals(
|
||||
hash_hmac('sha256', "{$organization->id}:{$owner}", (string) config('app.key')),
|
||||
$token,
|
||||
),
|
||||
403,
|
||||
);
|
||||
|
||||
abort_unless($organization->owner_ref === $owner, 404);
|
||||
|
||||
$visits = Visit::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->whereIn('status', [
|
||||
Visit::STATUS_SCHEDULED,
|
||||
Visit::STATUS_EXPECTED,
|
||||
Visit::STATUS_WAITING,
|
||||
])
|
||||
->whereNotNull('scheduled_at')
|
||||
->where('scheduled_at', '>=', now()->subDay())
|
||||
->with(['visitor', 'host'])
|
||||
->orderBy('scheduled_at')
|
||||
->limit(500)
|
||||
->get();
|
||||
|
||||
$lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Ladill Frontdesk//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
];
|
||||
|
||||
foreach ($visits as $visit) {
|
||||
$start = $visit->scheduled_at->utc()->format('Ymd\THis\Z');
|
||||
$end = $visit->scheduled_at->copy()->addHour()->utc()->format('Ymd\THis\Z');
|
||||
$summary = $this->escape('Visit: '.$visit->visitor->full_name);
|
||||
$description = $this->escape(trim(($visit->purpose ?? '').' Host: '.($visit->host?->name ?? '—')));
|
||||
|
||||
$lines[] = 'BEGIN:VEVENT';
|
||||
$lines[] = 'UID:frontdesk-visit-'.$visit->id.'@ladill.com';
|
||||
$lines[] = 'DTSTAMP:'.now()->utc()->format('Ymd\THis\Z');
|
||||
$lines[] = 'DTSTART:'.$start;
|
||||
$lines[] = 'DTEND:'.$end;
|
||||
$lines[] = 'SUMMARY:'.$summary;
|
||||
$lines[] = 'DESCRIPTION:'.$description;
|
||||
$lines[] = 'END:VEVENT';
|
||||
}
|
||||
|
||||
$lines[] = 'END:VCALENDAR';
|
||||
|
||||
return response(implode("\r\n", $lines), 200, [
|
||||
'Content-Type' => 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="frontdesk-visits.ics"',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function escape(string $value): string
|
||||
{
|
||||
return str_replace(["\n", ',', ';'], ['\\n', '\\,', '\\;'], $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function unread(Request $request): JsonResponse
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->unreadNotifications()
|
||||
->latest()
|
||||
->take(10)
|
||||
->get()
|
||||
->map(fn ($n) => [
|
||||
'id' => $n->id,
|
||||
'type' => class_basename($n->type),
|
||||
'title' => $n->data['title'] ?? 'Notification',
|
||||
'message' => $n->data['message'] ?? '',
|
||||
'icon' => $n->data['icon'] ?? 'bell',
|
||||
'url' => $n->data['url'] ?? null,
|
||||
'created_at' => $n->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notification = $request->user()
|
||||
->notifications()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($notification) {
|
||||
$notification->markAsRead();
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Frontdesk\DeviceService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateFrontdeskDevice
|
||||
{
|
||||
public function __construct(
|
||||
protected DeviceService $devices,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next, string $type = 'kiosk'): Response
|
||||
{
|
||||
$token = $request->route('token') ?? $request->header('X-Device-Token');
|
||||
|
||||
abort_unless(is_string($token) && $token !== '', 404);
|
||||
|
||||
$device = $this->devices->findByToken($token);
|
||||
|
||||
abort_unless($device && $device->type === $type, 404);
|
||||
|
||||
$this->devices->recordHeartbeat($device);
|
||||
|
||||
$request->attributes->set('frontdesk.device', $device->fresh());
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Generic service-to-service auth for internal APIs. Validates the bearer token
|
||||
* against per-consumer keys in config("{namespace}.service_api_keys") with a
|
||||
* constant-time compare, and records the caller. Usage: auth.service:crm.
|
||||
*/
|
||||
class AuthenticateService
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string $namespace = 'crm'): Response
|
||||
{
|
||||
$token = (string) $request->bearerToken();
|
||||
$caller = null;
|
||||
|
||||
if ($token !== '') {
|
||||
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
|
||||
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
|
||||
$caller = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($caller === null) {
|
||||
return response()->json(['error' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$request->attributes->set('service_caller', $caller);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Frontdesk\FrontdeskPermissions;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureFrontdeskAbility
|
||||
{
|
||||
public function __construct(
|
||||
protected OrganizationResolver $organizations,
|
||||
protected FrontdeskPermissions $permissions,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next, string $ability): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$organization = $this->organizations->resolveForUser($user);
|
||||
abort_unless($organization, 403);
|
||||
|
||||
$member = $this->organizations->memberFor($user, $organization);
|
||||
abort_unless($this->permissions->can($member, $ability), 403);
|
||||
|
||||
$request->attributes->set('frontdesk.organization', $organization);
|
||||
$request->attributes->set('frontdesk.member', $member);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureOrganizationSetup
|
||||
{
|
||||
public function __construct(
|
||||
protected OrganizationResolver $organizations,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->routeIs('frontdesk.onboarding*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $this->organizations->isOnboarded($user)) {
|
||||
return redirect()->route('frontdesk.onboarding.show');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* App sessions are subordinate to the platform (auth.ladill.com) session.
|
||||
* When the platform session ends, clear this app's local session too.
|
||||
*/
|
||||
class EnsurePlatformSession
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$authDomain = trim((string) config('app.auth_domain', ''));
|
||||
if ($authDomain === '') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$cookieHeader = (string) $request->headers->get('Cookie', '');
|
||||
if ($cookieHeader === '') {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders(['Cookie' => $cookieHeader])
|
||||
->timeout(3)
|
||||
->get('https://'.$authDomain.'/sso/ping');
|
||||
} catch (\Throwable) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function clearAppSession(Request $request): Response
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $request->fullUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
|
||||
* pages. The splash shows once per browser session while the app loads, then
|
||||
* fades out — masking the client-side boot so the app feels instant.
|
||||
*/
|
||||
class InjectBootSplash
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if (! $request->isMethod('GET') || ! Auth::check()) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
if (! is_string($content)
|
||||
|| stripos($content, '<body') === false
|
||||
|| str_contains($content, 'id="ladill-boot"')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$splash = View::make('partials.boot-splash')->render();
|
||||
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
|
||||
|
||||
if (is_string($content)) {
|
||||
$response->setContent($content);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Shares the signed-in account with views. CRM data is scoped per account by
|
||||
* the user's public_id (owner_ref); today every user acts as their own
|
||||
* account (team support graduates here later, like the platform pattern).
|
||||
*/
|
||||
class SetActingAccount
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($user = $request->user()) {
|
||||
$request->attributes->set('actingAccount', $user);
|
||||
|
||||
if (! $request->is('api/*')) {
|
||||
View::share('actingAccount', $user);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Campaign;
|
||||
use App\Models\CampaignRecipient;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class DispatchCampaignJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public int $campaignId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$campaign = Campaign::query()->find($this->campaignId);
|
||||
|
||||
if (! $campaign || ! in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($campaign->recipient_count === 0) {
|
||||
$campaign->forceFill([
|
||||
'status' => Campaign::STATUS_FAILED,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign->forceFill(['status' => Campaign::STATUS_SENDING])->save();
|
||||
|
||||
$campaign->recipients()
|
||||
->where('status', CampaignRecipient::STATUS_PENDING)
|
||||
->orderBy('id')
|
||||
->pluck('id')
|
||||
->each(fn (int $recipientId) => SendCampaignMessageJob::dispatch($recipientId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Campaign;
|
||||
use App\Models\CampaignRecipient;
|
||||
use App\Models\User;
|
||||
use App\Services\Campaigns\CampaignBillingService;
|
||||
use App\Services\Campaigns\CampaignMessageService;
|
||||
use App\Services\Campaigns\CampaignPricingService;
|
||||
use App\Services\Comms\EmailService;
|
||||
use App\Services\Comms\SmsService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SendCampaignMessageJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public int $recipientId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(
|
||||
EmailService $email,
|
||||
SmsService $sms,
|
||||
CampaignPricingService $pricing,
|
||||
CampaignBillingService $billing,
|
||||
CampaignMessageService $messages,
|
||||
): void {
|
||||
$recipient = CampaignRecipient::query()->with('campaign')->find($this->recipientId);
|
||||
|
||||
if (! $recipient || ! $recipient->campaign || $recipient->status !== CampaignRecipient::STATUS_PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign = $recipient->campaign;
|
||||
|
||||
if (! in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = $messages->personalize($campaign->body, $recipient->recipient_name);
|
||||
$subject = $messages->personalize((string) ($campaign->subject ?? ''), $recipient->recipient_name);
|
||||
$costMinor = $pricing->recipientCostMinor($campaign, $campaign->body);
|
||||
|
||||
if (! $billing->canAfford($campaign->owner_ref, $costMinor)) {
|
||||
$this->markRecipient($recipient, CampaignRecipient::STATUS_SKIPPED, 0, 'Insufficient wallet balance.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sent = $campaign->channel === Campaign::CHANNEL_SMS
|
||||
? $sms->send((string) $recipient->recipient_phone, $body)
|
||||
: $email->send(
|
||||
(string) $recipient->recipient_email,
|
||||
$subject !== '' ? $subject : 'Message from '.config('app.name'),
|
||||
$body,
|
||||
$this->senderName($campaign),
|
||||
$this->senderEmail($campaign),
|
||||
);
|
||||
|
||||
if (! $sent) {
|
||||
$this->markRecipient($recipient, CampaignRecipient::STATUS_FAILED, 0, 'Delivery could not be confirmed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$charged = $billing->charge(
|
||||
$campaign->owner_ref,
|
||||
$costMinor,
|
||||
$campaign->channel,
|
||||
$campaign->id,
|
||||
$recipient->id,
|
||||
'CRM campaign "'.$campaign->name.'" to '.$recipient->recipient_name,
|
||||
);
|
||||
|
||||
$this->markRecipient(
|
||||
$recipient,
|
||||
CampaignRecipient::STATUS_SENT,
|
||||
$charged ? $costMinor : 0,
|
||||
null,
|
||||
now(),
|
||||
);
|
||||
|
||||
$this->logActivity($campaign, $recipient, $subject, $body);
|
||||
}
|
||||
|
||||
private function markRecipient(
|
||||
CampaignRecipient $recipient,
|
||||
string $status,
|
||||
int $costMinor,
|
||||
?string $error,
|
||||
?\DateTimeInterface $sentAt = null,
|
||||
): void {
|
||||
$recipient->forceFill([
|
||||
'status' => $status,
|
||||
'cost_minor' => $costMinor,
|
||||
'error_message' => $error,
|
||||
'sent_at' => $sentAt,
|
||||
])->save();
|
||||
|
||||
$this->refreshCampaignCounters($recipient->campaign()->first());
|
||||
}
|
||||
|
||||
private function refreshCampaignCounters(?Campaign $campaign): void
|
||||
{
|
||||
if (! $campaign) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($campaign) {
|
||||
$campaign = Campaign::query()->lockForUpdate()->find($campaign->id);
|
||||
|
||||
if (! $campaign) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counts = $campaign->recipients()
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as sent_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as failed_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as skipped_count,
|
||||
SUM(cost_minor) as actual_cost_minor
|
||||
", [
|
||||
CampaignRecipient::STATUS_SENT,
|
||||
CampaignRecipient::STATUS_FAILED,
|
||||
CampaignRecipient::STATUS_SKIPPED,
|
||||
])
|
||||
->first();
|
||||
|
||||
$pending = $campaign->recipients()->where('status', CampaignRecipient::STATUS_PENDING)->count();
|
||||
|
||||
$campaign->forceFill([
|
||||
'sent_count' => (int) ($counts->sent_count ?? 0),
|
||||
'failed_count' => (int) ($counts->failed_count ?? 0),
|
||||
'skipped_count' => (int) ($counts->skipped_count ?? 0),
|
||||
'actual_cost_minor' => (int) ($counts->actual_cost_minor ?? 0),
|
||||
]);
|
||||
|
||||
if ($pending === 0 && in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
|
||||
$campaign->status = Campaign::STATUS_COMPLETED;
|
||||
$campaign->completed_at = now();
|
||||
} elseif ($campaign->status === Campaign::STATUS_QUEUED) {
|
||||
$campaign->status = Campaign::STATUS_SENDING;
|
||||
}
|
||||
|
||||
$campaign->save();
|
||||
});
|
||||
}
|
||||
|
||||
private function logActivity(Campaign $campaign, CampaignRecipient $recipient, string $subject, string $body): void
|
||||
{
|
||||
$title = $campaign->channel === Campaign::CHANNEL_SMS
|
||||
? 'Campaign SMS: '.$campaign->name
|
||||
: 'Campaign email: '.($subject !== '' ? $subject : $campaign->name);
|
||||
|
||||
$payload = [
|
||||
'owner_ref' => $campaign->owner_ref,
|
||||
'type' => $campaign->channel,
|
||||
'direction' => 'out',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'completed_at' => now(),
|
||||
];
|
||||
|
||||
if ($recipient->customer_id) {
|
||||
Activity::create([
|
||||
...$payload,
|
||||
'subject_type' => 'contact',
|
||||
'subject_id' => $recipient->customer_id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($recipient->lead_id) {
|
||||
Activity::create([
|
||||
...$payload,
|
||||
'subject_type' => 'lead',
|
||||
'subject_id' => $recipient->lead_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function senderName(Campaign $campaign): ?string
|
||||
{
|
||||
return User::query()->where('public_id', $campaign->owner_ref)->value('name');
|
||||
}
|
||||
|
||||
private function senderEmail(Campaign $campaign): ?string
|
||||
{
|
||||
return User::query()->where('public_id', $campaign->owner_ref)->value('email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ContactMessageMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $subjectLine,
|
||||
public string $bodyText,
|
||||
public ?string $fromName = null,
|
||||
public ?string $replyToAddress = null,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: $this->subjectLine,
|
||||
replyTo: $this->replyToAddress ? [$this->replyToAddress] : [],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'email.contact-message',
|
||||
with: [
|
||||
'bodyText' => $this->bodyText,
|
||||
'fromName' => $this->fromName,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'frontdesk_audit_logs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'actor_ref', 'action',
|
||||
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public static function record(
|
||||
string $ownerRef,
|
||||
string $action,
|
||||
?int $organizationId = null,
|
||||
?string $actorRef = null,
|
||||
?string $subjectType = null,
|
||||
?int $subjectId = null,
|
||||
?array $metadata = null,
|
||||
): self {
|
||||
return static::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $organizationId,
|
||||
'actor_ref' => $actorRef,
|
||||
'action' => $action,
|
||||
'subject_type' => $subjectType,
|
||||
'subject_id' => $subjectId,
|
||||
'metadata' => $metadata,
|
||||
'ip_address' => request()?->ip(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Branch extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_branches';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function buildings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Building::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function hosts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Host::class, 'branch_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Building extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_buildings';
|
||||
|
||||
protected $fillable = ['owner_ref', 'branch_id', 'name', 'floor_count'];
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function receptionDesks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReceptionDesk::class, 'building_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait BelongsToOwner
|
||||
{
|
||||
public function scopeOwned(Builder $query, string $ownerRef): Builder
|
||||
{
|
||||
return $query->where($this->getTable().'.owner_ref', $ownerRef);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Device extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_devices';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'branch_id', 'reception_desk_id',
|
||||
'name', 'type', 'status', 'device_token', 'config', 'last_online_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'config' => 'array',
|
||||
'last_online_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function receptionDesk(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReceptionDesk::class, 'reception_desk_id');
|
||||
}
|
||||
|
||||
public function isOnline(int $staleMinutes = 10): bool
|
||||
{
|
||||
return $this->status === 'online'
|
||||
&& $this->last_online_at
|
||||
&& $this->last_online_at->gte(now()->subMinutes($staleMinutes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Host extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_hosts';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'branch_id', 'name', 'department',
|
||||
'office', 'phone', 'email', 'extension', 'is_available', 'user_ref',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_available' => 'boolean'];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function visits(): HasMany
|
||||
{
|
||||
return $this->hasMany(Visit::class, 'host_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Member extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'frontdesk_members';
|
||||
|
||||
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function hasRole(string ...$roles): bool
|
||||
{
|
||||
return in_array($this->role, $roles, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OfflineCheckIn extends Model
|
||||
{
|
||||
protected $table = 'frontdesk_offline_checkins';
|
||||
|
||||
protected $fillable = ['device_id', 'client_id', 'payload', 'synced_at', 'visit_id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
'synced_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class, 'device_id');
|
||||
}
|
||||
|
||||
public function visit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visit::class, 'visit_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Organization extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_organizations';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'settings',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['settings' => 'array'];
|
||||
}
|
||||
|
||||
public function branches(): HasMany
|
||||
{
|
||||
return $this->hasMany(Branch::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function hosts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Host::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function visitors(): HasMany
|
||||
{
|
||||
return $this->hasMany(Visitor::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(Member::class, 'organization_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ReceptionDesk extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_reception_desks';
|
||||
|
||||
protected $fillable = ['owner_ref', 'building_id', 'name', 'location', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function building(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Building::class, 'building_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* Thin local mirror of the platform identity (auth.ladill.com owns users).
|
||||
* Keyed by the OIDC `sub` (public_id); every CRM record is scoped to it.
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, Notifiable;
|
||||
|
||||
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'last_app_active_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/** The owner reference used to scope every CRM record to this account. */
|
||||
public function ownerRef(): string
|
||||
{
|
||||
return (string) $this->public_id;
|
||||
}
|
||||
|
||||
public function customers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Customer::class, 'owner_ref', 'public_id');
|
||||
}
|
||||
|
||||
public function leads(): HasMany
|
||||
{
|
||||
return $this->hasMany(Lead::class, 'owner_ref', 'public_id');
|
||||
}
|
||||
|
||||
public function avatarUrl(): ?string
|
||||
{
|
||||
$url = trim((string) $this->avatar_url);
|
||||
|
||||
return $url !== '' ? $url : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Visit extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_visits';
|
||||
|
||||
public const STATUS_SCHEDULED = 'scheduled';
|
||||
|
||||
public const STATUS_EXPECTED = 'expected';
|
||||
|
||||
public const STATUS_WAITING = 'waiting';
|
||||
|
||||
public const STATUS_CHECKED_IN = 'checked_in';
|
||||
|
||||
public const STATUS_CHECKED_OUT = 'checked_out';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STATUS_OVERDUE = 'overdue';
|
||||
|
||||
public const ACTIVE_STATUSES = [
|
||||
self::STATUS_EXPECTED,
|
||||
self::STATUS_WAITING,
|
||||
self::STATUS_CHECKED_IN,
|
||||
self::STATUS_OVERDUE,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'public_id', 'external_ref', 'source', 'owner_ref', 'organization_id', 'branch_id', 'reception_desk_id',
|
||||
'visitor_id', 'host_id', 'visitor_type', 'status', 'purpose',
|
||||
'expected_duration_minutes', 'scheduled_at', 'checked_in_at', 'checked_out_at',
|
||||
'badge_expires_at', 'badge_code', 'qr_token', 'photo_path', 'signature_path',
|
||||
'policies_accepted', 'vehicle_info', 'contractor_details', 'delivery_details',
|
||||
'allowed_areas', 'notes', 'integration_metadata', 'checked_in_by', 'checked_out_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scheduled_at' => 'datetime',
|
||||
'checked_in_at' => 'datetime',
|
||||
'checked_out_at' => 'datetime',
|
||||
'badge_expires_at' => 'datetime',
|
||||
'policies_accepted' => 'boolean',
|
||||
'vehicle_info' => 'array',
|
||||
'contractor_details' => 'array',
|
||||
'delivery_details' => 'array',
|
||||
'allowed_areas' => 'array',
|
||||
'integration_metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Visit $visit) {
|
||||
if (! $visit->public_id) {
|
||||
$visit->public_id = (string) Str::uuid();
|
||||
}
|
||||
if (! $visit->qr_token) {
|
||||
$visit->qr_token = Str::random(32);
|
||||
}
|
||||
if (! $visit->badge_code) {
|
||||
$visit->badge_code = strtoupper(Str::random(8));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function receptionDesk(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReceptionDesk::class, 'reception_desk_id');
|
||||
}
|
||||
|
||||
public function visitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visitor::class, 'visitor_id');
|
||||
}
|
||||
|
||||
public function host(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Host::class, 'host_id');
|
||||
}
|
||||
|
||||
public function isInside(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CHECKED_IN;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_EXPECTED,
|
||||
self::STATUS_SCHEDULED,
|
||||
self::STATUS_WAITING,
|
||||
self::STATUS_OVERDUE,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function awaitingApproval(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_WAITING || $this->checked_in_at !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) (
|
||||
data_get($this->contractor_details, '_awaiting_approval')
|
||||
|| data_get($this->delivery_details, '_awaiting_approval')
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
public function typeDetailEntries(): array
|
||||
{
|
||||
$entries = [];
|
||||
$labels = collect(config('frontdesk.visitor_type_config', []))
|
||||
->flatMap(fn (array $config) => collect($config['fields'] ?? [])
|
||||
->mapWithKeys(fn (array $field) => [$field['name'] => $field['label']]));
|
||||
|
||||
foreach (array_filter([$this->contractor_details, $this->delivery_details]) as $details) {
|
||||
foreach ($details as $key => $value) {
|
||||
if (str_starts_with((string) $key, '_') || $value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = $labels->get($key, str_replace('_', ' ', (string) $key));
|
||||
|
||||
if (str_contains((string) $key, 'photo') || str_contains((string) $key, 'signature')) {
|
||||
$entries[$label] = 'On file';
|
||||
} elseif ($key === 'received_at') {
|
||||
$entries[$label] = \Illuminate\Support\Carbon::parse($value)->format('M j, Y g:i A');
|
||||
} else {
|
||||
$entries[$label] = is_bool($value) ? ($value ? 'Yes' : 'No') : (string) $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function canActivateCheckIn(): bool
|
||||
{
|
||||
return $this->isPending();
|
||||
}
|
||||
|
||||
public function isBadgeExpired(): bool
|
||||
{
|
||||
return $this->badge_expires_at && $this->badge_expires_at->isPast();
|
||||
}
|
||||
|
||||
public function scopeToday($query)
|
||||
{
|
||||
return $query->whereDate('checked_in_at', today())
|
||||
->orWhereDate('scheduled_at', today());
|
||||
}
|
||||
|
||||
public function scopeCurrentlyInside($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_CHECKED_IN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Visitor extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_visitors';
|
||||
|
||||
public const WATCHLIST_ALLOWED = 'allowed';
|
||||
|
||||
public const WATCHLIST_REQUIRES_APPROVAL = 'requires_approval';
|
||||
|
||||
public const WATCHLIST_BLACKLISTED = 'blacklisted';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'full_name', 'company', 'phone', 'email',
|
||||
'photo_path', 'id_document_path', 'watchlist_status', 'notes',
|
||||
'visit_count', 'is_frequent',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_frequent' => 'boolean'];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function visits(): HasMany
|
||||
{
|
||||
return $this->hasMany(Visit::class, 'visitor_id');
|
||||
}
|
||||
|
||||
public function isBlacklisted(): bool
|
||||
{
|
||||
return $this->watchlist_status === self::WATCHLIST_BLACKLISTED;
|
||||
}
|
||||
|
||||
public function requiresApproval(): bool
|
||||
{
|
||||
return $this->watchlist_status === self::WATCHLIST_REQUIRES_APPROVAL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class WatchlistEntry extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_watchlist_entries';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'visitor_id', 'full_name',
|
||||
'company', 'status', 'reason', 'created_by',
|
||||
];
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function visitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visitor::class, 'visitor_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WebhookEndpoint extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'frontdesk_webhook_endpoints';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'url', 'secret', 'events', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'events' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function subscribesTo(string $event): bool
|
||||
{
|
||||
$events = $this->events ?? config('frontdesk.webhook_events', []);
|
||||
|
||||
return in_array($event, $events, true) || in_array('*', $events, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class FrontdeskAlertNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $message,
|
||||
public array $payload = [],
|
||||
) {}
|
||||
|
||||
/** @return list<string> */
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->title,
|
||||
'message' => $this->message,
|
||||
'icon' => $this->payload['icon'] ?? 'bell',
|
||||
'url' => $this->payload['url'] ?? null,
|
||||
'event' => $this->payload['event'] ?? null,
|
||||
'visit_id' => $this->payload['visit_id'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('kiosk-device', function (Request $request) {
|
||||
$key = $request->route('token')
|
||||
?? $request->header('X-Device-Token')
|
||||
?? $request->ip();
|
||||
|
||||
return Limit::perMinute(30)->by((string) $key);
|
||||
});
|
||||
|
||||
RateLimiter::for('qr-scan', fn (Request $request) => Limit::perMinute(20)->by($request->ip()));
|
||||
|
||||
RateLimiter::for('device-heartbeat', function (Request $request) {
|
||||
return Limit::perMinute(60)->by((string) ($request->header('X-Device-Token') ?? $request->ip()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||
* platform; CRM only consumes it. Amounts are integer minor units; the user is
|
||||
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
|
||||
*/
|
||||
class BillingClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('billing.api_url'), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) (config('billing.api_key') ?? '');
|
||||
}
|
||||
|
||||
public function balanceMinor(string $publicId): int
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||
->get($this->base().'/balance', ['user' => $publicId]);
|
||||
$res->throw();
|
||||
|
||||
return (int) ($res->json('balance_minor') ?? 0);
|
||||
}
|
||||
|
||||
public function canAfford(string $publicId, int $amountMinor): bool
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
|
||||
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
|
||||
$res->throw();
|
||||
|
||||
return (bool) ($res->json('affordable') ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit the wallet. Returns true on success, false on insufficient balance
|
||||
* (HTTP 402). Idempotent by $reference.
|
||||
*/
|
||||
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
|
||||
'user' => $publicId,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => (string) config('billing.service', 'crm'),
|
||||
'source' => $source,
|
||||
'reference' => $reference,
|
||||
'description' => $description,
|
||||
], static fn ($v) => $v !== null));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
return false;
|
||||
}
|
||||
$res->throw();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\CrmPurchase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OneTimePurchaseService
|
||||
{
|
||||
public function __construct(private readonly BillingClient $billing)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
public function product(string $productKey): ?array
|
||||
{
|
||||
$product = config('crm_products.'.$productKey);
|
||||
|
||||
return is_array($product) ? $product : null;
|
||||
}
|
||||
|
||||
public function hasPurchased(string $ownerRef, string $productKey): bool
|
||||
{
|
||||
return CrmPurchase::has($ownerRef, $productKey);
|
||||
}
|
||||
|
||||
public function priceMinor(string $productKey): int
|
||||
{
|
||||
return (int) ($this->product($productKey)['price_minor'] ?? 0);
|
||||
}
|
||||
|
||||
public function purchase(string $ownerRef, string $productKey): bool
|
||||
{
|
||||
if ($this->hasPurchased($ownerRef, $productKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$product = $this->product($productKey);
|
||||
if (! $product) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$costMinor = (int) ($product['price_minor'] ?? 0);
|
||||
|
||||
if ($costMinor > 0) {
|
||||
try {
|
||||
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$charged = $this->billing->debit(
|
||||
$ownerRef,
|
||||
$costMinor,
|
||||
'purchase',
|
||||
'crm-purchase-'.$productKey.'-'.$ownerRef,
|
||||
'CRM: '.($product['name'] ?? $productKey),
|
||||
);
|
||||
|
||||
if (! $charged) {
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
CrmPurchase::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'product_key' => $productKey,
|
||||
'cost_minor' => $costMinor,
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Comms;
|
||||
|
||||
use App\Mail\ContactMessageMail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
|
||||
* a Ladill Bird SMTP credential in production so mail leaves as the account's
|
||||
* sender). Failures are swallowed + logged; the caller decides what to record.
|
||||
*/
|
||||
class EmailService
|
||||
{
|
||||
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
|
||||
{
|
||||
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Comms;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Outbound SMS to contacts via Arkesel (the platform SMS provider). Low-volume,
|
||||
* best-effort transactional send — mirrors the platform's direct Arkesel calls;
|
||||
* uses the shared ARKESEL_API_KEY. Failures are swallowed + logged.
|
||||
*/
|
||||
class SmsService
|
||||
{
|
||||
public function send(string $to, string $message): bool
|
||||
{
|
||||
$apiKey = (string) config('arkesel.api_key', '');
|
||||
$sender = (string) config('arkesel.sender_id', 'Ladill');
|
||||
|
||||
$msisdn = $this->normalise($to);
|
||||
if ($apiKey === '' || $msisdn === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::timeout(20)
|
||||
->withHeaders(['api-key' => $apiKey])
|
||||
->acceptJson()
|
||||
->post(rtrim((string) config('arkesel.base_url', 'https://sms.arkesel.com'), '/').'/api/v2/sms/send', [
|
||||
'sender' => $sender,
|
||||
'message' => $message,
|
||||
'recipients' => [$msisdn],
|
||||
]);
|
||||
|
||||
return $res->successful() && ($res->json('status') === 'success');
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CRM SMS send failed', ['to' => $msisdn, 'error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalise to E.164-without-plus (e.g. 233XXXXXXXXX). */
|
||||
private function normalise(string $to): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $to) ?? '';
|
||||
$country = (string) config('arkesel.default_country_code', '233');
|
||||
|
||||
if (strlen($digits) < 9) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($digits, $country)) {
|
||||
return $digits;
|
||||
}
|
||||
|
||||
if (str_starts_with($digits, '0')) {
|
||||
return $country.substr($digits, 1);
|
||||
}
|
||||
|
||||
if (strlen($digits) === 9) {
|
||||
return $country.$digits;
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CrossApp;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Deal;
|
||||
use App\Support\CrmPrefillCodec;
|
||||
use App\Support\LadillAppUrl;
|
||||
|
||||
class CrossAppLinkService
|
||||
{
|
||||
public function invoiceFromDeal(Deal $deal): string
|
||||
{
|
||||
$deal->loadMissing(['customer', 'lines']);
|
||||
|
||||
$customer = $deal->customer;
|
||||
$lines = $deal->lines->map(fn ($line) => [
|
||||
'description' => $line->description,
|
||||
'quantity' => (float) $line->quantity,
|
||||
'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''),
|
||||
])->values()->all();
|
||||
|
||||
if ($lines === [] && (int) $deal->value_minor > 0) {
|
||||
$lines = [[
|
||||
'description' => $deal->title,
|
||||
'quantity' => 1,
|
||||
'unit_price' => number_format($deal->value_minor / 100, 2, '.', ''),
|
||||
]];
|
||||
}
|
||||
|
||||
$prefill = CrmPrefillCodec::encode([
|
||||
'kind' => 'invoice',
|
||||
'crm_customer_id' => $customer?->id,
|
||||
'client_name' => $customer?->name ?: $deal->title,
|
||||
'client_email' => $customer?->email,
|
||||
'client_address' => $customer ? $this->formatAddress($customer) : null,
|
||||
'notes' => 'Invoice for CRM deal: '.$deal->title,
|
||||
'payment_enabled' => true,
|
||||
'lines' => $lines,
|
||||
]);
|
||||
|
||||
return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill));
|
||||
}
|
||||
|
||||
public function merchantStorefrontFromDeal(Deal $deal): string
|
||||
{
|
||||
$deal->loadMissing('customer');
|
||||
|
||||
$amount = number_format(((int) $deal->value_minor) / 100, 2, '.', '');
|
||||
$itemName = $deal->title;
|
||||
|
||||
$prefill = CrmPrefillCodec::encode([
|
||||
'kind' => 'merchant_storefront',
|
||||
'type' => 'shop',
|
||||
'label' => 'Payment — '.$deal->title,
|
||||
'shop_title' => $deal->customer?->company ?: $deal->title,
|
||||
'sections' => [[
|
||||
'name' => 'Payment',
|
||||
'items' => [[
|
||||
'name' => $itemName,
|
||||
'description' => $deal->notes ?: 'Payment for '.$deal->title,
|
||||
'price' => $amount,
|
||||
'image_path' => '',
|
||||
]],
|
||||
]],
|
||||
'accepts_payment' => true,
|
||||
]);
|
||||
|
||||
return LadillAppUrl::connect('merchant', '/storefronts/create?prefill='.urlencode($prefill));
|
||||
}
|
||||
|
||||
public function businessQrFromContact(Customer $contact): string
|
||||
{
|
||||
$prefill = CrmPrefillCodec::encode([
|
||||
'kind' => 'qr_business',
|
||||
'type' => 'business',
|
||||
'label' => 'Card — '.$contact->name,
|
||||
'name' => $contact->company ?: $contact->name,
|
||||
'phone' => $contact->phone,
|
||||
'email' => $contact->email,
|
||||
'address' => $this->formatAddress($contact, singleLine: true),
|
||||
]);
|
||||
|
||||
return LadillAppUrl::connect('qrplus', '/qr-codes/create?prefill='.urlencode($prefill));
|
||||
}
|
||||
|
||||
public function eventsHubForContact(Customer $contact): string
|
||||
{
|
||||
$query = http_build_query(array_filter([
|
||||
'search' => $contact->company ?: $contact->name,
|
||||
]));
|
||||
|
||||
return LadillAppUrl::connect('events', '/attendees'.($query !== '' ? '?'.$query : ''));
|
||||
}
|
||||
|
||||
private function formatAddress(Customer $contact, bool $singleLine = false): ?string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$contact->address_line1,
|
||||
$contact->address_line2,
|
||||
trim(implode(', ', array_filter([$contact->city, $contact->region, $contact->country]))),
|
||||
]);
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $singleLine
|
||||
? implode(', ', $parts)
|
||||
: implode("\n", $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BadgeRenderService
|
||||
{
|
||||
public function __construct(
|
||||
protected QrCodeService $qrCodes,
|
||||
) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function templateFor(Organization $organization): array
|
||||
{
|
||||
return array_merge(config('frontdesk.default_badge_template', []), $organization->settings['badge_template'] ?? []);
|
||||
}
|
||||
|
||||
public function renderHtml(Visit $visit, bool $autoPrint = false): string
|
||||
{
|
||||
$visit->load(['visitor', 'host', 'organization']);
|
||||
|
||||
return view('frontdesk.badges.render', [
|
||||
'visit' => $visit,
|
||||
'template' => $this->templateFor($visit->organization),
|
||||
'photoUrl' => $this->photoUrl($visit),
|
||||
'qrSvg' => $visit->qr_token ? $this->qrCodes->svg($visit, 120) : null,
|
||||
'autoPrint' => $autoPrint,
|
||||
'preview' => false,
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function photoUrl(Visit $visit): ?string
|
||||
{
|
||||
$path = $visit->photo_path ?? $visit->visitor?->photo_path;
|
||||
|
||||
return $path && Storage::disk('public')->exists($path)
|
||||
? Storage::disk('public')->url($path)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceService
|
||||
{
|
||||
public function generateToken(): string
|
||||
{
|
||||
return Str::random(48);
|
||||
}
|
||||
|
||||
public function findByToken(string $token): ?Device
|
||||
{
|
||||
return Device::query()
|
||||
->where('device_token', $token)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function recordHeartbeat(Device $device): Device
|
||||
{
|
||||
$device->update([
|
||||
'status' => 'online',
|
||||
'last_online_at' => now(),
|
||||
]);
|
||||
|
||||
return $device->fresh();
|
||||
}
|
||||
|
||||
public function markStaleDevicesOffline(int $minutes = 10): int
|
||||
{
|
||||
return Device::query()
|
||||
->where('status', 'online')
|
||||
->where(function ($q) use ($minutes) {
|
||||
$q->whereNull('last_online_at')
|
||||
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
|
||||
})
|
||||
->update(['status' => 'offline']);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $config */
|
||||
public function defaultConfigForType(string $type): array
|
||||
{
|
||||
return match ($type) {
|
||||
'badge_printer' => ['driver' => config('frontdesk.printers.default_driver', 'pdf')],
|
||||
'kiosk' => ['mode' => 'self_service', 'allow_checkout' => false],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Member;
|
||||
|
||||
class FrontdeskPermissions
|
||||
{
|
||||
/** @var array<string, list<string>> */
|
||||
protected array $roleAbilities = [
|
||||
'super_admin' => ['*'],
|
||||
'org_admin' => ['*'],
|
||||
'branch_admin' => [
|
||||
'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage',
|
||||
'hosts.view', 'hosts.manage', 'kiosk.use', 'security.view', 'security.checkout', 'security.verify',
|
||||
'settings.view', 'admin.branches.view', 'admin.desks.view', 'admin.desks.manage',
|
||||
'watchlist.view', 'watchlist.manage', 'audit.view', 'audit.export',
|
||||
'devices.view', 'devices.manage', 'reports.view', 'reports.export',
|
||||
],
|
||||
'receptionist' => [
|
||||
'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage',
|
||||
'hosts.view', 'kiosk.use', 'watchlist.view', 'devices.view',
|
||||
],
|
||||
'security_officer' => [
|
||||
'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'security.checkout', 'security.verify',
|
||||
'watchlist.view', 'audit.view',
|
||||
],
|
||||
'host' => [
|
||||
'dashboard.view', 'visits.view', 'visitors.view', 'host.portal',
|
||||
],
|
||||
'auditor' => [
|
||||
'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'settings.view',
|
||||
'audit.view', 'audit.export', 'watchlist.view', 'compliance.restore',
|
||||
'reports.view', 'reports.export',
|
||||
],
|
||||
];
|
||||
|
||||
public function can(?Member $member, string $ability): bool
|
||||
{
|
||||
if ($member === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$abilities = $this->roleAbilities[$member->role] ?? [];
|
||||
|
||||
if (in_array('*', $abilities, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($ability, $abilities, true);
|
||||
}
|
||||
|
||||
public function isAdmin(?Member $member): bool
|
||||
{
|
||||
return $member !== null && in_array($member->role, ['super_admin', 'org_admin'], true);
|
||||
}
|
||||
|
||||
public function managesBranches(?Member $member): bool
|
||||
{
|
||||
return $this->can($member, 'admin.branches.manage');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\Visit;
|
||||
use App\Notifications\FrontdeskAlertNotification;
|
||||
use App\Services\Comms\EmailService;
|
||||
use App\Services\Comms\SmsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NotificationDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
protected EmailService $email,
|
||||
protected SmsService $sms,
|
||||
protected NotificationPreferenceService $preferences,
|
||||
) {}
|
||||
|
||||
public function visitorArrived(Visit $visit): void
|
||||
{
|
||||
$this->dispatchVisitEvent(
|
||||
$visit,
|
||||
'visitor_arrived',
|
||||
'Visitor has arrived',
|
||||
"{$visit->visitor->full_name} has checked in.",
|
||||
['icon' => 'user-check'],
|
||||
);
|
||||
}
|
||||
|
||||
public function visitorCheckedOut(Visit $visit): void
|
||||
{
|
||||
$this->dispatchVisitEvent(
|
||||
$visit,
|
||||
'visitor_checked_out',
|
||||
'Visitor checked out',
|
||||
"{$visit->visitor->full_name} has checked out.",
|
||||
['icon' => 'logout'],
|
||||
);
|
||||
}
|
||||
|
||||
public function visitorExpected(Visit $visit): void
|
||||
{
|
||||
$when = $visit->scheduled_at?->format('M j, g:i A') ?? 'today';
|
||||
$this->dispatchVisitEvent(
|
||||
$visit,
|
||||
'visitor_expected',
|
||||
'Visitor expected',
|
||||
"{$visit->visitor->full_name} is expected on {$when}.",
|
||||
['icon' => 'calendar'],
|
||||
);
|
||||
}
|
||||
|
||||
public function visitorWaiting(Visit $visit): void
|
||||
{
|
||||
$this->dispatchVisitEvent(
|
||||
$visit,
|
||||
'visitor_waiting',
|
||||
'Visitor waiting',
|
||||
"{$visit->visitor->full_name} is waiting in reception.",
|
||||
['icon' => 'clock'],
|
||||
);
|
||||
}
|
||||
|
||||
public function visitCancelled(Visit $visit): void
|
||||
{
|
||||
$this->dispatchVisitEvent(
|
||||
$visit,
|
||||
'visit_cancelled',
|
||||
'Visit cancelled',
|
||||
"The visit for {$visit->visitor->full_name} has been cancelled.",
|
||||
['icon' => 'x-circle'],
|
||||
);
|
||||
}
|
||||
|
||||
public function approvalNeeded(Visit $visit): void
|
||||
{
|
||||
$visit->load(['visitor', 'host', 'organization']);
|
||||
|
||||
$this->dispatchVisitEvent(
|
||||
$visit,
|
||||
'approval_needed',
|
||||
'Visitor awaiting approval',
|
||||
"{$visit->visitor->full_name} ({$visit->visitor_type}) is waiting for approval.",
|
||||
['icon' => 'shield-alert'],
|
||||
);
|
||||
|
||||
$this->notifyStaffUsers(
|
||||
$visit->organization,
|
||||
'approval_needed',
|
||||
'Approval required',
|
||||
"{$visit->visitor->full_name} needs reception approval.",
|
||||
$visit,
|
||||
);
|
||||
}
|
||||
|
||||
public function watchlistBlockedAttempt(Visitor $visitor, Organization $organization): void
|
||||
{
|
||||
Log::alert('Watchlist blocked check-in attempt', [
|
||||
'visitor_id' => $visitor->id,
|
||||
'visitor' => $visitor->full_name,
|
||||
'organization_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$this->notifyStaffUsers(
|
||||
$organization,
|
||||
'watchlist_alert',
|
||||
'Blocked check-in attempt',
|
||||
"{$visitor->full_name} (blacklisted) attempted to check in.",
|
||||
);
|
||||
}
|
||||
|
||||
public function watchlistFlaggedCheckIn(Visitor $visitor, Organization $organization): void
|
||||
{
|
||||
Log::warning('Watchlist flagged visitor submitted for approval', [
|
||||
'visitor_id' => $visitor->id,
|
||||
'visitor' => $visitor->full_name,
|
||||
]);
|
||||
|
||||
$this->notifyStaffUsers(
|
||||
$organization,
|
||||
'watchlist_alert',
|
||||
'Flagged visitor check-in',
|
||||
"{$visitor->full_name} requires approval before badge issue.",
|
||||
);
|
||||
}
|
||||
|
||||
public function badgeExpired(Visit $visit): void
|
||||
{
|
||||
$visit->load(['visitor', 'organization']);
|
||||
|
||||
$this->notifyStaffUsers(
|
||||
$visit->organization,
|
||||
'badge_expired',
|
||||
'Expired badge',
|
||||
"{$visit->visitor->full_name}'s badge expired while still checked in.",
|
||||
$visit,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $meta */
|
||||
protected function dispatchVisitEvent(
|
||||
Visit $visit,
|
||||
string $event,
|
||||
string $title,
|
||||
string $message,
|
||||
array $meta = [],
|
||||
): void {
|
||||
$visit->load(['visitor', 'host', 'organization']);
|
||||
$channels = $this->preferences->channelsForEvent($visit->organization, $event);
|
||||
|
||||
if ($channels === [] || ! $visit->host) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifyHost($visit->host, $visit->organization, $channels, $title, $message, $visit, $event, $meta);
|
||||
}
|
||||
|
||||
/** @param list<string> $channels */
|
||||
protected function notifyHost(
|
||||
Host $host,
|
||||
Organization $organization,
|
||||
array $channels,
|
||||
string $title,
|
||||
string $message,
|
||||
?Visit $visit = null,
|
||||
?string $event = null,
|
||||
array $meta = [],
|
||||
): void {
|
||||
if (in_array('email', $channels, true) && $host->email) {
|
||||
try {
|
||||
$this->email->send($host->email, $title, $message);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Frontdesk host email failed', ['host_id' => $host->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('sms', $channels, true) && $host->phone) {
|
||||
$this->sms->send($host->phone, "{$title}: {$message}");
|
||||
}
|
||||
|
||||
if ($host->user_ref) {
|
||||
$user = User::where('public_id', $host->user_ref)->first();
|
||||
if ($user) {
|
||||
$user->notify(new FrontdeskAlertNotification($title, $message, array_merge($meta, [
|
||||
'event' => $event,
|
||||
'visit_id' => $visit?->id,
|
||||
'url' => $visit ? route('frontdesk.visits.show', $visit) : null,
|
||||
])));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function notifyStaffUsers(
|
||||
Organization $organization,
|
||||
string $event,
|
||||
string $title,
|
||||
string $message,
|
||||
?Visit $visit = null,
|
||||
): void {
|
||||
if (! $this->preferences->isEventEnabled($organization, $event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roles = ['org_admin', 'branch_admin', 'receptionist', 'security_officer'];
|
||||
|
||||
$userRefs = Member::query()
|
||||
->where('organization_id', $organization->id)
|
||||
->whereIn('role', $roles)
|
||||
->pluck('user_ref')
|
||||
->unique();
|
||||
|
||||
foreach ($userRefs as $userRef) {
|
||||
$user = User::where('public_id', $userRef)->first();
|
||||
if (! $user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user->notify(new FrontdeskAlertNotification($title, $message, [
|
||||
'event' => $event,
|
||||
'visit_id' => $visit?->id,
|
||||
'url' => $visit ? route('frontdesk.visits.show', $visit) : route('frontdesk.dashboard'),
|
||||
'icon' => 'bell',
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
class NotificationPreferenceService
|
||||
{
|
||||
/** @return list<string> */
|
||||
public function channelsForEvent(Organization $organization, string $event): array
|
||||
{
|
||||
if (! $this->isEventEnabled($organization, $event)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$organization->settings['notification_channels'] ?? ['email'],
|
||||
fn (string $channel) => in_array($channel, array_keys(config('frontdesk.notification_channels', [])), true),
|
||||
));
|
||||
}
|
||||
|
||||
public function isEventEnabled(Organization $organization, string $event): bool
|
||||
{
|
||||
$events = $organization->settings['notification_events']
|
||||
?? config('frontdesk.default_notification_events', []);
|
||||
|
||||
return (bool) ($events[$event] ?? true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Branch;
|
||||
use App\Models\Host;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OrganizationResolver
|
||||
{
|
||||
public function resolveForUser(User $user): ?Organization
|
||||
{
|
||||
$ref = $user->ownerRef();
|
||||
|
||||
$member = Member::where('user_ref', $ref)->first();
|
||||
if ($member) {
|
||||
return Organization::find($member->organization_id);
|
||||
}
|
||||
|
||||
return Organization::owned($ref)->first();
|
||||
}
|
||||
|
||||
public function forUser(User $user): Organization
|
||||
{
|
||||
return $this->resolveForUser($user)
|
||||
?? throw new \RuntimeException('No organization for user.');
|
||||
}
|
||||
|
||||
public function isOnboarded(User $user): bool
|
||||
{
|
||||
$organization = $this->resolveForUser($user);
|
||||
|
||||
return $organization !== null
|
||||
&& (bool) data_get($organization->settings, 'onboarded', false);
|
||||
}
|
||||
|
||||
public function memberFor(User $user, ?Organization $organization = null): ?Member
|
||||
{
|
||||
$organization ??= $this->resolveForUser($user);
|
||||
if (! $organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Member::where('organization_id', $organization->id)
|
||||
->where('user_ref', $user->ownerRef())
|
||||
->first();
|
||||
}
|
||||
|
||||
public function ensureOwnerMember(User $user, Organization $organization): Member
|
||||
{
|
||||
return Member::firstOrCreate(
|
||||
[
|
||||
'organization_id' => $organization->id,
|
||||
'user_ref' => $user->ownerRef(),
|
||||
],
|
||||
[
|
||||
'owner_ref' => $user->ownerRef(),
|
||||
'role' => 'org_admin',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function completeOnboarding(User $user, array $data): Organization
|
||||
{
|
||||
$ref = $user->ownerRef();
|
||||
|
||||
$organization = Organization::create([
|
||||
'owner_ref' => $ref,
|
||||
'name' => $data['organization_name'],
|
||||
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
|
||||
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
|
||||
'settings' => [
|
||||
'onboarded' => true,
|
||||
'badge_expiry_hours' => (int) ($data['badge_expiry_hours'] ?? config('frontdesk.badge.default_expiry_hours', 8)),
|
||||
'kiosk_reset_seconds' => (int) ($data['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
|
||||
'notification_channels' => ['email'],
|
||||
'visitor_policy' => $data['visitor_policy'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->ensureOwnerMember($user, $organization);
|
||||
|
||||
Branch::create([
|
||||
'owner_ref' => $ref,
|
||||
'organization_id' => $organization->id,
|
||||
'name' => $data['branch_name'],
|
||||
'address' => $data['branch_address'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
|
||||
public function hostFor(User $user): ?Host
|
||||
{
|
||||
$organization = $this->resolveForUser($user);
|
||||
if (! $organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Host::where('organization_id', $organization->id)
|
||||
->where('user_ref', $user->ownerRef())
|
||||
->first();
|
||||
}
|
||||
|
||||
/** Branch ID the member may access; null = all branches. */
|
||||
public function branchScope(?Member $member): ?int
|
||||
{
|
||||
if ($member === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($member->role, ['super_admin', 'org_admin', 'auditor'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $member->branch_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Visit;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
|
||||
class QrCodeService
|
||||
{
|
||||
public function visitUrl(Visit $visit): string
|
||||
{
|
||||
$path = config('frontdesk.badge.qr_url_path', '/q');
|
||||
|
||||
return url($path.'/'.$visit->qr_token);
|
||||
}
|
||||
|
||||
public function svg(Visit $visit, int $size = 200): string
|
||||
{
|
||||
$options = new QROptions([
|
||||
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
|
||||
'scale' => 5,
|
||||
'imageBase64' => false,
|
||||
]);
|
||||
|
||||
return (new QRCode($options))->render($this->visitUrl($visit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Host;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use App\Models\Visitor;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ReportService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summary(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$visits = $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId);
|
||||
|
||||
$checkedIn = (clone $visits)->whereNotNull('checked_in_at');
|
||||
$durations = (clone $checkedIn)
|
||||
->whereNotNull('checked_out_at')
|
||||
->get(['checked_in_at', 'checked_out_at'])
|
||||
->map(fn (Visit $v) => $v->checked_in_at->diffInMinutes($v->checked_out_at));
|
||||
|
||||
return [
|
||||
'total_visits' => (clone $visits)->count(),
|
||||
'checked_in' => (clone $checkedIn)->count(),
|
||||
'checked_out' => (clone $visits)->where('status', Visit::STATUS_CHECKED_OUT)->count(),
|
||||
'cancelled' => (clone $visits)->where('status', Visit::STATUS_CANCELLED)->count(),
|
||||
'contractors' => (clone $checkedIn)->where('visitor_type', 'contractor')->count(),
|
||||
'deliveries' => (clone $checkedIn)->where('visitor_type', 'delivery')->count(),
|
||||
'avg_duration_minutes' => $durations->isEmpty() ? 0 : (int) round($durations->avg()),
|
||||
'unique_visitors' => (clone $checkedIn)->distinct('visitor_id')->count('visitor_id'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, array{hour: int, count: int}> */
|
||||
public function peakHours(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$counts = array_fill(0, 24, 0);
|
||||
|
||||
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
|
||||
->whereNotNull('checked_in_at')
|
||||
->pluck('checked_in_at')
|
||||
->each(function ($checkedInAt) use (&$counts) {
|
||||
$counts[(int) Carbon::parse($checkedInAt)->format('G')]++;
|
||||
});
|
||||
|
||||
$hours = [];
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
$hours[] = ['hour' => $h, 'count' => $counts[$h]];
|
||||
}
|
||||
|
||||
return $hours;
|
||||
}
|
||||
|
||||
/** @return Collection<int, object{department: string, count: int}> */
|
||||
public function visitsByDepartment(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): Collection
|
||||
{
|
||||
return $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
|
||||
->whereNotNull('frontdesk_visits.checked_in_at')
|
||||
->join('frontdesk_hosts', 'frontdesk_visits.host_id', '=', 'frontdesk_hosts.id')
|
||||
->selectRaw("coalesce(frontdesk_hosts.department, 'Unassigned') as department, count(*) as total")
|
||||
->groupBy('department')
|
||||
->orderByDesc('total')
|
||||
->get()
|
||||
->map(fn ($row) => (object) ['department' => $row->department, 'count' => (int) $row->total]);
|
||||
}
|
||||
|
||||
/** @return Collection<int, Visitor> */
|
||||
public function frequentVisitors(string $ownerRef, Organization $organization, int $limit = 10): Collection
|
||||
{
|
||||
return Visitor::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('is_frequent', true)
|
||||
->orderByDesc('visit_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
public function securityIncidents(string $ownerRef, Organization $organization, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$query = AuditLog::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->whereBetween('created_at', [$from, $to])
|
||||
->whereIn('action', [
|
||||
'watchlist.blocked_attempt',
|
||||
'watchlist.flagged_checkin',
|
||||
'badge.expired',
|
||||
]);
|
||||
|
||||
return [
|
||||
'blocked_attempts' => (clone $query)->where('action', 'watchlist.blocked_attempt')->count(),
|
||||
'flagged_checkins' => (clone $query)->where('action', 'watchlist.flagged_checkin')->count(),
|
||||
'expired_badges' => (clone $query)->where('action', 'badge.expired')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, array{date: string, count: int}> */
|
||||
public function dailyCounts(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
|
||||
->whereNotNull('checked_in_at')
|
||||
->pluck('checked_in_at')
|
||||
->each(function ($checkedInAt) use (&$rows) {
|
||||
$key = Carbon::parse($checkedInAt)->toDateString();
|
||||
$rows[$key] = ($rows[$key] ?? 0) + 1;
|
||||
});
|
||||
|
||||
$days = [];
|
||||
for ($date = $from->copy()->startOfDay(); $date->lte($to); $date->addDay()) {
|
||||
$key = $date->toDateString();
|
||||
$days[] = ['date' => $key, 'count' => (int) ($rows[$key] ?? 0)];
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
protected function visitQuery(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): Builder
|
||||
{
|
||||
return Visit::query()
|
||||
->from('frontdesk_visits')
|
||||
->where('frontdesk_visits.owner_ref', $ownerRef)
|
||||
->where('frontdesk_visits.organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('frontdesk_visits.branch_id', $branchId))
|
||||
->where(function ($q) use ($from, $to) {
|
||||
$q->whereBetween('frontdesk_visits.checked_in_at', [$from, $to])
|
||||
->orWhereBetween('frontdesk_visits.scheduled_at', [$from, $to]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class VisitCheckInService
|
||||
{
|
||||
public function __construct(
|
||||
protected WatchlistService $watchlist,
|
||||
protected NotificationDispatcher $notifications,
|
||||
protected QrCodeService $qrCodes,
|
||||
protected VisitorTypeService $visitorTypes,
|
||||
protected \App\Services\Integrations\WebhookDispatcher $webhooks,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function checkIn(string $ownerRef, Organization $organization, array $data, ?string $actorRef = null): Visit
|
||||
{
|
||||
$data = $this->processMediaFields($data);
|
||||
$visitorType = $data['visitor_type'] ?? 'visitor';
|
||||
|
||||
if (! empty($data['type_fields'])) {
|
||||
$data = $this->visitorTypes->mergeTypeDetailsFromArray($visitorType, $data, $organization);
|
||||
}
|
||||
|
||||
$visitor = $this->resolveVisitor($ownerRef, $organization, $data);
|
||||
|
||||
$this->watchlist->assertCanCheckIn($visitor, $organization, $actorRef);
|
||||
|
||||
$needsApproval = $this->visitorTypes->requiresApproval($visitorType, $organization)
|
||||
|| $this->watchlist->visitorNeedsApprovalQueue($visitor);
|
||||
|
||||
if ($needsApproval) {
|
||||
$data = $this->watchlist->applyApprovalMetadata($data, $visitor);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($ownerRef, $organization, $data, $actorRef, $visitor, $visitorType, $needsApproval) {
|
||||
$duration = (int) ($data['expected_duration_minutes']
|
||||
?? config('frontdesk.kiosk.default_visit_duration_minutes', 60));
|
||||
$expiryHours = $this->visitorTypes->badgeExpiryHours($visitorType, $organization);
|
||||
|
||||
$visit = Visit::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $organization->id,
|
||||
'branch_id' => $data['branch_id'] ?? null,
|
||||
'reception_desk_id' => $data['reception_desk_id'] ?? null,
|
||||
'visitor_id' => $visitor->id,
|
||||
'host_id' => $data['host_id'] ?? null,
|
||||
'visitor_type' => $visitorType,
|
||||
'status' => $needsApproval ? Visit::STATUS_WAITING : Visit::STATUS_CHECKED_IN,
|
||||
'purpose' => $data['purpose'] ?? null,
|
||||
'expected_duration_minutes' => $duration,
|
||||
'scheduled_at' => $data['scheduled_at'] ?? null,
|
||||
'checked_in_at' => $needsApproval ? null : now(),
|
||||
'badge_expires_at' => $needsApproval ? null : now()->addHours($expiryHours),
|
||||
'photo_path' => $data['photo_path'] ?? null,
|
||||
'signature_path' => $data['signature_path'] ?? null,
|
||||
'policies_accepted' => (bool) ($data['policies_accepted'] ?? false),
|
||||
'vehicle_info' => $data['vehicle_info'] ?? null,
|
||||
'contractor_details' => $data['contractor_details'] ?? null,
|
||||
'delivery_details' => $data['delivery_details'] ?? null,
|
||||
'allowed_areas' => $data['allowed_areas'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'checked_in_by' => $needsApproval ? null : $actorRef,
|
||||
'external_ref' => $data['external_ref'] ?? null,
|
||||
'source' => $data['source'] ?? null,
|
||||
'integration_metadata' => $data['integration_metadata'] ?? null,
|
||||
]);
|
||||
|
||||
if (! $needsApproval) {
|
||||
$this->finalizeCheckIn($visit, $actorRef);
|
||||
} else {
|
||||
AuditLog::record(
|
||||
$ownerRef,
|
||||
'visit.awaiting_approval',
|
||||
$organization->id,
|
||||
$actorRef,
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor' => $visitor->full_name, 'visitor_type' => $visitorType],
|
||||
);
|
||||
|
||||
$this->notifications->approvalNeeded($visit);
|
||||
|
||||
if ($this->watchlist->visitorNeedsApprovalQueue($visitor)) {
|
||||
$this->watchlist->recordFlaggedCheckIn($visitor, $organization, $actorRef, [
|
||||
'visit_id' => $visit->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $visit->load(['visitor', 'host', 'organization']);
|
||||
});
|
||||
}
|
||||
|
||||
public function activateCheckIn(Visit $visit, ?string $actorRef = null, bool $policiesAccepted = true): Visit
|
||||
{
|
||||
return DB::transaction(function () use ($visit, $actorRef, $policiesAccepted) {
|
||||
$visit->load(['visitor', 'organization']);
|
||||
$expiryHours = $this->visitorTypes->badgeExpiryHours($visit->visitor_type, $visit->organization);
|
||||
|
||||
$contractorDetails = $visit->contractor_details ?? [];
|
||||
$deliveryDetails = $visit->delivery_details ?? [];
|
||||
unset($contractorDetails['_awaiting_approval'], $deliveryDetails['_awaiting_approval']);
|
||||
|
||||
$visit->update([
|
||||
'status' => Visit::STATUS_CHECKED_IN,
|
||||
'checked_in_at' => now(),
|
||||
'badge_expires_at' => now()->addHours($expiryHours),
|
||||
'policies_accepted' => $policiesAccepted,
|
||||
'checked_in_by' => $actorRef,
|
||||
'contractor_details' => $contractorDetails ?: null,
|
||||
'delivery_details' => $deliveryDetails ?: null,
|
||||
]);
|
||||
|
||||
$this->finalizeCheckIn($visit, $actorRef);
|
||||
|
||||
return $visit->fresh(['visitor', 'host', 'organization']);
|
||||
});
|
||||
}
|
||||
|
||||
protected function finalizeCheckIn(Visit $visit, ?string $actorRef): void
|
||||
{
|
||||
$visitor = $visit->visitor;
|
||||
$visitor->increment('visit_count');
|
||||
if ($visitor->visit_count >= 5) {
|
||||
$visitor->update(['is_frequent' => true]);
|
||||
}
|
||||
|
||||
AuditLog::record(
|
||||
$visit->owner_ref,
|
||||
'visit.checked_in',
|
||||
$visit->organization_id,
|
||||
$actorRef,
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor' => $visitor->full_name, 'badge_code' => $visit->badge_code],
|
||||
);
|
||||
|
||||
$this->notifications->visitorArrived($visit);
|
||||
$this->webhooks->dispatch('visit.checked_in', $visit);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
protected function processMediaFields(array $data): array
|
||||
{
|
||||
foreach (['photo_data' => 'photo_path', 'signature_data' => 'signature_path'] as $input => $target) {
|
||||
if (! empty($data[$input])) {
|
||||
$data[$target] = $this->visitorTypes->storeBase64Image($data[$input], $input === 'photo_data' ? 'photos' : 'signatures');
|
||||
unset($data[$input]);
|
||||
} elseif (! empty($data[$target]) && is_string($data[$target]) && str_starts_with($data[$target], 'data:image')) {
|
||||
$data[$target] = $this->visitorTypes->storeBase64Image(
|
||||
$data[$target],
|
||||
$target === 'photo_path' ? 'photos' : 'signatures',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function resolveVisitorForSchedule(string $ownerRef, Organization $organization, array $data): Visitor
|
||||
{
|
||||
return $this->resolveVisitor($ownerRef, $organization, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
protected function resolveVisitor(string $ownerRef, Organization $organization, array $data): Visitor
|
||||
{
|
||||
if (! empty($data['visitor_id'])) {
|
||||
return Visitor::owned($ownerRef)->findOrFail($data['visitor_id']);
|
||||
}
|
||||
|
||||
$existing = Visitor::owned($ownerRef)
|
||||
->where('organization_id', $organization->id)
|
||||
->where(function ($q) use ($data) {
|
||||
if (! empty($data['email'])) {
|
||||
$q->where('email', $data['email']);
|
||||
}
|
||||
if (! empty($data['phone'])) {
|
||||
$q->orWhere('phone', $data['phone']);
|
||||
}
|
||||
})
|
||||
->when(empty($data['email']) && empty($data['phone']), fn ($q) => $q->whereRaw('0 = 1'))
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->update(array_filter([
|
||||
'full_name' => $data['full_name'] ?? $existing->full_name,
|
||||
'company' => $data['company'] ?? $existing->company,
|
||||
'phone' => $data['phone'] ?? $existing->phone,
|
||||
'email' => $data['email'] ?? $existing->email,
|
||||
'photo_path' => $data['photo_path'] ?? $existing->photo_path,
|
||||
]));
|
||||
|
||||
return $existing->fresh();
|
||||
}
|
||||
|
||||
return Visitor::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $organization->id,
|
||||
'full_name' => $data['full_name'],
|
||||
'company' => $data['company'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'photo_path' => $data['photo_path'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Visit;
|
||||
|
||||
class VisitCheckOutService
|
||||
{
|
||||
public function __construct(
|
||||
protected NotificationDispatcher $notifications,
|
||||
protected \App\Services\Integrations\WebhookDispatcher $webhooks,
|
||||
) {}
|
||||
|
||||
public function checkOut(Visit $visit, ?string $actorRef = null): Visit
|
||||
{
|
||||
abort_unless($visit->isInside(), 422, 'Visitor is not currently checked in.');
|
||||
|
||||
$visit->update([
|
||||
'status' => Visit::STATUS_CHECKED_OUT,
|
||||
'checked_out_at' => now(),
|
||||
'checked_out_by' => $actorRef,
|
||||
]);
|
||||
|
||||
AuditLog::record(
|
||||
$visit->owner_ref,
|
||||
'visit.checked_out',
|
||||
$visit->organization_id,
|
||||
$actorRef,
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor' => $visit->visitor->full_name],
|
||||
);
|
||||
|
||||
$this->notifications->visitorCheckedOut($visit);
|
||||
$this->webhooks->dispatch('visit.checked_out', $visit->fresh(['visitor', 'host']));
|
||||
|
||||
return $visit->fresh(['visitor', 'host']);
|
||||
}
|
||||
|
||||
public function checkOutByQrToken(string $ownerRef, string $qrToken, ?string $actorRef = null): Visit
|
||||
{
|
||||
$visit = Visit::owned($ownerRef)
|
||||
->where('qr_token', $qrToken)
|
||||
->currentlyInside()
|
||||
->firstOrFail();
|
||||
|
||||
return $this->checkOut($visit, $actorRef);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
class VisitLifecycleService
|
||||
{
|
||||
public function __construct(
|
||||
protected VisitCheckInService $checkIns,
|
||||
protected NotificationDispatcher $notifications,
|
||||
) {}
|
||||
|
||||
public function checkInFromSchedule(Visit $visit, ?string $actorRef = null, bool $policiesAccepted = true): Visit
|
||||
{
|
||||
abort_unless($visit->canActivateCheckIn(), 422, 'This visit cannot be checked in.');
|
||||
|
||||
$visit->load('visitor');
|
||||
app(WatchlistService::class)->assertCanCheckIn($visit->visitor);
|
||||
|
||||
return $this->checkIns->activateCheckIn($visit, $actorRef, $policiesAccepted);
|
||||
}
|
||||
|
||||
public function markWaiting(Visit $visit, ?string $actorRef = null): Visit
|
||||
{
|
||||
abort_unless(in_array($visit->status, [
|
||||
Visit::STATUS_EXPECTED,
|
||||
Visit::STATUS_SCHEDULED,
|
||||
Visit::STATUS_OVERDUE,
|
||||
], true), 422, 'Visit is not awaiting arrival.');
|
||||
|
||||
$visit->update(['status' => Visit::STATUS_WAITING]);
|
||||
|
||||
AuditLog::record(
|
||||
$visit->owner_ref,
|
||||
'visit.waiting',
|
||||
$visit->organization_id,
|
||||
$actorRef,
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor' => $visit->visitor->full_name],
|
||||
);
|
||||
|
||||
$this->notifications->visitorWaiting($visit);
|
||||
|
||||
return $visit->fresh(['visitor', 'host']);
|
||||
}
|
||||
|
||||
public function cancel(Visit $visit, ?string $actorRef = null, ?string $reason = null): Visit
|
||||
{
|
||||
abort_if($visit->isInside(), 422, 'Checked-in visits must be checked out, not cancelled.');
|
||||
abort_if($visit->status === Visit::STATUS_CANCELLED, 422, 'Visit is already cancelled.');
|
||||
|
||||
$visit->update([
|
||||
'status' => Visit::STATUS_CANCELLED,
|
||||
'notes' => trim(($visit->notes ?? '').($reason ? "\nCancelled: {$reason}" : '')),
|
||||
]);
|
||||
|
||||
AuditLog::record(
|
||||
$visit->owner_ref,
|
||||
'visit.cancelled',
|
||||
$visit->organization_id,
|
||||
$actorRef,
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor' => $visit->visitor->full_name, 'reason' => $reason],
|
||||
);
|
||||
|
||||
$this->notifications->visitCancelled($visit);
|
||||
|
||||
return $visit->fresh(['visitor', 'host']);
|
||||
}
|
||||
|
||||
/** Mark expected/scheduled visits past their scheduled time as overdue. */
|
||||
public function markOverdueVisits(?Organization $organization = null): int
|
||||
{
|
||||
$query = Visit::query()
|
||||
->whereIn('status', [Visit::STATUS_EXPECTED, Visit::STATUS_SCHEDULED])
|
||||
->whereNotNull('scheduled_at')
|
||||
->where('scheduled_at', '<', now());
|
||||
|
||||
if ($organization) {
|
||||
$query->where('organization_id', $organization->id);
|
||||
}
|
||||
|
||||
return $query->update(['status' => Visit::STATUS_OVERDUE]);
|
||||
}
|
||||
|
||||
public function approve(Visit $visit, ?string $actorRef = null): Visit
|
||||
{
|
||||
abort_unless($visit->awaitingApproval(), 422, 'This visit is not awaiting approval.');
|
||||
|
||||
$visit->load('visitor');
|
||||
app(WatchlistService::class)->assertCanCheckIn($visit->visitor);
|
||||
|
||||
return $this->checkIns->activateCheckIn($visit, $actorRef, (bool) $visit->policies_accepted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class VisitScheduleService
|
||||
{
|
||||
public function __construct(
|
||||
protected VisitCheckInService $checkIns,
|
||||
protected WatchlistService $watchlist,
|
||||
protected NotificationDispatcher $notifications,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Pre-register or schedule a future visit without checking in.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function schedule(string $ownerRef, Organization $organization, array $data, ?string $actorRef = null): Visit
|
||||
{
|
||||
return DB::transaction(function () use ($ownerRef, $organization, $data, $actorRef) {
|
||||
$visitor = $this->checkIns->resolveVisitorForSchedule($ownerRef, $organization, $data);
|
||||
|
||||
$this->watchlist->assertCanCheckIn($visitor);
|
||||
|
||||
$scheduledAt = isset($data['scheduled_at'])
|
||||
? Carbon::parse($data['scheduled_at'])
|
||||
: now();
|
||||
|
||||
$status = $scheduledAt->startOfDay()->isAfter(now()->startOfDay())
|
||||
? Visit::STATUS_SCHEDULED
|
||||
: Visit::STATUS_EXPECTED;
|
||||
|
||||
$visit = Visit::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $organization->id,
|
||||
'branch_id' => $data['branch_id'] ?? null,
|
||||
'visitor_id' => $visitor->id,
|
||||
'host_id' => $data['host_id'] ?? null,
|
||||
'visitor_type' => $data['visitor_type'] ?? 'visitor',
|
||||
'status' => $status,
|
||||
'purpose' => $data['purpose'] ?? null,
|
||||
'expected_duration_minutes' => (int) ($data['expected_duration_minutes']
|
||||
?? config('frontdesk.kiosk.default_visit_duration_minutes', 60)),
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'external_ref' => $data['external_ref'] ?? null,
|
||||
'source' => $data['source'] ?? null,
|
||||
'integration_metadata' => $data['integration_metadata'] ?? null,
|
||||
]);
|
||||
|
||||
AuditLog::record(
|
||||
$ownerRef,
|
||||
'visit.scheduled',
|
||||
$organization->id,
|
||||
$actorRef,
|
||||
Visit::class,
|
||||
$visit->id,
|
||||
['visitor' => $visitor->full_name, 'scheduled_at' => $scheduledAt->toIso8601String()],
|
||||
);
|
||||
|
||||
if ($status === Visit::STATUS_EXPECTED) {
|
||||
$this->notifications->visitorExpected($visit);
|
||||
}
|
||||
|
||||
return $visit->load(['visitor', 'host']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Visitor;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class VisitorSearchService
|
||||
{
|
||||
/** @return Collection<int, Visitor> */
|
||||
public function search(string $ownerRef, int $organizationId, string $query, int $limit = 20): Collection
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
return Visitor::owned($ownerRef)
|
||||
->where('organization_id', $organizationId)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('full_name', 'like', "%{$query}%")
|
||||
->orWhere('company', 'like', "%{$query}%")
|
||||
->orWhere('phone', 'like', "%{$query}%")
|
||||
->orWhere('email', 'like', "%{$query}%");
|
||||
})
|
||||
->orderByDesc('is_frequent')
|
||||
->orderBy('full_name')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VisitorTypeService
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function configFor(string $type): array
|
||||
{
|
||||
return config("frontdesk.visitor_type_config.{$type}", config('frontdesk.visitor_type_config.visitor', []));
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function fieldsFor(string $type): array
|
||||
{
|
||||
return $this->configFor($type)['fields'] ?? [];
|
||||
}
|
||||
|
||||
public function requiresApproval(string $type, Organization $organization): bool
|
||||
{
|
||||
$settings = $organization->settings ?? [];
|
||||
$overrides = $settings['type_requires_approval'] ?? [];
|
||||
|
||||
if (array_key_exists($type, $overrides)) {
|
||||
return (bool) $overrides[$type];
|
||||
}
|
||||
|
||||
return (bool) ($this->configFor($type)['requires_approval'] ?? false);
|
||||
}
|
||||
|
||||
public function badgeExpiryHours(string $type, Organization $organization): int
|
||||
{
|
||||
$settings = $organization->settings ?? [];
|
||||
$overrides = $settings['type_badge_expiry_hours'] ?? [];
|
||||
|
||||
if (isset($overrides[$type])) {
|
||||
return (int) $overrides[$type];
|
||||
}
|
||||
|
||||
$typeHours = $this->configFor($type)['badge_expiry_hours'] ?? null;
|
||||
|
||||
if ($typeHours !== null) {
|
||||
return (int) $typeHours;
|
||||
}
|
||||
|
||||
return (int) data_get($settings, 'badge_expiry_hours', config('frontdesk.badge.default_expiry_hours', 8));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validationRules(string $type): array
|
||||
{
|
||||
$rules = ['type_fields' => ['nullable', 'array']];
|
||||
|
||||
foreach ($this->fieldsFor($type) as $field) {
|
||||
$key = 'type_fields.'.$field['name'];
|
||||
|
||||
if ($field['type'] === 'checkbox') {
|
||||
$rules[$key] = [($field['required'] ?? false) ? 'accepted' : 'nullable'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldRules = [($field['required'] ?? false) ? 'required' : 'nullable'];
|
||||
|
||||
$fieldRules[] = match ($field['type']) {
|
||||
'photo' => 'file',
|
||||
'signature' => 'string',
|
||||
'date' => 'date',
|
||||
'datetime-local' => 'date',
|
||||
'textarea' => 'string',
|
||||
default => 'string',
|
||||
};
|
||||
|
||||
if ($field['type'] === 'photo') {
|
||||
$fieldRules[] = 'image';
|
||||
$fieldRules[] = 'max:5120';
|
||||
}
|
||||
|
||||
if (in_array($field['type'], ['text', 'textarea'], true)) {
|
||||
$fieldRules[] = 'max:500';
|
||||
}
|
||||
|
||||
$rules[$key] = $fieldRules;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function enrichCheckInData(string $type, array $data, Organization $organization, ?Request $request = null): array
|
||||
{
|
||||
if ($request) {
|
||||
$typeFields = $data['type_fields'] ?? [];
|
||||
|
||||
foreach ($this->fieldsFor($type) as $field) {
|
||||
if ($field['type'] === 'photo' && $request->hasFile("type_fields.{$field['name']}")) {
|
||||
$typeFields[$field['name']] = $this->storeUpload(
|
||||
$request->file("type_fields.{$field['name']}"),
|
||||
'photos',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$data['type_fields'] = $typeFields;
|
||||
}
|
||||
|
||||
return $this->mergeTypeDetailsFromArray($type, $data, $organization);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function mergeTypeDetailsFromArray(string $type, array $data, Organization $organization): array
|
||||
{
|
||||
$detailsKey = $this->configFor($type)['details_key'] ?? null;
|
||||
|
||||
if ($detailsKey === null) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$details = [];
|
||||
$typeFields = $data['type_fields'] ?? [];
|
||||
|
||||
foreach ($this->fieldsFor($type) as $field) {
|
||||
$name = $field['name'];
|
||||
$value = $typeFields[$name] ?? null;
|
||||
|
||||
if ($field['type'] === 'signature' && is_string($value) && str_starts_with($value, 'data:image')) {
|
||||
$value = $this->storeBase64Image($value, 'signatures');
|
||||
}
|
||||
|
||||
if ($field['type'] === 'checkbox') {
|
||||
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN) || in_array($value, ['1', 'on', 1], true);
|
||||
}
|
||||
|
||||
if ($value !== null && $value !== '' && $value !== false) {
|
||||
$details[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'delivery') {
|
||||
$details['received_at'] = now()->toIso8601String();
|
||||
}
|
||||
|
||||
if ($this->requiresApproval($type, $organization)) {
|
||||
$details['_awaiting_approval'] = true;
|
||||
}
|
||||
|
||||
$data[$detailsKey] = $details;
|
||||
unset($data['type_fields']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
public function configsForFrontend(): array
|
||||
{
|
||||
$types = array_keys(config('frontdesk.visitor_types', []));
|
||||
|
||||
return collect($types)
|
||||
->mapWithKeys(fn (string $type) => [$type => [
|
||||
'label' => config("frontdesk.visitor_types.{$type}"),
|
||||
'fields' => $this->fieldsFor($type),
|
||||
'requires_approval' => (bool) ($this->configFor($type)['requires_approval'] ?? false),
|
||||
]])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function storeUpload(UploadedFile $file, string $folder): string
|
||||
{
|
||||
return $file->store("frontdesk/{$folder}", 'public');
|
||||
}
|
||||
|
||||
public function storeBase64Image(string $dataUrl, string $folder): string
|
||||
{
|
||||
if (! preg_match('/^data:image\/(\w+);base64,/', $dataUrl, $matches)) {
|
||||
throw new \InvalidArgumentException('Invalid image data.');
|
||||
}
|
||||
|
||||
$extension = $matches[1] === 'jpeg' ? 'jpg' : $matches[1];
|
||||
$contents = base64_decode(substr($dataUrl, strpos($dataUrl, ',') + 1));
|
||||
|
||||
$path = "frontdesk/{$folder}/".Str::uuid().".{$extension}";
|
||||
Storage::disk('public')->put($path, $contents);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Visitor;
|
||||
use App\Models\WatchlistEntry;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class WatchlistService
|
||||
{
|
||||
public function __construct(
|
||||
protected NotificationDispatcher $notifications,
|
||||
) {}
|
||||
|
||||
public function assertCanCheckIn(Visitor $visitor, ?Organization $organization = null, ?string $actorRef = null): void
|
||||
{
|
||||
if ($visitor->isBlacklisted()) {
|
||||
if ($organization) {
|
||||
$this->recordBlockedAttempt($visitor, $organization, $actorRef);
|
||||
}
|
||||
|
||||
throw new HttpException(403, 'This visitor is blacklisted and cannot check in.');
|
||||
}
|
||||
}
|
||||
|
||||
public function visitorNeedsApprovalQueue(Visitor $visitor): bool
|
||||
{
|
||||
return $visitor->requiresApproval();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
public function recordBlockedAttempt(
|
||||
Visitor $visitor,
|
||||
Organization $organization,
|
||||
?string $actorRef = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
AuditLog::record(
|
||||
$organization->owner_ref,
|
||||
'watchlist.blocked_attempt',
|
||||
$organization->id,
|
||||
$actorRef,
|
||||
Visitor::class,
|
||||
$visitor->id,
|
||||
array_merge([
|
||||
'visitor' => $visitor->full_name,
|
||||
'watchlist_status' => $visitor->watchlist_status,
|
||||
], $context),
|
||||
);
|
||||
|
||||
$this->notifications->watchlistBlockedAttempt($visitor, $organization);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
public function recordFlaggedCheckIn(
|
||||
Visitor $visitor,
|
||||
Organization $organization,
|
||||
?string $actorRef = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
AuditLog::record(
|
||||
$organization->owner_ref,
|
||||
'watchlist.flagged_checkin',
|
||||
$organization->id,
|
||||
$actorRef,
|
||||
Visitor::class,
|
||||
$visitor->id,
|
||||
array_merge([
|
||||
'visitor' => $visitor->full_name,
|
||||
'watchlist_status' => $visitor->watchlist_status,
|
||||
], $context),
|
||||
);
|
||||
|
||||
$this->notifications->watchlistFlaggedCheckIn($visitor, $organization);
|
||||
}
|
||||
|
||||
public function syncEntryForVisitor(Visitor $visitor, string $status, ?string $reason, ?string $actorRef): WatchlistEntry
|
||||
{
|
||||
$entry = WatchlistEntry::query()
|
||||
->where('owner_ref', $visitor->owner_ref)
|
||||
->where('organization_id', $visitor->organization_id)
|
||||
->where('visitor_id', $visitor->id)
|
||||
->first();
|
||||
|
||||
if ($status === Visitor::WATCHLIST_ALLOWED) {
|
||||
$entry?->delete();
|
||||
|
||||
return $entry ?? new WatchlistEntry;
|
||||
}
|
||||
|
||||
return WatchlistEntry::updateOrCreate(
|
||||
[
|
||||
'owner_ref' => $visitor->owner_ref,
|
||||
'organization_id' => $visitor->organization_id,
|
||||
'visitor_id' => $visitor->id,
|
||||
],
|
||||
[
|
||||
'full_name' => $visitor->full_name,
|
||||
'company' => $visitor->company,
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
'created_by' => $actorRef,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function applyApprovalMetadata(array $data, Visitor $visitor): array
|
||||
{
|
||||
if (! $this->visitorNeedsApprovalQueue($visitor)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$key = isset($data['delivery_details']) ? 'delivery_details' : 'contractor_details';
|
||||
$details = $data[$key] ?? [];
|
||||
$details['_awaiting_approval'] = true;
|
||||
$details['_watchlist_flagged'] = true;
|
||||
$data[$key] = $details;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integrations;
|
||||
|
||||
use App\Models\Visit;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WebhookDispatcher
|
||||
{
|
||||
public function dispatch(string $event, Visit $visit): void
|
||||
{
|
||||
$visit->loadMissing('organization');
|
||||
|
||||
$endpoints = WebhookEndpoint::query()
|
||||
->where('organization_id', $visit->organization_id)
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event));
|
||||
|
||||
if ($endpoints->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'event' => $event,
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'public_id' => $visit->public_id,
|
||||
'external_ref' => $visit->external_ref,
|
||||
'source' => $visit->source,
|
||||
'status' => $visit->status,
|
||||
'visitor_type' => $visit->visitor_type,
|
||||
'badge_code' => $visit->badge_code,
|
||||
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
|
||||
'checked_out_at' => $visit->checked_out_at?->toIso8601String(),
|
||||
'visitor_name' => $visit->visitor?->full_name,
|
||||
'host_name' => $visit->host?->name,
|
||||
],
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$this->send($endpoint, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
protected function send(WebhookEndpoint $endpoint, array $payload): void
|
||||
{
|
||||
$body = json_encode($payload);
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
|
||||
if ($endpoint->secret) {
|
||||
$headers['X-Frontdesk-Signature'] = hash_hmac('sha256', $body, $endpoint->secret);
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(10)->withHeaders($headers)->withBody($body, 'application/json')->post($endpoint->url);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Frontdesk webhook delivery failed', [
|
||||
'endpoint_id' => $endpoint->id,
|
||||
'url' => $endpoint->url,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Printers;
|
||||
|
||||
use App\Contracts\Printers\PrinterDriverInterface;
|
||||
use App\Models\Visit;
|
||||
|
||||
class BrotherPrinterDriver implements PrinterDriverInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'brother';
|
||||
}
|
||||
|
||||
public function renderBadge(Visit $visit): array
|
||||
{
|
||||
return (new PdfPrinterDriver)->renderBadge($visit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Printers;
|
||||
|
||||
use App\Contracts\Printers\PrinterDriverInterface;
|
||||
use App\Models\Visit;
|
||||
|
||||
class DymoPrinterDriver implements PrinterDriverInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'dymo';
|
||||
}
|
||||
|
||||
public function renderBadge(Visit $visit): array
|
||||
{
|
||||
return (new PdfPrinterDriver)->renderBadge($visit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Printers;
|
||||
|
||||
use App\Contracts\Printers\PrinterDriverInterface;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Frontdesk\BadgeRenderService;
|
||||
|
||||
class PdfPrinterDriver implements PrinterDriverInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
public function renderBadge(Visit $visit): array
|
||||
{
|
||||
$visit->load(['visitor', 'host', 'organization']);
|
||||
|
||||
$html = app(BadgeRenderService::class)->renderHtml($visit, true);
|
||||
|
||||
return [
|
||||
'format' => 'html',
|
||||
'content' => $html,
|
||||
'filename' => 'badge-'.$visit->badge_code.'.html',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Printers;
|
||||
|
||||
use App\Contracts\Printers\PrinterDriverInterface;
|
||||
use App\Models\Visit;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PrinterManager
|
||||
{
|
||||
/** @var array<string, PrinterDriverInterface> */
|
||||
protected array $drivers = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->register(new PdfPrinterDriver);
|
||||
$this->register(new ZebraPrinterDriver);
|
||||
$this->register(new BrotherPrinterDriver);
|
||||
$this->register(new DymoPrinterDriver);
|
||||
}
|
||||
|
||||
public function register(PrinterDriverInterface $driver): void
|
||||
{
|
||||
$this->drivers[$driver->name()] = $driver;
|
||||
}
|
||||
|
||||
public function driver(?string $name = null): PrinterDriverInterface
|
||||
{
|
||||
$name = $name ?? config('frontdesk.printers.default_driver', 'pdf');
|
||||
|
||||
if (! isset($this->drivers[$name])) {
|
||||
throw new InvalidArgumentException("Unknown printer driver: {$name}");
|
||||
}
|
||||
|
||||
return $this->drivers[$name];
|
||||
}
|
||||
|
||||
public function renderBadge(Visit $visit, ?string $driver = null): array
|
||||
{
|
||||
return $this->driver($driver)->renderBadge($visit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Printers;
|
||||
|
||||
use App\Contracts\Printers\PrinterDriverInterface;
|
||||
use App\Models\Visit;
|
||||
|
||||
class ZebraPrinterDriver implements PrinterDriverInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'zebra';
|
||||
}
|
||||
|
||||
public function renderBadge(Visit $visit): array
|
||||
{
|
||||
$visit->load(['visitor', 'host']);
|
||||
$name = strtoupper(substr($visit->visitor->full_name, 0, 24));
|
||||
$company = strtoupper(substr($visit->visitor->company ?? '', 0, 20));
|
||||
$host = strtoupper(substr($visit->host?->name ?? '', 0, 20));
|
||||
$code = $visit->badge_code;
|
||||
|
||||
$zpl = "^XA^FO50,50^A0N,40,40^FD{$name}^FS";
|
||||
$zpl .= "^FO50,100^A0N,25,25^FD{$company}^FS";
|
||||
$zpl .= "^FO50,140^A0N,25,25^FDHost: {$host}^FS";
|
||||
$zpl .= "^FO50,180^BQN,2,5^FDQA,{$code}^FS";
|
||||
$zpl .= "^XZ";
|
||||
|
||||
return [
|
||||
'format' => 'zpl',
|
||||
'content' => $zpl,
|
||||
'filename' => 'badge-'.$visit->badge_code.'.zpl',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class LadillAppUrl
|
||||
{
|
||||
public static function connect(string $appSubdomain, string $path = '/'): string
|
||||
{
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$path = '/'.ltrim($path, '/');
|
||||
$target = "https://{$appSubdomain}.{$root}{$path}";
|
||||
|
||||
return "https://{$appSubdomain}.{$root}/sso/connect?redirect=".urlencode($target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* Standard signed-in profile links for Ladill product apps (identical across
|
||||
* the suite). Points at the account/home portals, not app-local routes.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
class UserProfileMenu
|
||||
{
|
||||
public static function items(?Authenticatable $user = null): array
|
||||
{
|
||||
$user ??= auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
|
||||
foreach ([
|
||||
['label' => 'Home', 'path' => '', 'host' => 'home'],
|
||||
['label' => 'Profile', 'path' => 'profile'],
|
||||
['label' => 'Account Settings', 'path' => 'account-settings'],
|
||||
['label' => 'Dashboard', 'path' => 'dashboard'],
|
||||
['label' => 'Billing', 'path' => 'billing'],
|
||||
] as $link) {
|
||||
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
|
||||
|
||||
if (self::isCurrentMenuLink($link, $href)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'type' => 'link',
|
||||
'label' => $link['label'],
|
||||
'href' => $href,
|
||||
];
|
||||
}
|
||||
|
||||
// Wallet balance peek — sits directly after Billing (only when this
|
||||
// app exposes a balance endpoint).
|
||||
if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) {
|
||||
$items[] = ['type' => 'wallet'];
|
||||
}
|
||||
|
||||
if (self::userIsAdmin($user)) {
|
||||
$adminHref = self::platformUrl('account', 'admin');
|
||||
|
||||
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
|
||||
$items[] = [
|
||||
'type' => 'link',
|
||||
'label' => 'Admin',
|
||||
'href' => $adminHref,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (Route::has('logout')) {
|
||||
$items[] = [
|
||||
'type' => 'logout',
|
||||
'label' => 'Logout',
|
||||
'action' => route('logout'),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function platformUrl(string $host, string $path = ''): string
|
||||
{
|
||||
if ($host === 'home') {
|
||||
if (function_exists('ladill_home_url')) {
|
||||
return ladill_home_url($path);
|
||||
}
|
||||
|
||||
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
|
||||
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
|
||||
|
||||
return self::absoluteUrl($domain, $path);
|
||||
}
|
||||
|
||||
if (function_exists('ladill_account_url')) {
|
||||
return ladill_account_url($path);
|
||||
}
|
||||
|
||||
return self::absoluteUrl((string) config('app.account_domain'), $path);
|
||||
}
|
||||
|
||||
/** @param array{label?: string, path?: string, host?: string} $link */
|
||||
private static function isCurrentMenuLink(array $link, string $href): bool
|
||||
{
|
||||
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$request = request();
|
||||
if (! $request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
|
||||
$currentHost = strtolower($request->getHost());
|
||||
|
||||
if ($linkHost === '' || $linkHost !== $currentHost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentPath = trim($request->getPathInfo(), '/');
|
||||
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
|
||||
|
||||
if (($link['host'] ?? 'account') === 'home') {
|
||||
return $currentPath === '';
|
||||
}
|
||||
|
||||
if (($link['path'] ?? '') === 'dashboard') {
|
||||
return in_array($currentPath, ['dashboard', 'account'], true)
|
||||
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
|
||||
}
|
||||
|
||||
return self::pathMatchesSection($currentPath, $targetPath);
|
||||
}
|
||||
|
||||
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
|
||||
{
|
||||
if ($targetPath === '') {
|
||||
return $currentPath === '';
|
||||
}
|
||||
|
||||
return $currentPath === $targetPath
|
||||
|| str_starts_with($currentPath, $targetPath.'/');
|
||||
}
|
||||
|
||||
private static function absoluteUrl(string $domain, string $path = ''): string
|
||||
{
|
||||
$base = 'https://'.trim($domain, '/');
|
||||
|
||||
return $path === '' ? $base : $base.'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private static function userIsAdmin(Authenticatable $user): bool
|
||||
{
|
||||
if (method_exists($user, 'isAdmin')) {
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
return (bool) ($user->is_admin ?? false);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user