ENRE_WEB/gestor/almacen.go
SITO ce3127baff Sección «Pedir evento» con gestor de reservas y calendario legible en móvil
- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde),
  findes tranqui y con ruido, solo findes por defecto con opción de ver la
  semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se
  vuelve al formulario con la fecha puesta.
- Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y
  CSRF, cupo y ventana de la asamblea, caducidades, registro append-only.
- La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en
  «Todas las fechas» quita o modifica cualquier evento.
- Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas
  desde el servidor; ninguna contraseña va en el repositorio.
- Botón «Pedir evento» arriba de la barra lateral en todas las páginas.
- Calendario semanal en móvil: lista de días con nombre y hora de cada cita.
- Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
2026-09-23 11:32:25 +02:00

238 lines
5.4 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// Apunte es una linea del registro. El registro solo crece: cada cambio se
// escribe al final y nunca se reescribe nada. Al arrancar se relee entero para
// reconstruir el estado en memoria, asi que el fichero es a la vez la base de
// datos y el acta de la asamblea.
type Apunte struct {
Ts time.Time `json:"ts"`
Tipo string `json:"tipo"`
Por string `json:"por,omitempty"`
Cuenta *Cuenta `json:"cuenta,omitempty"`
Evento *Evento `json:"evento,omitempty"`
Excepcion *Excepcion `json:"excepcion,omitempty"`
}
const (
ApunteCuenta = "cuenta"
ApunteEvento = "evento"
ApunteExcepcion = "excepcion"
)
// Almacen aisla el resto del programa de como se guardan los datos. Hoy es un
// registro de texto; si algun dia hace falta SQL, se cambia solo esta pieza.
type Almacen interface {
Cuentas() []Cuenta
BuscarCuenta(usuario string) (Cuenta, bool)
GuardarCuenta(por string, c Cuenta) error
Eventos() []Evento
BuscarEvento(id string) (Evento, bool)
GuardarEvento(por string, e Evento) error
Excepciones() map[Dia]Turno
GuardarExcepcion(por string, x Excepcion) error
}
type Registro struct {
mu sync.RWMutex
f *os.File
ruta string
cuentas map[string]Cuenta
eventos map[string]Evento
excepciones map[Dia]Turno
}
func AbrirRegistro(dir string) (*Registro, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ruta := filepath.Join(dir, "registro.jsonl")
r := &Registro{
ruta: ruta,
cuentas: map[string]Cuenta{},
eventos: map[string]Evento{},
excepciones: map[Dia]Turno{},
}
if err := r.releer(); err != nil {
return nil, err
}
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
r.f = f
return r, nil
}
func (r *Registro) releer() error {
f, err := os.Open(r.ruta)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
n := 0
for sc.Scan() {
n++
linea := sc.Bytes()
if len(linea) == 0 {
continue
}
var a Apunte
if err := json.Unmarshal(linea, &a); err != nil {
return fmt.Errorf("%s linea %d: %w", r.ruta, n, err)
}
r.aplicar(a)
}
return sc.Err()
}
func (r *Registro) aplicar(a Apunte) {
switch a.Tipo {
case ApunteCuenta:
if a.Cuenta != nil {
r.cuentas[a.Cuenta.Usuario] = *a.Cuenta
}
case ApunteEvento:
if a.Evento == nil {
return
}
if _, err := a.Evento.Desde.Time(); err != nil {
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
return
}
if _, err := a.Evento.Hasta.Time(); err != nil {
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
return
}
r.eventos[a.Evento.ID] = *a.Evento
case ApunteExcepcion:
if a.Excepcion != nil {
if _, err := a.Excepcion.Viernes.Time(); err != nil {
log.Printf("excepcion de finde ignorada: %v", err)
return
}
if a.Excepcion.Turno == "" {
// Turno vacio significa volver a la alternancia automatica.
delete(r.excepciones, a.Excepcion.Viernes)
} else {
r.excepciones[a.Excepcion.Viernes] = a.Excepcion.Turno
}
}
}
}
// anotar escribe el apunte al final del fichero y lo sincroniza a disco antes
// de dar la operacion por buena. Con el candado ya tomado por quien llama.
func (r *Registro) anotar(a Apunte) error {
a.Ts = time.Now()
b, err := json.Marshal(a)
if err != nil {
return err
}
b = append(b, '\n')
if _, err := r.f.Write(b); err != nil {
return err
}
if err := r.f.Sync(); err != nil {
return err
}
r.aplicar(a)
return nil
}
func (r *Registro) Cerrar() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.f == nil {
return nil
}
return r.f.Close()
}
func (r *Registro) Cuentas() []Cuenta {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Cuenta, 0, len(r.cuentas))
for _, c := range r.cuentas {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Usuario < out[j].Usuario })
return out
}
func (r *Registro) BuscarCuenta(usuario string) (Cuenta, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
c, ok := r.cuentas[usuario]
return c, ok
}
func (r *Registro) GuardarCuenta(por string, c Cuenta) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteCuenta, Por: por, Cuenta: &c})
}
func (r *Registro) Eventos() []Evento {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Evento, 0, len(r.eventos))
for _, e := range r.eventos {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Desde != out[j].Desde {
return out[i].Desde < out[j].Desde
}
return out[i].ID < out[j].ID
})
return out
}
func (r *Registro) BuscarEvento(id string) (Evento, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.eventos[id]
return e, ok
}
func (r *Registro) GuardarEvento(por string, e Evento) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteEvento, Por: por, Evento: &e})
}
func (r *Registro) Excepciones() map[Dia]Turno {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[Dia]Turno, len(r.excepciones))
for k, v := range r.excepciones {
out[k] = v
}
return out
}
func (r *Registro) GuardarExcepcion(por string, x Excepcion) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteExcepcion, Por: por, Excepcion: &x})
}