# Internal API Spec (Laravel 10) — Derived from PRD

Scope: internal HTTP routes/controllers, jobs, services, and required configs per PRD. Includes DTOs and error shapes. No scope beyond PRD; ambiguities noted under OPEN QUESTIONS.

## Routes & Controllers

- `POST /api/telegram/webhook` → `TelegramWebhookController@handle`
  - Request: Telegram update payload (JSON) as per Telegraph.
  - Response: `200 {"ok":true}` (always 200 to Telegram unless signature invalid).
  - Errors: `401` invalid token/signature; logged, no retries from Telegram.

- AI (PRD §11 AI)
  - `POST /ai/respond` → `AIController@respond`
    - Request JSON:
      - `model` (string) — default from module config
      - `store` (bool)
      - `previous_response_id` (string|null)
      - `tool_resources.file_search.vector_store_ids` (string[])
      - `input` (array<Message>) — `{ role: system|user|assistant, content: string }`
      - `response_format` (optional) — `{ type: "json_object", schema?: object }`
    - Response JSON (envelope):
      - `json` (valuation object) or `text` (string), `response_id` (string), `token_in` (int), `token_out` (int), `latency_ms` (int)
      - Valuation JSON schema enforced when `response_format.type = json_object`:
        `{ min:int, max:int, anchor:int, confidence:"low"|"medium"|"high", reasons:[string; 2..4] }`
    - Errors:
      - `400` invalid schema/input
      - `504` provider timeout (>20s) — retried once internally
      - `502` provider error

- Ads/Admin (PRD §11 Ads)
  - `POST /admin/opportunities/push` → `Admin\OpportunitiesController@push`
    - Req: `{ ids: number[] | null, target_roles?: string[], target_cities?: string[], frequency?: string }`
    - Res: `{ pushed: number, skipped: number }`
    - Errors: `400` invalid IDs or segment; `409` already pushed.
  - `POST /admin/featured_ads/push` → `Admin\FeaturedAdsController@push`
    - Req: `{ id: number, segment: { roles?: string[], cities?: string[] } }`
    - Res: `{ pushed: number }`
  - `POST /admin/scrape_profiles/test` → `Admin\ScrapeProfilesController@test`
    - Req: `{ id: number }`
    - Res: `{ ok: boolean, sample_count: number, notes?: string }`

- Payments (PRD §11 Payments, §7.14)
  - `POST /payments/create` → `PaymentsController@create`
    - Req: `{ plan: string }`  // plan key; amount/duration loaded from DB (IRR)
    - Res: `{ payment_id: number, gateway: "zarinpal", redirect_url: string }`
    - Errors: `400` invalid plan/duration; `502` gateway init failure.
  - `GET /payments/verify?authority=...` → `PaymentsController@verify`
    - Res: `{ status: "ok"|"failed", payment_id: number, ref: string|null, subscription: { status: string }|null }`
    - Errors: `400` missing authority; `422` invalid; `502` gateway verify error.
  - `POST /payments/reconcile` → `PaymentsController@reconcile`
    - Res: `{ reconciled: number, failed: number }`
  - Plans
    - DB entity `plans(id, key, name, amount_irr, duration_days, is_active)`; `POST /payments/create` accepts `plan` key.

- Simulations
  - `POST /simulate/click` → `SimulateController@click`
    - Req: `{ id: number }` where id is `push_logs.id`
    - Res: `{ ok: true }`

## User Saved Ads (⭐)

- `POST /user/saved_ads` → `SavedAdsController@store`
  - Req: one of
    - Opportunity: `{ source: "opportunity", opportunity_id: number }`
    - External: `{ source: "external", external_source: "bama"|"divar", external_code: string, link: string }`
  - Res: `{ id: number, status: "saved" }`
  - Errors: `409` duplicate per uniqueness rules; `400` invalid payload.

- `DELETE /user/saved_ads/{id}` → `SavedAdsController@destroy`
  - Res: `{ id: number, status: "removed" }`
  - Errors: `404` not found or not owned by user.

- `GET /user/saved_ads` → `SavedAdsController@index`
  - Res: `{ items: [ { id, source, opportunity_id?, external_source?, external_code?, link, created_at } ] }`

