diff --git a/public/admin.html b/public/admin.html index bcb105c..8da98d3 100644 --- a/public/admin.html +++ b/public/admin.html @@ -3191,6 +3191,61 @@ padding: 15 $('.main-wrapper').css('margin-left', '80px'); } }); + + // Authentication check + (async function checkAuth() { + try { + const res = await fetch('/api/auth/me'); + const data = await res.json(); + + if (!data.success || !data.data) { + // Not authenticated, redirect to login + window.location.href = '/login.html'; + return; + } + + // Store user info + const user = data.data; + window.currentUser = user; + localStorage.setItem('user', JSON.stringify(user)); + + // Update UI with user info + const userNameEl = document.querySelector('.sidebar-user-info h6, .sidebar-user-name'); + const userRoleEl = document.querySelector('.sidebar-user-info small, .sidebar-user-role'); + + if (userNameEl) { + userNameEl.textContent = user.name || 'Admin'; + } + if (userRoleEl) { + const roleNames = { + admin: 'Administrador', + agent: 'Agente', + editor: 'Editor' + }; + userRoleEl.textContent = roleNames[user.role] || user.role; + } + + // Initialize admin panel + if (window.admin) { + window.admin.init(); + } + } catch (error) { + console.error('Auth check failed:', error); + window.location.href = '/login.html'; + } + })(); + + // Logout function + async function logout() { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + localStorage.removeItem('user'); + window.location.href = '/login.html'; + } catch (error) { + console.error('Logout failed:', error); + window.location.href = '/login.html'; + } + } diff --git a/public/js/api.js b/public/js/api.js index 04892a7..018a3ec 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -2,6 +2,50 @@ const API_BASE = '/api'; class API { + // Auth + static async login(email, password) { + try { + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + return response.json(); + } catch (error) { + return { success: false, error: 'Error de conexión' }; + } + } + + static async logout() { + try { + const response = await fetch(`${API_BASE}/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + return response.json(); + } catch (error) { + return { success: false, error: 'Error de conexión' }; + } + } + + static async getMe() { + try { + const response = await fetch(`${API_BASE}/auth/me`); + return response.json(); + } catch (error) { + return { success: false, error: 'Error de conexión' }; + } + } + + static async getCsrfToken() { + try { + const response = await fetch(`${API_BASE}/csrf-token`); + return response.json(); + } catch (error) { + return { success: false, error: 'Error de conexión' }; + } + } + // Properties static async getProperties(filters = {}) { const params = new URLSearchParams(filters); @@ -24,6 +68,32 @@ class API { return response.json(); } + // Admin Properties + static async createProperty(data) { + const response = await fetch(`${API_BASE}/admin/properties`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async updateProperty(id, data) { + const response = await fetch(`${API_BASE}/admin/properties/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async deleteProperty(id) { + const response = await fetch(`${API_BASE}/admin/properties/${id}`, { + method: 'DELETE' + }); + return response.json(); + } + // Leads static async createLead(data) { // Input validation @@ -56,7 +126,23 @@ class API { static async getLeads(filters = {}) { const params = new URLSearchParams(filters); - const response = await fetch(`${API_BASE}/leads?${params}`); + const response = await fetch(`${API_BASE}/admin/leads?${params}`); + return response.json(); + } + + static async updateLead(id, data) { + const response = await fetch(`${API_BASE}/admin/leads/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async deleteLead(id) { + const response = await fetch(`${API_BASE}/admin/leads/${id}`, { + method: 'DELETE' + }); return response.json(); } @@ -66,21 +152,111 @@ class API { return response.json(); } + static async createTestimonial(data) { + const response = await fetch(`${API_BASE}/admin/testimonials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async updateTestimonial(id, data) { + const response = await fetch(`${API_BASE}/admin/testimonials/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async deleteTestimonial(id) { + const response = await fetch(`${API_BASE}/admin/testimonials/${id}`, { + method: 'DELETE' + }); + return response.json(); + } + static async getFAQ(lang = 'es') { const response = await fetch(`${API_BASE}/faq?lang=${lang}`); return response.json(); } + static async createFAQ(data) { + const response = await fetch(`${API_BASE}/admin/faq`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async updateFAQ(id, data) { + const response = await fetch(`${API_BASE}/admin/faq/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async deleteFAQ(id) { + const response = await fetch(`${API_BASE}/admin/faq/${id}`, { + method: 'DELETE' + }); + return response.json(); + } + static async getServices(lang = 'es') { const response = await fetch(`${API_BASE}/services?lang=${lang}`); return response.json(); } + static async createService(data) { + const response = await fetch(`${API_BASE}/admin/services`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async updateService(id, data) { + const response = await fetch(`${API_BASE}/admin/services/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + static async deleteService(id) { + const response = await fetch(`${API_BASE}/admin/services/${id}`, { + method: 'DELETE' + }); + return response.json(); + } + static async getSettings() { const response = await fetch(`${API_BASE}/settings`); return response.json(); } + static async updateSettings(data) { + const response = await fetch(`${API_BASE}/admin/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return response.json(); + } + + // Admin Stats + static async getAdminStats() { + const response = await fetch(`${API_BASE}/admin/stats`); + return response.json(); + } + // Analytics static async trackEvent(type, data = {}) { let sessionId = localStorage.getItem('session_id'); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..f6779cf --- /dev/null +++ b/public/login.html @@ -0,0 +1,480 @@ + + + + + + + + Iniciar Sesión | TenerifeProp Admin + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/src/db/seed-comprehensive.ts b/src/db/seed-comprehensive.ts new file mode 100644 index 0000000..4af9edf --- /dev/null +++ b/src/db/seed-comprehensive.ts @@ -0,0 +1,538 @@ +// Comprehensive seed data with stock photos for TenerifeProp +import { Database } from 'bun:sqlite' + +const db = new Database('./data/tenerifeprop.db') + +// Stock photo URLs from Unsplash for properties +const propertyImages = { + land: [ + 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=1920&q=80', + 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=1920&q=80', + 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1920&q=80', + 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1920&q=80', + 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=1920&q=80' + ], + house: [ + 'https://images.unsplash.com/photo-1613490493576-7fde63acd811?w=1920&q=80', + 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a5?w=1920&q=80', + 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=1920&q=80', + 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=1920&q=80', + 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=1920&q=80' + ], + apartment: [ + 'https://images.unsplash.com/photo-1522708323590-d24dbb8d0d4d?w=1920&q=80', + 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=1920&q=80', + 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=1920&q=80', + 'https://images.unsplash.com/photo-1493809842364-78817add7ffb?w=1920&q=80', + 'https://images.unsplash.com/photo-1502672023488-70e01f7ef429?w=1920&q=80' + ], + agricultural: [ + 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=1920&q=80', + 'https://images.unsplash.com/photo-1576201834545-1d09720e91f2?w=1920&q=80', + 'https://images.unsplash.com/photo-1625246333195-78d9c5ad9498?w=1920&q=80', + 'https://images.unsplash.com/photo-1416879595882-3373a0480b5b?w=1920&q=80', + 'https://images.unsplash.com/photo-1586771107445-d3ca888129fe?w=1920&q=80' + ] +} + +const genId = () => crypto.randomUUID() + +// Tenerife locations with real coordinates +const locations = [ + { city: 'Adeje', zone: 'Costa Adeje', province: 'Santa Cruz de Tenerife', postalCode: '38670', lat: 28.1227, lng: -16.6942 }, + { city: 'Los Cristianos', zone: 'Los Cristianos', province: 'Santa Cruz de Tenerife', postalCode: '38650', lat: 28.0565, lng: -16.7134 }, + { city: 'Playa de las Américas', zone: 'Playa de las Américas', province: 'Santa Cruz de Tenerife', postalCode: '38660', lat: 28.0716, lng: -16.7278 }, + { city: 'Santa Cruz de Tenerife', zone: 'Centro', province: 'Santa Cruz de Tenerife', postalCode: '38005', lat: 28.4612, lng: -16.2602 }, + { city: 'Puerto de la Cruz', zone: 'Centro', province: 'Santa Cruz de Tenerife', postalCode: '38400', lat: 28.4156, lng: -16.5522 }, + { city: 'La Orotava', zone: 'Valle de La Orotava', province: 'Santa Cruz de Tenerife', postalCode: '38300', lat: 28.3922, lng: -16.5215 }, + { city: 'Güímar', zone: 'Valle de Güímar', province: 'Santa Cruz de Tenerife', postalCode: '38500', lat: 28.3183, lng: -16.4167 }, + { city: 'Icod de los Vinos', zone: 'Icod', province: 'Santa Cruz de Tenerife', postalCode: '38430', lat: 28.4736, lng: -16.7178 }, + { city: 'Granadilla de Abona', zone: 'Granadilla', province: 'Santa Cruz de Tenerife', postalCode: '38600', lat: 28.1189, lng: -16.5678 }, + { city: 'Los Realejos', zone: 'Los Realejos', province: 'Santa Cruz de Tenerife', postalCode: '38410', lat: 28.3783, lng: -16.5833 }, + { city: 'San Miguel de Abona', zone: 'San Miguel', province: 'Santa Cruz de Tenerife', postalCode: '38620', lat: 28.0945, lng: -16.6189 }, + { city: 'Candelaria', zone: 'Candelaria', province: 'Santa Cruz de Tenerife', postalCode: '38530', lat: 28.3552, lng: -16.3697 } +] + +// Property types and their characteristics +const propertyTypes = { + urban: { landType: 'urban', minArea: 500, maxArea: 5000, minPricePerM2: 100, maxPricePerM2: 300 }, + agricultural: { landType: 'agricultural', minArea: 5000, maxArea: 50000, minPricePerM2: 15, maxPricePerM2: 50 }, + house: { landType: 'urban', minArea: 150, maxArea: 600, minPricePerM2: 1200, maxPricePerM2: 2500 }, + apartment: { landType: 'urban', minArea: 50, maxArea: 200, minPricePerM2: 1500, maxPricePerM2: 3500 } +} + +// Spanish and Russian translations +const titleTemplates = { + urban: { + es: ['Terreno Urbano en {city}', 'Solar Urbano en {city}', 'Parcela Urbana en {city}', 'Terreno Edificable en {city}'], + ru: ['Городской участок в {city}', 'Земля под застройку в {city}', 'Урбанизированный участок в {city}'] + }, + agricultural: { + es: ['Finca Agrícola en {city}', 'Terreno Rústico en {city}', 'Huerta en {city}', 'Finca de Plátanos en {city}'], + ru: ['Сельскохозяйственная усадьба в {city}', 'Сельский участок в {city}', 'Банановая плантация в {city}', 'Финca в {city}'] + }, + house: { + es: ['Villa de Lujo en {city}', 'Chalet en {city}', 'Casa Independiente en {city}', 'Villa con Piscina en {city}'], + ru: ['Роскошная вилла в {city}', 'Шале в {city}', 'Частный дом в {city}', 'Вилла с бассейном в {city}'] + }, + apartment: { + es: ['Apartamento en {city}', 'Piso en {city}', 'Ático en {city}', 'Estudio en {city}'], + ru: ['Апартаменты в {city}', 'Квартира в {city}', 'Пентхаус в {city}', 'Студия в {city}'] + } +} + +const descriptions = { + urban: { + es: 'Increíble oportunidad de adquirir este terreno urbano de {area} m² en {city}. {features}', + ru: 'Потрясающая возможность приобрести этот городской участок площадью {area} м² в {city}. {features}' + }, + agricultural: { + es: 'Hermoso terreno agrícola de {area} m² ubicado en {city}. {features}', + ru: 'Прекрасный сельскохозяйственный участок площадью {area} м² в {city}. {features}' + }, + house: { + es: 'Espectacular {bedrooms} dormitorios, {bathrooms} baños, {area} m² en {city}. {features}', + ru: 'Потрясающий дом {bedrooms} спальнями, {bathrooms} ванными, площадью {area} м² в {city}. {features}' + }, + apartment: { + es: 'Moderno apartamento de {bedrooms} dormitorios, {area} m² en {city}. {features}', + ru: 'Современные апартаменты с {bedrooms} спальнями, площадью {area} м² в {city}. {features}' + } +} + +function randomChoice(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] +} + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function formatPrice(price: number): string { + return price.toLocaleString('es-ES') +} + +// Generate properties +function generateProperties() { + const now = new Date().toISOString() + const properties: any[] = [] + + // Generate 20+ properties of each type + const typeCounts = { urban: 8, agricultural: 10, house: 8, apartment: 10 } + let propIndex = 1 + + for (const [type, count] of Object.entries(typeCounts)) { + const typeConfig = propertyTypes[type as keyof typeof propertyTypes] + + for (let i = 0; i < count; i++) { + const location = randomChoice(locations) + const area = randomInt(typeConfig.minArea, typeConfig.maxArea) + const pricePerM2 = randomInt(typeConfig.minPricePerM2, typeConfig.maxPricePerM2) + const price = area * pricePerM2 + + const titleData = titleTemplates[type as keyof typeof titleTemplates] + const titleEsRaw = randomChoice(titleData.es) + const titleRuRaw = randomChoice(titleData.ru) + const descTemplate = descriptions[type as keyof typeof descriptions] + + const titleEs = titleEsRaw.replace('{city}', location.city) + const titleRu = titleRuRaw.replace('{city}', location.city) + + const hasBedrooms = type === 'house' || type === 'apartment' + const bedrooms = hasBedrooms ? randomInt(1, type === 'house' ? 5 : 3) : null + const bathrooms = hasBedrooms && bedrooms ? randomInt(1, bedrooms) : null + + const images = type === 'apartment' ? propertyImages.apartment : + type === 'house' ? propertyImages.house : + type === 'agricultural' ? propertyImages.agricultural : + [...propertyImages.land, ...propertyImages.house] + + const featuredImages = [ + JSON.stringify(images.slice(0, randomInt(3, 5))) + ] + + const status = Math.random() > 0.1 ? 'active' : randomChoice(['reserved', 'sold', 'inactive']) + const isFeatured = Math.random() > 0.7 + const isExclusive = Math.random() > 0.8 + + const featuresEs = [] + const featuresRu = [] + + if (type === 'urban') { + featuresEs.push(randomChoice(['Con licencia de obras', 'Urbanizado', 'Vistas al mar', 'Cerca de la playa', 'Todas las comunicaciones'])) + featuresRu.push(randomChoice(['С лицензией на строительство', 'Урбанизирован', 'Вид на море', 'Рядом с пляжем', 'Все коммуникации'])) + } else if (type === 'agricultural') { + featuresEs.push(randomChoice(['Agua propia', 'Acceso asfaltado', 'Vistas panorámicas', 'Ruinas tradicionales', 'Terreno fértil'])) + featuresRu.push(randomChoice(['Своя вода', 'Асфальтированный подъезд', 'Панорамный вид', 'Традиционные руины', 'Плодородная земля'])) + } else if (type === 'house') { + featuresEs.push(randomChoice(['Piscina privada', 'Jardín tropical', 'Vistas al océano', 'Garaje', 'Terraza panorámica'])) + featuresRu.push(randomChoice(['Частный бассейн', 'Тропический сад', 'Вид на океан', 'Гараж', 'Панорамная терраса'])) + } else { + featuresEs.push(randomChoice(['Cerca de la playa', 'Piscina comunitaria', 'Reformado', 'Vistas al mar', 'Terraza'])) + featuresRu.push(randomChoice(['Рядом с пляжем', 'Общий бассейн', 'Отремонтирован', 'Вид на море', 'Терраса'])) + } + + properties.push({ + id: `prop-${String(propIndex).padStart(3, '0')}`, + slug: titleEs.toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + `-${propIndex}`, + reference: `TP-${String(propIndex).padStart(3, '0')}`, + type, + status, + land_type: typeConfig.landType, + title_es: titleEs, + title_ru: titleRu, + description_es: descTemplate.es + .replace('{area}', area.toLocaleString()) + .replace('{city}', location.city) + .replace('{bedrooms}', bedrooms?.toString() || '') + .replace('{bathrooms}', bathrooms?.toString() || '') + .replace('{features}', featuresEs.join('. ')), + description_ru: descTemplate.ru + .replace('{area}', area.toLocaleString()) + .replace('{city}', location.city) + .replace('{bedrooms}', bedrooms?.toString() || '') + .replace('{bathrooms}', bathrooms?.toString() || '') + .replace('{features}', featuresRu.join('. ')), + address: `Calle ${randomChoice(['Principal', 'Real', 'Mayor', 'del Sol', 'de la Playa'])} ${randomInt(1, 150)}`, + city: location.city, + province: location.province, + postal_code: location.postalCode, + zone: location.zone, + lat: location.lat + (Math.random() - 0.5) * 0.05, + lng: location.lng + (Math.random() - 0.5) * 0.05, + area, + price, + price_per_m2: pricePerM2, + bedrooms, + bathrooms, + water: randomChoice(['available', 'available', 'nearby']), + electricity: randomChoice(['available', 'available', 'nearby']), + phone: randomChoice(['available', 'unavailable', 'nearby']), + drainage: type === 'agricultural' ? 'unavailable' : randomChoice(['available', 'nearby']), + road: randomChoice(['asphalt', 'paved', 'dirt']), + gas: randomChoice(['available', 'unavailable', 'planned']), + orientation: randomChoice(['north', 'south', 'east', 'west', 'northeast', 'northwest', 'southeast', 'southwest']), + views_sea: Math.random() > 0.4 ? 1 : 0, + views_mountain: Math.random() > 0.5 ? 1 : 0, + views_valley: Math.random() > 0.7 ? 1 : 0, + topography: type === 'agricultural' ? randomChoice(['slope', 'flat', 'terraced']) : 'flat', + has_ruins: type === 'agricultural' && Math.random() > 0.6 ? 1 : 0, + has_license: type === 'urban' && Math.random() > 0.3 ? 1 : 0, + is_buildable: type === 'urban' || type === 'agricultural' && Math.random() > 0.7 ? 1 : 0, + max_floors: type === 'urban' ? randomInt(1, 3) : 0, + images: featuredImages[0], + videos: '[]', + badges: JSON.stringify([ + isFeatured ? { type: 'featured', color: '#1a5f4a' } : null, + isExclusive ? { type: 'exclusive', color: '#d4a853' } : null, + Math.random() > 0.8 ? { type: 'new', color: '#e85d04' } : null + ].filter(Boolean)), + is_featured: isFeatured ? 1 : 0, + is_exclusive: isExclusive ? 1 : 0, + views_count: randomInt(100, 5000), + favorite_count: randomInt(0, 200), + inquiry_count: randomInt(0, 50), + agent_id: 'user-001', + created_at: now, + updated_at: now, + published_at: now + }) + + propIndex++ + } + } + + return properties +} + +// Generate testimonials +function generateTestimonials() { + const testimonials = [ + { name: 'Michael Schmidt', location: 'Munich, Germany', rating: 5, text_es: 'Excelente servicio, encontraron exactamente lo que buscaba.', text_ru: 'Отличный сервис, нашли именно то, что я искал.' }, + { name: 'Anna Petrova', location: 'Moscow, Russia', rating: 5, text_es: 'Muy profesionales, me ayudaron durante todo el proceso de compra.', text_ru: 'Очень профессиональная команда, помогали на протяжении всего процесса покупки.' }, + { name: 'Pierre Dubois', location: 'Paris, France', rating: 4, text_es: 'Buena experiencia, respuesta rápida a todas mis preguntas.', text_ru: 'Хороший опыт, быстрые ответы на все мои вопросы.' }, + { name: 'John Williams', location: 'London, UK', rating: 5, text_es: 'Fantastic team! They made buying property in Tenerife so easy.', text_ru: 'Фантастическая команда! Они сделали покупку недвижимости на Тенерифе очень простой.' }, + { name: 'Elena Ivanova', location: 'Saint Petersburg, Russia', rating: 5, text_es: 'Знания и опыт на высшем уровне. Советую!', text_ru: 'Знания и опыт на высшем уровне. Советую!' }, + { name: 'Marco Rossi', location: 'Milan, Italy', rating: 4, text_es: 'Ottimo servizio, ho trovato la casa dei miei sogni.', text_ru: 'Отличный сервис, я нашел дом своей мечты.' }, + { name: 'Sarah Johnson', location: 'New York, USA', rating: 5, text_es: 'Professional and reliable. Highly recommended!', text_ru: 'Профессионально и надёжно. Настоятельно рекомендую!' }, + { name: 'Klaus Müller', location: 'Berlin, Germany', rating: 5, text_es: 'Alles bestens! Sehr empfehlenswert.', text_ru: 'Всё отлично! Очень рекомендую.' } + ] + + return testimonials.map((t, i) => ({ + id: `test-${String(i + 1).padStart(3, '0')}`, + name: t.name, + avatar: `https://images.unsplash.com/photo-${1500000000000 + i * 10000000}?w=100&h=100&fit=crop`, + location: t.location, + rating: t.rating, + text_es: t.text_es, + text_ru: t.text_ru, + is_approved: 1, + is_featured: t.rating === 5 ? 1 : 0, + created_at: new Date().toISOString() + })) +} + +// Generate FAQ +function generateFAQ() { + return [ + { + id: 'faq-001', + question_es: '¿Qué documentos necesito para comprar una propiedad en Tenerife?', + question_ru: 'Какие документы нужны для покупки недвижимости на Тенерифе?', + answer_es: 'Necesita NIE (Número de Identidad de Extranjero), pasaporte vigente, cuenta bancaria española y comprobante de fondos.', + answer_ru: 'Нужен NIE (идентификационный номер иностранца), действующий паспорт, испанский банковский счёт и подтверждение средств.', + category: 'buying', + order_num: 1, + is_active: 1 + }, + { + id: 'faq-002', + question_es: '¿Pueden los extranjeros comprar propiedades en Tenerife?', + question_ru: 'Могут ли иностранцы покупать недвижимость на Тенерифе?', + answer_es: 'Sí, no hay restricciones para extranjeros. Los ciudadanos de la UE pueden comprar sin NIE inicialmente, mientras que los no pertenecientes a la UE necesitan un NIE.', + answer_ru: 'Да, ограничений для иностранцев нет. Граждане ЕС могут покупать без начального NIE, в то время как неграждане ЕС нуждаются в NIE.', + category: 'buying', + order_num: 2, + is_active: 1 + }, + { + id: 'faq-003', + question_es: '¿Qué impuestos aplican a la compra de propiedades?', + question_ru: 'Какие налоги применяются при покупке недвижимости?', + answer_es: 'Para propiedades nuevas: IVA 7% + IAJD 1.5%. Para propiedades de segunda mano: ITP 6.5%. Más gastos de notaría y registro.', + answer_ru: 'Для новой недвижимости: НДС 7% + гербовый сбор 1.5%. Для вторичной недвижимости: налог на передачу 6.5%. Плюс расходы на нотариуса и регистрацию.', + category: 'buying', + order_num: 3, + is_active: 1 + }, + { + id: 'faq-004', + question_es: '¿Cuánto tiempo tarda el proceso de compra?', + question_ru: 'Сколько времени занимает процесс покупки?', + answer_es: 'Generalmente de 4 a 8 semanas. Depende de la obtención de documentos, verificación y registro en el Registro de la Propiedad.', + answer_ru: 'Обычно от 4 до 8 недель. Зависит от получения документов, проверки и регистрации в Реестре собственности.', + category: 'buying', + order_num: 4, + is_active: 1 + }, + { + id: 'faq-005', + question_es: '¿Qué es el NIE y cómo lo obtengo?', + question_ru: 'Что такое NIE и как его получить?', + answer_es: 'NIE es el Número de Identidad de Extranjero. Se obtiene en la Oficina de Extranjería o comisaría de policía con pasaporte y formulario EX-15.', + answer_ru: 'NIE — это идентификационный номер иностранца. Получается в офисе по делам иностранцев или полицейском участке с паспортом и формой EX-15.', + category: 'legal', + order_num: 5, + is_active: 1 + }, + { + id: 'faq-006', + question_es: '¿Ofrecen servicio de gestión de alquiler?', + question_ru: 'Вы предлагаете услугу управления арендой?', + answer_es: 'Sí, ofrecemos servicio completo de gestión de alquiler vacacional, incluyendo marketing, limpieza y mantenimiento.', + answer_ru: 'Да, мы предлагаем полный сервис управления праздничной арендой, включая маркетинг, уборку и обслуживание.', + category: 'services', + order_num: 6, + is_active: 1 + }, + { + id: 'faq-007', + question_es: '¿Puedo obtener hipoteca en España siendo extranjero?', + question_ru: 'Могу ли я получить ипотеку в Испании будучи иностранцем?', + answer_es: 'Sí, los bancos españoles ofrecen hipotecas a no residentes. Normalmente financian hasta el 60-70% del valor de compra.', + answer_ru: 'Да, испанские банки предлагают ипотеку нерезидентам. Обычно финансируют до 60-70% от стоимости покупки.', + category: 'buying', + order_num: 7, + is_active: 1 + }, + { + id: 'faq-008', + question_es: '¿Qué zonas son mejores para inversión?', + question_ru: 'Какие районы лучше для инвестиций?', + answer_es: 'Las zonas más populares son Costa Adeje, Los Cristianos y Puerto de la Cruz, con alta demanda de alquiler vacacional.', + answer_ru: 'Самые популярные районы — Коста-Адехе, Лос-Кристианос и Пуэрто-де-ла-Крус с высоким спросом на праздничную аренду.', + category: 'general', + order_num: 8, + is_active: 1 + } + ] +} + +// Generate services +function generateServices() { + return [ + { + id: 'svc-001', + icon: 'bi-shield-check', + title_es: 'Legalidad Garantizada', + title_ru: 'Гарантия законности', + description_es: 'Verificación completa de documentación y legalidad de cada propiedad.', + description_ru: 'Полная проверка документации и законности каждого объекта недвижимости.', + order_num: 1, + is_active: 1 + }, + { + id: 'svc-002', + icon: 'bi-cash-stack', + title_es: 'Precios Transparentes', + title_ru: 'Прозрачные цены', + description_es: 'Sin costes ocultos. Todos los gastos están claros desde el principio.', + description_ru: 'Без скрытых расходов. Все затраты ясны с самого начала.', + order_num: 2, + is_active: 1 + }, + { + id: 'svc-003', + icon: 'bi-headset', + title_es: 'Asistencia 360°', + title_ru: 'Сопровождение 360°', + description_es: 'Acompañamiento completo en todo el proceso de compra y trámites.', + description_ru: 'Полное сопровождение на всех этапах покупки и оформления.', + order_num: 3, + is_active: 1 + }, + { + id: 'svc-004', + icon: 'bi-translate', + title_es: 'Atención Multilingüe', + title_ru: 'Многоязычное обслуживание', + description_es: 'Hablamos español, ruso, inglés y alemán para su comodidad.', + description_ru: 'Мы говорим на испанском, русском, английском и немецком для вашего удобства.', + order_num: 4, + is_active: 1 + }, + { + id: 'svc-005', + icon: 'bi-key', + title_es: 'Gestión de Alquiler', + title_ru: 'Управление арендой', + description_es: 'Servicio completo de gestión de alquiler vacacional y larga estancia.', + description_ru: 'Полный сервис управления праздничной и долгосрочной арендой.', + order_num: 5, + is_active: 1 + }, + { + id: 'svc-006', + icon: 'bi-bank', + title_es: 'Asesoría Hipotecaria', + title_ru: 'Ипотечное консультирование', + description_es: 'Le ayudamos a encontrar las mejores opciones de financiación.', + description_ru: 'Поможем найти лучшие варианты финансирования.', + order_num: 6, + is_active: 1 + } + ] +} + +// Main seed function +function seedDatabase() { + const existingProps = db.query('SELECT COUNT(*) as count FROM properties').get() as any + if (existingProps?.count > 0) { + console.log('✅ Database already seeded with', existingProps.count, 'properties') + return + } + + const now = new Date().toISOString() + + // Seed properties + const properties = generateProperties() + const propStmt = db.prepare(` + INSERT INTO properties ( + id, slug, reference, type, status, land_type, title_es, title_ru, description_es, description_ru, + address, city, province, postal_code, zone, lat, lng, area, price, price_per_m2, + bedrooms, bathrooms, water, electricity, phone, drainage, road, gas, orientation, + views_sea, views_mountain, views_valley, topography, has_ruins, has_license, is_buildable, + max_floors, images, videos, badges, is_featured, is_exclusive, views_count, favorite_count, + inquiry_count, agent_id, created_at, updated_at, published_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + for (const prop of properties) { + (propStmt as any).run( + prop.id, prop.slug, prop.reference, prop.type, prop.status, prop.land_type, + prop.title_es, prop.title_ru, prop.description_es, prop.description_ru, + prop.address, prop.city, prop.province, prop.postal_code, prop.zone, + prop.lat, prop.lng, prop.area, prop.price, prop.price_per_m2, + prop.bedrooms, prop.bathrooms, prop.water, prop.electricity, prop.phone, + prop.drainage, prop.road, prop.gas, prop.orientation, + prop.views_sea, prop.views_mountain, prop.views_valley, prop.topography, + prop.has_ruins, prop.has_license, prop.is_buildable, prop.max_floors, + prop.images, prop.videos, prop.badges, + prop.is_featured, prop.is_exclusive, prop.views_count, prop.favorite_count, + prop.inquiry_count, prop.agent_id, prop.created_at, prop.updated_at, prop.published_at + ) + } + + // Seed testimonials + const testimonials = generateTestimonials() + for (const t of testimonials) { + db.run( + 'INSERT INTO testimonials (id, name, avatar, location, rating, text_es, text_ru, is_approved, is_featured, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [t.id, t.name, t.avatar, t.location, t.rating, t.text_es, t.text_ru, t.is_approved, t.is_featured, t.created_at] + ) + } + + // Seed FAQ + const faqs = generateFAQ() + for (const f of faqs) { + db.run( + 'INSERT INTO faq (id, question_es, question_ru, answer_es, answer_ru, category, order_num, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [f.id, f.question_es, f.question_ru, f.answer_es, f.answer_ru, f.category, f.order_num, f.is_active, now] + ) + } + + // Seed services + const services = generateServices() + for (const s of services) { + db.run( + 'INSERT INTO services (id, icon, title_es, title_ru, description_es, description_ru, order_num, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [s.id, s.icon, s.title_es, s.title_ru, s.description_es, s.description_ru, s.order_num, s.is_active, now] + ) + } + + // Seed settings + const settings = [ + ['site_name', 'TenerifeProp'], + ['site_description', 'Inmobiliaria en Tenerife - Terrenos, Fincas y Propiedades'], + ['phone', '+34 922 123 456'], + ['whatsapp', '+34 600 123 456'], + ['email', 'info@tenerifeprop.com'], + ['address', 'Avenida de Colón, 12, 38660 Adeje, Santa Cruz de Tenerife'], + ['default_map_center', JSON.stringify({ lat: 28.1227, lng: -16.6942 })], + ['default_map_zoom', '11'], + ['social_facebook', 'https://facebook.com/tenerifeprop'], + ['social_instagram', 'https://instagram.com/tenerifeprop'], + ['social_twitter', 'https://twitter.com/tenerifeprop'], + ['social_whatsapp', '+34600123456'] + ] + for (const [k, v] of settings) { + db.run('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', [k, v]) + } + + // Seed admin user (password: admin123) + // Using Bun's password hash + const passwordHash = '$2b$10$wlW1hhV6tgq8gKFtnmTBXOO8yNEv3d2UyUvwbnbX84iW3JbB3h07O' + + try { + db.run( + 'INSERT INTO users (id, email, password_hash, name, role, language, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ['user-001', 'admin@tenerifeprop.com', passwordHash, 'Admin', 'admin', 'es', 1, now] + ) + } catch (e) { + // User already exists + } + + console.log(`✅ Database seeded successfully: + - ${properties.length} properties + - ${testimonials.length} testimonials + - ${faqs.length} FAQ items + - ${services.length} services + - Admin user: admin@tenerifeprop.com / admin123`) +} + +seedDatabase() + db.close() \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index 63b90e8..fde2f95 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -201,6 +201,12 @@ app.get('/admin/js/*', (c) => { // CSRF protection - only for API routes, not static files app.use('/api/*', csrf()) +// CSRF token endpoint for forms +app.get('/api/csrf-token', (c) => { + const token = c.req.query('csrf') || crypto.randomUUID() + return c.json({ success: true, token }) +}) + // Helper const genId = () => crypto.randomUUID() @@ -527,13 +533,13 @@ app.get('/api/properties', publicApiRateLimit, (c) => { }) app.get('/api/properties/:slug', publicApiRateLimit, (c) => { - const slug = c.req.param('slug') + const slug = c.req.param('slug') || '' const lang = c.req.query('lang') || 'es' - const property = db.query('SELECT * FROM properties WHERE slug = ?').get(slug) + const property = db.query('SELECT * FROM properties WHERE slug = ?').get(slug) as any if (!property) return c.json({ success: false, error: 'Not found' }, 404) - db.run('UPDATE properties SET views_count = views_count + 1 WHERE id = ?', (property as any).id) + db.run('UPDATE properties SET views_count = views_count + 1 WHERE id = ?', [property.id]) return c.json({ success: true, diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..44ce314 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,266 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test' +import { Database } from 'bun:sqlite' + +describe('Authentication', () => { + const db = new Database(':memory:') + + beforeAll(() => { + // Create tables + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `) + + db.run(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `) + + // Seed test user (password: admin123) + db.run( + 'INSERT INTO users (id, email, password_hash, name, role, is_active) VALUES (?, ?, ?, ?, ?, ?)', + ['user-001', 'admin@test.com', '$2b$10$wlW1hhV6tgq8gKFtnmTBXOO8yNEv3d2UyUvwbnbX84iW3JbB3h07O', 'Test Admin', 'admin', 1] + ) + }) + + afterAll(() => { + db.close() + }) + + test('should create user correctly', () => { + const user = db.query('SELECT * FROM users WHERE email = ?').get('admin@test.com') as any + expect(user).toBeDefined() + expect(user.email).toBe('admin@test.com') + expect(user.role).toBe('admin') + expect(user.is_active).toBe(1) + }) + + test('should verify password hash', async () => { + const user = db.query('SELECT * FROM users WHERE email = ?').get('admin@test.com') as any + const isValid = await Bun.password.verify('admin123', user.password_hash) + expect(isValid).toBe(true) + }) + + test('should reject invalid password', async () => { + const user = db.query('SELECT * FROM users WHERE email = ?').get('admin@test.com') as any + const isValid = await Bun.password.verify('wrongpassword', user.password_hash) + expect(isValid).toBe(false) + }) + + test('should create and query session', () => { + const sessionId = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() + + db.run( + 'INSERT INTO sessions (id, user_id, role, expires_at) VALUES (?, ?, ?, ?)', + [sessionId, 'user-001', 'admin', expiresAt] + ) + + const session = db.query('SELECT * FROM sessions WHERE id = ?').get(sessionId) as any + expect(session).toBeDefined() + expect(session.user_id).toBe('user-001') + expect(session.role).toBe('admin') + }) + + test('should validate session expiry', () => { + const sessionId = crypto.randomUUID() + const expiredDate = new Date(Date.now() - 1000).toISOString() + + db.run( + 'INSERT INTO sessions (id, user_id, role, expires_at) VALUES (?, ?, ?, ?)', + [sessionId, 'user-001', 'admin', expiredDate] + ) + + const session = db.query('SELECT * FROM sessions WHERE id = ?').get(sessionId) as any + const isExpired = new Date(session.expires_at) < new Date() + expect(isExpired).toBe(true) + }) +}) + +describe('Admin API', () => { + test('should have CSRF protection', async () => { + const res = await fetch('http://localhost:8080/api/csrf-token') + expect(res.status).toBe(200) + const data = await res.json() + expect(data.success).toBe(true) + expect(data.token).toBeDefined() + }) + + test('should reject unauthenticated admin requests', async () => { + const res = await fetch('http://localhost:8080/api/admin/stats') + expect(res.status).toBe(401) + }) + + test('should reject invalid login credentials', async () => { + const res = await fetch('http://localhost:8080/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'nonexistent@test.com', password: 'wrong' }) + }) + expect(res.status).toBe(401) + }) + + test('should validate email format on login', async () => { + const res = await fetch('http://localhost:8080/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'invalid-email', password: 'test123' }) + }) + expect(res.status).toBe(400) + }) + + test('should require password on login', async () => { + const res = await fetch('http://localhost:8080/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@test.com' }) + }) + expect(res.status).toBe(400) + }) +}) + +describe('Property CRUD', () => { + const db = new Database(':memory:') + + beforeAll(() => { + db.run(` + CREATE TABLE IF NOT EXISTS properties ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + reference TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + title_es TEXT NOT NULL, + title_ru TEXT NOT NULL, + description_es TEXT NOT NULL, + description_ru TEXT NOT NULL, + city TEXT NOT NULL, + area INTEGER NOT NULL, + price INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `) + }) + + afterAll(() => { + db.close() + }) + + test('should create property', () => { + const id = crypto.randomUUID() + db.run( + 'INSERT INTO properties (id, slug, reference, type, status, title_es, title_ru, description_es, description_ru, city, area, price) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, 'test-property', 'TP-TEST', 'urban', 'active', 'Test Property', 'Тест', 'Desc', 'Описание', 'Test City', 1000, 100000] + ) + + const prop = db.query('SELECT * FROM properties WHERE slug = ?').get('test-property') as any + expect(prop).toBeDefined() + expect(prop.type).toBe('urban') + expect(prop.price).toBe(100000) + }) + + test('should update property', () => { + const id = crypto.randomUUID() + db.run( + 'INSERT INTO properties (id, slug, reference, type, status, title_es, title_ru, description_es, description_ru, city, area, price) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, 'test-prop-2', 'TP-TEST2', 'urban', 'active', 'Test', 'Тест', 'Desc', 'Описание', 'City', 500, 50000] + ) + + db.run('UPDATE properties SET price = ? WHERE id = ?', [60000, id]) + + const prop = db.query('SELECT * FROM properties WHERE id = ?').get(id) as any + expect(prop.price).toBe(60000) + }) + + test('should delete property', () => { + const id = crypto.randomUUID() + db.run( + 'INSERT INTO properties (id, slug, reference, type, status, title_es, title_ru, description_es, description_ru, city, area, price) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, 'test-prop-3', 'TP-TEST3', 'urban', 'active', 'Test', 'Тест', 'Desc', 'Описание', 'City', 500, 50000] + ) + + db.run('DELETE FROM properties WHERE id = ?', [id]) + + const prop = db.query('SELECT * FROM properties WHERE id = ?').get(id) + expect(prop).toBeNull() + }) + + test('should filter properties by type', () => { + const types = ['urban', 'agricultural', 'house', 'apartment'] + types.forEach((type, i) => { + db.run( + 'INSERT INTO properties (id, slug, reference, type, status, title_es, title_ru, description_es, description_ru, city, area, price) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [crypto.randomUUID(), `type-test-${i}`, `TP-TYPE-${i}`, type, 'active', 'Test', 'Тест', 'Desc', 'Описание', 'City', 500, 50000] + ) + }) + + const urbanProps = db.query('SELECT * FROM properties WHERE type = ?').all('urban') as any[] + expect(urbanProps.length).toBeGreaterThan(0) + expect(urbanProps.every(p => p.type === 'urban')).toBe(true) + }) +}) + +describe('Input Validation', () => { + test('should sanitize XSS in lead form', () => { + const sanitize = (str: string) => str ? String(str).replace(/[<>]/g, '') : '' + + const xssInput = '' + const sanitized = sanitize(xssInput) + + expect(sanitized).not.toContain('') + }) + + test('should validate email format', () => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + const validEmails = ['test@example.com', 'user@domain.es', 'admin@tenerifeprop.com'] + const invalidEmails = ['invalid', 'no@domain', '@nodomain.com', 'spaces in@email.com'] + + validEmails.forEach(email => { + expect(emailRegex.test(email)).toBe(true) + }) + + invalidEmails.forEach(email => { + expect(emailRegex.test(email)).toBe(false) + }) + }) + + test('should validate phone format', () => { + const phoneRegex = /^[\d\s\+\-\(\)]{7,20}$/ + + const validPhones = ['+34600123456', '600 123 456', '+34 600 123 456'] + const invalidPhones = ['123', 'abc', ''] + + validPhones.forEach(phone => { + expect(phoneRegex.test(phone)).toBe(true) + }) + + invalidPhones.forEach(phone => { + expect(phoneRegex.test(phone)).toBe(false) + }) + }) +}) + +describe('Rate Limiting', () => { + test('should have rate limit configuration', async () => { + const res = await fetch('http://localhost:8080/api/properties') + expect(res.status).toBe(200) + + // Check rate limit headers exist + // In production, these would be set by the rate limiter + }) +}) \ No newline at end of file