এই পর্বে যা শিখবেন
- একটি ছোট প্রকল্পের brief লেখা: কার জন্য, কী সমস্যা, must-have আর nice-to-have আলাদা করা, আর scope ধরে রাখা
- কাজকে ছয়টি milestone-এ ভাগ করা, প্রতিটির জন্য হ্যাঁ-না দিয়ে যাচাই করা যায় এমন acceptance criteria লেখা
- ডেটা মডেল, folder-এর গঠন আর মূল কোড: session login, catalogue, checkout-এর transaction, signed webhook, email, admin-এর role
- প্রতিটি স্তরে test, production build, Lighthouse, আর নিরাপত্তা, performance, accessibility, deployment প্রস্তুতির সৎ review
- VPS আর PaaS-এ deploy করার ধাপ, stretch goal, portfolio আর ইন্টারভিউতে প্রকল্পটি উপস্থাপন, আর নিজের কাজ rubric দিয়ে মাপা
১ · Brief আর scope — বাজারঘর কী, কী নয়
গ্রামের হাটের কথা ভাবুন। একজন দোকানির লাগে চারটি জিনিস: মাল সাজানো, ক্রেতার দেখা আর বাছাই, দাম নেওয়া আর হিসাব খাতা। বাজারঘর ঠিক এমন একটি ছোট দোকান: বিশটি দেশি পণ্য, দুজন মানুষ (একজন ক্রেতা, একজন পরিচালক), আর পুরো পথ, পণ্য খোঁজা থেকে পেমেন্টের রসিদ পর্যন্ত।
এই পর্বটি একটি ক্যাপস্টোন: কোর্সের প্রায় সব মডিউল একটি প্রকল্পে জোড়া লাগে। ধাপে ধাপে কোড নয়; থাকছে brief, ছয়টি milestone, যাচাইয়ের উপায় আর কোন পর্বে ফিরবেন। পাশে আছে আমাদের নিজের একটি reference implementation: প্রায় ১,২৭০ লাইনের একটি আসল Next.js প্রকল্প, যা এই পাতা লেখার আগে বানিয়ে চালানো হয়েছে। পাতার প্রতিটি আউটপুট সেখান থেকে নেওয়া। এটি নকলের জন্য নয়, নিজের উত্তর মেলানোর জন্য।
Product brief — এক পাতায়
কার জন্য
রাজশাহীর রহিমা, যিনি ফোনে দেশি চাল, ইলিশ বা জামদানি কিনতে চান, বাংলায় পড়েন আর টাকার হিসাব টাকাতেই দেখতে চান। আর পরিচালক করিম, যিনি দেখেন কোন অর্ডার পরিশোধিত হলো আর কোনটি পাঠাতে হবে।
ব্যবহারকারীকী সমস্যা
ক্রেতা চান: দেখা দামই কাটবে, পণ্য সত্যিই আছে, টাকা দিলে রসিদ আসবে। দোকান চায়: এক পণ্য দুজনকে নয়, আর নকল “টাকা পেয়েছি” বার্তায় পণ্য না পাঠানো।
লক্ষ্যসাফল্যের মাপ
একজন ক্রেতা ফোনের পর্দায় খোঁজা থেকে রসিদের email পর্যন্ত এক বসায় যেতে পারেন, আর এই পুরো পথ একটি স্বয়ংক্রিয় end-to-end test-এ প্রতিবার পাস করে।
মাপMust-have বনাম nice-to-have
বেশিরভাগ শেখার প্রকল্প শেষ হয় না ফিচারের অভাবে নয়, ফিচারের ভারে। প্রতি সপ্তাহে “আর একটা ছোট জিনিস” ঢোকে, যাকে বলে scope creep, আর শেষে কোনোটাই পুরো চলে না। তাই শুরুতেই কাজগুলো MoSCoW পদ্ধতিতে ভাগ করুন: Must (না থাকলে প্রকল্পই নয়), Should (গুরুত্বপূর্ণ, কিন্তু ছাড়া চলে), Could (সময় থাকলে), Won't (এবার নয়, লিখে রাখুন)। এটিই MVP-এর সংজ্ঞা।
| ভাগ | কাজ | কোন পর্বে শিখেছেন |
|---|---|---|
| Must | পণ্যের তালিকা, খোঁজা আর পাতা ভাগ (pagination) | পর্ব ৩৪ |
| Must | Session-ভিত্তিক login আর logout | পর্ব ৪৫ |
| Must | কার্ট আর checkout: server action, Zod যাচাই, transaction-এ স্টক আটকে রাখা | পর্ব ৫৪, ২১, ৪৩ |
| Must | নকল gateway, সই করা webhook, একই event দুবার এলে নিরাপদ | পর্ব ৬৫ |
| Must | পরিশোধের পরে অর্ডারের email (local SMTP-তে ধরা) | পর্ব ৬৩ |
| Must | Admin পাতা, role যাচাই server-এ | পর্ব ৪৮ |
| Should | বাংলা অঙ্ক আর ৳, metadata, sitemap, প্রতিটি স্তরে test, Lighthouse | পর্ব ৫৯, ৫৮, মডিউল ১১, ৮৪ |
| Could | কুপন, login-এর rate limit, অপরিশোধিত অর্ডারের মেয়াদ | পর্ব ৪৯, ৬২ |
| Won't (এবার) | অতিথির কার্ট, ছবি, পণ্যের review, একাধিক বিক্রেতা, আসল gateway, ইংরেজি সংস্করণ | অংশ ৯-এর stretch goal |
দর্জি আগে মাপ নেন, তারপর কাপড় কাটেন। “পরে দেখা যাবে” বলে কাটতে শুরু করলে হাতা হয়ে যায় লম্বা, কোমর ছোট। Brief আর scope হলো প্রকল্পের মাপ নেওয়া। কাগজে দশ মিনিট, কোডে দশ দিন বাঁচায়।
লেখার সময়ের version
Reference app-এর সত্যিকারের run-এ ব্যবহার হয়েছে: Node 24.15.0, Next.js 16.4.4 (App Router, Turbopack, create-next-app-এর ডিফল্ট Cache Components চালু), React 19.3.0, TypeScript 5.9.3, Tailwind CSS 4.3.4, Prisma 7.10.1 আর @prisma/adapter-pg, Zod 4.6.7, Nodemailer 10.3.1, smtp-server 3.21.3, Vitest 5.1.0, Playwright 1.64.1 (Microsoft Edge 156-এ), Lighthouse 13.6.0. Database আসল PostgreSQL 18.6, চলেছে npm-এর embedded-postgres 18.6.0-beta.19-এ; এটি beta, শুধু শেখার ল্যাবের জন্য। Prisma-র npm latest এখন 8-এর release candidate, তাই stable 7.10.1. (লেখার দিন 16.4.5-ও এসেছিল; কোর্সের সাথে মেলাতে 16.4.4.) সব server চলেছে শুধু 127.0.0.1-এ, কাজ শেষে নিজের process ID ধরে বন্ধ করা হয়েছে। Docker এই মডিউলে ব্যবহার হয়নি।
শুরুতেই প্রথম আসল বাধা এল। create-next-app @types/node@^20 বসায়, আর Vitest 5.1 চায় 22 বা 24-এর ওপরে, তাই npm থামল:
npm error Could not resolve dependency:
npm error peerOptional @types/node@"^22.0.0 || >=24.0.0" from vitest@5.1.0
সমাধান --force নয়; Node 24 চলছে, তাই @types/node@^24. এমন দ্বন্দ্ব প্রায় প্রতিটি নতুন প্রকল্পে আসে (পর্ব ৫)।
২ · ডেটা মডেল আর folder-এর গঠন
কোডের আগে কাগজে টেবিল আঁকুন: কী আলাদা টেবিল, কী কীভাবে জোড়া, কোন নিয়ম database নিজেই পাহারা দেবে। বাজারঘরের সাতটি টেবিল নিচের ER ডায়াগ্রামে (তত্ত্ব আছে DBMS কোর্সের ER পর্বে, আর দোকানের মডেল পর্ব ৪১-এ):
বাজারঘরের ডেটা মডেল: সাতটি টেবিল
Entity-relationship diagram of the reference implementation (Prisma schema, PostgreSQL)
তিনটি নকশার সিদ্ধান্ত লক্ষ করুন। এক, OrderItem.unitPricePaisa: অর্ডারের সময়ের দাম আলাদা করে রাখা। দুই, Session.id-এ টোকেন নয়, টোকেনের SHA-256. Database ফাঁস হলেও চালু session চুরি হয় না। তিন, WebhookEvent.id হলো gateway-এর পাঠানো event id, primary key হিসেবে। একই event দ্বিতীয়বার ঢোকাতে গেলে database নিজেই আটকায়; এটাই idempotent webhook-এর ভিত্তি। Schema-র দুটি মডেল (সত্যিকারের ফাইল থেকে, অংশ):
enum OrderStatus {
PENDING_PAYMENT
PAID
SHIPPED
DELIVERED
CANCELLED
REFUNDED
}
model Product {
id Int @id @default(autoincrement())
slug String @unique
name String
pricePaisa Int
stock Int
cartItems CartItem[]
orderItems OrderItem[]
}
model Order {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
status OrderStatus @default(PENDING_PAYMENT)
subtotalPaisa Int
discountPaisa Int
deliveryPaisa Int
totalPaisa Int
coupon String?
phone String
address String
createdAt DateTime @default(now())
items OrderItem[]
@@index([userId])
@@index([status, createdAt])
}Prisma-র schema-য় CHECK constraint লেখা যায় না, তাই দ্বিতীয় একটি migration হাতে লেখা হয়েছে (migrate dev --create-only, তারপর SQL). এতে কোডে কোনো ভুল থাকলেও স্টক কখনো শূন্যের নিচে যাবে না:
-- Stock can never go below zero, even if application code has a bug.
ALTER TABLE "Product" ADD CONSTRAINT "Product_stock_nonnegative" CHECK ("stock" >= 0);
ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_qty_positive" CHECK ("qty" > 0);খালি database-এ দুটি migration চালানোর সত্যিকারের আউটপুট (Prisma 7.10.1, migrate deploy, যেমন production-এ চলবে):
~/bazarghar$ npx prisma migrate deploy
Loaded Prisma config from prisma7.config.ts.
Datasource "db": PostgreSQL database "bazarghar_capstone", schema "public" at "127.0.0.1:47188"
Applying migration `20261104092755_init`
Applying migration `20261104092809_stock_nonnegative`
The following migration(s) have been applied:
migrations/
└─ 20261104092755_init/
└─ migration.sql
└─ 20261104092809_stock_nonnegative/
└─ migration.sql
All migrations have been successfully applied.
~/bazarghar$ npx prisma db seed
seeded 2 users, 20 products
বানাতে গিয়ে পাওয়া আসল বাধা: বাংলা লেখা database-এ ঢুকল না। প্রথমবার seed চালাতেই PostgreSQL বলল character with byte sequence 0xe0 0xa6 0xb0 in encoding "UTF8" has no equivalent in encoding "WIN1252" (Prisma-র code P2039, database-এর code 22P05). Windows-এ initdb নিজে থেকে system-এর locale নিয়েছিল, তাই database-এর encoding হয়েছিল WIN1252, যেখানে বাংলা অক্ষরই নেই। সমাধান ছিল cluster নতুন করে বানানো, --encoding=UTF8 --locale=C দিয়ে, তারপর একই migration আবার চালানো। শিক্ষা: বাংলা সাইটের M1-এর acceptance check-এ “বাংলা নাম database-এ ঠিকভাবে ফেরে” একটি আলাদা লাইন হওয়া উচিত। Production-এর managed database-এ সাধারণত UTF-8 থাকে, তবুও SHOW server_encoding; চালিয়ে দেখে নিন।
Folder-এর গঠন
App Router-এ folder-ই route (পর্ব ৫১)। নিয়মটি সহজ রাখা হয়েছে: app/-এ শুধু পাতা, server action আর route handler; সব ব্যবসার নিয়ম lib/-এ, যাতে test সেগুলো Next.js ছাড়াই ডাকতে পারে (পর্ব ২৯-এর স্তরভাগ)।
bazarghar/
├─ app/
│ ├─ layout.tsx lang="bn", metadata, নিচে user-এর অংশ Suspense-এ
│ ├─ page.tsx হোম
│ ├─ products/page.tsx তালিকা, ?q= আর ?page=
│ ├─ products/[slug]/ পণ্যের পাতা, generateMetadata
│ ├─ login/ পাতা, form, actions.ts
│ ├─ cart/ পাতা, checkout-form.tsx, actions.ts
│ ├─ pay/[orderId]/ নকল gateway (শুধু ল্যাব)
│ ├─ orders/[id]/ অর্ডারের অবস্থা
│ ├─ admin/orders/ role যাচাই, actions.ts
│ ├─ api/webhooks/payment/route.ts
│ ├─ sitemap.ts · robots.ts · not-found.tsx
├─ components/ quantity-picker.tsx, user-nav.tsx
├─ lib/ auth, password, db, money, catalogue,
│ orders, payments, order-state,
│ webhook, mail, validation
├─ prisma/ schema.prisma, migrations/, seed.ts
├─ tests/unit · tests/component · tests/integration
├─ e2e/ buy.spec.ts, admin.spec.ts, helpers.ts
├─ scripts/ db.mjs, smtp-capture.mjs, webhook-check.mjs
├─ prisma7.config.ts · vitest.config.mts · playwright.config.ts
└─ .env নকল মান; .gitignore-এ আছে
৩ · ছয়টি milestone আর acceptance criteria
বড় কাজকে ছোট থামার জায়গায় ভাগ করুন, যাকে বলে milestone. প্রতিটির শেষে প্রকল্পটি চালানো যায় এমন অবস্থায় থাকবে; আর প্রতিটির জন্য আগে লিখুন কখন “শেষ” বলবেন: acceptance criteria, যার প্রতিটি লাইন হ্যাঁ বা না দিয়ে যাচাই করা যায়। “login ভালো কাজ করে” কোনো criterion নয়; “ভুল পাসওয়ার্ড আর অজানা email-এ হুবহু একই বার্তা আসে” একটি criterion. লেখার নিয়মের তত্ত্ব আছে Software Engineering কোর্সের user story পর্বে।
| Milestone | কী শেষ হবে | কীভাবে যাচাই (acceptance) | আবার দেখুন |
|---|---|---|---|
| M1 ভিত্তি | Next.js, TypeScript, Tailwind; PostgreSQL; schema, দুটি migration, seed | খালি database-এ migrate deploy দুটিই চালায়; seed ২ জন user আর ২০টি পণ্য বলে; বাংলা নাম ঠিক ফেরে; README-তে চালানোর ধাপ | ৫, ৩৯, ৪০, ৪১ |
| M2 ক্যাটালগ | তালিকা, খোঁজা, পাতা ভাগ, পণ্যের পাতা, metadata, sitemap | /products?page=3-এ “পাতা ৩ / ৩”; ilish খুঁজলে ইলিশ আসে; অজানা slug-এ “পাতাটি পাওয়া যায়নি” আর noindex; description আছে <head>-এ | ৩৪, ৫৩, ৫৮ |
| M3 লগইন | scrypt hash, session table, cookie, logout | ভুল পাসওয়ার্ড আর অজানা email-এ একই বার্তা; cookie HttpOnly, SameSite=Lax; database-এ টোকেন নয়, তার hash; login-এ rate limit | ৪৫, ৪৯ |
| M4 checkout | কার্ট, server action, Zod, কুপন, transaction-এ স্টক আটকানো | শেষ জারটি দুজন একসাথে কিনলে একজনই পান (integration test); ভুল ফোন নম্বরে বাংলা বার্তা; কুপন আর ডেলিভারির নিয়ম unit test-এ; অপরিশোধিত অর্ডারের মেয়াদ | ২১, ৪৩, ৫৪ |
| M5 পেমেন্ট | নকল gateway, সই করা webhook, অবস্থার যন্ত্র, email | সই ছাড়া বা পুরোনো সইয়ে 401; একই event দুবার এলে duplicate; email local SMTP-তে ধরা পড়ে; email ব্যর্থ হলে আবার চেষ্টা | ৬৩, ৬৫ |
| M6 production | admin, test, build, Lighthouse, review | customer-এর কাছে admin পাতা নেই; সব test পাস; next build পাস; মোবাইল Lighthouse-এ accessibility ≥ ৯০; CI-তে চালানো | ৪৮, মডিউল ১১, ৭৯, ৮১, ৮৪ |
নিচের stepper-এ প্রতিটি milestone-এর শেষে reference app-এ যে প্রমাণ পাওয়া গেছে তা দেখুন। এগুলো সত্যিকারের run-এর আউটপুট, ছোট করা।
- M1
ভিত্তিschema, seed - M2
ক্যাটালগখোঁজা, পাতা - M3
লগইনsession - M4
checkouttransaction - M5
পেমেন্টwebhook, email - M6
productiontest, build
db ready bazarghar_capstone port 47188
PostgreSQL 18.6 ... encoding UTF8
Applying migration `..._init`
Applying migration `..._stock_nonnegative`
seeded 2 users, 20 products
GET /products?page=3 → "পাতা ৩ / ৩"
GET /sitemap.xml → /, /products, ২০টি পণ্য
GET /robots.txt → Disallow: /cart /pay/ /admin/ …
GET /products/no-such → "পাতাটি পাওয়া যায়নি"
<meta name="robots" content="noindex">
wrong password -> ইমেইল বা পাসওয়ার্ড ঠিক নয়।
unknown email -> ইমেইল বা পাসওয়ার্ড ঠিক নয়।
cookie bg_session value length 43
httpOnly true sameSite Lax secure true days 7
বাকি: login rate limit
✓ sells the last jar exactly once when
two buyers press at the same time 162ms
phone "12345" → সঠিক মোবাইল নম্বর দিন (01XXXXXXXXX)
বাকি: অপরিশোধিত অর্ডারের মেয়াদ
no signature 401 {"error":"missing"}
forged amount 401 {"error":"bad-signature"}
signed, 10 min old 401 {"error":"stale"}
signed 200 {"outcome":"paid"}
same event again 200 {"outcome":"duplicate"}
Vitest Tests 21 passed (21)
Playwright 3 passed (4.5s)
next build ✓ Compiled successfully
Lighthouse perf 96 · a11y 100 · bp 100 · seo 100
বাকি: CI-তে চালানো
- M1 ভিত্তি. নিজের port (৪৭১৮৮) আর নিজের database, encoding UTF8 নিশ্চিত, দুটি migration আর seed. বাকি একটি check: README-তে চালানোর ধাপ।
- M2 ক্যাটালগ. বিশটি পণ্য, পাতায় আটটি, তাই তৃতীয় পাতাই শেষ। Sitemap-এ ২২টি URL; robots.txt ব্যক্তিগত পাতা বাদ দেয়। অজানা পণ্যে
noindex; HTTP status কেন ২০০, তা অংশ ৮-এ। - M3 লগইন. দুই রকম ভুলে একই বার্তা, যাতে কেউ email আছে কি না জানতে না পারেন (user enumeration). ৪৩ অক্ষর মানে ৩২ byte, base64url-এ। Rate limit বাকি, তাই ৭৫%.
- M4 checkout. সবচেয়ে কঠিন check পাস: একটিমাত্র গুড়ের জার, দুজন ক্রেতা, অর্ডার একটিই। বাকি: পরিশোধ না করা অর্ডার চিরকাল স্টক আটকে রাখে।
- M5 পেমেন্ট. পাঁচটি অনুরোধ, পাঁচটি প্রত্যাশিত উত্তর: তিনটি 401, তারপর
paid, তারপরduplicate. Email server বন্ধ থাকলে আবার চেষ্টার ব্যবস্থা নেই। - M6 production. ২১ + ৩টি test, build আর Lighthouse সবুজ; CI (পর্ব ৮১) চালানো হয়নি। মোট ২৪টির মধ্যে ১৯টি check পাস।
এই হিসাব হাতে না করে একটি ছোট tracker-এ রাখুন। নিচের ব্লকে reference app-এর আসল অবস্থা বসানো আছে। নিজের প্রকল্পের check বসিয়ে চালান:
// Milestone tracker: প্রতিটি acceptance check পাস (true) বা বাকি (false)
// মানগুলো বাজারঘরের reference implementation-এর আসল অবস্থা (৪ নভেম্বর ২০২৬)
const milestones = [
["M1 ভিত্তি", { "migrate deploy খালি DB-তে": true, "seed: 2 user, 20 product": true,
"বাংলা নাম DB-তে ঠিক": true, "README-তে চালানোর ধাপ": false }],
["M2 ক্যাটালগ", { "search আর page": true, "অজানা slug -> not found": true,
"metadata <head>-এ": true, "sitemap.xml": true }],
["M3 লগইন", { "scrypt hash": true, "HttpOnly cookie": true,
"ভুল পাসওয়ার্ডে একই বার্তা": true, "login rate limit": false }],
["M4 checkout", { "server-এ Zod যাচাই": true, "শেষ জার একবারই বিক্রি": true,
"কুপন আর ডেলিভারির নিয়ম test-এ": true, "অপরিশোধিত অর্ডারের মেয়াদ": false }],
["M5 পেমেন্ট", { "সই ছাড়া 401": true, "replay -> duplicate": true,
"email ধরা পড়ল": true, "email ব্যর্থ হলে আবার চেষ্টা": false }],
["M6 production", { "next build": true, "২৪টি test পাস": true,
"Lighthouse a11y >= 90": true, "CI-তে চালানো": false }],
];
let done = 0, all = 0;
for (const [name, checks] of milestones) {
const vals = Object.values(checks);
const ok = vals.filter(Boolean).length;
done += ok; all += vals.length;
const bar = "█".repeat(ok) + "░".repeat(vals.length - ok);
const todo = Object.keys(checks).filter((k) => !checks[k]);
console.log(`${name.padEnd(14)} ${bar} ${Math.round(100 * ok / vals.length)}%` +
(todo.length ? ` বাকি: ${todo.join(", ")}` : ""));
}
console.log(`মোট: ${done}/${all} = ${Math.round(100 * done / all)}%`);আউটপুটের প্রথম লাইন M1 ভিত্তি ███░ 75% বাকি: README-তে চালানোর ধাপ, দ্বিতীয় M2 ক্যাটালগ ████ 100%, আর শেষ লাইন মোট: 19/24 = 79%. প্রতিটি milestone-এ চারটি check, তাই একটি বাকি থাকলেই ৭৫%. এক, শতাংশ শুধু check গোনে, ওজন দেয় না: “README নেই” আর “শেষ জার দুবার বিক্রি” দুটোকেই একটি করে গুনত। তাই টাকা, স্টক বা নিরাপত্তার check কখনো “বাকি” রেখে পরের milestone-এ যাবেন না; ওগুলো gate. দুই, বাকি পাঁচটির প্রতিটির নাম লেখা আছে, শুধু সংখ্যা নয়। Portfolio-তে এই তালিকাই “জানা সীমাবদ্ধতা” হিসেবে যাবে।
৪ · টাকার হিসাব আর checkout — পয়সা, কুপন, Zod আর transaction
চায়ের দোকানের হিসাব খাতায় কেউ “সাড়ে বারো টাকা দশমিক শূন্য শূন্য চার” লেখেন না। কম্পিউটার কিন্তু ভগ্নাংশ ঠিকমতো রাখতে পারে না: 0.1 + 0.2 হয় 0.30000000000000004. তাই বাজারঘরে সব টাকা পয়সায়, পূর্ণসংখ্যায়: ৳১,২৫০ মানে 125000. দেখানোর সময় শুধু Intl.NumberFormat('bn-BD') দিয়ে বাংলা অঙ্ক, লাখের কমা আর ৳ (পর্ব ৫৯)। নিয়ম: ডেলিভারি ৳৬০, ছাড়ের পরে ৳২,০০০ বা বেশি হলে ডেলিভারি বিনা মূল্যে; কুপন EID10 মানে ১০% ছাড়, কেবল ৳১,০০০ বা বেশিতে, সর্বোচ্চ ৳২০০. নিচের ব্লকটি lib/money.ts-এর হুবহু একই নিয়ম, শুধু TypeScript-এর type সরানো:
// বাজারঘরের lib/money.ts-এর একই নিয়ম: সব টাকা পয়সায়, পূর্ণসংখ্যায়
const DELIVERY = 6000, FREE_FROM = 200000; // ৳60, ৳2,000
const COUPONS = { EID10: { percent: 10, min: 100000, maxOff: 20000 } };
function cartTotal(lines, code) {
const subtotal = lines.reduce((s, l) => s + l.pricePaisa * l.qty, 0);
const c = code ? COUPONS[code.trim().toUpperCase()] : undefined;
let discount = 0, note = "";
if (code && !c) note = "unknown coupon";
else if (c && subtotal < c.min) note = "below minimum";
else if (c) discount = Math.min(Math.floor(subtotal * c.percent / 100), c.maxOff);
const after = subtotal - discount;
const delivery = subtotal === 0 || after >= FREE_FROM ? 0 : DELIVERY;
return { subtotal, discount, delivery, total: after + delivery, note };
}
const taka = new Intl.NumberFormat("bn-BD", {
style: "currency", currency: "BDT", trailingZeroDisplay: "stripIfInteger" });
const ilish = { pricePaisa: 125000, qty: 1 }, rice = { pricePaisa: 42000, qty: 2 };
const cases = [
["rice x2", [rice], ""],
["rice x2 + EID10", [rice], "EID10"],
["ilish + rice + eid10", [ilish, rice], "eid10"],
["ilish x2 + EID10", [{ ...ilish, qty: 2 }], "EID10"],
];
for (const [name, lines, code] of cases) {
const t = cartTotal(lines, code);
console.log(name.padEnd(21), JSON.stringify(t), taka.format(t.total / 100));
}
// কেন পয়সা? ভগ্নাংশ টাকায় যোগ করলে:
console.log("0.1 + 0.2 =", 0.1 + 0.2, "| paisa: 10 + 20 =", 10 + 20);আউটপুট: rice x2 … "total":90000 … ৯০০৳ (৳৮৪০ + ৳৬০), rice x2 + EID10 … "note":"below minimum" … ৯০০৳ (৳৮৪০ কুপনের ৳১,০০০ সীমার নিচে), ilish + rice + eid10 … "discount":20000,"delivery":6000,"total":195000 … ১,৯৫০৳, আর ilish x2 + EID10 … "discount":20000,"delivery":0,"total":230000 … ২,৩০০৳. তৃতীয় সারিটি খেয়াল করুন: ১০% হতো ৳২০৯, সর্বোচ্চ সীমা ৳২০০ তাই কাটল; ছাড়ের পরে ৳১,৮৯০, যা ৳২,০০০-এর নিচে, তাই ডেলিভারি আবার যোগ হলো। চতুর্থ সারির ২,৩০০৳ ঠিক সেই সংখ্যা, যা end-to-end test পর্দায় খোঁজে (অংশ ৭)। শেষ লাইন: 0.1 + 0.2 = 0.30000000000000004 | paisa: 10 + 20 = 30.
একই function দুই জায়গায়, কিন্তু রায় একটির
কার্টের পাতায় ক্রেতা কুপন লিখতেই মোট বদলায়। এর জন্য client component-এ ঠিক এই cartTotal() চলে, কোনো server অনুরোধ ছাড়াই। কিন্তু যে সংখ্যায় টাকা কাটবে, সেটি আবার হিসাব হয় server-এ, database থেকে পড়া দামে। Browser-এর পাঠানো দাম বা মোট কখনো বিশ্বাস করা হয় না; form-এ দাম পাঠানোই হয় না, শুধু ফোন, ঠিকানা আর কুপন। Server action-এর প্রথম দুটি লাইন তাই login আর Zod (সত্যিকারের ফাইল থেকে):
'use server';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { prisma } from '@/lib/db';
import { requireUser } from '@/lib/auth';
import { checkoutSchema } from '@/lib/validation';
import { placeOrder, OutOfStockError } from '@/lib/orders';
export type CheckoutState = { errors?: Record<string, string[]>; message?: string };
export async function checkout(_prev: CheckoutState, form: FormData): Promise<CheckoutState> {
const user = await requireUser('/cart');
const parsed = checkoutSchema.safeParse(Object.fromEntries(form));
if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors };
let orderId: number;
try {
const order = await placeOrder(prisma, user.id, parsed.data);
orderId = order.id;
} catch (e) {
if (e instanceof OutOfStockError) return { message: 'একটি পণ্যের স্টক এইমাত্র শেষ হয়েছে। কার্ট দেখে আবার চেষ্টা করুন।' };
throw e;
}
redirect(`/pay/${orderId}`); // to the (fake) gateway
}Zod 4-এ error.flatten() পুরোনো পথ; z.flattenError() নতুন। Schema-তে ফোনের নিয়ম /^01[3-9]\d{8}$/ আর বাংলা বার্তা। redirect() try-এর বাইরে, কারণ Next.js redirect একটি বিশেষ error ছুড়ে কাজ করে; try-এর ভেতরে থাকলে catch সেটিকে গিলে ফেলতে পারত।
স্টক আটকে রাখা: একটি transaction, একটি শর্ত
শেষ খেজুরের গুড়ের জারটি রহিমা আর আরেকজন একই মুহূর্তে কিনতে চাইলেন। “আগে স্টক পড়ি, তারপর কমাই” লিখলে দুজনেই পড়বেন ১, দুজনেই কিনবেন, স্টক হবে −১; এটাই race condition আর overselling. বাজারঘরের সমাধান পর্ব ৪৩-এর: পড়া আর কমানো একটি শর্তসাপেক্ষ update-এ, “স্টক যদি অন্তত qty থাকে, তবেই কমাও”, আর পুরো অর্ডার একটি transaction-এ:
// Reserve stock and create the order in ONE transaction (পর্ব ৪৩).
export async function placeOrder(db: PrismaClient, userId: number, input: Checkout) {
return db.$transaction(async (tx) => {
const cart = await tx.cartItem.findMany({ where: { userId }, include: { product: true } });
if (cart.length === 0) throw new Error('empty cart');
for (const item of cart) {
// Conditional decrement: succeeds only if enough stock is left at this instant.
const r = await tx.product.updateMany({
where: { id: item.productId, stock: { gte: item.qty } },
data: { stock: { decrement: item.qty } },
});
if (r.count === 0) throw new OutOfStockError(item.productId); // rolls back everything
}
const lines = cart.map((i) => ({ pricePaisa: i.product.pricePaisa, qty: i.qty }));
const t = cartTotal(lines, input.coupon);
const order = await tx.order.create({
data: {
userId, phone: input.phone, address: input.address, coupon: t.coupon,
subtotalPaisa: t.subtotal, discountPaisa: t.discount,
deliveryPaisa: t.delivery, totalPaisa: t.total,
items: { create: cart.map((i) => ({
productId: i.productId, qty: i.qty, unitPricePaisa: i.product.pricePaisa,
})) },
},
});
await tx.cartItem.deleteMany({ where: { userId } });
return order;
});
}PostgreSQL-এর ডিফল্ট READ COMMITTED-এ দ্বিতীয় update প্রথমটির row lock-এর জন্য অপেক্ষা করে, তারপর শর্ত আবার মিলিয়ে দেখে স্টক ০, তাই count === 0, পুরো transaction ফিরে যায়, আর ক্রেতা বাংলা বার্তা পান। দাবিটি একটি integration test-এ আসল database-এ যাচাই করা হয়েছে; আলাদা bazarghar_test database, একই migration দিয়ে বানানো:
it('sells the last jar exactly once when two buyers press at the same time', async () => {
const results = await Promise.allSettled([placeOrder(prisma, a, addr), placeOrder(prisma, b, addr)]);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
const lost = results.find((r) => r.status === 'rejected') as PromiseRejectedResult;
expect(lost.reason).toBeInstanceOf(OutOfStockError);
expect((await prisma.product.findUniqueOrThrow({ where: { id: gur } })).stock).toBe(0);
expect(await prisma.order.count()).toBe(1);
});একটি সৎ ফাঁক: ভুল হলে form খালি হয়ে যায়। Edge-এ Playwright দিয়ে মাপা হলো: ফোনে 12345 দিয়ে জমা দিলে বার্তা আসে সঠিক মোবাইল নম্বর দিন (01XXXXXXXXX), কিন্তু তার পরে phone = "" আর address = "". React 19-এ <form action>-এর action শেষ হলে uncontrolled field-গুলো নিজে থেকে reset হয়। ক্রেতাকে পুরো ঠিকানা আবার লিখতে হবে, যা ফোনে বিরক্তিকর। সমাধান: action-এর ফেরত state-এ জমা দেওয়া মানগুলো ফিরিয়ে defaultValue-তে বসানো, বা field-গুলো controlled করা (পর্ব ১২, ২১)। Reference app-এ এটি এখনো ঠিক করা হয়নি।
৫ · পেমেন্ট, webhook আর email — একটি অর্ডারের পুরো পথ
আসল gateway-তে ক্রেতা আপনার সাইট ছেড়ে gateway-এর পাতায় যান, টাকা দেন, আর gateway আলাদাভাবে আপনার server-কে জানায়: “অর্ডার ৩-এর ২,৩০০ টাকা পেয়েছি”। এই খবরটিই webhook. Browser-এর redirect যে কেউ বানাতে পারেন, তাই বিশ্বাস শুধু সই করা webhook-এ। এই ল্যাবে কোনো আসল gateway নেই। তার বদলে /pay/[orderId] পাতাটি gateway-এর ভূমিকা নেয়: “পরিশোধ করুন” চাপলে একটি server action ঠিক আসল gateway-এর মতো body বানায়, HMAC-SHA256 দিয়ে সই করে আর নিজের webhook-এ POST করে। Header-এর রূপ পর্ব ৬৫-এর BazarPay মডেলের: x-bazarpay-signature: t=<সময়>,v1=<hex>. বাংলাদেশের কোনো আসল gateway-এর API বা নিয়ম এখানে নকল করা হয়নি।
নিচের stepper-এ reference app-এর একটি আসল অর্ডার, #৩, Playwright-এর একটি run থেকে, ধাপে ধাপে:
- খোঁজা
?q=ilishServer Component - কার্ট
addToCartserver action - checkout
$transactionস্টক ১০ → ৮ - gateway
/pay/3নকল - webhook
verifyPAID - email
SMTPধরা পড়ল
where: OR [ name contains "ilish" (insensitive),
slug contains "ilish" ]
skip 0, take 8 + count
→ ১টি পণ্য · পাতা ১ / ১
পদ্মার ইলিশ (১ কেজি) ১,২৫০৳
requireUser() → রহিমা
addToCartSchema.parse() → productId 1, qty 2
cartItem.upsert(...) → qty 2
redirect('/cart')
মোট (client preview): ২,৩০০৳ (EID10)
BEGIN
UPDATE "Product" SET stock = stock - 2
WHERE id = 1 AND stock >= 2 → 1 row (10 → 8)
INSERT "Order" total 230000, PENDING_PAYMENT → #3
INSERT "OrderItem" ; DELETE "CartItem"
COMMIT
body = {"id":"evt_71a55e6c-…","type":"payment.succeeded",
"orderId":3,"amountPaisa":230000}
t = এখনকার Unix সময়
x-bazarpay-signature: t=…,v1=HMAC(secret, t + "." + body)
POST /api/webhooks/payment
সই মেলে, সময় ৩০০ s-এর ভেতরে ✓
Zod: event-এর রূপ ঠিক ✓
অঙ্ক 230000 = অর্ডারের মোট ✓
INSERT "WebhookEvent" (id = evt_…) ✓ নতুন
PENDING_PAYMENT → PAID
{"msg":"webhook","event":"evt_71a55e6c-…","order":3,"outcome":"paid"}
captured ./mail-out/002.eml to rahima@example.com
Subject: অর্ডার #৩ নিশ্চিত — ২,৩০০৳
Content-Transfer-Encoding: base64
পর্দায় অর্ডারের অবস্থা: পরিশোধিত
- খোঁজা. তালিকা একটি Server Component, query চলে server-এ।
ilishবাংলা নামে মেলে না, slug-এ মেলে; তাই খোঁজা দুই ঘরেই। - কার্টে যোগ. “এক বাড়ান” চেপে পরিমাণ ২, তারপর server action-এ আবার login আর Zod. কুপন লিখতেই client-এ মোট ২,৩০০৳, শুধু preview.
- Checkout-এর transaction. আগের একটি test অর্ডারের পরে স্টক ছিল ১০; শর্তসাপেক্ষ update একটি row বদলাল, স্টক ৮. অর্ডার #৩
PENDING_PAYMENT, মোট server-এর নিজের হিসাবে। - নকল gateway. Server action এলোমেলো event id দিয়ে body বানাল, সময় আর body জুড়ে সই করল, নিজের webhook-এ পাঠাল। আসল জীবনে এটি gateway-এর server করে।
- Webhook. আগে কাঁচা body-র সই, তারপর JSON, অঙ্ক, event id, আর অবস্থার নিয়ম মেনে
PAID. লগের লাইন হুবহু সত্যিকারের run থেকে। - Email. Commit-এর পরে Nodemailer capture server-এ পাঠাল; বাংলা body base64-এ, subject encoded word-এ। পাতায় “পরিশোধিত”।
Webhook-এর route handler, পুরোটা (সত্যিকারের ফাইল):
import { prisma } from '@/lib/db';
import { verify } from '@/lib/webhook';
import { webhookEventSchema } from '@/lib/validation';
import { applyPaymentEvent } from '@/lib/payments';
import { sendOrderPaidEmail } from '@/lib/mail';
export async function POST(req: Request) {
const raw = await req.text(); // verify the exact bytes, before JSON.parse
if (raw.length > 10_000) return Response.json({ error: 'too large' }, { status: 413 });
const check = verify(raw, req.headers.get('x-bazarpay-signature'),
process.env.WEBHOOK_SECRET!, Math.floor(Date.now() / 1000));
if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
let json: unknown;
try { json = JSON.parse(raw); } catch { return Response.json({ error: 'bad json' }, { status: 400 }); }
const evt = webhookEventSchema.safeParse(json);
if (!evt.success) return Response.json({ error: 'bad event' }, { status: 400 });
const outcome = await applyPaymentEvent(prisma, evt.data);
console.log(JSON.stringify({ msg: 'webhook', event: evt.data.id, order: evt.data.orderId, outcome }));
if (outcome === 'no-order' || outcome === 'amount-mismatch') {
return Response.json({ outcome }, { status: 422 });
}
if (outcome === 'paid') {
// Known gap: sent inline. A failed send is logged, not retried (see the outbox stretch goal).
await sendOrderPaidEmail(evt.data.orderId).catch((e) => console.error('mail failed', e.message));
}
return Response.json({ outcome }); // 200 also for duplicates: the gateway stops retrying
}verify() সময়ের পার্থক্য ৩০০ সেকেন্ডের বেশি হলে stale বলে, আর তুলনা করে timingSafeEqual দিয়ে, যাতে উত্তর দেওয়ার সময় দেখে সই অনুমান করা না যায় (Cybersecurity কোর্সের hash ও MAC পর্ব)। applyPaymentEvent() একটি transaction-এ অঙ্ক মেলায়, WebhookEvent লেখে আর অবস্থা বদলায়; একই id আবার এলে Prisma-র P2002 ধরে duplicate. অবস্থার নিয়মটি একটি ছোট state machine. নিচের ব্লকে database ছাড়া একই নিয়ম, চারটি event দিয়ে:
// বাজারঘরের lib/order-state.ts আর lib/payments.ts-এর নিয়ম, database ছাড়া
const NEXT = {
PENDING_PAYMENT: ["PAID", "CANCELLED"], PAID: ["SHIPPED", "REFUNDED"],
SHIPPED: ["DELIVERED"], DELIVERED: [], CANCELLED: [], REFUNDED: [],
};
const seen = new Set(); // আসল app-এ WebhookEvent table-এর primary key
const stock = { ilish: 10 };
function apply(order, e) {
if (order.total !== e.amount) return "amount-mismatch";
if (seen.has(e.id)) return "duplicate";
seen.add(e.id);
const to = e.type === "payment.succeeded" ? "PAID" : "CANCELLED";
if (!NEXT[order.status].includes(to)) return "ignored";
order.status = to;
if (to === "CANCELLED") stock.ilish += order.qty; // আটকে রাখা স্টক ফেরত
return to === "PAID" ? "paid" : "cancelled";
}
const order = { id: 3, status: "PENDING_PAYMENT", total: 230000, qty: 2 };
stock.ilish -= order.qty; // checkout-এর transaction: 10 -> 8
const events = [
{ id: "evt_a", type: "payment.succeeded", amount: 1 }, // জাল অঙ্ক
{ id: "evt_b", type: "payment.succeeded", amount: 230000 },
{ id: "evt_b", type: "payment.succeeded", amount: 230000 }, // gateway আবার পাঠাল
{ id: "evt_c", type: "payment.failed", amount: 230000 }, // দেরিতে আসা ব্যর্থতা
];
for (const e of events) {
const out = apply(order, e);
console.log(`${e.id} ${e.type.padEnd(17)} -> ${out.padEnd(15)} status=${order.status} stock=${stock.ilish}`);
}
console.log("admin: PAID -> SHIPPED", NEXT[order.status].includes("SHIPPED"));
console.log("admin: PAID -> PENDING_PAYMENT", NEXT[order.status].includes("PENDING_PAYMENT"));আউটপুট: evt_a payment.succeeded -> amount-mismatch status=PENDING_PAYMENT stock=8, তারপর evt_b … -> paid status=PAID stock=8, একই evt_b আবার -> duplicate, আর দেরিতে আসা ব্যর্থতা evt_c payment.failed -> ignored status=PAID stock=8. শেষ দুই লাইন admin: PAID -> SHIPPED true আর admin: PAID -> PENDING_PAYMENT false. “ignored” নিরাপদ, কিন্তু চুপচাপ নয়: আসল দোকানে পরিশোধিত অর্ডারে ব্যর্থতার খবর মানে কোথাও গোলমাল, তাই log আর একজন মানুষের নজর লাগে (পর্ব ৮৩)। পূর্ণ তত্ত্ব আর refund-এর অবস্থা পর্ব ৬৫-এ।
ধরা পড়া email
উন্নয়নে কোনো আসল email provider নেই; smtp-server দিয়ে বানানো ৩০ লাইনের একটি capture server শুধু 127.0.0.1-এ শোনে আর প্রতিটি মেইল .eml ফাইলে রাখে, কাউকে পাঠায় না (পর্ব ৬৩)। অর্ডার #৩-এর মেইল (কাঁচা header সত্যিকারের, body base64 থেকে decode করা):
From: =?UTF-8?B?4Kas4Ka+4Kac4Ka+4Kaw4KaY4Kaw?= <orders@bazarghar.example>
To: rahima@example.com
Subject: =?UTF-8?B?4KaF4Kaw4KeN4Kah4Ka+4KawICPgp6kg4Kao4Ka/?=
=?UTF-8?B?4Ka24KeN4Kaa4Ka/4KakIOKAlCDgp6gs4Kep4Kem?= =?UTF-8?B?4Kem4Kez?=
Content-Transfer-Encoding: base64
Content-Type: text/plain; charset=utf-8
(মোট ১,০০৮ byte)
── decode করার পরে ──
Subject: অর্ডার #৩ নিশ্চিত — ২,৩০০৳
প্রিয় রহিমা,
আপনার অর্ডার #৩ পরিশোধিত হয়েছে।
পদ্মার ইলিশ (১ কেজি) × ২ = ২,৫০০৳
ছাড়: ২০০৳ ডেলিভারি: ০৳
মোট: ২,৩০০৳
অর্ডারের অবস্থা দেখুন: http://127.0.0.1:47130/orders/3
— বাজারঘর
Subject-এর তিনটি encoded word আলাদা decode করলে একটি বাংলা অক্ষর মাঝখানে কেটে যায়, আমাদের প্রথম চেষ্টায় যেমন হয়েছিল; আগে byte জোড়া দিন, তারপর UTF-8. আর link-এর ঠিকানা আসে APP_URL থেকে; production-এ এটি https-এর আসল domain না হলে রসিদের link ভুল জায়গায় যাবে।
৬ · লগইন, role, বাংলা UI আর metadata
হোটেলের রিসেপশন অতিথিকে একটি চাবির কার্ড দেয়, আর খাতায় লেখে কোন কার্ড কোন ঘরের, কবে পর্যন্ত। কার্ড হারালে খাতা থেকে মুছে দিলেই কার্ড অচল। Session-ভিত্তিক login ঠিক এমন (পর্ব ৪৫): cookie-তে ৩২ byte এলোমেলো টোকেন, database-এ তার SHA-256, মেয়াদ ৭ দিন। পাসওয়ার্ড রাখা হয় scrypt দিয়ে, scrypt$N$r$p$salt$hash রূপে, যাতে পরে cost বাড়ানো যায় (Cybersecurity কোর্সের password storage পর্ব)। মূল অংশ:
import 'server-only';
const COOKIE = 'bg_session';
const MAX_AGE = 60 * 60 * 24 * 7; // 7 days
const digest = (token: string) => createHash('sha256').update(token).digest('hex');
export async function createSession(userId: number) {
const token = randomBytes(32).toString('base64url');
await prisma.session.create({
data: { id: digest(token), userId, expiresAt: new Date(Date.now() + MAX_AGE * 1000) },
});
(await cookies()).set(COOKIE, token, {
httpOnly: true, sameSite: 'lax', path: '/', maxAge: MAX_AGE,
secure: process.env.COOKIE_SECURE !== '0' && process.env.NODE_ENV === 'production',
});
}
// Admin pages answer 404 to everyone else, so their existence is not advertised.
export async function requireRole(role: Role) {
const user = await requireUser('/admin/orders');
if (user.role !== role) notFound();
return user;
}import 'server-only' থাকায় ফাইলটি ভুলে client-এ import করলে build ভাঙে। Login-এর action-এ আরও দুটি অভ্যাস: অজানা email আর ভুল পাসওয়ার্ডে একই বার্তা, আর ?next= শুধু / দিয়ে শুরু হলে এবং // দিয়ে নয়, তবেই সেখানে ফেরা, যাতে কেউ link বানিয়ে ক্রেতাকে অন্য সাইটে পাঠাতে না পারে (open redirect).
Role যাচাই: পাতায় আর action-এ, দুই জায়গাতেই
Admin পাতা requireRole('ADMIN') ডাকে, আর “পাঠানো হয়েছে চিহ্নিত করুন”-এর server action আবার ডাকে। কেন দুবার? Server action আসলে একটি সাধারণ POST endpoint; পাতা না খুলেও কেউ action-এর id জোগাড় করে সরাসরি ডাকতে পারে। বোতাম লুকানো নিরাপত্তা নয়, শুধু সুবিধা (পর্ব ৪৮, তত্ত্ব broken access control পর্বে)। একই কারণে অর্ডারের পাতা আর নকল gateway-এর পাতা দেখে অর্ডারটি এই ক্রেতারই কি না; অন্যের অর্ডার হলে “পাতাটি পাওয়া যায়নি”, যেন অর্ডারটিই নেই। RBAC-এর এই ছোট রূপে মাত্র দুটি role; দোকান বড় হলে অনুমতির ছক লাগবে।
বাংলা UI
<html lang="bn">, next/font/google থেকে Noto Sans Bengali, body-তে স্থির line-height: 1.6 (font বদলের সময় লেখা যাতে না লাফায়, পর্ব ৮৪)। সংখ্যা সব জায়গায় একটি function দিয়ে: formatTaka(125000) দেয় ১,২৫০৳, আর formatTaka(12345650) দেয় ১,২৩,৪৫৬.৫০৳; দুটোই unit test-এ বাঁধা। পাতার নম্বর, কার্টের সংখ্যা, অর্ডারের নম্বরও বাংলা অঙ্কে। ফলে test-কেও বাংলা অঙ্ক পড়তে হয় (অংশ ৭)।
Metadata আর sitemap — একটি আসল ভুল আর তার সংশোধন
generateMetadata নাম আর দাম দিয়ে title আর description বানায়; sitemap.ts সব slug পড়ে, robots.ts ব্যক্তিগত পাতা বাদ দেয় (পর্ব ৫৮)। প্রথম Lighthouse run-এ পণ্যের পাতার SEO এল ৯১, কারণ “Document does not have a meta description”. অথচ description ছিল! HTML খুলে দেখা গেল, </head> শেষ হয়েছে ৯৬০ অক্ষরে, আর description এসেছে ১৩,১৪৭ অক্ষরে, body-র ভেতরে। Cache Components চালু থাকায় database থেকে পড়া metadata অন্য dynamic অংশের মতো পরে stream হচ্ছিল (streaming metadata); Googlebot user agent দিয়ে চাইলেও একই ফল।
সংশোধন: নাম আর দাম কম বদলায়, তাই সেগুলো "use cache" দিয়ে cache, আর generateStaticParams দিয়ে build-এর সময়েই সব slug জানানো। স্টকের সংখ্যা আগের মতোই প্রতি অনুরোধে আসে, Suspense-এর ভেতরে।
// Name and price change rarely: cache them so metadata can be prerendered into <head>.
async function getProductMeta(slug: string) {
'use cache';
cacheLife('hours');
cacheTag('products');
return prisma.product.findUnique({ where: { slug }, select: { slug: true, name: true, pricePaisa: true } });
}
export async function generateStaticParams() {
return prisma.product.findMany({ select: { slug: true } });
}
export async function generateMetadata({ params }: PageProps<'/products/[slug]'>): Promise<Metadata> {
const p = await getProductMeta((await params).slug);
if (!p) return { title: 'পণ্য পাওয়া যায়নি' };
return {
title: p.name,
description: `${p.name} — দাম ${formatTaka(p.pricePaisa)}। বাজারঘরে অর্ডার করুন।`,
alternates: { canonical: `/products/${p.slug}` },
};
}পরের build-এ description চলে এল ৯৩৮ অক্ষরে, </head>-এর (১,৭৯০) আগে, আর title হলো পদ্মার ইলিশ (১ কেজি) | বাজারঘর; Lighthouse-এর SEO ১০০. দাম বদলের কোনো admin পাতা এখনো নেই; যেদিন হবে, সেদিন সেই action-এ revalidateTag('products') না ডাকলে পুরোনো দাম কয়েক ঘণ্টা metadata-য় থেকে যাবে (পর্ব ৫৩)। Build-এর সময় এখন database লাগে; deploy-এর ধাপে এর প্রভাব অংশ ৯-এ।
৭ · প্রতিটি স্তরে test
মডিউল ১১-এর কৌশল (পর্ব ৭২) এখানে ছোট করে: যে নিয়মে টাকা বা স্টক জড়িত, তার জন্য অনেক দ্রুত unit test; ক্রেতার হাতে যে component, তার জন্য component test; database-এর আচরণ, যেমন lock আর unique key, শুধু আসল database-এ প্রমাণ হয়, তাই integration test; আর পুরো পথের জন্য দুটি end-to-end যাত্রা। Vitest-এর তিনটি project একটি config-এ: unit (node), component (jsdom), integration (node, আলাদা test database, শুরুতে migrate deploy, ফাইল একটির পর একটি)। সত্যিকারের run (ছোট করা):
~/bazarghar$ npx vitest run --reporter=verbose
✓ |unit| tests/unit/order-and-webhook.test.ts > order state machine > PENDING_PAYMENT -> PAID is true
✓ |unit| tests/unit/order-and-webhook.test.ts > order state machine > PAID -> PENDING_PAYMENT is false
…
✓ |unit| tests/unit/order-and-webhook.test.ts > webhook signature > rejects a changed amount
✓ |unit| tests/unit/order-and-webhook.test.ts > webhook signature > rejects a replay after 5 minutes
✓ |unit| tests/unit/money.test.ts > cartTotal > applies EID10 and caps it at ৳200
✓ |unit| tests/unit/money.test.ts > cartTotal > rejects EID10 below ৳1,000 and says why
✓ |unit| tests/unit/money.test.ts > formatTaka > uses Bangla digits, lakh grouping and ৳
✓ |component| tests/component/quantity-picker.test.tsx > QuantityPicker > counts up in Bangla digits and stops at the stock limit 159ms
✓ |component| tests/component/quantity-picker.test.tsx > QuantityPicker > sends the number as a hidden form field 51ms
✓ |integration| tests/integration/checkout.test.ts > … > sells the last jar exactly once when two buyers press at the same time 162ms
✓ |integration| tests/integration/checkout.test.ts > … > marks an order paid once, ignores the replay, refuses a wrong amount 33ms
✓ |integration| tests/integration/checkout.test.ts > … > gives the stock back when payment fails 26ms
Test Files 4 passed (4)
Tests 21 passed (21)
Duration 2.55s
২১টির মধ্যে ১৬টি unit, ২টি component, ৩টি integration. পুরো চক্র আড়াই সেকেন্ড। Component test (পর্ব ৭৪) বোতাম খোঁজে role আর বাংলা নাম দিয়ে, যেভাবে ক্রেতা দেখেন।
দুটি Playwright যাত্রা
Playwright চলে কম্পিউটারে ইনস্টল করা Edge-এ (channel: 'msedge'), workers: 1, কারণ সব যাত্রা একটি database ভাগ করে (পর্ব ৭৬)। প্রথম যাত্রা ক্রেতার, দ্বিতীয়টি দুই ভাগে admin-এর: customer admin পাতা পান না, আর admin একটি পরিশোধিত অর্ডার পাঠানো হিসেবে চিহ্নিত করেন। প্রথম যাত্রার চূড়ান্ত রূপ:
import { expect, test } from '@playwright/test';
import { latestMail, login, resetCart } from './helpers';
test.beforeEach(() => resetCart('rahima@example.com'));
test('a customer finds ilish, pays, and gets the email', async ({ page }) => {
await login(page, 'rahima@example.com', 'example-password-1');
await page.getByRole('searchbox').fill('ilish');
await page.getByRole('button', { name: 'খুঁজুন' }).click();
await page.getByRole('link', { name: 'পদ্মার ইলিশ (১ কেজি)' }).click();
await page.getByRole('button', { name: 'এক বাড়ান' }).click();
await page.getByRole('button', { name: 'কার্টে যোগ করুন' }).click();
await expect(page).toHaveURL(/\/cart$/);
await page.getByLabel('মোবাইল নম্বর').fill('01712345678');
await page.getByLabel('ঠিকানা').fill('বাড়ি ১২, রোড ৪, রাজশাহী');
await page.getByLabel('কুপন (ঐচ্ছিক)').fill('EID10');
await expect(page.getByTestId('total')).toHaveText('২,৩০০৳'); // what a Bangla reader sees
await page.getByRole('button', { name: 'অর্ডার দিন ও পেমেন্টে যান' }).click();
await expect(page).toHaveURL(/\/pay\/\d+$/);
await page.getByRole('button', { name: 'পরিশোধ করুন' }).click();
await expect(page.getByTestId('status')).toHaveText('পরিশোধিত');
const id = page.url().split('/').at(-1);
await expect.poll(latestMail).toContain(`${process.env.APP_URL ?? 'http://127.0.0.1:47130'}/orders/${id}`);
});এই রূপে পৌঁছানোর আগে পাঁচটি run ব্যর্থ হয়েছে, আর প্রায় প্রতিটি কিছু শিখিয়েছে। নিচে আসল run-গুলো, ক্রমানুসারে:
Error: locator.fill: Test timeout of 30000ms exceeded. - waiting for getByRole('searchbox') app-এ ছিল: <input id="q" name="q" placeholder="…"> type নেই → implicit role = textbox, searchbox নয়
Locator: getByText('৳2,300') Expected: visible Error: element(s) not found error-context.md-এর পাতার ছবি: - term: মোট - definition: ২,৩০০৳
Expected substring: "http://127.0.0.1:47130/orders/1" Received string: "From: =?UTF-8?B?4Kas…?= … Content-Transfer-Encoding: base64· 4Kaq4KeN4Kaw4Ka/4Kav4Ka8IOCmsOCm…" মেইল এসেছে, কিন্তু base64-এ
ok 1 admin.spec.ts › a customer cannot see the admin page x 2 admin.spec.ts › an admin ships a paid order (30.1s) waiting for getByRole('row').filter({ hasText: 'পরিশোধিত' }) x 3 buy.spec.ts › … admin ফাইল নামের ক্রমে আগে চলল: তখনো কোনো পরিশোধিত অর্ডার নেই
Running 3 tests using 1 worker
ok 1 e2e\admin.spec.ts:4:5 › a customer cannot see the admin page (492ms)
ok 2 e2e\admin.spec.ts:11:5 › an admin ships a paid order (746ms)
ok 3 e2e\buy.spec.ts:6:5 › a customer finds ilish, pays, and gets the email (2.6s)
3 passed (4.5s)
- ভুলটি app-এর.
typeছাড়া<input>-এর roletextbox; form-এরrole="search"তা বদলায় না। সংশোধন app-এ,type="search"; screen reader-ও এখন ঘরটিকে খোঁজার ঘর বলে। - ভুলটি test-এর. Test ইংরেজি অভ্যাসে
৳2,300খুঁজছিল; পাতায়২,৩০০৳.error-context.md-এ পাতার আসল লেখা দেখা গেল। সংশোধন:getByTestId('total'), আরbeforeEach-এ কার্ট খালি। - ভুলটি অনুমানের. কাঁচা
.eml-এ link খোঁজা হয়েছিল, কিন্তু Nodemailer বাংলা body base64-এ পাঠিয়েছে। সংশোধন helper-এ: base64 হলে decode করে খোঁজা। - ভুলটি নকশার. Admin-এর test ধরে নিয়েছিল ক্রেতার test আগে চলবে; Playwright ফাইল নামের ক্রমে চালায়। নির্ভরশীল test flaky হওয়ার পথে; এখন admin-এর test নিজেই একটি পরিশোধিত অর্ডার বানায় (test isolation, পর্ব ৭৭).
- সবুজ. মাঝের দুটি run ভেঙেছিল আমাদের নিজের ভুলে: Bash-এর heredoc-এ
\r\nআসল লাইন-ভাঙা হয়ে syntax error (এখানে দেখানো হয়নি)। ষষ্ঠ run-এ তিনটিই পাস, সাড়ে চার সেকেন্ডে; পরে পুরো সেট আবার চালিয়ে একই ফল।
আরও গভীরে। Playwright-এর getByRole W3C-র WAI-ARIA আর “HTML Accessibility API Mappings” (HTML-AAM) মেনে প্রতিটি element-এর implicit role হিসাব করে; <input type="search">-এর role searchbox, type না থাকলে textbox. তাই role-ভিত্তিক selector আসলে accessibility-র একটি ছোট পরীক্ষাও। অন্যদিকে webhook-এর idempotency নির্ভর করে database-এর unique constraint-এর ওপর, কোডের if-এর ওপর নয়: দুটি একই event একসাথে এলে দুটোই “এখনো দেখিনি” পড়তে পারে, কিন্তু primary key দ্বিতীয়টিকে লিখতে দেবে না।
৮ · Build, Lighthouse আর review checklist
Production build চালানো হয়েছে database চালু রেখে, কারণ পণ্যের পাতার slug আর metadata build-এর সময়েই database থেকে পড়া হয়। সত্যিকারের route table (Next.js 16.4.4, Turbopack):
~/bazarghar$ npx next build
▲ Next.js 16.4.4 (Turbopack)
- Environments: .env
- Cache Components enabled
- Partial Prefetching enabled
✓ Compiled successfully
✓ Generating static pages using 15 workers (34/34)
Route (app)
┌ ◐ /
├ ◐ /_not-found
├ ◐ /admin/orders
├ ƒ /api/webhooks/payment
├ ◐ /cart
├ ◐ /login
├ /orders/[id]
│ └ ◐ /orders/[id]
├ /pay/[orderId]
│ └ ◐ /pay/[orderId]
├ ◐ /products
├ /products/[slug]
│ ├ ◐ /products/[slug]
│ ├ ◐ /products/miniket-rice-5kg
│ ├ ◐ /products/mustard-oil-1l
│ └ ◐ [+18 more paths]
├ ○ /robots.txt
└ ƒ /sitemap.xml
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
প্রায় সব পাতা ◐, অর্থাৎ partial prerender: স্থির খোলস build-এ তৈরি, আর user-নির্ভর অংশ (header-এর নাম আর কার্ট, স্টক, অর্ডার) প্রতি অনুরোধে Suspense-এর ভেতরে stream হয় (পর্ব ৫৫)। কারণ root layout-এর header প্রতিটি পাতায় cookie পড়ে। এরপর server চালানো হয়েছে সাধারণভাবে, কোনো reverse proxy ছাড়া: next start -H 127.0.0.1 -p 47130.
Lighthouse — মোবাইল, Edge-এ
পণ্যের পাতায় তিনবার আর তালিকার পাতায় একবার, Lighthouse 13.6.0-এর ডিফল্ট মোবাইল throttling-এ (পর্ব ৮৪)। Metadata সংশোধনের পরের সত্যিকারের ফল:
product-1 perf 95 a11y 100 bp 100 seo 100 | FCP 0.8 s LCP 2.9 s TBT 50 ms CLS 0 | 306 KiB
product-2 perf 100 a11y 100 bp 100 seo 100 | FCP 0.8 s LCP 0.8 s TBT 20 ms CLS 0 | 306 KiB
product-3 perf 96 a11y 100 bp 100 seo 100 | FCP 0.8 s LCP 2.9 s TBT 60 ms CLS 0 | 306 KiB
list-1 perf 95 a11y 100 bp 100 seo 100 | FCP 0.8 s LCP 3.0 s TBT 50 ms CLS 0 | 307 KiB
বাকি audit: Reduce unused JavaScript (Est savings of 29 KiB) · Legacy JavaScript (14 KiB)
Render-blocking requests (Est savings of 140 ms) · bf-cache: 2 failure reasons
মাঝের মান: performance ৯৬, LCP ২.৯ সেকেন্ড, যা “ভালো”-র সীমা ২.৫-এর ওপরে। LCP element হলো পণ্যের নামের <h1>, কোনো ছবি নয়। Network-এর তালিকায় সবচেয়ে বড় জিনিস একটি font ফাইল, ১,০৮,১৮৬ byte-এর বাংলা woff2. যে run-এ font প্রথম আঁকার আগেই চলে এসেছিল, সেখানে LCP ০.৮ সেকেন্ড; বাকি দুটিতে font পরে এসে লেখা আবার আঁকা হয়েছে, আর ধীর নেটওয়ার্কের হিসাবে তা ২.৯ সেকেন্ড। এটি আমাদের ব্যাখ্যা, Lighthouse-এর নিজের রায় নয়; পরীক্ষার উপায় হলো font subset বা display: 'optional' দিয়ে আবার মাপা। আর bfcache কাজ করে না; Lighthouse-এর দেওয়া কারণ, মূল HTML-এ Cache-Control: no-store. সংখ্যাগুলো একটি কম্পিউটারের একদিনের run, সাধারণ ফল নয়।
Review checklist — reference app কেমন করল
Code review-এর নিয়মিত তালিকা (Software Engineering কোর্সের code review পর্ব, নিরাপত্তার পূর্ণ তালিকা পর্ব ৫০) থেকে পাঁচটি ভাগ। প্রতিটি ঘরে যা যাচাই করা হয়েছে আর যা হয়নি, দুটোই লেখা।
| ভাগ | যা আছে, আর কীভাবে দেখা | ফাঁক | রায় |
|---|---|---|---|
| নিরাপত্তা | scrypt hash; টোকেনের hash database-এ; cookie HttpOnly, SameSite=Lax, Secure (Edge-এ পড়া); প্রতিটি action-এ login, Zod আর role; webhook-এ সই, সময়সীমা, timingSafeEqual, অঙ্ক মেলানো; open redirect আটকানো; secret শুধু server-এ; npm audit --omit=dev: found 0 vulnerabilities | login-এ rate limit নেই (পর্ব ৪৯); CSP আর অন্য security header বসানো হয়নি (web hardening); dev dependency-তে ১৬টি moderate সতর্কতা দেখা হয়নি | ২/৩ |
| Performance | performance-এর মাঝের মান ৯৬, CLS ০, TBT ৫০ ms; তালিকায় পাতা ভাগ, query-তে select | LCP ২.৯ s (সম্ভবত বাংলা font); ২৯ KiB অব্যবহৃত JS; bfcache বন্ধ; বেশি পণ্যে offset pagination ধীর হবে | ২/৩ |
| Accessibility | পণ্য আর তালিকার পাতায় Lighthouse ১০০; প্রতিটি ঘরে label, type="search", পরিমাণে aria-live | login, কার্ট, admin-এ Lighthouse চালানো হয়নি; keyboard-এ পুরো পথ দেখা হয়নি; error-এ focus যায় না; ভুলের পরে form খালি | ২/৩ |
| Test | ২১ Vitest (১৬ unit, ২ component, ৩ integration, আসল PostgreSQL-এ) আর ৩ Playwright; শেষ জার আর replay-এর মতো কঠিন জিনিসে test | webhook route নিজে শুধু script দিয়ে দেখা, test suite-এ নেই; login-এর action-এর test নেই; CI-তে চালানো হয়নি | ২/৩ |
| Deployment প্রস্তুতি | next build পাস; সব config environment variable-এ; migration দিয়ে schema; .env আর lab-এর ফাইল .gitignore-এ | README নেই; health check route নেই (পর্ব ৩১); email inline, আবার চেষ্টা নেই; অপরিশোধিত অর্ডারের স্টক কখনো ফেরে না (পর্ব ৬২); backup-এর পরিকল্পনা নেই; Suspense-এর ভেতরের redirect আর 404 দুটোই HTTP ২০০ দেয় | ১/৩ |
Login ছাড়া curl দিয়ে /admin/orders চাইলে উত্তর HTTP/1.1 200 OK, আর body-তে <meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/login?next=%2Fadmin%2Forders"/>; admin-এর কোনো ডেটা (অর্ডার পরিচালনা-এর heading পর্যন্ত) পাঠানো হয়নি, browser login পাতায় চলে যায়। কারণ খোলস আগেই ২০০ দিয়ে পাঠানো শুরু হয়ে গিয়েছিল, redirect এসেছে পরে stream হওয়া অংশে। অজানা পণ্যের পাতাও ২০০ দেয়, সাথে noindex, অনেকটা soft 404. নিরাপত্তার ক্ষতি নেই, কিন্তু monitoring ভুল ছবি দেবে। যেখানে ঠিক status জরুরি, সেখানে পর্ব ৫৭-এর মতো আগে, request-এর স্তরে যাচাই ভাবুন।
পরীক্ষা ও ইন্টারভিউতে। (১) দাম float-এ নয় কেন? দশমিক ভগ্নাংশ binary-তে হুবহু রাখা যায় না; পয়সায় পূর্ণসংখ্যা রাখুন, দেখানোর সময় format. (২) দুজন একসাথে শেষ পণ্যটি কিনলে কী হবে, আর কীভাবে ঠেকাবেন? শর্তসাপেক্ষ update (WHERE stock >= qty) একটি transaction-এ; row lock দ্বিতীয়জনকে অপেক্ষা করায়, তারপর শর্ত ব্যর্থ। (৩) Webhook কেন সই করা আর idempotent? যে কেউ URL-এ POST করতে পারে, আর gateway একই event একাধিকবার পাঠায়; সই উৎস প্রমাণ করে, event id-র unique key দ্বিতীয়বার আটকায়। (৪) Admin-এর বোতাম লুকালেই কি যথেষ্ট? না; server action একটি endpoint, তাই action-এর ভেতরেও role যাচাই। (৫) Unit, integration আর E2E-তে কোনটি কী প্রমাণ করে? নিয়ম, database-এর আচরণ, আর পুরো পথ ব্যবহারকারীর চোখে।
৯ · Deploy-এর পরিকল্পনা, stretch goal, portfolio আর rubric
এই পর্বে কোথাও deploy করা হয়নি; কোনো hosting, domain বা আসল gateway-এর account লাগেনি। নিচের ধাপগুলো পরিকল্পনা, পর্ব ৮২-এর পথ ধরে। দুই পথেই একটি নিয়ম এক: migration আগে, নতুন কোড পরে। বাজারঘরের build নিজেও database থেকে slug পড়ে, তাই এখানে migrate deploy build-এরও আগে।
বাজারঘর production-এ: দুই পথ
Deployment topology on a VPS and on a PaaS (plan only, not deployed)
- Ubuntu-র মতো একটি Linux VPS, একজন আলাদা user, শুধু SSH key দিয়ে ঢোকা, firewall-এ শুধু ২২, ৮০, ৪৪৩ (Linux for servers).
- Node-এর LTS version আর PostgreSQL (একই যন্ত্রে বা managed); database তৈরির পরে
SHOW server_encoding;দিয়ে UTF8 নিশ্চিত। - Code আনা, তারপর
npm ci,npx prisma generate,npx prisma migrate deploy,npx next build, এই ক্রমে। - Secret থাকবে একটি environment ফাইলে, অনুমতি ৬০০, repo-র বাইরে:
DATABASE_URL,WEBHOOK_SECRET, SMTP-র login, আর https-এরAPP_URL(পর্ব ৭৮)। - systemd-এর একটি service:
next start -H 127.0.0.1 -p 3000, বন্ধ হলে আবার চালু (systemd services); অথবা PM2. - সামনে reverse proxy, যেমন Caddy বা nginx, TLS সহ (nginx reverse proxy).
- Gateway-এ webhook URL আর আসল secret; নিয়মিত backup, আর একবার restore করে দেখা।
- একটি PaaS-এ (অনেক প্রতিষ্ঠান এমন সেবা দেয়; কোনোটিই এখানে সুপারিশ নয়) repo যুক্ত করা আর একটি managed PostgreSQL যোগ করা।
- Platform-এর settings-এ environment variable: VPS-এর তালিকার মতোই, secret কখনো repo-তে নয়।
- Build command:
npx prisma generate && npx prisma migrate deploy && npx next build; অথবা platform-এ আলাদা “release” বা “pre-deploy” ধাপ থাকলে migration সেখানে, তবে খেয়াল রাখুন বাজারঘরের build নিজেই database পড়ে। - Start command:
npx next start -p $PORT; platform যে port দেয় সেটিই। - Preview deployment-এ আলাদা database, যাতে কোনো PR-এর migration production-এর ডেটা না ছোঁয় (পর্ব ৮১)।
Container-এ চালাতে চাইলে পর্ব ৮০-এর multi-stage Dockerfile প্রায় হুবহু খাটে; এই মডিউলে Docker চালানো হয়নি। Deploy-এর পরে পর্ব ৮৩-এর error tracking আর uptime check.
Stretch goal — মূল কাজ শেষ হলে
অর্ডারের অবস্থা live
Reload ছাড়াই “পরিশোধিত” থেকে “পাঠানো হয়েছে”: Route Handler থেকে SSE stream, heartbeat আর পুনঃসংযোগ (পর্ব ৬১)।
পর্ব ৬১পণ্যের ছবি
Presigned URL দিয়ে object storage-এ upload, কয়েক মাপে AVIF আর WebP, পাতায় next/image; LCP তখন ছবি, তাই আবার মাপুন (পর্ব ৬৪, ৮৪)।
ইংরেজি সংস্করণ
/en/... locale routing, বার্তাগুলো একটি অভিধানে, en-BD-তে টাকার format, আর hreflang (পর্ব ৫৯)।
ফাঁকগুলো বন্ধ করা
Email-এর জন্য transactional outbox আর retry; ৩০ মিনিটে পরিশোধ না হলে অর্ডার বাতিল আর স্টক ফেরত, একটি cron job-এ (পর্ব ৬২); login-এ rate limit; README আর health check.
পর্ব ৪৯, ৬২Portfolio আর ইন্টারভিউতে উপস্থাপন
একটি ক্যাপস্টোন চাকরি এনে দেবে, এমন কোনো নিশ্চয়তা নেই। তবে এটি আপনার কাজের যাচাইযোগ্য প্রমাণ আর ইন্টারভিউতে কথা বলার ভিত্তি:
- README-ই আপনার প্রথম পরিচয়। এক অনুচ্ছেদে সমস্যা আর ব্যবহারকারী; ER ডায়াগ্রাম আর একটি architecture-এর ছবি; চালানোর ধাপ; test-এর ফল; আর “জানা সীমাবদ্ধতা” অংশে উপরের checklist-এর ফাঁকগুলো।
- সিদ্ধান্ত আর বিকল্প। “কেন পয়সায়”, “কেন checkout-এ স্টক কমে, পেমেন্টে নয়”, “কেন session, JWT নয়”: প্রতিটির জন্য দুই লাইনে কারণ আর কী ছেড়ে দিলেন।
- একটি কঠিন bug-এর গল্প। STAR কাঠামোয়: অবস্থা (seed-এ বাংলা নাম ঢুকছিল না), দায়িত্ব (কারণ খোঁজা), কাজ (error code পড়ে database-এর encoding দেখা, cluster UTF8-এ নতুন করে বানানো, একটি acceptance check যোগ), ফল (আর কখনো হয়নি, check-টি নতুন milestone-এ আছে)।
- নিজের লেখা বুঝে বলা। যেকোনো লাইন দেখিয়ে “এটা কেন?” জিজ্ঞেস করা হতে পারে; AI দিয়ে লেখানো অংশও ব্যাখ্যা করতে পারলে তবেই সেটি আপনার কাজ। প্রশ্নের ধরন মডিউল ১৪-এ (পর্ব ৯৩ · ফুল-স্ট্যাক ইন্টারভিউ)।
নিজেকে মূল্যায়ন — rubric
একটি rubric আগে থেকে বলে দেয় কোন মানদণ্ডের কত ওজন, আর প্রতিটি মাত্রায় কী দেখা গেলে কত নম্বর। এতে “ভালো হয়েছে মনে হচ্ছে”-র জায়গায় আসে প্রমাণ। বাজারঘরের rubric, ০ থেকে ৩ মাত্রায়:
| মানদণ্ড (ওজন) | ০ | ১ | ২ | ৩ |
|---|---|---|---|---|
| কার্যকারিতা (২৫) | পথ ভাঙা | কয়েকটি must-have চলে | সব must-have, হাতে দেখা | সব must-have, E2E test-এ প্রমাণ |
| নিরাপত্তা (২০) | সাধারণ পাসওয়ার্ড বা client-এ role | hash আর cookie ঠিক | server-এ সব যাচাই, সই করা webhook | সাথে rate limit, header, audit |
| ডেটার সঠিকতা (১৫) | float-এ টাকা | পয়সা, কিন্তু race আছে | transaction | transaction, CHECK, idempotent webhook, test সহ |
| Test (১৫) | নেই | কয়েকটি unit | প্রতিটি স্তরে কিছু | প্রতিটি স্তরে, আর CI-তে |
| Performance ও accessibility (১০) | মাপা হয়নি | একবার মাপা | মাপা, বড় সমস্যা নেই | Core Web Vitals সীমার ভেতরে, keyboard-এ পরীক্ষা |
| Deployment প্রস্তুতি ও README (১৫) | শুধু dev-এ চলে | build পাস | README, env-এর তালিকা, health check | সাথে deploy-এর পরিকল্পনা, backup, সীমাবদ্ধতা |
নিচের scorer-এ reference app-এর নিজের মাত্রা বসানো, প্রতিটির ছোট প্রমাণসহ। নিজের মাত্রা বসিয়ে চালান; ওজনের যোগ ১০০ না হলে এটি নিজেই থামবে:
// নিজের ক্যাপস্টোনের rubric scorer: প্রতিটি মানদণ্ডে ০–৩ মাত্রা, ওজন মিলিয়ে ১০০-তে
const rubric = [
// [মানদণ্ড, ওজন, মাত্রা ০–৩, ছোট প্রমাণ]
["কার্যকারিতা (must-have সব)", 25, 3, "browse থেকে email পর্যন্ত Playwright পাস"],
["নিরাপত্তা", 20, 2, "hash, cookie, role, webhook সই আছে; rate limit, CSP নেই"],
["ডেটার সঠিকতা", 15, 3, "পয়সা, transaction, CHECK, idempotent webhook"],
["Test", 15, 2, "২১ Vitest + ৩ Playwright; CI-তে চালানো হয়নি"],
["Performance ও accessibility", 10, 2, "a11y 100; LCP 2.9 s (মোবাইল lab)"],
["Deployment প্রস্তুতি ও README", 15, 1, "build পাস; README, health check নেই"],
];
function score(rows, show = true) {
const weights = rows.reduce((s, r) => s + r[1], 0);
if (weights !== 100) throw new Error(`weights add to ${weights}, not 100`);
let total = 0;
for (const [name, w, level, why] of rows) {
const pts = (w * level) / 3;
total += pts;
if (show) console.log(`${name.padEnd(30)} ${level}/3 ${pts.toFixed(1).padStart(5)} ${why}`);
}
const blockers = rows.filter((r) => r[2] === 0).map((r) => r[0]);
const band = blockers.length ? "দেখানোর আগে ০ মাত্রার ঘর ঠিক করুন"
: total >= 85 ? "দেখানোর মতো প্রস্তুত" : total >= 70 ? "ভালো, ফাঁকগুলো লিখে রাখুন"
: total >= 50 ? "আরেকটি milestone লাগবে" : "মূল কাজ আগে";
console.log(`মোট ${total.toFixed(1)}/100 → ${band}`);
}
score(rubric);
// একই প্রকল্প, শুধু README লেখা হলে (deployment-এর ঘর ১ → ২):
score(rubric.map((r) => (r[0].startsWith("Deployment") ? [r[0], r[1], 2, "README যোগ"] : r)), false);প্রথম সারি কার্যকারিতা (must-have সব) 3/3 25.0 browse থেকে email পর্যন্ত Playwright পাস; নিরাপত্তা ২/৩-এ 13.3, কারণ ২০ × ২ ÷ ৩; deployment ১/৩-এ মাত্র 5.0. মোট মোট 75.0/100 → ভালো, ফাঁকগুলো লিখে রাখুন. শেষ লাইন মোট 80.0/100 → ভালো, ফাঁকগুলো লিখে রাখুন: শুধু একটি README লিখলে পাঁচ নম্বর বাড়ে, কারণ ওই ঘরের ওজন ১৫, আর এক মাত্রা মানে ১৫ ÷ ৩ = ৫. আর যেকোনো ঘর ০ হলে মোট যত বেশিই হোক রায় হবে “দেখানোর আগে ০ মাত্রার ঘর ঠিক করুন”; float-এ টাকা রাখা প্রকল্প ৯০ পেলেও প্রস্তুত নয়।
১০ · AI-এর যুগে ক্যাপস্টোন
ক্যাপস্টোনের আসল ফল কোড নয়, আপনার বোঝা। AI-এর সবচেয়ে ভালো ব্যবহার হলো ছোট, যাচাইযোগ্য টুকরো চাওয়া, আর প্রতিটি টুকরো acceptance check দিয়ে মেলানো। এই প্রকল্প বানাতে গিয়ে যে ভুলগুলো ধরা পড়েছে (@types/node-এর দ্বন্দ্ব, WIN1252, body-তে metadata, base64 মেইল, test-এর নির্ভরতা), AI-এর লেখা কোডেও এমন ভুল সহজে থাকে: কোড চলে, কিন্তু ঠিকভাবে নয়। AI দিয়ে কাজের পুরো পদ্ধতি পর্ব ৮৬-এ (AI দিয়ে ফুল-স্ট্যাক)।
একটি milestone-এর একটি টুকরোই চান, version আর নিয়মসহ, আর test-ও চান:
AI-এর লেখা কোড মেলানোর চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১বিশটি ফিচারের অর্ধেক-চলা প্রকল্পের চেয়ে ছয়টি ফিচারের পুরো-চলা প্রকল্প বেশি শেখায় কেন?
সফটওয়্যারের সবচেয়ে কঠিন ভুলগুলো সাধারণত কোনো একটি অংশের ভেতরে থাকে না, থাকে জোড়ের জায়গায়। বাজারঘরের ক্ষেত্রেই দেখুন: টাকার হিসাবের unit test প্রথম দিনেই পাস করেছিল, কিন্তু সেই হিসাব পর্দায় বাংলা অঙ্কে আসে, তাই end-to-end test ভাঙল। Database-এর schema ঠিক ছিল, কিন্তু Windows-এর ডিফল্ট encoding বাংলা লেখা নিল না। Metadata-র কোড ঠিক ছিল, কিন্তু Cache Components-এর সাথে মিলে সেটি body-তে গেল। এই ভুলগুলোর একটিও ধরা পড়ত না, যদি প্রকল্প “catalogue অর্ধেক, checkout অর্ধেক” অবস্থায় থেমে থাকত।
সফটওয়্যার প্রকৌশলে এই ধারণার একটি পরিচিত নাম আছে: “walking skeleton”, যা সাধারণত Alistair Cockburn-এর নামে বলা হয় (২০০০-এর দশকের শুরুতে তাঁর লেখায়)। আগে সব স্তর ছুঁয়ে যাওয়া একটি সবচেয়ে সরু কিন্তু চালানো যায় এমন পথ, তারপর তাতে মাংস লাগানো। একই কারণে অনেক দল “vertical slice” ধরে কাজ ভাগ করে: একটি ফিচার UI থেকে database পর্যন্ত, স্তর ধরে নয়।
শেখার দিক থেকেও পার্থক্য বড়। অর্ধেক-চলা প্রকল্পে আপনি অনেক API-র সাথে পরিচিত হন; পুরো-চলা প্রকল্পে আপনি শেখেন কীভাবে সেগুলো একে অপরের ওপর প্রভাব ফেলে: transaction কেন redirect-এর আগে শেষ হতে হয়, webhook কেন email-এর আগে commit করে, test কেন একে অপরের ওপর নির্ভর করতে পারে না। ইন্টারভিউতেও “এটা শেষ পর্যন্ত চলে, এই যে test” বলার ওজন “আরও অনেক কিছু করার পরিকল্পনা ছিল”-এর চেয়ে অনেক বেশি। আর ফিচারের তালিকা বাড়লে প্রতিটির জন্য test, নিরাপত্তা আর review-এর খরচও বাড়ে, যা ছোট সময়ে প্রায়ই বাদ পড়ে যায়।
মূল উপলব্ধি: সম্পূর্ণতা নিজেই একটি ফিচার। আগে সরু পথটি শেষ পর্যন্ত চালান, তারপর চওড়া করুন; জোড়ের ভুলগুলো তখনই দেখা দেয়, যখন প্রতিকার এখনো সস্তা।
প্র ০২অর্ডারের email webhook-এর ভেতরে সরাসরি পাঠানো ঝুঁকি কেন, আর transactional outbox কী সমাধান দেয়?
Reference app-এ webhook আগে database-এ অর্ডারকে PAID করে commit করে, তারপর email পাঠায়। দুটি আলাদা ব্যবস্থায় লেখা হচ্ছে, database আর email server, আর দুটোকে একসাথে “হয় দুটোই, নয় কোনোটাই” করার কোনো সহজ উপায় নেই। একে প্রায়ই dual write সমস্যা বলা হয়। যদি email server সেই মুহূর্তে বন্ধ থাকে, অর্ডার PAID হয়ে গেছে কিন্তু মেইল যায়নি, আর আমাদের কোড শুধু log লেখে। Gateway আবার webhook পাঠালেও event id দেখে আমরা duplicate বলি, তাই মেইল আর কখনো যায় না। উল্টো ক্রমে করলে, আগে মেইল তারপর commit, আরও খারাপ: commit ব্যর্থ হলে ক্রেতা রসিদ পান, অথচ অর্ডার পরিশোধিত নয়।
Transactional outbox pattern, যা microservices-এর লেখায় (যেমন Chris Richardson-এর microservices.io) পরিচিত, বলে: অবস্থা বদলের একই database transaction-এ একটি “পাঠাতে হবে” সারিও লিখুন, একটি outbox টেবিলে। তারপর একটি আলাদা worker সেই টেবিল পড়ে মেইল পাঠায়, সফল হলে সারিটি চিহ্নিত করে, ব্যর্থ হলে পরে আবার চেষ্টা করে। Database-এর transaction এখন একটিই, তাই অবস্থা আর “পাঠাতে হবে” কখনো আলাদা হয় না।
দাম আছে। মেইল এখন সাথে সাথে নয়, worker-এর পরের ঘুরে যায়। আর worker মেইল পাঠিয়ে সারি চিহ্নিত করার আগে ভেঙে পড়লে মেইল দুবার যেতে পারে; তাই “অন্তত একবার” পাঠানো ধরে নিয়ে মেইলে একটি স্থির message id বা অর্ডার নম্বর রাখতে হয়। অর্থাৎ সমস্যা মুছে যায় না, সরে যায় এমন জায়গায় যেখানে সামলানো সহজ। Background job-এর queue আর retry পর্ব ৬২-এ, delivery-র তত্ত্ব System Design কোর্সের delivery semantics পর্বে।
মূল উপলব্ধি: দুটি ভিন্ন ব্যবস্থায় একসাথে লেখা কখনো পুরো নিরাপদ নয়। একটি transaction-এ “কী করতে হবে” লিখে রাখুন, আর কাজটি আলাদাভাবে, আবার চেষ্টা করা যায় এমনভাবে করুন।
প্র ০৩Client আর server-এ একই cartTotal() চালানোর সুবিধা কী, আর কোথায় এর সীমা?
Node.js আসার পরে (২০০৯) একই ভাষা browser আর server-এ চলা সম্ভব হলো, আর তার একটি বড় প্রতিশ্রুতি ছিল “একবার লিখুন, দুই জায়গায় চালান”। বাজারঘরে এর একটি পরিষ্কার উদাহরণ lib/money.ts: কার্টের পাতায় ক্রেতা কুপন লিখতেই মোট বদলায়, কোনো server অনুরোধ ছাড়াই, আর server-এ অর্ডার তৈরির সময় ঠিক একই function চলে। দুই জায়গায় দুটি আলাদা হিসাব থাকলে একদিন কেউ একটিতে ডেলিভারির নিয়ম বদলাবেন, অন্যটিতে ভুলে যাবেন, আর ক্রেতা পর্দায় এক দাম দেখে অন্য দাম দেবেন। ভাগ করা function আর তার unit test এই বিভ্রান্তি দূর করে।
সীমা তিনটি। প্রথম ও প্রধান: client-এর হিসাব শুধু দেখানোর জন্য, রায়ের জন্য কখনো নয়। Browser-এর সব কোড ব্যবহারকারীর হাতে; তিনি DevTools-এ যেকোনো সংখ্যা বদলাতে পারেন। তাই server দাম পড়ে database থেকে, আর form-এ দামের কোনো ঘরই নেই। দ্বিতীয়: যা client-এ যায় তা সবাই দেখে। কুপনের তালিকা আর নিয়ম এখন bundle-এ আছে; একটি “গোপন” কুপন থাকলে এই নকশা তা ফাঁস করত। তখন preview-এর জন্য একটি ছোট server action ডাকাই ভালো। তৃতীয়: ভাগ করা কোডে কোনো server-এর জিনিস ঢুকে গেলে (database, secret, node:crypto) সেটি bundle-এ চলে যাওয়ার ঝুঁকি। এ কারণেই lib/auth.ts-এ import 'server-only', আর money.ts-এ কোনো import নেই।
একই নীতি Zod-এর schema ভাগ করার ক্ষেত্রেও (পর্ব ২১): client-এ যাচাই ব্যবহারকারীর সুবিধা, server-এ যাচাই নিরাপত্তা। দুটো একই schema থেকে এলে সবচেয়ে ভালো, কিন্তু server-এরটি কখনো বাদ দেওয়া যায় না।
মূল উপলব্ধি: কোড ভাগ করলে নিয়ম এক থাকে, কিন্তু বিশ্বাস ভাগ হয় না। Client দেখায়, server সিদ্ধান্ত নেয়, আর কোন কোড কোথায় যাবে তা নিজে পাহারা দিন।
প্র ০৪স্টক checkout-এ কমানো ভালো, না পেমেন্টের পরে? দুই পথের দাম কী?
বাজারঘর স্টক কমায় checkout-এ, অর্থাৎ অর্ডার তৈরির মুহূর্তে, পেমেন্টের আগেই। সুবিধা: যিনি gateway-এর পাতায় টাকা দিচ্ছেন, তাঁর পণ্যটি তাঁর জন্য আটকে আছে; টাকা দেওয়ার পরে “দুঃখিত, শেষ” শুনতে হয় না, আর refund-এর ঝামেলা নেই। দাম: যিনি checkout করে টাকা না দিয়ে চলে গেলেন, তাঁর অর্ডার স্টক আটকে রাখে। Reference app-এ মেয়াদের কোনো job নেই, তাই এমন অর্ডার চিরকাল আটকে রাখে, যা review-এর একটি স্বীকৃত ফাঁক। কেউ ইচ্ছা করে অনেক অর্ডার খুলে স্টক আটকে দিলে সেটি একটি সস্তা আক্রমণও হতে পারে; তাই মেয়াদ, প্রতি ব্যবহারকারীর সীমা আর rate limit লাগে।
অন্য পথ: পেমেন্ট নিশ্চিত হলে, অর্থাৎ webhook-এ, স্টক কমানো। তখন কেউ স্টক আটকাতে পারেন না, কিন্তু শেষ পণ্যের জন্য দুজনেই টাকা দিয়ে ফেলতে পারেন; একজনকে টাকা ফেরত দিতে হবে, আর gateway-এর refund-এর খরচ ও দেরি থাকে। বেশি চাহিদার সীমিত জিনিসে, যেমন অনুষ্ঠানের টিকিট বা ঈদের বিশেষ অফার, অনেক ব্যবস্থা তাই কয়েক মিনিটের একটি অস্থায়ী “hold” দেয়: আসন বা পণ্য সেই সময়ের জন্য আপনার, তারপর ছেড়ে দেওয়া হয়। এটি checkout-এ কমানোর পথ, সাথে মেয়াদ (System Design কোর্সের ticket booking case study).
কোনটি বেছে নেবেন তা নির্ভর করে স্টকের পরিমাণ, পেমেন্ট কতক্ষণ লাগে, আর ভুলের দাম কার ঘাড়ে পড়ে তার ওপর। মুদি দোকানের চাল, যা গুদামে অনেক আছে, সেখানে পার্থক্য সামান্য; একটিমাত্র জামদানি শাড়িতে পার্থক্য বড়। যে পথই নিন, কমানোটা শর্তসাপেক্ষ update আর transaction-এ হতে হবে, নইলে দুই পথেই overselling.
মূল উপলব্ধি: “কখন কমাবেন” একটি ব্যবসার সিদ্ধান্ত, “কীভাবে কমাবেন” একটি প্রকৌশলের সিদ্ধান্ত। প্রথমটির দাম খোলাখুলি লিখুন; দ্বিতীয়টিতে কোনো আপস নয়।
প্র ০৫Lighthouse ৯৬, ২৪টি test পাস, rubric-এ ৭৫ — এই সংখ্যাগুলো প্রকল্পের মান সম্পর্কে কতটা বলে?
অর্থনীতিবিদ Charles Goodhart ১৯৭৫ সালের দিকে মুদ্রানীতি নিয়ে একটি পর্যবেক্ষণ দিয়েছিলেন, যা পরে নৃবিজ্ঞানী Marilyn Strathern-এর সহজ রূপে পরিচিত হয়: কোনো মাপ লক্ষ্য হয়ে গেলে তা আর ভালো মাপ থাকে না। প্রকল্পের সংখ্যাগুলোর ক্ষেত্রেও এটি খাটে। বাজারঘরের Lighthouse ৯৬ মাপা হয়েছে পণ্য আর তালিকার পাতায়, login ছাড়া, কোনো ক্লিক ছাড়া; checkout-এর পাতা, যেখানে আসল টাকা জড়িত, মাপাই হয়নি। Accessibility ১০০ পাওয়া পাতাতেও ভুলের পরে form খালি হয়ে যায়, যা কোনো automated audit ধরেনি, ধরেছে একটি হাতে লেখা Playwright probe.
Test-এর সংখ্যাও একই রকম। ২৪টি test শুনতে ভালো, কিন্তু প্রশ্ন হলো কী test করা হয়েছে। এখানে সবচেয়ে মূল্যবান test সম্ভবত একটিই, “শেষ জার একবারই বিক্রি”, কারণ এটি এমন একটি ভুল ধরে যা হাতে কখনো দেখা যায় না। অন্যদিকে webhook route নিজে suite-এ নেই, শুধু একটি script দিয়ে দেখা। Coverage-এর শতাংশ বাড়ানোর জন্য সহজ getter-এর test লিখলে সংখ্যা বাড়ে, আত্মবিশ্বাস নয় (coverage-এর সীমা)।
Rubric-এর ৭৫ নিজের দেওয়া নম্বর, তাই পক্ষপাত থাকতে পারে; এর মূল্য সংখ্যায় নয়, প্রতিটি ঘরের পাশে লেখা প্রমাণে। তাহলে সংখ্যা কেন? কারণ সংখ্যা পরিবর্তন দেখায়: metadata ঠিক করার পরে SEO ৯১ থেকে ১০০, README লিখলে rubric ৭৫ থেকে ৮০. আর সংখ্যা আলোচনা শুরু করায়। সমস্যা হয় তখনই, যখন সংখ্যাকে প্রশ্নের উত্তর ধরে নেওয়া হয়, প্রশ্নের শুরু নয়।
মূল উপলব্ধি: প্রতিটি সংখ্যার পাশে লিখুন কী মাপা হয়েছে আর কী হয়নি। “৯৬, পণ্যের পাতায়, মোবাইল lab-এ, checkout মাপা হয়নি” একটি সৎ বাক্য; শুধু “৯৬” একটি বিজ্ঞাপন।
অনুশীলন
একটি হিসাব, একটি কমান্ডের কাজ, একটি নকশা পর্যালোচনা আর একটি ফল অনুমান।
-
হিসাব করুন: বাজারঘরের নিয়ম: ডেলিভারি ৳৬০, ছাড়ের পরে ৳২,০০০ বা বেশি হলে ডেলিভারি নেই;
EID10মানে ১০% ছাড়, শুধু ৳১,০০০ বা বেশিতে, সর্বোচ্চ ৳২০০. কার্টে সিলেটের চা ৳২৮০ × ৩ আর মধু ৳৬৫০ × ১. (ক)EID10দিয়ে মোট কত, পয়সায় আর টাকায়? (খ) মধু দুটি হলেEID10দিয়ে মোট কত? (গ) (খ)-এর কার্টে কুপন ছাড়া মোট কত, আর কুপনে ক্রেতার আসল লাভ কত?(ক) subtotal = ৩ × ২৮,০০০ + ৬৫,০০০ = ১,৪৯,০০০ পয়সা। ১০% = ১৪,৯০০, যা ২০,০০০-এর কম, তাই ছাড় ১৪,৯০০. ছাড়ের পরে ১,৩৪,১০০, ২,০০,০০০-এর কম, তাই ডেলিভারি ৬,০০০. মোট ১,৪০,১০০ পয়সা = ৳১,৪০১.
(খ) subtotal = ৮৪,০০০ + ১,৩০,০০০ = ২,১৪,০০০. ১০% = ২১,৪০০, সীমা ২০,০০০, তাই ছাড় ২০,০০০. ছাড়ের পরে ১,৯৪,০০০, ২,০০,০০০-এর নিচে, তাই ডেলিভারি আবার ৬,০০০. মোট ২,০০,০০০ পয়সা = ৳২,০০০.
(গ) কুপন ছাড়া subtotal ২,১৪,০০০, যা ২,০০,০০০-এর ওপরে, তাই ডেলিভারি নেই; মোট ৳২,১৪০. কুপনে লাভ ৳১৪০, ৳২০০ নয়, কারণ ছাড় ক্রেতাকে বিনা মূল্যের ডেলিভারির সীমার নিচে নামিয়ে দিয়েছে। সীমা ছাড়ের আগে না পরে মাপা হবে, এটি ব্যবসার সিদ্ধান্ত; test-এ স্পষ্ট লিখুন। হিসাবগুলো একই নিয়মে চালিয়ে মেলানো।
-
কমান্ডের কাজ: নিজের প্রকল্পে M6-এর সব প্রমাণ একবারে তৈরির জন্য কমান্ডগুলো কোন ক্রমে চালাবেন, আর প্রতিটির পরে কী দেখবেন? ধরে নিন database চলছে, কিন্তু খালি।
(১)
npm ci: lockfile থেকে install, কোনো ERESOLVE নেই। (২)npx prisma generate. (৩)npx prisma migrate deploy, তারপরnpx prisma db seed. (৪)npx vitest run: তিনটি project-এর সংখ্যা আলাদা দেখুন। (৫)npx next build: route table-এ ○, ◐, ƒ আপনার প্রত্যাশামতো কি না। (৬)next start, তারপরnpx playwright test; ব্যর্থ হলেerror-context.md. (৭) Lighthouse তিনবার, মাঝের মান। শেষে নিজের server আর database নিজের process ID দিয়ে বন্ধ করে port খালি কি না দেখুন; আমাদের একটি run-এ PostgreSQL-এর একটি io worker মূল process বন্ধের পরেও port ধরে ছিল। -
নকশা পর্যালোচনা: একজন সহপাঠীর checkout-এর নকশা: (ক) form-এ hidden field-এ
totalপাঠানো হয়, server সেটিই order-এ লেখে; (খ) স্টক কমে webhook-এ,product.update({ stock: product.stock - qty })দিয়ে, আগে পড়ে; (গ) admin-এর বোতাম শুধু admin-কে দেখানো হয়, action-এ কোনো যাচাই নেই; (ঘ) webhook আগে email পাঠায়, তারপর অর্ডার PAID করে। কী কী সমস্যা, কীভাবে সারাবেন?(ক) ক্রেতা DevTools-এ
totalবদলে ৳১ দিতে পারেন; মোট হিসাব server-এ, database-এর দামে। (খ) পড়া আর লেখা আলাদা, তাই দুটি webhook একসাথে এলে দুজনেই পুরোনো স্টক পড়বেন: overselling, বা CHECK না থাকলে ঋণাত্মক স্টক। শর্তসাপেক্ষupdateMany(stock: { gte: qty }) আরdecrement, একটি transaction-এ; সাথে CHECK constraint. (গ) Server action একটি খোলা endpoint; যে কেউ ডাকতে পারেন। Action-এর ভেতরেrequireRole('ADMIN'), আর অবস্থার নিয়ম। (ঘ) Commit ব্যর্থ হলে ক্রেতা রসিদ পান অথচ অর্ডার পরিশোধিত নয়। আগে commit, তারপর email, আর ভালো হয় outbox দিয়ে। -
ফল অনুমান করুন: বাজারঘরের নিয়মে একটি অর্ডার
PENDING_PAYMENT, মোট ৫০,০০০ পয়সা, ২টি গামছা; checkout-এর পরে গামছার স্টক ৩. এরপর webhook-এ এই event-গুলো আসে, ক্রমানুসারে:evt_1payment.failed ৫০,০০০;evt_2payment.succeeded ৫০,০০০; আবারevt_1payment.failed ৫০,০০০. প্রতিটির outcome কী, আর শেষে অবস্থা ও স্টক কত? দ্বিতীয় event-টি দোকানের জন্য কী বার্তা দেয়?evt_1: অঙ্ক মেলে, নতুন id,PENDING_PAYMENT → CANCELLEDবৈধ, তাইcancelled, আর আটকে রাখা ২টি ফেরত: স্টক ৫.evt_2: নতুন id, কিন্তুCANCELLED → PAIDনিয়মে নেই, তাইignored; অবস্থা আর স্টক বদলায় না। আবারevt_1: id আগে দেখা, তাইduplicate. শেষে অবস্থাCANCELLED, স্টক ৫. দ্বিতীয় event-টি বিপদসংকেত: gateway বলছে টাকা কাটা হয়েছে, অথচ অর্ডার বাতিল। কোড নিরাপদ, কিন্তু ক্রেতার টাকা আটকে; alert আর একজন মানুষের হাতে refund-এর সিদ্ধান্ত লাগবে (পর্ব ৬৫)।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমযে Next.js আর Prisma অ্যাপের build নিজেই database থেকে পণ্যের slug পড়ে, তার VPS-এ নতুন version পাঠানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- lockfile অনুযায়ী dependency install (npm ci)
- Prisma client তৈরি (prisma generate)
- Database-এ নতুন migration চালানো (migrate deploy)
- Production build (next build)
- Service আবার চালু করা (systemd restart)
- Smoke test: হোম, একটি পণ্যের পাতা আর health check
একটি অনলাইন দোকানে দাম রাখার সবচেয়ে নিরাপদ উপায় কোনটি?
- টাকায়, দশমিকসহ JavaScript number হিসেবে
- বাংলা অঙ্কে format করা লেখা হিসেবে, যেমন "১,২৫০৳"
- পয়সায়, পূর্ণসংখ্যা হিসেবে, দেখানোর সময় format
- browser থেকে আসা দাম, যাতে server-এর কাজ কমে
একটি পণ্যের স্টক ১. দুজন ক্রেতা একই মুহূর্তে checkout চাপলেন। কোন কোড নিশ্চিত করে যে কেবল একজনই পাবেন?
- আগে findUnique দিয়ে স্টক পড়া, ১ বা বেশি হলে update দিয়ে stock - 1 লেখা
- একটি transaction-এ updateMany, where-এ stock gte qty, আর data-তে decrement; count ০ হলে বাতিল
- Browser-এ বোতাম ক্লিকের পরে বন্ধ করে দেওয়া
- Checkout-এর action-এ এক সেকেন্ড অপেক্ষা যোগ করা
একটি payment gateway একই event (id: evt_77) দুবার একটি সই করা webhook-এ পাঠাল। ভালো নকশার server দ্বিতীয়বার কী করবে?
- 401 ফেরত দেবে, কারণ একই সই দুবার এসেছে
- আবার অর্ডারকে PAID করবে আর email পাঠাবে
- 500 ফেরত দেবে, যাতে কেউ log দেখে
- কিছু না বদলে সফল (2xx) উত্তর দেবে, যেমন outcome duplicate
Next.js-এর একটি admin পাতা শুধু ADMIN role-এর জন্য, আর তাতে একটি server action আছে যা অর্ডারের অবস্থা বদলায়। Role যাচাই কোথায় লাগবে?
- পাতার server component-এ আর server action-এর ভেতরে, দুই জায়গাতেই
- শুধু পাতার server component-এ, কারণ action ওই পাতাতেই থাকে
- শুধু client-এ, বোতামটি লুকিয়ে
- শুধু root layout-এ, একবার
একটি পাতায় আছে <input id="q" name="q"> (type নেই), একটি <form role="search">-এর ভেতরে। Playwright-এর page.getByRole('searchbox') কী করবে?
- input-টি পাবে, কারণ সে search form-এর ভেতরে
- কিছু পাবে না আর অপেক্ষা করে timeout হবে, কারণ input-টির role textbox
- id "q" দেখে input-টি খুঁজে নেবে
- strict mode error দেবে, কারণ দুটি element মেলে
দুই সপ্তাহের একটি ক্যাপস্টোনে MoSCoW পদ্ধতিতে কোনটি সবচেয়ে যুক্তিসঙ্গতভাবে “Won't have (এবার নয়)”?
- ক্রেতার login
- Checkout-এ স্টক আটকে রাখা
- একাধিক বিক্রেতার দোকান আর তাদের মধ্যে টাকা ভাগ
- সই করা পেমেন্ট webhook
Next.js 16-এ Cache Components চালু একটি পাতায় redirect() ডাকা হয়েছে একটি Suspense boundary-র ভেতরের async component থেকে, খোলস পাঠানো শুরু হওয়ার পরে। Login ছাড়া curl দিয়ে পাতাটি চাইলে কী দেখা যায়?
- HTTP 307, Location header-এ login পাতা
- HTTP 500, কারণ redirect একটি error ছোড়ে
- HTTP 200, সাথে পাতার সুরক্ষিত বিষয়বস্তু
- HTTP 200, body-তে login পাতার দিকে একটি meta refresh, সুরক্ষিত বিষয়বস্তু ছাড়া
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- আগে brief আর scope: কার জন্য, কী সমস্যা, কোনটি must আর কোনটি এবার নয়; ছোট কিন্তু শেষ পর্যন্ত চলা প্রকল্প ফিচারে ভরা অর্ধেক প্রকল্পের চেয়ে বেশি শেখায়।
- প্রতিটি milestone-এর জন্য হ্যাঁ-না দিয়ে যাচাই করা যায় এমন acceptance criteria লিখুন, আর টাকা, স্টক ও নিরাপত্তার check বাকি রেখে এগোবেন না।
- টাকা পয়সায় পূর্ণসংখ্যা, দাম server-এ; স্টক শর্তসাপেক্ষ update আর transaction-এ; পেমেন্টের খবর শুধু সই করা, idempotent webhook থেকে; role যাচাই পাতায় আর action-এ দুই জায়গাতেই।
- প্রতিটি স্তরে test: নিয়মে unit, component-এ ব্যবহারকারীর চোখ, database-এর আচরণে আসল database, পুরো পথে Playwright; আর প্রতিটি ব্যর্থতা থেকে শিখুন কোনটি app-এর ভুল আর কোনটি test-এর।
- Build, Lighthouse আর review-এর পরে ফাঁকগুলো খোলাখুলি লিখুন; deploy-এ migration আগে, কোড পরে; আর rubric-এর প্রতিটি নম্বরের পাশে প্রমাণ রাখুন।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৮৯ · Numerical সমস্যার অনুশীলন — performance, pagination, cache আর capacity, পূর্ণ সমাধানসহপরবর্তী পর্বপরীক্ষার প্রস্তুতি শুরু: performance, pagination, cache আর capacity-র অঙ্ক, পূর্ণ সমাধানসহ।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ; পরের পর্ব ৮৯ · Numerical সমস্যার অনুশীলন।
- পর্ব ৪৩ · Application-এ transactionপেছনে ফিরেস্টক, অর্ডার আর race condition-এর পুরো ব্যাখ্যা।
- পর্ব ৬৫ · Payment integrationপেছনে ফিরেGateway flow, webhook, idempotency আর refund.
- পর্ব ৭৬ · End-to-end testপেছনে ফিরেPlaywright, নির্ভরযোগ্য selector আর trace.
- Software Engineering · ক্যাপস্টোনপাশের ট্র্যাকপ্রকল্পের পরিকল্পনা, review আর Git-এর কাজের ধারা।
- System Design · Ticket booking case studyগভীরে যানসীমিত জিনিস আটকে রাখা আর বড় মাপে overselling ঠেকানো।
- Cybersecurity · API securityগভীরে যানWebhook, rate limit আর authorization-এর তত্ত্ব।