Flatten repo structure: move crawler/ to root, remove vqa/ and immoweb/
The crawler subdirectory was the only active project. Moving it to the repo root simplifies paths and removes the unnecessary nesting. The vqa/ and immoweb/ directories were legacy/unused and have been removed. Updated .drone.yml, .gitignore, .claude/ docs, and skills to reflect the new flat structure.
This commit is contained in:
parent
e2247be700
commit
eafbc1ac52
221 changed files with 70 additions and 146140 deletions
61
frontend/src/services/apiClient.ts
Normal file
61
frontend/src/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Generic API client with authentication
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import { ApiError } from '@/types';
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
params?: Record<string, string | number | boolean | Date | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters object
|
||||
*/
|
||||
function buildQueryString(params: Record<string, string | number | boolean | Date | undefined>): string {
|
||||
const queryString = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (value instanceof Date) {
|
||||
queryString.append(key, value.toISOString());
|
||||
} else {
|
||||
queryString.append(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queryString.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic authenticated API request
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
user: AuthUser,
|
||||
endpoint: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', params } = options;
|
||||
|
||||
let url = endpoint;
|
||||
if (params) {
|
||||
const queryString = buildQueryString(params);
|
||||
if (queryString) {
|
||||
url = `${endpoint}?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
74
frontend/src/services/healthService.ts
Normal file
74
frontend/src/services/healthService.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Health check service for backend connectivity
|
||||
|
||||
export type HealthStatus = 'checking' | 'healthy' | 'unhealthy';
|
||||
|
||||
export interface HealthCheckResult {
|
||||
status: HealthStatus;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check backend health by calling the /api/status endpoint
|
||||
*/
|
||||
export async function checkBackendHealth(): Promise<HealthCheckResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Short timeout for health checks
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
const latencyMs = Math.round(performance.now() - startTime);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: `HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'OK') {
|
||||
return {
|
||||
status: 'healthy',
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: 'Unexpected response',
|
||||
};
|
||||
} catch (error) {
|
||||
const latencyMs = Math.round(performance.now() - startTime);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: 'Request timeout',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: 'Connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
6
frontend/src/services/index.ts
Normal file
6
frontend/src/services/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Re-export all services
|
||||
export { apiRequest } from './apiClient';
|
||||
export { fetchListingGeoJSON, refreshListings } from './listingService';
|
||||
export { streamListingGeoJSON, type StreamingProgress } from './streamingService';
|
||||
export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService';
|
||||
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
||||
54
frontend/src/services/listingService.ts
Normal file
54
frontend/src/services/listingService.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Listing service for fetching and refreshing listings
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { GeoJSONFeatureCollection, RefreshListingsResponse } from '@/types';
|
||||
import type { ParameterValues } from '@/components/FilterPanel';
|
||||
import { apiRequest } from './apiClient';
|
||||
import { API_ENDPOINTS } from '@/constants';
|
||||
|
||||
/**
|
||||
* Build listing query parameters from form values
|
||||
*/
|
||||
function buildListingParams(parameters: ParameterValues): Record<string, string | number | boolean | Date | undefined> {
|
||||
return {
|
||||
listing_type: parameters.listing_type,
|
||||
min_bedrooms: parameters.min_bedrooms,
|
||||
max_bedrooms: parameters.max_bedrooms,
|
||||
max_price: parameters.max_price,
|
||||
min_price: parameters.min_price,
|
||||
min_sqm: parameters.min_sqm,
|
||||
max_sqm: parameters.max_sqm,
|
||||
min_price_per_sqm: parameters.min_price_per_sqm,
|
||||
max_price_per_sqm: parameters.max_price_per_sqm,
|
||||
last_seen_days: parameters.last_seen_days,
|
||||
let_date_available_from: parameters.available_from,
|
||||
district_names: parameters.district || undefined,
|
||||
furnish_types: parameters.furnish_types?.join(',') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch listing data as GeoJSON
|
||||
*/
|
||||
export async function fetchListingGeoJSON(
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues
|
||||
): Promise<GeoJSONFeatureCollection> {
|
||||
return apiRequest<GeoJSONFeatureCollection>(user, API_ENDPOINTS.LISTING_GEOJSON, {
|
||||
method: 'GET',
|
||||
params: buildListingParams(parameters),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a listing refresh task
|
||||
*/
|
||||
export async function refreshListings(
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues
|
||||
): Promise<RefreshListingsResponse> {
|
||||
return apiRequest<RefreshListingsResponse>(user, API_ENDPOINTS.REFRESH_LISTINGS, {
|
||||
method: 'POST',
|
||||
params: buildListingParams(parameters),
|
||||
});
|
||||
}
|
||||
137
frontend/src/services/streamingService.ts
Normal file
137
frontend/src/services/streamingService.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Streaming service for progressive listing data loading
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { PropertyFeature } from '@/types';
|
||||
import type { ParameterValues } from '@/components/FilterPanel';
|
||||
import { ApiError } from '@/types';
|
||||
import { API_ENDPOINTS } from '@/constants';
|
||||
|
||||
/**
|
||||
* Build query string from parameters object
|
||||
*/
|
||||
function buildQueryString(params: Record<string, string | number | boolean | Date | undefined>): string {
|
||||
const queryString = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (value instanceof Date) {
|
||||
queryString.append(key, value.toISOString());
|
||||
} else {
|
||||
queryString.append(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queryString.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build listing query parameters from form values
|
||||
*/
|
||||
function buildListingParams(parameters: ParameterValues): Record<string, string | number | boolean | Date | undefined> {
|
||||
return {
|
||||
listing_type: parameters.listing_type,
|
||||
min_bedrooms: parameters.min_bedrooms,
|
||||
max_bedrooms: parameters.max_bedrooms,
|
||||
max_price: parameters.max_price,
|
||||
min_price: parameters.min_price,
|
||||
min_sqm: parameters.min_sqm,
|
||||
max_sqm: parameters.max_sqm,
|
||||
min_price_per_sqm: parameters.min_price_per_sqm,
|
||||
max_price_per_sqm: parameters.max_price_per_sqm,
|
||||
last_seen_days: parameters.last_seen_days,
|
||||
let_date_available_from: parameters.available_from,
|
||||
district_names: parameters.district || undefined,
|
||||
furnish_types: parameters.furnish_types?.join(',') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamMessage {
|
||||
type: 'metadata' | 'batch' | 'complete';
|
||||
features?: PropertyFeature[];
|
||||
total?: number;
|
||||
total_expected?: number;
|
||||
batch_size?: number;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamingProgress {
|
||||
count: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream listing GeoJSON data as an async generator.
|
||||
* Yields batches of features as they arrive from the server.
|
||||
*/
|
||||
export async function* streamListingGeoJSON(
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues,
|
||||
onProgress?: (progress: StreamingProgress) => void
|
||||
): AsyncGenerator<PropertyFeature[], void, unknown> {
|
||||
const params = buildListingParams(parameters);
|
||||
const queryString = buildQueryString(params);
|
||||
const url = queryString
|
||||
? `${API_ENDPOINTS.LISTING_GEOJSON_STREAM}?${queryString}`
|
||||
: API_ENDPOINTS.LISTING_GEOJSON_STREAM;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let totalCount = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(line);
|
||||
|
||||
if (message.type === 'metadata') {
|
||||
onProgress?.({ count: 0, total: message.total_expected });
|
||||
} else if (message.type === 'batch' && message.features) {
|
||||
totalCount += message.features.length;
|
||||
onProgress?.({ count: totalCount });
|
||||
yield message.features;
|
||||
} else if (message.type === 'complete') {
|
||||
onProgress?.({ count: message.total ?? totalCount, total: message.total });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse streaming message:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in the buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(buffer);
|
||||
if (message.type === 'batch' && message.features) {
|
||||
yield message.features;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse final streaming message:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
frontend/src/services/taskService.ts
Normal file
58
frontend/src/services/taskService.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Task service for fetching task status
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { TaskStatusResponse } from '@/types';
|
||||
import { apiRequest } from './apiClient';
|
||||
import { API_ENDPOINTS } from '@/constants';
|
||||
|
||||
export interface CancelTaskResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ClearAllTasksResponse {
|
||||
success: boolean;
|
||||
count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all active tasks for the current user
|
||||
*/
|
||||
export async function fetchTasksForUser(user: AuthUser): Promise<string[]> {
|
||||
return apiRequest<string[]>(user, API_ENDPOINTS.TASKS_FOR_USER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the status of a specific task
|
||||
*/
|
||||
export async function fetchTaskStatus(
|
||||
user: AuthUser,
|
||||
taskId: string
|
||||
): Promise<TaskStatusResponse> {
|
||||
return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, {
|
||||
params: { task_id: taskId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running task
|
||||
*/
|
||||
export async function cancelTask(
|
||||
user: AuthUser,
|
||||
taskId: string
|
||||
): Promise<CancelTaskResponse> {
|
||||
return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, {
|
||||
method: 'POST',
|
||||
params: { task_id: taskId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tasks for the current user
|
||||
*/
|
||||
export async function clearAllTasks(user: AuthUser): Promise<ClearAllTasksResponse> {
|
||||
return apiRequest<ClearAllTasksResponse>(user, API_ENDPOINTS.CLEAR_ALL_TASKS, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue