Skip to main content

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.
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

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 both usuarios 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

<div v-if="store.loading">Loading...</div>
<div v-else-if="store.error">Error: {{ store.error }}</div>
<div v-else>
  <!-- Content -->
</div>
const authStore = useAuthStore();
if (!authStore.user) {
  throw new Error('Usuario no autenticado');
}
const result = await store.fetchData();
if (result.success) {
  // Handle success
} else {
  // Handle error
  console.error(result.error);
}
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

Build docs developers (and LLMs) love