من التطبيق الفردي إلى عالم SaaS: رحلة تحويل مشروع لارافل
رحلة تحويل مشروع Laravel تقليدي إلى منصة برمجية كخدمة (SaaS) متعددة المستأجرين هي رحلة مليئة بالتحديات التقنية والفرص الاستثنائية. كثير من المطورين بدأوا بنظام فردي لعميل واحد، وبفضل كفاءة إطار العمل لارافل، أنهوا العمل بسرعة ورضي العميل، بل وبدأ هذا العميل في التوصية بالتطبيق للآخرين الذين يعانون من نفس المشكلة. ومع تدفق المزيد من العملاء، أصبحت صعوبة الحفاظ على عمليات نشر منفصلة لكل عميل تشكل عبئًا حقيقيًا. هنا تبدأ الحكاية، حكاية البحث عن حل لهذا "العبء السعيد". عندما تبحث عن مصطلحات مثل "Laravel multi tenancy" أو "Laravel SaaS"، قد تجد حزمًا عديدة، لكنها غالبًا ما تفرض عليك إضافة عمود `tenant_id` إلى جميع جداولك، وهو ما قد لا يكون عمليًا في مشروع قديم. في هذه المقالة، سأشارككم تجربتي العملية في هذه الرحلة، مبتعدًا عن الأسلوب الأكاديمي الجاف، ومقدمًا شرحًا عمليًا شاملًا يغطي كل التفاصيل التي تحتاجون لمعرفتها.
الكلمات المفتاحية التي سنتناولها بعمق: [تحويل مشروع Laravel إلى SaaS]، [خصائص متعددة للمستخدمين]، [هندسة متعددة المستأجرين]، [عزل البيانات]، [حزم لارافل]، [إدارة المستأجرين]، [نظام الفوترة]، [نصائح أمنية].
النقطة الفاصلة: لماذا قررت التحول إلى نموذج SaaS؟
لاحظت مع نمو قاعدة العملاء أن عملية النشر والإدارة أصبحت أكثر تعقيدًا بكثير. كل عميل جديد كان يتطلب نسخة جديدة من التطبيق، وقاعدة بيانات منفصلة، وجهدًا مستمرًا في التحديثات والصيانة. لم يكن الأمر مجرد إزعاج تقني، بل كان يقيد نمو المشروع ويحد من قدرته على التوسع. في نموذج "مستأجر واحد لكل نسخة"، يستهلك كل عميل موارد تطوير مستقلة، مما يجعل إضافة ميزات جديدة عالمية أمرًا شاقًا. البحث عن حل قادني إلى فهم نماذج SaaS المختلفة. هناك نموذج "قاعدة بيانات لكل مستأجر" (Database-per-Tenant) الذي يوفر عزلًا تامًا للبيانات، ونموذج "قاعدة بيانات مشتركة مع معرف مستأجر" (Single Database, Shared Schema) الذي يعتمد على عمود `tenant_id` لفصل البيانات. لكل منهما إيجابيات وسلبيات، وسنناقش هذا بالتفصيل لاحقًا. بالنسبة لي، كان الهدف واضحًا: نشر نسخة واحدة من التطبيق على الخادم، بحيث تتعامل هذه النسخة مع طلبات جميع العملاء، وتقوم تلقائيًا بتحميل إعدادات قاعدة البيانات والإعدادات الخاصة بكل عميل بشكل ديناميكي بناءً على طلب المستخدم.
الفصل الأول: التخطيط والاستعداد – وضع حجر الأساس
قبل كتابة سطر برمجي واحد، فإن التخطيط الجيد هو من يصنع الفرق بين النجاح والفشل. في تجربتي، وجدت أن قضاء الوقت في فهم المتطلبات واختيار الاستراتيجية المناسبة وتهيئة البيئة قد وفر عليَّ أشهرًا من التعديلات والتصحيحات لاحقًا.
اختيار استراتيجية تعدد المستأجرين المناسبة
هذا هو القرار الأهم على الإطلاق. بناءً على احتياجات مشروعي ومتطلبات العملاء، درست النماذج الثلاثة الرئيسية:
1. نموذج قاعدة بيانات منفصلة لكل مستأجر (Database-per-Tenant): في هذا النموذج، يحصل كل عميل على قاعدة بياناته الخاصة تمامًا. هذا النهج يقدم أعلى مستوى من [عزل البيانات] والأمان، حيث أن بيانات كل عميل منفصلة فيزيائيًا. كما يسهل عملية عمل نسخ احتياطية محددة لأي عميل واستعادتها دون التأثير على الآخرين. يعيب هذا النموذج تعقيد الإدارة مع زيادة عدد العملاء، وتكلفة أعلى نسبيًا.
2. نموذج قاعدة بيانات مشتركة مع مخطط مشترك (Single Database, Shared Schema): هنا، يتم استخدام قاعدة بيانات واحدة لجميع العملاء، وتمييز بيانات كل عميل من خلال عمود `tenant_id` في الجداول. هذه الطريقة أكثر كفاءة من حيث التكلفة والإدارة، خاصة في المراحل الأولى. التحدي الأكبر هنا يكمن في ضرورة التأكد من أن كل استعلام يقوم بإضافة شرط `WHERE tenant_id = X` لتجنب تسريب البيانات بين العملاء.
3. نموذج قاعدة بيانات مشتركة بمخططات منفصلة (Single Database, Separate Schemas): وهو حل وسط، حيث تستخدم قاعدة بيانات واحدة، ولكن لكل عميل "مخطط" (Schema) خاص به داخل نفس القاعدة. هذا يوفر درجة جيدة من العزل مع تقليل تعقيد إدارة قواعد بيانات متعددة.
في تجربتي، اخترت النموذج الأول "قاعدة بيانات لكل مستأجر" بسبب طبيعة البيانات الحساسة التي يتعامل معها التطبيق، ورغبة العملاء في هذا المستوى من العزل. كان القرار مبنيًا على معايير واضحة شملت قابلية التوسع، وعزل الأداء، وسهولة التخصيص، والتعقيد التشغيلي كما هو موضح في أدلة التصميم.
تهيئة بيئة التطوير والهيكل العام
بعد اختيار الاستراتيجية، بدأت في تهيئة هيكل المشروع. قمت بإنشاء مجلدات جديدة لتنظيم الكود الخاص بالمستأجرين، مثل `app/Tenants` و`database/migrations/tenant`. كما أنشأت نموذج `Tenant` الأساسي في التطبيق، والذي سيدير معلومات كل عميل، مثل اسمه، ونطاقه (Domain)، واسم قاعدة البيانات الخاصة به، وحالة الاشتراك. في هذه المرحلة، كان من المهم أيضًا البدء في التفكير في كيفية تمييز طلبات كل عميل. الخيارات المتاحة كانت استخدام النطاقات الفرعية (subdomains) مثل `client1.myapp.com`، أو النطاقات المخصصة (custom domains)، أو حتى بادئة في الرابط (URL path). اخترت النطاقات الفرعية لسهولة تنفيذها وكفاءتها.
الفصل الثاني: التنفيذ العملي – من النظرية إلى الكود
هذا هو الجزء الأكثر تشويقًا، حيث تبدأ الأفكار في التحول إلى واقع ملموس. سأريك هنا الخطوات العملية التي اتبعتها، مع أمثلة برمجية مباشرة.
كيفية اكتشاف المستأجر ديناميكيًا
أول عقبة تقنية واجهتني هي: كيف يعرف التطبيق أي عميل يطلب الصفحة؟ الجواب يتم عبر خطوتين رئيسيتين: التعامل مع طلبات HTTP ومع أوامر Artisan.
لطلبات HTTP: الحل الأمثل هو استخدام Middleware. قمت بإنشاء Middleware جديد أسميته `IdentifyTenant`، مهمته استخراج النطاق الفرعي من عنوان الطلب، والبحث عنه في جدول المستأجرين، ثم تعيين إعدادات هذا المستأجر للتطبيق.
لنلقِ نظرة على مثال عملي:
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Config;
class IdentifyTenant
{
public function handle($request, Closure $next)
{
$host = $request->getHost(); // يحصل على client1.myapp.com
$subdomain = explode('.', $host)[0]; // يستخرج 'client1'
$tenant = Tenant::where('subdomain', $subdomain)->first();
if (!$tenant) {
abort(404, 'Tenant not found.');
}
// تخزين بيانات المستأجر الحالي في الـ Application Container
app()->instance('currentTenant', $tenant);
// تبديل إعدادات قاعدة البيانات بشكل ديناميكي
Config::set('database.connections.tenant.database', $tenant->database_name);
DB::purge('tenant');
DB::reconnect('tenant');
return $next($request);
}
}
بعد ذلك، قمت بتسجيل هذا الـ Middleware في `app/Http/Kernel.php` ضمن مصفوفة `$routeMiddleware` لتتمكن من استدعائه بسهولة على أي route.
لأوامر Artisan (CLI): كيف نحدد المستأجر عند تشغيل أمر مثل `php artisan migrate`؟ الحل الذي وجدته أنيقًا هو إضافة خيار `--tenant` إلى جميع أوامر Artisan. يمكن تحقيق هذا بتعديل ملف `app/Console/Kernel.php`. الفكرة مستوحاة من كيفية عمل خيار `--env` داخليًا في لارافل.
protected function getArtisan()
{
$artisan = parent::getArtisan();
$artisan->addCommand(new YourCustomCommand); // لأوامرك المخصصة إذا needed
// إضافة خيار --tenant عالميًا
$artisan->setName('artisan')->setVersion(Application::VERSION);
$artisan->getDefinition()->addOption(
new InputOption('--tenant', null, InputOption::VALUE_OPTIONAL, 'The tenant the command should be run for')
);
return $artisan;
}
ثم يمكنك كتابة كود في `TenantDetector` لقراءة هذا الخيار وتحديد المستأجر عند تنفيذ الأوامر.
تحميل الإعدادات والتكوينات الديناميكية
الآن بعد أن أصبح التطبيق يعرف هوية المستأجر، كيف يقوم بتحميل إعداداته الخاصة (مثل اتصال قاعدة البيانات) دون التأثير على الآخرين؟ الحل الأمثل الذي وجدته بعد تجربة عدة طرق (مثل التعديل في Service Providers أو الـ Middleware) هو استخدام "Bootstrapper" مخصص. فئة `Bootstrap\LoadConfiguration` في لارافل هي المسؤولة عن تحميل الإعدادات عند بدء التشغيل. يمكننا إضافة فئة مخصصة `LoadTenantConfiguration` إلى مصفوفة الـ bootstrappers في كل من `Http/Kernel.php` و `Console/Kernel.php`، مباشرة بعد `LoadConfiguration` الأصلية.
هذا يضمن أن تحميل إعدادات المستأجر سيحدث في بداية دورة حياة التطبيق، قبل تنفيذ أي كود آخر.
// في App\Http\Kernel و App\Console\Kernel
protected $bootstrappers = [
\Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
\App\Bootstrap\LoadTenantConfiguration::class, // خصائص المستأجرين
// ... باقي الـ bootstrappers
];
ثم نقوم بتنفيذ الفئة `LoadTenantConfiguration`:
namespace App\Bootstrap;
use Illuminate\Contracts\Foundation\Application;
class LoadTenantConfiguration
{
public function bootstrap(Application $app)
{
// إذا كان التطبيق يقوم بتخزين الإعدادات في cache، نتخطى التحميل الديناميكي
if ($app->configurationIsCached()) {
return;
}
$tenant = (new TenantDetector)->detect($app);
if ($tenant) {
$tenantConnection = $this->getTenantConnection($tenant);
// تغيير إعدادات الاتصال بقاعدة البيانات ديناميكيًا
config(['database.connections.tenant.database' => $tenantConnection]);
}
}
protected function getTenantConnection($tenant)
{
// هنا يمكنك إرجاع اسم قاعدة بيانات المستأجر
// في حالتي، كان النمط ثابتًا، مثل: 'appname_' . $tenant->id
return 'myapp_' . $tenant->id;
}
}
ضمان عزل البيانات التام (الجزء الأكثر حساسية)
هنا يكمن جوهر الأمان في تطبيقات SaaS المتعددة. الخطأ في هذه النقطة قد يعني تسريب بيانات بين العملاء. لضمان العزل، اتبعت استراتيجيتين:
1. استخدام Global Scopes (لنماذج قاعدة البيانات المشتركة): إذا كنت تستخدم نموذج قاعدة البيانات المشتركة مع `tenant_id`، فإن Global Scope هو أفضل صديق لك. يقوم تلقائيًا بإضافة شرط `where('tenant_id', auth()->user()->tenant_id)` إلى كل استعلام على النموذج.
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$tenantId = auth()->check() ? auth()->user()->tenant_id : null;
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
}
}
ثم تقوم بتطبيق هذا الـ Scope على جميع النماذج التي تريد عزل بياناتها:
namespace App\Models;
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected static function booted()
{
static::addGlobalScope(new TenantScope);
}
}
2. التحقق اليدوي من الملكية: الـ Global Scopes رائع، ولكن في بعض الحالات (مثل لوحة تحكم المسؤول)، قد تحتاج إلى تعطيله. في هذه الحالات، يجب عليك دائمًا التحقق يدويًا من أن السجل الذي يحاول المستخدم الوصول إليه أو تعديله ينتمي فعليًا إلى المستأجر الخاص به.
public function update(Request $request, Post $post)
{
// تحقق يدوي من ملكية السجل
if ($post->tenant_id !== auth()->user()->tenant_id) {
abort(403, 'Unauthorized action.');
}
// ... منطق التحديث
}
إدارة المهام المجدولة وقوائم الانتظار (Queues & Scheduling)
كيف تجعل المهام الخلفية (Background Jobs) والمهام المجدولة (Scheduled Tasks) تعمل في سياق المستأجر الصحيح؟ هذا سؤال مهم لأن هذه المهام لا ترتبط بطلب HTTP مباشر.
للمهام المجدولة (Scheduling): عندما تقوم بتشغيل `php artisan schedule:run`، فإنه يعمل في سياق عام. لحل هذه المشكلة، قمت بتعديل ملف `App\Console\Kernel` لتشغيل الأمر الخاص بكل مستأجر على حدة.
protected function schedule(Schedule $schedule)
{
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
$schedule->command('your:tenant-specific-command')
->daily()
->sendOutputTo(storage_path("logs/tenants/{$tenant->id}.log"))
->before(function () use ($tenant) {
// تعيين المستأجر الحالي قبل تنفيذ المهمة
app()->instance('currentTenant', $tenant);
Config::set('database.connections.tenant.database', $tenant->database_name);
DB::purge('tenant');
DB::reconnect('tenant');
});
}
}
لقوائم الانتظار (Queues): نفس المنطق ينطبق هنا. عند إرسال مهمة إلى قائمة الانتظار، يجب عليك تضمين معرف المستأجر معها. ثم، في معالج المهمة (Job Handler)، يمكنك استخدام هذا المعرف لتعيين إعدادات المستأجر الصحيحة قبل التنفيذ.
class ProcessCustomerData implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tenantId;
public function __construct($tenantId)
{
$this->tenantId = $tenantId;
}
public function handle()
{
$tenant = Tenant::find($this->tenantId);
// تعيين إعدادات المستأجر
Config::set('database.connections.tenant.database', $tenant->database_name);
DB::purge('tenant');
DB::reconnect('tenant');
// ... منطق المهمة
}
}
الفصل الثالث: إضافة الوظائف الأساسية لمنصة SaaS
بعد بناء الهيكل التقني المتين، حان الوقت لإضافة الوظائف التي تجعل المنصة كاملة وجذابة للعملاء.
نظام التسجيل والاشتراك (Onboarding)
يجب أن تكون عملية انضمام عميل جديد إلى المنصة سلسة وآلية قدر الإمكان. قمت بإنشاء Controller خاص بعملية التسجيل، يقوم بما يلي:
1. استقبال بيانات العميل الجديد (اسم الشركة، البريد الإلكتروني، كلمة المرور، إلخ). 2. إنشاء سجل جديد في جدول `tenants`. 3. إنشاء قاعدة البيانات الخاصة بهذا المستأجر. يمكن فعل هذا عن طريق نسخ قاعدة بيانات "قالب" فارغة، أو تشغيل مجموعة من Migrations محددة لتهيئة الجداول. 4. إرسال بريد إلكتروني ترحيبي يحتوي على رابط لتسجيل الدخول.
public function register(TenantRegistrationRequest $request)
{
DB::transaction(function () use ($request) {
// إنشاء المستأجر
$tenant = Tenant::create([
'name' => $request->company_name,
'subdomain' => $request->subdomain,
'database_name' => 'myapp_' . Str::random(10), // أو أي توليد فريد
'is_active' => true,
]);
// إنشاء قاعدة البيانات وتشغيل الـ Migrations
$this->createTenantDatabase($tenant->database_name);
$this->runTenantMigrations($tenant);
// إنشاء المستخدم المسؤول الأول للمستأجر
$this->createTenantAdminUser($tenant, $request->email, $request->password);
// إرسال بريد الترحيل
Mail::to($request->email)->send(new TenantWelcomeMail($tenant));
});
return redirect()->route('registration.success');
}
دمج نظام الفوترة (Billing) باستخدام Laravel Cashier
لجعل النظام قابلاً للربح، يجب دمجه مع بوابة دفع. Laravel Cashier يوفر واجهة رائعة للتعامل مع Stripe، مما يسهل عملية إدارة الاشتراكات والفواتير. بعد تثبيت Cashier وتكوينه، قمت بما يلي:
1. إضافة السمات المرتبطة بالدفع إلى نموذج `Tenant` (مثل `stripe_id`، `pm_type`، `pm_last_four`، `trial_ends_at`). 2. إنشاء صفحة لاختيار خطة الاشتراك أثناء عملية التسجيل. 3. توجيه العميل إلى صفحة الدفع التي يوفرها Cashier. 4. التعامل مع Webhooks من Stripe لتحديث حالة الاشتراك (عند التجديد، الإلغاء، فشل الدفع) تلقائيًا.
// في TenantController
public function subscribe(Request $request, $planId)
{
$tenant = app('currentTenant');
$plan = Plan::find($planId);
// إنشاء اشتراك في Stripe
$subscription = $tenant->newSubscription('default', $plan->stripe_price_id)
->trialDays(14) // فترة تجريبية
->create($request->paymentMethodId);
// تحديث حالة المستأجر
$tenant->update(['subscription_plan_id' => $plan->id]);
return redirect()->route('tenant.dashboard')->with('success', 'Subscription successful!');
}
بناء واجهات متعددة: لوحة التحكم العامة ولوحة تحكم المستأجر
من المهم الفصل بوضوح بين:
الواجهة المركزية (Central App): وهي الموقع الرئيسي (`myapp.com`)، ويحتوي على صفحة الهبوط (Landing Page)، وصفحات التسعير، والتسجيل، والدعم الفني، ولوحة تحكم المسؤول العام (Super Admin) الذي يمكنه رؤية جميع المستأجرين وإدارتهم.
واجهة المستأجر (Tenant App): وهي التطبيق الفعلي الذي يستخدمه عملاؤك (مثل `client1.myapp.com`). هذه الواجهة معزولة تمامًا عن المستأجرين الآخرين ويجب أن تظهر كما لو كانت التطبيق الوحيد.
في تجربتي، قمت باستخدام حزمة Nova Admin لبناء لوحة تحكم المسؤول العام بسرعة، بينما استخدمت Blade مع Tailwind CSS لبناء واجهة المستأجر لتكون خفيفة وسريعة وقابلة للتخصيص.
الفصل الرابع: استخدام الحزم الجاهزة – توفير الوقت والجهد
بينما قمت ببناء النظام يدويًا للتعلم والتحكم الكامل، فإنه لا يجب عليك دائمًا إعادة اختراع العجلة. هناك حزم مذهلة يمكنها أن تقدم لك بداية قوية وتوفر عليك مئات الساعات من التطوير.
حزمة Tenancy for Laravel
هذه الحزمة هي واحدة من أشهر الحزم في هذا المجال. تدعم نموذج "قاعدة بيانات لكل مستأجر" بشكل كامل، وتوفر حلاً شاملاً لإدارة دورة حياة المستأجر، من الإنشاء إلى الحذف. تأتي مع وثائق ممتازة ومجتمع نشط. كما أنها تقدم "SaaS Boilerplate" جاهزًا يتضمن نظام تسجيل، ودمج مع Cashier، ولوحة إدارة Nova، مقابل رسوم لمرة واحدة، مما يوفر لك نقطة بدء قوية جدًا.
حزمة Spatie Laravel Multi-Tenancy
هذه الحزمة رائعة أيضًا، خاصة إذا كنت تفضل نموذج قاعدة البيانات المشتركة. تركيزها الأساسي هو على استخدام `tenant_id` لفصل البيانات، وتجعل عملية إضافة الـ Global Scopes وتبديل إعدادات قاعدة البيانات أمرًا في غاية السهولة. الحزمة خفيفة الوزن وسهلة الإعداد إذا كنت تفهم مبادئ تعدد المستأجرين جيدًا.
الفصل الخامس: التحديات الشائعة والحلول التي توصلت إليها
لا تخلو أي رحلة تقنية من العقبات. هذه أبرز التحديات التي واجهتني وكيف تعاملت معها.
تحدي أداء التطبيق مع زيادة عدد المستأجرين
مع إضافة المئات من قواعد البيانات، قد تظهر مشاكل في الأداء. الحل كان في الاستثمار في آليات التخزين المؤقت (Caching) الفعالة. استخدمت Redis مع إضافة بادئة للمفتاح (key prefix) خاصة بكل مستأجر لضمان عدم تداخل البيانات. أيضًا، تأكد من فهرسة الحقول المستخدمة بكثرة في الاستعلامات، خاصة حقل `tenant_id` في النماذج المشتركة.
تحدي التعامل مع البيانات الخاصة بكل مستأجر
كيف تتعامل مع الملفات التي يرفعها المستأجرون؟ الحل هو استخدام أنظمة تخزين سحابية مثل Amazon S3، وإنشاء "مجلد" منفصل لكل مستأجر داخل الـ Bucket. هذا يضمن عزل الملفات ويبسط عملية النسخ الاحتياطي والاستعادة.
// مثال على رفع ملف خاص بمستأجر
$path = $request->file('avatar')->store(
"tenants/{$tenant->id}/avatars", 's3'
);
تحدي المراقبة والتنقيح (Monitoring & Debugging)
مع تعدد المستأجرين، يصبح من الصعب تتبع الأخطاء. الحل هو تطبيق "tenant-aware logging"، حيث يتم حفظ سجلات كل مستأجر في ملف منفصل. هذا يجعل عملية تتبع الخطأ أسهل بكثير.
// في App\Logging\TenantAwareLogger
protected function getTenantLogFilename()
{
$tenantId = app('currentTenant')?->id ?? 'central';
return "logs/tenants/{$tenantId}.log";
}
خاتمة: رحلة مليئة بالتحديات والفرص
رحلة تحويل مشروع Laravel قديم إلى منصة SaaS متعددة المستأجرين كانت واحدة من أكثر التجارب التقنية إثراءً في مسيرتي. لقد علمتني كيفية التفكير في القابلية للتوسع، والأمان بمستوى أعمق، وفهم دورة حياة البرمجيات بشكل كلي. النتيجة لم تكن مجرد تطبيق تقني، بل تحول كامل في نموذج العمل، من مشروع يعتمد على العملاء بشكل فردي إلى منصة قادرة على خدمة آلاف العملاء بشكل تلقائي ومربح.
إذا كنت تفكر في خوض هذه الرحلة، فابدأ بالتخطيط الجيد، واختر الاستراتيجية التي تناسبك، ولا تتردد في استخدام الحزم الجاهزة لتوفير الوقت. تذكر أن الأمان هو الأولوية القصوى، وأن بناء نظام مرن وقابل للتوسع سيمكنك من مواجهة النمو المتوقع في المستقبل. جرب، اخطئ، تعلم، وستصل إلى هدفك في النهاية. شاركنا في التعليقات بأهم التحديات التي تواجهك أو استفساراتك حول التحول إلى SaaS، وسأكون سعيدًا بمساعدتك.
