Adds basic address book handling; updates address book naming to use Book instead everywhere; removes AlpineJS; updates seeder to include address book and contact; updates DavController to handle address books and contacts now

This commit is contained in:
Andrew Gioia 2025-07-22 12:59:31 -04:00
parent cf8ee297d8
commit 5970991eac
Signed by: andrew
GPG Key ID: FC09694A000800C8
23 changed files with 426 additions and 54 deletions

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use App\Models\BookMeta;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class BookController extends Controller
{
public function index()
{
$principal = 'principals/' . Auth::user()->email;
$books = Book::query()
->join('addressbook_meta as meta', 'meta.addressbook_id', '=', 'addressbooks.id')
->where('principaluri', $principal)
->select('addressbooks.*', 'meta.color', 'meta.is_default')
->get();
return view('books.index', compact('books'));
}
public function create()
{
return view('books.create');
}
public function store(Request $request)
{
$data = $request->validate([
'displayname' => 'required|string|max:100',
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
]);
$principal = 'principals/' . Auth::user()->email;
$id = Book::insertGetId([
'principaluri' => $principal,
'uri' => (string) Str::uuid(),
'displayname' => $data['displayname'],
]);
BookMeta::create([
'book_id' => $id,
'color' => $data['color'] ?? '#cccccc',
'is_default' => false,
]);
return redirect()->route('books.index');
}
public function show(Book $book)
{
$this->authorize('view', $book);
$book->load('meta', 'cards');
return view('books.show', compact('book'));
}
public function edit(Book $book)
{
$book->load('meta');
return view('books.edit', compact('book'));
}
public function update(Request $request, Book $book)
{
$data = $request->validate([
'displayname' => 'required|string|max:100',
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
]);
$book->update([
'displayname' => $data['displayname'],
]);
$book->meta()->updateOrCreate([], [
'color' => $data['color'] ?? '#cccccc',
]);
return redirect()->route('books.index')->with('toast', 'Address Book updated.');
}
}

View File