## Support Tickets (v1)

- `POST /support/tickets` → `Support\TicketsController@store`
  - Req: `{ subject: string, body: string }`
  - Res: `{ id: number, status: "open" }`

- `GET /support/tickets` → `Support\TicketsController@index`
  - Res: `{ items: [ { id, subject, status, created_at, admin_replied_at? } ] }`

- `POST /admin/tickets/{id}/reply` → `Admin\TicketsController@reply`
  - Req: `{ body: string }`
  - Res: `{ id: number, status: "open"|"closed", admin_replied_at: string }`

## Domain Services (App\Services)

- `AI\LlmProvider` (interface) and `AI\OpenAiProvider` (Responses + file_search + memory) — PRD §2, Product Design §4.2, §9.
- `Ads\MajidApiClient` (bama/divar endpoints) — Product Design §5.1.
- `Ads\Normalizer` (raw → normalized fields) — PRD §8 فرصت‌های امروز.
- `Ads\Deduper` (dedup_key) — PRD §8/§12.
- `Ads\OpportunityScorer` (−15% vs city median → fallback −10% → rule-based) — PRD §12; ARCHITECTURE.md.
- `Ads\CityMedianService` (rolling median per brand/model/city_id via normalized cities) — PRD §12; Final Decision #6.
- `Messaging\TelegramBot` (Telegraph facade wrappers).
- `Payments\PaymentService` (wrap shetabit/payment for create/verify) — PRD §2, §7.14.
- `Content\VectorStoreService` (attach/detach, status) — Product Design §7.
- Feature modules (prompt + render): `ValuationService`, `NegotiationService`, `ExpertQaService`, `LegalService`, `InspectionService`, `InstagramContentService`, `CityReportService` — PRD §7.x.

## Notes on Onboarding & City Normalization

- Onboarding city search uses `cities` and `city_aliases`; users store `city_id`. All ads/opportunity scoring and reporting use normalized `city_id`.

## Jobs (App\Jobs)

- `BamaSearchJob` (Smart Multi-Page Search + Fallback) — PRD §5, ARCHITECTURE.md.
  - **Smart Search Phase**: Multi-page search (up to 15 pages) with city filtering
  - **Fallback Search Phase**: Activates if < 3 city-specific ads found
  - **City Detection**: Extract city from `detail.location` field, match against cities table
  - **Rate Limiting**: 0.5s delay between requests to avoid API limits
- `DivarScrapeJob` — PRD §5, city-specific scraping via Divar URLs.
- `ScoreOpportunitiesJob` (normalize/dedup/score) — PRD §8/§12.
  - Prioritizes `ads_raw.city_id` over normalized city detection
  - Adds session info for Telegram filtering
- `PushOpportunitiesJob`, `PushFeaturedAdsJob` — PRD §7.2.
- `GenerateCityReportJob` — PRD §7.10.
- `PaymentsReconcileJob` — PRD §7.14.

## Phase 2C/D Addendum (Security, Payments, Browse, KPI)

- Webhook security: `POST /api/telegram/webhook` requires secret header matching `config('telegraph.bots.default.secret')`.
  - Accepted headers: `X-Telegram-Bot-Api-Secret-Token` (preferred), fallback `X-Telegram-Secret-Token` or `X-Telegraph-Secret`.

- Payments
  - `POST /payments/create` creates pending payment in DB and returns sandbox `redirect_url`; emits `UpgradeClick`.
  - `GET /payments/verify?authority=...` is idempotent by `authority` and activates subscription once; emits `UpgradeSuccess {plan, amount, authority}`. Verifies via shetabit (sandbox) or `Status=OK`.
  - `POST /payments/reconcile` marks stale pendings (older than 24h) as failed.

- Saved Ads list guard fixed: `GET /user/saved_ads` uses middleware `feature.guard:view_deals`.

- Internal QA browse (admin only): `GET /opportunities?city_id=...` → `Admin\\OpportunitiesController@index` (middleware: `role:admin`)
  - Response: `{ items: [...], pagination: { page, per_page, total } }` (paginated)

