Skip to main content

Overview

The Survey System allows administrators to create interactive surveys with multiple question types and gather citizen feedback. The system prevents duplicate responses, supports time-bounded surveys, and provides real-time statistics.

Survey Types

Multiple Choice

Single or multiple questions with predefined options

Open-Ended

Free-text responses for qualitative feedback

Rating

Numeric or star-based rating scales

Data Model

Survey Structure

type EncuestaTipo = "opcion_multiple" | "abierta" | "calificacion";

interface Encuesta {
  id: string;
  titulo: string;
  descripcion: string;
  tipo: EncuestaTipo;
  opciones: any | null;                 // JSONB - flexible structure
  fecha_inicio: string;                 // ISO timestamp
  fecha_fin: string;                    // ISO timestamp
  activa: boolean;
  parroquia_destino?: string | null;
  barrio_destino?: string | null;
  created_at: string;
  updated_at: string;
}

interface RespuestaEncuesta {
  id: string;
  encuesta_id: string;
  usuario_id: string;
  respuesta: Record<string, any>;       // JSONB - flexible structure
  created_at: string;
}

Option Formats

{
  "opciones": ["Opción A", "Opción B", "Opción C"]
}

Survey Store (encuestas.store.ts)

State Management

import { defineStore } from "pinia";
import { ref } from "vue";
import { supabase } from "../lib/supabase";
import type { Database } from "../types/database.types";

type Encuesta = Database["public"]["Tables"]["encuestas"]["Row"];
type RespuestaEncuesta = Database["public"]["Tables"]["respuestas_encuestas"]["Row"];

export const useEncuestasStore = defineStore("encuestas", () => {
  const encuestas = ref<Encuesta[]>([]);
  const encuestaActual = ref<Encuesta | null>(null);
  const respuestas = ref<RespuestaEncuesta[]>([]);
  const loading = ref(false);
  const error = ref<string | null>(null);
  
  // ... actions
});

Creating Surveys (Admin)

const crearEncuesta = async (encuesta: InsertEncuesta) => {
  loading.value = true;
  error.value = null;

  try {
    // Verify valid session before critical operation
    const sessionValid = await checkSession();
    if (!sessionValid) {
      throw new Error("Sesión expirada. Por favor, inicia sesión nuevamente.");
    }

    const { data, error: insertError } = await supabase
      .from("encuestas")
      .insert(encuesta)
      .select()
      .single();

    if (insertError) throw insertError;

    encuestas.value.unshift(data);
    return { success: true, data };
  } catch (err: any) {
    error.value = err.message;
    return { success: false, error: err.message };
  } finally {
    loading.value = false;
  }
};

Responding to Surveys

The system prevents duplicate responses:
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 user already responded
    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;
  }
};

Checking Response Status

const verificarYaRespondio = async (encuestaId: string): Promise<boolean> => {
  try {
    const authStore = useAuthStore();
    if (!authStore.user) return false;

    const { data } = await supabase
      .from("respuestas_encuestas")
      .select("id")
      .eq("encuesta_id", encuestaId)
      .eq("usuario_id", authStore.user.id)
      .single();

    return !!data;
  } catch (err) {
    return false;
  }
};

Statistics and Analytics

Processing Survey Results

const obtenerEstadisticas = async (encuestaId: string) => {
  loading.value = true;
  error.value = null;

  try {
    // Get survey metadata
    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;

    // Process based on survey type
    if (encuestaData.tipo === "opcion_multiple") {
      return processMultipleChoiceStats(data, encuestaData.opciones);
    } else if (encuestaData.tipo === "abierta") {
      return processOpenEndedStats(data);
    } else {
      return processRatingStats(data);
    }
  } catch (err: any) {
    error.value = err.message;
    return { success: false, error: err.message };
  } finally {
    loading.value = false;
  }
};

Multiple Question Processing

For surveys with multiple questions:
const processMultipleChoiceStats = (responses: any[], opciones: any) => {
  const preguntas = opciones as any[];
  
  // Check if it's a multi-question survey
  if (preguntas.length > 0 && typeof preguntas[0] === "object" && "pregunta" in preguntas[0]) {
    const estadisticasPorPregunta: Array<{
      pregunta: string;
      estadisticas: Record<string, number>;
      total: number;
    }> = [];

    preguntas.forEach((preguntaObj, index) => {
      const stats: Record<string, number> = {};

      responses.forEach((resp) => {
        const respuesta = resp.respuesta;
        // respuesta.respuestaLibre is an array where each index corresponds to a question
        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: responses.length,
      });
    });

    return {
      success: true,
      data: estadisticasPorPregunta,
      total: responses.length,
      tipo: "multiple_preguntas",
    };
  }
  
  // Simple single-question format
  // ... process single question ...
};

Open-Ended Response Processing

