۱۰ — SMS و نوتیفیکیشن
۱. Provider
| مورد | انتخاب MVP |
|---|---|
| Primary | Panelchi (api.panelchi.com) |
| Legacy | Kavenegar (اگر PANELCHI_* خالی باشد) |
| Channel MVP | SMS only |
| v2 | Push (native / web push) |
پنل: https://next.panelchi.com — API keys: https://next.panelchi.com/api-keys
مستندات: https://docs.panelchi.com
env
PANELCHI_API_KEY=...
PANELCHI_SOURCE_NUMBER=50001
PANELCHI_OTP_PATTERN=takeeat-otp # اختیاری — پترن در پنل
SMS_DEV_MODE=false # stage/prod۲. هزینه (یادآوری)
| مورد | مقدار |
|---|---|
| هر پیامک | ~۱۲–۱۷ تومان |
| خط اختصاصی | ۱۵۰–۳۰۰K/year (optional) |
| شروع | خط اشتراکی رایگان OK |
۳. Architecture (بدون Celery)
MVP: همه SMS ها مستقیم از API Route فرستاده میشوند — volume کم است، queue لازم نیست.
// 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 |
|---|---|---|---|---|
| 1 | OTP login | user/vendor | فوری | critical |
| 2 | New order (vendor) | vendor owner | بعد payment OK → awaiting_vendor | high |
| 3 | Order confirmed (client) | client | بعد vendor confirm → confirmed | high |
| 4 | Pickup reminder | client | ۳۰–۶۰ min before pickup_end | medium |
| 5 | Order cancelled | client | فوری | high |
| 6 | Payment refunded | client | بعد refund | high |
| 7 | Vendor approved | vendor | بعد admin approve | once |
ترتیب مهم: 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
| Rule | Value |
|---|---|
| Code length | 5 digits |
| Storage | hash در Redis با SET otp:{phone}:{purpose} {hash} EX 120 |
| TTL | 120 seconds |
| Max attempts | 5 → delete session |
| Resend cooldown | 60 seconds (separate Redis key) |
| Dev mode | فقط log در console |
// 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 صدا زده میشود:
// 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 و آمار):
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
// وقتی volume بالا رفت:
import Queue from "bullmq";
const smsQueue = new Queue("sms", { connection: redisConnection });
await smsQueue.add("send", { to, text });