362 lines
12 KiB
JavaScript
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');
|
|
});
|