## Ads Ingestion (Bama/Divar) — Pilot Fixtures
- `BamaPullLatestJob` inserts each item in `storage/app/fixtures/bama_latest.json` into `ads_raw`.
- `DivarScrapeJob` GETs the provided URL or reads `storage/app/fixtures/divar_latest.json`, inserts payload into `ads_raw`.
- `ScoreOpportunitiesJob`: Normalize→Dedup→CityMedian (cached by TTL/minN)→Score (−15→−10→rules)→Persist with reasons_json (≤2).

## Config Files

- `config/ai.php` — default model per module, timeouts, retries, rate-limit (ref PRD §15).
- `config/ads.php` — thresholds (−15%, fallback −10%), TTL 72h, min N for median (PRD §12).
  - **Bama Smart Search**: `bama.smart_search` configuration
    - `min_results`: 5 (minimum city-specific ads before stopping)
    - `max_pages`: 15 (maximum pages in smart phase)
    - `delay_between_requests`: 500000 (microseconds, 0.5s)
    - `fallback_enabled`: true (enable fallback search)
    - `fallback_max_pages`: 5 (maximum pages in fallback phase)
  - **City Detection**: `bama.city_detection` configuration
    - `enabled`: true (enable smart city detection)
    - `fallback_to_constructor`: true (use constructor city_id if detection fails)
- `config/payments.php` — gateway keys, callback URL, plan durations/prices (PRD §7.14).
- `config/feature_flags.php` — toggles per module (PRD §15 Feature Flags).
- `config/quotas.php` — per-module quotas (PRD §15).
- `config/telegraph.php` — bot tokens, webhook secret (PRD §2 Telegram).

## Growth Plumbing (Phase 2A)

- Entitlements & Quotas
  - Tables: `plan_entitlements(plan_id,key,int_value,json_value)`, `user_quotas(user_id,key,used,period_start,period_end)`
  - Services: `EntitlementResolver` merges plan + addon entitlements; `QuotaService` checkAndConsume/getRemaining
  - Guards: Middleware `feature.guard:valuation|instant_push|deal_view` applied to key endpoints

- Add-ons
  - Tables: `addons(key,name,entitlements_json,is_active)`, `user_addons(user_id,addon_id,status,starts_at,ends_at)`
  - Filament: Addons, UserAddons resources

- Experiments & Push
  - Tables: `experiments`, `experiment_assignments`, `push_campaigns`, `push_logs`
  - Service: `ExperimentService` assign/get
  - Job: `PushSchedulerJob` (stub send; log state)

- Trial & Wallet
  - Tables: `wallet_ledger`, `trials`
  - Job: `GrowthDailyJob` (trial with penalty; challenge reward)

- Referral & Cancellation
  - Tables: `referral_links`, `referral_events`, `cancellations`


## DTOs (Requests/Responses)

### AI Respond (POST /ai/respond)
- Request example:
```json
{
  "model": "gpt-4o-mini",
  "store": true,
  "previous_response_id": "resp_123",
  "tool_resources": {"file_search": {"vector_store_ids": ["VS_MAIN"]}},
  "input": [
    {"role":"system","content":"[SYSTEM PROMPT: valuation-fa]"},
    {"role":"user","content":"206 تیپ 2، 1399، 85هزار، تهران؛ بازه و 3 دلیل بده."}
  ],
  "response_format": {"type":"json_object"}
}
```
- Response example (valuation):
```json
{
  "json": {"min": 420, "max": 470, "anchor": 445, "confidence": "medium", "reasons": ["کم‌کار", "سال ۱۳۹۹", "سالم بدنه"]},
  "response_id": "resp_456",
  "token_in": 850,
  "token_out": 160,
  "latency_ms": 1200
}
```
- Policy: متن خروجی که به کاربر نمایش داده می‌شود باید صرفاً اطلاعات قیمت/دلایل باشد؛ هرگونه CTA تماس یا دعوت به خرید ممنوع است.
- Errors: `400 {"error":"invalid_input"}`, `504 {"error":"ai_timeout"}`, `502 {"error":"ai_provider_error"}`

### Payments
- Create (POST /payments/create)
  - Request:
  ```json
  {"plan":"basic"}
  ```
  - Response:
  ```json
  {"payment_id":123,"gateway":"zarinpal","redirect_url":"https://www.zarinpal.com/pg/..."}
  ```
