Appearance
Frontend Developer Reference
Everything a Nuxt frontend developer needs to know about the Partizap backend API.
Base URL
All API requests go through the /api prefix. Nginx strips it before passing to PHP.
| Environment | Base URL |
|---|---|
| Production | https://partizap.ru/api |
| Development | https://dev.partizap.ru/api |
| Local dev | http://localhost:8000 (no /api prefix needed) |
In nuxt.config.ts:
ts
runtimeConfig: {
public: {
apiBase: '/api', // same-origin, nginx handles proxy
},
},Response Format
Success
json
{
"data": { ... },
"meta": { "has_more": true, "next_cursor": "..." }
}meta only present on paginated list endpoints.
Error
json
{
"error": {
"code": "validation_error",
"message": "Human-readable message",
"details": { "email": ["Email is required"] }
}
}details only present on validation errors (422).
Error Codes
| HTTP | Code | When |
|---|---|---|
| 401 | authentication_error | Not logged in or invalid credentials |
| 403 | authorization_error | Logged in but not allowed |
| 404 | not_found_error | Resource doesn't exist |
| 409 | conflict_error | Duplicate (e.g. email already registered) |
| 422 | validation_error | Invalid input, details has per-field errors |
| 429 | rate_limit_error | Too many requests, check Retry-After header |
Authentication
Session-based with HTTP-only cookies (not JWT). The browser handles cookies automatically.
CSRF Protection
Every mutating request (POST/PUT/PATCH/DELETE) must include CSRF token:
- Read
CSRF_TOKENcookie (readable, not HTTP-only) - Send as
X-CSRF-TOKENheader
ts
// composables/useApi.ts
const csrfToken = useCookie('CSRF_TOKEN');
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password },
credentials: 'include',
headers: {
'X-CSRF-TOKEN': csrfToken.value ?? '',
},
});SSR Cookie Forwarding
For SSR requests (server-side), forward browser cookies to the API:
ts
// In server routes or SSR composables
const headers = useRequestHeaders(['cookie']);
const data = await $fetch('/api/auth/me', {
headers,
credentials: 'include',
});Session Cookie
- Name:
PARTIZAP_SESSION - HTTP-only (JS cannot read it)
- Secure (HTTPS only in production)
- SameSite: Lax
- Managed automatically by the browser
Rate Limiting
Global: 100 requests/minute per IP.
Response headers on every request:
X-RateLimit-Limit: 100X-RateLimit-Remaining: NX-RateLimit-Reset: <unix timestamp>
Auth-specific:
| Endpoint | Limit | Window | Per |
|---|---|---|---|
POST /auth/login | 5 | 15 min | IP |
POST /auth/register | 3 | 1 hour | IP |
POST /auth/forgot-password | 3 | 1 hour | IP |
POST /auth/validate-reset-token | 5 | 15 min | IP |
POST /auth/verify-email | 5 | 15 min | user |
On 429: Retry-After header contains seconds to wait.
Cursor Pagination
All list endpoints use cursor-based pagination (no offset/page numbers).
Query params:
limit(default: 20, max: 100)cursor(opaque string, pass from previous response)
Usage:
ts
// First page
const { data, meta } = await $fetch('/api/store/products?limit=20');
// Next page
if (meta.has_more) {
const page2 = await $fetch(`/api/store/products?limit=20&cursor=${meta.next_cursor}`);
}Auth Endpoints
POST /api/auth/register
json
// Request
{ "email": "user@example.com", "password": "SecureP@ss1", "display_name": "John Doe" }
// Response 201 — session cookie set automatically
{ "data": { "id": 1, "email": "...", "display_name": "...", "email_verified": false, ... } }Side effects: sends 6-digit email verification code (expires in 15 min).
POST /api/auth/login
json
// Request
{ "email": "user@example.com", "password": "SecureP@ss1", "remember": true }
// Response 200
{ "data": { "id": 1, "email": "...", "display_name": "...", ... } }remember (optional, boolean): if true, session lifetime extends to 30 days (default: session-only cookie).
Errors:
- 401
"Invalid email or password"— wrong credentials (intentionally vague) - 401
"Too many failed attempts from this address. Try again later."— 10+ failed attempts from this IP for this email, 30-min lockout - 401
"Account is deactivated"—is_active = false
Brute-force protection (per IP+email pair, stored in Redis):
- Progressive delays: 2s after 4 attempts, 5s after 6, 10s after 8
- Lockout after 10 failed attempts — blocks that IP from trying that email for 30 min
- Other IPs are not affected — an attacker cannot lock out the real user
- Successful login resets the counter for that IP+email pair
- Alert email is sent to the user after 15 failed attempts across all IPs
GET /api/auth/me
Returns current user if session is valid, 401 otherwise. Response includes phones array and business_profile (if the user has one). Same shape as GET /vendor/me.
json
// Response 200
{
"data": {
"id": 1,
"email": "user@example.com",
"display_name": "John Doe",
"account_type": "business",
"email_verified": true,
"phones": [ ... ],
"business_profile": {
"id": 1,
"company_name": "BMW Parts Store",
"inn": "7712345678",
"address": "Невский проспект, 40",
"website": "https://example.com",
"working_hours": "10:00-18:00",
"is_verified": false,
"verified_at": null,
"created_at": "2026-02-07T12:30:45+00:00"
}
}
}business_profile is only present when the user has account_type: "business" and has created a profile. For personal accounts it is omitted.
POST /api/auth/verify-email (authenticated)
json
// Request
{ "code": "123456" }
// Response 200
{ "data": { "verified": true } }POST /api/auth/resend-verification (authenticated)
json
// Response 200
{ "data": { "sent": true } }Errors: 422 if email already verified or last code sent <60s ago.
POST /api/auth/forgot-password
json
// Request
{ "email": "user@example.com" }
// Response 200 — always succeeds (prevents email enumeration)
{ "data": { "sent": true } }POST /api/auth/validate-reset-token
Validates a password reset token without consuming it. Use this when the user opens the reset link — check the token before showing the new password form.
json
// Request
{ "token": "<from email link>" }
// Response 200 — token is valid
{ "data": { "valid": true } }
// Response 422 — token not found, expired, or already used
{ "error": { "code": "validation_error", "message": "Validation failed",
"details": { "token": ["Invalid or expired reset token"] } } }Rate-limited: 5 requests / 15 min per IP.
POST /api/auth/reset-password
json
// Request
{ "token": "<from email link>", "password": "NewSecureP@ss1" }
// Response 200
{ "data": { "reset": true } }Side effect: invalidates ALL user sessions.
POST /api/auth/logout (authenticated)
Destroys current session only.
POST /api/auth/logout-all (authenticated)
Destroys all sessions for the user.
Password Validation Rules
The backend enforces these rules — mirror them in the frontend for UX:
- 8-128 characters
- At least 1 uppercase letter (A-Z)
- At least 1 lowercase letter (a-z)
- At least 1 digit (0-9)
- At least 1 special character (!@#$%^&* etc.)
- No 3+ consecutive identical characters (e.g.
aaa) - No 3+ sequential characters (e.g.
abc,321) - Not a common password
- Doesn't contain the email local part
User Object
Private (returned to the user themselves via GET /auth/me, GET /vendor/me, PUT /admin/users/{id}):
json
{
"id": 1,
"email": "user@example.com",
"display_name": "John Doe",
"account_type": "business",
"email_verified": false,
"avatar_url": "https://s3.selectel.ru/...",
"city_id": 1,
"district_id": 5,
"metro_station_id": 3,
"rating": "0.00",
"reviews_count": 0,
"products_count": 0,
"is_active": true,
"is_admin": false,
"created_at": "2026-02-07T12:30:45+00:00",
"phones": [
{
"id": 1,
"user_id": 1,
"phone": "+79991234567",
"label": "Основной",
"is_primary": true,
"sort_order": 0,
"phone_verified": true,
"created_at": "2026-02-07T12:30:45+00:00"
}
],
"business_profile": {
"id": 1,
"company_name": "BMW Parts Store",
"inn": "7712345678",
"address": "Невский проспект, 40",
"website": "https://example.com",
"working_hours": "10:00-18:00",
"is_verified": false,
"verified_at": null,
"created_at": "2026-02-07T12:30:45+00:00"
}
}business_profile is conditionally included only when the user has created one (typically account_type: "business"). Omitted otherwise.
Public (returned when viewing another user/seller):
json
{
"id": 5,
"display_name": "BMW Parts Store",
"avatar_url": "https://s3.selectel.ru/...",
"city_id": 1,
"rating": "4.85",
"reviews_count": 127,
"products_count": 342,
"created_at": "2025-06-15T08:00:00+00:00"
}Store Endpoints (Public, No Auth)
Products
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/store/products | List/search active products (filtered, sorted, paginated) |
| GET | /api/store/products/suggest?q=... | Lightweight search suggestions for dropdown (id, title, price, thumbnail) |
| GET | /api/store/products/{id} | Product detail with images, compatibility, seller |
| GET | /api/store/products/{id}/similar | Similar products by category/compatibility |
GET /api/store/products query params:
q— full-text search query (optional). When present, default sort isrelevancesort:date_desc(default withoutq),price_asc,price_desc,relevance(only withq)make_id,model_id,generation_id,modification_id— YMMM filtercategory_id— category filter (includes descendants via many-to-many)city_ids— comma-separated city IDs (mutually exclusive withregion_id— sending both returns 422)region_id,district_id,metro_station_id— geo filtersteering:left,right,both,universal(validated against enum, invalid value returns 422)is_available—true/1orfalse/0condition— condition category ID (e.g. 96 = Б/У)price_min,price_maxlimit,cursor
Validation rules:
sort=relevancewithoutq→ 422"relevance sort requires q parameter"city_ids+region_idboth present → 422"city_ids and region_id are mutually exclusive"- Invalid
steeringvalue → 422"Invalid steering. Allowed: left, right, both, universal"
Response: all items include images array (may be empty)
Search Suggestions
GET /api/store/products/suggest
Lightweight FTS endpoint for search dropdown hints. Single SQL query, no Doctrine hydration, no search logging. Designed for keystroke-debounced autocomplete (~1-3ms server-side).
Query params:
q— search query (returns empty array if missing or blank)limit— max results (default 6, max 10)
Response 200:
json
{
"data": [
{ "id": 204, "title": "Пружины Lada Vesta", "price": 3500, "thumbnail": "https://cdn.partizap.ru/products/204/thumb.webp" },
{ "id": 55, "title": "Пружина задняя BMW E46", "price": 1800, "thumbnail": null }
]
}thumbnail is the primary image's thumbnail_webp URL, or null if no images.
Product Detail Response
Returned by GET /api/store/products/{id}, GET /api/vendor/products/{id}, GET /api/admin/products/{id}.
json
{
"data": {
"id": 1,
"seller_id": 5,
"title": "Бампер передний BMW 3 Series",
"description": "Оригинальный бампер BMW",
"price": 2500.00,
"steering": "left",
"oem_number": "51117140396",
"manufacturer": "BMW",
"city_id": 1,
"region_id": 47,
"primary_category_id": 50,
"district_id": 5,
"metro_station_id": 3,
"address": "Невский проспект, 40",
"phone_id": 1,
"is_available": true,
"status": "active",
"views_count": 42,
"favorites_count": 3,
"published_at": "2026-02-01T10:30:00+00:00",
"created_at": "2026-02-01T10:00:00+00:00",
"images": [
{
"id": 1,
"thumbnail_webp": "https://s3...",
"thumbnail_jpeg": "https://s3...",
"medium_webp": "https://s3...",
"medium_jpeg": "https://s3...",
"large_webp": "https://s3...",
"large_jpeg": "https://s3...",
"status": "ready",
"is_primary": true,
"sort_order": 0
}
],
"categories": [
{ "id": 1, "product_id": 1, "category_id": 50, "is_primary": true },
{ "id": 2, "product_id": 1, "category_id": 96, "is_primary": false },
{ "id": 3, "product_id": 1, "category_id": 99, "is_primary": false }
],
"compatibility": [
{ "id": 1, "product_id": 1, "make_id": 2, "model_id": 10, "generation_id": 45, "modification_id": 501, "note": null }
],
"oem_numbers": [
{ "id": 1, "product_id": 1, "oem_number": "51117140396", "oem_display": "51117140396" }
],
"seller": {
"id": 5,
"display_name": "BMW Parts Store",
"avatar_url": "...",
"rating": "4.85",
"reviews_count": 127,
"products_count": 342
}
}
}Similar Products
GET /api/store/products/{id}/similar
Returns similar active products based on shared category or car compatibility. No pagination — returns a flat list.
Response 200:
json
{
"data": [
{
"id": 3,
"title": "Тормозной диск передний",
"price": 3200,
"status": "active",
"images": [
{ "id": 2, "thumbnail_webp": "https://s3...", "is_primary": true, "sort_order": 0 }
]
}
]
}Errors:
- 404 — product not found or not active
categories array (in detail response): M:N from product_categories table. Each entry has category_id referencing a category (which has category_type: part, condition, or attribute) and is_primary flag. The primary_category_id on the product itself is a denormalized shortcut to the primary part category.
Sellers
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/store/sellers/{id} | Seller profile + business profile |
| GET | /api/store/sellers/{id}/products | Seller's active products |
Cars (YMMM Cascade)
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /api/store/cars/makes | All makes (?popular=true for popular only) | 1h |
| GET | /api/store/cars/makes/{id}/models | Models for a make | 1h |
| GET | /api/store/cars/models/{id}/generations | Generations for a model | 1h |
| GET | /api/store/cars/generations/{id}/modifications | Modifications for a generation | 1h |
Generation fields: id, model_id, name, code, year_from, year_to, steering.
Modification fields: id, generation_id, name, engine_volume, fuel_type, power, transmission, drivetrain.
Geography (4-Level Cascade)
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /api/store/geo/regions | All regions | 1h |
| GET | /api/store/geo/regions/{id}/cities | Cities in region | 1h |
| GET | /api/store/geo/cities/{id}/districts | Districts in city | 1h |
| GET | /api/store/geo/cities/{id}/metro | Metro stations in city | 1h |
Metro fields include line, line_color, lat, lon.
Categories
| Method | Endpoint | Description | Cache |
|---|---|---|---|
| GET | /api/store/categories | All active categories | 1h |
Category types: part, condition, attribute.
Part categories use a multi-level hierarchy (up to 4 levels) based on Avito structure:
Level 1: Запчасти, Аксессуары, Масла и автохимия, Шины/диски/колёса, ...
Level 2: Запчасти → Для автомобилей, Для мототехники, ...
Level 3: Для автомобилей → Двигатель, Кузов, Подвеска, Тормозная система, ...
Level 4: Двигатель → Блок цилиндров, Головка блока, Коленвал, ...Each category has parent_id pointing to its parent (NULL for top-level). Use parent_id to build the tree on the frontend.
Vendor Endpoints (Authenticated Sellers)
All require session cookie. Email must be verified for product creation.
Profile
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/me | Seller profile + business profile |
| PUT | /api/vendor/me | Update profile (display_name, account_type, city_id, district_id, metro_station_id) |
| POST | /api/vendor/me/avatar | Upload avatar (multipart, max 5MB, JPEG/PNG/WebP) |
PUT /api/vendor/me — Update profile:
json
{
"display_name": "Updated Name",
"account_type": "business",
"city_id": 1,
"district_id": 5,
"metro_station_id": 3
}All fields optional. account_type accepts personal or business. Phone numbers are managed separately via /vendor/me/phones.
Phone Management
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/me/phones | List user's phones (sorted by sort_order) |
| POST | /api/vendor/me/phones | Add phone (max 5 per user) |
| PUT | /api/vendor/me/phones/{id} | Update phone |
| DELETE | /api/vendor/me/phones/{id} | Delete phone |
POST /api/vendor/me/phones — Add phone:
json
// Request
{ "phone": "+79991234567", "label": "Рабочий", "is_primary": false }
// Response 201
{ "data": { "id": 2, "user_id": 1, "phone": "+79991234567", "label": "Рабочий", "is_primary": false, "sort_order": 1, "phone_verified": false, "created_at": "..." } }Required: phone (format: +7XXXXXXXXXX). Optional: label (max 50 chars, default "Основной"), is_primary (boolean).
Business rules:
- Max 5 phones per user
- First phone is always set as primary
- Setting
is_primary: trueclears primary flag on other phones sort_orderis auto-assigned (max + 1)
PUT /api/vendor/me/phones/{id} — Update phone:
json
{ "label": "Рабочий", "is_primary": true, "sort_order": 0 }All fields optional. Same validation as create for phone and label.
DELETE /api/vendor/me/phones/{id}:
Returns 204. If deleted phone was primary, the first remaining phone is promoted to primary. Products linked to the deleted phone have their phone_id set to NULL automatically (FK ON DELETE SET NULL).
Products
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/products | Seller's products (all statuses, ?status= filter) |
| POST | /api/vendor/products | Create product (draft) |
| GET | /api/vendor/products/{id} | Product detail (with categories, compatibility, images, oem_numbers) |
| PUT | /api/vendor/products/{id} | Update (draft/rejected only) |
| POST | /api/vendor/products/{id}/publish | Submit for approval (draft -> pending) |
| DELETE | /api/vendor/products/{id} | Delete (draft only) |
POST /api/vendor/products — Create product:
json
{
"title": "Амортизатор передний BMW E39",
"price": 4500.00,
"description": "Оригинальный амортизатор в хорошем состоянии",
"oem_numbers": ["31311096855", "31316785589"],
"manufacturer": "Sachs",
"steering": "left",
"region_id": 47,
"city_id": 1,
"district_id": 3,
"metro_station_id": 12,
"address": "ул. Ленина, 15",
"phone_id": 1,
"primary_category_id": 22,
"category_ids": [22, 96, 99],
"compatibility": [
{ "make_id": 1, "model_id": 10, "generation_id": 25, "modification_id": 42 }
]
}Required: title, price. All other fields optional.
phone_id — ID of a phone from /vendor/me/phones to display as contact for this product. Optional; if omitted, no phone is linked. On update, send "phone_id": null to explicitly clear.
category_ids — array of category IDs from all types:
partcategories (e.g. 22 = Амортизаторы) — at least one, set same ID asprimary_category_idconditioncategory (e.g. 96 = Б/У) — exactly oneattributecategory (e.g. 99 = Оригинал) — zero or one
compatibility — array of car fitment entries. Each requires make_id; model_id, generation_id, modification_id are optional.
oem_numbers — array of OEM number strings (e.g. ["04465-33471", "04465-06090"]). Populates the M:N oem_numbers/product_oem tables. The first value is synced to the denormalized oem_number field on the product. If oem_numbers is not provided, a single oem_number string field is still accepted as fallback.
PUT /api/vendor/products/{id} accepts the same fields (all optional — only sent fields are updated). category_ids, compatibility, and oem_numbers use replace-all strategy.
Product status flow:
draft -> (publish) -> pending -> (admin) -> active
-> rejected
active -> sold / archivedProduct Images
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/vendor/products/{id}/images | Upload (multipart, max 10MB) |
| PUT | /api/vendor/products/{id}/images/order | Reorder: { "image_ids": [5,3,1] } |
| DELETE | /api/vendor/products/{id}/images/{imgId} | Delete image |
Images are processed asynchronously via a Redis queue:
- Upload returns 201 with
status: 'processing'— variant URLs (thumbnail_webp,medium_webp, etc.) arenull - A background worker generates image variants and sets
status: 'ready'with populated URLs - On failure:
status: 'failed',error_messageis populated
Frontend should check status before rendering image URLs. Display a placeholder or spinner while status === 'processing'.
Favorites
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/favorites | User's favorites (with product data) |
| POST | /api/vendor/favorites | Add: { "product_id": 1 } |
| DELETE | /api/vendor/favorites/{product_id} | Remove |
Sessions
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/sessions | List active sessions |
| DELETE | /api/vendor/sessions/{id} | Kill specific session |
Message Search
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/vendor/conversations/{id}/messages/search | Search messages within a conversation |
| GET | /api/vendor/messages/search | Search messages across all user's conversations |
GET /api/vendor/conversations/{id}/messages/search — In-conversation search:
Query params: q (required, min 2 chars), limit (default 50, max 100), cursor (optional).
Returns messages matching the query (ILIKE, case-insensitive) within the specified conversation. User must be a participant.
json
// Response 200
{
"data": [
{
"id": 542,
"conversation_id": 12,
"sender_id": 5,
"type": "text",
"text": "Генератор в наличии, могу отправить фото",
"image_url": null,
"image_thumbnail": null,
"status": "read",
"created_at": "2026-02-20T14:30:00+03:00"
}
],
"meta": { "has_more": false, "next_cursor": null }
}GET /api/vendor/messages/search — Global search:
Query params: q (required, min 2 chars), limit (default 20, max 100), cursor (optional).
Searches across all conversations where the user is buyer or seller. Excludes soft-deleted conversations. Each result includes conversation context.
json
// Response 200
{
"data": [
{
"id": 542,
"conversation_id": 12,
"sender_id": 5,
"type": "text",
"text": "Генератор в наличии, могу отправить фото",
"image_url": null,
"image_thumbnail": null,
"status": "read",
"created_at": "2026-02-20T14:30:00+03:00",
"conversation": {
"id": 12,
"companion": { "id": 5, "name": "Иван" },
"product": { "id": 88, "title": "Генератор BMW E46" }
}
}
],
"meta": { "has_more": true, "next_cursor": "abc123" }
}Errors:
422— Query too short (qless than 2 characters)404— Conversation not found (in-conversation search only)403— Not a participant (in-conversation search only)
Admin Endpoints (Requires Admin Role)
All admin endpoints require both AuthMiddleware (valid session) and AdminMiddleware (is_admin = true). Returns 401 if not authenticated, 403 if not admin.
Dashboard & Stats
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/admin/stats | Dashboard overview |
| GET | /api/admin/stats/products | Product statistics |
| GET | /api/admin/stats/users | User statistics |
GET /api/admin/stats — Dashboard overview:
json
// Response 200
{
"data": {
"total_users": 150,
"total_products": 1200,
"products_by_status": [
{ "status": "active", "count": 800 },
{ "status": "pending", "count": 25 },
{ "status": "draft", "count": 100 }
],
"new_users_today": 5,
"new_products_today": 12
}
}GET /api/admin/stats/products — Product statistics:
json
// Response 200
{
"data": {
"by_status": [
{ "status": "active", "count": 800 },
{ "status": "pending", "count": 25 }
],
"by_city": [
{ "city_name": "Санкт-Петербург", "count": 650 },
{ "city_name": "Выборг", "count": 80 }
],
"recent_pending": 3
}
}recent_pending — count of products in pending status older than 24 hours (awaiting moderation).
GET /api/admin/stats/users — User statistics:
json
// Response 200
{
"data": {
"total": 150,
"active": 142,
"verified_email": 130,
"by_account_type": [
{ "account_type": "personal", "count": 120 },
{ "account_type": "business", "count": 30 }
],
"new_this_week": 12,
"new_this_month": 45
}
}User Management
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/admin/users | List all users (paginated) |
| GET | /api/admin/users/{id} | User detail (with phones, business_profile) |
| PUT | /api/admin/users/{id} | Update user (is_active, is_admin) |
| DELETE | /api/admin/users/{id} | Soft-delete user (deactivate + archive products) |
GET /api/admin/users — List users:
Query params: limit (default 20, max 100), after (cursor).
Returns paginated user list (base toArray() without phones/business_profile).
GET /api/admin/users/{id} — User detail:
Returns full private User Object (with phones array and business_profile if present). Same shape as GET /auth/me.
PUT /api/admin/users/{id} — Update user:
json
// Request — all fields optional
{ "is_active": false, "is_admin": true }Returns full private User Object (with phones and business_profile).
Errors:
- 400
"self_deactivation"— cannot setis_active: falseon your own account - 404 — user not found
DELETE /api/admin/users/{id} — Soft-delete user:
Deactivates the user (is_active = false) and archives all their products (status = archived). Returns 204.
Errors:
- 400
"self_deletion"— cannot delete your own account - 404 — user not found
Product Moderation
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/admin/products | List all products (paginated, optional ?status= filter) |
| GET | /api/admin/products/pending | List pending products only (paginated) |
| GET | /api/admin/products/{id} | Product detail (with images, categories, compatibility, oem_numbers, seller) |
| PUT | /api/admin/products/{id}/approve | Approve pending product |
| PUT | /api/admin/products/{id}/reject | Reject pending product |
| DELETE | /api/admin/products/{id} | Delete product |
GET /api/admin/products — List all products:
Query params: limit (default 20, max 100), after (cursor), status (optional filter: draft, pending, active, etc.).
Returns paginated product list (base toArray() without relations).
GET /api/admin/products/pending — List pending products:
Same as above but pre-filtered to status = pending.
GET /api/admin/products/{id} — Product detail:
Same shape as the store product detail (includes images, categories, compatibility, oem_numbers, seller). No status filter — returns any product regardless of status.
PUT /api/admin/products/{id}/approve — Approve product:
No request body. Sets status to active, sets published_at. Sends approval email to seller.
json
// Response 200
{ "data": { "id": 1, "status": "active", "published_at": "2026-03-01T...", ... } }Errors:
- 404 — product not found
- 422 — product is not in
pendingstatus
PUT /api/admin/products/{id}/reject — Reject product:
json
// Request
{ "reason": "Некорректное описание товара" }reason is required. Sets status to rejected, stores rejection reason. Sends rejection email to seller. Broadcasts system message to product conversations.
json
// Response 200
{ "data": { "id": 1, "status": "rejected", "rejection_reason": "Некорректное описание товара", ... } }Errors:
- 404 — product not found
- 422 — product is not in
pendingstatus
DELETE /api/admin/products/{id} — Delete product:
Deletes the product within a DB transaction (delete + audit log). Broadcasts system message "Объявление удалено" to all active conversations about this product after the transaction succeeds. Returns 204.
Reference Data CRUD (Cars)
All car reference data endpoints follow the same pattern: Create returns 201, Update returns 200, Delete returns 204. All invalidate the relevant Redis cache.
Makes
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/cars/makes | Create make |
| PUT | /api/admin/cars/makes/{id} | Update make |
| DELETE | /api/admin/cars/makes/{id} | Delete make |
POST /api/admin/cars/makes:
json
// Request
{ "name": "BMW", "slug": "bmw", "logo_url": "https://...", "is_popular": true }Required: name, slug. Optional: logo_url, is_popular.
Models
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/cars/models | Create model |
| PUT | /api/admin/cars/models/{id} | Update model |
| DELETE | /api/admin/cars/models/{id} | Delete model |
POST /api/admin/cars/models:
json
// Request
{ "make_id": 1, "name": "3 Series", "slug": "3-series" }Required: make_id, name, slug. Parent make must exist.
PUT /api/admin/cars/models/{id}:
Optional: name, slug.
Generations
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/cars/generations | Create generation |
| PUT | /api/admin/cars/generations/{id} | Update generation |
| DELETE | /api/admin/cars/generations/{id} | Delete generation |
POST /api/admin/cars/generations:
json
// Request
{ "model_id": 10, "name": "E46", "code": "E46", "year_from": 1998, "year_to": 2006, "steering": "left" }Required: model_id, name. Optional: code, year_from, year_to, steering (must be valid Steering enum value).
PUT /api/admin/cars/generations/{id}:
Optional: name, code, year_from, year_to, steering.
Modifications
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/cars/modifications | Create modification |
| PUT | /api/admin/cars/modifications/{id} | Update modification |
| DELETE | /api/admin/cars/modifications/{id} | Delete modification |
POST /api/admin/cars/modifications:
json
// Request
{
"generation_id": 45,
"name": "320i 2.0 AT",
"engine_volume": 2.0,
"fuel_type": "petrol",
"power": 150,
"transmission": "automatic",
"drivetrain": "rwd"
}Required: generation_id, name. Optional: engine_volume, fuel_type (FuelType enum), power, transmission (Transmission enum), drivetrain (Drivetrain enum). Invalid enum values return 422 with allowed values listed.
PUT /api/admin/cars/modifications/{id}:
Optional: name, engine_volume, fuel_type, power, transmission, drivetrain.
Reference Data CRUD (Categories)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/categories | Create category |
| PUT | /api/admin/categories/{id} | Update category |
| DELETE | /api/admin/categories/{id} | Delete category |
POST /api/admin/categories:
json
// Request
{ "name": "Двигатель", "slug": "engine", "category_type": "part", "icon": "engine", "parent_id": 1, "sort_order": 10 }Required: name, slug, category_type (CategoryType enum). Optional: icon, parent_id, sort_order.
PUT /api/admin/categories/{id}:
Optional: name, slug, category_type, icon, parent_id, sort_order, is_active.
Validates circular references when updating parent_id — a category cannot be its own parent or create a cycle in the hierarchy.
Reference Data CRUD (Geography)
Regions
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/geo/regions | Create region |
| PUT | /api/admin/geo/regions/{id} | Update region |
POST /api/admin/geo/regions:
json
{ "name": "Ленинградская область", "slug": "leningradskaya-oblast", "lat": "59.9343", "lon": "30.3351" }Required: name, slug. Optional: lat, lon.
PUT accepts same fields, all optional.
Cities
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/geo/cities | Create city |
| PUT | /api/admin/geo/cities/{id} | Update city |
POST /api/admin/geo/cities:
json
{ "region_id": 47, "name": "Санкт-Петербург", "slug": "saint-petersburg", "lat": "59.9343", "lon": "30.3351" }Required: region_id, name, slug. Optional: lat, lon.
PUT accepts name, slug, lat, lon — all optional.
Districts
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/geo/districts | Create district |
| PUT | /api/admin/geo/districts/{id} | Update district |
POST /api/admin/geo/districts:
json
{ "city_id": 1, "name": "Адмиралтейский", "slug": "admiralteyskiy" }Required: city_id, name, slug.
PUT accepts name, slug — all optional.
Metro Stations
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/admin/geo/metro-stations | Create metro station |
| PUT | /api/admin/geo/metro-stations/{id} | Update metro station |
POST /api/admin/geo/metro-stations:
json
{ "city_id": 1, "name": "Невский проспект", "line": "Линия 2", "line_color": "#0078BE", "lat": "59.9352", "lon": "30.3226" }Required: city_id, name, line, line_color. Optional: lat, lon.
PUT accepts name, line, line_color, lat, lon — all optional.
Enums
| Enum | Values |
|---|---|
| ProductStatus | draft, pending, active, sold, archived, rejected |
| Steering | left, right, both, universal |
| FuelType | petrol, diesel, hybrid, electric, gas |
| Transmission | at, mt, cvt, amt, robot |
| Drivetrain | fwd, rwd, awd, 4wd |
| AccountType | personal, business |
| CategoryType | part, condition, attribute |
| MessageType | text, image, system |
| ReportStatus | pending, resolved, rejected |
| PaymentStatus | pending, completed, failed, cancelled |
| PaymentType | promotion, featured, subscription |
CORS
Allowed origins: partizap.ru, dev.partizap.ru, localhost:3000
Allowed headers: Content-Type, Authorization, X-CSRF-TOKEN, X-Request-ID
Credentials: true (cookies sent cross-origin)Content Type
All requests and responses use application/json, enforced by middleware. Exception: image uploads use multipart/form-data.