Skip to content

۰۹ — API Endpoints (Next.js API Routes)

Base: src/app/api/
همه روت‌ها Route Handler هستند (route.ts)
Format: JSON
Auth: httpOnly cookie (db_access + db_refresh)


ساختار فایلی

src/app/api/
├── auth/
│   ├── otp/send/route.ts
│   ├── otp/verify/route.ts
│   ├── refresh/route.ts
│   └── logout/route.ts
├── boxes/
│   ├── route.ts              GET list
│   └── [id]/route.ts         GET detail
├── orders/
│   ├── route.ts              POST create
│   └── [id]/
│       ├── route.ts          GET detail
│       ├── pay/route.ts      POST → payment URL
│       ├── cancel/route.ts   POST
│       └── apply-discount/route.ts
├── payments/
│   └── zarinpal/
│       └── callback/route.ts
├── me/route.ts
├── vendor/
│   ├── register/route.ts
│   ├── me/route.ts
│   ├── boxes/
│   │   ├── route.ts
│   │   └── [id]/
│   │       ├── route.ts
│   │       ├── publish/route.ts
│   │       └── cancel/route.ts
│   └── orders/
│       ├── route.ts
│       ├── confirm-pickup/route.ts
│       ├── [id]/mark-paid/route.ts
│       └── [id]/cancel/route.ts
├── admin/
│   ├── vendors/
│   │   ├── route.ts
│   │   └── [id]/
│   │       ├── approve/route.ts
│   │       ├── reject/route.ts
│   │       └── suspend/route.ts
│   ├── packages/route.ts
│   ├── packages/[id]/route.ts
│   ├── discount-codes/route.ts
│   ├── discount-codes/[id]/route.ts
│   ├── orders/route.ts
│   └── orders/[id]/refund/route.ts
└── cron/
    └── tick/route.ts         GET — cron job (Vercel Cron یا node-cron)

Auth

POST /api/auth/otp/send

ts
// body
{ phone: "09xxxxxxxxx", purpose: "login_client" | "login_vendor" }

// response 200
{ expires_in: 120 }

// errors
// 429 OTP_RATE_LIMIT — بیش از ۳ بار/ساعت

POST /api/auth/otp/verify

ts
// body
{ phone: "09xxxxxxxxx", code: "12345", purpose: "login_client" | "login_vendor" }

// response 200 — sets httpOnly cookies
{ role: "client" | "vendor", name: string | null }

// errors
// 401 OTP_INVALID
// 401 OTP_EXPIRED
// 429 OTP_MAX_ATTEMPTS

POST /api/auth/refresh

ts
// cookie: db_refresh
// response 200 — new db_access cookie
{ ok: true }

POST /api/auth/logout

ts
// clears cookies
// 204

Client — Boxes

GET /api/boxes

ts
// query
?district=&min_price=&max_price=&page=1&page_size=20

// response
{
  items: [{
    id, vendorId, vendorName, vendorSlug, vendorLogoUrl, vendorTrustBadge,
    title, category, imageUrl,
    originalValueEstimate, salePrice,
    quantityRemaining,
    pickupStart, pickupEnd,
    distanceM: number | null
  }],
  page, page_size, total
}

GET /api/boxes/[id]

ts
{
  id, title, description, category, imageUrl,
  originalValueEstimate, salePrice, quantityRemaining,
  pickupStart, pickupEnd, status,
  vendor: { id, name, slug, address, lat, lng, logoUrl, trustBadge }
}

Client — Orders

POST /api/orders

ts
// body
{ surpriseBoxId: string, discountCode?: string, paymentMethod: "gateway" | "pay_at_pickup" }

// response 201
{ orderId, pickupCode, totalPrice, discountAmount, paymentMethod }

// errors
// 409 BOX_SOLD_OUT
// 409 PICKUP_WINDOW_CLOSED
// 400 DISCOUNT_INVALID

POST /api/orders/[id]/pay

ts
// مخصوص paymentMethod=gateway
// response 200
{ paymentUrl: "https://zarinpal.com/pg/StartPay/..." }

GET /api/orders/[id]

ts
{
  id, orderStatus, paymentStatus, paymentMethod,
  pickupCode, totalPrice, discountAmount,
  surpriseBox: { title, vendorName, pickupStart, pickupEnd, address },
  confirmedAt, pickedUpAt
}

POST /api/orders/[id]/apply-discount

ts
// body — قبل از pay
{ code: "WELCOME20" }
// response
{ discountAmount, totalPrice }

POST /api/orders/[id]/cancel

ts
// body
{ reason?: string }
// 204
// **فقط وقتی order_status = pending یا awaiting_vendor**
// بعد از اینکه وندور تأیید کرد (confirmed) → 409 CANCEL_NOT_ALLOWED
// errors: 409 CANCEL_NOT_ALLOWED

Payment callback

GET /api/payments/zarinpal/callback

ts
// query از Zarinpal
?Authority=xxx&Status=OK

