diff --git a/_docs/api/v1.yml b/_docs/api/v1.yml index 69f334cf..d40c0ec6 100644 --- a/_docs/api/v1.yml +++ b/_docs/api/v1.yml @@ -212,25 +212,12 @@ paths: /newsletter/subscribe: post: tags: [Newsletter] - summary: Subscribe an email address - description: Requires `newsletter:subscribe`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SubscribeNewsletterRequest' + deprecated: true + summary: Discontinued — newsletter subscription via the API + description: Discontinued on 2026-10-09. Responds with 410 and creates no subscription. responses: - '201': - description: Subscription created. - content: - application/json: - schema: - $ref: '#/components/schemas/NewsletterSubscriptionResponse' - '403': - $ref: '#/components/responses/Forbidden' - '422': - $ref: '#/components/responses/ValidationError' + '410': + description: Endpoint discontinued. components: securitySchemes: sanctumBearer: diff --git a/app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php b/app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php index 92e58ed0..c56892fb 100644 --- a/app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php +++ b/app/Http/Controllers/Api/V1/NewsletterSubscriptionController.php @@ -3,78 +3,21 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; -use App\Http\Requests\Api\V1\SubscribeNewsletterRequest; -use App\Models\NewsletterSubscription; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Str; +/** + * Newsletter-Anmeldung über die API — abgeschaltet am 09.10.2026 + * (Roadmap 4.3). Der Endpunkt legte Abos ohne Bestätigungsweg an, obwohl die + * Spezifikation ein Double-Opt-in versprach, und wurde seit dem Go-Live nicht + * genutzt. Eine Anmeldung mit Double-Opt-in kommt mit dem Branchen-Digest + * (Roadmap 6.4); bis dahin antwortet die Route mit 410. + */ class NewsletterSubscriptionController extends Controller { - public function store(SubscribeNewsletterRequest $request): JsonResponse + public function store(): JsonResponse { - $validated = $request->validated(); - - $email = mb_strtolower($validated['email']); - - $subscription = DB::transaction(function () use ($request, $validated, $email): NewsletterSubscription { - $subscription = NewsletterSubscription::withoutGlobalScopes() - ->where('portal', $validated['portal']) - ->where('email', $email) - ->lockForUpdate() - ->first(); - - // Nur ein fremder, tatsächlich gesetzter Eigentümer blockiert (409). - // Verwaiste Datensätze (user_id NULL nach Konto-Löschung/Legacy-Import) - // dürfen neu gebunden werden — sonst wäre die portal+email-Kombination - // dauerhaft nicht mehr abonnierbar. - if ($subscription !== null && $subscription->user_id !== null && $subscription->user_id !== $request->user()->id) { - abort(409, 'This email address is already subscribed.'); - } - - if ($subscription === null) { - return NewsletterSubscription::withoutGlobalScopes()->create([ - 'portal' => $validated['portal'], - 'email' => $email, - 'user_id' => $request->user()->id, - 'salutation_key' => $validated['salutation_key'] ?? null, - 'first_name' => $validated['first_name'] ?? null, - 'last_name' => $validated['last_name'] ?? null, - 'ip_address' => $request->ip(), - 'is_confirmed' => false, - 'confirmation_token' => Str::random(32), - 'subscribed_at' => now(), - 'unsubscribed_at' => null, - ]); - } - - $attributes = [ - 'user_id' => $request->user()->id, - 'salutation_key' => $validated['salutation_key'] ?? null, - 'first_name' => $validated['first_name'] ?? null, - 'last_name' => $validated['last_name'] ?? null, - 'ip_address' => $request->ip(), - 'subscribed_at' => now(), - 'unsubscribed_at' => null, - ]; - - if (! $subscription->is_confirmed) { - $attributes['confirmation_token'] = Str::random(32); - } - - $subscription->update($attributes); - - return $subscription; - }); - return response()->json([ - 'message' => 'Newsletter subscription created.', - 'data' => [ - 'id' => $subscription->id, - 'portal' => $subscription->portal->value, - 'email' => $subscription->email, - 'is_confirmed' => $subscription->is_confirmed, - ], - ], 201); + 'message' => 'Newsletter subscription via the API has been discontinued.', + ], 410); } } diff --git a/app/Http/Requests/Api/V1/SubscribeNewsletterRequest.php b/app/Http/Requests/Api/V1/SubscribeNewsletterRequest.php deleted file mode 100644 index 429be1c5..00000000 --- a/app/Http/Requests/Api/V1/SubscribeNewsletterRequest.php +++ /dev/null @@ -1,38 +0,0 @@ -user()?->tokenCan('newsletter:subscribe') === true; - } - - /** - * Get the validation rules that apply to the request. - * - * @return array|string> - */ - public function rules(): array - { - return [ - 'portal' => ['required', Rule::in([ - Portal::Presseecho->value, - Portal::Businessportal24->value, - ])], - 'email' => ['required', 'email', 'max:190'], - 'salutation_key' => ['nullable', 'string', 'max:20'], - 'first_name' => ['nullable', 'string', 'max:80'], - 'last_name' => ['nullable', 'string', 'max:80'], - ]; - } -} diff --git a/public/api/v1.yml b/public/api/v1.yml index 8b2b11d6..f9249130 100644 --- a/public/api/v1.yml +++ b/public/api/v1.yml @@ -40,7 +40,7 @@ tags: - name: categories description: "Aktive Rubriken (Ability: press-releases:read)" - name: newsletter - description: "Newsletter-Anmeldungen (Ability: newsletter:subscribe)" + description: "Newsletter-Anmeldung über die API — eingestellt am 09.10.2026" paths: /press-releases: get: @@ -257,24 +257,13 @@ paths: /newsletter/subscribe: post: tags: [newsletter] - summary: Newsletter-Anmeldung anlegen (Double-Opt-in folgt per Mail) - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [portal, email] - properties: - portal: - type: string - enum: [presseecho, businessportal24] - email: { type: string, format: email, maxLength: 190 } - salutation_key: { type: string, maxLength: 20 } - first_name: { type: string, maxLength: 80 } - last_name: { type: string, maxLength: 80 } + deprecated: true + summary: Eingestellt — Newsletter-Anmeldung über die API + description: > + Seit 09.10.2026 abgeschaltet. Die Route antwortet mit 410 und legt + keine Anmeldung an. responses: - "201": { description: "Anmeldung angelegt (unbestätigt)" } + "410": { description: "Endpunkt eingestellt" } components: securitySchemes: bearerToken: diff --git a/resources/views/livewire/customer/tokens.blade.php b/resources/views/livewire/customer/tokens.blade.php index f09ba7ee..f3b1a5d1 100644 --- a/resources/views/livewire/customer/tokens.blade.php +++ b/resources/views/livewire/customer/tokens.blade.php @@ -37,7 +37,6 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo 'press-releases:write' => 'Pressemitteilungen erstellen und bearbeiten', 'press-release-images:write' => 'Bilder zu Pressemitteilungen verwalten', 'companies:read' => 'Firmendaten lesen', - 'newsletter:subscribe' => 'Newsletter-Anmeldungen auslösen', ]; public function createToken(): void diff --git a/tests/Feature/Api/V1/CustomerDataApiTest.php b/tests/Feature/Api/V1/CustomerDataApiTest.php index 14dc47a5..8661247f 100644 --- a/tests/Feature/Api/V1/CustomerDataApiTest.php +++ b/tests/Feature/Api/V1/CustomerDataApiTest.php @@ -5,6 +5,7 @@ use App\Models\Category; use App\Models\Company; use App\Models\NewsletterSubscription; use App\Models\User; +use Livewire\Livewire; use Tests\TestCase; test('legacy api keys in query parameters are not accepted but get the migration hint', function () { @@ -51,86 +52,22 @@ test('api user can list categories with press release read ability', function () ]); }); -test('api user can subscribe an email to newsletter', function () { +test('the newsletter subscription endpoint is discontinued and creates nothing (Roadmap 4.3)', function () { /** @var TestCase $this */ $user = User::factory()->create(); - apiActingAs($user, ['newsletter:subscribe']); $this->postJson('/api/v1/newsletter/subscribe', [ 'portal' => 'presseecho', 'email' => 'newsletter@example.com', - 'first_name' => 'Max', - 'last_name' => 'Muster', - ]) - ->assertCreated() - ->assertJsonPath('data.email', 'newsletter@example.com') - ->assertJsonPath('data.is_confirmed', false); + ])->assertStatus(410); - expect(NewsletterSubscription::withoutGlobalScopes() - ->where('email', 'newsletter@example.com') - ->where('portal', 'presseecho') - ->exists())->toBeTrue(); + expect(NewsletterSubscription::withoutGlobalScopes()->where('email', 'newsletter@example.com')->exists())->toBeFalse(); }); -test('resubscribing keeps an existing confirmation intact', function () { - /** @var TestCase $this */ - $user = User::factory()->create(); - $subscription = NewsletterSubscription::withoutGlobalScopes()->create([ - 'portal' => 'presseecho', - 'user_id' => $user->id, - 'email' => 'confirmed@example.com', - 'is_confirmed' => true, - 'confirmation_token' => null, - 'subscribed_at' => now()->subMonth(), - 'unsubscribed_at' => now()->subDay(), - ]); - apiActingAs($user, ['newsletter:subscribe']); - - $this->postJson('/api/v1/newsletter/subscribe', [ - 'portal' => 'presseecho', - 'email' => 'confirmed@example.com', - 'first_name' => 'Neu', - ])->assertCreated() - ->assertJsonPath('data.is_confirmed', true); - - $subscription->refresh(); - - expect($subscription->is_confirmed)->toBeTrue() - ->and($subscription->confirmation_token)->toBeNull() - ->and($subscription->unsubscribed_at)->toBeNull() - ->and($subscription->first_name)->toBe('Neu'); -}); - -test('an orphaned subscription without owner can be rebound instead of being permanently blocked', function () { - /** @var TestCase $this */ - // user_id NULL entsteht z. B. nach Konto-Löschung (FK nullOnDelete) oder - // durch Legacy-Import. Ohne Rebind wäre portal+email dauerhaft gesperrt. - $subscription = NewsletterSubscription::withoutGlobalScopes()->create([ - 'portal' => 'presseecho', - 'user_id' => null, - 'email' => 'orphan@example.com', - 'is_confirmed' => false, - 'subscribed_at' => now()->subMonth(), - ]); - - $user = User::factory()->create(); - apiActingAs($user, ['newsletter:subscribe']); - - $this->postJson('/api/v1/newsletter/subscribe', [ - 'portal' => 'presseecho', - 'email' => 'orphan@example.com', - 'first_name' => 'Wieder da', - ])->assertCreated(); - - expect($subscription->fresh()->user_id)->toBe($user->id) - ->and($subscription->fresh()->first_name)->toBe('Wieder da'); -}); - -test('an api user cannot take over another newsletter subscription', function () { +test('the discontinued endpoint leaves existing subscriptions untouched', function () { /** @var TestCase $this */ $owner = User::factory()->create(); - $attacker = User::factory()->create(); $subscription = NewsletterSubscription::withoutGlobalScopes()->create([ 'portal' => 'presseecho', 'user_id' => $owner->id, @@ -139,15 +76,22 @@ test('an api user cannot take over another newsletter subscription', function () 'is_confirmed' => true, 'subscribed_at' => now(), ]); - apiActingAs($attacker, ['newsletter:subscribe']); + apiActingAs(User::factory()->create(), ['newsletter:subscribe']); $this->postJson('/api/v1/newsletter/subscribe', [ 'portal' => 'presseecho', 'email' => 'owned@example.com', 'first_name' => 'Übernommen', - ])->assertConflict(); + ])->assertStatus(410); expect($subscription->fresh()->user_id)->toBe($owner->id) - ->and($subscription->fresh()->first_name)->toBe('Original') - ->and($subscription->fresh()->is_confirmed)->toBeTrue(); + ->and($subscription->fresh()->first_name)->toBe('Original'); +}); + +test('new api tokens can no longer request the newsletter ability', function () { + /** @var TestCase $this */ + $this->actingAs(User::factory()->create(['is_active' => true])); + + Livewire::test('customer.tokens') + ->assertDontSee('Newsletter-Anmeldungen auslösen'); });