Initial independent version of CrAPP

This commit is contained in:
Ivan Cacciari
2026-08-06 08:36:47 +02:00
commit 330669176d
167 changed files with 24096 additions and 0 deletions
@@ -0,0 +1,59 @@
import { createFileRoute } from "@tanstack/react-router";
import { completaTurni, eventiPalloni, eventoPrecedente, oggiISO } from "@/lib/palloni-core";
import { inviaPush } from "@/lib/webpush.server";
import { leggiEventi } from "@/lib/eventi.server";
export const Route = createFileRoute("/api/public/promemoria-palloni")({
server: {
handlers: {
POST: async () => {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: righe } = await supabaseAdmin
.from("turni_palloni")
.select("evento_id, giocatore_id");
const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi();
const turni = completaTurni(salvati, eventi);
const oggi = oggiISO();
const destinatari = new Set<string>();
for (const evento of eventiPalloni(eventi)) {
if (evento.data !== oggi) continue;
const incaricato = turni[evento.id];
if (incaricato) destinatari.add(incaricato);
const prima = eventoPrecedente(eventi, evento.id);
const precedente = prima ? turni[prima.id] : undefined;
if (precedente) destinatari.add(precedente);
}
if (destinatari.size === 0) return Response.json({ inviate: 0 });
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id")
.in("giocatore_id", [...destinatari]);
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
const stato = await inviaPush(iscrizione.endpoint);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("promemoria-palloni", error);
}
}
return Response.json({ inviate });
},
},
},
});
+12
View File
@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/public/push-config")({
server: {
handlers: {
GET: async () => {
const publicKey = process.env["VAPID_PUBLIC_KEY"] ?? null;
return Response.json({ publicKey });
},
},
},
});
+84
View File
@@ -0,0 +1,84 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { formatData, giocatori } from "@/lib/crapp-data";
import {
completaTurni,
eventiPalloni,
eventoPrecedente,
eventoSuccessivo,
oggiISO,
} from "@/lib/palloni-core";
import { leggiEventi } from "@/lib/eventi.server";
const schema = z.object({ endpoint: z.string().url().max(1000) });
export const Route = createFileRoute("/api/public/push-messaggio")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
// Messaggio in coda (es. sollecito presenze): ha la precedenza e viene consumato.
const { data: promemoria } = await supabaseAdmin
.from("promemoria_push")
.select("id, titolo, testo")
.eq("endpoint", parsed.data.endpoint)
.order("creato_il", { ascending: false })
.limit(1)
.maybeSingle();
if (promemoria) {
await supabaseAdmin.from("promemoria_push").delete().eq("endpoint", parsed.data.endpoint);
return Response.json({ title: promemoria.titolo, body: promemoria.testo });
}
const { data: iscrizione } = await supabaseAdmin
.from("push_subscriptions")
.select("giocatore_id")
.eq("endpoint", parsed.data.endpoint)
.maybeSingle();
if (!iscrizione) return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." });
const { data: righe } = await supabaseAdmin
.from("turni_palloni")
.select("evento_id, giocatore_id");
const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi();
const turni = completaTurni(salvati, eventi);
const oggi = oggiISO();
const mioId = iscrizione.giocatore_id;
const nome = giocatori.find((g) => g.id === mioId)?.nome ?? "";
for (const evento of eventiPalloni(eventi)) {
if (evento.data !== oggi) continue;
const prima = eventoPrecedente(eventi, evento.id);
if (prima && turni[prima.id] === mioId) {
return Response.json({
title: "Porta i palloni oggi",
body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`,
});
}
if (turni[evento.id] === mioId) {
const dopo = eventoSuccessivo(eventi, evento.id);
return Response.json({
title: "Tocca a te prendere i palloni",
body: dopo
? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.`
: `A fine ${evento.titolo} porta a casa i palloni.`,
});
}
}
return Response.json({
title: "CrAPP · Turno palloni",
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
});
},
},
},
});
+46
View File
@@ -0,0 +1,46 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const schemaIscrizione = z.object({
endpoint: z.string().url().max(1000),
giocatoreId: z.string().min(1).max(50),
p256dh: z.string().min(1).max(500),
auth: z.string().min(1).max(500),
});
const schemaCancellazione = z.object({ endpoint: z.string().url().max(1000) });
export const Route = createFileRoute("/api/public/push-subscribe")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schemaIscrizione.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { error } = await supabaseAdmin.from("push_subscriptions").upsert(
{
endpoint: parsed.data.endpoint,
giocatore_id: parsed.data.giocatoreId,
p256dh: parsed.data.p256dh,
auth: parsed.data.auth,
},
{ onConflict: "endpoint" },
);
if (error) {
console.error("push-subscribe", error);
return new Response("Errore salvataggio", { status: 500 });
}
return Response.json({ ok: true });
},
DELETE: async ({ request }) => {
const parsed = schemaCancellazione.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
await supabaseAdmin.from("push_subscriptions").delete().eq("endpoint", parsed.data.endpoint);
return Response.json({ ok: true });
},
},
},
});
@@ -0,0 +1,74 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { formatData, giocatori } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({
eventoId: z.string().min(1).max(50),
da: z.string().min(1).max(60).optional(),
});
export const Route = createFileRoute("/api/public/sollecita-presenze")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const eventi = await leggiEventi();
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
if (!evento) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: righe } = await supabaseAdmin
.from("risposte_presenze")
.select("giocatore_id, stato")
.eq("evento_id", evento.id);
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
const destinatari = giocatori
.filter((g) => {
const stato = stati.get(g.id);
return stato === undefined || stato === "forse";
})
.map((g) => g.id);
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id")
.in("giocatore_id", destinatari);
const titolo = "Manca la tua risposta";
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
} una conferma: presente, assente o in ritardo?`;
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
await supabaseAdmin
.from("promemoria_push")
.insert({ endpoint: iscrizione.endpoint, titolo, testo });
const stato = await inviaPush(iscrizione.endpoint);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("sollecita-presenze", error);
}
}
return Response.json({ inviate, destinatari: destinatari.length });
},
},
},
});