diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/app/Http/Controllers/Api/GetPaidMembers.php b/app/Http/Controllers/Api/GetPaidMembers.php new file mode 100644 index 0000000..11c2880 --- /dev/null +++ b/app/Http/Controllers/Api/GetPaidMembers.php @@ -0,0 +1,23 @@ +whereHas('paymentEvents', function ($query) use ($year) { + $query->where('year', $year) + ->where('paid', true); + }) + ->select('id', 'npub', 'pubkey', 'nip05_handle') + ->get(); + + return response()->json($paidMembers); + } +} diff --git a/app/Models/PaymentEvent.php b/app/Models/PaymentEvent.php index 700fd71..ea62194 100644 --- a/app/Models/PaymentEvent.php +++ b/app/Models/PaymentEvent.php @@ -2,9 +2,17 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class PaymentEvent extends Model { + use HasFactory; + protected $guarded = []; + + public function pleb() + { + return $this->belongsTo(EinundzwanzigPleb::class, 'einundzwanzig_pleb_id'); + } } diff --git a/app/Traits/NostrFetcherTrait.php b/app/Traits/NostrFetcherTrait.php index 49a9c84..78769fd 100644 --- a/app/Traits/NostrFetcherTrait.php +++ b/app/Traits/NostrFetcherTrait.php @@ -30,7 +30,6 @@ trait NostrFetcherTrait 'npub' => $item, ]); } - $subscription = new Subscription; $subscriptionId = $subscription->setId(); @@ -41,13 +40,14 @@ trait NostrFetcherTrait $requestMessage = new RequestMessage($subscriptionId, $filters); $relayUrls = [ - 'wss://relay.primal.net', 'wss://purplepag.es', 'wss://nostr.wine', 'wss://relay.damus.io', + 'wss://relay.primal.net', ]; - $data = null; + // Collect all responses from all relays + $allResponses = collect([]); foreach ($relayUrls as $relayUrl) { $relay = new Relay($relayUrl); $relay->setMessage($requestMessage); @@ -57,25 +57,30 @@ trait NostrFetcherTrait $data = $response[$relayUrl]; if (! empty($data)) { \Log::info('Successfully fetched data from relay: '.$relayUrl); - break; // Exit the loop if data is not empty + $allResponses = $allResponses->concat($data); } } catch (\Exception $e) { \Log::warning('Failed to fetch from relay '.$relayUrl.': '.$e->getMessage()); } } - if (empty($data)) { + if ($allResponses->isEmpty()) { \Log::warning('No data found from any relay'); return; } - foreach ($data as $item) { + + // Group responses by pubkey and merge profile data + $mergedProfiles = []; + foreach ($allResponses as $item) { try { if (isset($item->event)) { + $pubkey = $item->event->pubkey; $result = json_decode($item->event->content, true, 512, JSON_THROW_ON_ERROR); - Profile::query()->updateOrCreate( - ['pubkey' => $item->event->pubkey], - [ + + if (! isset($mergedProfiles[$pubkey])) { + $mergedProfiles[$pubkey] = [ + 'pubkey' => $pubkey, 'name' => $result['name'] ?? null, 'display_name' => $result['display_name'] ?? null, 'picture' => $result['picture'] ?? null, @@ -86,13 +91,36 @@ trait NostrFetcherTrait 'lud16' => $result['lud16'] ?? null, 'lud06' => $result['lud06'] ?? null, 'deleted' => $result['deleted'] ?? false, - ], - ); - \Log::info('Profile updated/created for pubkey: '.$item->event->pubkey); + ]; + } else { + // Merge data: keep existing non-null values, use new values if existing is null + $fields = ['name', 'display_name', 'picture', 'banner', 'website', 'about', 'nip05', 'lud16', 'lud06', 'deleted']; + foreach ($fields as $field) { + if (array_key_exists($field, $result)) { + $mergedProfiles[$pubkey][$field] = $result[$field]; + } + } + } } } catch (\JsonException $e) { - \Log::error('Error decoding JSON: '.$e->getMessage()); - throw new \RuntimeException('Error decoding JSON: '.$e->getMessage()); + \Log::error('Error decoding JSON for pubkey: '.$item->event->pubkey ?? 'unknown', [ + 'error' => $e->getMessage(), + ]); + } + } + + // Update/create profiles with merged data + foreach ($mergedProfiles as $profileData) { + try { + Profile::query()->updateOrCreate( + ['pubkey' => $profileData['pubkey']], + $profileData, + ); + \Log::info('Profile updated/created for pubkey: '.$profileData['pubkey']); + } catch (\Exception $e) { + \Log::error('Failed to save profile for pubkey: '.$profileData['pubkey'], [ + 'error' => $e->getMessage(), + ]); } } } diff --git a/database/factories/PaymentEventFactory.php b/database/factories/PaymentEventFactory.php new file mode 100644 index 0000000..40c0539 --- /dev/null +++ b/database/factories/PaymentEventFactory.php @@ -0,0 +1,37 @@ + + */ +class PaymentEventFactory extends Factory +{ + public function definition(): array + { + return [ + 'einundzwanzig_pleb_id' => \App\Models\EinundzwanzigPleb::factory(), + 'year' => fake()->year(), + 'event_id' => fake()->uuid(), + 'amount' => 21000, + 'paid' => false, + 'btc_pay_invoice' => null, + ]; + } + + public function paid(): self + { + return $this->state(fn (array $attributes) => [ + 'paid' => true, + ]); + } + + public function withYear(int $year): self + { + return $this->state(fn (array $attributes) => [ + 'year' => $year, + ]); + } +} diff --git a/database/migrations/2026_01_20_113735_add_nip05_handle_to_einundzwanzig_plebs_table.php b/database/migrations/2026_01_20_113735_add_nip05_handle_to_einundzwanzig_plebs_table.php new file mode 100644 index 0000000..08d0baa --- /dev/null +++ b/database/migrations/2026_01_20_113735_add_nip05_handle_to_einundzwanzig_plebs_table.php @@ -0,0 +1,29 @@ +string('nip05_handle')->nullable()->unique(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('einundzwanzig_plebs', function (Blueprint $table) { + $table->dropUnique(['nip05_handle']); + $table->dropColumn('nip05_handle'); + }); + } +}; diff --git a/resources/views/livewire/association/news.blade.php b/resources/views/livewire/association/news.blade.php index cfc04df..f564984 100644 --- a/resources/views/livewire/association/news.blade.php +++ b/resources/views/livewire/association/news.blade.php @@ -114,8 +114,6 @@ class extends Component {
@if($isAllowed) -
-
@@ -344,10 +342,8 @@ class extends Component {
- -
@else -
+
Zugriff auf News nicht möglich

Um die News einzusehen, benötigst du:

diff --git a/resources/views/livewire/association/profile.blade.php b/resources/views/livewire/association/profile.blade.php index d9d493b..e4ef267 100644 --- a/resources/views/livewire/association/profile.blade.php +++ b/resources/views/livewire/association/profile.blade.php @@ -16,8 +16,7 @@ use swentel\nostr\Request\Request; use swentel\nostr\Sign\Sign; use swentel\nostr\Subscription\Subscription; -new class extends Component -{ +new class extends Component { public ApplicationForm $form; public bool $no = false; @@ -28,13 +27,15 @@ new class extends Component public ?string $email = ''; + public ?string $nip05Handle = ''; + public array $yearsPaid = []; public array $events = []; public $payments; - public int $amountToPay; + public int $amountToPay = 21000; public bool $currentYearIsPaid = false; @@ -55,16 +56,19 @@ new class extends Component $this->currentPubkey = NostrAuth::pubkey(); $this->currentPleb = EinundzwanzigPleb::query() ->with([ - 'paymentEvents' => fn ($query) => $query->where('year', date('Y')), + 'paymentEvents' => fn($query) => $query->where('year', date('Y')), + 'profile', ]) ->where('pubkey', $this->currentPubkey)->first(); if ($this->currentPleb) { $this->email = $this->currentPleb->email; - $this->no = $this->currentPleb->no_email; - $this->showEmail = ! $this->no; - if ($this->currentPleb->association_status === AssociationStatus::ACTIVE) { - $this->amountToPay = config('app.env') === 'production' ? 21000 : 1; + if ($this->currentPleb->nip05_handle) { + $this->nip05Handle = strtolower(str_replace('@einundzwanzig.space', '', + $this->currentPleb->nip05_handle)); } + $this->no = $this->currentPleb->no_email; + $this->showEmail = !$this->no; + $this->amountToPay = config('app.env') === 'production' ? 21000 : 1; if ($this->currentPleb->paymentEvents->count() < 1) { $this->createPaymentEvent(); $this->currentPleb->load('paymentEvents'); @@ -77,7 +81,7 @@ new class extends Component public function updatedNo(): void { - $this->showEmail = ! $this->no; + $this->showEmail = !$this->no; $this->currentPleb->update([ 'no_email' => $this->no, ]); @@ -88,6 +92,11 @@ new class extends Component $this->js('alert("Markus Turm wird sich per Fax melden!")'); } + public function updatedNip05Handle(): void + { + $this->nip05Handle = strtolower($this->nip05Handle); + } + public function saveEmail(): void { $this->validate([ @@ -99,6 +108,20 @@ new class extends Component Flux::toast('E-Mail Adresse gespeichert.'); } + public function saveNip05Handle(): void + { + $this->validate([ + 'nip05Handle' => 'required|string|max:255|regex:/^[a-z0-9_-]+$/|unique:einundzwanzig_plebs,nip05_handle', + ]); + + $nip05Handle = strtolower($this->nip05Handle).'@einundzwanzig.space'; + + $this->currentPleb->update([ + 'nip05_handle' => $nip05Handle, + ]); + Flux::toast('NIP-05 Handle gespeichert.'); + } + public function pay($comment): mixed { $paymentEvent = $this->currentPleb @@ -184,7 +207,7 @@ new class extends Component public function save($type): void { $this->form->validate(); - if (! $this->form->check) { + if (!$this->form->check) { $this->js('alert("Du musst den Statuten zustimmen.")'); return; @@ -249,7 +272,7 @@ new class extends Component $this->events = collect($response[config('services.relay')]) ->map(function ($event) { - if (! isset($event->event)) { + if (!isset($event->event)) { return false; } @@ -270,22 +293,194 @@ new class extends Component ?>
-
- -
-

- Einundzwanzig ist, was du draus machst -

-
+ +
+

+ Einundzwanzig ist, was du draus machst +

+
+
+ + +
+
+ Vorteile deiner Mitgliedschaft + + + +
+ +
+
+
+
+ +
+
+
+

+ Nostr Relay +

+

+ Exklusive Schreib-Rechte auf Premium Nostr Relay von Einundzwanzig. +

+
+
+
+ + +
+
+
+
+ +
+
+
+

+ Get NIP-05 verified +

+

+ Verifiziere deine Identität mit einem menschenlesbaren Nostr-Namen. +

+
+
+ + + @if($currentPleb && $currentPleb->association_status->value > 1 && $currentYearIsPaid) +
+ + Dein NIP-05 Handle + + + @einundzwanzig.space + + + + +
+ + Speichern + +
+ + +
+

+ Regeln für dein Handle: Nur Kleinbuchstaben (a-z), Zahlen + (0-9) und die Zeichen "-" und "_" sind erlaubt. Dein Handle wird automatisch + kleingeschrieben. +

+
+ + +
+

+ NIP-05 + + verifiziert deine Identität auf Nostr. Das Handle ist wie eine + E-Mail-Adresse (z.B. name@einundzwanzig.space). Clients zeigen ein Häkchen + für verifizierte Benutzer. Dies macht dein Profil einfacher zu teilen und + vertrauenswürdiger. +

+
+
+ @else +
+ Aktiviere deine Mitgliedschaft, um NIP-05 zu verifizieren. +
+ @endif +
+
+ + + +
+ +
+

+ Mehr Vorteile kommen bald! +

+

+ Wir arbeiten ständig daran, unsere Mitglieder-Vorteile auszubauen. + Bleib dran für neue exklusive Services und Kooperationen. +

+
+
+
+
+
+
-
+
+ + @if($currentPleb) + + +
+ Avatar +
+
+

+ {{ $currentPleb->profile?->display_name ?? $currentPleb->profile?->name ?? 'Unbekannt' }} +

+

+ @if($currentPleb->profile?->name) + {{ $currentPleb->profile->name }} + @endif +

+
+
+
+ Pubkey: + + {{ $currentPleb->pubkey }} + +
+
+ Npub: + + {{ $currentPleb->npub }} + +
+ @if($currentPleb->nip05_handle) +
+ NIP-05: + + {{ $currentPleb->nip05_handle }} + +
+ @endif +
+
+
+
+ @endif + -

- Aktueller Status -

- @if(!$currentPleb)
@@ -296,14 +491,16 @@ new class extends Component
-
+
Amber

- Perfekt für mobile Android Geräte. Eine App, in der man alle Keys/nsecs verwalten kann. + Perfekt für mobile Android Geräte. Eine App, in der man alle Keys/nsecs + verwalten kann.

@@ -313,14 +510,16 @@ new class extends Component -
+
Alby - Bitcoin Lightning Wallet & Nostr

- Browser-Erweiterung in die man seinen Key/nsec eingeben kann. Pro Alby-Konto ein nsec. + Browser-Erweiterung in die man seinen Key/nsec eingeben kann. Pro Alby-Konto + ein nsec.

@@ -330,7 +529,8 @@ new class extends Component -
+
@@ -347,7 +547,8 @@ new class extends Component -
+
@@ -383,10 +584,13 @@ new class extends Component @if($currentPubkey && $currentPleb->association_status->value < 2)
- - + + -

Profil in der Datenbank vorhanden.

+

Profil in der Datenbank + vorhanden.

@endif @@ -396,14 +600,15 @@ new class extends Component @if($currentPubkey && !$currentPleb->application_for && $currentPleb->association_status->value < 2) -
+

Einundzwanzig Mitglied werden

Nur Personen können Mitglied werden und zahlen 21.000 Satoshis im Jahr. - + Firmen melden sich bitte direkt an den Vorstand.

@@ -418,7 +623,8 @@ new class extends Component Mit deinem aktuellen Nostr-Profil Mitglied werden - + Statuten ansehen
@@ -428,14 +634,16 @@ new class extends Component @if($currentPubkey) -
+

- Falls du möchtest, kannst du hier eine E-Mail Adresse hinterlegen, damit der Verein dich darüber informieren kann, wenn es Neuigkeiten gibt. + Falls du möchtest, kannst du hier eine E-Mail Adresse hinterlegen, damit der Verein + dich darüber informieren kann, wenn es Neuigkeiten gibt.

- Am besten eine anonymisierte E-Mail Adresse verwenden. Wir sichern diese Adresse AES-256 verschlüsselt in der Datenbank ab. + Am besten eine anonymisierte E-Mail Adresse verwenden. Wir sichern diese Adresse + AES-256 verschlüsselt in der Datenbank ab.

@@ -459,7 +667,8 @@ new class extends Component E-Mail Adresse - +
@@ -479,8 +688,10 @@ new class extends Component @if($currentPleb && $currentPleb->association_status->value > 1)
- - + +

@@ -508,7 +719,8 @@ new class extends Component

Nostr Event für die Zahlung des Mitgliedsbeitrags: - {{ $currentPleb->paymentEvents->last()->event_id }} + {{ $currentPleb->paymentEvents->last()->event_id }}

@@ -541,7 +753,8 @@ new class extends Component

- Unser Nostr-Relay konnte derzeit nicht erreicht werden, um eine Zahlung zu initialisieren. Bitte versuche es später noch einmal. + Unser Nostr-Relay konnte derzeit nicht erreicht werden, um eine Zahlung zu + initialisieren. Bitte versuche es später noch einmal.

@@ -558,47 +771,51 @@ new class extends Component @@ -606,19 +823,24 @@ new class extends Component
@foreach($payments as $payment) -
+
Satoshis - {{ $payment->amount }} + {{ $payment->amount }}
Jahr - {{ $payment->year }} + {{ $payment->year }}
- Event-ID - {{ $payment->event_id }} + Event-ID + {{ $payment->event_id }}
@if($payment->btc_pay_invoice) delete(); + EinundzwanzigPleb::query()->delete(); +}); + +test('returns paid members for a specific year', function () { + $member1 = EinundzwanzigPleb::factory()->create([ + 'npub' => 'npub1abc', + 'pubkey' => 'pubkey1', + 'nip05_handle' => 'user1@example.com', + 'association_status' => AssociationStatus::ACTIVE, + ]); + + $member2 = EinundzwanzigPleb::factory()->create([ + 'npub' => 'npub2def', + 'pubkey' => 'pubkey2', + 'nip05_handle' => 'user2@example.com', + 'association_status' => AssociationStatus::ACTIVE, + ]); + + $member3 = EinundzwanzigPleb::factory()->create([ + 'npub' => 'npub3ghi', + 'pubkey' => 'pubkey3', + 'association_status' => AssociationStatus::ACTIVE, + ]); + + $year = 2024; + + PaymentEvent::factory()->create([ + 'einundzwanzig_pleb_id' => $member1->id, + 'year' => $year, + 'paid' => true, + ]); + + PaymentEvent::factory()->create([ + 'einundzwanzig_pleb_id' => $member2->id, + 'year' => $year, + 'paid' => true, + ]); + + PaymentEvent::factory()->create([ + 'einundzwanzig_pleb_id' => $member3->id, + 'year' => $year, + 'paid' => false, + ]); + + PaymentEvent::factory()->create([ + 'einundzwanzig_pleb_id' => $member1->id, + 'year' => 2023, + 'paid' => true, + ]); + + $response = $this->getJson("/api/members/{$year}"); + + $response->assertStatus(200); + + $response->assertJsonCount(2); + + $response->assertJsonFragment([ + 'npub' => 'npub1abc', + 'pubkey' => 'pubkey1', + 'nip05_handle' => 'user1@example.com', + ]); + + $response->assertJsonFragment([ + 'npub' => 'npub2def', + 'pubkey' => 'pubkey2', + 'nip05_handle' => 'user2@example.com', + ]); + + $response->assertJsonMissing([ + 'npub' => 'npub3ghi', + ]); +}); + +test('returns empty array when no members paid for year', function () { + $year = 2024; + + $response = $this->getJson("/api/members/{$year}"); + + $response->assertStatus(200); + + $response->assertJson([]); +}); + +test('only returns npub, pubkey, and nip05_handle fields', function () { + $member = EinundzwanzigPleb::factory()->create([ + 'npub' => 'npub1abc', + 'pubkey' => 'pubkey1', + 'nip05_handle' => 'user1@example.com', + 'association_status' => AssociationStatus::ACTIVE, + ]); + + PaymentEvent::factory()->create([ + 'einundzwanzig_pleb_id' => $member->id, + 'year' => 2024, + 'paid' => true, + ]); + + $response = $this->getJson('/api/members/2024'); + + $response->assertStatus(200); + + $json = $response->json(); + expect($json[0])->toHaveKeys(['id', 'npub', 'pubkey', 'nip05_handle']); + + expect($json[0])->not->toHaveKeys([ + 'email', + 'association_status', + 'no_email', + 'application_for', + ]); +}); + +test('includes nip05_handle in response when available', function () { + $member = EinundzwanzigPleb::factory()->create([ + 'npub' => 'npub1abc', + 'pubkey' => 'pubkey1', + 'nip05_handle' => 'verified@example.com', + 'association_status' => AssociationStatus::ACTIVE, + ]); + + PaymentEvent::factory()->create([ + 'einundzwanzig_pleb_id' => $member->id, + 'year' => 2024, + 'paid' => true, + ]); + + $response = $this->getJson('/api/members/2024'); + + $response->assertStatus(200); + + $response->assertJsonFragment([ + 'nip05_handle' => 'verified@example.com', + ]); +}); + +test('nip05_handle is null in response when not set', function () { + $member = EinundzwanzigPleb::factory()->create([ + 'npub' => 'npub1abc', + 'pubkey' => 'pubkey1', + 'nip05_handle' => null, + 'association_status' => AssociationStatus::ACTIVE, + ]); + + PaymentEvent::factory()->create([ + 'einundzwanzig_pleb_id' => $member->id, + 'year' => 2024, + 'paid' => true, + ]); + + $response = $this->getJson('/api/members/2024'); + + $response->assertStatus(200); + + $json = $response->json(); + expect($json[0]['nip05_handle'])->toBeNull(); +}); diff --git a/tests/Feature/Livewire/Association/ProfileTest.php b/tests/Feature/Livewire/Association/ProfileTest.php index 3eaa90d..062af73 100644 --- a/tests/Feature/Livewire/Association/ProfileTest.php +++ b/tests/Feature/Livewire/Association/ProfileTest.php @@ -49,6 +49,60 @@ it('validates email format', function () { ->assertHasErrors(['email']); }); +it('can save nip05 handle', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + + NostrAuth::login($pleb->pubkey); + + Livewire::test('association.profile') + ->set('nip05Handle', 'user@example.com') + ->call('saveNip05Handle') + ->assertHasNoErrors(); + + expect($pleb->fresh()->nip05_handle)->toBe('user@example.com'); +}); + +it('validates nip05 handle format', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + + NostrAuth::login($pleb->pubkey); + + Livewire::test('association.profile') + ->set('nip05Handle', 'not-an-email') + ->call('saveNip05Handle') + ->assertHasErrors(['nip05Handle']); +}); + +it('validates nip05 handle uniqueness', function () { + $pleb1 = EinundzwanzigPleb::factory()->active()->create([ + 'nip05_handle' => 'taken@example.com', + ]); + + $pleb2 = EinundzwanzigPleb::factory()->active()->create(); + + NostrAuth::login($pleb2->pubkey); + + Livewire::test('association.profile') + ->set('nip05Handle', 'taken@example.com') + ->call('saveNip05Handle') + ->assertHasErrors(['nip05Handle']); +}); + +it('can save null nip05 handle', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create([ + 'nip05_handle' => 'old@example.com', + ]); + + NostrAuth::login($pleb->pubkey); + + Livewire::test('association.profile') + ->set('nip05Handle', null) + ->call('saveNip05Handle') + ->assertHasNoErrors(); + + expect($pleb->fresh()->nip05_handle)->toBeNull(); +}); + it('can update no email preference', function () { $pleb = EinundzwanzigPleb::factory()->create();