WhatsApp grubundan ürün: MotoRiders'ta slug, yetki ve medya

Voge sürücülerini dağınık sohbetten çıkarıp forum, rota ve bayi vitrinine aldık. Slug başlıktan ayrı durur, yetki sunucuda kontrol edilir, image optimizer production'da yanlış yere bakınca kırılır.

WhatsApp grubu bir topluluk gibi görünür ama arşiv değildir. Sohbet akar, dün konuşulan lastik basıncı yarın kaybolur, etkinlik bir hikâyede kalır. “Bu satıcıya güvenilir mi?” sorusunun cevabı tanıdık zincirine bağlıdır. Voge sürücüleri Türkiye’de tam olarak bu dağınıklığın içindeydi. MotoRiders’ı bir motosiklet temalı vitrin diye kurmadık. Forum, üye, rota, etkinlik, bayi ve ilanın aynı adreste durduğu bir ürün. Canlı adres: motoriders.com.tr.

Bu yazı case study’nin özeti değil. Orada ne teslim ettiğimizi anlattık. Burada neden o mimariyi seçtiğimizi, nerede kırıldığını ve küçük ekibin hangi kuralı bir sonraki işe taşıdığını yazıyorum. Workers her projeye lazım değil. Burada lazım oldu; nedenini gizlemiyoruz.

Broşür site bu işi taşımaz

Beş sayfalık kurumsal site şunları istemez: konu kilitleme, başlık değişince URL’nin ölmesi, bölge liderinin il temsilcisinden farklı yetkisi, R2’ye giden avatar, masaüstünde yan menü mobilde alt sekme. Bunların her biri kendi tablosu, kendi yetki kontrolü, kendi arayüz yoğunluğu ister. “Hepsini bir kerede mükemmel” demek yerine çekirdeği ayırdık: üye, forum ve bildirim. Ticari yüzeyler (bayi vitrini, üye ilanı) ikinci katman kaldı. Kampanya her yere yapışmadı; forumun üstüne oturmadı.

İlk mimari karar buydu. İkincisi altyapı.

Neden Workers, D1, R2

Oturumlu, medyalı, Türkiye’den girilen bir ürünü VPS’te de kurardık. Seçim Cloudflare oldu çünkü gecikme, medya ve HTML’in aynı kenarda yaşaması, deploy’un “sunucu güncellemesi” olmaması küçük ekibe sığıyordu. Bedavaya hız gelmez. Image optimizer, aynı origin fetch ve D1 yazma limiti: bunları production domain’de görmeden “bitti” demedik.

D1 SQLite semantiği taşır. İlişkisel model forum için doğru durur: konu, yanıt, tepki, rol, denetim izi. “Her şeyi KV’ye JSON bas” bu işte bozulur çünkü konu taşıma, kilit, arama ve sayfalama SQL ister. R2 medya için durur. Avatar ve ilan görseli D1 blob’u olmaz; Workers CPU ve satır boyutu bunu cezalandırır.

Kullandığımız sınır kabaca şöyle:

// wrangler bağları (isimler örnek)
[[d1_databases]]
binding = "DB"
database_name = "motoriders"

[[r2_buckets]]
binding = "MEDIA"
bucket_name = "motoriders-media"

Local’de binding yoksa tablo “yok” sanılır. Preview ayrı, production ayrı veritabanı id’si. Bunu README’ye yazmazsanız bir gece “veri kayboldu” paniği yaşarsınız. Kaybolmamıştır; yanlış D1’e bakıyorsunuzdur. Kendi sitemizde de aynı kural: db:migrate:local geçmeden uygulamayı temiz saymıyoruz.

Slug başlıktan ayrı durur

Rehber konu WhatsApp’ta, yer iminde ve Google’da paylaşılır. Yönetici başlığı düzeltince URL değişirse topluluğun hafızası ölür. Çözüm sıkıcı ve doğrudur: slug insert’te bir kez üretilir, title UPDATE slug’a dokunmaz.

CREATE TABLE threads (
  id TEXT PRIMARY KEY,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  locked INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL
);

-- başlık düzeltilir, adres aynı kalır
UPDATE threads SET title = ? WHERE id = ?;

Sunucu tarafında da bakılır. Butonu gizlemek yetmez. İstek doğrudan API’ye gelirse kilitli konuda başlık değişmemeli, slug asla değişmemeli. Rol hiyerarşisi (süper yönetici, yönetici, bölge lideri, il temsilcisi, moderatör, üye) menüde rozet değil; hangi SQL’in çalışacağıdır.

function assertCanRenameThread(role: Role, thread: Thread) {
  if (thread.locked && role !== "super_admin") {
    throw new Error("Kilitli konuda başlık değişmez");
  }
  if (!["super_admin", "admin", "moderator"].includes(role)) {
    throw new Error("Yetki yok");
  }
}

