Groogle Raffle

Idea

mn-raffle es (otra) implementación de un sistema para sortear premios entre una serie de participantes, como por ejemplo los asistentes a una charla (presencial o virtual) usando GoogleSheet como repositorio para guardar los datos de los participantes y del sorteo

A diferencia de la implementación google-raffle.html, puramente en javascript y embebida en una hoja GoogleSheet, en esta ocasión vamos a usar una aplicación web, desarrollada en Micronaut para el backend y Vue para el frontend

El código del proyecto se encuentra alojado en https://gitlab.com/groogle/mn-raffle
La aplicación desplegada se encuentra en https://mn-raffle-pvidasoftware.cloud.okteto.net/
En este post NO voy a describir exhaustivamente paso a paso cómo se ha desarollado la aplicación, sino las partes que considero más interesantes

Básicamente, un usuario organizador de un sorteo accederá a la aplicación vía web la cual, a través de llamadas REST solicitará datos de participantes, premios, etc contra el backend.

El backend a su vez no tendrá nada de persistencia sino que delegará en Google Sheet la misma. El usuario deberá haberse identificado ante el sistema usando así mismo el mecanismo de autentificación de Google.

Arquitectura

Diagram

Como se puede ver en el diagrama, la aplicación se divide en los siguientes componentes:

  • Una aplicación Vue (Javascript y HTML) que correrá en el navegador del usuario

  • Un backend REST en Micronaut que devuelve la lista de participantes, premios y al que se le indica quién ha ganado qué.

  • Google: GoogleSheet como persistencia y GoogleAuth como autentificación de usuarios ( para futuras funcionalidades que lo requieran)

La aplicación constará de 2 módulos (client y server) que podrán ser desarrollados de forma independiente pero que se empaquetarán como un artefacto único para ser desplegado.

Proyecto

Como ya se ha mencionado, el proyecto va a ser un multi-module de Gradle, client y backend.

Diagram

Client

Para la parte cliente (Vue) lo normal es usar gulp, o herramientas similares para construirlo, sin embargo gracias a los plugins npm que tenemos en gradle, vamos a usarlo tanto para construir la parte cliente como la parte server y de esta forma tener una única tarea capaz de construir ambos y juntarlos en un único artefacto a desplegar.

Por lo demás el proyecto semilla lo crearemos mediante vue-cli siguiendo los tutoriales disponibles en la página oficial de Vue siendo relevante que usaremos:

  • bootstrap-vue para la parte visual

  • route para enrutar las diferentes partes de la aplicación

  • vuex para gestionar el estado de la misma

Así mismo crearemos un interface service que sirva para dialogar con el backend con una implementación fake a usar en el desarrollo del cliente y evitar la necesidad de tener el backend levantado

Backend

El server será una aplicación micronaut típica con los siguientes componentes:

  • micronaut security, en concreto usaremos Google para la autentificación

  • groogle-sheet, un DSL que nos permite acceder a una hoja Google de forma fácil

Debido a que el backend accederá a servicios remotos en Google, deberemos de crear un proyecto en Google Cloud Platform para obtener unas credenciales de servicio. Descargaremos el fichero JSON que las contiene pero nos aseguraremos que NO se versiona junto al código

Para ejecutar el server y poder acceder a la hoja de cálculo que nos indique el usuario deberemos tener una variable de entorno GOOGLE_APPLICATION_CREDENTIALS apuntando a la ruta completa del fichero JSON anterior.

Google

La persistencia se va a realizar utilizando GoogleSheet.

El organizador del sorteo podrá crear tantos documentos y hojas como desee, correspondiendo cada una de ellas a un sorteo.

En la hoja el organizador dispondrá de los nombres de los participantes en una columna, los premios a sortear en otra junto con la cantidad de cada uno de ellos y el sistema anotará a qué participante le ha tocado qué premio.

Si se desea se puede proporcionar un email por cada participante para notificarle vía correo que ha sido agraciado con un premio.

La imagen siguiente es un ejemplo con las filas y columnas a utilizar en cada hoja:

mn raffle
Para que el backend pueda acceder al documento este deberá ser compartido con la cuenta de servicio que se creó en Google Cloud Console.

Backend

Como se ha comentado el backend será una aplicación Micronaut ofreciendo un API:

Diagram

Mediante UserController el front podrá obtener detalles del organizador del sorteo (previa autentificación del mismo usando Google como proveedor de autentificación) como el nombre y el email. Sólo se usa para para fines estadísticos de uso.

