push notifications

This commit is contained in:
2026-08-15 12:00:28 +03:00
parent 8936ed1008
commit f206b0db9a
14 changed files with 1799 additions and 0 deletions
@@ -0,0 +1,51 @@
<?php
use Phinx\Migration\AbstractMigration;
/**
* Token dei dispositivi per le notifiche push (Firebase Cloud Messaging).
* Un utente puo' avere piu' dispositivi, quindi piu' token.
*/
final class CreateUserdevicetokenTable extends AbstractMigration
{
public function change(): void
{
$this->table('userdevicetoken', ['id' => 'iduserdevicetoken'])
->addColumn('iduser', 'integer', [
'null' => false,
'comment' => 'auth_users.id',
])
->addColumn('devicetoken', 'string', [
'limit' => 255,
'null' => false,
'comment' => 'Token FCM del dispositivo',
])
->addColumn('platform', 'string', [
'limit' => 20,
'null' => false,
'default' => 'android',
'comment' => 'android / ios',
])
->addColumn('appversion', 'string', [
'limit' => 20,
'null' => true,
])
->addColumn('active', 'char', [
'limit' => 1,
'null' => false,
'default' => 'Y',
'comment' => 'N quando FCM segnala il token come non valido',
])
->addColumn('created_at', 'datetime', [
'default' => 'CURRENT_TIMESTAMP',
'null' => false,
])
->addColumn('lastseen_at', 'datetime', [
'null' => true,
'comment' => 'Ultima volta che l\'app ha registrato questo token',
])
->addIndex(['devicetoken'], ['unique' => true, 'name' => 'uq_userdevicetoken_token'])
->addIndex(['iduser', 'active'], ['name' => 'idx_userdevicetoken_user'])
->create();
}
}
@@ -0,0 +1,88 @@
<?php
use Phinx\Migration\AbstractMigration;
final class CreateNotificationsettingTable extends AbstractMigration
{
public function change(): void
{
$this->table('notificationsetting', ['id' => 'idnotificationsetting'])
->addColumn('notificationtype', 'string', [
'limit' => 30,
'null' => false,
'comment' => 'lesson / order / certificate',
])
->addColumn('daysbefore', 'integer', [
'null' => false,
'default' => 0,
'comment' => 'Giorni di anticipo; 0 = il giorno stesso',
])
->addColumn('sendhour', 'string', [
'limit' => 5,
'null' => false,
'default' => '08:00',
'comment' => 'Ora di invio del cron (HH:MM)',
])
->addColumn('active', 'char', [
'limit' => 1,
'null' => false,
'default' => 'Y',
])
->addColumn('title', 'string', [
'limit' => 150,
'null' => false,
])
->addColumn('body', 'text', [
'null' => false,
])
->addIndex(
['notificationtype', 'daysbefore'],
['unique' => true, 'name' => 'uq_notificationsetting_type']
)
->insert([
[
'notificationtype' => 'lesson',
'daysbefore' => 0,
'sendhour' => '08:00',
'active' => 'Y',
'title' => 'La tua lezione di oggi',
'body' => 'Ciao {first_name}, oggi hai {servicename} alle {time}. Ti aspettiamo!',
],
[
'notificationtype' => 'order',
'daysbefore' => 5,
'sendhour' => '08:00',
'active' => 'Y',
'title' => 'Il tuo abbonamento sta per scadere',
'body' => 'Ciao {first_name}, il tuo abbonamento scade il {expiredate}. '
. 'Ricordati di prenotare le lezioni rimaste.',
],
[
'notificationtype' => 'order',
'daysbefore' => 0,
'sendhour' => '08:00',
'active' => 'Y',
'title' => 'Il tuo abbonamento scade oggi',
'body' => 'Ciao {first_name}, oggi e\' l\'ultimo giorno del tuo abbonamento.',
],
[
'notificationtype' => 'certificate',
'daysbefore' => 7,
'sendhour' => '08:00',
'active' => 'Y',
'title' => 'Certificato medico in scadenza',
'body' => 'Ciao {first_name}, il tuo certificato medico scade il {expiredate}.',
],
[
'notificationtype' => 'certificate',
'daysbefore' => 5,
'sendhour' => '08:00',
'active' => 'Y',
'title' => 'Certificato medico in scadenza',
'body' => 'Ciao {first_name}, mancano pochi giorni: il tuo certificato medico '
. 'scade il {expiredate}.',
],
])
->create();
}
}
@@ -0,0 +1,53 @@
<?php
use Phinx\Migration\AbstractMigration;
final class CreateNotificationlogTable extends AbstractMigration
{
public function change(): void
{
$this->table('notificationlog', ['id' => 'idnotificationlog'])
->addColumn('iduser', 'integer', [
'null' => false,
'comment' => 'auth_users.id',
])
->addColumn('notificationtype', 'string', [
'limit' => 30,
'null' => false,
'comment' => 'lesson / order / certificate',
])
->addColumn('idreference', 'integer', [
'null' => false,
'comment' => 'Id della lezione, ordine o certificato',
])
->addColumn('daysbefore', 'integer', [
'null' => false,
'default' => 0,
])
->addColumn('sent_at', 'datetime', [
'default' => 'CURRENT_TIMESTAMP',
'null' => false,
])
->addColumn('status', 'string', [
'limit' => 20,
'null' => false,
'default' => 'sent',
'comment' => 'sent / failed',
])
->addColumn('devicecount', 'integer', [
'null' => false,
'default' => 0,
'comment' => 'A quanti dispositivi e stata inviata',
])
->addColumn('errormessage', 'string', [
'limit' => 255,
'null' => true,
])
->addIndex(
['iduser', 'notificationtype', 'idreference', 'daysbefore'],
['unique' => true, 'name' => 'uq_notificationlog_invio']
)
->addIndex(['sent_at'], ['name' => 'idx_notificationlog_data'])
->create();
}
}
@@ -0,0 +1,38 @@
<?php
use Phinx\Migration\AbstractMigration;
final class AddMultiTextsToNotificationsetting extends AbstractMigration
{
public function up(): void
{
$this->table('notificationsetting')
->addColumn('title_multi', 'string', [
'limit' => 150,
'null' => true,
'after' => 'title',
'comment' => 'Titolo usato quando la notifica raggruppa piu elementi',
])
->addColumn('body_multi', 'text', [
'null' => true,
'after' => 'body',
'comment' => 'Testo usato quando la notifica raggruppa piu elementi',
])
->update();
$this->execute("
UPDATE notificationsetting
SET title_multi = 'Le tue lezioni di oggi',
body_multi = 'Ciao {first_name}, oggi hai {count} lezioni: {lessons}. Ti aspettiamo!'
WHERE notificationtype = 'lesson'
");
}
public function down(): void
{
$this->table('notificationsetting')
->removeColumn('title_multi')
->removeColumn('body_multi')
->update();
}
}
@@ -0,0 +1,39 @@
<?php
use Phinx\Migration\AbstractMigration;
final class AddMultiTextsDefaults extends AbstractMigration
{
public function up(): void
{
$this->execute("
UPDATE notificationsetting
SET title_multi = 'I tuoi abbonamenti in scadenza',
body_multi = 'Ciao {first_name}, hai {count} abbonamenti in scadenza il {expiredate}: {orders}.'
WHERE notificationtype = 'order' AND daysbefore > 0
");
$this->execute("
UPDATE notificationsetting
SET title_multi = 'I tuoi abbonamenti scadono oggi',
body_multi = 'Ciao {first_name}, oggi scadono {count} dei tuoi abbonamenti: {orders}.'
WHERE notificationtype = 'order' AND daysbefore = 0
");
$this->execute("
UPDATE notificationsetting
SET title_multi = 'Certificati medici in scadenza',
body_multi = 'Ciao {first_name}, hai {count} certificati in scadenza il {expiredate}: {documents}.'
WHERE notificationtype = 'certificate'
");
}
public function down(): void
{
$this->execute("
UPDATE notificationsetting
SET title_multi = NULL, body_multi = NULL
WHERE notificationtype IN ('order', 'certificate')
");
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
require_once __DIR__ . '/_bootstrap.php';
$userId = (int) $user->id;
$metodo = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($metodo === 'POST' || $metodo === 'PUT' || $metodo === 'PATCH') {
$input = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($input) || !array_key_exists('enabled', $input)) {
http_response_code(422);
echo json_encode(['success' => false, 'error' => 'Parametro enabled mancante']);
exit;
}
$valore = $input['enabled'];
if (is_bool($valore)) {
$nuovo = $valore ? 'Y' : 'N';
} else {
$nuovo = strtoupper(trim((string) $valore)) === 'Y' ? 'Y' : 'N';
}
$stmt = $db->prepare("UPDATE auth_users SET pushnotification = :valore WHERE id = :uid");
$stmt->execute([':valore' => $nuovo, ':uid' => $userId]);
echo json_encode(['success' => true, 'push_enabled' => $nuovo]);
exit;
}
$stmt = $db->prepare("SELECT pushnotification FROM auth_users WHERE id = :uid LIMIT 1");
$stmt->execute([':uid' => $userId]);
echo json_encode([
'success' => true,
'push_enabled' => (string) ($stmt->fetchColumn() ?: 'Y'),
]);
+68
View File
@@ -0,0 +1,68 @@
<?php
require_once __DIR__ . '/_bootstrap.php';
$userId = (int) $user->id;
$metodo = $_SERVER['REQUEST_METHOD'] ?? 'POST';
$input = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($input)) {
$input = [];
}
$deviceToken = trim((string) ($input['device_token'] ?? ''));
if ($deviceToken === '') {
http_response_code(422);
echo json_encode(['success' => false, 'error' => 'device_token mancante']);
exit;
}
if ($metodo === 'DELETE') {
$stmt = $db->prepare("
UPDATE userdevicetoken
SET active = 'N'
WHERE devicetoken = :token AND iduser = :uid
");
$stmt->execute([':token' => $deviceToken, ':uid' => $userId]);
echo json_encode(['success' => true, 'deactivated' => $stmt->rowCount()]);
exit;
}
$platform = strtolower(trim((string) ($input['platform'] ?? 'android')));
if (!in_array($platform, ['android', 'ios'], true)) {
$platform = 'android';
}
$appVersion = trim((string) ($input['app_version'] ?? ''));
if ($appVersion === '') {
$appVersion = null;
}
$stmt = $db->prepare("
INSERT INTO userdevicetoken (iduser, devicetoken, platform, appversion, active, lastseen_at)
VALUES (:uid, :token, :platform, :versione, 'Y', NOW())
ON DUPLICATE KEY UPDATE
iduser = VALUES(iduser),
platform = VALUES(platform),
appversion = COALESCE(VALUES(appversion), appversion),
active = 'Y',
lastseen_at = NOW()
");
$stmt->execute([
':uid' => $userId,
':token' => $deviceToken,
':platform' => $platform,
':versione' => $appVersion,
]);
$stmt = $db->prepare("SELECT pushnotification FROM auth_users WHERE id = :uid LIMIT 1");
$stmt->execute([':uid' => $userId]);
$pushEnabled = (string) ($stmt->fetchColumn() ?: 'Y');
echo json_encode([
'success' => true,
'push_enabled' => $pushEnabled,
]);
+374
View File
@@ -0,0 +1,374 @@
<?php
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
use Dotenv\Dotenv;
date_default_timezone_set('Europe/Rome');
class PushSender
{
/** @var string */
private $projectId;
/**
* @var string Endpoint di Google. Si puo' sovrascrivere con FCM_OAUTH_URL /
* FCM_BASE_URL nel .env: serve SOLO per i test locali, che puntano a un
* finto server FCM. In produzione queste variabili non vanno impostate.
*/
private $oauthUrl;
/** @var string */
private $baseUrl;
/** @var array Contenuto del service account JSON */
private $credentials;
/** @var string|null Token OAuth2 in cache per la durata dello script */
private $accessToken = null;
/** @var int Timestamp di scadenza del token in cache */
private $accessTokenExpiry = 0;
/**
* @var string|null Se l'autenticazione con Google fallisce, il motivo resta qui:
* gli invii successivi escono subito invece di ritentare per ogni utente.
*/
private $authFallita = null;
public function __construct()
{
$rootPath = dirname(__DIR__, 2);
// Le variabili possono essere gia' state caricate da un altro require:
// safeLoad non esplode se il .env manca o e' gia' in memoria.
$dotenv = Dotenv::createImmutable($rootPath);
$dotenv->safeLoad();
$this->projectId = $_ENV['FCM_PROJECT_ID'] ?? '';
$credentialsPath = $_ENV['FCM_CREDENTIALS_PATH'] ?? '';
$this->oauthUrl = $_ENV['FCM_OAUTH_URL'] ?? 'https://oauth2.googleapis.com/token';
$this->baseUrl = $_ENV['FCM_BASE_URL'] ?? 'https://fcm.googleapis.com';
if ($this->projectId === '' || $credentialsPath === '') {
throw new RuntimeException(
'FCM_PROJECT_ID o FCM_CREDENTIALS_PATH mancanti nel file .env'
);
}
// Percorso relativo -> lo consideriamo a partire dalla root del progetto.
if ($credentialsPath[0] !== '/') {
$credentialsPath = $rootPath . '/' . $credentialsPath;
}
if (!is_readable($credentialsPath)) {
throw new RuntimeException("Service account non leggibile: $credentialsPath");
}
$json = json_decode((string) file_get_contents($credentialsPath), true);
if (!is_array($json) || empty($json['client_email']) || empty($json['private_key'])) {
throw new RuntimeException("Service account non valido: $credentialsPath");
}
$this->credentials = $json;
}
/**
* Invia la notifica a tutti i dispositivi attivi dell'utente.
* I token rifiutati da FCM vengono disattivati (active = 'N').
*
* @return array ['sent' => int, 'failed' => int, 'errors' => string[], 'no_devices' => bool]
* no_devices = l'utente non ha dispositivi registrati: non e'
* un errore, semplicemente non ha (ancora) aperto l'app.
*/
public function sendToUser(PDO $db, int $iduser, string $title, string $body, array $data = []): array
{
$stmt = $db->prepare("
SELECT iduserdevicetoken, devicetoken
FROM userdevicetoken
WHERE iduser = :uid AND active = 'Y'
");
$stmt->execute([':uid' => $iduser]);
$devices = $stmt->fetchAll(PDO::FETCH_ASSOC);
$esito = ['sent' => 0, 'failed' => 0, 'errors' => [], 'no_devices' => !$devices];
foreach ($devices as $device) {
$risultato = $this->sendToToken($device['devicetoken'], $title, $body, $data);
if ($risultato['ok']) {
$esito['sent']++;
continue;
}
$esito['failed']++;
$esito['errors'][] = $risultato['error'];
// Token non piu' valido (app disinstallata, token rigenerato):
// lo spegniamo per non riprovarci ad ogni giro di cron.
if ($risultato['invalid_token']) {
$upd = $db->prepare("
UPDATE userdevicetoken SET active = 'N' WHERE iduserdevicetoken = :id
");
$upd->execute([':id' => $device['iduserdevicetoken']]);
}
}
return $esito;
}
/**
* Invia la notifica a un singolo token.
*
* @return array ['ok' => bool, 'error' => string, 'invalid_token' => bool]
*/
public function sendToToken(string $deviceToken, string $title, string $body, array $data = []): array
{
// Autenticazione gia' fallita in questo giro: inutile riprovare per ogni
// singolo utente, il cron deve solo finire in fretta e scrivere il log.
if ($this->authFallita !== null) {
return ['ok' => false, 'error' => $this->authFallita, 'invalid_token' => false];
}
try {
$accessToken = $this->getAccessToken();
} catch (Throwable $e) {
// Un problema di credenziali o di rete non deve interrompere il cron:
// lo registriamo e le notifiche restanti risulteranno "failed".
$this->authFallita = 'auth: ' . $e->getMessage();
return ['ok' => false, 'error' => $this->authFallita, 'invalid_token' => false];
}
$url = "{$this->baseUrl}/v1/projects/{$this->projectId}/messages:send";
// FCM accetta solo stringhe dentro "data".
$payloadData = [];
foreach ($data as $chiave => $valore) {
$payloadData[(string) $chiave] = (string) $valore;
}
$messaggio = [
'message' => [
'token' => $deviceToken,
'notification' => [
'title' => $title,
'body' => $body,
],
// Attenzione: un array PHP vuoto diventa [] in JSON, ma FCM si
// aspetta un oggetto e risponde 400. Se non ci sono dati da
// mandare, il campo va proprio omesso.
'data' => (object) $payloadData,
'android' => ['priority' => 'high'],
'apns' => [
'headers' => ['apns-priority' => '10'],
'payload' => ['aps' => ['sound' => 'default']],
],
],
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json; charset=UTF-8',
],
CURLOPT_POSTFIELDS => json_encode($messaggio, JSON_UNESCAPED_UNICODE),
]);
$risposta = curl_exec($ch);
$codice = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$errCurl = curl_error($ch);
curl_close($ch);
if ($risposta === false) {
return ['ok' => false, 'error' => "curl: $errCurl", 'invalid_token' => false];
}
if ($codice >= 200 && $codice < 300) {
return ['ok' => true, 'error' => '', 'invalid_token' => false];
}
$decodificata = json_decode((string) $risposta, true);
$statoErrore = $decodificata['error']['status'] ?? '';
// UNREGISTERED / NOT_FOUND = app disinstallata o token non piu' valido.
//
// INVALID_ARGUMENT NON va messo qui: quasi sempre vuol dire che siamo
// noi a mandare un messaggio malformato, e spegnere i dispositivi degli
// utenti per un nostro errore li lascerebbe senza notifiche per sempre.
$tokenNonValido = in_array($statoErrore, ['UNREGISTERED', 'NOT_FOUND'], true);
return [
'ok' => false,
'error' => "HTTP $codice: " . substr((string) $risposta, 0, 300),
'invalid_token' => $tokenNonValido,
];
}
/**
* Token OAuth2 per FCM: JWT firmato con la chiave del service account,
* scambiato con un access token da Google. Vale un'ora, lo teniamo in cache.
*/
private function getAccessToken(): string
{
if ($this->accessToken !== null && time() < $this->accessTokenExpiry - 60) {
return $this->accessToken;
}
$adesso = time();
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
$claim = [
'iss' => $this->credentials['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $adesso,
'exp' => $adesso + 3600,
];
$daFirmare = $this->base64UrlEncode(json_encode($header))
. '.' . $this->base64UrlEncode(json_encode($claim));
$firma = '';
if (!openssl_sign($daFirmare, $firma, $this->credentials['private_key'], OPENSSL_ALGO_SHA256)) {
throw new RuntimeException('Firma del JWT fallita (chiave del service account non valida?)');
}
$jwt = $daFirmare . '.' . $this->base64UrlEncode($firma);
$ch = curl_init($this->oauthUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]),
]);
$risposta = curl_exec($ch);
$codice = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decodificata = json_decode((string) $risposta, true);
if ($codice !== 200 || empty($decodificata['access_token'])) {
throw new RuntimeException(
'Impossibile ottenere il token OAuth2 da Google: ' . substr((string) $risposta, 0, 300)
);
}
$this->accessToken = $decodificata['access_token'];
$this->accessTokenExpiry = $adesso + (int) ($decodificata['expires_in'] ?? 3600);
return $this->accessToken;
}
private function base64UrlEncode(string $dati): string
{
return rtrim(strtr(base64_encode($dati), '+/', '-_'), '=');
}
}
/**
* Sostituisce i segnaposto nei testi presi da notificationsetting.
* Esempio: pushRender('Ciao {first_name}', ['first_name' => 'Anna'])
*/
function pushRender(string $testo, array $valori): string
{
foreach ($valori as $chiave => $valore) {
$testo = str_replace('{' . $chiave . '}', (string) $valore, $testo);
}
return $testo;
}
/**
* Legge le impostazioni di un tipo di notifica (lesson / order / certificate).
* Ritorna solo le righe attive, ordinate dal preavviso piu' lungo.
*/
function pushSettings(PDO $db, string $tipo): array
{
$stmt = $db->prepare("
SELECT idnotificationsetting, notificationtype, daysbefore, sendhour,
title, title_multi, body, body_multi
FROM notificationsetting
WHERE notificationtype = :tipo AND active = 'Y'
ORDER BY daysbefore DESC
");
$stmt->execute([':tipo' => $tipo]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Verifica se una notifica e' gia' stata inviata (chiave anti-duplicato).
*
* Contano solo gli invii andati a buon fine: se il tentativo precedente era
* fallito (Firebase irraggiungibile, credenziali sbagliate) il giro successivo
* del cron deve riprovare.
*/
function pushGiaInviata(PDO $db, int $iduser, string $tipo, int $idreference, int $daysbefore): bool
{
$stmt = $db->prepare("
SELECT 1
FROM notificationlog
WHERE iduser = :uid
AND notificationtype = :tipo
AND idreference = :idref
AND daysbefore = :giorni
AND status = 'sent'
LIMIT 1
");
$stmt->execute([
':uid' => $iduser,
':tipo' => $tipo,
':idref' => $idreference,
':giorni' => $daysbefore,
]);
return (bool) $stmt->fetchColumn();
}
/**
* Scrive l'esito dell'invio nello storico.
*
* La chiave unica impedisce i doppioni; se la riga esiste gia' (tentativo
* precedente fallito) viene aggiornata con l'esito nuovo.
*/
function pushRegistraInvio(
PDO $db,
int $iduser,
string $tipo,
int $idreference,
int $daysbefore,
array $esito
): void {
$stmt = $db->prepare("
INSERT INTO notificationlog
(iduser, notificationtype, idreference, daysbefore, status, devicecount, errormessage)
VALUES
(:uid, :tipo, :idref, :giorni, :stato, :dispositivi, :errore)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
devicecount = VALUES(devicecount),
errormessage = VALUES(errormessage),
sent_at = NOW()
");
$stmt->execute([
':uid' => $iduser,
':tipo' => $tipo,
':idref' => $idreference,
':giorni' => $daysbefore,
':stato' => $esito['sent'] > 0 ? 'sent' : 'failed',
':dispositivi' => $esito['sent'],
':errore' => $esito['errors'] ? substr(implode(' | ', $esito['errors']), 0, 255) : null,
]);
}
+6
View File
@@ -120,6 +120,12 @@
<span class="menu-item" data-key="t-email">Sommario Ordini</span>
</a>
</li>
<li>
<a href="push-settings.php" class="">
<i class="bx bx-bell icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Notifiche Push</span>
</a>
</li>
<li class="menu-title" data-key="t-applications">Template</li>
+205
View File
@@ -0,0 +1,205 @@
<?php
/**
* push-certificati-cron.php
* --------------------------------------------------------------------------
* Notifica push per i certificati medici in scadenza.
* Di default: una settimana prima (7 giorni) e 5 giorni prima.
* Giorni e testi si cambiano dal pannello admin (notificationsetting,
* tipo 'certificate').
*
* Da mettere in cron OGNI ORA:
* 0 * * * * /usr/bin/php /percorso/public/push-certificati-cron.php
*
* Prova immediata: php push-certificati-cron.php --force
*
* Due regole importanti:
* 1. Se l'utente ha rinnovato il certificato (ne esiste uno con scadenza
* piu' lontana) NON viene avvisato: il documento vecchio e' gia' stato
* sostituito e ricordarne la scadenza sarebbe solo fastidioso.
* 2. Se pero' ha piu' documenti validi con la STESSA scadenza (capita con
* caricamenti doppi) riceve una notifica sola, non una per documento
* (title_multi / body_multi, segnaposti {count} e {documents}).
* --------------------------------------------------------------------------
*/
require_once __DIR__ . '/class/db-functions.php';
require_once __DIR__ . '/class/push-functions.php';
$logFile = __DIR__ . '/logs/push_certificati_cron.log';
$forza = in_array('--force', $argv ?? [], true) || isset($_GET['force']);
function scriviLog(string $file, string $messaggio): void
{
file_put_contents($file, date('Y-m-d H:i:s') . " - $messaggio\n", FILE_APPEND);
}
$db = DBHandlerSelect::getInstance()->getConnection();
$impostazioni = pushSettings($db, 'certificate');
if (!$impostazioni) {
scriviLog($logFile, 'Nessuna impostazione attiva per i certificati, esco.');
exit;
}
$sender = null;
foreach ($impostazioni as $impostazione) {
$giorniPrima = (int) $impostazione['daysbefore'];
$oraPrevista = (int) substr((string) $impostazione['sendhour'], 0, 2);
if (!$forza && (int) date('G') !== $oraPrevista) {
continue;
}
$dataScadenza = date('Y-m-d', strtotime("+$giorniPrima days"));
scriviLog($logFile, "Controllo certificati con scadenza $dataScadenza (anticipo $giorniPrima giorni).");
// ----------------------------------------------------------------------
// Prendiamo solo i certificati "in corso": la sottoquery cerca la scadenza
// piu' lontana dell'utente e la confronta con quella della riga. Se non
// coincidono vuol dire che il documento e' gia' stato rinnovato.
// Se piu' righe hanno la stessa scadenza massima escono tutte e vengono
// poi raggruppate in un unico messaggio.
// ----------------------------------------------------------------------
$stmt = $db->prepare("
SELECT
c.idcertificateuserprofile,
c.iduser,
c.documentdescription,
c.expirydatedocument,
u.first_name,
u.last_name
FROM certificateuserprofile c
INNER JOIN auth_users u ON c.iduser = u.id
WHERE c.expirydatedocument = :scadenza
AND c.expirydatedocument = (
SELECT MAX(c2.expirydatedocument)
FROM certificateuserprofile c2
WHERE c2.iduser = c.iduser
)
AND u.pushnotification = 'Y'
AND u.status = 'Active'
");
$stmt->execute([':scadenza' => $dataScadenza]);
$certificati = $stmt->fetchAll(PDO::FETCH_ASSOC);
scriviLog($logFile, 'Certificati trovati: ' . count($certificati));
if (!$certificati) {
continue;
}
// ----------------------------------------------------------------------
// Raggruppiamo per utente: piu' documenti con la stessa scadenza -> una
// notifica sola.
// ----------------------------------------------------------------------
$perUtente = [];
$saltate = 0;
foreach ($certificati as $certificato) {
$iduser = (int) $certificato['iduser'];
$idcertificato = (int) $certificato['idcertificateuserprofile'];
if (pushGiaInviata($db, $iduser, 'certificate', $idcertificato, $giorniPrima)) {
$saltate++;
continue;
}
$perUtente[$iduser][] = $certificato;
}
if (!$perUtente) {
scriviLog($logFile, "Anticipo $giorniPrima gg -> niente da inviare (saltati: $saltate).");
continue;
}
if ($sender === null) {
try {
$sender = new PushSender();
} catch (Throwable $e) {
scriviLog($logFile, 'ERRORE configurazione FCM: ' . $e->getMessage());
exit;
}
}
$inviate = 0;
$fallite = 0;
$senzaApp = 0;
foreach ($perUtente as $iduser => $certificatiUtente) {
$primo = $certificatiUtente[0];
$quanti = count($certificatiUtente);
$elenco = [];
foreach ($certificatiUtente as $c) {
$elenco[] = trim((string) ($c['documentdescription'] ?? 'Certificato medico'));
}
$valori = [
'first_name' => $primo['first_name'] ?? '',
'last_name' => $primo['last_name'] ?? '',
'expiredate' => date('d/m/Y', strtotime((string) $primo['expirydatedocument'])),
'count' => $quanti,
'documents' => implode(', ', $elenco),
];
$titolo = $impostazione['title'];
$testo = $impostazione['body'];
if ($quanti > 1) {
if (!empty($impostazione['title_multi'])) {
$titolo = $impostazione['title_multi'];
}
if (!empty($impostazione['body_multi'])) {
$testo = $impostazione['body_multi'];
}
}
$esito = $sender->sendToUser(
$db,
(int) $iduser,
pushRender($titolo, $valori),
pushRender($testo, $valori),
[
'type' => 'certificate',
'idcertificate' => (int) $primo['idcertificateuserprofile'],
'count' => $quanti,
]
);
// Utente senza dispositivi registrati: non ha ancora aperto l'app.
if ($esito['no_devices']) {
$senzaApp++;
continue;
}
foreach ($certificatiUtente as $c) {
pushRegistraInvio(
$db,
(int) $iduser,
'certificate',
(int) $c['idcertificateuserprofile'],
$giorniPrima,
$esito
);
}
if ($esito['sent'] > 0) {
$inviate++;
} else {
$fallite++;
if ($esito['errors']) {
scriviLog($logFile, "Utente $iduser: " . implode(' | ', $esito['errors']));
}
}
}
scriviLog(
$logFile,
"Anticipo $giorniPrima gg -> inviate: $inviate, saltate: $saltate, "
. "fallite: $fallite, senza app: $senzaApp."
);
}
+192
View File
@@ -0,0 +1,192 @@
<?php
/**
* push-lezioni-cron.php
* --------------------------------------------------------------------------
* Notifica push del mattino: ogni giorno all'ora impostata (default 08:00)
* avvisa chi ha lezioni prenotate OGGI.
*
* L'utente riceve UNA SOLA notifica al giorno: se ha piu' lezioni vengono
* raggruppate in un unico messaggio (title_multi / body_multi, con i
* segnaposto {count} e {lessons}).
*
* Da mettere in cron OGNI ORA: lo script controlla da solo se l'ora attuale
* corrisponde a notificationsetting.sendhour, cosi' l'orario si cambia dal
* pannello admin senza toccare il cron del server.
*
* 0 * * * * /usr/bin/php /percorso/public/push-lezioni-cron.php
*
* Per provarlo subito, ignorando l'orario:
* php push-lezioni-cron.php --force
*
* Riceve la notifica solo chi ha auth_users.pushnotification = 'Y'.
* Ogni lezione inclusa viene registrata in notificationlog: se lo script gira
* due volte nello stesso giorno non si ripete nulla.
* --------------------------------------------------------------------------
*/
require_once __DIR__ . '/class/db-functions.php';
require_once __DIR__ . '/class/push-functions.php';
$logFile = __DIR__ . '/logs/push_lezioni_cron.log';
$forza = in_array('--force', $argv ?? [], true) || isset($_GET['force']);
function scriviLog(string $file, string $messaggio): void
{
file_put_contents($file, date('Y-m-d H:i:s') . " - $messaggio\n", FILE_APPEND);
}
$db = DBHandlerSelect::getInstance()->getConnection();
// --------------------------------------------------------------------------
// Impostazioni: per le lezioni c'e' una sola riga (daysbefore = 0)
// --------------------------------------------------------------------------
$impostazioni = pushSettings($db, 'lesson');
if (!$impostazioni) {
scriviLog($logFile, 'Nessuna impostazione attiva per le lezioni, esco.');
exit;
}
$impostazione = $impostazioni[0];
$oraPrevista = (int) substr((string) $impostazione['sendhour'], 0, 2);
if (!$forza && (int) date('G') !== $oraPrevista) {
// Non e' l'ora giusta: usciamo in silenzio (il cron gira ogni ora).
exit;
}
$giorno = date('Y-m-d');
scriviLog($logFile, "Avvio invio lezioni del $giorno (ora impostata {$impostazione['sendhour']}).");
// --------------------------------------------------------------------------
// Lezioni di oggi.
// Stessi filtri usati da userpanel.php / api/my_lessons.php per le lezioni
// valide: status 'booked', non perse, non scadute.
// --------------------------------------------------------------------------
$stmt = $db->prepare("
SELECT
bc.idbookingclass,
bc.iduser,
ss.dateschedule,
s.servicename,
u.first_name,
u.last_name
FROM bookingclass bc
INNER JOIN serviceschedule ss ON bc.idserviceschedule = ss.idserviceschedule
INNER JOIN auth_users u ON bc.iduser = u.id
LEFT JOIN service s ON bc.idservice = s.idservice
WHERE bc.status = 'booked'
AND bc.lostlesson = 'N'
AND bc.expirylesson = 'N'
AND DATE(ss.dateschedule) = :giorno
AND u.pushnotification = 'Y'
AND u.status = 'Active'
ORDER BY bc.iduser ASC, ss.dateschedule ASC
");
$stmt->execute([':giorno' => $giorno]);
$lezioni = $stmt->fetchAll(PDO::FETCH_ASSOC);
// --------------------------------------------------------------------------
// Raggruppiamo per utente: una notifica a testa, non una per lezione.
// Le lezioni gia' notificate (rilancio manuale dello script) vengono escluse;
// se per un utente non ne resta nessuna, l'utente viene saltato del tutto.
// --------------------------------------------------------------------------
$perUtente = [];
foreach ($lezioni as $lezione) {
$iduser = (int) $lezione['iduser'];
if (pushGiaInviata($db, $iduser, 'lesson', (int) $lezione['idbookingclass'], 0)) {
continue;
}
$perUtente[$iduser][] = $lezione;
}
scriviLog($logFile, 'Lezioni da notificare: ' . count($lezioni) . ', utenti da avvisare: ' . count($perUtente));
if (!$perUtente) {
exit;
}
try {
$sender = new PushSender();
} catch (Throwable $e) {
scriviLog($logFile, 'ERRORE configurazione FCM: ' . $e->getMessage());
exit;
}
$inviate = 0;
$fallite = 0;
$senzaApp = 0;
foreach ($perUtente as $iduser => $lezioniUtente) {
$prima = $lezioniUtente[0];
$quante = count($lezioniUtente);
// Elenco leggibile: "Hatha Yoga alle 19:30, Yin Yoga alle 21:00"
$elenco = [];
foreach ($lezioniUtente as $l) {
$ora = (new DateTime($l['dateschedule']))->format('H:i');
$elenco[] = trim(($l['servicename'] ?? '') . " alle $ora");
}
$dataPrima = new DateTime($prima['dateschedule']);
$valori = [
'first_name' => $prima['first_name'] ?? '',
'last_name' => $prima['last_name'] ?? '',
'servicename' => $prima['servicename'] ?? '',
'date' => $dataPrima->format('d/m/Y'),
'time' => $dataPrima->format('H:i'),
'count' => $quante,
'lessons' => implode(', ', $elenco),
];
// Con piu' lezioni usiamo i testi "multi", se l'admin li ha compilati.
$titolo = $impostazione['title'];
$testo = $impostazione['body'];
if ($quante > 1) {
if (!empty($impostazione['title_multi'])) {
$titolo = $impostazione['title_multi'];
}
if (!empty($impostazione['body_multi'])) {
$testo = $impostazione['body_multi'];
}
}
$esito = $sender->sendToUser(
$db,
(int) $iduser,
pushRender($titolo, $valori),
pushRender($testo, $valori),
['type' => 'lesson', 'idbookingclass' => (int) $prima['idbookingclass'], 'count' => $quante]
);
// Utente senza dispositivi registrati: non ha ancora aperto l'app.
// Non e' un errore e non va segnato nello storico, altrimenti quando
// installera' l'app risulterebbe gia' "notificato".
if ($esito['no_devices']) {
$senzaApp++;
continue;
}
// Registriamo tutte le lezioni incluse nel messaggio: cosi' nessuna di
// esse verra' rinotificata se lo script viene rilanciato.
foreach ($lezioniUtente as $l) {
pushRegistraInvio($db, (int) $iduser, 'lesson', (int) $l['idbookingclass'], 0, $esito);
}
if ($esito['sent'] > 0) {
$inviate++;
} else {
$fallite++;
if ($esito['errors']) {
scriviLog($logFile, "Utente $iduser: " . implode(' | ', $esito['errors']));
}
}
}
scriviLog($logFile, "Fine. Notifiche inviate: $inviate, fallite: $fallite, senza app: $senzaApp.");
+189
View File
@@ -0,0 +1,189 @@
<?php
/**
* push-ordini-cron.php
* --------------------------------------------------------------------------
* Notifica push per gli abbonamenti in scadenza.
* Di default: 5 giorni prima della scadenza e il giorno stesso.
* I giorni di anticipo e i testi si cambiano dal pannello admin
* (tabella notificationsetting, tipo 'order').
*
* Da mettere in cron OGNI ORA, come gli altri script push:
* 0 * * * * /usr/bin/php /percorso/public/push-ordini-cron.php
*
* Prova immediata: php push-ordini-cron.php --force
*
* Viene avvisato chi ha un abbonamento in scadenza in quella data, senza altre
* condizioni.
*
* Se una persona ha piu' abbonamenti che scadono lo stesso giorno riceve una
* notifica sola (title_multi / body_multi, segnaposti {count} e {orders}).
* --------------------------------------------------------------------------
*/
require_once __DIR__ . '/class/db-functions.php';
require_once __DIR__ . '/class/push-functions.php';
$logFile = __DIR__ . '/logs/push_ordini_cron.log';
$forza = in_array('--force', $argv ?? [], true) || isset($_GET['force']);
function scriviLog(string $file, string $messaggio): void
{
file_put_contents($file, date('Y-m-d H:i:s') . " - $messaggio\n", FILE_APPEND);
}
$db = DBHandlerSelect::getInstance()->getConnection();
$impostazioni = pushSettings($db, 'order');
if (!$impostazioni) {
scriviLog($logFile, 'Nessuna impostazione attiva per gli ordini, esco.');
exit;
}
$sender = null;
foreach ($impostazioni as $impostazione) {
$giorniPrima = (int) $impostazione['daysbefore'];
$oraPrevista = (int) substr((string) $impostazione['sendhour'], 0, 2);
if (!$forza && (int) date('G') !== $oraPrevista) {
continue;
}
// Data di scadenza che ci interessa in questo giro.
$dataScadenza = date('Y-m-d', strtotime("+$giorniPrima days"));
scriviLog($logFile, "Controllo ordini con scadenza $dataScadenza (anticipo $giorniPrima giorni).");
// ----------------------------------------------------------------------
// Ordini che scadono in quella data.
// ----------------------------------------------------------------------
$stmt = $db->prepare("
SELECT
ob.idorderbook,
ob.iduser,
ob.expireon,
ob.nticket,
ob.product_name,
u.first_name,
u.last_name
FROM orderbook ob
INNER JOIN auth_users u ON ob.iduser = u.id
WHERE ob.expireon = :scadenza
AND (ob.status IS NULL OR ob.status != 'cancelled')
AND u.pushnotification = 'Y'
AND u.status = 'Active'
");
$stmt->execute([':scadenza' => $dataScadenza]);
$ordini = $stmt->fetchAll(PDO::FETCH_ASSOC);
scriviLog($logFile, 'Ordini trovati: ' . count($ordini));
if (!$ordini) {
continue;
}
// ----------------------------------------------------------------------
// Raggruppiamo per utente: chi ha due abbonamenti in scadenza lo stesso
// giorno riceve un messaggio solo.
// ----------------------------------------------------------------------
$perUtente = [];
$saltate = 0;
foreach ($ordini as $ordine) {
$iduser = (int) $ordine['iduser'];
$idorderbook = (int) $ordine['idorderbook'];
if (pushGiaInviata($db, $iduser, 'order', $idorderbook, $giorniPrima)) {
$saltate++;
continue;
}
$perUtente[$iduser][] = $ordine;
}
if (!$perUtente) {
scriviLog($logFile, "Anticipo $giorniPrima gg -> niente da inviare (saltati: $saltate).");
continue;
}
if ($sender === null) {
try {
$sender = new PushSender();
} catch (Throwable $e) {
scriviLog($logFile, 'ERRORE configurazione FCM: ' . $e->getMessage());
exit;
}
}
$inviate = 0;
$fallite = 0;
$senzaApp = 0;
foreach ($perUtente as $iduser => $ordiniUtente) {
$primo = $ordiniUtente[0];
$quanti = count($ordiniUtente);
// Elenco leggibile: "Abbonamento 10 lezioni, Lezione singola"
$elenco = [];
foreach ($ordiniUtente as $o) {
$elenco[] = trim((string) ($o['product_name'] ?? 'Abbonamento'));
}
$valori = [
'first_name' => $primo['first_name'] ?? '',
'last_name' => $primo['last_name'] ?? '',
'expiredate' => date('d/m/Y', strtotime((string) $primo['expireon'])),
'servicename' => $primo['product_name'] ?? '',
'count' => $quanti,
'orders' => implode(', ', $elenco),
];
$titolo = $impostazione['title'];
$testo = $impostazione['body'];
if ($quanti > 1) {
if (!empty($impostazione['title_multi'])) {
$titolo = $impostazione['title_multi'];
}
if (!empty($impostazione['body_multi'])) {
$testo = $impostazione['body_multi'];
}
}
$esito = $sender->sendToUser(
$db,
(int) $iduser,
pushRender($titolo, $valori),
pushRender($testo, $valori),
['type' => 'order', 'idorderbook' => (int) $primo['idorderbook'], 'count' => $quanti]
);
// Utente senza dispositivi registrati: non ha ancora aperto l'app.
if ($esito['no_devices']) {
$senzaApp++;
continue;
}
// Registriamo tutti gli ordini inclusi nel messaggio.
foreach ($ordiniUtente as $o) {
pushRegistraInvio($db, (int) $iduser, 'order', (int) $o['idorderbook'], $giorniPrima, $esito);
}
if ($esito['sent'] > 0) {
$inviate++;
} else {
$fallite++;
if ($esito['errors']) {
scriviLog($logFile, "Utente $iduser: " . implode(' | ', $esito['errors']));
}
}
}
scriviLog(
$logFile,
"Anticipo $giorniPrima gg -> inviate: $inviate, saltate: $saltate, "
. "fallite: $fallite, senza app: $senzaApp."
);
}
+317
View File
@@ -0,0 +1,317 @@
<?php
// headscript.php stampa gia' dell'HTML: bufferizziamo l'output cosi', se chi
// apre la pagina non e' amministratore, possiamo buttarlo via e fare un
// redirect HTTP vero invece di uno via JavaScript.
ob_start();
require_once('include/headscript.php');
?>
<?php
/**
* push-settings.php
* --------------------------------------------------------------------------
* Pannello admin per le notifiche push dell'app: orario di invio, giorni di
* anticipo e testi dei messaggi (tabella notificationsetting).
*
* I cron (push-lezioni-cron.php, push-ordini-cron.php, push-certificati-cron.php)
* girano ogni ora e leggono da qui a che ora devono partire: cambiando l'orario
* su questa pagina non serve toccare il cron del server.
* --------------------------------------------------------------------------
*/
// Solo amministratori: da qui si decidono i messaggi inviati a tutti gli iscritti.
if ((int) $roleuser !== 1) {
ob_end_clean();
redirectTo('/');
}
ob_end_flush();
$pdo = DBHandlerSelect::getInstance()->getConnection();
$impostazioni = $pdo->query("
SELECT idnotificationsetting, notificationtype, daysbefore, sendhour, active,
title, title_multi, body, body_multi
FROM notificationsetting
ORDER BY FIELD(notificationtype, 'lesson', 'order', 'certificate'), daysbefore DESC
")->fetchAll(PDO::FETCH_ASSOC);
// Etichette e spiegazioni per ogni tipo di notifica.
$etichette = [
'lesson' => 'Lezioni del giorno',
'order' => 'Scadenza abbonamento',
'certificate' => 'Scadenza certificato medico',
];
$spiegazioni = [
'lesson' => 'Inviata ogni mattina a chi ha lezioni prenotate per la giornata. '
. 'Se una persona ha piu\' lezioni riceve un solo messaggio: in quel caso vengono usati '
. 'il titolo e il testo "per piu\' lezioni".',
'order' => 'Inviata ai giorni di anticipo indicati rispetto alla data di scadenza '
. 'dell\'abbonamento.',
'certificate' => 'Inviata ai giorni di anticipo indicati rispetto alla scadenza del certificato '
. 'medico. Viene considerato solo il certificato piu\' recente di ogni utente.',
];
$segnaposti = [
'lesson' => '{first_name} {last_name} {servicename} {date} {time} {count} {lessons}',
'order' => '{first_name} {last_name} {expiredate} {servicename} {count} {orders}',
'certificate' => '{first_name} {last_name} {expiredate} {count} {documents}',
];
// Come si chiama il caso "piu' elementi insieme", tipo per tipo.
$etichetteMulti = [
'lesson' => 'piu\' lezioni nello stesso giorno',
'order' => 'piu\' abbonamenti in scadenza lo stesso giorno',
'certificate' => 'piu\' certificati con la stessa scadenza',
];
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Notifiche push</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="YogiBook - Impostazioni notifiche push" name="description" />
<meta content="Advanced Creative Solutions" name="author" />
<link rel="shortcut icon" href="assets/images/favicon.ico">
<!-- Bootstrap Css -->
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<!-- Icons Css -->
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<!-- App Css-->
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@10"></script>
<style>
.custom-card {
margin: 20px auto;
background-color: white;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 20px;
}
.push-hint {
font-size: 12px;
color: #74788d;
}
.push-placeholders {
font-family: monospace;
font-size: 12px;
color: #556ee6;
}
</style>
<script>
$(document).ready(function() {
$('.btn-salva-push').on('click', function() {
var card = $(this).closest('.custom-card');
$.ajax({
url: 'updatepushsetting.php',
method: 'POST',
dataType: 'json',
data: {
idnotificationsetting: card.data('id'),
active: card.find('.push-active').is(':checked') ? 'Y' : 'N',
daysbefore: card.find('.push-daysbefore').val(),
sendhour: card.find('.push-sendhour').val(),
title: card.find('.push-title').val(),
body: card.find('.push-body').val(),
title_multi: card.find('.push-title-multi').val(),
body_multi: card.find('.push-body-multi').val()
},
success: function(response) {
if (response.success) {
Swal.fire({
title: 'Salvato!',
text: 'Impostazione aggiornata con successo.',
icon: 'success',
confirmButtonText: 'OK'
});
} else {
Swal.fire({
title: 'Errore!',
text: response.error,
icon: 'error',
confirmButtonText: 'OK'
});
}
},
error: function() {
Swal.fire({
title: 'Errore!',
text: 'Errore durante la richiesta AJAX.',
icon: 'error',
confirmButtonText: 'OK'
});
}
});
});
});
</script>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Notifiche push</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<div class="row">
<div class="col-xl-12">
<div class="custom-card">
<h4>Notifiche push dell'app</h4>
<p class="push-hint mb-0">
Le notifiche partono solo agli utenti che le hanno attivate nell'app.
I cron girano ogni ora e controllano l'orario impostato qui sotto,
quindi per cambiare l'ora di invio basta modificare questa pagina.
</p>
</div>
</div>
</div>
<?php foreach ($impostazioni as $riga) : ?>
<?php $tipo = $riga['notificationtype']; ?>
<div class="row">
<div class="col-xl-12">
<div class="custom-card" data-id="<?php echo (int) $riga['idnotificationsetting']; ?>">
<h5>
<?php echo htmlspecialchars($etichette[$tipo] ?? $tipo); ?>
<?php if ($tipo !== 'lesson') : ?>
<small class="text-muted">
&mdash; <?php echo (int) $riga['daysbefore'] === 0
? 'il giorno della scadenza'
: (int) $riga['daysbefore'] . ' giorni prima'; ?>
</small>
<?php endif; ?>
</h5>
<p class="push-hint"><?php echo $spiegazioni[$tipo] ?? ''; ?></p>
<div class="row mb-3">
<div class="col-md-3">
<div class="form-check mt-4">
<input class="form-check-input push-active" type="checkbox"
id="active-<?php echo (int) $riga['idnotificationsetting']; ?>"
<?php echo $riga['active'] === 'Y' ? 'checked' : ''; ?>>
<label class="form-check-label"
for="active-<?php echo (int) $riga['idnotificationsetting']; ?>">
Notifica attiva
</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Ora di invio</label>
<input type="text" class="form-control push-sendhour"
value="<?php echo htmlspecialchars($riga['sendhour']); ?>"
placeholder="08:00">
<span class="push-hint">Formato HH:MM</span>
</div>
<div class="col-md-3">
<label class="form-label">Giorni di anticipo</label>
<input type="number" min="0" max="60" class="form-control push-daysbefore"
value="<?php echo (int) $riga['daysbefore']; ?>"
<?php echo $tipo === 'lesson' ? 'readonly' : ''; ?>>
<span class="push-hint">
<?php echo $tipo === 'lesson'
? 'Sempre il giorno stesso'
: '0 = il giorno della scadenza'; ?>
</span>
</div>
</div>
<div class="mb-3">
<label class="form-label">Titolo</label>
<input type="text" class="form-control push-title" maxlength="150"
value="<?php echo htmlspecialchars($riga['title']); ?>">
</div>
<div class="mb-3">
<label class="form-label">Testo</label>
<textarea class="form-control push-body" rows="2"><?php
echo htmlspecialchars($riga['body']);
?></textarea>
</div>
<div class="mb-3">
<label class="form-label">
Titolo quando ci sono <?php echo $etichetteMulti[$tipo] ?? 'piu\' elementi'; ?>
</label>
<input type="text" class="form-control push-title-multi" maxlength="150"
value="<?php echo htmlspecialchars((string) $riga['title_multi']); ?>">
</div>
<div class="mb-3">
<label class="form-label">
Testo quando ci sono <?php echo $etichetteMulti[$tipo] ?? 'piu\' elementi'; ?>
</label>
<textarea class="form-control push-body-multi" rows="2"><?php
echo htmlspecialchars((string) $riga['body_multi']);
?></textarea>
<span class="push-hint">
In quel caso parte comunque UNA sola notifica. Se questi campi restano
vuoti viene usato il testo qui sopra.
</span>
</div>
<p class="mb-2">
<span class="push-hint">Segnaposto disponibili: </span>
<span class="push-placeholders"><?php echo $segnaposti[$tipo] ?? ''; ?></span>
</p>
<button type="button" class="btn btn-primary btn-salva-push">Salva</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
<?php
/**
* updatepushsetting.php
* --------------------------------------------------------------------------
* Salva una riga della tabella notificationsetting (chiamato in AJAX da
* push-settings.php).
*
* Richiede la sessione di un amministratore: da qui si decidono i testi e gli
* orari delle notifiche inviate a tutti gli iscritti.
* --------------------------------------------------------------------------
*/
// Non usiamo include/headscript.php: quello stampa HTML e qui dobbiamo poter
// mandare gli header JSON. extra/auth.php avvia la sessione senza output.
require_once __DIR__ . '/../extra/auth.php';
require_once __DIR__ . '/class/db-functions.php';
header('Content-Type: application/json');
if (!Auth::check()) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Sessione scaduta']);
exit;
}
// Solo amministratori (role_id = 1).
if ((int) Auth::user()->role_id !== 1) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Non autorizzato']);
exit;
}
$pdo = DBHandlerSelect::getInstance()->getConnection();
$id = (int) ($_POST['idnotificationsetting'] ?? 0);
$active = ($_POST['active'] ?? 'N') === 'Y' ? 'Y' : 'N';
$daysbefore = (int) ($_POST['daysbefore'] ?? 0);
$sendhour = trim((string) ($_POST['sendhour'] ?? ''));
$title = trim((string) ($_POST['title'] ?? ''));
$body = trim((string) ($_POST['body'] ?? ''));
// Testi usati quando la notifica raggruppa piu' elementi (piu' lezioni in un
// giorno). Possono restare vuoti: in quel caso si usa il testo singolo.
//
// Attenzione alla differenza: campo inviato vuoto = l'admin lo ha svuotato
// apposta; campo NON inviato = chi chiama non se ne occupa, e quello che c'e'
// gia' nel database non va perso.
$aggiornaMulti = array_key_exists('title_multi', $_POST) || array_key_exists('body_multi', $_POST);
$titleMulti = trim((string) ($_POST['title_multi'] ?? ''));
$bodyMulti = trim((string) ($_POST['body_multi'] ?? ''));
if ($id <= 0) {
echo json_encode(['success' => false, 'error' => 'Impostazione non valida']);
exit;
}
if (!preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $sendhour)) {
echo json_encode(['success' => false, 'error' => "L'ora deve essere nel formato HH:MM (es. 08:00)"]);
exit;
}
if ($daysbefore < 0 || $daysbefore > 60) {
echo json_encode(['success' => false, 'error' => 'I giorni di anticipo devono essere tra 0 e 60']);
exit;
}
if ($title === '' || $body === '') {
echo json_encode(['success' => false, 'error' => 'Titolo e testo non possono essere vuoti']);
exit;
}
if (mb_strlen($title) > 150 || mb_strlen($titleMulti) > 150) {
echo json_encode(['success' => false, 'error' => 'Il titolo non puo superare i 150 caratteri']);
exit;
}
// Il tipo non si cambia da qui: le righe sono quelle previste dai cron.
$stmt = $pdo->prepare("
SELECT notificationtype FROM notificationsetting WHERE idnotificationsetting = :id LIMIT 1
");
$stmt->execute([':id' => $id]);
$tipo = $stmt->fetchColumn();
if (!$tipo) {
echo json_encode(['success' => false, 'error' => 'Impostazione inesistente']);
exit;
}
// Per le lezioni l'anticipo non ha senso: e' sempre l'invio del mattino stesso.
if ($tipo === 'lesson') {
$daysbefore = 0;
}
// La coppia tipo + giorni è unica: due righe uguali romperebbero i cron.
$stmt = $pdo->prepare("
SELECT 1 FROM notificationsetting
WHERE notificationtype = :tipo AND daysbefore = :giorni AND idnotificationsetting != :id
LIMIT 1
");
$stmt->execute([':tipo' => $tipo, ':giorni' => $daysbefore, ':id' => $id]);
if ($stmt->fetchColumn()) {
echo json_encode([
'success' => false,
'error' => "Esiste gia' una notifica di questo tipo con $daysbefore giorni di anticipo",
]);
exit;
}
$stmt = $pdo->prepare("
UPDATE notificationsetting
SET active = :active,
daysbefore = :giorni,
sendhour = :ora,
title = :titolo,
body = :testo"
. ($aggiornaMulti ? ",
title_multi = :titoloMulti,
body_multi = :testoMulti" : '') . "
WHERE idnotificationsetting = :id
");
$parametri = [
':active' => $active,
':giorni' => $daysbefore,
':ora' => $sendhour,
':titolo' => $title,
':testo' => $body,
':id' => $id,
];
if ($aggiornaMulti) {
$parametri[':titoloMulti'] = $titleMulti !== '' ? $titleMulti : null;
$parametri[':testoMulti'] = $bodyMulti !== '' ? $bodyMulti : null;
}
$stmt->execute($parametri);
echo json_encode([
'success' => true,
'daysbefore' => $daysbefore,
]);