php-tenant/Models/Workspace.php

835 lines
22 KiB
PHP
Raw Normal View History

2026-01-26 21:08:59 +00:00
<?php
namespace Core\Mod\Tenant\Models;
2026-01-26 21:08:59 +00:00
use Core\Mod\Tenant\Services\EntitlementResult;
use Core\Mod\Tenant\Services\EntitlementService;
2026-01-26 21:08:59 +00:00
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Workspace extends Model
{
use HasFactory;
protected static function newFactory(): \Core\Mod\Tenant\Database\Factories\WorkspaceFactory
2026-01-26 21:08:59 +00:00
{
return \Core\Mod\Tenant\Database\Factories\WorkspaceFactory::new();
2026-01-26 21:08:59 +00:00
}
protected $fillable = [
'name',
'slug',
'domain',
'icon',
'color',
'description',
'type',
'settings',
'is_active',
'sort_order',
// WP Connector fields (secret excluded for security)
'wp_connector_enabled',
'wp_connector_url',
'wp_connector_verified_at',
'wp_connector_last_sync',
'wp_connector_config',
// Billing fields
'stripe_customer_id',
'btcpay_customer_id',
'billing_name',
'billing_email',
'billing_address_line1',
'billing_address_line2',
'billing_city',
'billing_state',
'billing_postal_code',
'billing_country',
'vat_number',
'tax_id',
'tax_exempt',
];
/**
* Guarded attributes (sensitive data that should not be mass-assigned).
*/
protected $guarded = [
'wp_connector_secret',
];
protected $casts = [
'settings' => 'array',
'is_active' => 'boolean',
'wp_connector_enabled' => 'boolean',
'wp_connector_verified_at' => 'datetime',
'wp_connector_last_sync' => 'datetime',
'wp_connector_config' => 'array',
'tax_exempt' => 'boolean',
];
/**
* Hidden attributes (sensitive data).
*/
protected $hidden = [
'wp_connector_secret',
];
/**
* Get the users that have access to this workspace.
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_workspace')
->withPivot(['role', 'is_default', 'team_id', 'custom_permissions', 'joined_at', 'invited_by'])
->withTimestamps();
}
/**
* Get workspace members (via the enhanced pivot model).
*/
public function members(): HasMany
{
return $this->hasMany(WorkspaceMember::class);
}
/**
* Get teams defined for this workspace.
*/
public function teams(): HasMany
{
return $this->hasMany(WorkspaceTeam::class);
}
/**
* Get the workspace owner (user with 'owner' role).
*/
public function owner(): ?User
{
return $this->users()
->wherePivot('role', 'owner')
->first();
}
/**
* Get the default team for new members.
*/
public function defaultTeam(): ?WorkspaceTeam
{
return $this->teams()->where('is_default', true)->first();
}
/**
* Active package assignments for this workspace.
*/
public function workspacePackages(): HasMany
{
return $this->hasMany(WorkspacePackage::class);
}
/**
* Get pending invitations for this workspace.
*/
public function invitations(): HasMany
{
return $this->hasMany(WorkspaceInvitation::class);
}
/**
* Get pending invitations only.
*/
public function pendingInvitations(): HasMany
{
return $this->invitations()->pending();
}
// ─────────────────────────────────────────────────────────────────────────
// Namespace Relationships
// ─────────────────────────────────────────────────────────────────────────
/**
* Get all namespaces owned by this workspace.
*/
public function namespaces(): MorphMany
{
return $this->morphMany(Namespace_::class, 'owner');
}
/**
* Get the workspace's default namespace.
*/
public function defaultNamespace(): ?Namespace_
{
return $this->namespaces()
->where('is_default', true)
->active()
->first()
?? $this->namespaces()->active()->ordered()->first();
}
/**
* The package definitions assigned to this workspace.
*/
public function packages(): BelongsToMany
{
return $this->belongsToMany(Package::class, 'entitlement_workspace_packages', 'workspace_id', 'package_id')
->withPivot(['status', 'starts_at', 'expires_at', 'metadata'])
->withTimestamps();
}
/**
* Get a setting from the settings JSON column.
*/
public function getSetting(string $key, mixed $default = null): mixed
{
return data_get($this->settings, $key, $default);
}
/**
* Active boosts for this workspace.
*/
public function boosts(): HasMany
{
return $this->hasMany(Boost::class);
}
/**
* Usage records for this workspace.
*/
public function usageRecords(): HasMany
{
return $this->hasMany(UsageRecord::class);
}
/**
* Entitlement logs for this workspace.
*/
public function entitlementLogs(): HasMany
{
return $this->hasMany(EntitlementLog::class);
}
/**
* Usage alert history for this workspace.
*/
public function usageAlerts(): HasMany
{
return $this->hasMany(UsageAlertHistory::class);
}
/**
* Get active (unresolved) usage alerts for this workspace.
*/
public function activeUsageAlerts(): HasMany
{
return $this->usageAlerts()->whereNull('resolved_at');
}
// SocialHost Relationships (Native)
/**
* Get social accounts for this workspace.
*/
public function socialAccounts(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Account::class);
}
/**
* Get social posts for this workspace.
*/
public function socialPosts(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Post::class);
}
/**
* Get social media templates for this workspace.
*/
public function socialTemplates(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Template::class);
}
/**
* Get social media files for this workspace.
*/
public function socialMedia(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Media::class);
}
/**
* Get social hashtag groups for this workspace.
*/
public function socialHashtagGroups(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\HashtagGroup::class);
}
/**
* Get social webhooks for this workspace.
*/
public function socialWebhooks(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Webhook::class);
}
/**
* Get social analytics for this workspace.
*/
public function socialAnalytics(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Analytics::class);
}
/**
* Get social variables for this workspace.
*/
public function socialVariables(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Variable::class);
}
/**
* Get posting schedule for this workspace.
*/
public function socialPostingSchedule(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\PostingSchedule::class);
}
/**
* Get imported posts for this workspace.
*/
public function socialImportedPosts(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\ImportedPost::class);
}
/**
* Get social metrics for this workspace.
*/
public function socialMetrics(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Metric::class);
}
/**
* Get audience data for this workspace.
*/
public function socialAudience(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\Audience::class);
}
/**
* Get Facebook insights for this workspace.
*/
public function socialFacebookInsights(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\FacebookInsight::class);
}
/**
* Get Instagram insights for this workspace.
*/
public function socialInstagramInsights(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\InstagramInsight::class);
}
/**
* Get Pinterest analytics for this workspace.
*/
public function socialPinterestAnalytics(): HasMany
{
return $this->hasMany(\Core\Mod\Social\Models\PinterestAnalytic::class);
}
/**
* Check if this workspace has SocialHost enabled (has connected social accounts).
*/
public function hasSocialHost(): bool
{
return $this->socialAccounts()->exists();
}
/**
* Get count of connected social accounts.
*/
public function socialAccountsCount(): int
{
return $this->socialAccounts()->count();
}
// NOTE: Bio service relationships (bioPages, bioProjects, bioDomains, bioPixels)
// have been moved to the Host UK app's Mod\Bio module.
// AnalyticsHost Relationships
/**
* Get analytics websites for this workspace (AnalyticsHost).
*/
public function analyticsSites(): HasMany
{
return $this->hasMany(\Core\Mod\Analytics\Models\Website::class);
}
/**
* Get social analytics websites for this workspace (legacy, for SocialHost analytics).
*/
public function socialAnalyticsWebsites(): HasMany
{
return $this->hasMany(\Core\Mod\Analytics\Models\AnalyticsWebsite::class);
}
/**
* Get analytics goals for this workspace (AnalyticsHost).
*/
public function analyticsGoals(): HasMany
{
return $this->hasMany(\Core\Mod\Analytics\Models\Goal::class);
}
/**
* Get social analytics goals for this workspace (legacy, for SocialHost analytics).
*/
public function socialAnalyticsGoals(): HasMany
{
return $this->hasMany(\Core\Mod\Analytics\Models\AnalyticsGoal::class);
}
// TrustHost Relationships
/**
* Get social proof campaigns (TrustHost widgets) for this workspace.
*/
public function trustWidgets(): HasMany
{
return $this->hasMany(\Core\Mod\Trust\Models\Campaign::class);
}
/**
* Get social proof notifications for this workspace.
*/
public function trustNotifications(): HasMany
{
return $this->hasMany(\Core\Mod\Trust\Models\Notification::class);
}
// NotifyHost Relationships
/**
* Get push notification websites for this workspace.
*/
public function notificationSites(): HasMany
{
return $this->hasMany(\Core\Mod\Notify\Models\PushWebsite::class);
}
/**
* Get push campaigns for this workspace.
*/
public function pushCampaigns(): HasMany
{
return $this->hasMany(\Core\Mod\Notify\Models\PushCampaign::class);
}
/**
* Get push flows for this workspace.
*/
public function pushFlows(): HasMany
{
return $this->hasMany(\Core\Mod\Notify\Models\PushFlow::class);
}
/**
* Get push segments for this workspace.
*/
public function pushSegments(): HasMany
{
return $this->hasMany(\Core\Mod\Notify\Models\PushSegment::class);
}
// API & Webhooks Relationships
/**
* Get API keys for this workspace.
*/
public function apiKeys(): HasMany
{
return $this->hasMany(\Core\Mod\Api\Models\ApiKey::class);
}
/**
* Get webhook endpoints for this workspace.
*/
public function webhookEndpoints(): HasMany
{
return $this->hasMany(\Core\Mod\Api\Models\WebhookEndpoint::class);
}
/**
* Get entitlement webhooks for this workspace.
*/
public function entitlementWebhooks(): HasMany
{
return $this->hasMany(EntitlementWebhook::class);
}
// Trees for Agents Relationships
/**
* Get tree plantings for this workspace.
*/
public function treePlantings(): HasMany
{
return $this->hasMany(\Core\Mod\Trees\Models\TreePlanting::class);
}
/**
* Get total trees planted for this workspace.
*/
public function treesPlanted(): int
{
return $this->treePlantings()
->whereIn('status', ['confirmed', 'planted'])
->sum('trees');
}
/**
* Get trees planted this year for this workspace.
*/
public function treesThisYear(): int
{
return $this->treePlantings()
->whereIn('status', ['confirmed', 'planted'])
->whereYear('created_at', now()->year)
->sum('trees');
}
// Content & Media Relationships
/**
* Get content items for this workspace.
*/
public function contentItems(): HasMany
{
return $this->hasMany(\Core\Mod\Content\Models\ContentItem::class);
}
/**
* Get content authors for this workspace.
*/
public function contentAuthors(): HasMany
{
return $this->hasMany(\Core\Mod\Content\Models\ContentAuthor::class);
}
// Commerce Relationships (defined in app Mod\Commerce)
/**
* Get subscriptions for this workspace.
*/
public function subscriptions(): HasMany
{
return $this->hasMany(\Mod\Commerce\Models\Subscription::class);
}
/**
* Get invoices for this workspace.
*/
public function invoices(): HasMany
{
return $this->hasMany(\Mod\Commerce\Models\Invoice::class);
}
/**
* Get payment methods for this workspace.
*/
public function paymentMethods(): HasMany
{
return $this->hasMany(\Mod\Commerce\Models\PaymentMethod::class);
}
/**
* Get orders for this workspace.
*/
public function orders(): MorphMany
{
return $this->morphMany(\Mod\Commerce\Models\Order::class, 'orderable');
}
// Helper Methods
/**
* Get the currently active workspace from request context.
*
* Returns the Workspace model instance (not array).
*/
public static function current(): ?self
{
// Try to get from request attributes (set by middleware)
if (request()->attributes->has('workspace_model')) {
return request()->attributes->get('workspace_model');
}
// Try to get from authenticated user's default workspace
if (auth()->check() && auth()->user() instanceof \Core\Mod\Tenant\Models\User) {
2026-01-26 21:08:59 +00:00
return auth()->user()->defaultHostWorkspace();
}
// Try to resolve from subdomain via WorkspaceService
$workspaceService = app(\App\Services\WorkspaceService::class);
$slug = $workspaceService->currentSlug();
return static::where('slug', $slug)->first();
}
/**
* Check if workspace can use a feature.
*/
public function can(string $featureCode, int $quantity = 1): EntitlementResult
{
return app(EntitlementService::class)->can($this, $featureCode, $quantity);
}
/**
* Record usage of a feature.
*/
public function recordUsage(string $featureCode, int $quantity = 1, ?User $user = null, ?array $metadata = null): UsageRecord
{
return app(EntitlementService::class)->recordUsage($this, $featureCode, $quantity, $user, $metadata);
}
/**
* Get usage summary for all features.
*/
public function getUsageSummary(): \Illuminate\Support\Collection
{
return app(EntitlementService::class)->getUsageSummary($this);
}
/**
* Check if workspace has a specific package.
*/
public function hasPackage(string $packageCode): bool
{
return $this->workspacePackages()
->whereHas('package', fn ($q) => $q->where('code', $packageCode))
->active()
->exists();
}
/**
* Check if workspace has Apollo tier.
*/
public function isApollo(): bool
{
return $this->can('tier.apollo')->isAllowed();
}
/**
* Check if workspace has Hades tier.
*/
public function isHades(): bool
{
return $this->can('tier.hades')->isAllowed();
}
// ─────────────────────────────────────────────────────────────────────────
// Workspace Invitations
// ─────────────────────────────────────────────────────────────────────────
/**
* Invite a user to this workspace by email.
*
* @param string $email The email address to invite
* @param string $role The role to assign (owner, admin, member)
* @param User|null $invitedBy The user sending the invitation
* @param int $expiresInDays Number of days until invitation expires
*/
public function invite(string $email, string $role = 'member', ?User $invitedBy = null, int $expiresInDays = 7): WorkspaceInvitation
{
// Check if there's already a pending invitation for this email
$existing = $this->invitations()
->where('email', $email)
->pending()
->first();
if ($existing) {
// Update existing invitation
$existing->update([
'role' => $role,
'invited_by' => $invitedBy?->id,
'expires_at' => now()->addDays($expiresInDays),
]);
return $existing;
}
// Create new invitation
$invitation = $this->invitations()->create([
'email' => $email,
'token' => WorkspaceInvitation::generateToken(),
'role' => $role,
'invited_by' => $invitedBy?->id,
'expires_at' => now()->addDays($expiresInDays),
]);
// Send notification
$invitation->notify(new \Core\Mod\Tenant\Notifications\WorkspaceInvitationNotification($invitation));
2026-01-26 21:08:59 +00:00
return $invitation;
}
/**
* Accept an invitation to this workspace using a token.
*
* @param string $token The invitation token
* @param User $user The user accepting the invitation
* @return bool True if accepted, false if invalid/expired
*/
public static function acceptInvitation(string $token, User $user): bool
{
$invitation = WorkspaceInvitation::findPendingByToken($token);
if (! $invitation) {
return false;
}
return $invitation->accept($user);
}
/**
* Get the external CMS URL for this workspace.
*/
public function getCmsUrlAttribute(): string
{
return 'https://'.$this->domain;
}
/**
* Scope to only active workspaces.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope to order by sort order.
*/
public function scopeOrdered($query)
{
return $query->orderBy('sort_order');
}
/**
* Convert to array format used by WorkspaceService.
*/
public function toServiceArray(): array
{
return [
'name' => $this->name,
'slug' => $this->slug,
'domain' => $this->domain,
'icon' => $this->icon,
'color' => $this->color,
'description' => $this->description,
];
}
/**
* Generate a new webhook secret for the WP connector.
*/
public function generateWpConnectorSecret(): string
{
$secret = bin2hex(random_bytes(32));
$this->update(['wp_connector_secret' => $secret]);
return $secret;
}
/**
* Enable the WP connector with a URL.
*/
public function enableWpConnector(string $url): self
{
$this->update([
'wp_connector_enabled' => true,
'wp_connector_url' => rtrim($url, '/'),
'wp_connector_secret' => $this->wp_connector_secret ?? bin2hex(random_bytes(32)),
]);
return $this;
}
/**
* Disable the WP connector.
*/
public function disableWpConnector(): self
{
$this->update([
'wp_connector_enabled' => false,
'wp_connector_verified_at' => null,
]);
return $this;
}
/**
* Mark the WP connector as verified.
*/
public function markWpConnectorVerified(): self
{
$this->update(['wp_connector_verified_at' => now()]);
return $this;
}
/**
* Update the last sync timestamp.
*/
public function touchWpConnectorSync(): self
{
$this->update(['wp_connector_last_sync' => now()]);
return $this;
}
/**
* Check if the WP connector is active and verified.
*/
public function hasActiveWpConnector(): bool
{
return $this->wp_connector_enabled
&& ! empty($this->wp_connector_url)
&& ! empty($this->wp_connector_secret);
}
/**
* Get the webhook URL that external CMS should POST to.
*/
public function getWpConnectorWebhookUrlAttribute(): string
{
return route('api.webhook.content').'?workspace='.$this->slug;
}
/**
* Validate an incoming webhook signature.
*/
public function validateWebhookSignature(string $payload, string $signature): bool
{
if (empty($this->wp_connector_secret)) {
return false;
}
$expected = hash_hmac('sha256', $payload, $this->wp_connector_secret);
return hash_equals($expected, $signature);
}
}