- Verify (GET /payments/verify?authority=AU-...)
  - Response (ok):
  ```json
  {"status":"ok","payment_id":123,"ref":"ZP-XYZ","subscription":{"status":"active"}}
  ```
  - Response (failed):
  ```json
  {"status":"failed","payment_id":123,"ref":null,"subscription":null}
  ```
- Reconcile (POST /payments/reconcile)
  - Response: `{ "reconciled": 5, "failed": 1 }`

### Admin Push
- Opportunities push (POST /admin/opportunities/push)
  - Request: `{ "ids": [10,11], "target_roles": ["dealer"], "target_cities": ["tehran"], "frequency": "normal" }`
  - Response: `{ "pushed": 2, "skipped": 0 }`
- Featured ad push (POST /admin/featured_ads/push)
  - Request: `{ "id": 5, "segment": {"roles": ["novice"], "cities": ["mashhad"]} }`
  - Response: `{ "pushed": 1 }`

### Telegram Webhook
- Request: Telegram update payload (Message/CallbackQuery).
- Response: `{ "ok": true }`.
- **Callback Data Schema**: `HJI:<action>:<payload>` (≤ 64 bytes)
  - **Onboarding**: 
    - `HJI:ONB:ROLE|dealer|showroom|novice`
    - `HJI:ONB:CITY|<city_id>`
    - `HJI:ONB:BUDGET|low|200-400`
    - `HJI:ONB:NOTIF|normal`
  - **Main Menu**: 
    - `HJI:MENU:ROOT` (main menu)
    - `HJI:MENU:OPPORTUNITIES` (opportunities today)
    - `HJI:MENU:VALUATION` (price estimation)
    - `HJI:MENU:AFFORDABLE_ADS` (affordable ads main)
  - **Affordable Ads Flow**:
    - `HJI:MENU:AFFORDABLE_ADS_VIEW` (view current ads)
    - `HJI:MENU:AFFORDABLE_ADS_SEARCH` (search new ads)
    - `HJI:MENU:AFFORDABLE_ADS_CAR|<brand>` (car selection: 206, ساینا, کوییک, تیبا, تیبا 2, 207, پراید)
    - `HJI:MENU:AFFORDABLE_ADS_MENU` (back to affordable ads menu)
  - **Ads Actions**: 
    - `HJI:ADS:SAVE|src=opportunity|<id>`
    - `HJI:ADS:NEXT|p=<page>`
  - **Payments**: 
    - `HJI:PAY:PLAN|<plan_key>`
- **Gates**:
   - Phone gate: After onboarding, send ReplyKeyboard with `request_contact=true`; accept only self-contact.
   - Channel gate: After onboarding/phone (if enabled), present join links and a [✅ عضو شدم] button; on tap, verify membership for `chat_id`s from Settings using `getChatMember`.

## External Integrations & Call Shapes

- OpenAI Responses + file_search (PRD §2; Product Design §4.2, §9)
  - Endpoint: `POST /v1/responses` (provider API)
  - Core fields we send: `model`, `store:true`, `previous_response_id?`, `input[system,user,…]`, `tool_resources.file_search.vector_store_ids`, `response_format?`.
  - Behavior: No citations shown to user; threads per module using `previous_response_id`.

- majidapi (Product Design §5.1)
  - Bama latest: `GET https://api.majidapi.ir/bama?action=latest&page=1`
  - Bama search: `GET https://api.majidapi.ir/bama?action=search&s=<q>&page=1`
    - **Important**: No city filtering parameter available
    - **Location Field**: City info in `detail.location` (e.g., "تهران / کلاهدوز")
    - **Smart Search**: We implement multi-page search with city filtering
  - Bama details: `GET https://api.majidapi.ir/bama?action=details&code=<CODE>`
  - Divar scraper: `GET https://api.majidapi.ir/tools/scraper?url=<encoded_url>`
    - **City Support**: Divar URLs support city filtering (`/s/tehran/car`)
  - Minimal fields consumed (normalized): `{ code, brand, model, trim, year, km, city_id, price, body_status, link, ts_posted }`.

