Skip to content

Commit 321ecf4

Browse files
committed
feat(phase-28/29): tenant feature flags and user preferences
Phase 28: Tenant Feature Flags - TenantFeature model: feature key, is_enabled, config (json) - 10 built-in features: recurring_invoices, customer_portal, webhooks, 2fa, audit_log, etc. - isEnabled() with 5-minute cache (auto-invalidated on toggle) - toggle() uses updateOrCreate to avoid duplicates - TenantFeatureController: index (list with defaults), toggle, check by name - 9 feature tests: listing, defaults, toggling on/off, validation, config, cache invalidation Phase 29: User Preferences - UserPreference model: user_id + key-value pairs - 10 default preferences: timezone, date_format, time_format, language, currency_display, items_per_page, compact_mode, sidebar_collapsed, notifications_email, notifications_push - getAllForUser() merges stored values over defaults - setForUser() uses updateOrCreate for idempotent setting - UserPreferenceController: index (get all), update (patch multiple), reset (delete all) - Unknown keys silently ignored for safety - 6 feature tests: defaults, update, unknown keys, reset, user scoping, auth Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 4c9a002 commit 321ecf4

9 files changed

Lines changed: 476 additions & 0 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Models\TenantFeature;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Validation\Rule;
9+
10+
class TenantFeatureController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$tenantId = $this->tenantId($request);
15+
16+
$configured = TenantFeature::where('tenant_id', $tenantId)
17+
->get()
18+
->keyBy('feature');
19+
20+
$features = collect(TenantFeature::$availableFeatures)->map(function ($meta, $key) use ($configured) {
21+
$record = $configured->get($key);
22+
return [
23+
'feature' => $key,
24+
'description' => $meta['description'],
25+
'is_enabled' => $record ? $record->is_enabled : true,
26+
'config' => $record?->config,
27+
];
28+
})->values();
29+
30+
return $this->success($features);
31+
}
32+
33+
public function toggle(Request $request): JsonResponse
34+
{
35+
$tenantId = $this->tenantId($request);
36+
37+
$data = $request->validate([
38+
'feature' => ['required', Rule::in(array_keys(TenantFeature::$availableFeatures))],
39+
'is_enabled' => ['required', 'boolean'],
40+
'config' => ['nullable', 'array'],
41+
]);
42+
43+
$instance = TenantFeature::toggle(
44+
$tenantId,
45+
$data['feature'],
46+
$data['is_enabled'],
47+
$data['config'] ?? null
48+
);
49+
50+
return $this->success($instance);
51+
}
52+
53+
public function check(Request $request, string $feature): JsonResponse
54+
{
55+
$tenantId = $this->tenantId($request);
56+
$isEnabled = TenantFeature::isEnabled($tenantId, $feature);
57+
58+
return $this->success([
59+
'feature' => $feature,
60+
'is_enabled' => $isEnabled,
61+
]);
62+
}
63+
64+
private function tenantId(Request $request): int
65+
{
66+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
67+
}
68+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Models\UserPreference;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Validation\Rule;
9+
10+
class UserPreferenceController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$prefs = UserPreference::getAllForUser($request->user()->id);
15+
return $this->success($prefs);
16+
}
17+
18+
public function update(Request $request): JsonResponse
19+
{
20+
$allowedKeys = array_keys(UserPreference::$defaults);
21+
22+
$data = $request->validate([
23+
'preferences' => ['required', 'array'],
24+
'preferences.*' => ['nullable', 'string', 'max:255'],
25+
]);
26+
27+
$updated = [];
28+
foreach ($data['preferences'] as $key => $value) {
29+
if (! in_array($key, $allowedKeys, true)) {
30+
continue;
31+
}
32+
UserPreference::setForUser($request->user()->id, $key, (string) $value);
33+
$updated[$key] = $value;
34+
}
35+
36+
return $this->success([
37+
'updated' => $updated,
38+
'preferences' => UserPreference::getAllForUser($request->user()->id),
39+
]);
40+
}
41+
42+
public function reset(Request $request): JsonResponse
43+
{
44+
UserPreference::where('user_id', $request->user()->id)->delete();
45+
return $this->success(UserPreference::$defaults);
46+
}
47+
}

