كيف بنيت هذا الموقع، والنظام الذي يدير العمل خلفه.
بدأ صفحةً تعرّف بي. واليوم هو خمس خدمات على قاعدة بيانات واحدة، تأخذ العميل من أول طلب إلى عقد موقّع وفاتورة مدفوعة، وهو المكان الذي أجرّب فيه كل شيء قبل أن أثق به.
هذه جولة كاملة: الكلام البسيط أولاً، ثم مخططات تستطيع استكشافها، والكود الحقيقي خلف كل جزء.
بدأ صفحةً تعرّف بي، وصار النظام الذي أجرّب عليه كل شيء.
في يوليو ٢٠٢٦ كان هذا الموقع صفحة واحدة: من أنا، وماذا أعمل، وأين عملت. ثم احتجت مكاناً أستقبل فيه طلبات العملاء، فأضفت متجراً للخدمات وحجزاً للمواعيد. ثم احتجت أن أرسل عروض أسعار، فبنيتها. ثم العقود، والفواتير، والدفع الإلكتروني، والبريد، والمدونة. ومع كل حاجة حقيقية كبر الموقع، حتى صار نظاماً صغيراً يدير عملاً كاملاً.
وهنا تغيّرت وظيفته، فصار بيئتي التجريبية: كل ما أتعلمه أطبقه هنا أولاً، على نظام حي يستقبل طلبات حقيقية ومربوط ببوابة دفع حقيقية. أقيس ثباته وفائدته وأمانه، وبعدها فقط أقرر هل يستحق أن ينتقل إلى أنظمة الشركات. الخطأ هنا يكلفني أنا، أما في نظام شركة فيكلف موظفين وعملاء ومالاً.
اختر أي مربع لترى ما يفعله وبماذا يرتبط. اسحب جانبياً لترى المخطط كاملاً.
أتعلّم
نمط أو أداة أو طريقة أفضل لعمل شيء ما. قد تأتي من دورة، أو من خطأ، أو من مشكلة في العمل.
أطبّق هنا أولاً
تُبنى داخل هذا النظام فعلاً، بطلبات حقيقية وربط حقيقي، وليست عرضاً تجريبياً يعمل على جهازي فقط.
أقيس
السرعة في متصفحات الزوار الحقيقيين، والأخطاء، ونتائج الاختبارات، وهل وفّرت وقتاً فعلاً. الانطباع ليس قياساً.
أقرّر
أعتمدها، أو أعدّلها، أو أرفضها. وكل قرار يُكتب مع سببه، حتى لا أجرّب الشيء نفسه مرتين دون قصد.
أنقل للشركات
لا ينتقل إلى أنظمة الشركات إلا ما صمد هنا، وحتى حينها يبدأ على نطاق صغير داخل بيئة الشركة نفسها.
رفض حقيقي
جرّبت الإصدارات الرئيسية التالية لإطار عمل الموقع ومولّد المتجر. عملت كلها، لكن كود JavaScript الأساسي للموقع زاد ١٧٪، فأبقيت الإصدارات الحالية وحددت موعداً لإعادة التقييم.
هذه الصفحة جولة كاملة: ماذا يفعل النظام، وكيف تتحدث أجزاؤه مع بعضها، والكود خلف الأجزاء التي أعتز بها. كل قسم يبدأ بكلام بسيط، ومن يريد التفاصيل الهندسية يفتح «التفاصيل التقنية»، أو يفتحها كلها مرة واحدة من القائمة.
صناديق الكود مقتطفات حقيقية من النظام، مختصرة، وغيّرت فيها الأسماء حيث تكشف شيئاً خاصاً. وتركت خارج الصفحة عمداً طريقة حماية اللوحة الخاصة، وكل مفتاح وعنوان وبيانات أي عميل.
المعمارية
خمس خدمات، وقاعدة بيانات واحدة، وكل ما تتصل به.
يعمل النظام على شبكة Cloudflare كخمس خدمات صغيرة: الموقع، والمتجر، والمدونة، والواجهة البرمجية، ولوحة التشغيل الخاصة بي. لكل خدمة مهمة واحدة وتُنشر وحدها، وكلها تقرأ وتكتب في قاعدة بيانات واحدة، فلا توجد إلا نسخة واحدة من الحقيقة.
اختر أي مربع لترى ما يفعله وبماذا يرتبط. اسحب جانبياً لترى المخطط كاملاً.
الزوار
كل من يقرأ الموقع أو المتجر أو المدونة. لا حساب، ولا جدار من نوافذ ملفات الارتباط.
العملاء
من أعمل معهم. تصلهم روابط خاصة: عرض سعر للموافقة، وعقد للتوقيع، وفاتورة للدفع، وصفحة واحدة تجمع ذلك كله.
+التفاصيل التقنية
كل رابط خاص لا يمكن تخمينه، وكل ما خلفه محصور في العميل صاحب الرابط، ولا يعتمد على أي شيء مكتوب في العنوان.
أنا
أدير كل شيء من لوحة خاصة واحدة، على الكمبيوتر أو كتطبيق على جوالي.
الموقع
هذا الموقع: لغتان، وخمسة عشر مشروعاً، والشعار ثلاثي الأبعاد، وهذه الصفحة.
+التفاصيل التقنية
تصدير ثابت من Next.js 15، مبني مسبقاً بالإنجليزية والعربية ويُقدَّم كملفات ثابتة من Worker. لا يعمل أي كود خادم عند فتح الصفحة.
المتجر
متجر الخدمات: اختر خدمة، واحجز موعداً، أو اطلب عرض سعر، أو ادفع.
+التفاصيل التقنية
صفحات Astro ثابتة تُبنى من الكتالوج في قاعدة البيانات، مع طبقة حية صغيرة تسأل الواجهة البرمجية في كل زيارة كيف يُباع كل منتج الآن.
المدونة
مقالات بالعربية والإنجليزية، تُنشر فوراً من اللوحة أو تلقائياً من قائمة انتظار.
+التفاصيل التقنية
Worker مبني على Hono يعرض كل مقال من قاعدة البيانات عند الطلب ويخزنه مؤقتاً على الحافة. وخلاصة RSS وخريطة الموقع والبيانات المنظمة تأتي من نفس السجلات.
لوحة التشغيل
مكتبي الخلفي: أربع عشرة شاشة للبيع، والتنفيذ، والمحتوى، والنظام نفسه.
+التفاصيل التقنية
Hono على Worker، وصفحات HTML تُبنى على الخادم مباشرة من قاعدة البيانات، و١٢٤ مساراً بمكتبتين فقط، خلف بوابة هوية. وتُثبَّت كتطبيق بإشعارات فورية.
محركات البحث والذكاء الاصطناعي
Google وBing ومساعدات الذكاء الاصطناعي. تصلها خريطة موقع باللغتين، وبيانات منظمة، وملف llms.txt، وإشعار فوري عند نشر أي مقال جديد.
الواجهة البرمجية
غرفة المحركات: الطلبات، والمواعيد، والعروض، والعقود، والفواتير، والمدفوعات، والبريد، والتذكيرات.
+التفاصيل التقنية
Worker واحد بثلاثة مداخل: ٢٢ مساراً، ومهمة مجدولة كل خمس دقائق من ١٣ خطوة مستقلة، ومعالج للبريد الوارد. واعتماد تشغيلي على مكتبتين فقط.
قاعدة بيانات واحدة
تتشاركها الخدمات الخمس، ولا تحتفظ أي خدمة بنسختها الخاصة، فتكون حالة أي عميل حقيقة في مكان واحد، لا شيئاً يُجمع من ملفات ورسائل متفرقة.
+التفاصيل التقنية
Cloudflare D1 (SQLite): ٣٠ جدولاً، و٥٥ فهرساً، و٧ قيود تلقائية (triggers)، بُنيت عبر ٣١ هجرة مرتبة. ستة منها تجعل جداول الأدلة للإضافة فقط، وواحد يقفل العقود الموقعة.
Tap
الدفع الإلكتروني عبر صفحة الدفع المستضافة لدى Tap، ويشمل Apple Pay. بيانات البطاقة لا تمر بنظامي أبداً.
+التفاصيل التقنية
تُنشأ عملية الدفع لحظة ضغط العميل على زر الدفع. ورسائل الدفع يُتحقق منها قبل كتابة أي شيء، ومنطق مطابقة واحد تتشاركه رسالة الدفع وصفحة العودة والمهمة المجدولة.
البريد
كل بريد يرسله النظام، وكل بريد يصل إلى النطاق ويستقر في صندوق البريد داخل لوحتي.
+التفاصيل التقنية
الصادر عبر Resend، ويُكتب في سجل الإشعارات قبل إرساله. والوارد عبر Cloudflare Email Routing إلى معالج البريد في الواجهة البرمجية، فيُقرأ ويُرتَّب في محادثات ويُحفظ.
Google
تقويمي، وما يقوله Google عن الموقع، دون أن أغادر اللوحة.
+التفاصيل التقنية
المواعيد المؤكدة تُنسخ إلى تقويم Google. وأرقام Search Console وGA4 تُسحب يومياً إلى قاعدة البيانات وتظهر في شاشة مستقلة.
LinkedIn
ضغطة واحدة في اللوحة تشارك المقال المنشور على حسابي في LinkedIn.
GitHub Actions
من هنا يُختبر كل تعديل ويُنشر، وتُنسخ قاعدة البيانات احتياطياً كل ليلة، ومراقب خارجي يفحص الموقع كل ٣٠ دقيقة. والخط المتقطع هو زر النشر في المتجر، الذي يبدأ إعادة البناء.
مسارات الواجهة البرمجية
٢٢
مسارات اللوحة
١٢٤
وحدات مشتركة
٢٠
جداول قاعدة البيانات
٣٠
هجرات مرتبة
٣١
قيود حماية تلقائية
٧
+التفاصيل التقنية
عشرون وحدة TypeScript مشتركة تحمل كل قاعدة يجب أن تتفق عليها الواجهة البرمجية واللوحة: ختم المستندات، والأرقام المرجعية القصيرة، وتوقيت الرياض، وتعارض المواعيد، وطابور البريد، وعميل الدفع. كل قاعدة مكتوبة مرة واحدة، فلا تختلف الخدمتان أبداً.
وتوجد قاعدة بيانات ثانية فيها الهيكل فقط، لغرض واحد: كل هجرة تُجرَّب عليها قبل أن تلمس قاعدة الإنتاج.
حالات الاستخدام
خمسة أنواع من المستخدمين، وما يستطيع كل منهم فعله.
أسهل طريقة لفهم أي نظام هي من خلال من يستخدمه. واثنان من الخمسة ليسوا بشراً أصلاً: الساعة التي تشغّل المهام المجدولة، ومحركات البحث والذكاء الاصطناعي التي تقرأ الموقع.
اختر أي مربع لترى ما يفعله وبماذا يرتبط. اسحب جانبياً لترى المخطط كاملاً.
الزائر
يقرأ ويتصفح، وحين يكون جاهزاً يحجز موعداً أو يطلب عرض سعر. لا شيء يطلب منه التسجيل.
العميل
يوافق على عرض السعر، ويوقّع العقد، ويدفع الفاتورة أو قسطاً منها، ويرى كل ما يخص عملنا في صفحة خاصة واحدة.
محركات البحث والذكاء الاصطناعي
تقرأ الصفحات باللغتين، والبيانات المنظمة، وملف llms.txt، لتفهم من أنا وماذا بنيت.
أنا
أدير البيع، والتنفيذ، والبريد، والمحتوى، والأرقام من لوحة واحدة، على الكمبيوتر أو الجوال.
الساعة
كل خمس دقائق يستيقظ النظام وينجز ما لا ينبغي أن يتذكره أحد: التذكيرات، وإعادة المحاولة، والمطابقة، والفوترة، والنشر. وكل ليلة ينسخ قاعدة البيانات احتياطياً.
قراءة الموقع والمدونة
صفحات ثابتة بلغتين، ومقالات تُعرض مباشرة من قاعدة البيانات.
حجز موعد أو طلب عرض
مواعيد متاحة فعلاً فقط، ونموذج لا يمكن أن ينشئ طلباً مكرراً، ورد خلال يوم عمل واحد.
الدفع الإلكتروني
على صفحة Tap المستضافة، ويشمل Apple Pay، لشراء مباشر أو عرض سعر محدد أو قسط واحد.
كل بريد يصل إلى النطاق يظهر كمحادثة، والرد يخرج من نفس الشاشة.
نشر المقالات ومشاركتها
النشر يدوياً أو من قائمة انتظار، ثم المشاركة على LinkedIn بضغطة. والخط المتقطع هو القائمة التي تنشر وحدها.
متابعة الأرقام
ما ينتظرني اليوم، والمبالغ المحصلة، ومسار التحويل من الطلب إلى العقد، وما يراه Google.
التذكير والإعادة والمطابقة
تذكيرات قبل المواعيد، وإعادة إرسال للبريد المتعثر، ومطابقة المدفوعات بالطلبات، كل خمس دقائق.
النسخ الاحتياطي كل ليلة
قاعدة البيانات كاملة، بعد إزالة أي بيانات اعتماد، وتُحفظ ١٤ ليلة.
المسار التجاري
من أول طلب، إلى عقد موقّع، إلى فاتورة مدفوعة.
هذا قلب النظام. كل مرحلة تُنشئ المرحلة التي بعدها، فتكون حالة أي عميل حقيقة مسجلة في مكان واحد. تنقّل بين الخطوات واحدة تلو الأخرى.
يصل طلب
يختار الزائر خدمة من المتجر ويرسل طلباً أو يحجز موعداً. يُنشأ الطلب، ويصل العميل إشعار بالاستلام، ويظهر تنبيه على جوالي.
+التفاصيل التقنية
دفعة ذرية واحدة تكتب العميل والطلب ورقمه المرجعي القصير والرسائل المنتظرة. والنموذج يرسل مفتاحاً لكل محاولة، فإعادة الإرسال بعد ضياع الرد تعيد الطلب نفسه ولا تنشئ طلباً ثانياً.
// One key per booking attempt, created when the form opens and sent with// every retry. A retry after a lost answer gets the same order back, never a// second one. The key is retired only when the attempt is really over.constres = awaitfetch(`${apiBase}/api/orders`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ ...body, attemptKey }),});constj: any = awaitres.json().catch(() => ({}));// Nothing was charged, so the next press is a new attempt, not a retry.if (j.error === 'charge_create_failed' || j.error === 'attempt_closed') attemptKey = '';if (!res.ok) thrownewError(j.error || 'error');// A paid product answers with the payment page, also on a retry.if (j.redirectUrl) { location.assign(j.redirectUrl); return; }msgEl.textContent = en() ? 'Request received. I will contact you within one business day.' : 'تم استلام طلبك. سأتواصل معك خلال يوم عمل واحد.';form.reset();attemptKey = ''; // the next submission is a genuinely new booking
نتحدث، وأسجل المبلغ المتفق عليه
أقرأ وصف الطلب، وأتواصل مع العميل من بطاقة الطلب عبر واتساب أو الهاتف أو البريد، ونتفق على النطاق، وأسجل المبلغ المتفق عليه. وكل تغيير في الحالة يرسل للعميل تحديثاً قصيراً، مرة واحدة لكل حالة.
+التفاصيل التقنية
تحديث الحالة وحجز الرسالة المشروط يعملان في دفعة واحدة، بمفتاح يجمع الطلب والحالة. فتغيير الحالة ذهاباً وإياباً لا يرسل الرسالة نفسها مرتين.
يُكتب عرض السعر ويُختم
ضغطة واحدة تحوّل الطلب إلى مسودة عرض، فيها حقائق الطلب الفعلية وفراغات للنطاق والاستثناءات والمدة. ولا يُنشر العرض ما دام فيه فراغ واحد. والنشر يختمه.
+التفاصيل التقنية
الختم بصمة SHA-256 للنص بالضبط مع رقم نسخته. وجملة النشر لا تُنفَّذ إلا إذا كان النص ما زال هو النص الذي حُسبت بصمته، فلا يستطيع أحد الموافقة على نسخة لم تُنشر.
// Publishing seals the proposal with a SHA-256 hash of its exact text and// version. The update only applies if the text is still the text that was// hashed, so nobody can approve a version that was never published.constgap = unfinished(String(p.body_md));if (gap) returnflash(c, "e", gap); // a [[blank]] left in the text stops publishingconsthash = awaitdocumentSeal(p.body_md, p.doc_version);constr = awaitc.env.DB.prepare("UPDATE proposals SET status='published', content_hash=?, published_at=?, updated_at=? WHERE slug=? AND status='draft' AND body_md=?",).bind(hash, nowIso(), nowIso(), slug, p.body_md).run();if (!r.meta.changes) returnflash(c, "e", "تغيّر العرض أثناء النشر، افتح المسودة وأعد المحاولة");awaitaudit(c.env, actor, "proposal.publish", "proposal", slug, `sealed ${hash.slice(0, 12)}`);
العميل يوافق على النسخة بعينها
يفتح العميل صفحة خاصة ويوافق، أو يطلب تعديلات. وتُسجَّل الموافقة على النسخة التي قرأها فعلاً، ولا يمكن تعديلها أو حذفها أبداً.
+التفاصيل التقنية
سجلات الموافقة للإضافة فقط: قيود تلقائية في قاعدة البيانات تُلغي أي تعديل أو حذف. وإذا تغيّر العرض لاحقاً، تظهر الموافقات القديمة على أنها لنسخة سابقة.
يتحول إلى عقد
من العرض المعتمد، تفتح ضغطة واحدة عقداً معبأً بتفاصيله. أكتب البنود والتواريخ، وأحدد إن كان اشتراكاً شهرياً، ثم أختمه وأرسله للتوقيع الإلكتروني.
+التفاصيل التقنية
تعديل عقد سبق إرساله يعيده مسودة، لأن الختم يخص نصاً واحداً بعينه. والعقد الموقع لا يُعدَّل أبداً: التعديل نسخة جديدة يجب توقيعها من جديد، وترتبط بالعقد الذي تحل محله.
العميل يوقّع، وقاعدة البيانات تقفل العقد
يكتب العميل اسمه، ويؤكد الموافقة، ويوقّع. يتحقق النظام أن النص ما زال هو المختوم تماماً، ويسجل التوقيع، ويرسل نسخة موقعة لكلينا. ومن تلك اللحظة ترفض قاعدة البيانات أي تغيير في العقد.
+التفاصيل التقنية
ترسل الصفحة البصمة التي عرضتها، ويقارنها الخادم ويعيد حساب الختم من النص المحفوظ قبل القبول. ويُحسب رمز HMAC على العقد والبصمة والاسم والوقت، فيمكن التحقق من التوقيع علناً لاحقاً، وفحص يومي يعيد التحقق من كل التوقيعات المحفوظة.
// Signing is one atomic batch. The first statement claims the contract; the// second records the signature only if this exact claim won. Two signatures// on one contract are impossible, even in the same millisecond.constclaimed = awaitenv.DB.batch([env.DB.prepare(`UPDATE contracts SET status = 'signed', signed_at = ?, updated_at = ? WHERE id = ? AND status = 'sent' AND NOT EXISTS (SELECT 1 FROM contract_signatures s WHERE s.contract_id = contracts.id)`, ).bind(ts, ts, row.id),env.DB.prepare(`INSERT INTO contract_signatures (contract_id, signer_name, content_hash, sig_token, signed_at) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM contracts WHERE id = ? AND status = 'signed' AND signed_at = ?) AND NOT EXISTS (SELECT 1 FROM contract_signatures WHERE contract_id = ?)`, ).bind(row.id, name, row.content_hash, sigToken, ts, row.id, ts, row.id),]);if (!(claimed[0]?.meta?.changes ?? 0)) {returnjson(c, { error: "already_signed" }, 409);}
-- After a signature exists, the database itself refuses to change the-- contract's text, dates or terms. This holds even if the application above-- it has a bug: the guarantee lives in the data, not in the code.CREATETRIGGERtrg_contracts_freeze_signedBEFOREUPDATEONcontractsWHENEXISTS (SELECT1FROMcontract_signaturessWHEREs.contract_id = OLD.id)AND (NEW.body_mdISNOTOLD.body_mdORNEW.content_hashISNOTOLD.content_hashORNEW.doc_versionISNOTOLD.doc_versionORNEW.starts_onISNOTOLD.starts_onORNEW.ends_onISNOTOLD.ends_onORNEW.is_retainerISNOTOLD.is_retainerORNEW.retainer_amount_minorISNOTOLD.retainer_amount_minor)BEGINSELECTRAISE(ABORT, 'a contract that carries a signature cannot be rewritten'); END;
فاتورة بأقساط حقيقية
أنشئ الفاتورة من الطلب أو من صفحة العميل وأقسّمها أقساطاً، مثلاً ٥٠ و٣٠ و٢٠ بالمئة. لكل قسط تاريخ استحقاق ورابط دفع خاص به، والمتأخر منها يظهر في ملخصي الصباحي.
+التفاصيل التقنية
الأقساط بيانات، وليست جملاً داخل عقد. والمبالغ بالهللات الكاملة، والقسط الأخير يأخذ الباقي، فيكون المجموع مطابقاً دائماً. والاشتراكات الشهرية تصدر فواتيرها بنفسها في أول تشغيل صباحي من كل شهر.
// Splitting an invoice into installments without losing a single halala.// Amounts are whole halalas; the last installment takes whatever is left, so// the parts always add up to the total exactly.exportfunctionplanInstallments(totalMinor: number, splitRaw: string): Schedule {constraw = String(splitRaw || "").trim();letpercents = [100];if (raw) {constgiven = raw.split(",").map((x) => Number(x.trim()));percents = given.filter((x) => Number.isFinite(x) && x > 0);// A part that cannot be read is refused, never silently dropped.if (percents.length !== given.length) return { ok: false, reason: "unreadable" };constsum = percents.reduce((a, b) => a + b, 0);if (!percents.length || Math.abs(sum - 100) > 0.01) return { ok: false, reason: "sum" };if (percents.length > 12) return { ok: false, reason: "count" }; }constamounts: number[] = [];letremaining = totalMinor;for (leti = 0; i < percents.length; i++) {constamount = i === percents.length - 1 ? remaining : Math.round(totalMinor * (percents[i] / 100));if (amount <= 0) return { ok: false, reason: "nonpositive" };amounts.push(amount);remaining -= amount; }return { ok: true, amounts, percents };}
دُفعت، وأُغلقت
يدفع العميل إلكترونياً، أو أسجل تحويلاً بنكياً بضغطة واحدة. وتُغلق الفاتورة نفسها حين لا يبقى شيء مستحق، ويظهر المبلغ في التحليلات، وينتقل العمل إلى التنفيذ.
+التفاصيل التقنية
إذا وصل دفع بالبطاقة وتحويل بنكي للدين نفسه، تكتشف الخدمتان ذلك بالقاعدة نفسها، ويصلني تنبيه واحد فقط لأعيد المبلغ المكرر.
١ / ٨
المال
مدفوعات مصممة لليوم السيئ.
مع أموال العملاء لا يكفي أن تعمل الدفعة. كل مسار يلمس المال صُمم لليوم السيئ: الرد الذي يضيع، والضغطة المزدوجة، والرسالة المكررة، والعميل الذي يدفع مرتين.
عملية الدفع تُنشأ لحظة الضغط. صفحة الدفع في Tap تنتهي بعد ٣٠ دقيقة، لذلك لا يُنشأ شيء عند إرسال الرابط بالبريد. الصفحة تُنشأ حين يضغط العميل زر الدفع، فتكون صالحة دائماً.
التحقق أولاً، ثم الكتابة. كل رسالة دفع من Tap يُتحقق منها قبل تسجيل أي شيء، ثم تُقرأ عملية الدفع من Tap من جديد. ولا يغيّر حالة أي مبلغ إلا جواب Tap نفسه.
الحدث الواحد مرة واحدة. رسالة الدفع نفسها إن وصلت مرتين تُسجَّل مرة واحدة، ومنطق مطابقة واحد تتشاركه الرسالة وصفحة العودة والمهمة المجدولة، فأياً كان الأسبق تكون النتيجة واحدة.
الاسترداد يُكتب قبل أن يُطلب. إذا ضاع الرد، يبقى السجل شاهداً أن استرداداً ربما تم، ومفتاح إعادة المحاولة مرتبط بذلك السجل، فلا يمكن أن تتحول الإعادة إلى استرداد ثانٍ.
تنبيه واضح واحد لأي حالة غير معتادة. دفع مزدوج، أو دفع بالبطاقة بعد تحويل بنكي، أو مبلغ تجريبي على طلب حقيقي، أو مبلغ تغيّر: كل حالة تُنتج تنبيهاً واحداً فقط يخبرني ماذا أراجع.
طريقة بيع لكل منتج. كل منتج يُباع بطلب عرض سعر، أو بحجز موعد، أو بدفع مباشر، وأستطيع تغيير ذلك من اللوحة دون إعادة بناء المتجر.
التشغيل يدوي، بعد فحص حقيقي. الدفع الإلكتروني لا يُشغَّل إلا مني، من اللوحة، والزر يجري فحصاً حقيقياً لدى Tap قبل أن يقبل.
أول دفعة كاملة تمت بـ Apple Pay في بيئة Tap التجريبية: اكتملت الدفعة، وتحقق النظام من توقيعها، ووصلت رسالتا البريد.
// Verify first, write second. The duplicate check is built from fields// inside the message, so recording an unverified message would let a forged// one block the real one.if (!(awaitverifyWebhookSignature(tap, payload, received))) {returnjson(c, { error: "bad_signature" }, 401);}constdedupeKey = awaitsha256Hex(`${payload?.id}|${payload?.status}|${payload?.transaction?.created}`);// The same event twice is one event.constins = awaitenv.DB.prepare("INSERT OR IGNORE INTO webhook_events (tap_charge_id, tap_status, dedupe_key, signature_valid, processing_status) VALUES (?, ?, ?, 1, 'verified')",).bind(String(payload?.id || ""), String(payload?.status || ""), dedupeKey).run();if (ins.meta.changes === 0) returnjson(c, { ok: true, duplicate: true });// A signature proves who sent the message, not that its body is true. The// charge is read again from Tap, and only that answer moves any money state.constcharge = awaitretrieveCharge(tap, String(payload.id));awaitreconcile(env, charge);
// Write it down first, then ask for the money back. If the answer is lost,// a PENDING row still says a refund may have happened, and reconciliation// can settle it. The retry key comes from that row, so a retry can never// become a second refund.constclaim = awaitc.env.DB.prepare("INSERT INTO refunds (order_id, idempotency_key, amount_minor, reason, status, created_by) VALUES (?, ?, ?, 'requested_by_customer', 'PENDING', ?)",).bind(row.oid, `rf_claim_${crypto.randomUUID()}`, amount, actor).run();constclaimId = Number(claim.meta?.last_row_id || 0);if (!claimId) returnflash(c, "e", "تعذّر تسجيل المطالبة، ولم يُطلب أي استرداد");constidem = `rf_${row.oid}_${claimId}`;awaitc.env.DB.prepare("UPDATE refunds SET idempotency_key = ? WHERE id = ?").bind(idem, claimId).run();try {rf = awaitcreateRefund(tap, { chargeId: row.cid, amountMinor: amount, reason: "requested_by_customer", idempotencyKey: idem });} catch (e: any) {// "No" and "no answer" are different facts. A refusal frees the amount;// silence keeps the row PENDING, because the money may already have moved.if (e?.refused) {awaitc.env.DB.prepare("UPDATE refunds SET status = 'FAILED' WHERE id = ?").bind(claimId).run(); }}
+التفاصيل التقنية
حالات التسابق تُختبر ولا يُراهن عليها. مختبر صممته يعيد تمثيل عميلين يتصرفان في اللحظة نفسها، وخادم يتوقف في منتصف الطلب، بطريقة قابلة للتكرار. يشغّل المعالجات الحقيقية جملة بعد جملة على قاعدة البيانات، أمام نسخة محاكية من Tap تلتزم بحالاته المنشورة، ويتحقق من ١٤ قاعدة يجب أن تصمد دائماً، مثل ألا يُدفع طلب مرتين، وألا يوجد مال بلا سجل.
المواعيد
محرك حجز مملوك: شخصان، وموعد واحد، ولا حجز مزدوج.
بدل أداة حجز على منصة شخص آخر، للنظام محركه الخاص. أحدد ساعات العمل الأسبوعية، وأغلق تواريخ بعينها، وأحجز وقتاً لنفسي. والزائر لا يرى إلا المواعيد المتاحة فعلاً، حتى ١٤ يوماً للأمام.
حين يضغط شخصان زر الحجز على وقتين متداخلين في اللحظة نفسها، يُكتب الطلبان، ثم يعيد كل منهما النظر، ويحتفظ الأسبق بالموعد. أما الأحدث فيلغي نفسه ويطلب من الزائر اختيار موعد آخر. والحجز المدفوع يحتفظ بموعده أثناء الدفع، ويتحرر الموعد وحده إن لم يصل الدفع.
تأكيد الطلب يُنشئ رابط اجتماع مرئي خاصاً به، ويرسله للعميل بالبريد، وينسخ الموعد إلى تقويم Google. ويصل تذكير قبل الموعد بيوم. وأستطيع أيضاً ربط تقويم جوالي برابط خاص يعمل دون Google أصلاً.
// Two people press "book" on overlapping times in the same instant. Both// rows are written; then each request re-reads the window, and the older// claim keeps the time. The newer one cancels itself and tells the visitor.exportfunctionslotVerdict(mineId: number,startsAt: string,hours: number,windowRows: SlotRow[],): { overlapping: SlotRow[]; mustYield: boolean } {conststartMs = newDate(startsAt).getTime();constendMs = endOf(startsAt, hours);constoverlapping = windowRows.filter((r) =>r.id !== mineId && collides(startMs, endMs, newDate(r.starts_at).getTime(), endOf(r.starts_at, r.hours)), );// If we cannot identify our own row, we do not yield: destroying a real// booking on an uncertain fact is worse than an overlap I can see and fix.constmustYield = mineId > 0 && overlapping.some((r) => mineId > r.id);return { overlapping, mustYield };}
// Confirming an appointment: one press wins. The status change and the// client's confirmation email are committed together, so a confirmed// booking has always told the client, and a second click changes nothing.conststamp = nowIso();constclaim = c.env.DB.prepare("UPDATE bookings SET status = 'confirmed', meet_link = COALESCE(meet_link, ?), updated_at = ? WHERE id = ? AND status = 'pending_confirm'",).bind(meet, stamp, id);constwon = {sql: "EXISTS (SELECT 1 FROM bookings WHERE id = ? AND status = 'confirmed' AND updated_at = ?)",bind: [id, stamp],};const [claimed] = awaitc.env.DB.batch(mail ? [claim, mailClaim(c.env, mail, won)] : [claim]);if (!claimed?.meta?.changes) returnflash(c, "e", "غير قابل للتأكيد");// The email goes first. The Google Calendar copy is a courtesy and has no// say in whether the client is told.constmailed = mail ? awaitsendClaimed(c.env, mail) : true;
+التفاصيل التقنية
فهرس فريد على وقت البداية يرفض البداية المطابقة فوراً، وقاعدة التداخل في وحدة مشتركة تستخدمها الواجهة البرمجية واللوحة معاً. وأي طلب لم أؤكده خلال ٣٦ ساعة يتحرر مع رسالة اعتذار، فلا يبقى موعد محجوزاً بطلب لم يرد عليه أحد.
رسالة العميل تخرج قبل نسخة التقويم. فـ Google إضافة لطيفة، ولا دخل له في وصول التأكيد للعميل.
الأتمتة
كل خمس دقائق تعمل ثلاث عشرة مهمة، وكل واحدة آمنة عند التكرار.
مهمة مجدولة تعمل كل خمس دقائق، ٢٨٨ مرة في اليوم. تطابق المدفوعات، وتحرر الحجوزات المنتهية، وتعيد إرسال البريد المتعثر، وتصدر فواتير الاشتراكات الشهرية، وتفحص النظام كله، وتُنهي الطلبات التي لم يُرد عليها، وترسل التذكيرات، وتجهز ملخصي الصباحي، وتعيد التحقق من توقيعات العقود، وتنظف السجلات القديمة، وتنشر المقال التالي من قائمة المدونة.
العمل ٢٨٨ مرة في اليوم يعني أن كل مهمة يجب أن تكون آمنة عند التكرار. التذكير يُحجز في قاعدة البيانات قبل إرساله، فيُرسل مرة واحدة مهما حدث. وأول ما تفعله المهمة في كل تشغيل أن تسجل أنها عملت، فإن توقفت يوماً ظهر تحذير أعلى كل شاشة في لوحتي، لأن المهمة لا تستطيع أن تبلغ عن توقفها بنفسها.
// Every five minutes, 288 times a day. Each step is isolated: one failing// step is logged and the rest still run. The order is deliberate: money is// settled before holds are released, and housekeeping always comes last.asyncscheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext) {conststep = <T>(name: string, work: () => Promise<T>) => work().catch((e) => {console.error(`cron: ${name}:`, String(e?.message || e));returnnull; });constlastTick = awaitstep("heartbeat", () => heartbeat(env));awaitstep("reconcile", () => reconcilePending(env, lastTick));awaitstep("booking holds", () => expireUnpaidBookings(env));awaitstep("mail retry", () => retryWhenDue(env));awaitstep("retainer billing", () => generateRetainerInvoices(env));awaitstep("estate check", () => monitorEstate(env));awaitstep("request expiry", () => expireStaleBookings(env));// Claimed once per row, so 288 runs a day still send one reminder.awaitstep("appointment reminders", () => sendAppointmentReminders(env));awaitstep("digest", () => sendReminderDigest(env));awaitstep("contract reminders", () => remindExpiringContracts(env));// Before housekeeping: a signature that stopped proving anything cannot wait.awaitstep("signature sweep", () => sweepSignatures(env));awaitstep("retention", () => purgeOperationalLogs(env));awaitstep("blog queue", () => publishFromQueue(env));}
+التفاصيل التقنية
البريد المتعثر يُعاد كل ساعة: البريد العادي حتى ثلاث محاولات، وبريد المال طوال يوم كامل. وفحص النظام يختبر الموقع العام واللوحة، وينبّه إلى طابور بريد عالق أو رسائل دفع استنفدت محاولاتها، ويراسلني مرة واحدة لكل مجموعة جديدة من المشكلات، لا كل خمس دقائق.
ومراقب خارجي على GitHub يفحص المتجر والمدونة والواجهة البرمجية كل ٣٠ دقيقة، لأن الـ Worker لا يستطيع مراقبة جيرانه بدقة من داخل النطاق نفسه.
المكتب الخلفي
لوحة واحدة بدل عشر أدوات.
أغلب الأعمال الصغيرة تتنقل بين بريد، وأداة لإدارة العملاء، وأداة توقيع، وأداة فواتير، وتقويم، ولوحة تحليلات. أردت ذلك كله في مكان واحد، مرتباً بالترتيب الذي يحدث به العمل: البيع، ثم التنفيذ، ثم المحتوى، ثم النظام نفسه. على الكمبيوتر تظهر الشاشات في شريط جانبي، وعلى الجوال تفتح من الأسفل حيث يصل الإبهام.
رسم توضيحي للوحة التشغيل1 / 15
الطلبات
كل طلب بوصفه وحالته والمبلغ المتفق عليه، مع البحث، والتواصل بضغطة عبر واتساب أو الهاتف أو البريد.
كل تغيير في الحالة يرسل للعميل تحديثاً قصيراً، مرة لكل حالة
تسجيل التحويل البنكي بضغطة، والمبلغ الذي وصل مرتين يُنبَّه إليه
تحويل الطلب إلى مسودة عرض، أو إنشاء فاتورته
استرداد كامل أو جزئي، يُكتب قبل أن يتحرك أي مبلغ
العروض
صفحات عروض خاصة، تُختم عند النشر، ومعها دليل موافقة كل عميل.
شارة سلامة تبيّن هل النص المنشور ما زال هو المختوم
إرسال العرض مع رابط دفعه بضغطة واحدة
العرض المعتمد يصير عقداً بضغطة واحدة
العقود
عقود للتوقيع الإلكتروني، من المسودة، إلى الختم، إلى التوقيع.
اشتراكات شهرية تصدر فواتيرها بنفسها
العقود الموقعة مقفلة، والتعديل نسخة جديدة
إنهاء الاتفاق مع تسجيل السبب
دليل التوقيع وفحص الختم على كل عقد
الفواتير
فواتير حقيقية مرقمة، والأقساط فيها بيانات.
حتى ١٢ قسطاً، لكل منها تاريخ استحقاق ورابط دفع خاص
فاتورة قابلة للطباعة
الأقساط المتأخرة تظهر في ملخصي الصباحي
المواعيد
تقويم شهري لكل المواعيد.
التأكيد، أو تغيير الموعد، أو الإلغاء
ساعات أسبوعية، وتواريخ مغلقة، وأوقات محجوزة لي
رابط تقويم خاص لجوالي
البريد
صندوق بريدي داخل اللوحة: كل بريد يصل إلى النطاق، مرتب في محادثات حقيقية، والرد من نفس المحادثة.
الردود العربية والإنجليزية تجتمع في محادثة واحدة
يُحفظ قبل أي شيء آخر، فلا يضيع شيء
العملاء
صفحة لكل عميل فيها كل ما يخصه، ولوحة تعرض كل الصفقات في نظرة واحدة.
الطلبات والعروض والعقود والفواتير والبريد في مكان واحد، مع ملاحظاتي ومصدر العميل
مسار من سبع مراحل، من الطلب الجديد إلى العمل الجاري
رابط بوابة خاص بالعميل
محو البيانات الشخصية عند الطلب (PDPL) بإجراء واحد
تصدير الطلبات والعملاء والفواتير بصيغة CSV
المنتجات
كتالوج المتجر، يُعدَّل من اللوحة.
طريقة بيع كل منتج تتغير فوراً
زر النشر يعيد بناء المتجر الثابت من قاعدة البيانات
المدونة
كتابة المقالات ونشرها باللغتين.
النشر الفوري، أو الجدولة للنشر التلقائي
استيراد المقالات وتصديرها بصيغة CSV
المشاركة على LinkedIn بضغطة
ردود متابعة تحت كل مقال
التحليلات
كيف يسير العمل، وتفتح على ما ينتظرني اليوم.
أقساط متأخرة، ومواعيد تنتظر التأكيد، وبريد غير مقروء، وعروض بلا رد
المبالغ المحصلة خلال آخر ستة أشهر
مسار التحويل من الطلب إلى العقد الموقع
Google
ما يقوله Google عن الموقع، في شاشة مستقلة.
استعلامات Search Console وصفحاتها، تُسحب يومياً
زيارات GA4 حسب الصفحة والمدينة والدولة والجهاز والقناة
فحص للخاصية يقول بالضبط ما الخطأ إن وُجد
الإشعارات
كل بريد آلي بحالته وعدد محاولاته. وحين يقول عميل إنه لم يصله شيء، تبيّن قائمة المتعثر ما حدث.
سجل التدقيق
سجل للإضافة فقط: ماذا حدث، ومن فعله، وعلى ماذا. وقاعدة البيانات ترفض تعديله أو حذفه.
الإعدادات
كل اتصال خارجي في مكان واحد: المدفوعات، وGoogle، وLinkedIn، ورابط التقويم، وإيقاع نشر المدونة.
على جوالي
تُثبَّت اللوحة كتطبيق. كل نشاط جديد يرسل إشعاراً فورياً، وأيقونة التطبيق تعرض رقماً لا ينقص إلا حين يُنجز العمل فعلاً.
+التفاصيل التقنية
اللوحة صفحات HTML تُبنى على الخادم مباشرة من قاعدة البيانات، بلا إطار واجهات وبلا خط لتجميع الملفات: ٩٬٣١٠ أسطر TypeScript في ٢٧ ملفاً، و١٢٤ مساراً، ومكتبتان فقط وقت التشغيل. مكتوبة بالعربية أولاً، وزر واحد يحولها إلى الإنجليزية عبر قاموس من ٥٦١ مدخلاً. وكل رفض يعود إلى الشاشة التي كنت فيها برسالة مترجمة، لا بصفحة خطأ مجردة، وكل إجراء تقريباً يكتب سطراً في سجل التدقيق.
// The panel is written in Arabic and rendered on the server. One button// walks the live page and swaps each text for its English entry (561 of// them), flips the direction, and re-formats every date in Riyadh time.functionapply(lang){varen = lang === "en", html = document.documentElement;html.lang = lang; html.dir = en ? "ltr" : "rtl";document.querySelectorAll("[data-en]").forEach(function(el){if (el.dataset.ar === undefined) el.dataset.ar = (el.textContent || "");el.textContent = en ? (el.dataset.en || el.dataset.ar) : el.dataset.ar; });texts.forEach(function(p){if (!en) { p[0].nodeValue = p[1]; return; }vart = swap(p[1]); if (t !== null) p[0].nodeValue = t; });varfmt = tsFmt(en);if (fmt) document.querySelectorAll("time[datetime]").forEach(function(el){variso = el.getAttribute("datetime"); if (!iso) return;try { el.textContent = fmt.format(newDate(iso)); } catch (e) {} });document.title = en ? (DICT[docTitle] || docTitle) : docTitle;btn.textContent = en ? "عربي" : "EN";}
// Names and phone numbers arrive from public forms. A cell that starts with// = + - or @ would run as a formula in my own spreadsheet, so it is written// as text. The byte-order mark lets Excel open Arabic correctly.functioncsvCell(v: unknown): string {lets = String(v ?? "");if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;}function csvResponse(name: string, header: string[], rows: unknown[][]): Response { const body = "" + [header, ...rows].map((r) => r.map(csvCell).join(",")).join("\r\n"); return new Response(body, { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${name}"`, }, });}
البريد
بريد لا يمكن أن يضيع، وصندوق بريد يعيش داخل اللوحة.
كل بريد يرسله النظام يبدأ حياته سجلاً في قاعدة البيانات، يُكتب في الخطوة الذرية نفسها التي تُسجَّل فيها الحقيقة التي يعلنها. إن سُجّل الطلب مدفوعاً، فإيصاله في الطابور أصلاً، وإن فشلت الخطوة فلا يوجد أي منهما. ثم يُرسل البريد، وإن تعثر أُعيد كل ساعة.
البريد المرسل إلى النطاق يصل إلى لوحتي، فيُقرأ ويُرتب في محادثات ويُحفظ قبل أي شيء آخر. والرد بالعربية مع الرسالة الأصلية بالإنجليزية يجتمعان في المحادثة نفسها. ويصل تنبيه إلى جوالي، وأرد من الشاشة نفسها.
// An email is first a row, written in the same batch as the fact it// announces. If the order is marked paid, the receipt is already queued;// if the batch fails, neither exists. The send comes after the commit.exportfunctionmailClaim(env: { DB: D1Database }, msg: Mail, when?: { sql: string; bind: unknown[] }): D1PreparedStatement {returnenv.DB.prepare(`INSERT OR IGNORE INTO notifications_log (order_id, template_key, recipient, status, provider, idempotency_key, subject, body_text, attempts, claimed_at) SELECT ?, ?, ?, 'queued', 'resend', ?, ?, ?, 1, ? ${when ? `WHERE${when.sql}` : ""}`, ).bind(msg.orderId, msg.templateKey, msg.to, msg.idem, msg.subject, msg.text, newDate().toISOString(), ...(when?.bind ?? []));}// The send behind a claim this caller won. A failed send is left to the// hourly retry; it is never lost and never sent twice.exportasyncfunctionsendClaimed(env: { DB: D1Database } & EmailEnv, msg: Mail): Promise<boolean> {constr = awaitsendEmail(env, { to: msg.to, subject: msg.subject, text: msg.text });if (r.ok) {awaitenv.DB.prepare("UPDATE notifications_log SET status = 'sent', provider_message_id = ?, claimed_at = NULL WHERE idempotency_key = ?") .bind(r.id || null, msg.idem).run(); } else {awaitenv.DB.prepare("UPDATE notifications_log SET claimed_at = NULL WHERE idempotency_key = ?") .bind(msg.idem).run().catch(() => { /* the lease runs out by itself */ }); }returnr.ok;}
// One conversation is one person plus one subject. Reply prefixes in both// languages are removed, so "Re: X" and "رد: X" land in the thread of "X".exportfunctionnormalizeSubject(subject: string): string {returnString(subject || "") .replace(/^\s*((re|fwd?|رد|اعادة توجيه|إعادة توجيه)\s*:\s*)+/gi, "") .trim() .toLowerCase();}exportasyncfunctionthreadKeyFor(counterpart: string, subject: string): Promise<string> {constdata = `${String(counterpart || "").trim().toLowerCase()}|${normalizeSubject(subject)}`;constd = awaitcrypto.subtle.digest("SHA-256", newTextEncoder().encode(data));return [...newUint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");}
+التفاصيل التقنية
البريد الصادر يمر عبر Resend ويحمل معرّف رسالة خاصاً به يُحفظ، حتى تنتظم ردود العملاء في محادثاتها. والوارد يصل عبر Cloudflare Email Routing إلى معالج البريد في الواجهة البرمجية: تُقرأ الرسالة الخام، ويتحول البريد المكتوب بـ HTML فقط إلى نص مع الإبقاء على روابطه، ويُحدّ طول كل حقل قبل حفظه.
المحتوى
مدونة تنشر نفسها، وتشارك على LinkedIn بضغطة واحدة.
المدونة تقرأ المقالات مباشرة من قاعدة البيانات، فالنشر فوري ولا يحتاج إعادة بناء. أستطيع النشر يدوياً، أو وضع المقالات الجاهزة في قائمة انتظار: بإيقاع أحدده، وفي ساعة ثابتة بتوقيت الرياض، ينشر النظام المقال التالي ويبلّغ محركات البحث عنه عبر IndexNow.
حين يذكر مقالٌ مقالاً آخر لم يُنشر بعد، يبقى الذكر نصاً عادياً، ويتحول إلى رابط وحده يوم يُنشر ذلك المقال. وتحت كل مقال أستطيع إضافة متابعات قصيرة تظهر كسلسلة.
يُربط LinkedIn مرة واحدة عبر تسجيل الدخول الرسمي. بعدها، ضغطة واحدة على مقال منشور تنشره في حسابي بعنوانه وملخصه ورابطه، وتنبهني اللوحة قبل انتهاء الربط بأسبوعين.
اختر أي مربع لترى ما يفعله وبماذا يرتبط. اسحب جانبياً لترى المخطط كاملاً.
كتابة أو استيراد
تُكتب المقالات في اللوحة باللغتين، أو تُستورد من ملف CSV. واستيراد الملف نفسه مرتين لا ينشئ نسخاً مكررة.
قائمة الانتظار
لا يُنشر شيء لأن تاريخاً حلّ، بل يُنشر المقال الجاهز حين يمضي وقت كافٍ على آخر مقال. والقائمة الفارغة تنتظر بهدوء.
+التفاصيل التقنية
تتحقق المهمة المجدولة من الساعة بتوقيت الرياض ومن المدة منذ آخر نشر تلقائي، ثم تنشر أقدم مسودة مستحقة بتحديث مشروط، فلا يمكن لتشغيلين متداخلين أن ينشرا المقال نفسه مرتين.
النشر اليدوي
مفتاح في اللوحة ينشر المقال أو يسحبه فوراً.
منشور
تعرضه المدونة من قاعدة البيانات خلال دقيقتين تقريباً، بالعربية والإنجليزية، مع بيانات منظمة لمحركات البحث.
+التفاصيل التقنية
تُخزن الصفحات مؤقتاً على الحافة دقيقتين، والخلاصات وخريطة الموقع خمس دقائق. ولا يُخزن إلا الناجح منها، فلا تبقى صفحة مفقودة بعد النشر الذي يصلحها.
IndexNow
محركات البحث التي تدعم IndexNow تُبلَّغ بالمقال الجديد وبصفحة المدونة فوراً، بدل أن تنتظر الزحف.
LinkedIn
المشاركة قراري، لذلك هي ضغطة واحدة وليست تلقائية. ويتغير الزر بعد المشاركة، وتُسجَّل كل مشاركة.
RSS وخريطة الموقع
خلاصة RSS تحمل آخر ٣٠ مقالاً، وخريطة المدونة تضم كل مقال منشور.
// Escape everything first, then allow a short list of Markdown. No raw tag// can survive, a link must be http(s) or a path on this site, and a link to// an article that is not published yet stays plain text until it is.functionesc(s: string): string {returns.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] asstring));}functioninline(s: string, isPublished: (slug: string) => boolean): string {returns .replace(/`([^`]+)`/g, "<code>$1</code>") .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") .replace(/\*([^*]+)\*/g, "<em>$1</em>") .replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/(?!\/)[^\s)]*)\)/g, (_m, text: string, href: string) => { const self = SELF_ABS.exec(href); const path = self ? self[1] : href; if (!path.startsWith("/")) return `<ahref="${href}"rel="noopener">${text}</a>`; const m = ARTICLE.exec(path); if (m && !m[1].includes(".") && !isPublished(m[1])) return text; return `<ahref="${href}">${text}</a>`; }, );}
// A page built by a Worker is not cached just because it says so: the Worker// has to store it. Only successful pages that declared a lifetime are kept,// never an error, because a cached 404 would outlive the publish that fixes it.exportdefault {asyncfetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {if (req.method !== "GET") returnapp.fetch(req, env, ctx);constcache = caches.default;consthit = awaitcache.match(req);if (hit) returnhit;constres = awaitapp.fetch(req, env, ctx);if (res.status === 200 && (res.headers.get("Cache-Control") || "").includes("s-maxage")) {// A body can be read once: the copy is stored, the original returned.// Failing to cache is a slower page, not a broken one.ctx.waitUntil(cache.put(req, res.clone()).catch((e) => {console.error("blog: cache.put refused", req.url, String(e)); })); }returnres; },};
// One press in the panel shares a published article to LinkedIn: the title,// a short summary and the link, as a public article post.consttitle = p.title_en || p.title_ar;constexcerpt = p.title_en ? (p.excerpt_en || "") : (p.excerpt_ar || "");constcommentary = `${title}${excerpt ? `\n\n${excerpt}` : ""}\n\n${link}`;constres = awaitfetch("https://api.linkedin.com/v2/ugcPosts", {method: "POST",headers: {Authorization: `Bearer ${token}`,"content-type": "application/json","X-Restli-Protocol-Version": "2.0.0", },body: JSON.stringify({author: person,lifecycleState: "PUBLISHED",specificContent: {"com.linkedin.ugc.ShareContent": {shareCommentary: { text: commentary },shareMediaCategory: "ARTICLE",media: [{ status: "READY", originalUrl: link, title: { text: title } }], }, },visibility: { "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" }, }),});
+التفاصيل التقنية
محوّل Markdown يعطّل كل شيء أولاً، ثم يسمح بثمانية عناصر فقط: العناوين، وكتل الكود، والقوائم، والاقتباسات، والخط العريض، والمائل، والكود داخل السطر، والروابط. والرابط يجب أن يكون http(s) أو مساراً في هذا الموقع، فلا يمكن تهريب أي سكربت عبر مقال.
العربية والإنجليزية
لغتان، مبنيتان من أول سطر.
الإنجليزية هي اللغة الأساسية، والعربية ليست طبقة ترجمة فوقها. كل صفحة موجودة مرتين، صفحة حقيقية بعنوانها الخاص. وكل جملة تعيش في ملف واحد كزوج إنجليزي وعربي، فتُبنى النسختان من الأجزاء نفسها ولا تختلفان.
تغيير اللغة يبقيك في الصفحة نفسها ويغير العنوان دون إعادة تحميل. وقبل رسم أي شيء، يحدد سكربت صغير اللغة والاتجاه. وبعد البناء، يكتب سكربت آخر العربية في ملفات كل صفحة عربية، فتراها محركات البحث وقارئات الشاشة دون تشغيل أي كود.
الدرس الذي أحمله دائماً: اختبر العربية أولاً. تباعد الحروف الذي يبدو أنيقاً في الإنجليزية يفصل الحروف العربية المتصلة، فتُقرأ «تقنية» كأنها «تقذية». صار هذا قاعدة في التنسيقات، وفحصاً في التدقيق البصري الآلي.
// Every page has a real route in both languages: the Arabic one is the// English path under /ar. Switching maps the CURRENT path, so the reader// stays on the same page instead of landing on the home page.functionpathFor(lang: Lang, current: string): string {constbare = current === '/ar' || current === '/ar/' ? '/' : current.startsWith('/ar/') ? current.slice(3) : current;constnormalized = bare.startsWith('/') ? bare : `/${bare}`;if (lang !== 'ar') returnnormalized;returnnormalized === '/' ? '/ar/' : `/ar${normalized}`;}// No reload and no extra history entry: the address simply follows the language.functionsyncPath(lang: Lang) {constcurrent = window.location.pathname;consttarget = pathFor(lang, current);if (target !== current) {window.history.replaceState(null, '', target + window.location.search + window.location.hash); }}
// The first thing inside <body>. The page knows its language and its// direction before anything is drawn, so Arabic never flashes left to right.constlangScript = `(function(){try{ var p = location.pathname; var ar = p === '/ar' || p === '/ar/' || p.indexOf('/ar/') === 0; var d = document.documentElement; d.lang = ar ? 'ar' : 'en'; d.dir = ar ? 'rtl' : 'ltr';}catch(e){}})();`;
// Runs after the build. A static export leaves every page with lang="en",// so this writes Arabic into the bytes of every page under /ar/. Search// engines, screen readers and translators read it without running JavaScript.for (constfileofhtmlFilesUnder(AR_ROOT)) {consthtml = readFileSync(file, "utf8");consttag = html.match(/<html[^>]*>/)?.[0];if (!tag) {console.error(`fix-ar-lang: no <html> tag in ${file}`);process.exit(1); }if (tag.includes('lang="ar"') && tag.includes('dir="rtl"')) continue;constfixed = tag .replace(/\slang="[^"]*"/, ' lang="ar"') .replace(/\sdir="[^"]*"/, ' dir="rtl"');writeFileSync(file, html.replace(tag, fixed));patched++;}// A build that produced no Arabic pages is a broken build, not a quiet one.if (patched === 0 && alreadyCorrect === 0) process.exit(1);
+التفاصيل التقنية
التخطيط يستخدم الخصائص المنطقية (البداية والنهاية بدل اليسار واليمين)، فملف تنسيق واحد يخدم الاتجاهين. وتبديل اللغة يجري داخل انتقال React، فيظهر أثر الضغط على الزر أولاً ثم تتبعه إعادة الرسم الكاملة. والأرقام تُنسق لكل لغة، والخط العربي بثلاثة أوزان مع خطوط بديلة مقاسة، ويُحمَّل مسبقاً في الصفحات العربية بعد البناء.
SEO وGEO
مبني ليجده محرك البحث، ويفهمه الذكاء الاصطناعي.
SEO أن يجدك محرك البحث. وGEO أن يفهمك مساعد الذكاء الاصطناعي ويذكرك في إجاباته. وكلاهما يبدأ من الشيء نفسه: صفحة تقول بوضوح من كتبها، وعن ماذا تتحدث، وكيف ترتبط بكل ما حولها.
اختر أي مربع لترى ما يفعله وبماذا يرتبط. اسحب جانبياً لترى المخطط كاملاً.
شخص واحد، معرّف واحد
كل صفحة تصف الشخص نفسه بالمعرّف الثابت نفسه، فلا تحتاج محركات البحث ولا مساعدات الذكاء الاصطناعي إلى التخمين: هل الصفحتان عن ماجد نفسه؟
+التفاصيل التقنية
رسم JSON-LD في التخطيط الجذري يعرّف الشخص بمعرّف @id في الصفحة الرئيسية، وكل عقدة أخرى في أي صفحة تشير إلى ذلك المعرّف بدل أن تكرر بياناته.
ProfilePage
يخبر المحركات أن الصفحة الرئيسية صفحة تعريفية، وأن موضوعها الأساسي هذا الشخص.
WebSite
الموقع نفسه، بلغتيه.
التعليم
أين درست، مربوطاً كحقيقة لا كجملة.
knowsAbout
ثمانية عشر مجالاً أعمل فيها، من معمارية الحلول إلى إدارة تقنية المعلومات والأنظمة الرقمية.
صفحات المشاريع
كل صفحة مشروع تعرّف نفسها كعمل مستقل مع مسار تنقل، وتذكرني صانعاً له.
هذه الصفحة
هذه المقالة من نوع TechArticle، ومؤلفها الشخص نفسه.
نماذج الأعمال
مواقع حية بنيتها، مربوطة كنماذج أعمال.
منشورات LinkedIn
منشورات عن عملي مربوطة بأوقات نشرها الدقيقة، مستخرجة من أرقام منشورات LinkedIn نفسها.
كل كلمة موجودة في HTML قبل تشغيل أي سكربت.
كل صفحة تعلن عنوانها الرسمي ونسختها باللغة الأخرى عبر hreflang.
عنوان رسمي واحد للموقع كله: النسخ العامة المكررة أُغلقت أو حُوّلت إلى النطاق الرئيسي.
ملف robots.txt يرحب بـ ٢٧ زاحفاً للذكاء الاصطناعي بالاسم، وملف llms.txt يعطي محركات الذكاء الاصطناعي ملخصاً واضحاً عني وعن مكان كل شيء.
خريطة الموقع تضم كل صفحة باللغتين، والمقالات الجديدة تُعلن عبر IndexNow.
أرقام Search Console وGA4 تصل إلى اللوحة يومياً، فأتابع ما يُعثر عليه، لا ما يُنشر فقط.
وللأمانة: هذا يجعل الموقع مقروءاً ومفهوماً. أما أن يُذكر اسمك في الإجابات، فيحتاج أيضاً محتوى حقيقياً وإشارات من مصادر أخرى مع الوقت.
// Both language routes of a case page call this one builder, so the English// and Arabic pages cannot disagree. `creator` points at the same Person id as// the home page, which makes every project a link in one knowledge graph.constgraph = {'@context': 'https://schema.org','@graph': [ {'@type': 'CreativeWork','@id': `${url}#project`,name: project.name[lang],description: project.description[lang],inLanguage: lang,url,sameAs: project.liveUrl,creator: { '@id': `${SITE}/#person` },keywords: project.stack.join(', '), }, {'@type': 'BreadcrumbList',itemListElement: [ { '@type': 'ListItem', position: 1, name: OWNER[lang], item: home }, { '@type': 'ListItem', position: 2, name: project.name[lang], item: url }, ], }, ],};
// How the publish time of a LinkedIn post was worked out for the structured// data: the post id carries it. The top 41 bits are milliseconds since 1970.constpublishedAt = (activityId: string) =>newDate(Number(BigInt(activityId) >> 22n)).toISOString();publishedAt('7431610991085297665');// '2026-02-23T08:08:52.930Z'
# Majed Alandajani> Solutions Architect and AI Automation Engineer in Jeddah, Saudi Arabia.> He builds production systems that turn manual operations into> autonomous workflows.## Pages## Selected work## Profiles## Honors and Community Endorsements## Live Production Platforms Architected & Built by Majed
الأداء
شعار ثلاثي الأبعاد، وسرعة على الجوال.
تبدأ الصفحة الرئيسية بمشهد ثلاثي الأبعاد: ٤٧ رمزاً برمجياً تتطاير ثم تتجمع، فتمتصها خطوط معدنية تكتب شعاري أثناء التمرير. مشهد كهذا يبطئ أي موقع بسهولة، فكانت القاعدة بسيطة: النص يُحمَّل أولاً، والمشهد لا يقف في طريقه أبداً.
كان تقييم السرعة على الجوال ٤٤. المتهم الطبيعي كان المشهد ثلاثي الأبعاد، لكن القياس قال غير ذلك: مكتبة تمرير كانت تزيح فقرة قليلاً بعد ظهورها، فيحسب المتصفح أن المحتوى الأساسي ظهر مرة ثانية ومتأخراً. عرفت ذلك بحجب كل ملف على حدة وإعادة القياس. صار التثبيت أثناء التمرير CSS عادياً، وانتقل المشهد إلى مسار خلفي، وصارت أداة التحليلات تنتظر الزائر. فارتفع التقييم إلى نحو ٨٥، وطابقت الصور الملتقطة عند أربع نقاط تمرير النسخة السابقة.
المشهد ينتظر انتهاء المقدمة وفراغ المتصفح، ثم يُرسم داخل Web Worker. وإن تعذر ذلك يُرسم في المسار الرئيسي، ودون WebGL يظهر رسم مسطح للشعار بدلاً منه.
لا يُرسم إلا حين يتغير شيء، ويبطئ نفسه في الحركة الهادئة، ويتوقف تماماً حين يخرج من الشاشة.
كل قسم في الصفحة الرئيسية يُفعَّل وحده، والأقسام الأسفل تتجاوز حساب التخطيط حتى يفرغ المتصفح.
أداة التحليلات تُحمَّل عند أول تفاعل أو بعد سبع ثوانٍ، ولا ترسل إلا مسار الصفحة.
الملفات ذات الأسماء المشفرة تُخزن مؤقتاً لسنة، ومن يفضل تقليل الحركة يرى الحالة النهائية مع تلاشٍ سريع.
والزوار الحقيقيون هم الأهم. حين قست في سبتمبر، كان ٧٥٪ من الزيارات الحقيقية يظهر محتواها الأساسي خلال ثانية واحدة.
// The 3D scene draws inside a Web Worker, so three.js never blocks the page.// Three outcomes race (ready, error, silence) and a single latch settles the// race once: the worker keeps the canvas, or the page falls back to drawing// on the main thread. The visitor sees the logo either way.constworker = newWorker(newURL('./scene.worker.tsx', import.meta.url));letsettled = false;constfallBack = () => {if (settled) return;settled = true;setUseWorker(false);};consttimer = window.setTimeout(fallBack, READY_TIMEOUT_MS);worker.onerror = fallBack;worker.onmessage = (e: MessageEvent<{ type: string }>) => {if (e.data.type === 'error') fallBack();if (e.data.type === 'ready' && !settled) {settled = true;window.clearTimeout(timer); }};constrect = canvas.getBoundingClientRect();constoffscreen = canvas.transferControlToOffscreen();constdpr = Math.min(Math.max(1, window.devicePixelRatio || 1), 1.5);worker.postMessage( { type: 'init', canvas: offscreen, width: rect.width, height: rect.height, dpr }, [offscreen],);
// The scene draws only when asked. About 30 frames a second while the// pointer plays with it, about 18 for the slow ambient motion, nothing at// all when it is still, and nothing while it is off screen.constloop = (time: number) => {if (pausedRef.current) {raf = requestAnimationFrame(loop);return; }constp = progressRef.current;const { x: mx, y: my } = mouseRef.current;if (Math.abs(mx - lastMx) + Math.abs(my - lastMy) > 0.0005) {lastMx = mx;lastMy = my;lastMoveAt = time; }constpointerActive = time - lastMoveAt < 900;constanimActive = !reduced && (p < 0.6 || p >= 0.92);constinterval = pointerActive ? 33 : 55;consttimed = (animActive || pointerActive) && time - lastTick > interval;if (p !== last || timed) {invalidate();last = p;if (timed) lastTick = time; }raf = requestAnimationFrame(loop);};
// The hold while you scroll is plain CSS (position: sticky). This effect only// turns the scroll position into a number from 0 to 1. Sizes are measured by a// ResizeObserver, so the scroll handler never forces the browser to lay out.useEffect(() => {if (reduced) {progressRef.current = 1;return; }constpin = pinRef.current;constsection = sectionRef.current;if (!pin || !section) return;lettop = 0;letdistance = 1;constupdate = () => {progressRef.current = Math.min(1, Math.max(0, (window.scrollY - top) / distance)); };constmeasure = () => {top = pin.getBoundingClientRect().top + window.scrollY;distance = Math.max(1, pin.offsetHeight - section.offsetHeight);update(); };constro = newResizeObserver(measure);ro.observe(pin);ro.observe(section);window.addEventListener('scroll', update, { passive: true });return () => {ro.disconnect();window.removeEventListener('scroll', update); };}, [reduced]);
// Sections below the fold skip layout on the first load (content-visibility).// After six seconds they are released one per idle moment, and the first// click or key press releases all of them, so a jump link always lands right.constRELEASE_AFTER_MS = 6000;exportfunctionDeferredLayoutRelease() {useEffect(() => {letidle = 0;constnext = () => document.querySelector('.cv-section');constreleaseAll = () => {document.querySelectorAll('.cv-section').forEach((el) => el.classList.remove('cv-section')); };constschedule = (fn: () => void) =>typeofwindow.requestIdleCallback === 'function' ? window.requestIdleCallback(fn, { timeout: 1000 }) : window.setTimeout(fn, 50);conststep = () => {next()?.classList.remove('cv-section');if (next()) idle = schedule(step); };consttimer = window.setTimeout(() => { idle = schedule(step); }, RELEASE_AFTER_MS);constevents = ['pointerdown', 'keydown'] asconst;events.forEach((e) => window.addEventListener(e, releaseAll, { once: true, passive: true }));return () => {window.clearTimeout(timer);if (typeofwindow.cancelIdleCallback === 'function') window.cancelIdleCallback(idle);events.forEach((e) => window.removeEventListener(e, releaseAll)); }; }, []);returnnull;}
// The analytics queue exists from the first moment, so no event is lost.// The library itself waits for the visitor's first interaction, or seven// seconds, and only the page path is sent, never the query string.gtag('config', GA_ID, { page_location: location.origin + location.pathname });(function () {vardone = false, o = { passive: true }, t;varev = ['pointerdown', 'keydown', 'touchstart', 'scroll'];functionload() {if (done) return;done = true;clearTimeout(t);ev.forEach(function (e) { removeEventListener(e, load, o); });vars = document.createElement('script');s.async = true;s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;document.head.appendChild(s); }ev.forEach(function (e) { addEventListener(e, load, o); });t = setTimeout(load, 7000);})();
واجهة المتجر
متجر ثابت يعرف رغم ذلك ما هو صحيح الآن.
يُبنى المتجر صفحات ثابتة من الكتالوج في قاعدة البيانات. وحين أعدّل الكتالوج في اللوحة وأضغط نشر، تعيد مهمة في GitHub Actions بناءه ونشره. وإن تعذر الوصول إلى الواجهة البرمجية أثناء البناء، يُستخدم الكتالوج المحفوظ في الكود، فلا يفشل النشر بسبب خلل عابر في الشبكة.
وفوق الصفحات الثابتة طبقة حية صغيرة: في كل زيارة يسأل المتجر الواجهة البرمجية كيف يُباع كل منتج الآن. وهكذا أستطيع تحويل منتج من طلب عرض سعر إلى دفع مباشر دون أي إعادة بناء.
على الكمبيوتر، يخرج نموذج الحجز من الزر الذي ضغطته، وعلى الجوال يصير لوحاً من أسفل الشاشة. ولكل محاولة مفتاحها، فإعادة الإرسال بعد ضياع الرد لا تنشئ طلباً ثانياً أبداً، والتحقق البشري لا يُحمَّل إلا عند فتح النموذج.
// At build time the catalog comes from the database through the API. If the// API cannot be reached, the catalog kept in code is used, so a network// hiccup can never fail a publish.letOFFS: Off[] = OFFERINGS.map(toCard);try {constr = awaitfetch(`${SITE}/api/products`, { headers: { accept: 'application/json' } });if (r.ok) {constj: any = awaitr.json();if (Array.isArray(j?.offerings) && j.offerings.length > 0) OFFS = j.offerings; }} catch { /* offline build: the fallback stays */ }
// The store is built once, as static pages. Then, on every visit, it asks the// API how each product is sold right now: request a quote, book, or pay.// Changing how a product is sold needs no rebuild.constlive = newMap<string, { paymentMode?: string; priceMinor?: number | null }>();letpaymentsOn = false;fetch(`${apiBase}/api/products`) .then((r) => (r.ok ? r.json() : null)) .then((j: any) => {if (!Array.isArray(j?.offerings)) return;paymentsOn = !!j.paymentsOn;for (constoofj.offerings) live.set(o.slug, o);document.querySelectorAll<HTMLElement>('[data-book]').forEach((btn) => {consto = live.get(btn.dataset.book || '');if (!paymentsOn || o?.paymentMode !== 'direct' || !o.priceMinor) return;relabel(btn, 'ادفع', 'Pay'); }); }) .catch(() => { /* the built page stands as it is */ });
// One key per booking attempt, created when the form opens and sent with// every retry. A retry after a lost answer gets the same order back, never a// second one. The key is retired only when the attempt is really over.constres = awaitfetch(`${apiBase}/api/orders`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ ...body, attemptKey }),});constj: any = awaitres.json().catch(() => ({}));// Nothing was charged, so the next press is a new attempt, not a retry.if (j.error === 'charge_create_failed' || j.error === 'attempt_closed') attemptKey = '';if (!res.ok) thrownewError(j.error || 'error');// A paid product answers with the payment page, also on a retry.if (j.redirectUrl) { location.assign(j.redirectUrl); return; }msgEl.textContent = en() ? 'Request received. I will contact you within one business day.' : 'تم استلام طلبك. سأتواصل معك خلال يوم عمل واحد.';form.reset();attemptKey = ''; // the next submission is a genuinely new booking
+التفاصيل التقنية
المتجر مبني بـ Astro بمكتبتين فقط وقت التشغيل، وخطوط مستضافة ذاتياً، وصفحة لكل خدمة ببيانات Service المنظمة، وصفحتا خصوصية وشروط بلغة واضحة. واللغة التي تختارها تنتقل معك بين الصفحة الرئيسية والمتجر والمدونة وصفحة الحجز. ودوّار الفئات يقيس أبعاده نادراً ويحرك البطاقات بمتغيرات CSS فقط.
النشر والجودة
من التعديل إلى الإنتاج، دون أي نشر يدوي.
كل تعديل يسلك الطريق نفسه: الدفع يشغّل الاختبارات، ويحدد أي الخدمات الخمس تغيرت فعلاً، ويرفض النشر إن كانت في قاعدة البيانات هجرة تنتظر، وينشر ما تغير فقط، ويفحص كل عنوان حي، ثم يفتح الموقع العام في متصفح حقيقي باللغتين، على الجوال والكمبيوتر.
اختر أي مربع لترى ما يفعله وبماذا يرتبط. اسحب جانبياً لترى المخطط كاملاً.
دفع التعديل
يُدفع التعديل إلى الفرع الرئيسي. والتعديلات على التوثيق وحده لا تنشر شيئاً.
التخطيط
يحدد أي الخدمات الخمس تغيرت. والكود المشترك يُحسب للواجهة البرمجية واللوحة معاً، وتعديل الاختبارات وحدها لا يُحسب.
بوابة الهجرات
إن كانت في الإنتاج هجرة قاعدة بيانات تنتظر، يتوقف كل شيء. فلا يصل الكود إلى قاعدة بيانات غير جاهزة له.
النشر
كل خدمة متغيرة تعيد فحص الأنواع والاختبارات داخل مهمتها، ثم تُنشر. ومهمة الموقع تكتب العربية في الصفحات العربية وتحمّل الخط العربي مسبقاً.
التحقق الحي
كل عنوان حي يجب أن يجيب كما هو متوقع. ست محاولات، بانتظار أطول كل مرة، وباستعلام جديد حتى لا تجيب ذاكرة مؤقتة بدل النسخة الجديدة.
التدقيق بالمتصفح
متصفح حقيقي يفتح الصفحات العامة والمتجر والمدونة وأحدث مقال باللغتين، على الجوال والكمبيوتر، ويُفشل التشغيل إن وجد أي خلل.
الاختبارات
١٬٢١٢ اختباراً آلياً تعمل مع كل دفع بالتوازي، ومرة أخرى داخل كل مهمة نشر. والخط المتقطع يعني أن فشل اختبار يوقف النشر.
النسخ الليلي
كل ليلة تُصدَّر قاعدة البيانات بعد إزالة بيانات الاعتماد، وتُفحص، وتُحفظ ١٤ ليلة. وتحتفظ المنصة أيضاً بتاريخ قابل للاستعادة لـ ٣٠ يوماً.
مراقبة التشغيل
جهاز خارج Cloudflare يفحص المتجر والمدونة والواجهة البرمجية كل ٣٠ دقيقة، ويراسلني إن بقي أحدها متوقفاً.
اختبار آلي
١٬٢١٢
قاعدة في مختبر التسابق
١٤
شاشة لوحة مدققة
٢٣
مهام نشر
٧
نسخة ليلية محفوظة
١٤
يوماً من تاريخ الاستعادة
٣٠
تعديلات قاعدة البيانات تُجرَّب أولاً على قاعدة تجريبية منفصلة، مع نسخة احتياطية من الإنتاج قبل تشغيل أي شيء. والاستعادة نفسها تُجرَّب: تُستعاد النسخة إلى قاعدة مؤقتة وتُقارن أعداد سجلاتها قبل لمس الإنتاج.
# A push deploys only what it changed. Shared code is compiled into both the# API and the panel, so a change there redeploys both. Test-only changes do# not change what a Worker serves, so they deploy nothing.- name: Planrun: |has() { grep -qE"$1" <<<"$CHANGED"; }WORKER_FILES="$(grep -vE '^store-system/[^/]+/test/' <<<"$CHANGED" || true)"hasw() { grep -qE"$1" <<<"$WORKER_FILES"; }SHARED=0ifhasw'^store-system/shared/'; thenSHARED=1; fiifhasw'^store-system/api/' || [ "$SHARED" = 1 ]; thenonapi; fiifhasw'^store-system/admin/' || [ "$SHARED" = 1 ]; thenonadmin; fiifhasw'^store-system/blog/'; thenonblog; fiSITE_FILES="$(grep -vE '^scripts/ci/' <<<"$CHANGED" || true)"ifgrep -qE'^(src/|public/|assets/|scripts/|next\.config|wrangler\.worker\.jsonc|package\.json)' <<<"$SITE_FILES"; thenonportfoliofi
# After every deploy the live address must answer as expected. Six tries,# waiting 5, 10, 20, 40 and 80 seconds, with a fresh query each time so no# cache can answer for the new version.ATTEMPTS=6WAIT=5foriin$(seq1$ATTEMPTS); dosep="?"; case"$URL"in *\?*) sep="&";; esacgot="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 "${URL}${sep}ci=$(date +%s)-$i" || echo 000)"case",$WANT,"in *",$got,"*) matched=1;; *) matched=0;; esacif [ "$matched" = 1 ]; thenecho"OK $URL answered $got (attempt $i)"exit0fiecho".. $URL answered $got, wanted $WANT (attempt $i of $ATTEMPTS)" [ "$i" -lt"$ATTEMPTS" ] && sleep"$WAIT" && WAIT=$((WAIT * 2))doneecho"::error::$URL answered $got after $ATTEMPTS attempts, expected $WANT."exit1
// Tests run against a real SQLite database built from the same 31// migrations as production. A batch here is a real transaction, exactly like// D1: statement N sees statement N-1, and any failure rolls back everything.asyncbatch(stmts) {if (inBatch) thrownewError("D1_ERROR: batch() called while a batch is already open");inBatch = true;db.exec("BEGIN");try {constout = stmts.map((s) => s._runSync());db.exec("COMMIT");returnout; } catch (e) {db.exec("ROLLBACK");throwe; } finally {inBatch = false; }},
+التفاصيل التقنية
الاختبارات مكتوبة بـ node:test دون إطار إضافي، وتشغّل الـ Worker كما يُشحن عبر طلبات حقيقية على قاعدة بيانات مبنية من الهجرات الحقيقية. ومختبر للتزامن يعيد تمثيل حالات التسابق والانهيار بطريقة قابلة للتكرار، وإثبات تشغيلي يعمل داخل workerd نفسه، وتدقيق بصري منفصل يفتح ٢٣ شاشة من اللوحة بعرضين وباللغتين، ويفشل عند أي تسمية مقصوصة أو تباعد حروف على نص عربي.
الأمان
الأمان والخصوصية، بالقدر الذي يصح قوله علناً.
بعض الأشياء تركتها خارج هذه الصفحة عمداً: طريقة حماية اللوحة الخاصة، وأماكن الصفحات الخاصة، وكل مفتاح وكل حد. وما يمكن مشاركته هو الشكل العام.
عنوان واحد، مشفّر، بخطوات محسوبة. الموقع لا يجيب إلا عبر HTTPS، مع HSTS رُفع على درجات: خمس دقائق، ثم أسبوع، ثم سنة كاملة بعد أن ثبت كل شيء. فالمتصفح الذي حفظ HSTS لن يفتح الموقع دون تشفير، ولا يستطيع الخادم التراجع عن ذلك، لذلك لم أستعجل.
لا نسخ مكررة. وُجدت نسختان عامتان كاملتان من الموقع على عناوين أخرى، إحداهما دون تشفير. أُغلقت الأولى، وصارت الثانية تحوّل إلى النطاق الرئيسي.
ترويسات حماية، وسياسة رُوقبت قبل فرضها. ترويسات حماية أساسية على الخدمات الخمس، وسياسة أمان للمحتوى (CSP) عملت في وضع التقارير فقط قرابة أسبوع قبل فرضها، فتمنع ما يجب منعه دون أن تكسر الموقع.
أدلة لا يستطيع التطبيق إعادة كتابتها. رسائل الدفع يُتحقق منها قبل تسجيلها، والعقود الموقعة تقفلها قاعدة البيانات، والموافقات والتوقيعات وسجل التدقيق للإضافة فقط.
لوحة خاصة خلف بوابة هوية. لا يمكن الوصول إلى لوحة التشغيل دون المرور ببوابة هوية، والنماذج العامة محمية بتحقق بشري لا يُحمَّل إلا عند الحاجة.
لا أسرار في الكود. لا يوجد أي سر في مستودع الكود. والنسخ الليلية تُزال منها بيانات الاعتماد وتُفحص قبل حفظها.
الخصوصية كإجراء. محو بيانات شخص بموجب نظام حماية البيانات الشخصية (PDPL) إجراء واحد: يُمحى من كل مكان دفعة واحدة، وتبقى المستندات التي يُلزمني النظام بحفظها مع استبدال اسمه.
// Erasing a person (PDPL) without destroying the documents the law says I// must keep. Every identifier is replaced in place, longest first, so the// scope and the amounts survive while the person does not.constMARK = "[محذوف بموجب طلب PDPL]";constvalues = [...newSet(raw.map((v) => String(v || "").trim()).filter((v) => v.length > 2))] .sort((x, y) => y.length - x.length);constparams = values.flatMap((v) => [v, MARK]);// replace(replace(col, ?, ?), ?, ?) ... one layer per value that exists,// because SQLite's replace() returns NULL if any argument is NULL.constscrub = (col: string) => values.reduce((e) => `replace(${e}, ?, ?)`, col);for (constpofproposals) {stmts.push(env.DB.prepare(`UPDATE proposals SET client_email = NULL, title_ar = ${scrub("title_ar")}, title_en = ${scrub("title_en")}, body_md = ${scrub("body_md")} WHERE id = ?`, ).bind(...params, ...params, ...params, p.id));}// All statements run as one batch: the person is erased everywhere, or nowhere.
التقدير
ما قررته، وما رفضته، وما لا تستطيع هذه البيئة إثباته.
مرفوض: ترقية رئيسية. الإصدارات الرئيسية التالية لإطار الموقع ومولّد المتجر عملت كلها. لكن كود JavaScript الأساسي للموقع زاد ١٧٪، وفقد المتجر تأثير الزجاج في Chrome إلا بحلول التفافية. فأبقيت الإصدارات الحالية وحددت موعداً للمراجعة.
مستبدَل: مكتبة تمرير. المكتبة التي كانت تثبّت مقدمة الصفحة شوّهت قياس سرعة تحميلها. وCSS العادي يؤدي العمل نفسه، بالشكل نفسه.
مُبقى عمداً: إصدار مكتبة الأبعاد الثلاثية. الإصدارات الأحدث تغيّر طريقة رسم الشعار، والشعار لا يتغير دون سبب.
تغيّر بعد المراجعة: طريقة التعامل مع الإصلاحات. بعد بناء طرق الدفع نجحت كل الاختبارات، ومع ذلك وجدت مراجعة الكود ٣٣ ملاحظة حقيقية، ووجدت مراجعة ثانية ١٤ أخرى، اثنتان منها سببتها إصلاحات المراجعة الأولى. ومنذ ذلك الحين، أي إصلاح يمس المال أو بيانات العملاء يُعامل ككود جديد يحتاج مراجعته الخاصة.
ما لا تستطيع هذه البيئة إثباته
الحجم. الموقع الشخصي يستقبل عدداً محدوداً من الزوار. وما يصمد هنا قد لا يصمد مع آلاف المستخدمين، وهذا يحتاج اختبار ضغط مستقلاً.
الفرق. أنا المستخدم الوحيد للوحة. فالصلاحيات، والموافقات بين الأقسام، وتسليم الحسابات عند خروج موظف، لا تُختبر هنا.
الامتثال. الموقع يدربني على الضوابط، لكنه لا يثبت امتثال شركة لنظام حماية البيانات أو لضوابط الأمن السيبراني.
الأنظمة القائمة. بنيت هذا النظام من الصفر. والشركات فيها أنظمة قديمة وموردون وطرق عمل، ونقل أي شيء إليها يحتاج خطة خاصة بها.
لذلك لا أعتبر شيئاً جاهزاً للشركات لمجرد أنه يعمل هنا. نجاحه هنا يعني أنه يستحق التجربة التالية داخل الشركة، على نطاق صغير أولاً.
node:test وnode:sqlite: ١٬٢١٢ اختباراً آلياً على قاعدة بيانات حقيقية مبنية من الهجرات.
Playwright: التدقيق البصري للوحة وللموقع الحي في متصفح حقيقي.
لماذا كتبت كل هذا
بنيت هذا النظام لأدير به عملي، وليكون لي مكان أجرّب فيه الفكرة فعلاً قبل أن أوصي بها لأحد. وكتابته جزء من الانضباط نفسه: إن لم أستطع شرح جزء منه ببساطة، فغالباً لم أفهمه بما يكفي بعد.
وإن كنت تبني شيئاً مشابهاً، أو تريد نظاماً كهذا لشركتك، يسعدني الحديث معك.