RaffleController es el encargado de ofrecer la lista de participantes así como de precios disponibles en cada momento. Requiere para ello que se le indique el id de la hoja de Google así como el nombre del tab que se quiere usar para ese sorteo.

Así mismo acepta que tras un sorteo se le indique quién ha salido ganador y aceptado el premio o bien si no está presente para no volver a ofrecerlo en la lista de participantes.

Para que el backend pueda acceder a la hoja indicada el propietario de esta deberá haberla compartido con una cuenta de servicio creada para ello a través de las opciones de compartir disponible en la propia hoja de GoogleSheet.

Raffle usa el DSL 'Groogle' para leer y escribir en la hoja. Por ejemplo, para leer la lista de participantes:

List<Participant> loadParticipants(String sheetId, String tabId){
    List people = []

    sheetService.withSpreadSheet sheetId, {
        withSheet tabId, {
            writeRange"A3", "C99", {
                get().eachWithIndex{ def entry, int i ->    (1)
                    if( entry[0] && !entry[1])
                        people.add new Participant(name:entry[0], email: entry[2])
                }
            }
        }
    }
    people.sort { Math.random() }
}
1 Leemos un rango de filas-columnas y rellenamos un array con aquellas que tienen datos

Para escribir en la hoja, por ejemplo si un participante no ha acudido y no volver a ofrecerlo, el código sería:

 Boolean notPressent(String sheetId, String tabId, String name){
    sheetService.withSpreadSheet sheetId, {
        withSheet tabId, {
            List<List<String>> range = writeRange("A3", "C99").get() (1)
            List<String> rwinner = range.find{ it[0] && it[0] == name}
            rwinner[1] = "NOT PRESSENT"
            writeRange("A3","C99").set(range)   (2)
        }
    }
    true

}
1 Leemos un rango determinado
2 escribimos un array en la hoja

Como se ha mencionado, el backend contendrá a su vez el build del client como recursos incluidos en el propio jar. De esta forma evitamos requerir otro componente para servir la parte html+javascript

Client

El client va a consistir en una aplicación Vue (con route+state y bootstrap-vue para la parte visual)

Al ser un submodulo del proyecto Gradle, podemos usar el mismo IDE a la vez que mantenemos alineados los dos proyectos.

Lo único que neceitamos para ello es establecer "un puente" entre Gradle y npm para lo que usaremos el plugin org.ysb33r.nodejs.npm de Gradle y crearemos unas task especificas para construir y ejecutar la parte cliente

plugins {
    id 'org.ysb33r.nodejs.base'  version '0.6.2'
    id 'org.ysb33r.nodejs.npm'   version '0.6.2'
}

task npmInstall( type : org.ysb33r.gradle.nodejs.tasks.NpmTask ) {
    group 'build'
    description = 'Install dependencies'
    command 'install'
}

task start( type : org.ysb33r.gradle.nodejs.tasks.NpmTask ) {
    group 'build'
    description = 'Run the client app'
    command 'run'
    cmdArgs 'serve'
}

//others task

State

La aplicación va a consistir en unos estados muy simples

src/store/index.ts
class State {

    busy = false;

    user = {
        name:'',
        email:''
    };

    googleForm = {
        sheetId:'',
        tabId:''
    };

    participants = [];
    prizes =  [];

    winner =  '';
    prize =  '';
}

con unas actions también simples:

src/store/index.ts
    actions: {
    //....
        fetchParticipants( context ){
            return api
                .loadParticipants(context.state.googleForm)
                .then((participants: any) => context.commit('participants', participants))
        },
    //....
        raffle( context, prize ){
            const arr = context.state.participants
            const winner = arr[Math.floor(Math.random() * arr.length)]
            context.commit('prize', prize)
            context.commit('winner', winner['name'])
        },
        acceptWinner( context){
            return api
                .winner( context.state.googleForm, context.state.prize, context.state.winner)
                .then( () => context.dispatch('fetchPrizes') )
                .then( () => context.dispatch('fetchParticipants') )
        },
        notPressent(context){
            return api
                .notPressent( context.state.googleForm, context.state.winner)
                .then( () => context.dispatch('fetchParticipants') )
        }
    },

Usando la configuración por entorno de Vue podremos indicar al store qué implementación de API usar:

env.development
VUE_APP_API_CLIENT = 'mock'
env.production
VUE_APP_API_CLIENT = 'server'

Service

Para poder desarrollar el front sin depender de tener corriendo el backend, vamos a usar un mock que devolverá datos de pruebas contenidos en un JSON con un cierto retraso, simulando que se está realizando una petición http:

