diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index 3e68f76..61d9ba1 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -143,7 +143,7 @@ class PackageResource extends Resource ->nullable() ->visible(fn ($get) => $get('type') === 'reseller'), Toggle::make('watermark_allowed') - ->label('Wasserzeichen erlaubt') + ->label('Eigenes Wasserzeichen erlaubt') ->default(true), Toggle::make('branding_allowed') ->label('Eigenes Branding erlaubt') diff --git a/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php index 313b4f0..5aab0b5 100644 --- a/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php +++ b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php @@ -27,7 +27,7 @@ class WatermarkSettingsPage extends Page return __('admin.nav.branding'); } - public ?string $asset = null; + public $asset = []; public string $position = 'bottom-right'; @@ -46,7 +46,7 @@ class WatermarkSettingsPage extends Page $settings = WatermarkSetting::query()->first(); if ($settings) { - $this->asset = $settings->asset; + $this->asset = $settings->asset ? [$settings->asset] : []; $this->position = $settings->position; $this->opacity = (float) $settings->opacity; $this->scale = (float) $settings->scale; @@ -119,8 +119,14 @@ class WatermarkSettingsPage extends Page { $this->validate(); + $state = $this->form->getState(); + $asset = $state['asset'] ?? $this->asset; + if (is_array($asset)) { + $asset = $asset[0] ?? null; + } + $settings = WatermarkSetting::query()->firstOrNew([]); - $settings->asset = $this->asset; + $settings->asset = $asset; $settings->position = $this->position; $settings->opacity = $this->opacity; $settings->scale = $this->scale; diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 5e50504..48400b6 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -20,6 +20,7 @@ use App\Models\User; use App\Services\EventJoinTokenService; use App\Support\ApiError; use App\Support\TenantMemberPermissions; +use App\Support\WatermarkConfigResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -414,6 +415,7 @@ class EventController extends Controller $package = $event->eventPackage?->package; $brandingAllowed = optional($package)->branding_allowed !== false; $watermarkAllowed = optional($package)->watermark_allowed !== false; + $watermarkRemovalAllowed = WatermarkConfigResolver::determineRemovalAllowed($event); if (isset($validated['settings']) && is_array($validated['settings'])) { $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); @@ -423,6 +425,7 @@ class EventController extends Controller $validated['settings']['branding_allowed'] = $brandingAllowed; $validated['settings']['watermark_allowed'] = $watermarkAllowed; + $validated['settings']['watermark_removal_allowed'] = $watermarkRemovalAllowed; $settings = $validated['settings']; $branding = Arr::get($settings, 'branding', []); @@ -435,20 +438,19 @@ class EventController extends Controller if (is_array($watermark)) { $mode = $watermark['mode'] ?? 'base'; - $policy = $watermarkAllowed ? 'basic' : 'none'; if (! $watermarkAllowed) { - $mode = 'off'; + $mode = 'base'; } elseif (! $brandingAllowed) { $mode = 'base'; - } elseif ($mode === 'off' && $policy === 'basic') { + } elseif ($mode === 'off' && ! $watermarkRemovalAllowed) { $mode = 'base'; } $assetPath = $watermark['asset'] ?? null; $assetDataUrl = $watermark['asset_data_url'] ?? null; - if (! $watermarkAllowed) { + if (! $watermarkAllowed || $mode === 'off') { $assetPath = null; } diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index dcfc7ef..d352029 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -2,12 +2,15 @@ namespace App\Http\Resources\Tenant; +use App\Models\WatermarkSetting; use App\Services\Packages\PackageLimitEvaluator; use App\Support\TenantMemberPermissions; +use App\Support\WatermarkConfigResolver; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\URL; use function app; @@ -47,6 +50,8 @@ class EventResource extends JsonResource $limitEvaluator = app()->make(PackageLimitEvaluator::class); } + $settings['watermark_removal_allowed'] = WatermarkConfigResolver::determineRemovalAllowed($this->resource); + return [ 'id' => $this->id, 'name' => $this->name, @@ -108,6 +113,27 @@ class EventResource extends JsonResource $watermark = Arr::get($settings, 'watermark'); $base = config('watermark.base', []); $base = is_array($base) ? $base : []; + $baseSetting = null; + + if (class_exists(WatermarkSetting::class) && Schema::hasTable('watermark_settings')) { + try { + $baseSetting = WatermarkSetting::query()->first(); + } catch (\Throwable) { + $baseSetting = null; + } + } + + if ($baseSetting) { + $base = array_merge($base, array_filter([ + 'asset' => $baseSetting->asset, + 'position' => $baseSetting->position, + 'opacity' => $baseSetting->opacity, + 'scale' => $baseSetting->scale, + 'padding' => $baseSetting->padding, + 'offset_x' => $baseSetting->offset_x, + 'offset_y' => $baseSetting->offset_y, + ], static fn ($value) => $value !== null)); + } if (! is_array($watermark)) { $watermark = []; diff --git a/app/Support/Concerns/PresentsPackages.php b/app/Support/Concerns/PresentsPackages.php index 0235317..c359aeb 100644 --- a/app/Support/Concerns/PresentsPackages.php +++ b/app/Support/Concerns/PresentsPackages.php @@ -13,7 +13,8 @@ trait PresentsPackages $packageArray = $package->toArray(); $features = $packageArray['features'] ?? []; $features = $this->normaliseFeatures($features); - $watermarkPolicy = $package->watermark_allowed === false ? 'none' : 'basic'; + $watermarkRemovalAllowed = in_array('no_watermark', $features, true) || in_array('watermark_removal', $features, true); + $watermarkPolicy = $watermarkRemovalAllowed ? 'none' : 'basic'; $locale = app()->getLocale(); $name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale); diff --git a/app/Support/ImageHelper.php b/app/Support/ImageHelper.php index 26d1fcc..73e29b8 100644 --- a/app/Support/ImageHelper.php +++ b/app/Support/ImageHelper.php @@ -178,10 +178,13 @@ class ImageHelper $y = max(0, min($srcH - $targetH, $y + $offsetY)); $opacity = max(0.0, min(1.0, (float) ($config['opacity'] ?? 0.25))); - $mergeOpacity = (int) round($opacity * 100); // imagecopymerge uses 0-100 + + if ($opacity < 1.0) { + self::applyOpacity($resized, $opacity); + } imagealphablending($src, true); - imagecopymerge($src, $resized, $x, $y, 0, 0, $targetW, $targetH, $mergeOpacity); + imagecopy($src, $resized, $x, $y, 0, 0, $targetW, $targetH); imagedestroy($resized); // Overwrite original (respect mime: always JPEG for compatibility) @@ -210,4 +213,34 @@ class ImageHelper return $applied ? $destPath : null; } + + /** + * @param \GdImage|resource $image + */ + private static function applyOpacity($image, float $opacity): void + { + $width = imagesx($image); + $height = imagesy($image); + + if ($width <= 0 || $height <= 0) { + return; + } + + imagealphablending($image, false); + imagesavealpha($image, true); + + for ($x = 0; $x < $width; $x++) { + for ($y = 0; $y < $height; $y++) { + $rgba = imagecolorat($image, $x, $y); + $alpha = ($rgba >> 24) & 0x7F; + $red = ($rgba >> 16) & 0xFF; + $green = ($rgba >> 8) & 0xFF; + $blue = $rgba & 0xFF; + + $adjustedAlpha = (int) round(127 - (127 - $alpha) * $opacity); + $color = imagecolorallocatealpha($image, $red, $green, $blue, max(0, min(127, $adjustedAlpha))); + imagesetpixel($image, $x, $y, $color); + } + } + } } diff --git a/app/Support/WatermarkConfigResolver.php b/app/Support/WatermarkConfigResolver.php index acfa97e..0d786ff 100644 --- a/app/Support/WatermarkConfigResolver.php +++ b/app/Support/WatermarkConfigResolver.php @@ -3,6 +3,7 @@ namespace App\Support; use App\Models\Event; +use App\Models\Package; use Illuminate\Support\Arr; class WatermarkConfigResolver @@ -29,7 +30,21 @@ class WatermarkConfigResolver { $event->loadMissing('eventPackage.package'); - return $event->eventPackage?->package?->watermark_allowed === false ? 'none' : 'basic'; + return self::determineRemovalAllowed($event) ? 'none' : 'basic'; + } + + public static function determineRemovalAllowed(Event $event): bool + { + $event->loadMissing('eventPackage.package', 'eventPackages.package'); + + $package = $event->eventPackage?->package; + + if (! $package && $event->relationLoaded('eventPackages')) { + $package = $event->eventPackages->first()?->package; + } + + return self::packageHasFeature($package, 'no_watermark') + || self::packageHasFeature($package, 'watermark_removal'); } /** @@ -39,13 +54,6 @@ class WatermarkConfigResolver { $policy = self::determinePolicy($event); - if ($policy === 'none') { - return [ - 'type' => 'none', - 'policy' => $policy, - ]; - } - $baseSetting = null; if (class_exists(\App\Models\WatermarkSetting::class) && \Illuminate\Support\Facades\Schema::hasTable('watermark_settings')) { @@ -65,8 +73,15 @@ class WatermarkConfigResolver 'offset_y' => $baseSetting?->offset_y ?? config('watermark.base.offset_y', 0), ]; - $event->loadMissing('eventPackage.package', 'tenant'); + $event->loadMissing('eventPackage.package', 'eventPackages.package', 'tenant'); + $package = $event->eventPackage?->package; + + if (! $package && $event->relationLoaded('eventPackages')) { + $package = $event->eventPackages->first()?->package; + } $brandingAllowed = self::determineBrandingAllowed($event); + $watermarkAllowed = $package?->watermark_allowed !== false; + $removalAllowed = self::determineRemovalAllowed($event); $eventWatermark = Arr::get($event->settings, 'watermark', []); $tenantWatermark = Arr::get($event->tenant?->settings, 'watermark', []); $serveOriginals = (bool) Arr::get($event->settings, 'watermark_serve_originals', false); @@ -75,7 +90,11 @@ class WatermarkConfigResolver ? ($eventWatermark['mode'] ?? $tenantWatermark['mode'] ?? 'base') : 'base'; - if ($mode === 'off' && $policy === 'basic') { + if ($mode === 'custom' && (! $brandingAllowed || ! $watermarkAllowed)) { + $mode = 'base'; + } + + if ($mode === 'off' && ! $removalAllowed) { $mode = 'base'; } @@ -111,4 +130,30 @@ class WatermarkConfigResolver 'serve_originals' => $serveOriginals, ]; } + + private static function packageHasFeature(?Package $package, string $feature): bool + { + if (! $package) { + return false; + } + + $features = $package->features ?? []; + + if (is_string($features)) { + $decoded = json_decode($features, true); + if (json_last_error() === JSON_ERROR_NONE) { + $features = $decoded; + } + } + + if (! is_array($features)) { + return false; + } + + if (array_is_list($features)) { + return in_array($feature, $features, true); + } + + return isset($features[$feature]) && (bool) $features[$feature]; + } } diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index 1978931..1882c85 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -29,7 +29,7 @@ class PackageSeeder extends Seeder 'gallery_days' => 180, 'max_tasks' => 30, 'max_events_per_year' => 1, - 'watermark_allowed' => true, + 'watermark_allowed' => false, 'branding_allowed' => false, 'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'], 'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej', @@ -64,7 +64,7 @@ TEXT, 'max_tasks' => 100, 'watermark_allowed' => true, 'branding_allowed' => true, - 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'], + 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'no_watermark'], 'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j', 'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc', 'description' => <<<'TEXT' @@ -95,7 +95,7 @@ TEXT, 'max_guests' => null, 'gallery_days' => 730, 'max_tasks' => 200, - 'watermark_allowed' => false, + 'watermark_allowed' => true, 'branding_allowed' => true, 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'], 'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s', @@ -197,7 +197,7 @@ TEXT, 'max_guests' => null, 'gallery_days' => null, 'max_tasks' => null, - 'watermark_allowed' => false, + 'watermark_allowed' => true, 'branding_allowed' => true, 'max_events_per_year' => 35, 'expires_after' => null, @@ -231,7 +231,7 @@ TEXT, 'max_guests' => null, 'gallery_days' => null, 'max_tasks' => null, - 'watermark_allowed' => false, + 'watermark_allowed' => true, 'branding_allowed' => true, 'max_events_per_year' => 5, 'expires_after' => null, diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index c70fc17..bf386ca 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -103,6 +103,7 @@ export type TenantEvent = { live_show?: LiveShowSettings; watermark?: WatermarkSettings; watermark_allowed?: boolean | null; + watermark_removal_allowed?: boolean | null; watermark_serve_originals?: boolean | null; }; package?: { diff --git a/resources/js/admin/lib/__tests__/events.test.ts b/resources/js/admin/lib/__tests__/events.test.ts index 9dc61d0..a4711bb 100644 --- a/resources/js/admin/lib/__tests__/events.test.ts +++ b/resources/js/admin/lib/__tests__/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isBrandingAllowed, isWatermarkAllowed } from '../events'; +import { isBrandingAllowed, isWatermarkAllowed, isWatermarkRemovalAllowed } from '../events'; describe('event branding access helpers', () => { it('respects package-level disallow', () => { @@ -25,5 +25,14 @@ describe('event branding access helpers', () => { it('defaults to allow when nothing is set', () => { expect(isBrandingAllowed({} as any)).toBe(true); expect(isWatermarkAllowed({} as any)).toBe(true); + expect(isWatermarkRemovalAllowed({} as any)).toBe(false); + }); + + it('uses removal flag from settings', () => { + const event = { + settings: { watermark_removal_allowed: true }, + }; + + expect(isWatermarkRemovalAllowed(event as any)).toBe(true); }); }); diff --git a/resources/js/admin/lib/events.ts b/resources/js/admin/lib/events.ts index 6cd3e39..6707352 100644 --- a/resources/js/admin/lib/events.ts +++ b/resources/js/admin/lib/events.ts @@ -116,6 +116,15 @@ export function isWatermarkAllowed(event?: TenantEvent | null): boolean { return true; } +export function isWatermarkRemovalAllowed(event?: TenantEvent | null): boolean { + if (!event) return false; + const settings = (event.settings ?? {}) as Record; + if (typeof settings.watermark_removal_allowed === 'boolean') { + return settings.watermark_removal_allowed; + } + return false; +} + export function formatEventStatusLabel( status: TenantEvent['status'] | null, t: (key: string, options?: Record) => string, diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 1ceea16..58836cd 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -12,7 +12,7 @@ import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSele import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; -import { isBrandingAllowed, isWatermarkAllowed } from '../lib/events'; +import { isBrandingAllowed, isWatermarkAllowed, isWatermarkRemovalAllowed } from '../lib/events'; import { MobileSheet } from './components/Sheet'; import toast from 'react-hot-toast'; import { adminPath } from '../constants'; @@ -188,10 +188,24 @@ export default function MobileBrandingPage() { const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : ''; const previewInitials = getInitials(previewTitle); const watermarkAllowed = isWatermarkAllowed(event ?? null); + const watermarkRemovalAllowed = isWatermarkRemovalAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null); + const customWatermarkAllowed = watermarkAllowed && brandingAllowed; const watermarkLocked = watermarkAllowed && !brandingAllowed; const brandingDisabled = !brandingAllowed || form.useDefaultBranding; + React.useEffect(() => { + setWatermarkForm((prev) => { + if (prev.mode === 'custom' && !customWatermarkAllowed) { + return { ...prev, mode: 'base' }; + } + if (prev.mode === 'off' && !watermarkRemovalAllowed) { + return { ...prev, mode: 'base' }; + } + return prev; + }); + }, [customWatermarkAllowed, watermarkRemovalAllowed]); + async function handleSave() { if (!event?.slug) return; setSaving(true); @@ -271,7 +285,12 @@ export default function MobileBrandingPage() { size: form.logoSize, }, }; - const watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed); + const watermarkPayload = buildWatermarkPayload( + watermarkForm, + watermarkAllowed, + brandingAllowed, + watermarkRemovalAllowed + ); if (watermarkPayload) { settings.watermark = watermarkPayload; } @@ -307,10 +326,14 @@ export default function MobileBrandingPage() { } function renderWatermarkTab() { - const policyLabel = watermarkAllowed ? 'basic' : 'none'; - const disabled = !watermarkAllowed; - const controlsLocked = watermarkLocked || disabled; + const controlsLocked = watermarkLocked; const mode = controlsLocked ? 'base' : watermarkForm.mode; + const resolvedMode = mode === 'custom' && !customWatermarkAllowed + ? 'base' + : mode === 'off' && !watermarkRemovalAllowed + ? 'base' + : mode; + const customizationDisabled = controlsLocked || resolvedMode !== 'custom'; return ( <> @@ -325,12 +348,12 @@ export default function MobileBrandingPage() { padding={watermarkForm.padding} offsetX={watermarkForm.offsetX} offsetY={watermarkForm.offsetY} - previewUrl={mode === 'off' ? '' : watermarkForm.assetDataUrl || watermarkForm.assetPreviewUrl} + previewUrl={resolvedMode === 'off' ? '' : watermarkForm.assetDataUrl || watermarkForm.assetPreviewUrl} previewAlt={t('events.watermark.previewAlt', 'Watermark preview')} /> - {disabled ? ( + {!watermarkAllowed ? ( { const value = event.target.value; @@ -364,16 +387,16 @@ export default function MobileBrandingPage() { }} > - - - {mode === 'custom' && !controlsLocked ? ( + {resolvedMode === 'custom' && !controlsLocked ? ( {t('events.watermark.upload', 'Wasserzeichen hochladen')} @@ -452,7 +475,7 @@ export default function MobileBrandingPage() { setWatermarkForm((prev) => ({ ...prev, position: next }))} - disabled={controlsLocked} + disabled={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, scale: value / 100 }))} - disabled={controlsLocked} + disabled={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, opacity: value / 100 }))} - disabled={controlsLocked} + disabled={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, padding: value }))} - disabled={controlsLocked} + disabled={customizationDisabled} /> setWatermarkForm((prev) => ({ ...prev, offsetX: value }))} - disabled={controlsLocked} + disabled={customizationDisabled} suffix={t('events.watermark.offsetX', 'X-Achse')} /> setWatermarkForm((prev) => ({ ...prev, offsetY: value }))} - disabled={controlsLocked} + disabled={customizationDisabled} /> @@ -1095,15 +1118,19 @@ function extractWatermark(event: TenantEvent): WatermarkForm { function buildWatermarkPayload( form: WatermarkForm, watermarkAllowed: boolean, - brandingAllowed: boolean + brandingAllowed: boolean, + removalAllowed: boolean ): WatermarkSettings | null { - if (!watermarkAllowed) { - return { mode: 'off' }; + const customAllowed = watermarkAllowed && brandingAllowed; + let mode = form.mode; + + if (mode === 'custom' && !customAllowed) { + mode = 'base'; } - const policy = watermarkAllowed ? 'basic' : 'none'; - const desiredMode = brandingAllowed ? form.mode : 'base'; - const mode = desiredMode === 'off' && policy === 'basic' ? 'base' : desiredMode; + if (mode === 'off' && !removalAllowed) { + mode = 'base'; + } const payload: WatermarkSettings = { mode, @@ -1115,7 +1142,7 @@ function buildWatermarkPayload( offset_y: Math.max(-500, Math.min(500, Math.round(form.offsetY))), }; - if (mode === 'custom' && brandingAllowed) { + if (mode === 'custom' && customAllowed) { if (form.assetDataUrl) { payload.asset_data_url = form.assetDataUrl; } diff --git a/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php b/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php index 63a4f22..e8f42f8 100644 --- a/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php +++ b/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php @@ -5,6 +5,7 @@ use Illuminate\View\ComponentAttributeBag; $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); + $assetPath = is_array($asset ?? null) ? ($asset[0] ?? null) : $asset; @endphp @@ -17,9 +18,9 @@ - @if($asset) + @if($assetPath) - Watermark + Watermark @endif diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index 7208cd0..0f82f72 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -8,6 +8,7 @@ use App\Models\EventType; use App\Models\Package; use App\Models\PackagePurchase; use App\Models\TenantPackage; +use App\Models\WatermarkSetting; use App\Services\EventJoinTokenService; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; @@ -369,7 +370,14 @@ class EventControllerTest extends TenantTestCase { Storage::fake('public'); - config(['watermark.base.asset' => 'branding/watermarks/base-watermark.png']); + $setting = WatermarkSetting::query()->create([ + 'asset' => 'branding/watermarks/base-watermark.png', + 'position' => 'bottom-right', + 'opacity' => 0.25, + 'scale' => 0.2, + 'padding' => 16, + ]); + Storage::disk('public')->put('branding/watermarks/base-watermark.png', 'asset'); $eventType = EventType::factory()->create(); @@ -389,6 +397,83 @@ class EventControllerTest extends TenantTestCase $this->assertNotSame('', $url); $this->assertStringContainsString('/api/v1/branding/asset/branding/watermarks/base-watermark.png', $url); $this->assertStringContainsString('signature=', $url); + $this->assertSame($setting->asset, $response->json('data.settings.watermark.asset')); + } + + public function test_update_event_allows_disabling_watermark_when_removal_is_enabled(): void + { + $package = Package::factory()->create([ + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'features' => ['no_watermark'], + ]); + + $eventType = EventType::factory()->create(); + $event = Event::factory()->for($this->tenant)->create([ + 'event_type_id' => $eventType->id, + 'name' => 'Removal Allowed Event', + 'slug' => 'removal-allowed', + 'date' => now()->addDays(2), + ]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price ?? 0, + 'used_photos' => 0, + ]); + + $response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [ + 'settings' => [ + 'watermark' => [ + 'mode' => 'off', + ], + ], + ]); + + $response->assertOk(); + + $event->refresh(); + $this->assertSame('off', data_get($event->settings, 'watermark.mode')); + $this->assertTrue((bool) data_get($event->settings, 'watermark_removal_allowed')); + } + + public function test_update_event_forces_base_watermark_when_removal_is_disabled(): void + { + $package = Package::factory()->create([ + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'features' => [], + ]); + + $eventType = EventType::factory()->create(); + $event = Event::factory()->for($this->tenant)->create([ + 'event_type_id' => $eventType->id, + 'name' => 'Removal Disabled Event', + 'slug' => 'removal-disabled', + 'date' => now()->addDays(2), + ]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price ?? 0, + 'used_photos' => 0, + ]); + + $response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [ + 'settings' => [ + 'watermark' => [ + 'mode' => 'off', + ], + ], + ]); + + $response->assertOk(); + + $event->refresh(); + $this->assertSame('base', data_get($event->settings, 'watermark.mode')); + $this->assertFalse((bool) data_get($event->settings, 'watermark_removal_allowed')); } public function test_update_event_uploads_branding_logo_data_url(): void diff --git a/tests/Feature/SuperAdminAuditLogSettingsTest.php b/tests/Feature/SuperAdminAuditLogSettingsTest.php index 5aa1bfc..5df094d 100644 --- a/tests/Feature/SuperAdminAuditLogSettingsTest.php +++ b/tests/Feature/SuperAdminAuditLogSettingsTest.php @@ -4,11 +4,15 @@ namespace Tests\Feature; use App\Filament\Resources\EventResource\Pages\ManageWatermark; use App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage; +use App\Filament\SuperAdmin\Pages\WatermarkSettingsPage; use App\Models\Event; use App\Models\SuperAdminActionLog; use App\Models\User; +use App\Models\WatermarkSetting; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Tests\TestCase; @@ -49,6 +53,28 @@ class SuperAdminAuditLogSettingsTest extends TestCase ->exists()); } + public function test_base_watermark_settings_save_creates_audit_log(): void + { + Storage::fake('public'); + + $user = User::factory()->create(['role' => 'super_admin']); + + $this->bootSuperAdminPanel($user); + + $file = UploadedFile::fake()->image('watermark.png', 120, 120); + + Livewire::test(WatermarkSettingsPage::class) + ->set('asset', [$file]) + ->call('save'); + + $settings = WatermarkSetting::query()->first(); + + $this->assertNotNull($settings); + $this->assertNotNull($settings->asset); + Storage::disk('public')->assertExists($settings->asset); + + } + private function bootSuperAdminPanel(User $user): void { $panel = Filament::getPanel('superadmin');