Skip to main content

Overview

The News System enables administrators to publish location-targeted announcements and news articles to specific parishes (parroquias) and neighborhoods (barrios) in Manta. Citizens automatically receive news relevant to their registered location.

Architecture

Data Model

interface Noticia {
  id: string;                           // UUID
  titulo: string;                       // Title
  contenido: string;                    // Content (supports markdown)
  imagen_url?: string;                  // Optional image URL
  administrador_id: string;             // Creator admin ID
  
  // Geolocation targeting
  parroquia_destino?: string | null;    // Target parish (null = global)
  barrio_destino?: string | null;       // Target neighborhood
  
  created_at: string;
  updated_at: string;
}

Targeting Logic

The system implements a three-tier targeting hierarchy:
1

Global News

When parroquia_destino is null, the news is visible to all users citywide.
2

Parish-Level News

When parroquia_destino is set but barrio_destino is null, the news is visible to all users in that parish.
3

Neighborhood-Level News

When both parroquia_destino and barrio_destino are set, the news is visible only to users in that specific neighborhood.

News Store (noticias.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 Noticia = Database["public"]["Tables"]["noticias"]["Row"];
type InsertNoticia = Database["public"]["Tables"]["noticias"]["Insert"];

export const useNoticiasStore = defineStore("noticias", () => {
  const noticias = ref<Noticia[]>([]);
  const noticiaActual = ref<Noticia | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);
  
  // ... actions
});

Fetching News for Citizens

Citizens receive news based on their registered location:
const fetchNoticiasUsuario = async (parroquia: string, barrio?: string) => {
  if (loading.value) {
    console.log("⏳ Ya hay una carga en progreso, retornando datos actuales");
    return { success: true, data: noticias.value };
  }

  loading.value = true;
  error.value = null;

  try {
    let query = supabase
      .from("noticias")
      .select("*")
      .order("created_at", { ascending: false });

    if (barrio) {
      // User has neighborhood: show global + parish + 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 + parish news (no specific neighborhood)
      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 || [];
    console.log(`βœ… ${noticias.value.length} noticias cargadas para el usuario`);

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

Fetching All News (Admin)

Administrators can view all news regardless of targeting:
const fetchTodasLasNoticias = async () => {
  if (loading.value) {
    return { success: true, data: noticias.value };
  }

  loading.value = true;
  error.value = null;

  try {
    console.log("πŸ‘€ Administrador: cargando TODAS las noticias sin filtros");

    const { data, error: fetchError } = await supabase
      .from("noticias")
      .select("*")
      .order("created_at", { ascending: false });

    if (fetchError) throw fetchError;

    noticias.value = data || [];
    console.log(`βœ… ${noticias.value.length} noticias cargadas (TODAS - Admin)`);

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

Creating News (Admin)

Only administrators can create news articles:
const crearNoticia = async (noticia: Omit<InsertNoticia, "administrador_id">) => {
  loading.value = true;
  error.value = null;

  try {
    const authStore = useAuthStore();
    if (!authStore.user) throw new Error("Usuario no autenticado");

    // Verify user is an administrator
    const { data: admin } = await supabase
      .from("administradores")
      .select("id")
      .eq("id", authStore.user.id)
      .single();

    if (!admin) throw new Error("Usuario no es administrador");

    const { data, error: insertError } = await supabase
      .from("noticias")
      .insert({
        ...noticia,
        administrador_id: authStore.user.id,
      })
      .select()
      .single();

    if (insertError) throw insertError;

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

Updating News

const actualizarNoticia = async (
  id: string,
  updates: Partial<Omit<InsertNoticia, "administrador_id">>
) => {
  loading.value = true;
  error.value = null;

  try {
    const { data, error: updateError } = await supabase
      .from("noticias")
      .update({ ...updates, updated_at: new Date().toISOString() })
      .eq("id", id)
      .select()
      .single();

    if (updateError) throw updateError;

    // Update in local list
    const index = noticias.value.findIndex((n) => n.id === id);
    if (index !== -1) {
      noticias.value[index] = data;
    }

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

Geolocation Filtering

Query Builder Pattern

The system uses Supabase’s query builder with OR conditions:
// Example: User in "Tarqui" parish, "Jocay" neighborhood
const query = supabase
  .from("noticias")
  .select("*")
  .or(
    // Global news (no location targeting)
    `parroquia_destino.is.null,` +
    // Parish-wide news for Tarqui
    `and(parroquia_destino.eq.Tarqui,barrio_destino.is.null),` +
    // Specific news for Jocay neighborhood in Tarqui
    `and(parroquia_destino.eq.Tarqui,barrio_destino.eq.Jocay)`
  )
  .order("created_at", { ascending: false });

Filtering Examples

-- Visible to all users
SELECT * FROM noticias 
WHERE parroquia_destino IS NULL
ORDER BY created_at DESC;

Usage Examples

In Vue Components

<script setup lang="ts">
import { onMounted } from "vue";
import { useNoticiasStore } from "@/stores/noticias.store";
import { useAuthStore } from "@/stores/auth.store";

const noticiasStore = useNoticiasStore();
const authStore = useAuthStore();

onMounted(async () => {
  const usuario = authStore.usuario;
  
  if (authStore.isAdministrador()) {
    // Fetch all news for admin
    await noticiasStore.fetchTodasLasNoticias();
  } else if (authStore.isCiudadano() && usuario) {
    // Fetch location-filtered news for citizen
    await noticiasStore.fetchNoticiasUsuario(
      usuario.parroquia,
      usuario.barrio
    );
  }
});
</script>

<template>
  <div v-if="noticiasStore.loading">Cargando noticias...</div>
  
  <div v-else>
    <article v-for="noticia in noticiasStore.noticias" :key="noticia.id">
      <h2>{{ noticia.titulo }}</h2>
      <img v-if="noticia.imagen_url" :src="noticia.imagen_url" />
      <p>{{ noticia.contenido }}</p>
      
      <!-- Show targeting info for admins -->
      <div v-if="authStore.isAdministrador()">
        <span v-if="!noticia.parroquia_destino">🌍 Global</span>
        <span v-else-if="noticia.barrio_destino">
          πŸ“ {{ noticia.parroquia_destino }} - {{ noticia.barrio_destino }}
        </span>
        <span v-else>
          πŸ“ {{ noticia.parroquia_destino }}
        </span>
      </div>
    </article>
  </div>
</template>

Performance Optimization

Concurrent Fetch Prevention

The store prevents concurrent API calls to improve performance:
if (loading.value) {
  console.log("⏳ Ya hay una carga en progreso, retornando datos actuales");
  return { success: true, data: noticias.value };
}

Query Optimization

The Supabase query uses indexed columns (parroquia_destino, barrio_destino) for optimal performance. Make sure to add indexes in your database:
CREATE INDEX idx_noticias_parroquia ON noticias(parroquia_destino);
CREATE INDEX idx_noticias_barrio ON noticias(parroquia_destino, barrio_destino);
CREATE INDEX idx_noticias_created ON noticias(created_at DESC);

Best Practices

Always order by created_at DESC to show newest news first. This is critical for user experience.
Store images in Supabase Storage and reference them via imagen_url. Optimize images before upload (max 1200px width, compressed).
Use the loading flag to prevent duplicate API calls when components mount simultaneously.
Always handle errors gracefully and provide user feedback. The store’s error state can be used to display error messages.

Reports System

Similar geolocation-based targeting for citizen reports

Supabase Queries

Advanced query patterns and filtering

Build docs developers (and LLMs) love