async function renameThread(env: Env, id: string, title: string, role: Role) {
  const thread = await env.DB.prepare(
    "SELECT id, slug, locked FROM threads WHERE id = ?"
  ).bind(id).first();
  if (!thread) throw new Error("Konu yok");
  assertCanRenameThread(role, thread);
  await env.DB.prepare(
    "UPDATE threads SET title = ? WHERE id = ?"
  ).bind(title, id).run();
  // slug kasıtlı olarak yok
}

Değişiklik kayda geçer: kim, ne zaman, eski başlık, yeni başlık. Moderasyon tartışması çıktığında log yoksa ezberle konuşursunuz.

Medya: optimizer kaynağı yanlış yerden arar

Demo’da dosya açılır. Canlıda next/image veya Cloudflare Images üzerinden gidince “upstream invalid” benzeri hata çıkar. Sebep çoğu zaman dosyanın yokluğu değil. Optimizer göreli /api/media/... yolunu statik asset sanır, Workers route’una gitmez, boş döner.

Ayırın: /public gerçekten statiktir. Üye yüklemesi R2’dedir ve uygulama route’u üzerinden servis edilir. Optimizer’a verdiğiniz kaynak URL, production host’ta o route’a düşmeli. Local’de çalışıp production’da kırılması bu üçgende olur. Kontrol olarak aynı görseli önce ham URL, sonra optimizer URL ile açın. İkisi de 200 değilse “görsel pipeline bitti” demeyin.

// Ham medya: R2'den stream
export async function GET(req: Request, env: Env) {
  const key = new URL(req.url).searchParams.get("key");
  if (!key) return new Response("yok", { status: 400 });
  const obj = await env.MEDIA.get(key);
  if (!obj) return new Response("yok", { status: 404 });
  return new Response(obj.body, {
    headers: {
      "Content-Type": obj.httpMetadata?.contentType ?? "application/octet-stream",
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  });
}

Cache uzun durur çünkü key içerik hash’i taşır. Eski dosyanın üzerine yazmazsınız; yeni key basarsınız. Aksi halde CDN eski avatar’ı günlerce gösterir.

Mobil ve masaüstü aynı ürün, farklı kabuk

Sürücü kitlesi telefonda. Moderasyon ve uzun okuma çoğu zaman masaüstünde. Alt sekme (forum, etkinlik, bildirim, profil) mobilde durur; masaüstünde yan menü ve üst bar. Küçük sapma ürünü “bozuk” hissettirir: bildirim çanı bir yüzeyde var, öbüründe yok. Favori ikonu kartın sağ üstündeyse CMS kalemini oraya koymayın; çakışır.

Performans aynı hikâye. Messenger, asistan veya ağır yönetim parçası ilk boyaya girerse LCP bozulur. Kabukta asıl içerik gelsin, ikincil widget idle’da yüklensin. “Özellik eklemek” bazen “doğru yere koymak”tır.

Yerinde CMS, her alanı HTML etmeyin

Duyuru, hero ve paket metni için deploy bekletmek pahalıdır. Yetkili kullanıcı sayfada içeriğin üstüne gelince düzenliyor, kaydedince yayınlanıyor. WordPress klonu değil. Şema var: hangi alan düz metin, hangisi zengin metin, kim görebilir. Her şeyi HTML editör yapmak en geç ikinci ay XSS ve bozuk layout üretir.

Ticaret forumu boğmasın

Üye ilanı ve bayi vitrini birinci sınıf vatandaş. Reklamı her konunun tepesine yapıştırmadık. Onay kuyruğu, paket ve vitrin kuralı duruyor. Rakam ve iç komisyon burada yok; operasyonel sır. Dışarıdan görünen şu: ticaret var, forumun üstüne oturmuyor.

Bir sonraki işe ne taşıdık

Topluluk ürünü içerik sitesi değildir; yetki, onay ve bildirim baştan konuşulur. Edge sihir değildir: medya, optimizer ve origin üçgenini gerçek domain’de test edin. Slug’ı başlıktan ayırın ki yönetici başlığı düzeltince yer imleri ölmesin. Operasyonu sonraya bırakmayın; moderasyon barı “v2” değildir. Az servis, net sınır, sık deploy: küçük ekip mikroservis koleksiyonu taşımaz.

WhatsApp grubunu kapatmanızı söylemiyoruz. Sohbet orada kalır. Aranabilir bilgi, kalıcı duyuru ve kimin ne yetkisi olduğu orada kalmaz; onu ürün olarak kurduk. Benzer bir topluluk (marka etrafı, üye, ilan, etkinlik) konuşacaksanız topluluk platformu veya iletişim. Broşür teklifi istemiyorsanız onu da söyleyin; yanlış işe girmeyelim.