🚀 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:
126
frontend/package-lock.json
generated
126
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|
||||||
|
|||||||
@ -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)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
6
frontend/src/supabase.ts
Normal 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)
|
||||||
@ -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
8
frontend/vercel.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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
299
supabase_schema.sql
Normal 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);
|
||||||
Reference in New Issue
Block a user