"Our Damascus" — A web and mobile classifieds platform for the Syrian market where users can post listings to sell items, advertise services, or list rentals.
- Project Overview
- Tech Stack
- Hosting & Infrastructure
- Monorepo Structure
- Getting Started
- Environment Variables
- Database & Migrations
- API Reference
- Key Architectural Decisions
- Development Status
- Roadmap
- Next Session
Shamna is a Sahibinden/Craigslist-style classifieds platform built specifically for the Syrian market. Core features include:
- Phone number + OTP authentication (no email required)
- Post, browse, and search listings across categories
- Category-specific listing attributes via JSONB
- Arabic-first design with full RTL layout
- Image uploads per listing and profile photos (Cloudflare R2 — live)
- My Listings page — view, mark as sold, delete your own listings
- Save/unsave listings — heart button on cards and detail pages,
/savedpage - Notifications — admin broadcasts, two-way admin ↔ user threads, system events (expiry, phone reveal, saved listing changes, account standing)
- Mobile app (React Native) planned for phase 2
- Business/advertiser login planned for a later phase
| Layer | Choice | Notes |
|---|---|---|
| Backend API | Python + FastAPI | Chosen for long-term ML integration (recommendations, fraud detection, price suggestions) |
| ORM | SQLAlchemy 2.x | Using Mapped + mapped_column style — required for Pylance compatibility |
| DB Migrations | Alembic | Run locally via uv run alembic upgrade head or via GitHub Actions |
| Web Frontend | Next.js 15 + Tailwind CSS | App Router, Arabic/RTL from day one |
| UI Components | shadcn/ui | Copy-paste components, no lock-in |
| Mobile | React Native + Expo | Phase 2 |
| Primary Database | PostgreSQL (Supabase) | Free tier during dev, session pooler for IPv4 compatibility |
| Search | Meilisearch | Arabic full-text search — planned |
| Cache / Queue | Redis + BullMQ | Planned — needed for automated notification triggers (expiry warnings, welcome messages) |
| Object Storage | Cloudflare R2 | Live — presigned URL upload, direct browser → R2 |
| Auth | Custom JWT + OTP (phone-based) | Access tokens (15 min, localStorage) + refresh tokens in httpOnly cookies (30 days) |
| Package Manager (API) | uv | Fast Python package manager — always use uv add never pip install |
| Font | IBM Plex Sans Arabic | Arabic-first, clean for marketplace UI |
| Service | Provider | Purpose | Notes |
|---|---|---|---|
| Web Frontend | Vercel | Next.js hosting | Live at https://www.shamna.shop — auto-deploy from GitHub |
| Backend API | Railway | FastAPI server | Live at https://railway.shamna.shop — migrate to Hetzner + Coolify pre-launch |
| Database | Supabase | PostgreSQL | Use Session Pooler URL (IPv4 compatible). Free tier pauses after 1 week inactivity |
| DNS / CDN | Cloudflare | DNS (proxy disabled for Vercel compatibility) | Both www.shamna.shop and railway.shamna.shop resolve correctly |
| Image Storage | Cloudflare R2 | Listing photos + profile pics | Live — bucket: shamna-listings, public URL: https://media.shamna.shop |
| Search | Meilisearch | Self-hosted on Hetzner | Planned |
- Frontend:
https://www.shamna.shop→ Vercel - API:
https://railway.shamna.shop→ Railway (port 8080) - Both sit under the same root domain (
.shamna.shop), which means cookies withdomain=".shamna.shop"are shared across both — this fully resolves the cross-domain cookie issue. - Cloudflare proxy is disabled on both records (Vercel requires DNS-only mode).
Before launch, migrate Railway → Hetzner Cloud + Coolify for full control and lower cost. Meilisearch and Redis will also be self-hosted there.
shamna/
├── apps/
│ ├── api/ ← FastAPI backend
│ │ ├── alembic/ ← DB migrations
│ │ │ └── versions/ ← migration files
│ │ ├── app/
│ │ │ ├── core/
│ │ │ │ ├── config.py ← pydantic-settings (reads .env)
│ │ │ │ ├── security.py ← JWT create/decode helpers
│ │ │ │ ├── dependencies.py ← get_current_user / get_optional_user
│ │ │ │ └── r2.py ← R2 client, generate_presigned_upload, public_url
│ │ │ ├── db/
│ │ │ │ ├── base.py ← SQLAlchemy DeclarativeBase only — no model imports
│ │ │ │ └── session.py ← engine, SessionLocal, get_db
│ │ │ ├── models/
│ │ │ │ ├── user.py ← User model (Mapped style) — includes is_admin bool
│ │ │ │ ├── otp.py ← OTPCode model
│ │ │ │ ├── listing.py ← Listing model
│ │ │ │ ├── saved_listing.py ← SavedListing model (user_saved_listings table)
│ │ │ │ ├── notification.py ← Notification, NotificationThread, NotificationMessage models + NotificationType enum
│ │ │ │ └── rating.py ← Rating model — ratings table with all constraints
│ │ │ ├── routers/
│ │ │ │ ├── auth.py ← /auth/* endpoints including /auth/me (GET + PUT)
│ │ │ │ ├── listings.py ← /listings CRUD + /listings/mine + /listings/saved + save/unsave + phone reveal + delete
│ │ │ │ ├── uploads.py ← /uploads/presign + /uploads/presign-profile
│ │ │ │ ├── notifications.py ← /notifications + /admin/notifications endpoints
│ │ │ │ └── ratings.py ← POST /listings/{id}/rate + GET /users/{id}/ratings + GET /users/{id}/ratings/summary
│ │ │ └── main.py ← FastAPI app, CORS, router registration
│ │ ├── alembic/
│ │ │ └── env.py ← imports all models for autogenerate detection
│ │ ├── alembic.ini
│ │ ├── Procfile ← Railway: uvicorn app.main:app
│ │ └── pyproject.toml ← Python dependencies (managed by uv)
│ ├── web/ ← Next.js 15 frontend
│ │ ├── app/
│ │ │ ├── auth/ ← OTP login flow (2 steps: phone → code)
│ │ │ ├── category/[slug]/ ← Category listing page + filters
│ │ │ ├── listing/[id]/ ← Listing detail page (server component)
│ │ │ ├── my-listings/ ← Owner listing management page
│ │ │ │ └── page.tsx ← View all own listings, mark as sold, delete
│ │ │ ├── saved/ ← Saved listings page
│ │ │ │ └── page.tsx ← Grid of saved listings, empty state, auth-gated
│ │ │ ├── notifications/ ← Notifications page
│ │ │ │ └── page.tsx ← Two tabs: flat notifications + two-way message threads
│ │ │ ├── post/ ← Multi-step post an ad wizard
│ │ │ │ └── page.tsx ← Owns all form state, handles submit to /listings
│ │ │ ├── profile/ ← User profile page (inline editable fields)
│ │ │ │ └── page.tsx ← Name, email, bio, profile pic, standing badge
│ │ │ ├── layout.tsx ← Root layout: IBM Plex Sans Arabic, RTL, AuthProvider, Navbar + Footer
│ │ │ ├── page.tsx ← Homepage: Hero + per-category listing sections
│ │ │ └── globals.css ← CSS vars: brand, surface, border, text colors
│ │ ├── components/
│ │ │ ├── post/ ← step-indicator, step-category, step-details,
│ │ │ │ step-photos, step-review
│ │ │ ├── navbar.tsx ← Auth-aware: logged-out = two buttons; logged-in = icon row (Bell/Heart/ClipboardList) + avatar dropdown; bell shows unread badge
│ │ │ ├── footer.tsx
│ │ │ ├── hero.tsx ← 3-panel layout: category sidebar (hover) + animated banner + post-ad promo card
│ │ │ ├── category-section.tsx ← Per-category block: colored feature card + 2×4 mini listing grid
│ │ │ ├── listing-card.tsx ← Grid card (homepage + category page) — includes SaveButton overlay
│ │ │ ├── listing-list-card.tsx ← Horizontal card for list view
│ │ │ ├── listing-gallery.tsx ← Image carousel with thumbnails
│ │ │ ├── category-filters.tsx ← URL-based filters: condition, city, price, sort
│ │ │ ├── view-toggle.tsx ← Grid/list toggle
│ │ │ ├── save-button.tsx ← Client component: heart toggle, two variants (icon overlay / full button)
│ │ │ ├── phone-reveal.tsx ← Reveal phone button + WhatsApp button
│ │ │ └── report-button.tsx
│ │ ├── contexts/
│ │ │ └── auth-context.tsx ← AuthProvider, useAuth(), AuthUser type
│ │ ├── lib/
│ │ │ └── api.ts ← apiFetch, getAuthHeaders, getApiBaseUrl
│ │ ├── types/
│ │ │ ├── listing.ts ← Listing, Seller, ListingsResponse types
│ │ │ ├── post.ts ← PostFormData, EMPTY_POST_FORM
│ │ │ └── notification.ts ← Notification, NotificationThread, NotificationMessage, NotificationType, UnreadCount types
│ │ └── middleware.ts ← Protects /post, /profile, /my-listings, /saved, /notifications routes
│ └── mobile/ ← React Native stub (phase 2)
├── .github/
│ └── workflows/
│ └── migrate.yml ← Manual trigger: runs alembic upgrade head
└── README.md
- Node.js 18+
- Python 3.13+
uv—brew install uv- Railway CLI —
brew install railway
cd apps/web
npm install
npm run dev
# runs on http://localhost:3000cd apps/api
uv sync # install dependencies into .venv
uv run uvicorn app.main:app --reload
# runs on http://localhost:8000
# interactive docs at http://localhost:8000/docsImportant: Always use
uv runto execute Python commands. Never callpython,alembic, oruvicorndirectly — they will resolve to the wrong Python installation.
DATABASE_URL=postgresql://postgres.xxxx:PASSWORD@aws-1-eu-west-3.pooler.supabase.com:5432/postgres?sslmode=require
JWT_SECRET=your-generated-secret # generate with: openssl rand -hex 32
OTP_DEV_BYPASS=1234 # dev only — remove before launch
ENVIRONMENT=development # set to "production" in Railway
# Cloudflare R2
R2_ACCOUNT_ID=your_account_id
R2_ACCESS_KEY_ID=your_access_key_id
R2_SECRET_ACCESS_KEY=your_secret_access_key
R2_BUCKET_NAME=shamna-listings
R2_PUBLIC_URL=https://media.shamna.shopNEXT_PUBLIC_API_URL=https://railway.shamna.shop
API_URL=https://railway.shamna.shop
R2_PUBLIC_URL=https://media.shamna.shop
NEXT_PUBLIC_API_URLis used by client components.API_URLis used by server components and is not exposed to the browser.
DATABASE_URL → Supabase session pooler URL
JWT_SECRET → same as .env above
OTP_DEV_BYPASS → 1234 (remove before launch)
ENVIRONMENT → production
R2_ACCOUNT_ID → Cloudflare account ID
R2_ACCESS_KEY_ID → R2 API token access key
R2_SECRET_ACCESS_KEY → R2 API token secret
R2_BUCKET_NAME → shamna-listings
R2_PUBLIC_URL → https://media.shamna.shop
NEXT_PUBLIC_API_URL → https://railway.shamna.shop
API_URL → https://railway.shamna.shop
R2_PUBLIC_URL → https://media.shamna.shop
DATABASE_URL → Supabase session pooler URL
⚠️ Never commit.envor.env.local. Both are in.gitignore.
Hosted on Supabase PostgreSQL. Direct connection is IPv6 only — always use the Session Pooler URL for local dev and Railway.
cd apps/api
uv run alembic upgrade headcd apps/api
uv run alembic revision --autogenerate -m "describe your change"
uv run alembic upgrade headAdding NOT NULL columns to existing tables: Alembic autogenerate will produce
ADD COLUMN col BOOLEAN NOT NULLwith no default, which Postgres rejects when rows already exist. Fix by addingserver_default='false'(or the appropriate default) to the generatedop.add_column()call before runningupgrade head.
Trigger manually from GitHub → Actions → Run DB Migrations.
| Table | Description |
|---|---|
users |
id, phone, name, email, bio, profile_pic, user_type, standing, warning_reason, is_active, is_admin, average_rating, rating_count, created_at |
otp_codes |
phone, code, used, expires_at, created_at |
listings |
Full listing record — see columns below |
user_saved_listings |
user_id, listing_id, created_at — unique constraint on (user_id, listing_id) |
notifications |
Flat one-way notifications (system events + admin broadcasts) |
notification_threads |
Two-way admin ↔ user conversation threads |
notification_messages |
Individual messages inside a thread |
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
phone |
String | Unique, indexed |
name |
String | Nullable |
email |
String | Nullable |
bio |
String | Nullable — short user bio |
profile_pic |
String | Nullable — R2 public URL (profiles/{user_id}.ext) |
user_type |
String | "regular" (business accounts planned) |
standing |
String | "good" | "warned" | "suspended" — default "good" |
warning_reason |
String | Nullable — populated when standing is warned/suspended |
is_active |
Boolean | Default true |
is_admin |
Boolean | Default false — grants access to /admin/* endpoints |
average_rating |
Numeric(3,2) | Nullable — denormalized avg score, updated after each new rating |
rating_count |
Integer | Default 0 — denormalized count, updated after each new rating |
created_at |
DateTime |
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | FK → users.id |
title |
String(100) | |
description |
Text | |
price |
Numeric(12,2) | |
currency |
String(3) | "USD" or "SYP" |
category |
String(50) | electronics, cars, real-estate, furniture, clothing, jobs |
condition |
String(10) | "new" or "used" |
city |
String(50) | Arabic city name |
status |
String(10) | "active", "sold", "expired" |
attrs |
JSONB | Category-specific attributes (flexible) |
image_urls |
JSONB | Array of R2 public URL strings |
views |
Integer | Incremented on each detail page visit (skipped for owner) |
expires_at |
DateTime | 30 days from creation |
created_at |
DateTime | |
updated_at |
DateTime |
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | FK → users.id, CASCADE delete, indexed |
listing_id |
UUID | FK → listings.id, CASCADE delete, indexed |
created_at |
DateTime | |
| — | UniqueConstraint | (user_id, listing_id) — one save per user per listing |
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | FK → users.id, CASCADE delete, indexed |
type |
Enum (NotificationType) | See enum below |
title |
String(200) | |
body |
Text | |
listing_id |
UUID | Nullable FK → listings.id, SET NULL on delete |
is_read |
Boolean | Default false |
created_at |
DateTime |
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | FK → users.id, CASCADE delete, indexed |
subject |
String(300) | |
type |
Enum (NotificationType) | |
is_noreply |
Boolean | Default false — when true, user cannot reply |
user_has_unread |
Boolean | Default true — drives bell badge count |
created_at |
DateTime | |
updated_at |
DateTime | Bumped on every new message — used for sorting |
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
thread_id |
UUID | FK → notification_threads.id, CASCADE delete, indexed |
body |
Text | |
sender_is_admin |
Boolean | True = admin sent, False = user sent |
created_at |
DateTime |
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
listing_id |
UUID | FK → listings.id, CASCADE delete, indexed — the sold listing that triggered the rating |
rater_id |
UUID | FK → users.id, CASCADE delete — who is leaving the rating |
ratee_id |
UUID | FK → users.id, CASCADE delete, indexed — who is being rated (always the listing owner) |
role |
String(10) | "buyer" or "seller" — the rater's role in the transaction |
score |
SmallInt | 1–5 stars |
recommended |
Boolean | Would recommend this user |
created_at |
DateTime | |
| — | UniqueConstraint | (listing_id, rater_id) — one rating per person per listing |
| — | CheckConstraint | rater_id != ratee_id — cannot rate yourself |
| — | CheckConstraint | score BETWEEN 1 AND 5 |
| Value | Description |
|---|---|
ADMIN_BROADCAST |
Admin → all or selected users, always one-way (flat notification) |
ADMIN_MESSAGE |
Admin → user, starts a two-way thread |
LISTING_EXPIRING_SOON |
System — 3 days before expires_at |
LISTING_EXPIRED |
System — on expires_at |
LISTING_REMOVED |
System/admin — listing removed by admin |
LISTING_PHONE_REVEALED |
System — someone revealed your phone number |
WELCOME |
System — sent to new users on registration |
ACCOUNT_WARNING |
System/admin — standing changed to warned |
ACCOUNT_SUSPENDED |
System/admin — standing changed to suspended |
ACCOUNT_REINSTATED |
System/admin — standing restored to good |
SAVED_PRICE_DROP |
System — price changed on a saved listing |
SAVED_LISTING_SOLD |
System — saved listing marked as sold |
SAVED_LISTING_REMOVED |
System — saved listing deleted |
RATING_RECEIVED |
System — someone rated you (phase 2) |
| Revision | Description |
|---|---|
8a5d4dc3c4c1 |
Initial — users, otp_codes, listings tables |
1.2_user_profile_fields |
Add bio, standing, warning_reason to users |
add_user_saved_listings |
Add user_saved_listings join table |
add_notifications_and_threads |
Add notifications, notification_threads, notification_messages tables + is_admin to users |
001_add_ratings |
Add ratings table + average_rating and rating_count columns to users |
Base URL: https://railway.shamna.shop
Interactive docs: https://railway.shamna.shop/docs
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/request-otp |
No | Send OTP to phone number |
| POST | /auth/verify-otp |
No | Verify OTP → returns access token + sets refresh cookie |
| POST | /auth/refresh |
Cookie | Exchange refresh token for new access token |
| GET | /auth/me |
Required | Get current user profile |
| PUT | /auth/me |
Required | Update name, email, bio |
| PUT | /auth/me/profile-pic |
Required | Save R2 profile pic URL on user record |
verify-otp response:
{
"access_token": "eyJ...",
"token_type": "bearer",
"user": {
"id": "uuid",
"phone": "+963...",
"name": null,
"email": null,
"bio": null,
"profile_pic": null,
"user_type": "regular",
"standing": "good",
"warning_reason": null,
"created_at": "2026-05-01T..."
}
}PUT /auth/me request:
{ "name": "أحمد", "email": "ahmed@example.com", "bio": "بائع موثوق" }Dev OTP bypass: code
1234always works (controlled byOTP_DEV_BYPASSenv var)
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /listings |
No | List with filters + pagination |
| POST | /listings |
Required | Create a listing |
| GET | /listings/mine |
Required | Get all listings owned by current user |
| GET | /listings/saved |
Required | Get all listings saved by current user |
| GET | /listings/{id} |
Optional | Get listing detail (increments views) |
| PATCH | /listings/{id}/status |
Required (owner only) | Mark as sold |
| DELETE | /listings/{id} |
Required (owner only) | Hard delete a listing |
| GET | /listings/{id}/phone |
Required | Reveal seller phone number |
| POST | /listings/{id}/save |
Required | Save a listing (idempotent) |
| DELETE | /listings/{id}/save |
Required | Unsave a listing |
⚠️ Route order matters in FastAPI:/listings/mineand/listings/savedmust be registered before/listings/{id}inlistings.py, otherwise the literal strings"mine"and"saved"are matched as listing IDs and return 404s. The same rule applies innotifications.py—/notifications/read-all,/notifications/threads, and/notifications/unread-countmust appear before/notifications/{id}and/notifications/threads/{id}.
GET /listings query params:
| Param | Type | Values |
|---|---|---|
category |
string | electronics, cars, real-estate, furniture, clothing, jobs |
city |
string | Arabic city name e.g. دمشق |
condition |
string | new, used |
min_price |
float | e.g. 100 |
max_price |
float | e.g. 1000 |
sort |
string | newest, price_asc, price_desc |
page |
int | default 1 |
limit |
int | default 20, max 100 |
GET /listings/mine query params:
| Param | Type | Values |
|---|---|---|
status |
string | active, sold, expired (optional — omit for all) |
page |
int | default 1 |
limit |
int | default 20, max 100 |
POST /listings/{id}/save response:
{ "saved": true }DELETE /listings/{id}/save response:
{ "saved": false }Listing object shape:
{
"id": "uuid",
"title": "آيفون ١٥ برو ماكس",
"description": "...",
"price": 850.0,
"currency": "USD",
"category": "electronics",
"condition": "new",
"city": "دمشق",
"status": "active",
"attrs": {},
"image_urls": ["https://media.shamna.shop/listings/user-id/uuid.jpg"],
"views": 12,
"created_at": "2026-04-30T07:58:32Z",
"expires_at": "2026-05-30T07:58:32Z",
"seller": {
"id": "uuid",
"name": "أحمد",
"member_since": "April 2026"
}
}| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /uploads/presign |
Required | Get presigned R2 PUT URLs for listing images (max 5) |
| POST | /uploads/presign-profile |
Required | Get presigned R2 PUT URL for profile picture |
POST /uploads/presign request:
[
{ "filename": "photo.jpg", "content_type": "image/jpeg" },
{ "filename": "photo2.png", "content_type": "image/png" }
]POST /uploads/presign-profile request:
{ "content_type": "image/jpeg" }POST /uploads/presign-profile response:
{
"upload_url": "https://...r2.cloudflarestorage.com/...?X-Amz-Signature=...",
"public_url": "https://media.shamna.shop/profiles/user-id.jpg"
}Listing images keyed as
listings/{user_id}/{uuid}.ext. Profile pics keyed asprofiles/{user_id}.ext— re-uploads overwrite the previous pic automatically.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /notifications |
Required | Current user's flat notifications, newest first |
| GET | /notifications/unread-count |
Required | Unread counts for bell badge (unread_notifications, unread_threads, total) |
| PATCH | /notifications/read-all |
Required | Mark all flat notifications as read |
| PATCH | /notifications/{id}/read |
Required | Mark one flat notification as read |
| GET | /notifications/threads |
Required | User's thread summaries, newest-updated first |
| GET | /notifications/threads/{id} |
Required | Full thread with all messages — marks thread as read |
| POST | /notifications/threads/{id}/reply |
Required | User replies to a thread (403 if is_noreply=true) |
| POST | /admin/notifications/broadcast |
Admin only | Send flat notification to all or selected users |
| POST | /admin/notifications/threads |
Admin only | Start a two-way thread with a user |
| POST | /admin/notifications/threads/{id}/reply |
Admin only | Admin replies in an existing thread |
GET /notifications/unread-count response:
{ "unread_notifications": 3, "unread_threads": 1, "total": 4 }POST /admin/notifications/broadcast request:
{
"type": "ADMIN_BROADCAST",
"title": "تحديث مهم",
"body": "سيتوقف الموقع عن العمل مؤقتاً للصيانة.",
"user_ids": null
}
user_ids: nullsends to all active users. Pass an array of UUIDs to target specific users.
POST /admin/notifications/threads request:
{
"user_id": "uuid",
"subject": "مراجعة الإعلان",
"type": "ADMIN_MESSAGE",
"body": "لاحظنا بعض المخالفات في إعلانك، نرجو التوضيح.",
"is_noreply": false
}Admin auth: Requires is_admin = true on the user record. Grant via SQL:
UPDATE users SET is_admin = true WHERE phone = '+963...';Authorization for protected endpoints:
Authorization: Bearer <access_token>
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /listings/{id}/rate |
Required | Submit a rating on a sold listing |
| GET | /users/{id}/ratings |
No | All ratings received by a user, newest first |
| GET | /users/{id}/ratings/summary |
No | Aggregate stats: avg score, total count, recommend % |
⚠️ Route order note:/listings/{id}/rateis a POST so it doesn't conflict withGET /listings/{id}— no ordering concern here.
POST /listings/{id}/rate request:
{
"score": 4,
"recommended": true,
"role": "buyer"
}POST /listings/{id}/rate errors:
400— listing is not yet marked as sold400— cannot rate your own listing409— you have already rated this listing404— listing not found
GET /users/{id}/ratings/summary response:
{
"total": 12,
"average_score": 4.58,
"recommend_pct": 91.7
}
average_scoreandrecommend_pctarenullwhentotalis 0 (no ratings yet).
Rating object shape:
{
"id": "uuid",
"listing_id": "uuid",
"rater_id": "uuid",
"ratee_id": "uuid",
"role": "buyer",
"score": 4,
"recommended": true,
"created_at": "2026-05-16T...",
"rater_name": "أحمد"
}Arabic-first: lang="ar" and dir="rtl" on root HTML element. IBM Plex Sans Arabic as primary font. All UI copy in Arabic.
Phone OTP auth: No email/password. Syrian phone numbers (+963). Access token in localStorage (client API calls) + session=1 non-httpOnly cookie (Next.js middleware route protection). Refresh token in httpOnly cookie. Post-OTP redirect uses window.location.href (not router.push) to force a full page load so middleware re-evaluates with the fresh cookie.
Cookie strategy — two cookies on login:
refresh_token— httpOnly,secure=True,samesite="lax",domain=".shamna.shop". Used by/auth/refreshto silently renew access tokens.session— non-httpOnly,secure=True,samesite="lax",domain=".shamna.shop", value"1". Presence signal only — no sensitive data. Readable by Next.js middleware to gate protected routes.
Both cookies use domain=".shamna.shop" so they are scoped to the entire root domain and readable by both www.shamna.shop (frontend) and railway.shamna.shop (API). This is what makes same-site cookie auth work across Vercel and Railway.
Cookie secure flag: Always True in production — both frontend and API are on HTTPS. The ENVIRONMENT setting in config.py still controls this; Railway has ENVIRONMENT=production.
Cross-domain cookie fix (resolved): Previously the frontend (Vercel) and API (Railway) were on different root domains, causing the session cookie set by the API to not be visible to the Next.js middleware on the frontend. Resolved by wiring both services under shamna.shop and setting domain=".shamna.shop" on all cookies in auth.py.
Cloudflare R2 CORS: The R2 bucket (shamna-listings) must have a CORS policy allowing PUT from https://www.shamna.shop and http://localhost:3000. The AllowedHeaders: ["*"] entry is required because presigned URLs include content-type in the signed headers — without it the browser preflight fails.
[
{
"AllowedOrigins": ["https://www.shamna.shop", "http://localhost:3000"],
"AllowedMethods": ["GET", "PUT", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]Next.js image domains: The next.config.js images.remotePatterns must include the R2 public hostname (media.shamna.shop) for <Image> to render R2-hosted photos. Without this, Next.js blocks the image and renders a broken icon.
/listings/mine and /listings/saved endpoint ordering: In FastAPI, routes are matched in registration order. GET /listings/mine and GET /listings/saved must appear in listings.py before GET /listings/{listing_id}, otherwise the literal strings "mine" and "saved" are interpreted as UUID listing IDs and return 404s. The same principle applies to notifications: /notifications/read-all, /notifications/threads, and /notifications/unread-count must be registered before /notifications/{id} and /notifications/threads/{id}.
My Listings page — client component: /my-listings/page.tsx is a client component (not server) because it needs localStorage access for the auth token header, and requires interactive optimistic UI updates (instant removal on delete, instant status badge change on mark-as-sold) without a page reload.
SaveButton — isolated client component: save-button.tsx is a "use client" component intentionally kept separate from listing-card.tsx and listing/[id]/page.tsx, both of which are server components. This avoids converting the entire card or detail page to a client component just for one interactive element. The button uses e.preventDefault() + e.stopPropagation() to prevent the parent <Link> from navigating when the heart is tapped on a card.
SaveButton auth redirect: Unauthenticated users who tap the heart are redirected to /auth?from=/listing/{id} — after login they land back on the listing. Authenticated users get an optimistic toggle (state flips immediately, rolls back on API failure).
SaveButton initial state: The heart always renders unfilled on page load because category pages are server components with no user context — there's no cheap way to know which listings the current user has saved at render time. The filled/unfilled state becomes accurate after the user interacts. A future optimization is to fetch /listings/saved server-side on authenticated requests and pass the saved IDs as props.
db/base.py — models must NOT be imported here: base.py only defines DeclarativeBase. Model imports belong in alembic/env.py (for autogenerate detection), not in base.py. Importing models in base.py creates a circular import: user.py imports Base from base.py, and if base.py imports User from user.py, Python partially initializes user.py before Base is defined — crashing with ImportError: cannot import name 'User' from partially initialized module.
SQLAlchemy 2.x Mapped style: User model uses Mapped[type] + mapped_column() annotations instead of the legacy Column() style. This is required for Pylance to correctly type-check attribute assignments (e.g. user.name = "Ahmed" without errors). New models (SavedListing, Notification, NotificationThread, NotificationMessage) follow the same Mapped style for consistency.
__table_args__ with a single constraint: When __table_args__ is a tuple containing constraints (e.g. UniqueConstraint), SQLAlchemy requires an empty dict {} as the last element of the tuple — e.g. (UniqueConstraint(...), {}). Without it, SQLAlchemy raises ArgumentError: __table_args__ value must be a tuple, dict, or None.
ENVIRONMENT setting: config.py exposes ENVIRONMENT: str = "development". Cookie secure flag is conditioned on this. Set ENVIRONMENT=production in Railway env vars.
JSONB for listing attributes: Category-specific fields (car mileage, apartment rooms, etc.) go in attrs JSONB column — no separate table per category. Flexible from day one.
URL-based filters: Category page filters stored in URL query params — shareable and bookmarkable. CategoryFilters component reads/writes via useSearchParams + router.push.
R2 image upload — presigned URL pattern: Frontend requests presigned PUT URLs from our API. The browser PUTs files directly to R2 — the API server never buffers image bytes. Same pattern for both listing images and profile photos.
AuthContext hydration strategy: On every app mount, hydrate() runs once. It checks localStorage for a stored access token, validates it via GET /auth/me, and falls back to a silent cookie refresh if expired. Failed refreshes during hydration call tryRefreshSilently() (not refreshToken()) — this returns null on failure without calling logout(), preventing spurious logouts on 404 pages or cold loads.
Server vs client API calls: Server components use API_URL env var (not exposed to browser). Client components use NEXT_PUBLIC_API_URL. Both point to https://railway.shamna.shop.
Next.js 15 async params: params in server components is a Promise. Always const { id } = await params before use.
CSS variables for design tokens: Custom colors (--color-brand, --color-border, --color-surface, --color-text-primary, --color-text-muted) defined in globals.css. Always use inline style={{ ... }} — never Tailwind utility classes like bg-brand which won't be generated for custom vars.
uv for Python deps: All packages managed through uv. Never pip install — always uv add.
Pydantic v2 settings: Use model_config = SettingsConfigDict(env_file=".env"). Do NOT use the old inner class Config: pattern.
Homepage category data — single source of truth: The CATEGORIES array in apps/web/app/page.tsx serves both the <Hero> sidebar and each <CategorySection> below it. Adding, removing, or reordering a category only requires editing that one array. CSS gradient strings in accentColor must have no spaces (e.g. linear-gradient(...) not linear - gradient(...)).
Hero banner image — full-panel background: HeroCategory accepts an optional bannerImage field (string, path under /public). When provided, the image fills the entire center panel as a background layer (using Next.js <Image fill /> with objectFit: "cover") at opacity: 0.35 so the gradient and text remain readable. The emoji watermark and inline image are not used when bannerImage is set. Recommended export settings: WebP, 880×320px, quality 80%, under 60KB. The emoji-only fallback renders when no bannerImage is provided.
Category mini-grid images: category-section.tsx uses listing.image_urls?.[0] (not listing.images?.[0]) to render listing thumbnails — the API returns image_urls. Using the wrong field silently falls through to the emoji fallback.
Navbar auth states: Logged-out shows two buttons (Post an Ad → /auth?from=/post, Login → /auth?from={pathname}). Logged-in replaces both with an icon row: Bell (/notifications), Heart (/saved), ClipboardList (/my-listings), and avatar circle with dropdown (profile, my listings, logout). The bell icon shows an unread badge driven by GET /notifications/unread-count.
Notification architecture — two data models: Flat notifications table for one-way system events and admin broadcasts. Separate notification_threads + notification_messages tables for two-way admin ↔ user conversations. Keeping them separate avoids hacking replies into a flat table. is_noreply on a thread prevents user replies while still using the thread UI (rare, but supported). The user_has_unread boolean on threads is the cheap flag used for the bell badge count — avoids a message join on every page load.
Admin access — is_admin flag: No separate admin login. Admins are regular users with is_admin = true on their users row. The get_current_admin FastAPI dependency checks this flag and raises 403 otherwise. Grant admin access via a direct SQL update in Supabase.
Ratings — open model (no buyer tracking): Any authenticated user can rate a seller on a sold listing, one rating per person per listing. We don't track who the buyer was (no checkout/offer flow exists yet), so locking rating to a specific buyer isn't possible. The constraint is enforced by a UNIQUE(listing_id, rater_id) index — double-submitting returns a clean 409. The ratee is always the listing owner (seller). Tighten this to buyer-only once a messaging/offer flow is added.
Ratings — denormalized stats on users: average_rating (Numeric 3,2) and rating_count (Integer) are stored directly on the users row and updated by _refresh_user_stats() after every new rating. This avoids an aggregate query across the ratings table every time a profile page loads. The tradeoff is that these values are eventually consistent by one write — acceptable for a marketplace context.
Alembic NOT NULL column gotcha: Adding a NOT NULL column to a table with existing rows requires a server_default in the migration. Alembic autogenerate does not add this automatically. Always inspect generated migration files and add server_default='false' (or appropriate default) to op.add_column() calls for boolean columns on existing tables.
| Area | Status |
|---|---|
| Monorepo structure | ✅ Done |
| FastAPI skeleton + Railway deploy | ✅ Done |
| Supabase PostgreSQL connected | ✅ Done |
| Alembic migrations (users, otp_codes, listings) | ✅ Done |
| OTP auth endpoints | ✅ Done |
| JWT access + refresh tokens | ✅ Done |
| Next.js app + Vercel deploy | ✅ Done |
| Arabic/RTL layout + font | ✅ Done |
| Navbar + footer | ✅ Done |
| Navbar auth-aware (icon row when logged in, two buttons when logged out) | ✅ Done |
| Homepage — real API data | ✅ Done |
| Homepage — hero 3-panel layout (category sidebar + animated banner + promo card) | ✅ Done |
| Homepage — per-category listing sections (feature card + 2×4 mini grid) | ✅ Done |
| Category page + filters + view toggle — real API | ✅ Done |
| Listing detail page — real API | ✅ Done |
| Post an ad form (multi-step wizard UI) | ✅ Done |
| Auth middleware (protected routes) | ✅ Done |
| Frontend auth flow (OTP UI) — tested end to end | ✅ Done |
| Listings API (create, list, get, status, phone reveal) | ✅ Done |
| Post form wired to API (submit) | ✅ Done |
| Image upload — listings (Cloudflare R2) | ✅ Done |
| AuthContext (user state, login, logout, hydration) | ✅ Done |
| Silent token refresh (tryRefreshSilently) | ✅ Done |
| GET /auth/me endpoint | ✅ Done |
| PUT /auth/me endpoint (name, email, bio) | ✅ Done |
| PUT /auth/me/profile-pic endpoint | ✅ Done |
| POST /uploads/presign-profile endpoint | ✅ Done |
| User model expanded (bio, standing, warning_reason) | ✅ Done |
| SQLAlchemy Mapped style migration | ✅ Done |
| User profile page (inline edit, photo upload, standing badge) | ✅ Done |
Custom domain — www.shamna.shop + railway.shamna.shop |
✅ Done |
| Auth persistence across page refresh (middleware + cookie fix) | ✅ Done — domain=".shamna.shop" on all cookies resolves cross-subdomain issue |
| Profile page fully functional end-to-end | ✅ Done — unblocked by custom domain |
| Cloudflare R2 CORS for custom domain | ✅ Done — allows PUT from www.shamna.shop |
| GET /listings/mine endpoint | ✅ Done |
| DELETE /listings/{id} endpoint | ✅ Done |
| My listings page (owner view, mark as sold, delete) | ✅ Done |
| Hero banner — full-panel background image (no emoji overlap) | ✅ Done |
| Category mini-grid — real listing images (not emoji fallback) | ✅ Done |
| Saved listings — user_saved_listings migration | ✅ Done |
| Saved listings — POST/DELETE /listings/{id}/save endpoints | ✅ Done |
| Saved listings — GET /listings/saved endpoint | ✅ Done |
| Saved listings — SaveButton component (icon + full variants) | ✅ Done |
| Saved listings — heart overlay on listing cards (category page) | ✅ Done |
| Saved listings — full save button on listing detail page | ✅ Done |
| Saved listings — /saved page with empty state | ✅ Done |
| Notifications — is_admin field on users + migration | ✅ Done |
| Notifications — notification.py model (3 tables + NotificationType enum) | ✅ Done |
| Notifications — notifications.py router (all user + admin endpoints) | ✅ Done |
| Notifications — /notifications page (two tabs: flat + threads) | ✅ Done |
| Notifications — thread detail view with reply box | ✅ Done |
| Notifications — unread bell badge on navbar | ✅ Done |
| Notifications — notification.ts types file | ✅ Done |
| Profile page — apiFetch Content-Type bug fixed (spread order) | ✅ Done — { headers, ...rest } destructure pattern prevents options spread clobbering merged headers |
| Ratings — ratings table migration (+ average_rating, rating_count on users) | ✅ Done |
| Ratings — Rating model (rating.py) | ✅ Done |
| Ratings — ratings.py router (POST /listings/{id}/rate, GET /users/{id}/ratings, GET /users/{id}/ratings/summary) | ✅ Done |
| Ratings — frontend UI (profile page star display, rate modal) | ⏳ Next session |
| Automated notifications (welcome, expiry warnings, price drops) | ⏳ Planned — needs Redis + BullMQ |
| Meilisearch integration | ⏳ Planned |
| Redis + BullMQ | ⏳ Planned |
| React Native mobile app | ⏳ Phase 2 |
| Image moderation pipeline | ⏳ Pre-launch |
| Wire real SMS provider (Twilio/Vonage) | ⏳ Pre-launch |
| Business/advertiser login | ⏳ Later phase |
- Monorepo + deployment pipeline
- Auth flow (OTP + JWT)
- Listings CRUD API
- Full frontend shell (homepage, category, detail, post form)
- Post form submission + image upload (R2)
- AuthContext + silent token refresh
- Auth-aware navbar (icon row when logged in, two buttons when logged out)
- User profile page (inline editing, photo upload, account standing)
- Homepage visual refactor (3-panel hero + per-category listing sections)
- Custom domain (
www.shamna.shop+railway.shamna.shop) — fully resolves middleware auth - My listings page (view, mark sold, delete)
- Saved listings (heart button on cards + detail page,
/savedpage) - Notifications (admin broadcast + two-way threads + system event types, bell badge,
/notificationspage) - Ratings backend (ratings table, rating.py model, ratings.py router — 3 endpoints)
- Ratings frontend (star display on profile, rate modal triggered from sold listings)
- Search (Meilisearch)
- Migrate Railway → Hetzner + Coolify
- Self-host Meilisearch + Redis
- Automated notifications (welcome message, expiry warnings, price drop alerts) via BullMQ
- Image moderation pipeline
- Mobile app (React Native + Expo)
- Remove OTP dev bypass, wire real SMS provider
- Business/advertiser accounts
- ML features: recommendations, price suggestions, fraud detection
- Analytics dashboard
Backend is fully wired. Frontend deliverables:
apps/web/types/rating.ts—Rating,RatingSummarytypes matching the API shapes- Star display on profile page — show average score + total count on
GET /users/{id}/ratings/summary; render filled/half/empty stars inline on the profile header card - Ratings list on profile — collapsible section or tab showing individual
Ratingobjects fromGET /users/{id}/ratings(rater name, score, role badge, recommend pill, timestamp) - Rate modal — triggered from
/my-listingswhen a listing is marked as sold; or from the listing detail page whenstatus === "sold"and the viewer is not the owner; posts toPOST /listings/{id}/rate apps/web/types/rating.tsshould include:export type Rating = { id: string listing_id: string rater_id: string ratee_id: string role: "buyer" | "seller" score: number // 1–5 recommended: boolean created_at: string rater_name: string | null } export type RatingSummary = { total: number average_score: number | null recommend_pct: number | null }
The notification infrastructure is in place. Automated triggers to wire up later:
WELCOME— send on user creation (hook into/auth/verify-otp)LISTING_EXPIRING_SOON— BullMQ scheduled job, 3 days beforeexpires_atLISTING_EXPIRED— BullMQ scheduled job, onexpires_atLISTING_PHONE_REVEALED— hook intoGET /listings/{id}/phoneSAVED_PRICE_DROP— hook into listing update endpointSAVED_LISTING_SOLD/SAVED_LISTING_REMOVED— hook into status/delete endpoints
- Meilisearch instance (self-hosted on Hetzner, or Meilisearch Cloud for dev)
- Index sync: new/updated/deleted listings pushed to Meilisearch
- Arabic full-text search settings (language tokenizer, stopwords, ranking rules)
GET /search?q=endpoint wired to Meilisearch- Search bar in the navbar/hero
- Search results page