Overview
Portal Ciudadano Manta uses Pinia 3.0+ for centralized state management. All stores follow the Composition API pattern with TypeScript for type safety.Store Architecture
The application has four main stores:auth.store
Authentication state and user session management
reportes.store
Citizen reports and issue tracking
encuestas.store
Survey management and responses
noticias.store
News articles and announcements
Store Pattern
All stores follow a consistent structure:import { defineStore } from "pinia";
import { ref } from "vue";
import { supabase } from "../lib/supabase";
import type { Database } from "../types/database.types";
export const useExampleStore = defineStore("example", () => {
// 1. State (reactive refs)
const items = ref<Item[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
// 2. Getters (computed values via functions)
const itemCount = () => items.value.length;
// 3. Actions (async operations)
const fetchItems = async () => {
loading.value = true;
error.value = null;
try {
const { data, error: fetchError } = await supabase
.from('items')
.select('*');
if (fetchError) throw fetchError;
items.value = data || [];
return { success: true, data };
} catch (err: any) {
error.value = err.message;
return { success: false, error: err.message };
} finally {
loading.value = false;
}
};
return {
// State
items,
loading,
error,
// Getters
itemCount,
// Actions
fetchItems,
};
});
Authentication Store
The auth store manages user authentication and session state.- State
- Getters
- Actions
src/stores/auth.store.ts
const user = ref<User | null>(null); // Supabase Auth user
const usuario = ref<Usuario | null>(null); // Database user profile
const loading = ref(false);
const error = ref<string | null>(null);
const fetchingUser = ref(false); // Prevent concurrent fetches
const isAuthenticated = () => !!user.value;
const isCiudadano = () => usuario.value?.tipo === "ciudadano";
const isAdministrador = () => usuario.value?.tipo === "administrador";
return {
initAuth, // Initialize auth state
register, // Register new user
login, // Sign in
logout, // Sign out
resetPassword, // Password reset
updateProfile, // Update user profile
};
Authentication Flow
src/stores/auth.store.ts
const initAuth = async () => {
loading.value = true;
try {
// Get current session
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
user.value = session.user;
await fetchUsuario(session.user.id);
}
// Listen for auth state changes
supabase.auth.onAuthStateChange(async (event, session) => {
console.log('🔐 Auth state change:', event);
if (event === "SIGNED_OUT") {
user.value = null;
usuario.value = null;
} else if (event === "SIGNED_IN") {
if (!usuario.value || user.value?.id !== session?.user?.id) {
user.value = session?.user ?? null;
if (session?.user) {
await fetchUsuario(session.user.id);
}
}
} else if (event === "TOKEN_REFRESHED") {
user.value = session?.user ?? null;
}
});
} catch (err: any) {
error.value = err.message;
console.error('❌ Error initializing auth:', err);
} finally {
loading.value = false;
}
};
The auth store listens to Supabase auth events and automatically updates state when users sign in, sign out, or tokens are refreshed.
Dual Table User Lookup
The auth store checks bothusuarios and administradores tables:
src/stores/auth.store.ts
const fetchUsuario = async (userId: string) => {
if (fetchingUser.value) {
console.log('⏳ Already fetching user, using current data');
return usuario.value;
}
fetchingUser.value = true;
try {
// Try usuarios table first
const { data, error: fetchError } = await supabase
.from("usuarios")
.select("*")
.eq("id", userId)
.maybeSingle();
if (fetchError || !data) {
// If not found, try administradores table
const { data: admin, error: adminError } = await supabase
.from("administradores")
.select("id, nombres, email")
.eq("id", userId)
.maybeSingle();
if (adminError || !admin) {
usuario.value = null;
return null;
}
// Create usuario object for admin
usuario.value = {
id: admin.id,
email: admin.email ?? "",
nombres: admin.nombres,
tipo: "administrador",
// ... other fields
};
return usuario.value;
}
usuario.value = data;
return data;
} finally {
fetchingUser.value = false;
}
};
Reportes Store
Manages citizen reports of municipal issues.Key Actions
const fetchReportes = async (filtros?: {
usuario_id?: string;
estado?: Reporte["estado"];
categoria?: Reporte["categoria"];
}) => {
loading.value = true;
error.value = null;
try {
let query = supabase
.from("reportes")
.select("*")
.order("created_at", { ascending: false });
if (filtros?.usuario_id) {
query = query.eq("usuario_id", filtros.usuario_id);
}
if (filtros?.estado) {
query = query.eq("estado", filtros.estado);
}
if (filtros?.categoria) {
query = query.eq("categoria", filtros.categoria);
}
const { data, error: fetchError } = await query;
if (fetchError) throw fetchError;
reportes.value = data || [];
return { success: true, data };
} catch (err: any) {
error.value = err.message;
return { success: false, error: err.message };
} finally {
loading.value = false;
}
};
Real-time Subscriptions
The reportes store supports real-time updates via Supabase subscriptions:src/stores/reportes.store.ts
const subscribeToReportes = () => {
const authStore = useAuthStore();
return supabase
.channel("reportes-changes")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "reportes",
filter: authStore.isCiudadano()
? `usuario_id=eq.${authStore.user?.id}`
: undefined,
},
(payload) => {
console.log("📡 Change in reportes:", payload);
if (payload.eventType === "INSERT") {
reportes.value.unshift(payload.new as Reporte);
} else if (payload.eventType === "UPDATE") {
const index = reportes.value.findIndex(
(r) => r.id === payload.new.id
);
if (index !== -1) {
reportes.value[index] = payload.new as Reporte;
}
} else if (payload.eventType === "DELETE") {
reportes.value = reportes.value.filter(
(r) => r.id !== payload.old.id
);
}
}
)
.subscribe();
};
Encuestas Store
Manages surveys and poll functionality.Survey Response Handling
src/stores/encuestas.store.ts
const responderEncuesta = async (
encuestaId: string,
respuesta: Record<string, any>
) => {
loading.value = true;
error.value = null;
try {
const authStore = useAuthStore();
if (!authStore.user) throw new Error("Usuario no autenticado");
// Check if already answered
const { data: yaRespondio } = await supabase
.from("respuestas_encuestas")
.select("id")
.eq("encuesta_id", encuestaId)
.eq("usuario_id", authStore.user.id)
.single();
if (yaRespondio) {
throw new Error("Ya has respondido esta encuesta");
}
const { data, error: insertError } = await supabase
.from("respuestas_encuestas")
.insert({
encuesta_id: encuestaId,
usuario_id: authStore.user.id,
respuesta,
})
.select()
.single();
if (insertError) throw insertError;
return { success: true, data };
} catch (err: any) {
error.value = err.message;
return { success: false, error: err.message };
} finally {
loading.value = false;
}
};
Statistics Calculation
The encuestas store includes complex statistics calculation for different survey types:src/stores/encuestas.store.ts
const obtenerEstadisticas = async (encuestaId: string) => {
loading.value = true;
error.value = null;
try {
// Get survey to know its type
const { data: encuestaData, error: encuestaError } = await supabase
.from("encuestas")
.select("tipo, opciones")
.eq("id", encuestaId)
.single();
if (encuestaError) throw encuestaError;
// Get all responses
const { data, error: fetchError } = await supabase
.from("respuestas_encuestas")
.select("respuesta")
.eq("encuesta_id", encuestaId);
if (fetchError) throw fetchError;
const encuesta = encuestaData as any;
// Handle multiple choice with multiple questions
if (
encuesta?.tipo === "opcion_multiple" &&
Array.isArray(encuesta.opciones)
) {
const preguntas = encuesta.opciones as any[];
if (
preguntas.length > 0 &&
typeof preguntas[0] === "object" &&
"pregunta" in preguntas[0]
) {
const estadisticasPorPregunta = [];
preguntas.forEach((preguntaObj, index) => {
const stats: Record<string, number> = {};
data?.forEach((resp: any) => {
const respuesta = resp.respuesta as any;
if (Array.isArray(respuesta.respuestaLibre)) {
const opcionSeleccionada = respuesta.respuestaLibre[index];
if (opcionSeleccionada) {
stats[opcionSeleccionada] =
(stats[opcionSeleccionada] || 0) + 1;
}
}
});
estadisticasPorPregunta.push({
pregunta: preguntaObj.pregunta,
estadisticas: stats,
total: data?.length || 0,
});
});
return {
success: true,
data: estadisticasPorPregunta,
total: data?.length || 0,
tipo: "multiple_preguntas",
};
}
}
// Handle open-ended questions
if (encuesta?.tipo === "abierta") {
const respuestasAbiertas = [];
data?.forEach((resp: any) => {
const respuesta = resp.respuesta as any;
const textoRespuesta =
respuesta.respuestaLibre ||
respuesta.calificacion ||
respuesta.opcion ||
Object.values(respuesta)[0];
if (textoRespuesta) {
respuestasAbiertas.push({
respuesta: String(textoRespuesta),
fecha: new Date().toISOString(),
});
}
});
return {
success: true,
data: respuestasAbiertas,
total: data?.length || 0,
tipo: "abierta",
};
}
// Handle ratings and other types
const estadisticas: Record<string, number> = {};
data?.forEach((resp: any) => {
const respuesta = resp.respuesta as any;
Object.entries(respuesta).forEach(([_key, value]) => {
const opcion = String(value);
estadisticas[opcion] = (estadisticas[opcion] || 0) + 1;
});
});
return { success: true, data: estadisticas, total: data?.length || 0 };
} catch (err: any) {
error.value = err.message;
return { success: false, error: err.message };
} finally {
loading.value = false;
}
};
Noticias Store
Manages news articles with location-based filtering.Location-Based Filtering
News can be filtered by location (parish/neighborhood):src/stores/noticias.store.ts
const fetchNoticiasUsuario = async (parroquia: string, barrio?: string) => {
loading.value = true;
error.value = null;
try {
// Filter logic:
// 1. Global news (parroquia_destino = null)
// 2. Parish news (parroquia_destino = parroquia AND barrio_destino = null)
// 3. Neighborhood news (parroquia AND barrio specified)
let query = supabase
.from("noticias")
.select("*")
.order("created_at", { ascending: false });
if (barrio) {
// User has neighborhood: show global, parish, and neighborhood news
query = query.or(
`parroquia_destino.is.null,and(parroquia_destino.eq.${parroquia},barrio_destino.is.null),and(parroquia_destino.eq.${parroquia},barrio_destino.eq.${barrio})`
);
} else {
// User only has parish: show global and parish news
query = query.or(
`parroquia_destino.is.null,and(parroquia_destino.eq.${parroquia},barrio_destino.is.null)`
);
}
const { data, error: fetchError } = await query;
if (fetchError) throw fetchError;
noticias.value = data || [];
return { success: true, data: noticias.value };
} catch (err: any) {
error.value = err.message;
return { success: false, error: err.message };
} finally {
loading.value = false;
}
};
The location-based filtering ensures users only see relevant news for their area, plus global announcements.
Using Stores in Components
<script setup lang="ts">
import { onMounted } from 'vue';
import { useReportesStore } from '@/stores/reportes.store';
import { useAuthStore } from '@/stores/auth.store';
const reportesStore = useReportesStore();
const authStore = useAuthStore();
onMounted(async () => {
// Fetch user's reports
if (authStore.user) {
await reportesStore.fetchReportes({
usuario_id: authStore.user.id
});
}
});
const createReport = async () => {
const result = await reportesStore.crearReporte({
categoria: 'baches',
descripcion: 'Large pothole',
// ... other fields
});
if (result.success) {
console.log('Report created:', result.data);
} else {
console.error('Error:', result.error);
}
};
</script>
<template>
<div>
<div v-if="reportesStore.loading">Loading...</div>
<div v-else>
<div v-for="reporte in reportesStore.reportes" :key="reporte.id">
{{ reporte.descripcion }}
</div>
</div>
</div>
</template>
Best Practices
Always handle loading states
Always handle loading states
<div v-if="store.loading">Loading...</div>
<div v-else-if="store.error">Error: {{ store.error }}</div>
<div v-else>
<!-- Content -->
</div>
Check authentication before operations
Check authentication before operations
const authStore = useAuthStore();
if (!authStore.user) {
throw new Error('Usuario no autenticado');
}
Use return values for success/error handling
Use return values for success/error handling
const result = await store.fetchData();
if (result.success) {
// Handle success
} else {
// Handle error
console.error(result.error);
}
Prevent concurrent operations
Prevent concurrent operations
if (loading.value) {
console.log('Already loading, returning current data');
return { success: true, data: currentData.value };
}
Next Steps
Database Schema
Learn about the database structure
API Reference
Explore the complete API documentation