erp/app/Models/TenantFeature.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Support\Facades\Cache;
7+
8+
class TenantFeature extends Model
9+
{
10+
protected $fillable = [
11+
'tenant_id',
12+
'feature',
13+
'is_enabled',
14+
'config',
15+
];
16+
17+
protected $casts = [
18+
'is_enabled' => 'boolean',
19+
'config' => 'array',
20+
];
21+
22+
public static array $availableFeatures = [
23+
'recurring_invoices' => ['description' => 'Auto-generate invoices on a schedule'],
24+
'customer_portal' => ['description' => 'Customer self-service portal'],
25+
'webhooks' => ['description' => 'Outbound webhook integrations'],
26+
'two_factor_auth' => ['description' => 'Two-factor authentication for users'],
27+
'audit_log' => ['description' => 'Track all model changes'],
28+
'email_templates' => ['description' => 'Customise email templates'],
29+
'report_schedules' => ['description' => 'Schedule automatic report delivery'],
30+
'api_access' => ['description' => 'REST API access for external apps'],
31+
'sso' => ['description' => 'Single sign-on via SAML/OAuth'],
32+
'advanced_analytics' => ['description' => 'Enhanced analytics and reporting'],
33+
];
34+
35+
public static function isEnabled(int $tenantId, string $feature): bool
36+
{
37+
return Cache::remember("tenant_feature_{$tenantId}_{$feature}", 300, function () use ($tenantId, $feature) {
38+
$record = static::where('tenant_id', $tenantId)
39+
->where('feature', $feature)
40+
->first();
41+
42+
// Default: enabled if no explicit record (opt-in by default)
43+
return $record === null ? true : $record->is_enabled;
44+
});
45+
}
46+
47+
public static function toggle(int $tenantId, string $feature, bool $enabled, ?array $config = null): self
48+
{
49+
$instance = static::updateOrCreate(
50+
['tenant_id' => $tenantId, 'feature' => $feature],
51+
['is_enabled' => $enabled, 'config' => $config]
52+
);
53+
54+
Cache::forget("tenant_feature_{$tenantId}_{$feature}");
55+
56+
return $instance;
57+
}
58+
}

erp/app/Models/UserPreference.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class UserPreference extends Model
9+
{
10+
protected $fillable = ['user_id', 'key', 'value'];
11+
12+
public static array $defaults = [
13+
'timezone' => 'UTC',
14+
'date_format' => 'Y-m-d',
15+
'time_format' => 'H:i',
16+
'language' => 'en',
17+
'currency_display' => 'code', // code | symbol
18+
'items_per_page' => '25',
19+
'compact_mode' => 'false',
20+
'sidebar_collapsed' => 'false',
21+
'notifications_email' => 'true',
22+
'notifications_push' => 'true',
23+
];
24+
25+
public function user(): BelongsTo
26+
{
27+
return $this->belongsTo(User::class);
28+
}
29+
30+
public static function getForUser(int $userId, string $key): mixed
31+
{
32+
$pref = static::where('user_id', $userId)->where('key', $key)->first();
33+
return $pref ? $pref->value : (static::$defaults[$key] ?? null);
34+
}
35+
36+
public static function getAllForUser(int $userId): array
37+
{
38+
$stored = static::where('user_id', $userId)
39+
->get()
40+
->pluck('value', 'key')
41+
->toArray();
42+
43+
return array_merge(static::$defaults, $stored);
44+
}
45+
46+
public static function setForUser(int $userId, string $key, string $value): self
47+
{
48+
return static::updateOrCreate(
49+
['user_id' => $userId, 'key' => $key],
50+
['value' => $value]
51+
);
52+
}
53+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
Schema::create('tenant_features', function (Blueprint $table) {
11+
$table->id();
12+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
13+
$table->string('feature');
14+
$table->boolean('is_enabled')->default(true);
15+
$table->json('config')->nullable();
16+
$table->timestamps();
17+
18+
$table->unique(['tenant_id', 'feature']);
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::dropIfExists('tenant_features');
25+
}
26+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
Schema::create('user_preferences', function (Blueprint $table) {
11+
$table->id();
12+
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
13+
$table->string('key');
14+
$table->text('value')->nullable();
15+
$table->timestamps();
16+
17+
$table->unique(['user_id', 'key']);
18+
});
19+
}
20+
21+
public function down(): void
22+
{
23+
Schema::dropIfExists('user_preferences');
24+
}
25+
};

erp/routes/api.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,20 @@
356356
Route::delete('/{dashboardWidget}', [\App\Http\Controllers\Api\V1\DashboardWidgetController::class, 'destroy']);
357357
});
358358

359+
// User Preferences
360+
Route::prefix('preferences')->group(function () {
361+
Route::get('/', [\App\Http\Controllers\Api\V1\UserPreferenceController::class, 'index']);
362+
Route::put('/', [\App\Http\Controllers\Api\V1\UserPreferenceController::class, 'update']);
363+
Route::delete('/', [\App\Http\Controllers\Api\V1\UserPreferenceController::class, 'reset']);
364+
});
365+
366+
// Tenant Feature Flags
367+
Route::prefix('features')->group(function () {
368+
Route::get('/', [\App\Http\Controllers\Api\V1\TenantFeatureController::class, 'index']);
369+
Route::post('/toggle', [\App\Http\Controllers\Api\V1\TenantFeatureController::class, 'toggle']);
370+
Route::get('/{feature}/check', [\App\Http\Controllers\Api\V1\TenantFeatureController::class, 'check']);
371+
});
372+
359373
// Email Templates
360374
Route::prefix('email-templates')->group(function () {
361375
Route::get('/', [\App\Http\Controllers\Api\V1\EmailTemplateController::class, 'index']);

0 commit comments

Comments
 (0)