Skip to content

۱۰ — SMS و نوتیفیکیشن


۱. Provider

موردانتخاب MVP
PrimaryPanelchi (api.panelchi.com)
LegacyKavenegar (اگر PANELCHI_* خالی باشد)
Channel MVPSMS only
v2Push (native / web push)

پنل: https://next.panelchi.com — API keys: https://next.panelchi.com/api-keys
مستندات: https://docs.panelchi.com

env

env
PANELCHI_API_KEY=...
PANELCHI_SOURCE_NUMBER=50001
PANELCHI_OTP_PATTERN=takeeat-otp   # اختیاری — پترن در پنل
SMS_DEV_MODE=false                 # stage/prod

۲. هزینه (یادآوری)

موردمقدار
هر پیامک~۱۲–۱۷ تومان
خط اختصاصی۱۵۰–۳۰۰K/year (optional)
شروعخط اشتراکی رایگان OK

05-budget-and-costs.md


۳. Architecture (بدون Celery)

MVP: همه SMS ها مستقیم از API Route فرستاده می‌شوند — volume کم است، queue لازم نیست.

ts
// src/lib/sms.ts — fire-and-forget از API Route
void sendSms({ to, template: "order_confirmed", vars: { ... } });

// Provider chain:
// 1. SMS_DEV_MODE=true → console.log + Notification.status=sent
// 2. PANELCHI_API_KEY + PANELCHI_SOURCE_NUMBER → panelchiSendPattern (OTP) یا panelchiSendSimple
// 3. KAVENEGAR_API_KEY + KAVENEGAR_SENDER → legacy fallback
// 4. هیچ‌کدام → log + providerRef=fallback

// OTP: اگر PANELCHI_OTP_PATTERN تنظیم باشد → pattern با vars { brand, code, ttl, domain }
// سایر templateها → simple SMS

فایل‌های مرتبط: src/lib/sms.ts, src/lib/panelchi.ts, src/lib/otpSms.ts

اگر volume بالا رفت (v2): BullMQ + Redis برای queue — همان Redis که OTP رویش است.


۴. رویدادهای SMS (MVP)

#EventگیرندهزمانPriority
1OTP loginuser/vendorفوریcritical
2New order (vendor)vendor ownerبعد payment OK → awaiting_vendorhigh
3Order confirmed (client)clientبعد vendor confirm → confirmedhigh
4Pickup reminderclient۳۰–۶۰ min before pickup_endmedium
5Order cancelledclientفوریhigh
6Payment refundedclientبعد refundhigh
7Vendor approvedvendorبعد admin approveonce

ترتیب مهم: SMS شماره ۲ (به وندور) قبل از SMS شماره ۳ (به مشتری) ارسال می‌شود.
مشتری تا زمانی که وندور تأیید نکند، هیچ SMS ای دریافت نمی‌کند.


۵. قالب پیامک

نام برند: TakeEat / تیک ایت — ثابت در @/lib/brand

OTP (با autofill مرورگر)

متن واقعی از buildOtpSmsBody() — خط آخر برای WebOTP:

کد تأیید تیک ایت: 123456
اعتبار ۲ دقیقه

@stage.takeeat.ir #123456

پترن پنلچی (اسلاگ takeeat-otp):

کد تأیید %brand%: %code%
اعتبار %ttl% دقیقه

@%domain% #%code%

متغیرها: brand, code, ttl, domain

فرانت: autocomplete="one-time-code" + WebOTP API (useOtpAutofill)
پشتیبانی: Chrome/Edge/Opera Android (WebOTP)، Safari iOS (کیبورد پیشنهاد)، Firefox (autocomplete)

Order confirmed (client)

{brand}: رزرو شما در {vendor_name} تأیید شد.
تحویل: {pickup_start}-{pickup_end}
کد: {pickup_code}

Pickup reminder

{brand}: یادآوری — باکس {vendor_name} تا {pickup_end} آماده تحویل است.
کد: {pickup_code}

Order cancelled

{brand}: سفارش شما در {vendor_name} لغو شد.
{refund_note}

Payment refunded

