Initial commit: CABM-IT (ItGlue-style MSP documentation platform)

Laravel 13 + Livewire/Volt app for managing client organizations,
documents, passwords, flexible assets, virtual machines, contacts,
domains, todos, shortcuts, and role-based user administration.
This commit is contained in:
jeremy bayse
2026-08-16 13:28:12 +02:00
commit 8f22dca9c8
209 changed files with 23324 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'location_id', 'first_name', 'last_name',
'title', 'email', 'phone', 'mobile', 'is_important', 'notes',
])]
class Contact extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'is_important' => 'boolean',
];
}
protected function fullName(): Attribute
{
return Attribute::get(fn () => trim("{$this->first_name} {$this->last_name}"));
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function location()
{
return $this->belongsTo(Location::class);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\CommonMarkConverter;
#[Fillable(['organization_id', 'folder_id', 'name', 'type', 'is_folder', 'content', 'file_path', 'office_extension', 'office_key', 'current_version', 'created_by'])]
class Document extends Model implements Linkable
{
use SoftDeletes;
protected function casts(): array
{
return [
'is_folder' => 'boolean',
];
}
public function isOffice(): bool
{
return $this->type === 'office';
}
public function isMarkdown(): bool
{
return $this->type === 'markdown';
}
public function isUpload(): bool
{
return $this->type === 'upload';
}
public function renderedMarkdown(): string
{
return (new CommonMarkConverter())->convert($this->content ?? '')->getContent();
}
public function isFolder(): bool
{
return $this->is_folder || $this->children()->exists();
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function folder()
{
return $this->belongsTo(Document::class, 'folder_id');
}
public function children()
{
return $this->hasMany(Document::class, 'folder_id');
}
public function versions()
{
return $this->hasMany(DocumentVersion::class)->orderByDesc('version');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function relatedItems()
{
return RelatedItem::forItem($this);
}
public function linkableKind(): string
{
return 'Document';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.documents.edit', [$this->organization_id, $this]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DocumentVersion extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function document()
{
return $this->belongsTo(Document::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['organization_id', 'name', 'registrar', 'nameservers', 'expires_at', 'auto_renew', 'last_checked_at', 'notes'])]
class Domain extends Model
{
protected function casts(): array
{
return [
'expires_at' => 'date',
'auto_renew' => 'boolean',
'last_checked_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function sslCertificates()
{
return $this->hasMany(SslCertificate::class);
}
public function tld(): ?string
{
$parts = explode('.', $this->name);
return count($parts) > 1 ? '.'.end($parts) : null;
}
public function expiresInDays(): ?int
{
return $this->expires_at ? now()->startOfDay()->diffInDays($this->expires_at, false) : null;
}
public function expiryStatus(): string
{
$days = $this->expiresInDays();
return match (true) {
$days === null => 'unknown',
$days < 0 => 'expired',
$days <= 30 => 'soon',
default => 'ok',
};
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable(['flexible_asset_type_id', 'organization_id', 'name', 'data', 'is_archived', 'created_by'])]
class FlexibleAsset extends Model implements Linkable
{
use SoftDeletes;
protected function casts(): array
{
return [
'data' => 'array',
'is_archived' => 'boolean',
];
}
public function type()
{
return $this->belongsTo(FlexibleAssetType::class, 'flexible_asset_type_id');
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function relatedItems()
{
return RelatedItem::forItem($this);
}
public function logs()
{
return $this->hasMany(FlexibleAssetLog::class)->orderByDesc('created_at');
}
public function linkableKind(): string
{
return $this->type?->name ?? 'Flexible Asset';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.assets.edit', [$this->organization_id, $this->flexible_asset_type_id, $this]);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
#[Fillable([
'flexible_asset_type_id', 'name', 'label', 'field_type',
'options', 'is_required', 'show_in_list', 'sort_order',
])]
class FlexibleAssetField extends Model
{
const FIELD_TYPES = ['text', 'textarea', 'password', 'checkbox', 'date', 'tag', 'select', 'related_asset'];
/**
* Built-in record lists a "related_asset" field can point to.
* Keys are stored in options.target; values map to an Organization relation
* name and the attribute used as the display label.
*/
const RELATED_TARGETS = [
'contact' => ['relation' => 'contacts', 'label' => 'full_name'],
'location' => ['relation' => 'locations', 'label' => 'name'],
'domain' => ['relation' => 'domains', 'label' => 'name'],
];
protected function casts(): array
{
return [
'options' => 'array',
'is_required' => 'boolean',
'show_in_list' => 'boolean',
];
}
public function type()
{
return $this->belongsTo(FlexibleAssetType::class, 'flexible_asset_type_id');
}
public function relatedTarget(): ?string
{
return $this->options['target'] ?? null;
}
/**
* Records available for this field's related_asset target, scoped to the organization.
* @return Collection<int, array{id: int, label: string}>
*/
public function relatedOptions(Organization $organization): Collection
{
$target = $this->relatedTarget();
if (! $target) {
return collect();
}
if (str_starts_with($target, 'flexible_asset:')) {
$typeId = (int) substr($target, strlen('flexible_asset:'));
return $organization->flexibleAssets()
->where('flexible_asset_type_id', $typeId)
->orderBy('name')
->get()
->map(fn (FlexibleAsset $asset) => ['id' => $asset->id, 'label' => $asset->name]);
}
$config = self::RELATED_TARGETS[$target] ?? null;
if (! $config) {
return collect();
}
return $organization->{$config['relation']}()
->orderBy($config['label'] === 'full_name' ? 'last_name' : $config['label'])
->get()
->map(fn (Model $record) => ['id' => $record->id, 'label' => (string) $record->{$config['label']}]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class FlexibleAssetLog extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function flexibleAsset()
{
return $this->belongsTo(FlexibleAsset::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'icon', 'description', 'enabled_for_all_organizations'])]
class FlexibleAssetType extends Model
{
protected function casts(): array
{
return [
'enabled_for_all_organizations' => 'boolean',
];
}
public function fields()
{
return $this->hasMany(FlexibleAssetField::class)->orderBy('sort_order');
}
public function assets()
{
return $this->hasMany(FlexibleAsset::class);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'organization_id', 'name', 'address_line_1', 'address_line_2',
'city', 'region', 'postal_code', 'country', 'phone', 'is_primary',
])]
class Location extends Model
{
protected function casts(): array
{
return [
'is_primary' => 'boolean',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function contacts()
{
return $this->hasMany(Contact::class);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path'])]
class Organization extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'allowed_ip_ranges' => 'array',
];
}
protected function logoUrl(): Attribute
{
return Attribute::get(
fn () => $this->logo_path ? Storage::disk('public')->url($this->logo_path) : asset('images/default-organization-logo.svg')
);
}
public function users()
{
return $this->belongsToMany(User::class);
}
public function locations()
{
return $this->hasMany(Location::class);
}
public function contacts()
{
return $this->hasMany(Contact::class);
}
public function passwords()
{
return $this->hasMany(Password::class);
}
public function flexibleAssets()
{
return $this->hasMany(FlexibleAsset::class);
}
public function documents()
{
return $this->hasMany(Document::class);
}
public function domains()
{
return $this->hasMany(Domain::class);
}
public function sslCertificates()
{
return $this->hasMany(SslCertificate::class);
}
public function virtualMachines()
{
return $this->hasMany(VirtualMachine::class);
}
public function logEntries()
{
return $this->hasMany(OrganizationLogEntry::class)->orderByDesc('created_at');
}
public function todos()
{
return $this->hasMany(Todo::class);
}
public function shortcuts()
{
return $this->hasMany(Shortcut::class);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OrganizationLogEntry extends Model
{
const UPDATED_AT = null;
const CATEGORIES = [
'project' => 'Projet en cours',
'attention' => "Point d'attention",
'intervention' => 'Intervention prévue',
'info' => 'Information générale',
];
protected $guarded = [];
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function categoryLabel(): string
{
return self::CATEGORIES[$this->category] ?? $this->category;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Models;
use App\Casts\EncryptedField;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'resource_type', 'resource_id', 'name', 'username',
'password_ciphertext', 'url', 'notes', 'otp_secret', 'is_favorite',
'password_updated_at', 'rotation_due_at', 'created_by',
])]
class Password extends Model implements Linkable
{
use SoftDeletes;
protected function casts(): array
{
return [
'password_ciphertext' => EncryptedField::class,
'otp_secret' => EncryptedField::class,
'is_favorite' => 'boolean',
'password_updated_at' => 'datetime',
'rotation_due_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function resource()
{
return $this->morphTo();
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function auditLogs()
{
return $this->hasMany(PasswordAuditLog::class);
}
public function logAction(string $action): void
{
$this->auditLogs()->create([
'user_id' => auth()->id(),
'action' => $action,
'ip_address' => request()->ip(),
'user_agent' => (string) request()->userAgent(),
]);
}
public function relatedItems()
{
return RelatedItem::forItem($this);
}
public function linkableKind(): string
{
return 'Password';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.passwords.edit', [$this->organization_id, $this]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PasswordAuditLog extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function password()
{
return $this->belongsTo(Password::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class RelatedItem extends Model
{
protected $guarded = [];
public function item(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'item_type', 'item_id');
}
public function relatedItem(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'related_item_type', 'related_item_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* All edges touching a given model, either as owner or as target (symmetric view).
*/
public static function forItem(Model $model)
{
return static::where(function ($query) use ($model) {
$query->where('item_type', $model->getMorphClass())->where('item_id', $model->getKey());
})->orWhere(function ($query) use ($model) {
$query->where('related_item_type', $model->getMorphClass())->where('related_item_id', $model->getKey());
});
}
/**
* The model on the "other side" of this edge, relative to the given model.
*/
public function otherItem(Model $relativeTo): ?Model
{
$isOwner = $this->item_type === $relativeTo->getMorphClass() && (string) $this->item_id === (string) $relativeTo->getKey();
return $isOwner ? $this->relatedItem : $this->item;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['organization_id', 'label', 'url', 'icon', 'created_by'])]
class Shortcut extends Model
{
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function isGlobal(): bool
{
return $this->organization_id === null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['organization_id', 'domain_id', 'name', 'issuer', 'issued_at', 'expires_at', 'last_checked_at'])]
class SslCertificate extends Model
{
protected function casts(): array
{
return [
'issued_at' => 'date',
'expires_at' => 'date',
'last_checked_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function domain()
{
return $this->belongsTo(Domain::class);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'title', 'description', 'category', 'criticality',
'due_date', 'assigned_to', 'is_done', 'completed_at', 'created_by',
])]
class Todo extends Model implements Linkable
{
use SoftDeletes;
const CATEGORIES = [
'infra' => 'Infra',
'support' => 'Support',
'metier' => 'Métier',
'autre' => 'Autre',
];
const CRITICALITIES = [
'low' => 'Basse',
'medium' => 'Moyenne',
'high' => 'Haute',
'urgent' => 'Urgente',
];
protected function casts(): array
{
return [
'due_date' => 'date',
'is_done' => 'boolean',
'completed_at' => 'datetime',
];
}
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function assignee()
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function categoryLabel(): string
{
return self::CATEGORIES[$this->category] ?? $this->category;
}
public function criticalityLabel(): string
{
return self::CRITICALITIES[$this->criticality] ?? $this->criticality;
}
public function isOverdue(): bool
{
return ! $this->is_done && $this->due_date && $this->due_date->isPast();
}
public function linkableKind(): string
{
return 'Todo';
}
public function linkableLabel(): string
{
return $this->title;
}
public function linkableUrl(): string
{
return route('organizations.todos.edit', [$this->organization_id, $this]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password', 'is_msp_staff'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasApiTokens, HasRoles, TwoFactorAuthenticatable;
public function organizations()
{
return $this->belongsToMany(Organization::class);
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_msp_staff' => 'boolean',
'last_login_at' => 'datetime',
];
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Contracts\Linkable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'organization_id', 'name', 'role', 'memory_gb', 'cpu_count',
'operating_system', 'notes', 'created_by',
])]
class VirtualMachine extends Model implements Linkable
{
use SoftDeletes;
public function organization()
{
return $this->belongsTo(Organization::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function logs()
{
return $this->hasMany(VirtualMachineLog::class)->orderByDesc('created_at');
}
public function linkableKind(): string
{
return 'VirtualMachine';
}
public function linkableLabel(): string
{
return $this->name;
}
public function linkableUrl(): string
{
return route('organizations.virtual-machines.edit', [$this->organization_id, $this]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VirtualMachineLog extends Model
{
const UPDATED_AT = null;
protected $guarded = [];
public function virtualMachine()
{
return $this->belongsTo(VirtualMachine::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}