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
- Multiple Choice - Simple
- Multiple Choice - Complex
- Rating
- Open-Ended
{
"opciones": ["Opción A", "Opción B", "Opción C"]
}
{
"opciones": [
{
"pregunta": "¿Cómo califica el servicio de alumbrado?",
"opciones": ["Excelente", "Bueno", "Regular", "Malo"]
},
{
"pregunta": "¿Cómo califica el servicio de recolección?",
"opciones": ["Excelente", "Bueno", "Regular", "Malo"]
}
]
}
{
"min": 1,
"max": 5,
"labels": ["Muy malo", "Malo", "Regular", "Bueno", "Excelente"]
}
{
"maxLength": 500,
"placeholder": "Escribe tu respuesta aquÃ..."
}
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
Prevent Duplicate Responses
Prevent Duplicate Responses
Always check if a user has already responded before showing the survey form. This improves UX and data quality.
Session Validation
Session Validation
Validate the user’s session before creating surveys or submitting responses to prevent expired session errors.
Flexible Response Format
Flexible Response Format
Use JSONB for the
respuesta field to support various response formats. This allows for future survey types without schema changes.Time-Bounded Surveys
Time-Bounded Surveys
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:This ensures data integrity even if client-side checks fail.
ALTER TABLE respuestas_encuestas
ADD CONSTRAINT unique_user_survey
UNIQUE (encuesta_id, usuario_id);
Related Resources
News System
Similar geolocation targeting for surveys
Supabase JSONB
Working with JSONB columns in PostgreSQL
