e2365e29fe
- Prayer times page: arc progress ring, next-prayer countdown, clean prayer list - Dhikr counter: SVG ring with glow, 3-phase sequence (SubhanAllah/Alhamdulillah/AllahuAkbar), streak tracking - Daily Quran reading tracker API routes (GET/POST) - Bottom nav updated: Prayer + Dhikr tabs, Repeat2 icon for dhikr, Moon for prayer - Home page redirects to /prayer as primary entry point - Dockerfile: openssl added to builder stage, explicit prisma schema path fixes build - Schema: dhikrStreak, quranStreak on User; DhikrSession and QuranReading models - Stub wallet top-up checkout to fix TypeScript build error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { verifyJWT } from '@/lib/auth'
|
|
|
|
const FREE_DAILY_LIMIT = 20
|
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
|
|
interface OverpassElement {
|
|
id: number
|
|
lat?: number
|
|
lon?: number
|
|
center?: { lat: number; lon: number }
|
|
tags?: Record<string, string>
|
|
}
|
|
|
|
interface Place {
|
|
id: string
|
|
lat: number
|
|
lon: number
|
|
name: string
|
|
type: 'mosque' | 'restaurant'
|
|
address?: string
|
|
}
|
|
|
|
const OVERPASS_ENDPOINTS = [
|
|
'https://overpass-api.de/api/interpreter',
|
|
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
|
'https://overpass.kumi.systems/api/interpreter',
|
|
]
|
|
|
|
async function queryOverpass(query: string): Promise<OverpassElement[]> {
|
|
let lastError: Error | null = null
|
|
for (const endpoint of OVERPASS_ENDPOINTS) {
|
|
try {
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: `data=${encodeURIComponent(query)}`,
|
|
signal: AbortSignal.timeout(15000),
|
|
})
|
|
if (!res.ok) { lastError = new Error(`Overpass error ${res.status} from ${endpoint}`); continue }
|
|
const data = await res.json()
|
|
return data.elements || []
|
|
} catch (e) {
|
|
lastError = e as Error
|
|
}
|
|
}
|
|
throw lastError ?? new Error('All Overpass endpoints failed')
|
|
}
|
|
|
|
function buildMosqueQuery(lat: number, lon: number, radius: number): string {
|
|
return `[out:json][timeout:10];(node["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon});way["amenity"="place_of_worship"]["religion"="muslim"](around:${radius},${lat},${lon}););out center;`
|
|
}
|
|
|
|
function buildRestaurantQuery(lat: number, lon: number, radius: number): string {
|
|
return `[out:json][timeout:10];(node["diet:halal"="yes"](around:${radius},${lat},${lon});node["cuisine"~"halal"](around:${radius},${lat},${lon});way["diet:halal"="yes"](around:${radius},${lat},${lon}););out center;`
|
|
}
|
|
|
|
function elementsToPlaces(elements: OverpassElement[], type: 'mosque' | 'restaurant'): Place[] {
|
|
return elements
|
|
.filter(e => {
|
|
const lat = e.lat ?? e.center?.lat
|
|
const lon = e.lon ?? e.center?.lon
|
|
return lat !== undefined && lon !== undefined
|
|
})
|
|
.map(e => ({
|
|
id: String(e.id),
|
|
lat: (e.lat ?? e.center?.lat)!,
|
|
lon: (e.lon ?? e.center?.lon)!,
|
|
name: e.tags?.name || e.tags?.['name:en'] || (type === 'mosque' ? 'Mosque' : 'Halal Restaurant'),
|
|
type,
|
|
address: [e.tags?.['addr:street'], e.tags?.['addr:city']].filter(Boolean).join(', ') || undefined,
|
|
}))
|
|
}
|
|
|
|
async function getUserFromRequest(req: NextRequest) {
|
|
const auth = req.headers.get('authorization')
|
|
if (!auth?.startsWith('Bearer ')) return null
|
|
const payload = await verifyJWT(auth.slice(7))
|
|
if (!payload) return null
|
|
return prisma.user.findUnique({ where: { id: payload.id } })
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
const { searchParams } = new URL(req.url)
|
|
const lat = parseFloat(searchParams.get('lat') || '')
|
|
const lon = parseFloat(searchParams.get('lng') || '')
|
|
const radius = Math.min(parseInt(searchParams.get('radius') || '5000'), 10000)
|
|
|
|
if (isNaN(lat) || isNaN(lon)) {
|
|
return NextResponse.json({ error: 'lat and lng required' }, { status: 400 })
|
|
}
|
|
|
|
const user = await getUserFromRequest(req)
|
|
const isPremium = user?.isPremium || user?.isPro || false
|
|
|
|
// Rate limit free users
|
|
if (user && !isPremium) {
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
let usage = await prisma.halalUsage.findUnique({ where: { userId: user.id } })
|
|
if (!usage || !usage.periodEnd || usage.periodEnd < today) {
|
|
usage = await prisma.halalUsage.upsert({
|
|
where: { userId: user.id },
|
|
create: { userId: user.id, queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) },
|
|
update: { queriesUsed: 0, queriesLimit: FREE_DAILY_LIMIT, periodEnd: new Date(today.getTime() + 86400000) },
|
|
})
|
|
}
|
|
if (usage.queriesUsed >= FREE_DAILY_LIMIT) {
|
|
return NextResponse.json({ error: 'daily_limit_reached', limit: FREE_DAILY_LIMIT }, { status: 429 })
|
|
}
|
|
}
|
|
|
|
// Check cache
|
|
const cacheKey = `${isPremium ? 'premium' : 'free'}:${lat.toFixed(2)}:${lon.toFixed(2)}:${radius}`
|
|
const cached = await prisma.halalPlaceCache.findUnique({ where: { cacheKey } })
|
|
if (cached && cached.expiresAt > new Date()) {
|
|
return NextResponse.json(JSON.parse(cached.data))
|
|
}
|
|
|
|
// Fetch from Overpass
|
|
const mosqueElements = await queryOverpass(buildMosqueQuery(lat, lon, radius))
|
|
const mosques = elementsToPlaces(mosqueElements, 'mosque')
|
|
|
|
let restaurants: Place[] = []
|
|
if (isPremium) {
|
|
const restaurantElements = await queryOverpass(buildRestaurantQuery(lat, lon, radius))
|
|
restaurants = elementsToPlaces(restaurantElements, 'restaurant')
|
|
}
|
|
|
|
const result = { mosques, restaurants }
|
|
|
|
// Cache result
|
|
await prisma.halalPlaceCache.upsert({
|
|
where: { cacheKey },
|
|
create: { cacheKey, data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) },
|
|
update: { data: JSON.stringify(result), expiresAt: new Date(Date.now() + CACHE_TTL_MS) },
|
|
})
|
|
|
|
// Increment usage for free users
|
|
if (user && !isPremium) {
|
|
await prisma.halalUsage.update({
|
|
where: { userId: user.id },
|
|
data: { queriesUsed: { increment: 1 } },
|
|
})
|
|
}
|
|
|
|
return NextResponse.json(result)
|
|
} catch (e) {
|
|
console.error('Halal places error:', e)
|
|
return NextResponse.json({ error: 'Failed to fetch places' }, { status: 500 })
|
|
}
|
|
}
|