{brand}: مبلغ {amount} تومان به حساب شما برگشت داده شد.

New order (vendor) — هنگام awaiting_vendor

{brand}: سفارش جدید دریافت شد.
کد تحویل: {pickup_code}
برای تأیید به پنل وندور مراجعه کنید.

Order confirmed (client) — هنگام confirmed

{brand}: رزرو شما در {vendor_name} تأیید شد.
تحویل: {pickup_start}–{pickup_end}
کد تحویل: {pickup_code}

Vendor approved

{brand}: حساب {vendor_name} فعال شد. می‌توانید اولین باکس را منتشر کنید.

۶. OTP rules

RuleValue
Code length5 digits
Storagehash در Redis با SET otp:{phone}:{purpose} {hash} EX 120
TTL120 seconds
Max attempts5 → delete session
Resend cooldown60 seconds (separate Redis key)
Dev modeفقط log در console
ts
// src/lib/otp.ts
import { redis } from "./redis";
import crypto from "crypto";

export function generateOtp(): string {
  return Math.floor(10000 + Math.random() * 90000).toString();
}

export function hashOtp(code: string): string {
  return crypto.createHash("sha256").update(code).digest("hex");
}

export async function storeOtp(phone: string, purpose: string, code: string) {
  await redis.set(`otp:${phone}:${purpose}`, hashOtp(code), "EX", 120);
}

export async function verifyOtp(phone: string, purpose: string, code: string): Promise<boolean> {
  const stored = await redis.get(`otp:${phone}:${purpose}`);
  if (!stored) return false;
  const match = stored === hashOtp(code);
  if (match) await redis.del(`otp:${phone}:${purpose}`);
  return match;
}

۷. Cron (pickup reminder + no-show)

از src/lib/cron.ts صدا زده می‌شود:

ts
// src/lib/cron.ts — اجرا هر ۵ دقیقه از /api/cron/tick

export async function sendPickupReminders(): Promise<number> {
  const now = new Date();
  const in60min = new Date(now.getTime() + 60 * 60 * 1000);
  const in30min = new Date(now.getTime() + 30 * 60 * 1000);

  const orders = await prisma.order.findMany({
    where: {
      orderStatus: "confirmed",
      box: {
        pickupEnd: { gte: in30min, lte: in60min }, // ← pickupEnd روی SurpriseBox، نه Order
      },
    },
    include: {
      user: { select: { phone: true } },
      vendor: { select: { name: true } },
      box: { select: { pickupEnd: true } },
    },
  });

  for (const order of orders) {
    void sendSms({ to: order.user.phone, template: "pickup_reminder", vars: { ... } });
  }
  return orders.length;
}

// Auto-cancel orders stuck in awaiting_vendor after pickup_start
export async function expireUnconfirmedOrders(): Promise<number> {
  const now = new Date();
  const result = await prisma.order.updateMany({
    where: {
      orderStatus: "awaiting_vendor",
      box: { pickupStart: { lt: now } }, // ← pickup window started, vendor still hasn't confirmed
    },
    data: { orderStatus: "cancelled", cancelReason: "vendor_no_response", cancelledAt: now },
  });
  return result.count;
}

نکته: pickupEnd فیلد SurpriseBox است، نه Order — همیشه از box: { pickupEnd: ... } استفاده کن.

اگر روی Vercel: node-cron کار نمی‌کند — به جایش /api/cron/tick + Vercel Cron Job (هر ۵ دقیقه).


۸. Notification log

هر SMS ارسال‌شده در جدول Notification ثبت می‌شود (برای debug و آمار):

ts
await prisma.notification.create({
  data: {
    userId: order.userId,
    phone: user.phone,
    channel: "sms",
    template: "order_confirmed",
    payload: { orderId: order.id, pickupCode: order.pickupCode },
    status: "sent",
    sentAt: new Date(),
  },
});

۹. v2 — BullMQ queue

ts
// وقتی volume بالا رفت:
import Queue from "bullmq";
const smsQueue = new Queue("sms", { connection: redisConnection });
await smsQueue.add("send", { to, text });