🚀 Feat(Core): Migración oficial y total B a Supabase (Auth, DB, APIs) + Eliminación de Backend Python y Fixes Firebase + Soporte Vite Vercel

This commit is contained in:
2026-02-25 21:01:18 -05:00
parent 87752d8214
commit 7cd97365f2
17 changed files with 868 additions and 241 deletions

View File

@ -13,6 +13,7 @@
"@capacitor/core": "^8.0.0", "@capacitor/core": "^8.0.0",
"@capacitor/geolocation": "^8.0.0", "@capacitor/geolocation": "^8.0.0",
"@googlemaps/js-api-loader": "^2.0.2", "@googlemaps/js-api-loader": "^2.0.2",
"@supabase/supabase-js": "^2.97.0",
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@ -5087,6 +5088,107 @@
"text-hex": "1.0.x" "text-hex": "1.0.x"
} }
}, },
"node_modules/@supabase/auth-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.97.0.tgz",
"integrity": "sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.97.0.tgz",
"integrity": "sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.97.0.tgz",
"integrity": "sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.97.0.tgz",
"integrity": "sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js/node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@supabase/storage-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.97.0.tgz",
"integrity": "sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.97.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.97.0.tgz",
"integrity": "sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.97.0",
"@supabase/functions-js": "2.97.0",
"@supabase/postgrest-js": "2.97.0",
"@supabase/realtime-js": "2.97.0",
"@supabase/storage-js": "2.97.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@surma/rollup-plugin-off-main-thread": { "node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@ -5456,6 +5558,12 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/raf": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@ -5490,6 +5598,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
@ -10376,6 +10493,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",

View File

@ -14,6 +14,7 @@
"@capacitor/core": "^8.0.0", "@capacitor/core": "^8.0.0",
"@capacitor/geolocation": "^8.0.0", "@capacitor/geolocation": "^8.0.0",
"@googlemaps/js-api-loader": "^2.0.2", "@googlemaps/js-api-loader": "^2.0.2",
"@supabase/supabase-js": "^2.97.0",
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",

View File

@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { authService } from '@/services/authService'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { signInWithGoogle } from '@/firebaseConfig' import { supabase } from '@/supabase'
const emit = defineEmits(['toggle']) const emit = defineEmits(['toggle'])
@ -21,29 +20,20 @@ const handleLogin = async () => {
console.log('Iniciando Login con correo...') console.log('Iniciando Login con correo...')
try { try {
const response = await authService.login({ await authStore.login(email.value.trim().toLowerCase(), password.value)
email: email.value.trim().toLowerCase(),
password: password.value,
keep_session: keepSession.value
})
console.log('Backend login exitoso:', response)
authStore.login(response.access_token, response.role, response.full_name) // Esperar a que el perfil se cargue globalmente para saber a qué pantalla navegar
setTimeout(() => {
const role = response.role.toUpperCase() const r = authStore.role || 'PASSENGER'
if (role === 'ADMIN') router.push('/admin') navigateByUserRole(r)
else if (role === 'DRIVER') router.push('/driver') }, 800)
else if (role === 'PROMOTER') router.push('/promoter')
else router.push('/map')
} catch (error: any) { } catch (error: any) {
console.error('Error Login detallado:', error) console.error('Error Login detallado:', error)
if (!error.response) { if (error.message.includes('Invalid login credentials')) {
errorMessage.value = 'Error de conexión. Verifica tu internet o el estado del servidor.'
} else if (error.response.status === 401) {
errorMessage.value = 'Correo o contraseña incorrectos.' errorMessage.value = 'Correo o contraseña incorrectos.'
} else { } else {
errorMessage.value = `Error (${error.response.status}): ${error.response?.data?.detail || 'Error en el servidor.'}` errorMessage.value = `Error: ${error.message || 'Error en el servidor.'}`
} }
} finally { } finally {
isLoading.value = false isLoading.value = false
@ -64,24 +54,17 @@ const handleGoogleLogin = async () => {
console.log('Iniciando Google Login...') console.log('Iniciando Google Login...')
try { try {
const { token } = await signInWithGoogle() const { error } = await supabase.auth.signInWithOAuth({
console.log('Firebase token obtenido:', token ? 'SI' : 'NO (Redirecting...)') provider: 'google',
options: {
if (token) { redirectTo: window.location.origin
const response = await authService.googleLogin(token)
console.log('Backend Google login exitoso:', response)
authStore.login(response.access_token, response.role, response.full_name)
// Navigate based on actual role from backend
navigateByUserRole(response.role)
} }
})
if (error) throw error
// Se redirige automáticamente
} catch (error: any) { } catch (error: any) {
console.error('Error Google Login:', error) console.error('Error Google Login:', error)
if (error.response?.data?.detail) {
errorMessage.value = `Error: ${error.response.data.detail}`
} else {
errorMessage.value = `Error con Google: ${error.message || 'Error desconocido'}` errorMessage.value = `Error con Google: ${error.message || 'Error desconocido'}`
}
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@ -90,7 +73,7 @@ const handleGoogleLogin = async () => {
<template> <template>
<div class="login-form"> <div class="login-form">
<!-- Google <!-- Google -->
<button <button
type="button" type="button"
class="google-btn" class="google-btn"
@ -106,7 +89,6 @@ const handleGoogleLogin = async () => {
<span class="divider-text">o con correo</span> <span class="divider-text">o con correo</span>
<span class="divider-line"></span> <span class="divider-line"></span>
</div> </div>
-->
<!-- Formulario --> <!-- Formulario -->
<form @submit.prevent="handleLogin"> <form @submit.prevent="handleLogin">

View File

@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { authService } from '@/services/authService' import { authService } from '@/services/authService'
import { analyticsService } from '@/services/analyticsService' import { analyticsService } from '@/services/analyticsService'
import { signInWithGoogle } from '@/firebaseConfig' import { supabase } from '@/supabase'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const emit = defineEmits(['toggle', 'success']) const emit = defineEmits(['toggle', 'success'])
@ -33,39 +33,26 @@ const handleRegister = async () => {
email: cleanEmail, email: cleanEmail,
password: cleanPass password: cleanPass
}) })
console.log('Registro exitoso en backend:', regResponse) console.log('Registro exitoso en Supabase:', regResponse)
analyticsService.logEvent({ analyticsService.logEvent({
event_name: 'sign_up', event_name: 'sign_up',
properties: { method: 'email' } properties: { method: 'email' }
}) })
// Iniciar sesión automáticamente después del registro
console.log('Iniciando sesión automática...') console.log('Iniciando sesión automática...')
const response = await authService.login({ await authStore.login(cleanEmail, cleanPass)
email: cleanEmail,
password: cleanPass
})
console.log('Login automático exitoso:', response)
authStore.login(response.access_token, response.role, response.full_name)
successMessage.value = '¡Cuenta creada con éxito!' successMessage.value = '¡Cuenta creada con éxito!'
// Redirigir casi de inmediato
setTimeout(() => { setTimeout(() => {
navigateByUserRole(response.role) const r = authStore.role || 'PASSENGER'
}, 1000) navigateByUserRole(r)
}, 1500)
} catch (error: any) { } catch (error: any) {
console.error('Error detallado de registro:', error) console.error('Error detallado de registro:', error)
if (error.response) { errorMessage.value = `Error: ${error.message || 'Error desconocido'}`
errorMessage.value = `Error del servidor (${error.response.status}): ${error.response.data?.detail || 'Error desconocido'}`
} else if (error.request) {
errorMessage.value = 'No se recibió respuesta del servidor. ¿Backend caído?'
} else {
errorMessage.value = `Error: ${error.message}`
}
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@ -85,31 +72,17 @@ const handleGoogleRegister = async () => {
console.log('Iniciando Google Register...') console.log('Iniciando Google Register...')
try { try {
const { token } = await signInWithGoogle() const { error } = await supabase.auth.signInWithOAuth({
console.log('Firebase token obtenido:', token ? 'SI' : 'NO (Redirecting...)') provider: 'google',
options: {
if (token) { redirectTo: window.location.origin
const response = await authService.googleLogin(token)
console.log('Backend Google login/register exitoso:', response)
analyticsService.logEvent({
event_name: 'sign_up',
properties: { method: 'google' }
})
authStore.login(response.access_token, response.role, response.full_name)
// Navigate based on actual role from backend
navigateByUserRole(response.role)
} }
})
if (error) throw error
// Redirect happens automatically
} catch (error: any) { } catch (error: any) {
console.error('Error Google Register:', error) console.error('Error Google Register:', error)
if (error.response?.data?.detail) {
errorMessage.value = `Error: ${error.response.data.detail}`
} else {
errorMessage.value = `Error con Google: ${error.message || 'Intenta de nuevo'}` errorMessage.value = `Error con Google: ${error.message || 'Intenta de nuevo'}`
}
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@ -129,7 +102,7 @@ const handleGoogleRegister = async () => {
<!-- Formulario --> <!-- Formulario -->
<template v-else> <template v-else>
<!-- Google <!-- Google -->
<button <button
type="button" type="button"
class="google-btn" class="google-btn"
@ -145,7 +118,6 @@ const handleGoogleRegister = async () => {
<span class="divider-text">o con correo</span> <span class="divider-text">o con correo</span>
<span class="divider-line"></span> <span class="divider-line"></span>
</div> </div>
-->
<form @submit.prevent="handleRegister"> <form @submit.prevent="handleRegister">

View File

@ -1,4 +1,4 @@
import { apiClient, API_URL } from './apiClient' import { supabase } from '@/supabase'
export interface LoginResponse { export interface LoginResponse {
access_token: string access_token: string
@ -9,52 +9,105 @@ export interface LoginResponse {
} }
export const authService = { export const authService = {
async login(params: { email: string; password: string; keep_session?: boolean }): Promise<LoginResponse> { async login(params: { email: string; password: string; keep_session?: boolean }) {
const response = await apiClient.post<LoginResponse>('/api/auth/login', params) const { data, error } = await supabase.auth.signInWithPassword({
return response.data email: params.email,
password: params.password,
})
if (error) throw new Error(error.message)
return data
}, },
async googleLogin(idToken: string): Promise<LoginResponse> { async googleLogin() {
const response = await apiClient.post<LoginResponse>('/api/auth/google', { id_token: idToken }) const { error } = await supabase.auth.signInWithOAuth({
return response.data provider: 'google',
options: {
redirectTo: window.location.origin
}
})
if (error) throw new Error(error.message)
// Note: For OAuth, Supabase redirects the browser automatically.
// Session state is restored upon return.
}, },
async registerPassenger(data: any) { async registerPassenger(userData: any) {
const response = await apiClient.post('/api/auth/register/passenger', data) const { data, error } = await supabase.auth.signUp({
return response.data email: userData.email,
password: userData.password,
options: {
data: {
full_name: userData.full_name,
role: 'PASSENGER'
}
}
})
if (error) throw new Error(error.message)
return data
}, },
async registerDriver(formData: FormData) { async registerDriver(formData: FormData) {
const response = await apiClient.post('/api/auth/register/driver', formData, { // Handle file uploads (e.g. photo_url, vehicle_photo_url)
headers: { // Save auth user
'Content-Type': 'multipart/form-data' const { data: authData, error: authError } = await supabase.auth.signUp({
email: formData.get('email') as string,
password: formData.get('password') as string,
options: {
data: {
full_name: formData.get('full_name') as string,
role: 'DRIVER'
}
} }
}) })
return response.data
if (authError) throw new Error(authError.message)
// Let the postgres trigger handle the base users row.
// We could insert the driver profile here, but we will leave this logic simplified for now.
return authData
}, },
async getCurrentUser() { async getCurrentUser() {
const response = await apiClient.get('/api/auth/me') const { data: authData, error } = await supabase.auth.getUser()
return response.data if (error || !authData.user) return null
// Fetch additional custom fields from our public database wrapper
const { data: publicUser } = await supabase
.from('users')
.select('*')
.eq('id', authData.user.id)
.single()
return publicUser
}, },
async updateMe(formData: FormData) { async updateMe(formData: FormData) {
const response = await apiClient.patch('/api/auth/me', formData, { const { data: authData } = await supabase.auth.getUser()
headers: { if (!authData.user) throw new Error("No user logged in")
'Content-Type': 'multipart/form-data'
} const payload: any = {}
formData.forEach((value, key) => {
if (value !== 'null' && value !== '') payload[key] = value
}) })
return response.data
// File handling logic can be added here if needed
const { data, error } = await supabase
.from('users')
.update(payload)
.eq('id', authData.user.id)
.select()
.single()
if (error) throw new Error(error.message)
return data
}, },
logout() { async logout() {
localStorage.removeItem('auth_token') await supabase.auth.signOut()
localStorage.removeItem('user_role') localStorage.clear()
localStorage.removeItem('user_name')
localStorage.removeItem('profile_photo_url')
}, },
getApiUrl() { getApiUrl() {
return API_URL return 'Supabase (Nativo)'
} }
} }

View File

@ -1,24 +1,32 @@
/** Service for bus stop-related API calls */ /** Service for bus stop-related API calls */
import { apiClient } from './apiClient' import { supabase } from '@/supabase'
import type { BusStop, Route } from '@/types' import type { BusStop, Route } from '@/types'
export const busStopsService = { export const busStopsService = {
/** Get all bus stops */ /** Get all bus stops */
async getAllBusStops(): Promise<BusStop[]> { async getAllBusStops(): Promise<BusStop[]> {
const response = await apiClient.get<BusStop[]>('/api/bus-stops') const { data, error } = await supabase.from('bus_stops').select('*')
return response.data if (error) throw new Error(error.message)
return data as BusStop[]
}, },
/** Get a single bus stop by ID */ /** Get a single bus stop by ID */
async getBusStopById(id: string): Promise<BusStop> { async getBusStopById(id: string): Promise<BusStop> {
const response = await apiClient.get<BusStop>(`/api/bus-stops/${id}`) const { data, error } = await supabase.from('bus_stops').select('*').eq('id', id).single()
return response.data if (error) throw new Error(error.message)
return data as BusStop
}, },
/** Get all routes passing through a bus stop */ /** Get all routes passing through a bus stop */
async getBusStopRoutes(stopId: string): Promise<Route[]> { async getBusStopRoutes(stopId: string): Promise<Route[]> {
const response = await apiClient.get<Route[]>(`/api/bus-stops/${stopId}/routes`) const { data, error } = await supabase
return response.data .from('route_stops')
.select('routes(*)')
.eq('stop_id', stopId)
if (error) throw new Error(error.message)
// Extract the nested strictly typed route object automatically connected by Supabase relationships
return (data || []).map((row: any) => row.routes) as Route[]
}, },
/** Get estimated next bus arrivals (Mock Data) */ /** Get estimated next bus arrivals (Mock Data) */
@ -38,20 +46,33 @@ export const busStopsService = {
}, },
/** Create a new bus stop (Admin) */ /** Create a new bus stop (Admin) */
async createBusStop(data: import('@/types').BusStopCreate): Promise<BusStop> { async createBusStop(currentData: import('@/types').BusStopCreate): Promise<BusStop> {
const response = await apiClient.post<BusStop>('/api/bus-stops', data) const { data, error } = await supabase
return response.data .from('bus_stops')
.insert([currentData])
.select()
.single()
if (error) throw new Error(error.message)
return data as BusStop
}, },
/** Update a bus stop (Admin) */ /** Update a bus stop (Admin) */
async updateBusStop(id: string, data: import('@/types').BusStopUpdate): Promise<BusStop> { async updateBusStop(id: string, currentData: import('@/types').BusStopUpdate): Promise<BusStop> {
const response = await apiClient.put<BusStop>(`/api/bus-stops/${id}`, data) const { data, error } = await supabase
return response.data .from('bus_stops')
.update(currentData)
.eq('id', id)
.select()
.single()
if (error) throw new Error(error.message)
return data as BusStop
}, },
/** Delete a bus stop (Admin) */ /** Delete a bus stop (Admin) */
async deleteBusStop(id: string): Promise<void> { async deleteBusStop(id: string): Promise<void> {
await apiClient.delete(`/api/bus-stops/${id}`) const { error } = await supabase.from('bus_stops').delete().eq('id', id)
if (error) throw new Error(error.message)
} }
} }

View File

@ -1,42 +1,82 @@
/** Service for business-related API calls */ /** Service for business-related API calls */
import { apiClient } from './apiClient' import { supabase } from '@/supabase'
import type { Business } from '@/types' import type { Business } from '@/types'
export const businessService = { export const businessService = {
/** Helper to upload file to supabase storage */
async uploadImage(file: File): Promise<string> {
const fileExt = file.name.split('.').pop()
const fileName = `${Math.random()}-${Date.now()}.${fileExt}`
const filePath = `businesses/${fileName}`
const { error } = await supabase.storage.from('uploads').upload(filePath, file)
if (error) throw new Error(error.message)
const { data } = supabase.storage.from('uploads').getPublicUrl(filePath)
return data.publicUrl
},
/** Get all businesses */ /** Get all businesses */
async getAllBusinesses(): Promise<Business[]> { async getAllBusinesses(): Promise<Business[]> {
const response = await apiClient.get<Business[]>('/api/businesses') const { data, error } = await supabase.from('businesses').select('*')
return response.data if (error) throw new Error(error.message)
return data as Business[]
}, },
/** Get a single business by ID */ /** Get a single business by ID */
async getBusiness(id: string): Promise<Business> { async getBusiness(id: string): Promise<Business> {
const response = await apiClient.get<Business>(`/api/businesses/${id}`) const { data, error } = await supabase.from('businesses').select('*').eq('id', id).single()
return response.data if (error) throw new Error(error.message)
return data as Business
}, },
/** Create a new business */ /** Create a new business */
async createBusiness(businessData: FormData): Promise<Business> { async createBusiness(businessData: FormData): Promise<Business> {
const response = await apiClient.post<Business>('/api/businesses', businessData, { const payload: any = {}
headers: { let fileUpload: File | null = null
'Content-Type': 'multipart/form-data'
businessData.forEach((value, key) => {
if (key === 'file' && value instanceof File) {
fileUpload = value
} else if (value !== 'null' && value !== '') {
payload[key] = value
} }
}) })
return response.data
if (fileUpload) {
payload.image_url = await this.uploadImage(fileUpload)
}
const { data, error } = await supabase.from('businesses').insert([payload]).select().single()
if (error) throw new Error(error.message)
return data as Business
}, },
/** Update an existing business */ /** Update an existing business */
async updateBusiness(id: string, businessData: FormData): Promise<Business> { async updateBusiness(id: string, businessData: FormData): Promise<Business> {
const response = await apiClient.patch<Business>(`/api/businesses/${id}`, businessData, { const payload: any = {}
headers: { let fileUpload: File | null = null
'Content-Type': 'multipart/form-data'
businessData.forEach((value, key) => {
if (key === 'file' && value instanceof File) {
fileUpload = value
} else if (value !== 'null' && value !== '') {
payload[key] = value
} }
}) })
return response.data
if (fileUpload) {
payload.image_url = await this.uploadImage(fileUpload)
}
const { data, error } = await supabase.from('businesses').update(payload).eq('id', id).select().single()
if (error) throw new Error(error.message)
return data as Business
}, },
/** Delete a business */ /** Delete a business */
async deleteBusiness(id: string): Promise<void> { async deleteBusiness(id: string): Promise<void> {
await apiClient.delete(`/api/businesses/${id}`) const { error } = await supabase.from('businesses').delete().eq('id', id)
if (error) throw new Error(error.message)
}, },
} }

View File

@ -1,5 +1,5 @@
/** Service for coupon-related API calls */ /** Service for coupon-related API calls */
import { apiClient } from './apiClient' import { supabase } from '@/supabase'
import type { Coupon } from '@/types' import type { Coupon } from '@/types'
export interface CouponFilters { export interface CouponFilters {
@ -11,51 +11,93 @@ export interface CouponFilters {
export const couponsService = { export const couponsService = {
/** Get all coupons with optional filters */ /** Get all coupons with optional filters */
async getAllCoupons(filters?: CouponFilters): Promise<Coupon[]> { async getAllCoupons(filters?: CouponFilters): Promise<Coupon[]> {
const response = await apiClient.get<Coupon[]>('/api/coupons', { let query = supabase.from('coupons').select('*, business:businesses(*)')
params: filters,
}) if (filters?.category) query = query.eq('category', filters.category)
return response.data if (filters?.is_active !== undefined) query = query.eq('is_active', filters.is_active)
if (filters?.active_only !== undefined && filters.active_only) {
query = query.eq('is_active', true)
}
const { data, error } = await query
if (error) throw new Error(error.message)
return data as Coupon[]
}, },
/** Get a single coupon by ID */ /** Get a single coupon by ID */
async getCouponById(id: string): Promise<Coupon> { async getCouponById(id: string): Promise<Coupon> {
const response = await apiClient.get<Coupon>(`/api/coupons/${id}`) const { data, error } = await supabase.from('coupons').select('*, business:businesses(*)').eq('id', id).single()
return response.data if (error) throw new Error(error.message)
return data as Coupon
}, },
/** Create a new coupon */ /** Create a new coupon */
async createCoupon(coupon: Omit<Coupon, 'id' | 'created_at' | 'updated_at'>): Promise<Coupon> { async createCoupon(coupon: Omit<Coupon, 'id' | 'created_at' | 'updated_at'>): Promise<Coupon> {
const response = await apiClient.post<Coupon>('/api/coupons', coupon) // Prevent sending nested business properties over insert
return response.data const { business, ...payload } = coupon as any
const { data, error } = await supabase.from('coupons').insert([payload]).select().single()
if (error) throw new Error(error.message)
return data as Coupon
}, },
/** Update an existing coupon */ /** Update an existing coupon */
async updateCoupon(id: string, coupon: Partial<Coupon>): Promise<Coupon> { async updateCoupon(id: string, coupon: Partial<Coupon>): Promise<Coupon> {
const response = await apiClient.patch<Coupon>(`/api/coupons/${id}`, coupon) const { business, ...payload } = coupon as any
return response.data const { data, error } = await supabase.from('coupons').update(payload).eq('id', id).select().single()
if (error) throw new Error(error.message)
return data as Coupon
}, },
/** Delete a coupon */ /** Delete a coupon */
async deleteCoupon(id: string): Promise<void> { async deleteCoupon(id: string): Promise<void> {
await apiClient.delete(`/api/coupons/${id}`) const { error } = await supabase.from('coupons').delete().eq('id', id)
if (error) throw new Error(error.message)
}, },
/** Claim a coupon */ /** Claim a coupon */
async claimCoupon(id: string): Promise<any> { async claimCoupon(id: string): Promise<any> {
const response = await apiClient.post(`/api/coupons/${id}/claim`) const { data: userData, error: userError } = await supabase.auth.getUser()
return response.data if (userError || !userData?.user) throw new Error('User not logged in.')
const claimPayload = {
user_id: userData.user.id,
coupon_id: id,
status: 'claimed',
redemption_code: Math.random().toString(36).substring(2, 8).toUpperCase()
}
const { data, error } = await supabase.from('user_coupons').insert([claimPayload]).select().single()
// If error code is duplicate, it means they already claimed it (handled properly).
if (error) throw error
return data
}, },
/** Get current user's claimed coupons */ /** Get current user's claimed coupons */
async getMyCoupons(): Promise<any[]> { async getMyCoupons(): Promise<any[]> {
const response = await apiClient.get('/api/coupons/my-coupons') const { data: userData } = await supabase.auth.getUser()
return response.data if (!userData || !userData.user) return []
const { data, error } = await supabase
.from('user_coupons')
.select('*, coupon:coupons(*, business:businesses(*))')
.eq('user_id', userData.user.id)
if (error) throw new Error(error.message)
return data || []
}, },
/** Validate a coupon by code (merchants/drivers only) */ /** Validate a coupon by code (merchants/drivers only) */
async validateCoupon(code: string): Promise<any> { async validateCoupon(code: string): Promise<any> {
const response = await apiClient.post(`/api/coupons/validate/${code}`) const { data, error } = await supabase
return response.data .from('user_coupons')
.update({ status: 'redeemed', redeemed_at: new Date().toISOString() })
.eq('redemption_code', code)
.select()
.single()
if (error) throw new Error(error.message)
if (!data) throw new Error('Invalid code or already redeemed.')
return data
} }
} }

View File

@ -1,61 +1,122 @@
/** Service for route-related API calls */ /** Service for route-related API calls */
import { apiClient } from './apiClient' import { supabase } from '@/supabase'
import type { Route, BusStop } from '@/types' import type { Route, BusStop } from '@/types'
export const routesService = { export const routesService = {
/** Get all routes with optional filtering */ /** Get all routes with optional filtering */
async getAllRoutes(filters?: { originCity?: string, destinationCity?: string }): Promise<Route[]> { async getAllRoutes(filters?: { originCity?: string, destinationCity?: string }): Promise<Route[]> {
const response = await apiClient.get<Route[]>('/api/routes', { let query = supabase.from('routes').select('*')
params: {
origin_city: filters?.originCity, if (filters?.originCity) {
destination_city: filters?.destinationCity query = query.eq('origin_city', filters.originCity)
} }
}) if (filters?.destinationCity) {
return response.data query = query.eq('destination_city', filters.destinationCity)
}
const { data, error } = await query
if (error) throw new Error(error.message)
return data as Route[]
}, },
/** Get a single route by ID */ /** Get a single route by ID */
async getRouteById(id: string): Promise<Route> { async getRouteById(id: string): Promise<Route> {
const response = await apiClient.get<Route>(`/api/routes/${id}`) const { data, error } = await supabase.from('routes').select('*').eq('id', id).single()
return response.data if (error) throw new Error(error.message)
return data as Route
}, },
/** Get all stops for a route */ /** Get all stops for a route */
async getRouteStops(routeId: string): Promise<BusStop[]> { async getRouteStops(routeId: string): Promise<BusStop[]> {
const response = await apiClient.get<BusStop[]>(`/api/routes/${routeId}/stops`) // Query the junction table to get the order, joined with the actual bus_stop table
return response.data const { data, error } = await supabase
.from('route_stops')
.select(`
stop_order,
travel_time_minutes,
stop_delay_minutes,
is_pickup_point,
is_dropoff_point,
bus_stops (*)
`)
.eq('route_id', routeId)
.order('stop_order', { ascending: true })
if (error) throw new Error(error.message)
// Map back to the expected plain array of BusStop with merged properties
return (data || []).map((row: any) => ({
...row.bus_stops,
stop_order: row.stop_order,
travel_time_minutes: row.travel_time_minutes,
stop_delay_minutes: row.stop_delay_minutes,
is_pickup_point: row.is_pickup_point,
is_dropoff_point: row.is_dropoff_point
})) as BusStop[]
}, },
/** Create a new route (Admin) */ /** Create a new route (Admin) */
async createRoute(data: import('@/types').RouteCreate): Promise<Route> { async createRoute(data: import('@/types').RouteCreate): Promise<Route> {
const response = await apiClient.post<Route>('/api/routes', data) // Pydantic automatically generated IDs in Python; we rely on Postgres default uuid_generate_v4()
return response.data const { data: insertedRoute, error } = await supabase
.from('routes')
.insert([data])
.select()
.single()
if (error) throw new Error(error.message)
return insertedRoute as Route
}, },
/** Update a route (Admin) */ /** Update a route (Admin) */
async updateRoute(id: string, data: import('@/types').RouteUpdate): Promise<Route> { async updateRoute(id: string, data: import('@/types').RouteUpdate): Promise<Route> {
const response = await apiClient.put<Route>(`/api/routes/${id}`, data) const { data: updatedRoute, error } = await supabase
return response.data .from('routes')
.update(data)
.eq('id', id)
.select()
.single()
if (error) throw new Error(error.message)
return updatedRoute as Route
}, },
/** Delete a route (Admin) */ /** Delete a route (Admin) */
async deleteRoute(id: string): Promise<void> { async deleteRoute(id: string): Promise<void> {
await apiClient.delete(`/api/routes/${id}`) const { error } = await supabase.from('routes').delete().eq('id', id)
if (error) throw new Error(error.message)
}, },
/** Add a stop to a route (Admin) */ /** Add a stop to a route (Admin) */
async addStopToRoute(routeId: string, data: import('@/types').RouteStopCreate): Promise<void> { async addStopToRoute(routeId: string, data: import('@/types').RouteStopCreate): Promise<void> {
await apiClient.post(`/api/routes/${routeId}/stops`, data) // The API allowed dynamic re-ordering of stops.
// We will insert the new stop at the specified stop_order directly.
// (A fully featured sort requires more logic to push other stops down,
// but we mirror the basic insert here)
const { error } = await supabase.from('route_stops').insert([{
route_id: routeId,
...data
}])
if (error) throw new Error(error.message)
}, },
/** Update a stop on a route (Admin) - including reorder */ /** Update a stop on a route (Admin) - including reorder */
async updateRouteStop(routeId: string, stopId: string, data: import('@/types').RouteStopUpdate): Promise<void> { async updateRouteStop(routeId: string, stopId: string, data: import('@/types').RouteStopUpdate): Promise<void> {
await apiClient.put(`/api/routes/${routeId}/stops/${stopId}`, data) const { error } = await supabase
.from('route_stops')
.update(data)
.match({ route_id: routeId, stop_id: stopId })
if (error) throw new Error(error.message)
}, },
/** Remove a stop from a route (Admin) */ /** Remove a stop from a route (Admin) */
async removeStopFromRoute(routeId: string, stopId: string): Promise<void> { async removeStopFromRoute(routeId: string, stopId: string): Promise<void> {
await apiClient.delete(`/api/routes/${routeId}/stops/${stopId}`) const { error } = await supabase
.from('route_stops')
.delete()
.match({ route_id: routeId, stop_id: stopId })
if (error) throw new Error(error.message)
} }
} }

View File

@ -1,5 +1,5 @@
/** Service for shuttle-related API calls (Intercity/Tourism) */ /** Service for shuttle-related API calls (Intercity/Tourism) */
import { apiClient } from './apiClient' import { supabase } from '@/supabase'
import type { Shuttle } from '@/types' import type { Shuttle } from '@/types'
export interface ShuttleFilters { export interface ShuttleFilters {
@ -13,15 +13,23 @@ export interface ShuttleFilters {
export const shuttlesService = { export const shuttlesService = {
/** Get all shuttles with optional filters */ /** Get all shuttles with optional filters */
async getAllShuttles(filters?: ShuttleFilters): Promise<Shuttle[]> { async getAllShuttles(filters?: ShuttleFilters): Promise<Shuttle[]> {
const response = await apiClient.get<Shuttle[]>('/api/shuttles', { let query = supabase.from('shuttles').select('*')
params: filters,
}) if (filters?.origin) query = query.eq('origin', filters.origin)
return response.data if (filters?.destination) query = query.eq('destination', filters.destination)
if (filters?.company_name) query = query.eq('company_name', filters.company_name)
if (filters?.trip_type) query = query.eq('trip_type', filters.trip_type)
if (filters?.is_active !== undefined) query = query.eq('is_active', filters.is_active)
const { data, error } = await query
if (error) throw new Error(error.message)
return data as Shuttle[]
}, },
/** Get a single shuttle by ID */ /** Get a single shuttle by ID */
async getShuttleById(id: string): Promise<Shuttle> { async getShuttleById(id: string): Promise<Shuttle> {
const response = await apiClient.get<Shuttle>(`/api/shuttles/${id}`) const { data, error } = await supabase.from('shuttles').select('*').eq('id', id).single()
return response.data if (error) throw new Error(error.message)
return data as Shuttle
}, },
} }

View File

@ -1,5 +1,5 @@
/** Service for taxi-related API calls */ /** Service for taxi-related API calls */
import { apiClient } from './apiClient' import { supabase } from '@/supabase'
import type { Taxi } from '@/types' import type { Taxi } from '@/types'
export interface TaxiFilters { export interface TaxiFilters {
@ -12,16 +12,22 @@ export interface TaxiFilters {
export const taxisService = { export const taxisService = {
/** Get all taxis with optional filters */ /** Get all taxis with optional filters */
async getAllTaxis(filters?: TaxiFilters): Promise<Taxi[]> { async getAllTaxis(filters?: TaxiFilters): Promise<Taxi[]> {
const response = await apiClient.get<Taxi[]>('/api/taxis', { let query = supabase.from('taxis').select('*')
params: filters,
}) if (filters?.corregimiento) query = query.eq('corregimiento', filters.corregimiento)
return response.data if (filters?.shift) query = query.eq('shift', filters.shift)
if (filters?.english_speaking !== undefined) query = query.eq('english_speaking', filters.english_speaking)
if (filters?.is_active !== undefined) query = query.eq('is_active', filters.is_active)
const { data, error } = await query
if (error) throw new Error(error.message)
return data as Taxi[]
}, },
/** Get a single taxi by ID */ /** Get a single taxi by ID */
async getTaxiById(id: string): Promise<Taxi> { async getTaxiById(id: string): Promise<Taxi> {
const response = await apiClient.get<Taxi>(`/api/taxis/${id}`) const { data, error } = await supabase.from('taxis').select('*').eq('id', id).single()
return response.data if (error) throw new Error(error.message)
return data as Taxi
}, },
} }

View File

@ -1,39 +1,56 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { supabase } from '@/supabase'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token')) const userSession = ref<any>(null)
const role = ref<string | null>(localStorage.getItem('user_role')) const userProfile = ref<any>(null)
const userName = ref<string | null>(localStorage.getItem('user_name'))
const isAuthenticated = computed(() => !!userSession.value)
// We check the custom Postgres role we put in our `users` table
const role = computed(() => userProfile.value?.role || userSession.value?.user?.user_metadata?.role)
const userName = computed(() => userProfile.value?.full_name || userSession.value?.user?.user_metadata?.full_name)
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => role.value?.toUpperCase() === 'ADMIN') const isAdmin = computed(() => role.value?.toUpperCase() === 'ADMIN')
const isDriver = computed(() => role.value?.toUpperCase() === 'DRIVER') const isDriver = computed(() => role.value?.toUpperCase() === 'DRIVER')
const isPromoter = computed(() => role.value?.toUpperCase() === 'PROMOTER') const isPromoter = computed(() => role.value?.toUpperCase() === 'PROMOTER')
const isPassenger = computed(() => !role.value || role.value?.toUpperCase() === 'PASSENGER') const isPassenger = computed(() => !role.value || role.value?.toUpperCase() === 'PASSENGER')
function login(newToken: string, newRole: string, newName: string) { /** Listens for Supabase Auth state changes */
token.value = newToken supabase.auth.onAuthStateChange(async (_event, session) => {
role.value = newRole userSession.value = session
userName.value = newName
localStorage.setItem('auth_token', newToken) if (session?.user) {
localStorage.setItem('user_role', newRole) // Immediately fetch their role from standard Public table if present
localStorage.setItem('user_name', newName) const { data } = await supabase
.from('users')
.select('*')
.eq('id', session.user.id)
.single()
if (data) userProfile.value = data
} else {
userProfile.value = null
}
})
async function login(email: string, pass: string) {
// Use standard Supabase signIn
const { error } = await supabase.auth.signInWithPassword({ email, password: pass })
if (error) throw new Error(error.message)
} }
function logout() { async function logout() {
token.value = null await supabase.auth.signOut()
role.value = null userSession.value = null
userName.value = null userProfile.value = null
localStorage.removeItem('auth_token')
localStorage.removeItem('user_role')
localStorage.removeItem('user_name')
window.location.href = '/' window.location.href = '/'
} }
return { return {
token, userSession,
userProfile,
role, role,
userName, userName,
isAuthenticated, isAuthenticated,

6
frontend/src/supabase.ts Normal file
View File

@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js'
export const SUPABASE_URL = 'https://bjgixlugjzsccazdfmph.supabase.co'
export const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJqZ2l4bHVnanpzY2NhemRmbXBoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIwNjQyMTAsImV4cCI6MjA4NzY0MDIxMH0.untLQoPi4yUr3cPnxo23wYSlg6xnNK0daKu9UHmFTp8'
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

View File

@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import LoginForm from '@/components/auth/LoginForm.vue' import LoginForm from '@/components/auth/LoginForm.vue'
import RegisterForm from '@/components/auth/RegisterForm.vue' import RegisterForm from '@/components/auth/RegisterForm.vue'
import { getGoogleRedirectResult } from '@/firebaseConfig'
import { authService } from '@/services/authService'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const isLogin = ref(true) const isLogin = ref(true)
@ -12,33 +10,25 @@ const toggleAuth = () => { isLogin.value = !isLogin.value }
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const redirectErrorMessage = ref('')
const sessionExpiredMessage = ref('') const sessionExpiredMessage = ref('')
// Detectar si fue redirigido por sesión expirada // Detectar si fue redirigido por sesión expirada
onMounted(async () => { onMounted(() => {
if (route.query.reason === 'session_expired') { if (route.query.reason === 'session_expired') {
sessionExpiredMessage.value = 'Tu sesión ha expirado. Por favor, inicia sesión nuevamente.' sessionExpiredMessage.value = 'Tu sesión ha expirado. Por favor, inicia sesión nuevamente.'
} }
})
try { // Observa cambios en el rol cuando regresa de Google Oauth y lo redirige automáticamente
const result = await getGoogleRedirectResult() watch(() => authStore.role, (newRole) => {
if (result) { if (authStore.isAuthenticated && newRole) {
console.log('Procesando resultado de redirección de Google...') const role = newRole.toUpperCase()
const response = await authService.googleLogin(result.token)
authStore.login(response.access_token, response.role, response.full_name)
const role = response.role.toUpperCase()
if (role === 'ADMIN') router.push('/admin') if (role === 'ADMIN') router.push('/admin')
else if (role === 'DRIVER') router.push('/driver') else if (role === 'DRIVER') router.push('/driver')
else if (role === 'PROMOTER') router.push('/promoter') else if (role === 'PROMOTER') router.push('/promoter')
else router.push('/map') else router.push('/map')
} }
} catch (e: any) { }, { immediate: true })
console.error('Google redirect result error:', e)
redirectErrorMessage.value = `Error al volver de Google: ${e.message || 'Error desconocido'}`
}
})
</script> </script>
<template> <template>
@ -85,12 +75,6 @@ onMounted(async () => {
{{ sessionExpiredMessage }} {{ sessionExpiredMessage }}
</div> </div>
<!-- Alerta de error en redirección -->
<div v-if="redirectErrorMessage" class="redirect-error">
<span class="material-icons">warning</span>
{{ redirectErrorMessage }}
</div>
<!-- Formularios con transición --> <!-- Formularios con transición -->
<Transition name="auth-slide" mode="out-in"> <Transition name="auth-slide" mode="out-in">
<LoginForm v-if="isLogin" @toggle="toggleAuth" /> <LoginForm v-if="isLogin" @toggle="toggleAuth" />

8
frontend/vercel.json Normal file
View File

@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}

View File

@ -1,6 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
@ -12,8 +13,8 @@ export default defineConfig(({ mode }) => {
plugins: [ plugins: [
vue(), vue(),
tailwindcss(), tailwindcss(),
// VueDevTools SOLO en desarrollo — no se incluye en el bundle de producción // VueDevTools SOLO en desarrollo
...(isDev ? [require('vite-plugin-vue-devtools').default()] : []), isDev ? vueDevTools() : false,
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['icon-192.png', 'icon-512.png', 'icon-1024.png', 'favicon.ico'], includeAssets: ['icon-192.png', 'icon-512.png', 'icon-1024.png', 'favicon.ico'],

299
supabase_schema.sql Normal file
View File

@ -0,0 +1,299 @@
-- Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Enums
DO $$ BEGIN
CREATE TYPE routestatus AS ENUM ('ACTIVE', 'INACTIVE', 'MAINTENANCE');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE stoptype AS ENUM ('TERMINAL', 'REGULAR', 'EXPRESS_ONLY');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE busscheduletype AS ENUM ('WEEKDAY', 'WEEKEND', 'HOLIDAY');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE userrole AS ENUM ('ADMIN', 'PASSENGER', 'DRIVER', 'PROMOTER');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE vehicletype AS ENUM ('taxi', 'bus');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE usercouponstatus AS ENUM ('claimed', 'redeemed', 'expired');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- 1. Users
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY, -- Will link to auth.users.id
email TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
role userrole DEFAULT 'PASSENGER'::userrole,
is_active BOOLEAN DEFAULT true,
is_verified BOOLEAN DEFAULT false,
profile_photo_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 2. Driver Profiles
CREATE TABLE IF NOT EXISTS public.driver_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES public.users(id) ON DELETE CASCADE,
cedula TEXT NOT NULL,
vehicle_type vehicletype NOT NULL,
license_plate TEXT NOT NULL,
photo_url TEXT,
vehicle_photo_url TEXT,
cooperative_name TEXT,
shift TEXT,
payment_methods TEXT,
speaks_english BOOLEAN DEFAULT false
);
-- 3. Bus Stops
CREATE TABLE IF NOT EXISTS public.bus_stops (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
latitude FLOAT NOT NULL,
longitude FLOAT NOT NULL,
city TEXT NOT NULL,
address TEXT,
stop_type stoptype NOT NULL,
has_shelter BOOLEAN DEFAULT false,
has_seating BOOLEAN DEFAULT false,
is_accessible BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 4. Routes
CREATE TABLE IF NOT EXISTS public.routes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT UNIQUE NOT NULL,
description TEXT,
origin_city TEXT NOT NULL,
destination_city TEXT NOT NULL,
distance_km FLOAT,
estimated_duration_minutes INTEGER,
average_speed_kmh FLOAT,
color TEXT DEFAULT '#FEE715',
direction TEXT DEFAULT 'outbound',
status routestatus DEFAULT 'ACTIVE'::routestatus,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 5. Route Stops
CREATE TABLE IF NOT EXISTS public.route_stops (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
route_id UUID REFERENCES public.routes(id) ON DELETE CASCADE,
stop_id UUID REFERENCES public.bus_stops(id) ON DELETE CASCADE,
stop_order INTEGER NOT NULL,
travel_time_minutes INTEGER,
stop_delay_minutes INTEGER DEFAULT 0,
is_pickup_point BOOLEAN DEFAULT false,
is_dropoff_point BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 6. Bus Schedules
CREATE TABLE IF NOT EXISTS public.bus_schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
route_id UUID REFERENCES public.routes(id) ON DELETE CASCADE,
departure_time TIME NOT NULL,
frequency_minutes INTEGER,
schedule_type busscheduletype NOT NULL,
is_active BOOLEAN DEFAULT true,
is_published BOOLEAN DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 7. Businesses
CREATE TABLE IF NOT EXISTS public.businesses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
address TEXT,
phone TEXT,
image_url TEXT,
social_media TEXT,
category TEXT,
latitude FLOAT,
longitude FLOAT,
area TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 8. Coupons
CREATE TABLE IF NOT EXISTS public.coupons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
business_id UUID REFERENCES public.businesses(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
image_url TEXT,
social_media TEXT,
terms TEXT,
discount_percentage FLOAT,
discount_amount FLOAT,
category TEXT,
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
is_active BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 9. Shuttles
CREATE TABLE IF NOT EXISTS public.shuttles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
route_name TEXT NOT NULL,
description TEXT,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
vehicle_type TEXT NOT NULL,
company_name TEXT,
trip_type TEXT DEFAULT 'one_way',
price_per_person FLOAT,
price_private_trip FLOAT,
estimated_duration TEXT,
departure_times TEXT,
contact_whatsapp TEXT NOT NULL,
phone_number TEXT,
english_speaking BOOLEAN DEFAULT false,
image_url TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 10. Taxis
CREATE TABLE IF NOT EXISTS public.taxis (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
owner_name TEXT NOT NULL,
phone_number TEXT NOT NULL,
license_plate TEXT NOT NULL,
cooperative TEXT,
corregimiento TEXT NOT NULL,
shift TEXT NOT NULL,
rating FLOAT,
english_speaking BOOLEAN DEFAULT false,
image_url TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 11. Favorites
CREATE TABLE IF NOT EXISTS public.favorites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES public.users(id) ON DELETE CASCADE,
item_type TEXT NOT NULL,
item_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 12. User Coupons
CREATE TABLE IF NOT EXISTS public.user_coupons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES public.users(id) ON DELETE CASCADE,
coupon_id UUID REFERENCES public.coupons(id) ON DELETE CASCADE,
status usercouponstatus DEFAULT 'claimed'::usercouponstatus,
redemption_code TEXT NOT NULL,
claimed_at TIMESTAMPTZ DEFAULT NOW(),
redeemed_at TIMESTAMPTZ,
UNIQUE(user_id, coupon_id)
);
-- TRIGGER FOR AUTH.USERS -> PUBLIC.USERS
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.users (id, email, full_name, role)
VALUES (
new.id,
new.email,
COALESCE(new.raw_user_meta_data->>'full_name', new.email),
COALESCE((new.raw_user_meta_data->>'role')::userrole, 'PASSENGER'::userrole)
);
RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger firing when a new user signs up
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();
-- SET DEFAULT RLS (Row Level Security) TO EVERY TABLE
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.routes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.bus_stops ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.route_stops ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.bus_schedules ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.shuttles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.businesses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.coupons ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_coupons ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.taxis ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.favorites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.driver_profiles ENABLE ROW LEVEL SECURITY;
-- SIMPLE POLICIES (Read for all, Insert/Update/Delete requires auth)
-- You can tighten these up later, this gets you going securely.
-- Routes, Stops, Shuttles, Taxis, Businesses, Coupons: public read
CREATE POLICY "Public Read" ON public.routes FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.bus_stops FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.route_stops FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.bus_schedules FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.shuttles FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.businesses FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.coupons FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.taxis FOR SELECT USING (true);
CREATE POLICY "Public Read" ON public.driver_profiles FOR SELECT USING (true);
-- Users can read their own data, Admins can read everything
CREATE POLICY "Users can view own data" ON public.users FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own data" ON public.users FOR UPDATE USING (auth.uid() = id);
-- Allow admins to manage (This policy assumes admins are users with role = 'ADMIN')
CREATE OR REPLACE FUNCTION is_admin() RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND role = 'ADMIN');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE POLICY "Admins full access" ON public.routes FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.bus_stops FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.route_stops FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.bus_schedules FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.shuttles FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.businesses FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.coupons FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.taxis FOR ALL USING (is_admin());
CREATE POLICY "Admins full access" ON public.users FOR ALL USING (is_admin());
-- Favorites and User Coupons
CREATE POLICY "Users can view own favorites" ON public.favorites FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can manage own favorites" ON public.favorites FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users can view own coupons" ON public.user_coupons FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can manage own coupons" ON public.user_coupons FOR ALL USING (auth.uid() = user_id);