@ -5,8 +5,10 @@ namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use App\Services\Dav\LaravelSabreAuthBackend;
use App\Services\Dav\LaravelSabrePrincipalBackend;
use Sabre\DAV;
use Sabre\DAV\Auth\Plugin as AuthPlugin;
use Sabre\DAVACL\Plugin as ACLPlugin;
@ -15,6 +17,8 @@ use Sabre\CalDAV\CalendarRoot;
use Sabre\CalDAV\Plugin as CalDavPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVPDO;
use Sabre\CardDAV\AddressBookRoot;
use Sabre\CardDAV\Plugin as CardDavPlugin;
use Sabre\CardDAV\Backend\PDO as CardDAVPDO;
class DavController extends Controller
{
@ -30,11 +34,12 @@ class DavController extends Controller
$authBackend = new LaravelSabreAuthBackend();
$principalBackend = new LaravelSabrePrincipalBackend();
$calendarBackend = new CalDAVPDO($pdo);
$cardBackend = new CardDAVPDO($pdo);
$nodes = [
new PrincipalCollection($principalBackend),
new CalendarRoot($principalBackend, $calendarBackend)
// Add your Calendars or Addressbooks here
new CalendarRoot($principalBackend, $calendarBackend),
new AddressBookRoot($principalBackend, $cardBackend)
];
$server = new DAV\Server($nodes);
@ -43,6 +48,7 @@ class DavController extends Controller
$server->addPlugin(new AuthPlugin($authBackend, 'Kithkin DAV'));
$server->addPlugin(new ACLPlugin());
$server->addPlugin(new CalDavPlugin());
$server->addPlugin(new CardDavPlugin());
$server->on('beforeMethod', function () {
\Log::info('SabreDAV beforeMethod triggered');

View File

@ -6,18 +6,18 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class AddressBook extends Model
class Book extends Model
{
protected $table = 'addressbooks'; // Sabre table
public $timestamps = false;
public function contacts(): HasMany
public function cards(): HasMany
{
return $this->hasMany(Contact::class, 'addressbookid');
return $this->hasMany(Card::class, 'addressbookid');
}
public function meta(): HasOne
{
return $this->hasOne(AddressbookMeta::class, 'addressbook_id');
return $this->hasOne(BookMeta::class, 'addressbook_id');
}
}

View File

@ -5,7 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AddressBookMeta extends Model
class BookMeta extends Model
{
protected $table = 'addressbook_meta';
protected $primaryKey = 'addressbook_id';
@ -24,6 +24,6 @@ class AddressBookMeta extends Model
public function addressBook(): BelongsTo
{
return $this->belongsTo(AddressBook::class, 'addressbook_id');
return $this->belongsTo(Book::class, 'addressbook_id');
}
}

View File

@ -6,19 +6,28 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Contact extends Model
class Card extends Model
{
protected $table = 'cards'; // Sabre table
public $timestamps = false;
protected $primaryKey = 'id';
public function addressBook(): BelongsTo
protected $fillable = [
'addressbookid',
'uri',
'lastmodified',
'etag',
'size',
'carddata',
];
public function book()
{
return $this->belongsTo(AddressBook::class, 'addressbookid');
return $this->belongsTo(Book::class, 'addressbookid');
}
public function meta(): HasOne
{
return $this->hasOne(ContactMeta::class, 'card_id');
return $this->hasOne(CardMeta::class, 'card_id');
}
}

View File

@ -19,7 +19,7 @@ class ContactMeta extends Model
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class, 'card_id');
return $this->belongsTo(Card::class, 'card_id');
}
/* convenience: return tags as array */

View File

@ -0,0 +1,30 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Book;
class BookPolicy
{
public function view(User $user, Book $book): bool
{
return $book->principaluri === 'principals/' . $user->email;
}
public function update(User $user, Book $book): bool
{
return $book->principaluri === 'principals/' . $user->email;
}
public function delete(User $user, Book $book): bool
{
return $book->principaluri === 'principals/' . $user->email;
}
public function share(User $user, Book $book): bool
{
// Placeholder for future sharing logic
return $book->principaluri === 'principals/' . $user->email;
}
}

View File

@ -9,6 +9,7 @@
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"omnia-digital/livewire-calendar": "^3.2",
"sabre/dav": "^4.7"
},
"require-dev": {

151
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5c25965fd8b365e18fdf5a50055d481f",
"content-hash": "7376fcf2b57a1242a21836781b34e006",
"packages": [
{
"name": "brick/math",
@ -2006,6 +2006,82 @@
],
"time": "2024-12-08T08:18:47+00:00"
},
{
"name": "livewire/livewire",
"version": "v3.6.4",
"source": {
"type": "git",
"url": "https://github.com/livewire/livewire.git",
"reference": "ef04be759da41b14d2d129e670533180a44987dc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/ef04be759da41b14d2d129e670533180a44987dc",
"reference": "ef04be759da41b14d2d129e670533180a44987dc",
"shasum": ""
},
"require": {
"illuminate/database": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/validation": "^10.0|^11.0|^12.0",
"laravel/prompts": "^0.1.24|^0.2|^0.3",
"league/mime-type-detection": "^1.9",
"php": "^8.1",
"symfony/console": "^6.0|^7.0",
"symfony/http-kernel": "^6.2|^7.0"
},
"require-dev": {
"calebporzio/sushi": "^2.1",
"laravel/framework": "^10.15.0|^11.0|^12.0",
"mockery/mockery": "^1.3.1",
"orchestra/testbench": "^8.21.0|^9.0|^10.0",
"orchestra/testbench-dusk": "^8.24|^9.1|^10.0",
"phpunit/phpunit": "^10.4|^11.5",
"psy/psysh": "^0.11.22|^0.12"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Livewire": "Livewire\\Livewire"
},
"providers": [
"Livewire\\LivewireServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Livewire\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "A front-end framework for Laravel.",
"support": {
"issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v3.6.4"
},
"funding": [
{
"url": "https://github.com/livewire",
"type": "github"
}
],
"time": "2025-07-17T05:12:15+00:00"
},
{
"name": "monolog/monolog",
"version": "3.9.0",
@ -2507,6 +2583,79 @@
],
"time": "2025-05-08T08:14:37+00:00"
},
{
"name": "omnia-digital/livewire-calendar",
"version": "3.2.0",
"source": {
"type": "git",
"url": "https://github.com/omnia-digital/livewire-calendar.git",
"reference": "9488ebaa84bf96f09c25dfbc2538d394aec365a7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/omnia-digital/livewire-calendar/zipball/9488ebaa84bf96f09c25dfbc2538d394aec365a7",
"reference": "9488ebaa84bf96f09c25dfbc2538d394aec365a7",
"shasum": ""
},
"require": {
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"livewire/livewire": "^2.0||^3.0",
"php": "^7.2|^8.0|^8.1|^8.2"
},
"require-dev": {
"orchestra/testbench": "^5.0|^6.0",
"phpunit/phpunit": "^8.0|^9.0|^10.0|^11.0|^12.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"LivewireCalendar": "Omnia\\LivewireCalendar\\LivewireCalendarFacade"
},
"providers": [
"Omnia\\LivewireCalendar\\LivewireCalendarServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Omnia\\LivewireCalendar\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Josh Torres",
"email": "josht@omniadigital.io",
"role": "Developer"
},
{
"name": "Andrés Santibáñez",
"email": "santibanez.andres@gmail.com",
"role": "Developer"
},
{
"name": "Osei Quashie",
"email": "osei@omniadigital.io",
"role": "Developer"
}
],
"description": "Laravel Livewire calendar component",
"homepage": "https://github.com/omnia-digital/livewire-calendar",
"keywords": [
"livewire-calendar",
"omnia",
"omnia-digital"
],
"support": {
"issues": "https://github.com/omnia-digital/livewire-calendar/issues",
"source": "https://github.com/omnia-digital/livewire-calendar/tree/3.2.0"
},
"time": "2025-02-28T18:18:23+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.3",

View File

@ -101,5 +101,50 @@ ICS;
'updated_at' => now(),
]
);
/** create cards */
$bookId = DB::table('addressbooks')->insertGetId([
'principaluri' => $user->uri,
'uri' => 'default',
'displayname' => 'Default Address Book',
]);
$vcard = <<<VCF
BEGIN:VCARD
VERSION:3.0
FN:Seeded Contact
EMAIL:seeded@example.com
TEL:+1-555-123-4567
UID:seeded-contact-001
END:VCARD
VCF;
DB::table('addressbook_meta')->insert([
'addressbook_id' => $bookId,
'color' => '#ff40ff',
'is_default' => true,
'settings' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$cardId = DB::table('cards')->insertGetId([
'addressbookid' => $bookId,
'uri' => Str::uuid().'.vcf',
'lastmodified' => now()->timestamp,
'etag' => md5($vcard),
'size' => strlen($vcard),
'carddata' => $vcard,
]);
DB::table('contact_meta')->insert([
'card_id' => $cardId,
'avatar_url' => null,
'tags' => 'demo,seed',
'notes' => 'Seeded contact from DatabaseSeeder.',
'created_at' => now(),
'updated_at' => now(),
]);
}
}

