PointTracker/server.js
2025-02-20 16:02:09 -05:00

362 lines
12 KiB
JavaScript

/*
server.js
- Listens on port 80
- Uses "web-push" for push notifications (replace VAPID keys!)
- Timer logic: 15-min usage => ring => optional 2-min => or ignore
- Weekly reset for base usage arrays
- Serves React build from "client/build"
*/
const express = require('express');
const cors = require('cors');
const path = require('path');
const webPush = require('web-push');
// -----------------------------------------------------------------
// REPLACE THESE with your actual VAPID keys from "web-push generate-vapid-keys"
// -----------------------------------------------------------------
const VAPID_PUBLIC_KEY = 'BGTl7xYXEr2gY_O6gmVGYy0DTFlm6vepYUmkt8_6P9PHwOJcHsPZ5CUSEzsoCq7CszPwMyUbq0nG6xjrzJMWZOg';
const VAPID_PRIVATE_KEY = 'jCSzm4m7EQtv_pKw1Ao1cP4KIvoip2NVpfUiEgJnVR4';
// Configure web-push
webPush.setVapidDetails(
'mailto:someone@example.com', // can be any mailto:
VAPID_PUBLIC_KEY,
VAPID_PRIVATE_KEY
);
const app = express();
app.use(cors());
app.use(express.json());
// In-memory push subscriptions (for real usage, store in DB)
let pushSubscriptions = [];
/* ------------------------------------------------------------------
TIMER / WEEKLY RESET LOGIC
------------------------------------------------------------------ */
const SOLO_BASE_COUNT = 5;
const TOGETHER_BASE_COUNT = 5;
let soloUsed = Array(SOLO_BASE_COUNT).fill(false);
let togetherUsed = Array(TOGETHER_BASE_COUNT).fill(false);
let logs = [];
let lastReset = null;
const FIFTEEN_MINUTES = 15 * 60 * 1000;
const TWO_MINUTES = 2 * 60 * 1000;
let timerRunning = false;
let timeRemaining = 0;
let isPaused = false;
let activeUsage = null; // e.g. { category:'solo'|'together', index:number }
let ringing = false;
// Weekly reset => keep array lengths, set usage to false if new Monday
function getMonday(d = new Date()) {
const day = d.getDay(); // 0=Sun,1=Mon,...
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
return d.toISOString().split('T')[0];
}
function resetIfNewWeek() {
const currentMonday = getMonday();
if (!lastReset || lastReset !== currentMonday) {
soloUsed = soloUsed.map(() => false);
togetherUsed = togetherUsed.map(() => false);
lastReset = currentMonday;
console.log('[Server] Weekly reset triggered =>', currentMonday);
}
}
// Timer loop => decrement once per second
setInterval(() => {
if (timerRunning && !isPaused && timeRemaining > 0) {
timeRemaining -= 1000;
if (timeRemaining <= 0) {
timerRunning = false;
timeRemaining = 0;
// If this was the 15-min block => ring
if (!ringing) {
ringing = true;
console.log('[Server] 15-minute ended => ringing=true');
sendPushToAll('Timer ended!', 'Time is up! Tap for details.');
}
}
}
}, 1000);
// Send push to all subscribed
function sendPushToAll(title, body) {
console.log('[Server] Sending push to', pushSubscriptions.length, 'subscribers');
const payload = JSON.stringify({ title, body });
pushSubscriptions.forEach((sub) => {
webPush.sendNotification(sub, payload).catch((err) => {
console.error('[Push Error]', err);
});
});
}
// Middleware => check weekly reset
app.use((req, res, next) => {
resetIfNewWeek();
next();
});
/* ------------------------------------------------------------------
API ROUTES
------------------------------------------------------------------ */
// GET /state => current usage/timer
app.get('/state', (req, res) => {
res.json({
soloUsed,
togetherUsed,
logs,
lastReset,
timerRunning,
timeRemaining,
isPaused,
activeUsage,
ringing,
});
});
// POST /use-bubble => single bubble => 15 min
app.post('/use-bubble', (req, res) => {
if (timerRunning && !ringing && timeRemaining > 0) {
return res.status(400).json({ error: 'A timer is already active.' });
}
// If currently ringing => auto-ignore
if (ringing) {
timerRunning = false;
timeRemaining = 0;
isPaused = false;
activeUsage = null;
ringing = false;
console.log('[Server] Timer was ringing => auto-ignored => new usage');
}
const { category, index } = req.body;
let arr = category==='solo' ? soloUsed : category==='together' ? togetherUsed : null;
if (!arr) return res.status(400).json({ error: 'Invalid category' });
if (index<0 || index>=arr.length) return res.status(400).json({ error: 'Index out of range' });
if (arr[index]) return res.status(400).json({ error: 'Bubble already used' });
arr[index] = true;
logs.push({ type: category, index, usedAt: new Date().toISOString() });
timerRunning = true;
timeRemaining = FIFTEEN_MINUTES;
isPaused = false;
activeUsage = { category, index };
res.json({ success: true });
});
// POST /check-password => verify "mySecretPassword"
app.post('/check-password', (req, res) => {
const { password } = req.body;
if (password !== 'ParentsOnly25') {
return res.status(403).json({ error: 'Incorrect password' });
}
res.json({ success: true });
});
// POST /add-points => append new false items
app.post('/add-points', (req, res) => {
const { password, category, amount } = req.body;
if (password !== 'ParentsOnly25') {
return res.status(403).json({ error: 'Wrong password' });
}
if (!['solo', 'together'].includes(category)) {
return res.status(400).json({ error: 'Invalid category' });
}
if (typeof amount!=='number' || amount<1 || amount>4) {
return res.status(400).json({ error: 'Amount must be 1..4' });
}
if (category==='solo') {
for (let i=0; i<amount; i++) {
soloUsed.push(false);
}
console.log(`[Server] +${amount} appended to soloUsed => length now`, soloUsed.length);
} else {
for (let i=0; i<amount; i++) {
togetherUsed.push(false);
}
console.log(`[Server] +${amount} appended to togetherUsed => length now`, togetherUsed.length);
}
res.json({ success: true });
});
// POST /pause-resume => toggles paused
app.post('/pause-resume', (req, res) => {
if(!timerRunning && timeRemaining<=0){
return res.status(400).json({ error: 'No active timer.' });
}
if(ringing){
return res.status(400).json({ error: 'Timer is ringing, cannot pause.' });
}
isPaused = !isPaused;
res.json({ success: true, isPaused });
});
// POST /finish-up => only if ringing => 2-min
app.post('/finish-up',(req,res)=>{
if(!ringing){
return res.status(400).json({ error: 'Not currently ringing. 2-min only after 15-min ends.' });
}
timerRunning=true;
timeRemaining=TWO_MINUTES;
isPaused=false;
ringing=false;
res.json({ success:true });
});
// POST /cancel-finish-up => end the 2-min early
app.post('/cancel-finish-up',(req,res)=>{
if(!timerRunning || ringing || timeRemaining> TWO_MINUTES){
return res.status(400).json({ error: 'Not in finish-up timer.' });
}
timerRunning=false;
timeRemaining=0;
isPaused=false;
activeUsage=null;
res.json({ success:true });
});
// POST /ignore-ring => skip the 2-min if rung
app.post('/ignore-ring',(req,res)=>{
if(!ringing){
return res.status(400).json({ error: 'Not currently ringing.' });
}
timerRunning=false;
timeRemaining=0;
isPaused=false;
activeUsage=null;
ringing=false;
res.json({ success:true });
});
// POST /use-movie => pick 4 points
app.post('/use-movie',(req,res)=>{
const { chosenPoints }=req.body;
if(!Array.isArray(chosenPoints)|| chosenPoints.length!==4){
return res.status(400).json({ error:'Must pick exactly 4 points' });
}
for(const p of chosenPoints){
let arr = p.category==='solo'? soloUsed : p.category==='together'? togetherUsed : null;
if(!arr) return res.status(400).json({ error:'Invalid category' });
if(arr[p.index]) return res.status(400).json({ error:'Point already used' });
}
for(const p of chosenPoints){
let arr = p.category==='solo'?soloUsed:togetherUsed;
arr[p.index] = true;
}
logs.push({ type:'movie', index:null, usedAt:new Date().toISOString()});
res.json({ success:true });
});
// POST /use-episode => pick 2 points
app.post('/use-episode',(req,res)=>{
const { chosenPoints }=req.body;
if(!Array.isArray(chosenPoints)|| chosenPoints.length!==2){
return res.status(400).json({ error:'Must pick exactly 2 points' });
}
for(const p of chosenPoints){
let arr = p.category==='solo'?soloUsed: p.category==='together'?togetherUsed:null;
if(!arr) return res.status(400).json({ error:'Invalid category' });
if(arr[p.index]) return res.status(400).json({ error:'Point already used' });
}
for(const p of chosenPoints){
let arr = p.category==='solo'?soloUsed:togetherUsed;
arr[p.index]=true;
}
logs.push({ type:'episode', index:null, usedAt:new Date().toISOString()});
res.json({ success:true });
});
/* ------------------------------------------------------------------
PUSH NOTIFICATION ROUTES
------------------------------------------------------------------ */
// POST /subscribe => store subscription
app.post('/subscribe',(req,res)=>{
const sub = req.body.subscription;
pushSubscriptions.push(sub);
console.log('[Server] New subscription => total:', pushSubscriptions.length);
res.json({ success:true });
});
// POST /unsubscribe => remove subscription
app.post('/unsubscribe',(req,res)=>{
const sub = req.body.subscription;
pushSubscriptions = pushSubscriptions.filter(s => s.endpoint!==sub.endpoint);
console.log('[Server] Unsubscribe => total:', pushSubscriptions.length);
res.json({ success:true });
});
/* ------------------------------------------------------------------
ADMIN ROUTES
------------------------------------------------------------------ */
app.post('/admin/login',(req,res)=>{
const { password }=req.body;
if(password!=='AdminSecret'){
return res.status(403).json({ error:'Wrong admin password' });
}
res.json({ success:true });
});
app.post('/admin/clear-logs',(req,res)=>{
logs=[];
res.json({ success:true });
});
app.post('/admin/reset-usage',(req,res)=>{
soloUsed=soloUsed.map(()=>false);
togetherUsed=togetherUsed.map(()=>false);
res.json({ success:true });
});
app.post('/admin/remove-all-earned',(req,res)=>{
soloUsed.splice(5);
togetherUsed.splice(5);
res.json({ success:true });
});
app.post('/admin/add-earned',(req,res)=>{
soloUsed.push(false);
res.json({ success:true });
});
app.post('/admin/remove-earned',(req,res)=>{
if(soloUsed.length<=0){
return res.status(400).json({ error:'No bubble to remove' });
}
soloUsed.pop();
res.json({ success:true });
});
app.post('/admin/clear-timer',(req,res)=>{
timerRunning=false;
timeRemaining=0;
isPaused=false;
activeUsage=null;
ringing=false;
res.json({ success:true });
});
app.post('/admin/expire-timer',(req,res)=>{
if(timeRemaining>1){
timeRemaining=1;
}
res.json({ success:true });
});
/* ------------------------------------------------------------------
SERVE REACT BUILD
------------------------------------------------------------------ */
app.use(express.static(path.join(__dirname, 'build')));
app.get('*', (req,res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
/* ------------------------------------------------------------------
START SERVER ON PORT 80
------------------------------------------------------------------ */
app.listen(80, () => {
console.log('[Server] Listening on port 80');
});