// 1. verify with Zarinpal API
// 2. payment.status = paid, payment.paidAt
// 3. order.paymentStatus = paid, order.orderStatus = awaiting_vendor  ← نه confirmed
// 4. decrement quantityRemaining
// 5. send SMS to VENDOR: «سفارش جدید — لطفاً تأیید کنید»
// 6. redirect → /order/[id] (success) یا /order/[id]?failed=1
// ** مشتری SMS نمی‌گیرد تا وندور تأیید کند **

Client — Profile

GET /api/me

ts
{ id, phone, name, role }

PATCH /api/me

ts
// body
{ name?: string }

Vendor

POST /api/vendor/register

ts
// body
{ name, category, address, lat, lng, phone, description? }
// response 201
{ vendorId, status: "pending" }

GET /api/vendor/me

ts
{ id, name, status, package: { name, commissionRate } | null }

POST /api/vendor/boxes

ts
// body
{
  title, description?, category, imageUrl?,
  originalValueEstimate, salePrice,
  quantityTotal, pickupStart, pickupEnd
}
// response 201 — status: draft
// errors: 409 BOX_ALREADY_EXISTS_TODAY

POST /api/vendor/boxes/[id]/publish

ts
// draft → active
// errors: 403 VENDOR_NOT_APPROVED

GET /api/vendor/orders

ts
// query: ?date=2026-06-23
{
  items: [{ id, pickupCode, userPhone, orderStatus, paymentMethod, paymentStatus, totalPrice, createdAt }]
}

POST /api/vendor/orders/[id]/confirm

ts
// ← تأیید سفارش توسط وندور (awaiting_vendor → confirmed)
// این باید قبل از اطلاع‌رسانی مشتری انجام شود
// 1. order.orderStatus = awaiting_vendor چک می‌شود
// 2. order.orderStatus = confirmed
// 3. SMS به مشتری: «باکس شما آماده تحویل است»
// response 200
{ orderId }
// errors: 404, 409 ORDER_ALREADY_CONFIRMED, 403 FORBIDDEN

POST /api/vendor/orders/confirm-pickup

ts
// body
{ pickupCode: "ABC123" }
// 1. find order by pickupCode, check vendor owns it
// 2. order.orderStatus = picked_up, order.pickedUpAt
// 3. if pay_at_pickup → paymentStatus = paid
// response 200
{ orderId, userName }
// errors: 404 PICKUP_CODE_NOT_FOUND, 409 ORDER_ALREADY_CONFIRMED

POST /api/vendor/orders/[id]/cancel

ts
// وندور می‌تواند سفارش را قبل از picked_up لغو کند
// response 204
// errors: 409 CANCEL_NOT_ALLOWED (اگر picked_up)

POST /api/vendor/orders/[id]/mark-paid

ts
// فقط pay_at_pickup
// payment_status → paid

GET /api/vendor/stats/summary

ts
// query: ?from=2026-06-01&to=2026-06-23
{ ordersCount, revenue, pickupRate, noShowCount }

Admin

GET /api/admin/vendors

ts
// query: ?status=pending&page=1

POST /api/admin/vendors/[id]/approve

ts
// body: { packageId?: string }
// vendor.status = approved, vendor.approvedAt
// send SMS to vendor owner

POST /api/admin/vendors/[id]/reject

ts
// body: { reason: string }

CRUD /api/admin/packages

ts
// GET list, POST create, PATCH /[id], DELETE /[id]

CRUD /api/admin/discount-codes

ts
// GET list, POST create, PATCH /[id] (toggle isActive)

GET /api/admin/orders

ts
// filters: vendorId, status, dateFrom, dateTo

POST /api/admin/orders/[id]/refund

ts
// stub MVP — mark payment_status=refunded
// v2: Zarinpal refund API

Cron

GET /api/cron/tick

ts
// secret header: x-cron-secret
// runs:
//   markNoShows()   — orders past pickup_end → no_show
//   sendReminders() — 25-35 min before pickup_end → SMS
//   expireBoxes()   — active boxes past pickup_end → expired
// response: { noShows, reminders, expiredBoxes }

Format خطا

ts
{
  error: {
    code: string,     // "BOX_SOLD_OUT"
    message: string   // "آخرین باکس رزرو شد"
  }
}

Common error codes

CodeHTTP
OTP_INVALID401
OTP_EXPIRED401
OTP_RATE_LIMIT429
BOX_SOLD_OUT409
BOX_ALREADY_EXISTS_TODAY409
PICKUP_WINDOW_CLOSED409
DISCOUNT_INVALID400
PAYMENT_FAILED402
FORBIDDEN403
VENDOR_NOT_APPROVED403
CANCEL_NOT_ALLOWED409
PICKUP_CODE_NOT_FOUND404
ORDER_ALREADY_CONFIRMED409

Pagination (standard)

ts
{ items: T[], page: number, page_size: number, total: number }

Rate limits (middleware)

EndpointLimit
/api/auth/otp/send3/hour/phone
/api/vendor/orders/confirm-pickup20/min/vendor
/api/cron/ticksecret header required