- Zarinpal via shetabit/payment (PRD §2; §7.14)
  - Create: library call to init transaction → redirect URL provided to client (`/payments/create`).
  - Verify: callback param `authority` handled at `/payments/verify`; idempotent verify; reconcile job for misses.
  - Plans: amounts in IRR and durations sourced from DB `plans` table.

- Telegram (Telegraph)
  - Webhook target `/telegram/webhook`; SDK validates token/secret; update payloads mapped to handlers.
  - SDK: defstudio/telegraph (final choice per ARCHITECTURE.md and PRD decisions).

## Phase 2C Addendum (Security, Payments, Browse, Guards)

- Telegram Webhook
- Path: `POST /api/telegram/webhook`
  - Security: requires header `X-Telegram-Bot-Api-Secret-Token` = `config('telegraph.bots.default.secret')`.
  - Note: ensure `routes/telegraph.php` is registered, or webhook will 404.

- Payments
  - Create: returns `{ payment_id, gateway, redirect_url, authority }` (authority for idempotent verify)
  - Verify: idempotent by `authority`; activates subscription; emits `UpgradeSuccess`.

- Saved Ads
  - `GET /user/saved_ads` guarded by `feature.guard:view_deals` (quota consumption window=day).

- Admin Push and Internal Browse
  - `POST /admin/opportunities/push`, `POST /admin/featured_ads/push` require `role:admin` and `feature.guard:instant_push`.
  - `GET /opportunities?city_id=...` → `Admin\OpportunitiesController@index` for QA; requires `role:admin`.

- Simulations
  - `POST /simulate/click` allowed only in `APP_ENV=local` (SimulateProtect).

## Implementation Status (2025-09-17)

**IMPLEMENTED ROUTES:**
- ✅ `POST /api/telegram/webhook` - TelegramWebhookController with security validation
- ✅ `POST /api/ai/respond` - AIController with OpenAI Responses API integration
- ✅ `POST /api/payments/create` - PaymentsController with PaymentService
- ✅ `GET /api/payments/verify` - Idempotent verification by authority
- ✅ `POST /api/payments/reconcile` - Reconcile stale payments
- ✅ `GET /api/user/saved_ads` - SavedAdsController with FeatureGuard
- ✅ `POST /api/user/saved_ads` - Store with duplicate prevention
- ✅ `DELETE /api/user/saved_ads/{id}` - Remove with ownership check
- ✅ `POST /api/support/tickets` - Support ticket creation
- ✅ `GET /api/support/tickets` - List user tickets
- ✅ `POST /api/admin/tickets/{id}/reply` - Admin ticket reply
- ✅ `POST /api/admin/opportunities/push` - Admin push opportunities
- ✅ `POST /api/admin/featured_ads/push` - Admin push featured ads
- ✅ `GET /api/admin/opportunities` - Admin browse opportunities
- ✅ `POST /api/simulate/click` - Simulation endpoint (local only)

**MIDDLEWARE REGISTERED:**
- ✅ `feature.guard` - Quota checking with proper key mapping
- ✅ `rate_limit_per_user` - Rate limiting per user
- ✅ `simulate.protect` - Environment protection
- ✅ `role` - Spatie role middleware
- ✅ `permission` - Spatie permission middleware

**SCHEDULED JOBS:**
- ✅ `PushSchedulerJob` - 9 AM, 11 AM, 2 PM daily
- ✅ `ScoreOpportunitiesJob` - Every 30 minutes
- ✅ `GrowthDailyJob` - Daily at 2 AM
- ✅ `PaymentsReconcileJob` - Hourly
- ✅ `GenerateCityReportJob` - Weekly Sundays at 3 AM

## Notes

- Admin Test Prompt in panel reuses `/ai/respond`; no extra HTTP route needed.
- All routes use proper middleware and error handling conventions.
- OpenAI provider implements full Responses API with file_search and memory.
- Payment verification is idempotent by authority token.

## Error Handling Conventions

- Standard JSON error: `{ "error": "code", "message": "human readable" }`.
- Idempotency for payments verify; safe to re-call with same `authority`.
- Jobs use retries with backoff; terminal failure emits log + event (PRD §15).

## OPEN QUESTIONS

- None at this time.