const processOpenEndedStats = (responses: any[]) => {
  const respuestasAbiertas: Array<{
    respuesta: string;
    fecha: string;
  }> = [];

  responses.forEach((resp) => {
    const respuesta = resp.respuesta;
    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: responses.length,
    tipo: "abierta",
  };
};

Usage Examples

Creating a Survey

<script setup lang="ts">
import { ref } from "vue";
import { useEncuestasStore } from "@/stores/encuestas.store";

const encuestasStore = useEncuestasStore();

const nuevaEncuesta = ref({
  titulo: "Satisfacción con servicios municipales",
  descripcion: "Ayúdanos a mejorar los servicios de tu comunidad",
  tipo: "opcion_multiple" as const,
  opciones: [
    {
      pregunta: "¿Cómo califica el servicio de alumbrado público?",
      opciones: ["Excelente", "Bueno", "Regular", "Malo"]
    },
    {
      pregunta: "¿Cómo califica el servicio de recolección de basura?",
      opciones: ["Excelente", "Bueno", "Regular", "Malo"]
    }
  ],
  fecha_inicio: new Date().toISOString(),
  fecha_fin: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days
  activa: true,
});

const crear = async () => {
  const result = await encuestasStore.crearEncuesta(nuevaEncuesta.value);
  if (result.success) {
    console.log("Encuesta creada:", result.data);
  }
};
</script>

Responding to a Survey

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useEncuestasStore } from "@/stores/encuestas.store";
import { useRoute } from "vue-router";

const route = useRoute();
const encuestasStore = useEncuestasStore();
const yaRespondio = ref(false);
const respuestas = ref<string[]>([]);

onMounted(async () => {
  const encuestaId = route.params.id as string;
  await encuestasStore.fetchEncuesta(encuestaId);
  yaRespondio.value = await encuestasStore.verificarYaRespondio(encuestaId);
});

const enviarRespuestas = async () => {
  const encuestaId = route.params.id as string;
  const result = await encuestasStore.responderEncuesta(encuestaId, {
    respuestaLibre: respuestas.value,
  });
  
  if (result.success) {
    console.log("Respuesta enviada exitosamente");
    yaRespondio.value = true;
  }
};
</script>

<template>
  <div v-if="yaRespondio">
    <p>Ya has respondido esta encuesta. ¡Gracias por tu participación!</p>
  </div>
  
  <form v-else @submit.prevent="enviarRespuestas">
    <div v-if="encuestasStore.encuestaActual">
      <h2>{{ encuestasStore.encuestaActual.titulo }}</h2>
      <p>{{ encuestasStore.encuestaActual.descripcion }}</p>
      
      <div v-for="(pregunta, index) in encuestasStore.encuestaActual.opciones" :key="index">
        <h3>{{ pregunta.pregunta }}</h3>
        <select v-model="respuestas[index]">
          <option v-for="opcion in pregunta.opciones" :key="opcion" :value="opcion">
            {{ opcion }}
          </option>
        </select>
      </div>
      
      <button type="submit">Enviar respuestas</button>
    </div>
  </form>
</template>

Viewing Statistics

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useEncuestasStore } from "@/stores/encuestas.store";
import { useRoute } from "vue-router";

const route = useRoute();
const encuestasStore = useEncuestasStore();
const estadisticas = ref<any>(null);

onMounted(async () => {
  const encuestaId = route.params.id as string;
  const result = await encuestasStore.obtenerEstadisticas(encuestaId);
  if (result.success) {
    estadisticas.value = result;
  }
});
</script>

<template>
  <div v-if="estadisticas">
    <h2>Resultados de la Encuesta</h2>
    <p>Total de respuestas: {{ estadisticas.total }}</p>
    
    <!-- Multiple choice statistics -->
    <div v-if="estadisticas.tipo === 'multiple_preguntas'">
      <div v-for="(pregunta, index) in estadisticas.data" :key="index">
        <h3>{{ pregunta.pregunta }}</h3>
        <div v-for="(count, opcion) in pregunta.estadisticas" :key="opcion">
          <span>{{ opcion }}: {{ count }} respuestas</span>
          <progress :value="count" :max="pregunta.total"></progress>
        </div>
      </div>
    </div>
    
    <!-- Open-ended responses -->
    <div v-else-if="estadisticas.tipo === 'abierta'">
      <ul>
        <li v-for="(resp, index) in estadisticas.data" :key="index">
          {{ resp.respuesta }}
        </li>
      </ul>
    </div>
  </div>
</template>

Best Practices

Always check if a user has already responded before showing the survey form. This improves UX and data quality.
Validate the user’s session before creating surveys or submitting responses to prevent expired session errors.
Use JSONB for the respuesta field to support various response formats. This allows for future survey types without schema changes.
Always set fecha_inicio and fecha_fin to control survey availability. Validate these dates on both client and server.

Database Constraints

Add a unique constraint to prevent duplicate responses:
ALTER TABLE respuestas_encuestas 
ADD CONSTRAINT unique_user_survey 
UNIQUE (encuesta_id, usuario_id);
This ensures data integrity even if client-side checks fail.

News System

Similar geolocation targeting for surveys

Supabase JSONB

Working with JSONB columns in PostgreSQL

Build docs developers (and LLMs) love