cd backend
npm install
cp .env.example .env
# Fyll i SMTP (och ev. Twilio) i .env
npm run dev
Uppgifter
| Uppgift |
Område |
Frekvens |
Nästa förfall |
Mottagare |
Åtgärd |
cd backend
npm install
cp .env.example .env
# Fyll i SMTP (och ev. Twilio) i .env
npm run dev
async function loadHist(){
const res = await fetch('/api/admin/completions');
const rows = await res.json();
hist.innerHTML = rows.map(r => {
const cl = r.checklist_checked_json ? JSON.parse(r.checklist_checked_json) : [];
const files = r.attachments_json ? JSON.parse(r.attachments_json) : [];
return `
| ${fmt(r.completed_at)} |
${r.title} |
${r.area_name || '-'} |
${r.frequency} |
${r.reporter || ''} |
${cl.map(t => `${t}`).join('')} |
${r.comment || ''} |
${files.map(f => `${f.split('__')[1]||f}`).join(' ')} |
`;
}).join('');
}
async function load(){ const u = await ensureAdmin(); if(!u) return; await loadUsers(); await loadHist(); }
load();{
"name": "ugc-reminder-backend",
"version": "3.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "NODE_ENV=development node server.js",
"seed-admin": "node seed-admin.js"
},
"dependencies": {
"better-sqlite3": "^9.4.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.13",
"twilio": "^4.23.0",
"bcryptjs": "^2.4.3",
"csv-stringify": "^6.5.0",
"multer": "^1.4.5-lts.1"
}
}
Uppgifter
| Uppgift | Frekvens | Nästa förfall | Åtgärd |
import express from 'express';
import cors from 'cors';
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import { addDays, addWeeks, addMonths, parseISO } from 'date-fns';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.json());
app.use(cors());
// DB
const db = new Database(path.join(__dirname, 'data.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT UNIQUE,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
area_id INTEGER NOT NULL,
frequency TEXT CHECK(frequency IN ('daily','weekly','monthly','once')) NOT NULL DEFAULT 'weekly',
notes TEXT,
next_due_at TEXT,
last_completed_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(area_id) REFERENCES areas(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS completions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
completed_at TEXT NOT NULL,
reporter TEXT,
comment TEXT,
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
`);
// helpers
function slugify(s){ return s.toLowerCase().trim().replace(/[^a-z0-9åäöüé]+/g,'-').replace(/^-+|-+$/g,''); }
function computeNextDue(fromISO, freq){
const d = parseISO(fromISO);
if (freq === 'daily') return addDays(d,1).toISOString();
if (freq === 'weekly') return addWeeks(d,1).toISOString();
if (freq === 'monthly') return addMonths(d,1).toISOString();
return null;
}
// API
app.get('/api/areas', (req,res)=>{
const rows = db.prepare('SELECT * FROM areas ORDER BY name').all();
res.json(rows);
});
app.post('/api/areas', (req,res)=>{
const { name, slug } = req.body || {};
if(!name) return res.status(400).json({ error: 'name krävs' });
const s = slug || slugify(name);
const info = db.prepare('INSERT INTO areas (name, slug) VALUES (?, ?)').run(name, s);
res.json({ id: info.lastInsertRowid, name, slug: s });
});
app.patch('/api/areas/:id', (req,res)=>{
const id = Number(req.params.id);
const { name, slug } = req.body || {};
const s = slug || (name ? slugify(name) : undefined);
const sets = []; const params = { id };
if (name){ sets.push('name=@name'); params.name = name; }
if (s){ sets.push('slug=@slug'); params.slug = s; }
if (!sets.length) return res.json({ ok: true });
db.prepare(`UPDATE areas SET ${sets.join(', ')} WHERE id=@id`).run(params);
res.json({ ok: true });
});
app.delete('/api/areas/:id', (req,res)=>{
const id = Number(req.params.id);
db.prepare('DELETE FROM areas WHERE id=?').run(id);
res.json({ ok: true });
});
app.get('/api/tasks', (req,res)=>{
const rows = db.prepare(`
SELECT t.*, a.name AS area_name, a.slug AS area_slug
FROM tasks t JOIN areas a ON a.id = t.area_id
ORDER BY COALESCE(t.next_due_at, t.created_at) ASC
`).all();
res.json(rows);
});
app.post('/api/tasks', (req,res)=>{
const { title, area_id, frequency='weekly', notes, first_due_at } = req.body || {};
if (!title || !area_id) return res.status(400).json({ error: 'title och area_id krävs' });
const next_due_at = first_due_at || new Date().toISOString();
const info = db.prepare(`
INSERT INTO tasks (title, area_id, frequency, notes, next_due_at)
VALUES (?,?,?,?,?)
`).run(title, area_id, frequency, notes || null, next_due_at);
res.json({ id: info.lastInsertRowid });
});
app.patch('/api/tasks/:id', (req,res)=>{
const id = Number(req.params.id);
const fields = ['title','area_id','frequency','notes','next_due_at'];
const sets = []; const params = { id };
for (const f of fields) if (f in req.body){ sets.push(`${f}=@${f}`); params[f] = req.body[f]; }
if (!sets.length) return res.json({ ok: true });
db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id=@id`).run(params);
res.json({ ok: true });
});
app.delete('/api/tasks/:id', (req,res)=>{
const id = Number(req.params.id);
db.prepare('DELETE FROM tasks WHERE id=?').run(id);
res.json({ ok: true });
});
// staff filter by area
app.get('/api/staff', (req,res)=>{
const { area, slug } = req.query;
let rows = [];
if (area){
rows = db.prepare(`
SELECT t.*, a.name AS area_name FROM tasks t JOIN areas a ON a.id=t.area_id WHERE a.id=?
ORDER BY COALESCE(t.next_due_at, t.created_at) ASC
`).all(Number(area));
} else if (slug){
rows = db.prepare(`
SELECT t.*, a.name AS area_name FROM tasks t JOIN areas a ON a.id=t.area_id WHERE a.slug=?
ORDER BY COALESCE(t.next_due_at, t.created_at) ASC
`).all(String(slug));
} else {
rows = db.prepare(`
SELECT t.*, a.name AS area_name FROM tasks t JOIN areas a ON a.id=t.area_id
ORDER BY COALESCE(t.next_due_at, t.created_at) ASC
`).all();
}
res.json(rows);
});
// complete
app.post('/api/tasks/:id/complete', (req,res)=>{
const id = Number(req.params.id);
const { reporter, comment } = req.body || {};
const t = db.prepare('SELECT * FROM tasks WHERE id=?').get(id);
if (!t) return res.status(404).json({ error:'task finns ej' });
const completed_at = new Date().toISOString();
db.prepare('INSERT INTO completions (task_id, completed_at, reporter, comment) VALUES (?,?,?,?)')
.run(id, completed_at, reporter || null, comment || null);
let next_due = null;
if (t.frequency === 'daily') next_due = addDays(parseISO(completed_at),1).toISOString();
else if (t.frequency === 'weekly') next_due = addWeeks(parseISO(completed_at),1).toISOString();
else if (t.frequency === 'monthly') next_due = addMonths(parseISO(completed_at),1).toISOString();
db.prepare('UPDATE tasks SET last_completed_at=?, next_due_at=? WHERE id=?').run(completed_at, next_due, id);
res.json({ ok:true, next_due_at: next_due });
});
// history
app.get('/api/tasks/:id/history', (req,res)=>{
const id = Number(req.params.id);
const rows = db.prepare('SELECT * FROM completions WHERE task_id=? ORDER BY completed_at DESC').all(id);
res.json(rows);
});
// static
app.use(express.static(path.join(__dirname, 'public')));
const port = Number(process.env.PORT || 4000);
app.listen(port, ()=> console.log('UGC MVP lyssnar på http://localhost:'+port));