feat: EMEX HTTP decode + multi-candidate selection UI
Replace Playwright browser with pure HTTP for EMEX VIN decode:
- Vehicles.aspx, QuickGroups.aspx, QuickDetails.aspx all work
without authentication — no session/browser needed
- VIN decode drops from ~10-20s (Playwright) to ~1-5s (HTTP)
- Playwright browser kept as fallback if HTTP fails
Add multi-candidate selection for EMEX (mirrors PartsCatalogs flow):
- EmexCandidate interface matches PcatCar shape so VehicleSelectModal
renders without changes
- decodeVinOrCandidates(): single HTTP fetch returns vehicle OR
candidates (avoids double Vehicles.aspx request)
- decodeVinByIndex(): resolves user-selected candidate by index
- vehicles.service: emexCandidates in VinResolveResult, emexCarIndex
param in decodeVin/resolveVin, resolveEmexCarByIndex() method
- vehicles.controller: @Body("emexCarIndex") accepted
- search.tsx: candidateSource state, handleCandidateSelect sends
emexCarIndex or pcatCarId based on source
Test VIN NM417800006410193 (Fiat SIENA) now shows 2-option modal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
* EMEX VIN Service
|
||||
*
|
||||
* NestJS service for emexdwc.ae VIN integration.
|
||||
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
|
||||
* and provides standardized DecodedVehicle responses.
|
||||
* Primary path: pure HTTP fetch (no browser) — fast (~1-5s).
|
||||
* Fallback: Playwright scraper via EmexBrowserService.
|
||||
*
|
||||
* Uses EmexBrowserService for a persistent singleton browser —
|
||||
* each request gets a pre-created Page (tab) via acquirePage().
|
||||
* emexdwc.ae does NOT require authentication for Vehicles.aspx,
|
||||
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -30,6 +30,42 @@ import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
|
||||
import { EmexBrowserService } from './emex.browser';
|
||||
import { RedisService } from '../../redis/redis.service';
|
||||
|
||||
const EMEX_BASE_URL = 'https://emexdwc.ae';
|
||||
const EMEX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
interface EmexHttpVehicle {
|
||||
label: string;
|
||||
model: string;
|
||||
yearFrom: number | null;
|
||||
catalogCode: string | null;
|
||||
vid: string | null;
|
||||
ssd: string | null;
|
||||
quickGroupsUrl: string | null;
|
||||
}
|
||||
|
||||
interface EmexHttpCategory {
|
||||
gid: string | null;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate vehicle returned when EMEX finds multiple matches for a VIN.
|
||||
* Shape matches PcatCar so VehicleSelectModal can render without changes.
|
||||
*/
|
||||
export interface EmexCandidate {
|
||||
/** String index into the Vehicles.aspx list — "0", "1", etc. */
|
||||
id: string;
|
||||
name: string;
|
||||
parameters?: Array<{ key: string; idx: string; value: string }>;
|
||||
catalogId: string;
|
||||
/** Internal: used by resolveEmexCarByIndex to fetch the specific vehicle */
|
||||
_index: number;
|
||||
_quickGroupsUrl: string | null;
|
||||
_ssd: string | null;
|
||||
_vid: string | null;
|
||||
}
|
||||
|
||||
// Type definition for the imported scraper module
|
||||
interface EmexScraperModule {
|
||||
EmexVinScraper: new (options?: { page?: unknown }) => EmexVinScraperInstance;
|
||||
@@ -183,10 +219,316 @@ export class EmexService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP-based methods (no browser required) ────────────────
|
||||
|
||||
/**
|
||||
* Decodes a VIN number using EMEX scraper.
|
||||
* Works with or without a known catalog code — the scraper
|
||||
* falls back to VIN URL search which doesn't require one.
|
||||
* Fetch a URL from emexdwc.ae without a browser session.
|
||||
* emexdwc.ae serves Vehicles.aspx, QuickGroups.aspx, and QuickDetails.aspx
|
||||
* without requiring authentication cookies.
|
||||
*/
|
||||
private async fetchEmexHtml(url: string): Promise<string> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' },
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Vehicles.aspx HTML — extract vehicle list from "Found vehicles" table.
|
||||
*/
|
||||
private parseVehiclesList(html: string): EmexHttpVehicle[] {
|
||||
const linkRx = /href="(Vehicle\.aspx\?[^"]+)">([^<]+)<\/a>/g;
|
||||
const seen = new Set<string>();
|
||||
const vehicles: EmexHttpVehicle[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRx.exec(html)) !== null) {
|
||||
const href = m[1].replace(/&/g, '&');
|
||||
if (seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
const label = m[2].trim();
|
||||
const params = new URLSearchParams(href.replace('Vehicle.aspx?', ''));
|
||||
const c = params.get('c');
|
||||
const vid = params.get('vid');
|
||||
const ssd = params.get('ssd');
|
||||
const modelMatch = label.match(/^([^\[]+)/);
|
||||
const yearMatch = label.match(/\((\d{4})/);
|
||||
vehicles.push({
|
||||
label,
|
||||
model: modelMatch ? modelMatch[1].trim() : label,
|
||||
yearFrom: yearMatch ? parseInt(yearMatch[1], 10) : null,
|
||||
catalogCode: c,
|
||||
vid,
|
||||
ssd,
|
||||
quickGroupsUrl: c && vid != null && ssd
|
||||
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return vehicles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse QuickGroups.aspx HTML — extract flat category list (QuickDetails links).
|
||||
*/
|
||||
private parseCategoryList(html: string): EmexHttpCategory[] {
|
||||
const catRx = /href="(QuickDetails\.aspx\?[^"]+)">([^<]+)<\/a>/g;
|
||||
const seen = new Set<string>();
|
||||
const cats: EmexHttpCategory[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = catRx.exec(html)) !== null) {
|
||||
const href = m[1].replace(/&/g, '&');
|
||||
const name = m[2].trim();
|
||||
if (name.length < 2 || seen.has(href)) continue;
|
||||
seen.add(href);
|
||||
const params = new URLSearchParams(href.replace('QuickDetails.aspx?', ''));
|
||||
cats.push({ gid: params.get('gid'), name, url: `${EMEX_BASE_URL}/${href}` });
|
||||
}
|
||||
return cats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine brand name from EMEX catalog code (e.g. "BMW202501" → "BMW").
|
||||
*/
|
||||
private brandFromCatalogCode(c: string | null): string | null {
|
||||
if (!c) return null;
|
||||
const upper = c.toUpperCase();
|
||||
const prefixes: [string, string][] = [
|
||||
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['AU', 'Audi'],
|
||||
['VW', 'Volkswagen'], ['FFIAT', 'Fiat'], ['RFIAT', 'Alfa Romeo'],
|
||||
['FORD', 'Ford'], ['RENAULT', 'Renault'], ['TOYOTA', 'Toyota'],
|
||||
['HONDA', 'Honda'], ['KIA', 'Kia'], ['HYUNDAI', 'Hyundai'],
|
||||
['PORSCHE', 'Porsche'], ['SUBARU', 'Subaru'], ['MAZDA', 'Mazda'],
|
||||
['CPSA', 'Citroën/Peugeot'], ['VOLVO', 'Volvo'], ['NISSAN', 'Nissan'],
|
||||
['OPEL', 'Opel'],
|
||||
];
|
||||
for (const [prefix, brand] of prefixes) {
|
||||
if (upper.startsWith(prefix)) return brand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN decode via pure HTTP (primary path — no browser needed).
|
||||
* Returns null if VIN is not found in EMEX database.
|
||||
*/
|
||||
private async decodeVinHttp(vin: string): Promise<DecodedVehicle | null> {
|
||||
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
|
||||
this.logger.log(`EMEX HTTP: fetching ${vinUrl}`);
|
||||
|
||||
const vinHtml = await this.fetchEmexHtml(vinUrl);
|
||||
const vehicles = this.parseVehiclesList(vinHtml);
|
||||
|
||||
if (vehicles.length === 0) {
|
||||
this.logger.log(`EMEX HTTP: no vehicles found for VIN ${vin}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = vehicles[0];
|
||||
this.logger.log(`EMEX HTTP: found "${v.label}" (c=${v.catalogCode})`);
|
||||
|
||||
// Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic
|
||||
const wmi = vin.substring(0, 3).toUpperCase();
|
||||
const catalogEntry = CATALOG_MAP[wmi];
|
||||
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
|
||||
|
||||
// Fetch categories from QuickGroups.aspx (fast HTTP, no browser)
|
||||
let categories: EmexHttpCategory[] = [];
|
||||
if (v.quickGroupsUrl) {
|
||||
try {
|
||||
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
|
||||
categories = this.parseCategoryList(qgHtml);
|
||||
this.logger.log(`EMEX HTTP: found ${categories.length} categories`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX HTTP: category fetch failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a response compatible with mapEmexResponse
|
||||
const response: EmexScraperResponse = {
|
||||
success: true,
|
||||
source: 'emexdwc.ae',
|
||||
method: 'vin_url',
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
ssd: v.ssd || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
model: v.model,
|
||||
year: v.yearFrom,
|
||||
bodyType: null,
|
||||
engineCode: null,
|
||||
engineType: null,
|
||||
engineVolume: null,
|
||||
transmission: null,
|
||||
driveType: null,
|
||||
},
|
||||
quickGroupsUrl: v.quickGroupsUrl || null,
|
||||
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
|
||||
categoryTree: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return mapEmexResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-fetch combined decode: fetches Vehicles.aspx once and returns either:
|
||||
* - `{ type: 'vehicle', vehicle }` — single match decoded as DecodedVehicle
|
||||
* - `{ type: 'candidates', candidates }` — multiple matches, caller shows selection UI
|
||||
* - `{ type: 'notFound' }` — VIN not in EMEX
|
||||
* - `{ type: 'error' }` — fetch failed
|
||||
*/
|
||||
async decodeVinOrCandidates(vin: string): Promise<
|
||||
| { type: 'vehicle'; vehicle: DecodedVehicle }
|
||||
| { type: 'candidates'; candidates: EmexCandidate[] }
|
||||
| { type: 'notFound' }
|
||||
| { type: 'error' }
|
||||
> {
|
||||
try {
|
||||
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
|
||||
const html = await this.fetchEmexHtml(vinUrl);
|
||||
const vehicleList = this.parseVehiclesList(html);
|
||||
|
||||
if (vehicleList.length === 0) return { type: 'notFound' };
|
||||
|
||||
if (vehicleList.length > 1) {
|
||||
const candidates: EmexCandidate[] = vehicleList.map((v, i) => {
|
||||
const params: Array<{ key: string; idx: string; value: string }> = [];
|
||||
if (v.yearFrom) params.push({ key: 'year', idx: '0', value: String(v.yearFrom) });
|
||||
if (v.catalogCode) params.push({ key: 'catalog', idx: '1', value: v.catalogCode });
|
||||
return {
|
||||
id: String(i),
|
||||
name: v.label,
|
||||
parameters: params,
|
||||
catalogId: v.catalogCode || '',
|
||||
_index: i,
|
||||
_quickGroupsUrl: v.quickGroupsUrl,
|
||||
_ssd: v.ssd,
|
||||
_vid: v.vid,
|
||||
};
|
||||
});
|
||||
return { type: 'candidates', candidates };
|
||||
}
|
||||
|
||||
// Single result — decode directly
|
||||
const v = vehicleList[0];
|
||||
const wmi = vin.substring(0, 3).toUpperCase();
|
||||
const catalogEntry = CATALOG_MAP[wmi];
|
||||
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
|
||||
|
||||
let categories: EmexHttpCategory[] = [];
|
||||
if (v.quickGroupsUrl) {
|
||||
try {
|
||||
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
|
||||
categories = this.parseCategoryList(qgHtml);
|
||||
this.logger.log(`EMEX HTTP: found ${categories.length} categories`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX HTTP category fetch failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response: EmexScraperResponse = {
|
||||
success: true,
|
||||
source: 'emexdwc.ae',
|
||||
method: 'vin_url',
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
ssd: v.ssd || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
model: v.model,
|
||||
year: v.yearFrom,
|
||||
bodyType: null,
|
||||
engineCode: null,
|
||||
engineType: null,
|
||||
engineVolume: null,
|
||||
transmission: null,
|
||||
driveType: null,
|
||||
},
|
||||
quickGroupsUrl: v.quickGroupsUrl || null,
|
||||
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
|
||||
categoryTree: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { type: 'vehicle', vehicle: mapEmexResponse(response) };
|
||||
} catch (err) {
|
||||
this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`);
|
||||
return { type: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a specific EMEX candidate by its index in the Vehicles.aspx list.
|
||||
* Used after the user selects a vehicle from the multi-candidate modal.
|
||||
*/
|
||||
async decodeVinByIndex(vin: string, index: number): Promise<DecodedVehicle | null> {
|
||||
try {
|
||||
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
|
||||
const vinHtml = await this.fetchEmexHtml(vinUrl);
|
||||
const vehicleList = this.parseVehiclesList(vinHtml);
|
||||
|
||||
if (index < 0 || index >= vehicleList.length) {
|
||||
this.logger.warn(`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = vehicleList[index];
|
||||
this.logger.log(`EMEX decodeVinByIndex[${index}]: "${v.label}" (c=${v.catalogCode})`);
|
||||
|
||||
const wmi = vin.substring(0, 3).toUpperCase();
|
||||
const catalogEntry = CATALOG_MAP[wmi];
|
||||
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
|
||||
|
||||
let categories: EmexHttpCategory[] = [];
|
||||
if (v.quickGroupsUrl) {
|
||||
try {
|
||||
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
|
||||
categories = this.parseCategoryList(qgHtml);
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response: EmexScraperResponse = {
|
||||
success: true,
|
||||
source: 'emexdwc.ae',
|
||||
method: 'vin_url',
|
||||
vin,
|
||||
catalogCode: v.catalogCode || '',
|
||||
ssd: v.ssd || undefined,
|
||||
vehicle: {
|
||||
brand,
|
||||
model: v.model,
|
||||
year: v.yearFrom,
|
||||
bodyType: null,
|
||||
engineCode: null,
|
||||
engineType: null,
|
||||
engineVolume: null,
|
||||
transmission: null,
|
||||
driveType: null,
|
||||
},
|
||||
quickGroupsUrl: v.quickGroupsUrl || null,
|
||||
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
|
||||
categoryTree: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return mapEmexResponse(response);
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX decodeVinByIndex failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a VIN number.
|
||||
* Primary: pure HTTP fetch (fast, ~1-5s).
|
||||
* Fallback: Playwright browser scraper (slower, used if HTTP fails).
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<DecodedVehicle> {
|
||||
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
|
||||
@@ -197,6 +539,23 @@ export class EmexService {
|
||||
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
|
||||
await this.touchActivity();
|
||||
|
||||
// ── Primary: HTTP (no browser) ──────────────────────────────
|
||||
try {
|
||||
const result = await this.decodeVinHttp(cleanVin);
|
||||
if (result) {
|
||||
this.logger.log(
|
||||
`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
// VIN not in EMEX — return empty rather than hitting browser
|
||||
return createEmptyDecodedVehicle(cleanVin, 'Vehicle not found in EMEX database');
|
||||
} catch (httpErr) {
|
||||
const err = httpErr as Error;
|
||||
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
|
||||
}
|
||||
|
||||
// ── Fallback: Playwright browser scraper ────────────────────
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
@@ -211,69 +570,27 @@ export class EmexService {
|
||||
|
||||
if (this.debug) {
|
||||
this.logger.debug(
|
||||
`EMEX raw response: ${JSON.stringify(response, null, 2)}`,
|
||||
`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
this.logger.warn(
|
||||
`EMEX search unsuccessful: ${response.message || response.error}`,
|
||||
`EMEX browser search unsuccessful: ${response.message || response.error}`,
|
||||
);
|
||||
|
||||
if (response.vehicle && response.vehicle.brand) {
|
||||
return mapEmexResponse(response);
|
||||
}
|
||||
|
||||
return createEmptyDecodedVehicle(
|
||||
cleanVin,
|
||||
response.message || response.error,
|
||||
);
|
||||
}
|
||||
|
||||
// Try category tree from QuickGroups.aspx (hierarchical)
|
||||
if (response.quickGroupsUrl) {
|
||||
this.logger.log(`Fetching category tree from QuickGroups.aspx: ${response.quickGroupsUrl}`);
|
||||
try {
|
||||
const tree = await this.executeWithTimeout(
|
||||
scraper.getCategoryTree(response.quickGroupsUrl),
|
||||
this.timeout,
|
||||
);
|
||||
|
||||
if (tree && tree.length > 0) {
|
||||
this.logger.log(`Found ${tree.length} top-level category groups`);
|
||||
response.categoryTree = tree;
|
||||
}
|
||||
} catch (treeError) {
|
||||
const err = treeError as Error;
|
||||
this.logger.warn(`Failed to fetch category tree: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to flat categories if tree failed
|
||||
if (!response.categoryTree?.length && response.quickGroupsUrl) {
|
||||
this.logger.log(`Falling back to flat categories from: ${response.quickGroupsUrl}`);
|
||||
try {
|
||||
const categories = await this.executeWithTimeout(
|
||||
scraper.getCategories(response.quickGroupsUrl),
|
||||
this.timeout,
|
||||
);
|
||||
|
||||
if (categories && categories.length > 0) {
|
||||
this.logger.log(`Found ${categories.length} flat categories (on-demand parts loading enabled)`);
|
||||
response.categories = categories;
|
||||
}
|
||||
} catch (catError) {
|
||||
const err = catError as Error;
|
||||
this.logger.warn(`Failed to fetch categories: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const decodedVehicle = mapEmexResponse(response);
|
||||
|
||||
this.logger.log(
|
||||
`VIN decoded successfully: ${decodedVehicle.brand} ${decodedVehicle.model} (${decodedVehicle.year})`,
|
||||
`EMEX browser decode OK: ${decodedVehicle.brand} ${decodedVehicle.model} (${decodedVehicle.year})`,
|
||||
);
|
||||
|
||||
return decodedVehicle;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
@@ -293,17 +610,6 @@ export class EmexService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
err.message?.includes('browser') ||
|
||||
err.message?.includes('playwright') ||
|
||||
err.message?.includes('navigation')
|
||||
) {
|
||||
this.logger.error(`Browser error: ${err.message}`, err.stack);
|
||||
throw new ServiceUnavailableException(
|
||||
'EMEX servisine baglanamadi. Lutfen daha sonra tekrar deneyin.',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
|
||||
throw new ServiceUnavailableException(
|
||||
'VIN sorgulama sirasinda bir hata olustu',
|
||||
|
||||
Reference in New Issue
Block a user