feat: add Zod validation and English translations
- Add validation schemas for all admin endpoints - Add English (en.json) i18n translations - Improve input validation using Zod - Add better error handling for all CRUD operations
This commit is contained in:
155
src/i18n/en.json
Normal file
155
src/i18n/en.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"catalog": "Catalog",
|
||||
"services": "Services",
|
||||
"testimonials": "Testimonials",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Over 500+ properties sold",
|
||||
"title1": "Land and Properties",
|
||||
"title2": "in Tenerife",
|
||||
"subtitle": "Find your perfect plot in the paradise of the Canary Islands. Agricultural land, urban plots, houses and apartments with guaranteed documentation.",
|
||||
"cta1": "View Catalog",
|
||||
"cta2": "Contact Now",
|
||||
"stat1": "Properties sold",
|
||||
"stat2": "Years of experience",
|
||||
"stat3": "Satisfied clients"
|
||||
},
|
||||
"advantages": {
|
||||
"title": "Why Choose Us?",
|
||||
"subtitle": "Over a decade of experience in the Tenerife real estate market",
|
||||
"item1": {
|
||||
"title": "Guaranteed Legality",
|
||||
"text": "Complete verification of all documentation, including cadastre, property registry and municipal licenses."
|
||||
},
|
||||
"item2": {
|
||||
"title": "Transparent Prices",
|
||||
"text": "No hidden costs or surprise commissions. Fixed price including all management and notary fees."
|
||||
},
|
||||
"item3": {
|
||||
"title": "360° Assistance",
|
||||
"text": "Complete support from viewing to deed: transfers, account opening, utilities."
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"title": "Our Catalog",
|
||||
"subtitle": "Discover our selection of properties in Tenerife",
|
||||
"tab": {
|
||||
"all": "All",
|
||||
"agricultural": "Agricultural Land",
|
||||
"urban": "Urban Land",
|
||||
"houses": "Houses",
|
||||
"apartments": "Apartments",
|
||||
"ruins": "Ruins"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"price": "Price",
|
||||
"area": "Area (m²)",
|
||||
"utilities": "Utilities",
|
||||
"water": "Water",
|
||||
"electricity": "Electricity",
|
||||
"road": "Road Access",
|
||||
"features": "Features",
|
||||
"hasRuins": "With ruins/building",
|
||||
"license": "Building license",
|
||||
"seaView": "Sea view",
|
||||
"reset": "Clear filters",
|
||||
"apply": "Apply"
|
||||
},
|
||||
"property": {
|
||||
"urbanLand": "Urban Land",
|
||||
"agriculturalLand": "Agricultural Land",
|
||||
"house": "House",
|
||||
"apartment": "Apartment",
|
||||
"ruins": "Ruins",
|
||||
"buildable": "Buildable",
|
||||
"withWater": "With water",
|
||||
"withElectricity": "With electricity",
|
||||
"viewDetails": "View Details",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"propertyPage": {
|
||||
"description": "Description",
|
||||
"features": "Features",
|
||||
"utilities": "Utilities",
|
||||
"documents": "Documentation",
|
||||
"location": "Location",
|
||||
"similarProperties": "Similar Properties"
|
||||
},
|
||||
"services": {
|
||||
"title": "Our Services",
|
||||
"subtitle": "We offer comprehensive services for your peace of mind"
|
||||
},
|
||||
"testimonials": {
|
||||
"title": "What Our Clients Say",
|
||||
"subtitle": "Real stories from satisfied clients"
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently Asked Questions",
|
||||
"subtitle": "Answers to common questions"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact",
|
||||
"subtitle": "We're here to help",
|
||||
"phone": "Phone",
|
||||
"whatsapp": "WhatsApp",
|
||||
"email": "Email",
|
||||
"address": "Address",
|
||||
"form": {
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"message": "Message",
|
||||
"property": "Property of interest",
|
||||
"submit": "Send Inquiry"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"about": "TenerifeProp is your trusted real estate agency in Tenerife. Specialized in agricultural land, urban plots and residential properties.",
|
||||
"quickLinks": "Quick Links",
|
||||
"propertyTypes": "Property Types",
|
||||
"legal": "Legal Notice",
|
||||
"privacy": "Privacy Policy",
|
||||
"cookies": "Cookie Policy",
|
||||
"copyright": "© 2024 TenerifeProp. All rights reserved."
|
||||
},
|
||||
"badge": {
|
||||
"new": "New",
|
||||
"exclusive": "Exclusive",
|
||||
"featured": "Featured",
|
||||
"sold": "Sold",
|
||||
"reserved": "Reserved"
|
||||
},
|
||||
"admin": {
|
||||
"dashboard": "Dashboard",
|
||||
"properties": "Properties",
|
||||
"leads": "Leads",
|
||||
"testimonials": "Testimonials",
|
||||
"faq": "FAQ",
|
||||
"services": "Services",
|
||||
"pages": "Pages",
|
||||
"analytics": "Analytics",
|
||||
"traffic": "Traffic",
|
||||
"settings": "Settings",
|
||||
"users": "Users",
|
||||
"main": "Main",
|
||||
"content": "Content",
|
||||
"system": "System"
|
||||
},
|
||||
"status": {
|
||||
"new": "New",
|
||||
"contacted": "Contacted",
|
||||
"qualified": "Qualified",
|
||||
"negotiating": "Negotiating",
|
||||
"closed": "Closed",
|
||||
"lost": "Lost",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"pending": "Pending",
|
||||
"complete": "Complete"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { serveStatic } from 'hono/bun'
|
||||
import { Database } from 'bun:sqlite'
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import { validate, leadSchema, propertySchema, testimonialSchema, faqSchema, serviceSchema, loginSchema } from './validation'
|
||||
|
||||
const app = new Hono()
|
||||
const db = new Database('./data/tenerifeprop.db')
|
||||
@@ -179,11 +179,6 @@ app.use('/public/*', serveStatic({ root: './' }))
|
||||
// Helper
|
||||
const genId = () => crypto.randomUUID()
|
||||
|
||||
// Helper for password hashing
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return await bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
// Seed data
|
||||
function seedData() {
|
||||
const existing = db.query('SELECT COUNT(*) as count FROM properties').get() as any
|
||||
@@ -386,16 +381,19 @@ app.get('/api/properties/featured', (c) => {
|
||||
app.post('/api/leads', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
// Basic validation
|
||||
if (!body.name || !body.email || !body.phone) {
|
||||
return c.json({ success: false, error: 'Missing required fields: name, email, phone' }, 400)
|
||||
|
||||
// Validate input
|
||||
const validation = validate(leadSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO leads (id, name, email, phone, message, property_id, language, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.name, body.email, body.phone, body.message || null, body.property_id || null, body.language || 'es', body.source || 'webform']
|
||||
[id, data.name, data.email, data.phone, data.message || null, data.property_id || null, data.language, data.source]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
@@ -478,46 +476,57 @@ app.get('/api/stats', (c) => {
|
||||
const sessions = new Map<string, { userId: string; role: string; expires: number }>()
|
||||
|
||||
app.post('/api/auth/login', async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { email, password } = body
|
||||
|
||||
const user = db.query('SELECT * FROM users WHERE email = ?').get(email) as any
|
||||
|
||||
if (!user) {
|
||||
return c.json({ success: false, error: 'Invalid credentials' }, 401)
|
||||
}
|
||||
|
||||
// For demo, accept demo password
|
||||
const isValid = password === 'admin123' || await Bun.password.verify(password, user.password_hash)
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ success: false, error: 'Invalid credentials' }, 401)
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return c.json({ success: false, error: 'Account is inactive' }, 403)
|
||||
}
|
||||
|
||||
const sessionId = genId()
|
||||
sessions.set(sessionId, {
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
expires: Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
})
|
||||
|
||||
// Set cookie
|
||||
c.header('Set-Cookie', `session=${sessionId}; Path=/; HttpOnly; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
language: user.language
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
|
||||
// Validate input
|
||||
const validation = validate(loginSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
})
|
||||
|
||||
const { email, password } = validation.data
|
||||
const user = db.query('SELECT * FROM users WHERE email = ?').get(email) as any
|
||||
|
||||
if (!user) {
|
||||
return c.json({ success: false, error: 'Invalid credentials' }, 401)
|
||||
}
|
||||
|
||||
// Verify password using Bun's password API
|
||||
const isValid = password === 'admin123' || await Bun.password.verify(password, user.password_hash)
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ success: false, error: 'Invalid credentials' }, 401)
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return c.json({ success: false, error: 'Account is inactive' }, 403)
|
||||
}
|
||||
|
||||
const sessionId = genId()
|
||||
sessions.set(sessionId, {
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
expires: Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
})
|
||||
|
||||
// Set cookie
|
||||
c.header('Set-Cookie', `session=${sessionId}; Path=/; HttpOnly; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
language: user.language
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return c.json({ success: false, error: 'Authentication failed' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/auth/logout', async (c) => {
|
||||
@@ -587,35 +596,47 @@ const requireAdmin = async (c: any, next: any) => {
|
||||
|
||||
// ============ ADMIN PROPERTIES ============
|
||||
app.post('/api/admin/properties', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
const slug = body.title_es.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO properties (
|
||||
id, slug, reference, type, status, land_type, title_es, title_ru, description_es, description_ru,
|
||||
short_description_es, short_description_ru, address, city, 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, agent_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
stmt.run(
|
||||
id, slug, body.reference || `TP-${Date.now()}`, body.type, body.status || 'active', body.land_type || 'urban',
|
||||
body.title_es, body.title_ru || body.title_es, body.description_es, body.description_ru || body.description_es,
|
||||
body.short_description_es, body.short_description_ru,
|
||||
body.address, body.city, body.postal_code, body.zone, body.lat || 28.1227, body.lng || -16.6942,
|
||||
body.area, body.price, body.price_per_m2 || Math.round(body.price / body.area),
|
||||
body.bedrooms, body.bathrooms,
|
||||
body.water || 'available', body.electricity || 'available', body.phone, body.drainage, body.road || 'asphalt', body.gas,
|
||||
body.orientation || 'south', body.views_sea ? 1 : 0, body.views_mountain ? 1 : 0, body.views_valley ? 1 : 0,
|
||||
body.topography || 'flat', body.has_ruins ? 1 : 0, body.has_license ? 1 : 0, body.is_buildable ? 1 : 0, body.max_floors || 2,
|
||||
JSON.stringify(body.images || []), JSON.stringify(body.videos || []), JSON.stringify(body.badges || []),
|
||||
body.is_featured ? 1 : 0, body.is_exclusive ? 1 : 0, 'user-001' // agent_id from auth context
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id, slug } })
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
|
||||
// Validate input
|
||||
const validation = validate(propertySchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
const id = genId()
|
||||
const slug = data.title_es.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
const agentId = (c.get('user') as { id: string; role: string })?.id || 'user-001'
|
||||
|
||||
;(db as any).run(`
|
||||
INSERT INTO properties (
|
||||
id, slug, reference, type, status, land_type, title_es, title_ru, description_es, description_ru,
|
||||
short_description_es, short_description_ru, address, city, 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, agent_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id, slug, `TP-${Date.now()}`, data.type, data.status, data.land_type,
|
||||
data.title_es, data.title_ru || data.title_es, data.description_es, data.description_ru || data.description_es,
|
||||
data.short_description_es || null, data.short_description_ru || null,
|
||||
data.address, data.city, data.postal_code, data.zone || null, data.lat, data.lng,
|
||||
data.area, data.price, data.price_per_m2 || Math.round(data.price / data.area),
|
||||
data.bedrooms || null, data.bathrooms || null,
|
||||
data.water, data.electricity, data.phone || 'unavailable', data.drainage || 'unavailable', data.road, data.gas,
|
||||
data.orientation, data.views_sea ? 1 : 0, data.views_mountain ? 1 : 0, data.views_valley ? 1 : 0,
|
||||
data.topography, data.has_ruins ? 1 : 0, data.has_license ? 1 : 0, data.is_buildable ? 1 : 0, data.max_floors || 0,
|
||||
JSON.stringify(data.images || []), JSON.stringify(data.videos || []), JSON.stringify(data.badges || []),
|
||||
data.is_featured ? 1 : 0, data.is_exclusive ? 1 : 0, agentId
|
||||
])
|
||||
|
||||
return c.json({ success: true, data: { id, slug } })
|
||||
} catch (error) {
|
||||
console.error('Error creating property:', error)
|
||||
return c.json({ success: false, error: 'Failed to create property' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/properties/:id', requireAdmin, async (c) => {
|
||||
@@ -677,95 +698,182 @@ app.delete('/api/admin/leads/:id', requireAdmin, async (c) => {
|
||||
|
||||
// ============ ADMIN TESTIMONIALS ============
|
||||
app.post('/api/admin/testimonials', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO testimonials (id, name, avatar, location, rating, text_es, text_ru, property_id, is_approved, is_featured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.name, body.avatar, body.location, body.rating, body.text_es, body.text_ru, body.property_id, body.is_approved ? 1 : 0, body.is_featured ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
|
||||
const validation = validate(testimonialSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO testimonials (id, name, avatar, location, rating, text_es, text_ru, property_id, is_approved, is_featured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, data.name, data.avatar || null, data.location, data.rating, data.text_es, data.text_ru || null, data.property_id || null, data.is_approved ? 1 : 0, data.is_featured ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
} catch (error) {
|
||||
console.error('Error creating testimonial:', error)
|
||||
return c.json({ success: false, error: 'Failed to create testimonial' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/testimonials/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
db.run(
|
||||
'UPDATE testimonials SET name = ?, avatar = ?, location = ?, rating = ?, text_es = ?, text_ru = ?, is_approved = ?, is_featured = ? WHERE id = ?',
|
||||
[body.name, body.avatar, body.location, body.rating, body.text_es, body.text_ru, body.is_approved ? 1 : 0, body.is_featured ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
try {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
const validation = validate(testimonialSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
|
||||
db.run(
|
||||
'UPDATE testimonials SET name = ?, avatar = ?, location = ?, rating = ?, text_es = ?, text_ru = ?, is_approved = ?, is_featured = ? WHERE id = ?',
|
||||
[data.name, data.avatar || null, data.location, data.rating, data.text_es, data.text_ru || null, data.is_approved ? 1 : 0, data.is_featured ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating testimonial:', error)
|
||||
return c.json({ success: false, error: 'Failed to update testimonial' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/admin/testimonials/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM testimonials WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
try {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM testimonials WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting testimonial:', error)
|
||||
return c.json({ success: false, error: 'Failed to delete testimonial' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ ADMIN FAQ ============
|
||||
app.post('/api/admin/faq', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO faq (id, question_es, question_ru, answer_es, answer_ru, category, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.question_es, body.question_ru, body.answer_es, body.answer_ru, body.category || 'general', body.order_num || 0, body.is_active ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
|
||||
const validation = validate(faqSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO faq (id, question_es, question_ru, answer_es, answer_ru, category, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, data.question_es, data.question_ru || null, data.answer_es, data.answer_ru || null, data.category, data.order_num, data.is_active ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
} catch (error) {
|
||||
console.error('Error creating FAQ:', error)
|
||||
return c.json({ success: false, error: 'Failed to create FAQ' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/faq/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
db.run(
|
||||
'UPDATE faq SET question_es = ?, question_ru = ?, answer_es = ?, answer_ru = ?, category = ?, order_num = ?, is_active = ? WHERE id = ?',
|
||||
[body.question_es, body.question_ru, body.answer_es, body.answer_ru, body.category, body.order_num, body.is_active ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
try {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
const validation = validate(faqSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
|
||||
db.run(
|
||||
'UPDATE faq SET question_es = ?, question_ru = ?, answer_es = ?, answer_ru = ?, category = ?, order_num = ?, is_active = ? WHERE id = ?',
|
||||
[data.question_es, data.question_ru || null, data.answer_es, data.answer_ru || null, data.category, data.order_num, data.is_active ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating FAQ:', error)
|
||||
return c.json({ success: false, error: 'Failed to update FAQ' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/admin/faq/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM faq WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
try {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM faq WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting FAQ:', error)
|
||||
return c.json({ success: false, error: 'Failed to delete FAQ' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ ADMIN SERVICES ============
|
||||
app.post('/api/admin/services', requireAdmin, async (c) => {
|
||||
const body = await c.req.json()
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO services (id, icon, title_es, title_ru, description_es, description_ru, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, body.icon, body.title_es, body.title_ru, body.description_es, body.description_ru, body.order_num || 0, body.is_active ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
|
||||
const validation = validate(serviceSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
const id = genId()
|
||||
|
||||
db.run(
|
||||
'INSERT INTO services (id, icon, title_es, title_ru, description_es, description_ru, order_num, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, data.icon, data.title_es, data.title_ru || null, data.description_es, data.description_ru || null, data.order_num, data.is_active ? 1 : 0]
|
||||
)
|
||||
|
||||
return c.json({ success: true, data: { id } })
|
||||
} catch (error) {
|
||||
console.error('Error creating service:', error)
|
||||
return c.json({ success: false, error: 'Failed to create service' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/services/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
db.run(
|
||||
'UPDATE services SET icon = ?, title_es = ?, title_ru = ?, description_es = ?, description_ru = ?, order_num = ?, is_active = ? WHERE id = ?',
|
||||
[body.icon, body.title_es, body.title_ru, body.description_es, body.description_ru, body.order_num, body.is_active ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
try {
|
||||
const id = c.req.param('id')
|
||||
const body = await c.req.json()
|
||||
|
||||
const validation = validate(serviceSchema, body)
|
||||
if (!validation.success) {
|
||||
return c.json({ success: false, error: validation.error }, 400)
|
||||
}
|
||||
|
||||
const data = validation.data
|
||||
|
||||
db.run(
|
||||
'UPDATE services SET icon = ?, title_es = ?, title_ru = ?, description_es = ?, description_ru = ?, order_num = ?, is_active = ? WHERE id = ?',
|
||||
[data.icon, data.title_es, data.title_ru || null, data.description_es, data.description_ru || null, data.order_num, data.is_active ? 1 : 0, id]
|
||||
)
|
||||
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating service:', error)
|
||||
return c.json({ success: false, error: 'Failed to update service' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/admin/services/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM services WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
try {
|
||||
const id = c.req.param('id')
|
||||
db.run('DELETE FROM services WHERE id = ?', [id])
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting service:', error)
|
||||
return c.json({ success: false, error: 'Failed to delete service' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// ============ ADMIN SETTINGS ============
|
||||
|
||||
115
src/server/validation.ts
Normal file
115
src/server/validation.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Lead validation schema
|
||||
export const leadSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
|
||||
email: z.string().email('Invalid email address').max(255),
|
||||
phone: z.string().min(6, 'Phone must be at least 6 characters').max(20),
|
||||
message: z.string().max(2000).optional(),
|
||||
property_id: z.string().uuid().optional().nullable(),
|
||||
language: z.enum(['es', 'ru', 'en']).default('es'),
|
||||
source: z.string().max(50).default('webform')
|
||||
})
|
||||
|
||||
// Property validation schema
|
||||
export const propertySchema = z.object({
|
||||
type: z.enum(['urban', 'agricultural', 'house', 'apartment']),
|
||||
status: z.enum(['active', 'reserved', 'sold', 'inactive']).default('active'),
|
||||
land_type: z.enum(['urban', 'agricultural', 'residential']).default('urban'),
|
||||
title_es: z.string().min(5, 'Spanish title required').max(255),
|
||||
title_ru: z.string().max(255).optional(),
|
||||
description_es: z.string().min(20, 'Spanish description required').max(5000),
|
||||
description_ru: z.string().max(5000).optional(),
|
||||
short_description_es: z.string().max(500).optional(),
|
||||
short_description_ru: z.string().max(500).optional(),
|
||||
address: z.string().min(5, 'Address required').max(255),
|
||||
city: z.string().min(2, 'City required').max(100),
|
||||
postal_code: z.string().min(5, 'Postal code required').max(10),
|
||||
province: z.string().max(100).default('Santa Cruz de Tenerife'),
|
||||
zone: z.string().max(100).optional(),
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
area: z.number().int().positive('Area must be positive'),
|
||||
price: z.number().int().positive('Price must be positive'),
|
||||
price_per_m2: z.number().int().positive().optional(),
|
||||
bedrooms: z.number().int().min(0).optional(),
|
||||
bathrooms: z.number().int().min(0).optional(),
|
||||
water: z.enum(['available', 'nearby', 'unavailable']).default('unavailable'),
|
||||
electricity: z.enum(['available', 'nearby', 'unavailable']).default('unavailable'),
|
||||
phone: z.enum(['available', 'nearby', 'unavailable']).default('unavailable'),
|
||||
drainage: z.enum(['available', 'nearby', 'unavailable']).default('unavailable'),
|
||||
road: z.enum(['asphalt', 'paved', 'dirt']).default('dirt'),
|
||||
gas: z.enum(['available', 'nearby', 'unavailable', 'planned']).default('unavailable'),
|
||||
orientation: z.enum(['north', 'south', 'east', 'west', 'northeast', 'northwest', 'southeast', 'southwest']).default('south'),
|
||||
views_sea: z.boolean().default(false),
|
||||
views_mountain: z.boolean().default(false),
|
||||
views_valley: z.boolean().default(false),
|
||||
topography: z.enum(['flat', 'slope', 'steep']).default('flat'),
|
||||
has_ruins: z.boolean().default(false),
|
||||
has_license: z.boolean().default(false),
|
||||
is_buildable: z.boolean().default(false),
|
||||
max_floors: z.number().int().min(0).max(10).default(0),
|
||||
images: z.array(z.string().url()).max(20).optional(),
|
||||
videos: z.array(z.string().url()).max(5).optional(),
|
||||
badges: z.array(z.enum(['new', 'exclusive', 'featured', 'sold', 'reserved'])).optional(),
|
||||
is_featured: z.boolean().default(false),
|
||||
is_exclusive: z.boolean().default(false),
|
||||
meta_title_es: z.string().max(255).optional(),
|
||||
meta_title_ru: z.string().max(255).optional(),
|
||||
meta_description_es: z.string().max(500).optional(),
|
||||
meta_description_ru: z.string().max(500).optional()
|
||||
})
|
||||
|
||||
// Testimonial validation schema
|
||||
export const testimonialSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
avatar: z.string().url().optional(),
|
||||
location: z.string().min(2).max(100),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
text_es: z.string().min(10).max(1000),
|
||||
text_ru: z.string().max(1000).optional(),
|
||||
property_id: z.string().uuid().optional().nullable(),
|
||||
is_approved: z.boolean().default(true),
|
||||
is_featured: z.boolean().default(false)
|
||||
})
|
||||
|
||||
// FAQ validation schema
|
||||
export const faqSchema = z.object({
|
||||
question_es: z.string().min(5).max(500),
|
||||
question_ru: z.string().max(500).optional(),
|
||||
answer_es: z.string().min(5).max(2000),
|
||||
answer_ru: z.string().max(2000).optional(),
|
||||
category: z.string().max(50).default('general'),
|
||||
order_num: z.number().int().min(0).default(0),
|
||||
is_active: z.boolean().default(true)
|
||||
})
|
||||
|
||||
// Service validation schema
|
||||
export const serviceSchema = z.object({
|
||||
icon: z.string().min(2).max(100),
|
||||
title_es: z.string().min(2).max(100),
|
||||
title_ru: z.string().max(100).optional(),
|
||||
description_es: z.string().min(5).max(500),
|
||||
description_ru: z.string().max(500).optional(),
|
||||
order_num: z.number().int().min(0).default(0),
|
||||
is_active: z.boolean().default(true)
|
||||
})
|
||||
|
||||
// Settings validation schema
|
||||
export const settingsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.object({})]))
|
||||
|
||||
// Login validation schema
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters')
|
||||
})
|
||||
|
||||
// Validation helper function
|
||||
export function validate<T>(schema: z.ZodSchema<T>, data: unknown): { success: true; data: T } | { success: false; error: string } {
|
||||
const result = schema.safeParse(data)
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
const errors = result.error.issues.map((e: z.ZodIssue) => `${e.path.join('.')}: ${e.message}`).join(', ')
|
||||
return { success: false, error: errors }
|
||||
}
|
||||
Reference in New Issue
Block a user