src/services/mock/index.ts
const user = mock.user
const prizes = mock.prizes
const vparticipants = mock.participants

const mockFetchData = (mockData: any, time = 0) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(mockData)
        }, time)
    })
}

export default new class {
    loadParticipants(groogleData: any){
        return mockFetchData(vparticipants, 1000) // wait 1s before returning posts
    }
    // others methods
}

Componentes

Por último tendremos una serie de componentes aislados entre sí y desacoplados del api mediante el store anterior

Así un componente como el de capturar la hoja y tab a usar, SheetInput, simplemente se adjunta al state y cuando el usuario rellena el formulario este se actualiza automaticamente.

    <b-input
            id="inline-form-input-tab"
            v-model="$store.state.googleForm.tabId"
            class="mb-2 mr-sm-2 mb-sm-0"
            required
            placeholder="Sheet 1">
    </b-input>

Cuando el usuario pulsa el botón de cargar, el componente ejecutará un action del store que cargue los participantes y premios:

SheetInput.vue
    ...
    <b-form @submit="load" v-if="$store.state.user.name" inline>
    ...

    @Component
    export default class SheetInput extends Vue {

        private busy = false

        load() {
            this.$store.dispatch('fetchPrizes')
            this.$store.dispatch('fetchParticipants')
        }
    }

Así mismo WinnerModal es un componente que mostrará un diálogo modal cuando el sistema eliga un ganador de un premio para que el organizador pueda indicar si lo quiere o no o incluso si no está presente. Para ello se subscribe como un listener del $store esperando a que se produzca una mutación del winner momento en el cual simplemente mostrará el diálogo:

created(){
    this.$store.subscribe( (mutation, state) =>{
        if( mutation.type === 'winner'){
            this.lastWinner = state.winner
            this.$bvModal.show('modal-winner')
        }
    })
}

Build and deploy (Okteto)

Una vez que queramos desplegar una nueva versión simplemente ejecutaremos ./gradlew assembleServerAndClient la cual preparará un jar con los dos componentes.

Así mismo mediante el plugin jib de Gradle podremos generar y subir una imagen Docker con la aplicación a DockerHub (o a tu repositorio).

Como plataforma donde desplegar la aplicación he elegido Okteto (https://okteto.com) el cual cuenta con un plan gratuito muy generoso donde ejecutar este tipo de aplicaciones usando Kubernetes.

Para kubernetizar la aplicación simplemente usamos el fichero que nos creó micronaut (con algunos ajustes)

k8s.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: "mn-raffle"
spec:
  selector:
    matchLabels:
      app: "mn-raffle"
  template:
    metadata:
      labels:
        app: "mn-raffle"
    spec:
      volumes:
      - name: google-cloud-key
        secret:
          secretName: mn-raffle-key
      containers:
        - name: "mn-raffle"
          image: "jagedn/mn-raffle"
          imagePullPolicy: "Always"
          volumeMounts:
            - name: google-cloud-key
              mountPath: /var/secrets/google
          env:
            - name: GOOGLE_APPLICATION_CREDENTIALS
              value: /var/secrets/google/client_secret.json
            - name: MICRONAUT_SECURITY_OAUTH2_CLIENTS_GOOGLE_CLIENT_SECRET
              valueFrom:
                configMapKeyRef:
                  name: mn-raffle
                  key: google_client_secret
            - name: MICRONAUT_SECURITY_OAUTH2_CLIENTS_GOOGLE_CLIENT_ID
              valueFrom:
                configMapKeyRef:
                  name: mn-raffle
                  key: google_client_id
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
            failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
  name: "mn-raffle"
  annotations:
    dev.okteto.com/auto-ingress: "true"
spec:
  selector:
    app: "mn-raffle"
  type: ClusterIP
  ports:
    - port: 8080
      protocol: TCP
      targetPort: 8080

mediante el comando `kubectl apply -f k8s.yml

(como se puede observar las credenciales se encuentran guardadas en un configMap dentro del kubernetes)

Ejemplo

A continuación se muestran algunas pantallas de cómo sería un sorteo

mn raffle 1
Figure 1. FormEntry
mn raffle 2
Figure 2. PariticpantsAndPrizes
mn raffle 3
Figure 3. Winner
mn raffle 4
Figure 4. DiscountPrizeAfterWinner

Este texto ha sido escrito por un humano

This post was written by a human

2019 - 2024 | Mixed with Bootstrap | Baked with JBake v2.6.7