28
package-lock.json generated
View File

@ -7,7 +7,6 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"axios": "^1.8.2",
"concurrently": "^9.0.1",
@ -1196,33 +1195,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz",
"integrity": "sha512-gqSOhTEyryU9FhviNqiHBHzgjkvtukq9tevew29fTj+ofZtfsYriw4zPirHHOAy9bw8QoL3WGhyk7QqCh5AYlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",

View File

@ -9,7 +9,6 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"axios": "^1.8.2",
"concurrently": "^9.0.1",

View File

@ -1,7 +1 @@
import './bootstrap';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();

View File

@ -0,0 +1,4 @@
<x-layout>
<h1>Create Address Book</h1>
@include('addressbooks.form', ['action' => route('addressbooks.store'), 'isEdit' => false])
</x-layout>

View File

@ -0,0 +1,4 @@
<x-layout>
<h1>Edit Address Book</h1>
@include('addressbooks.form', ['action' => route('addressbooks.update', $addressbook), 'isEdit' => true])
</x-layout>

View File

@ -0,0 +1,12 @@
<form method="POST" action="{{ $action }}">
@csrf
@if($isEdit) @method('PUT') @endif
<label>Display Name:</label>
<input name="displayname" value="{{ old('displayname', $addressbook->displayname ?? '') }}" required>
<label>Color:</label>
<input name="color" type="color" value="{{ old('color', $addressbook->meta->color ?? '#cccccc') }}">
<button type="submit">{{ $isEdit ? 'Update' : 'Create' }}</button>
</form>

View File

@ -0,0 +1,28 @@
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight">
{{ __('My Address Books') }}
</h2>
</x-slot>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- Books list --}}
<div class="bg-white shadow sm:rounded-lg">
<ul class="divide-y divide-gray-200">
@forelse($books as $book)
<li class="px-6 py-4 flex items-center justify-between">
<a href="{{ route('books.show', $book) }}" class="font-medium text-indigo-600">
{{ $book->displayname }}
</a>
</li>
@empty
<li class="px-6 py-4">{{ __('No address books yet.') }}</li>
@endforelse
</ul>
</div>
</div>
</div>
</x-app-layout>

View File

@ -0,0 +1,15 @@
<x-app-layout>
<h1>{{ $book->displayname }}</h1>
<p>Color: <span style="color: {{ $book->meta->color }}">{{ $book->meta->color }}</span></p>
<h2>Contacts</h2>
<ul>
@foreach ($book->cards as $card)
<li>{{ $card->displayname ?? 'Unnamed contact' }}</li>
@endforeach
</ul>
<a href="{{ route('books.edit', $book) }}">Edit Book</a>
<a href="{{ route('books.index') }}">Back to all</a>
</x-app-layout>

View File

@ -7,10 +7,6 @@
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

View File

@ -21,6 +21,11 @@
{{ __('Calendars') }}
</x-nav-link>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('books.index')" :active="request()->routeIs('books*')">
{{ __('Address Books') }}
</x-nav-link>
</div>
</div>
<!-- Settings Dropdown -->

View File

@ -2,9 +2,11 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\BookController;
use App\Http\Controllers\CalendarController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\CardController;
use App\Http\Controllers\DavController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\SubscriptionController;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@ -44,6 +46,19 @@ Route::middleware('auth')->group(function () {
Route::get ('events/{event}/edit', [EventController::class, 'edit' ])->name('events.edit');
Route::put ('events/{event}', [EventController::class, 'update'])->name('events.update');
});
/** address books */
Route::resource('books', BookController::class)
->names('books') // books.index, books.create, …
->parameter('books', 'book'); // {book} binding
/** contacts inside a book
nested so urls look like /books/{book}/contacts/{contact} */
Route::resource('books.contacts', ContactController::class)
->names('books.contacts')
->parameter('contacts', 'contact')
->except(['index']) // you may add an index later
->shallow();
});
/* Breeze auth routes (login, register, password reset, etc.) */