diff --git a/README.md b/README.md index f1eba62..06d4773 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ GitHub sign-in is enabled when `GITHUB_CLIENT_ID` is set. If your OAuth app requires it, set `GITHUB_CLIENT_SECRET` on the server too; the secret is only used by the backend token exchange endpoint. +Assignment notification digests can be enabled with Resend: + +```sh +TASKFORGE_EMAIL_PROVIDER=resend +TASKFORGE_EMAIL_FROM="TaskForge " +RESEND_API_KEY="..." +TASKFORGE_PUBLIC_URL="https://your-taskforge-host.example" +``` + +For local development, set `TASKFORGE_EMAIL_PROVIDER=log` to print due digest +messages instead of sending them. + ## Contributing We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) — TL;DR: diff --git a/index.html b/index.html index b6b00fb..0f9b254 100644 --- a/index.html +++ b/index.html @@ -35,6 +35,13 @@

TaskForge

autocomplete="off" required /> + diff --git a/server.js b/server.js index 695407f..83572e8 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,11 @@ const DATA_FILE = process.env.TASKFORGE_DATA_FILE || path.join(__dirname, ".taskforge-data.json"); const SESSION_COOKIE = "taskforge_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +const ASSIGNMENT_DIGEST_INTERVAL_MS = + Number(process.env.TASKFORGE_ASSIGNMENT_DIGEST_INTERVAL_MS) || 60 * 60 * 1000; +const DIGEST_POLL_INTERVAL_MS = + Number(process.env.TASKFORGE_DIGEST_POLL_INTERVAL_MS) || 60 * 1000; +const PUBLIC_URL = process.env.TASKFORGE_PUBLIC_URL || `http://${HOST}:${PORT}`; const CONTENT_TYPES = { ".css": "text/css; charset=utf-8", @@ -26,11 +31,26 @@ function createServer(options = {}) { const dataFile = options.dataFile || DATA_FILE; const fetchImpl = options.fetchImpl || global.fetch; const sessionStore = options.sessionStore || sessions; + const now = options.now || (() => Date.now()); + const digestIntervalMs = + options.digestIntervalMs || ASSIGNMENT_DIGEST_INTERVAL_MS; + const publicUrl = options.publicUrl || PUBLIC_URL; + const emailSender = + "emailSender" in options ? options.emailSender : createEmailSender(fetchImpl); + const context = { + dataFile, + digestIntervalMs, + emailSender, + fetchImpl, + now, + publicUrl, + sessionStore, + }; - return http.createServer(async (req, res) => { + const server = http.createServer(async (req, res) => { try { if (req.url.startsWith("/api/")) { - await handleApi(req, res, { dataFile, fetchImpl, sessionStore }); + await handleApi(req, res, context); return; } await serveStatic(req, res); @@ -39,6 +59,21 @@ function createServer(options = {}) { sendJson(res, 500, { error: "internal_server_error" }); } }); + + if (emailSender && options.startDigestTimer !== false) { + const pollIntervalMs = options.digestPollIntervalMs || DIGEST_POLL_INTERVAL_MS; + const timer = setInterval(() => { + flushDueAssignmentDigests(dataFile, { + emailSender, + now, + publicUrl, + }).catch((error) => console.error("assignment digest delivery failed", error)); + }, pollIntervalMs); + timer.unref?.(); + server.on("close", () => clearInterval(timer)); + } + + return server; } async function handleApi(req, res, context) { @@ -72,6 +107,11 @@ async function handleApi(req, res, context) { return; } + if (url.pathname === "/api/notifications/unsubscribe") { + await unsubscribeFromAssignmentDigests(req, res, context, url); + return; + } + if (url.pathname === "/api/tasks") { await handleTasks(req, res, context); return; @@ -160,7 +200,8 @@ async function exchangeGitHubCode(req, res, { fetchImpl, sessionStore }) { sendJson(res, 200, { user }); } -async function handleTasks(req, res, { dataFile, sessionStore }) { +async function handleTasks(req, res, context) { + const { dataFile, sessionStore } = context; const session = getSession(req, sessionStore); if (!session) { sendJson(res, 401, { error: "authentication_required" }); @@ -177,8 +218,23 @@ async function handleTasks(req, res, { dataFile, sessionStore }) { const body = await readJson(req); const tasks = sanitizeTasks(body.tasks); const data = await readData(dataFile); + const previousTasks = data.tasksByUser[session.user.id] || []; + const assignedAt = context.now(); + const assignments = collectNewAssignments( + previousTasks, + tasks, + session.user, + assignedAt, + ); data.tasksByUser[session.user.id] = tasks; + queueAssignmentDigests(data, assignments, { + digestIntervalMs: context.digestIntervalMs, + now: assignedAt, + }); await writeData(dataFile, data); + if (context.emailSender) { + await flushDueAssignmentDigests(dataFile, context); + } sendJson(res, 200, { tasks }); return; } @@ -186,6 +242,38 @@ async function handleTasks(req, res, { dataFile, sessionStore }) { sendJson(res, 405, { error: "method_not_allowed" }); } +async function unsubscribeFromAssignmentDigests(req, res, { dataFile }, url) { + if (req.method !== "GET") { + sendJson(res, 405, { error: "method_not_allowed" }); + return; + } + + const token = normalizeString(url.searchParams.get("token")); + if (!token) { + sendText(res, 400, "Missing unsubscribe token."); + return; + } + + const data = await readData(dataFile); + const entry = Object.entries(data.assignmentDigests).find( + ([, digest]) => digest.unsubscribeToken === token, + ); + if (!entry) { + sendText(res, 404, "Notification subscription not found."); + return; + } + + const [email, digest] = entry; + data.assignmentDigests[email] = { + ...digest, + assignments: [], + scheduledFor: 0, + unsubscribed: true, + }; + await writeData(dataFile, data); + sendText(res, 200, "You have been unsubscribed from TaskForge assignment digests."); +} + async function serveStatic(req, res) { const rawPathname = req.url.split(/[?#]/)[0]; let decodedRawPathname; @@ -232,13 +320,22 @@ async function serveStatic(req, res) { async function readData(dataFile) { try { const parsed = JSON.parse(await fs.readFile(dataFile, "utf8")); - if (parsed && typeof parsed === "object" && parsed.tasksByUser) { + if (parsed && typeof parsed === "object") { + if (!parsed.tasksByUser || typeof parsed.tasksByUser !== "object") { + parsed.tasksByUser = {}; + } + if ( + !parsed.assignmentDigests || + typeof parsed.assignmentDigests !== "object" + ) { + parsed.assignmentDigests = {}; + } return parsed; } } catch (error) { if (error.code !== "ENOENT") throw error; } - return { tasksByUser: {} }; + return { assignmentDigests: {}, tasksByUser: {} }; } async function writeData(dataFile, data) { @@ -254,6 +351,174 @@ async function readJson(req) { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } +function collectNewAssignments(previousTasks, nextTasks, actor, assignedAt) { + const previousById = new Map(previousTasks.map((task) => [task.id, task])); + const assignments = []; + + for (const task of nextTasks) { + const assigneeEmail = normalizeEmail(task.assigneeEmail); + if (!assigneeEmail) continue; + + const previous = previousById.get(task.id); + if (previous?.assigneeEmail === assigneeEmail) continue; + + assignments.push({ + assignedAt, + assignedBy: actor.login, + assigneeEmail, + taskId: task.id, + taskTitle: task.title, + }); + } + + return assignments; +} + +function queueAssignmentDigests(data, assignments, { digestIntervalMs, now }) { + if (assignments.length === 0) return; + + for (const assignment of assignments) { + const email = assignment.assigneeEmail; + const digest = data.assignmentDigests[email] || { + assignments: [], + lastSentAt: 0, + scheduledFor: 0, + unsubscribeToken: crypto.randomBytes(18).toString("base64url"), + unsubscribed: false, + }; + + if (digest.unsubscribed) continue; + + digest.assignments = Array.isArray(digest.assignments) + ? digest.assignments + : []; + digest.assignments.push(assignment); + if (!Number.isFinite(digest.scheduledFor) || digest.scheduledFor <= 0) { + digest.scheduledFor = now + digestIntervalMs; + } + data.assignmentDigests[email] = digest; + } +} + +async function flushDueAssignmentDigests(dataFile, { emailSender, now, publicUrl }) { + if (!emailSender) return { sent: 0 }; + + const data = await readData(dataFile); + const result = await sendDueAssignmentDigests(data, { + emailSender, + now: now(), + publicUrl, + }); + if (result.changed) { + await writeData(dataFile, data); + } + return { sent: result.sent }; +} + +async function sendDueAssignmentDigests(data, { emailSender, now, publicUrl }) { + let changed = false; + let sent = 0; + + for (const [email, digest] of Object.entries(data.assignmentDigests)) { + if (!digest || digest.unsubscribed) continue; + + const assignments = Array.isArray(digest.assignments) + ? digest.assignments + : []; + if (assignments.length === 0) { + if (digest.scheduledFor) { + digest.scheduledFor = 0; + changed = true; + } + continue; + } + if (Number(digest.scheduledFor || 0) > now) continue; + + await emailSender.send({ + subject: `TaskForge assignment digest (${assignments.length})`, + text: buildAssignmentDigestText(email, digest, { publicUrl }), + to: email, + }); + digest.assignments = []; + digest.lastSentAt = now; + digest.scheduledFor = 0; + changed = true; + sent += 1; + } + + return { changed, sent }; +} + +function buildAssignmentDigestText(email, digest, { publicUrl }) { + const unsubscribeUrl = `${normalizeBaseUrl( + publicUrl, + )}/api/notifications/unsubscribe?token=${encodeURIComponent( + digest.unsubscribeToken, + )}`; + const lines = [ + "You have new TaskForge task assignments:", + "", + ...digest.assignments.map( + (assignment) => + `- ${assignment.taskTitle} (assigned by ${assignment.assignedBy})`, + ), + "", + `This digest was sent to ${email}.`, + `Unsubscribe: ${unsubscribeUrl}`, + ]; + return lines.join("\n"); +} + +function createEmailSender(fetchImpl) { + const provider = normalizeString(process.env.TASKFORGE_EMAIL_PROVIDER); + if (!provider) return null; + + if (provider === "log") { + return { + async send(message) { + console.log("TaskForge assignment digest", message); + }, + }; + } + + if (provider === "resend") { + return { + async send(message) { + if (!process.env.RESEND_API_KEY) { + throw new Error("RESEND_API_KEY is required for Resend email delivery"); + } + if (!process.env.TASKFORGE_EMAIL_FROM) { + throw new Error("TASKFORGE_EMAIL_FROM is required for email delivery"); + } + if (typeof fetchImpl !== "function") { + throw new Error("fetch is required for Resend email delivery"); + } + + const response = await fetchImpl("https://api.resend.com/emails", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.RESEND_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + from: process.env.TASKFORGE_EMAIL_FROM, + subject: message.subject, + text: message.text, + to: message.to, + }), + }); + if (!response.ok) { + const detail = + typeof response.text === "function" ? await response.text() : ""; + throw new Error(`Resend email delivery failed: ${detail}`); + } + }, + }; + } + + throw new Error(`Unsupported email provider: ${provider}`); +} + function sanitizeTasks(tasks) { if (!Array.isArray(tasks)) return []; return tasks @@ -263,10 +528,20 @@ function sanitizeTasks(tasks) { title: normalizeString(task.title).slice(0, 500), done: Boolean(task.done), createdAt: Number.isFinite(task.createdAt) ? task.createdAt : Date.now(), + assigneeEmail: normalizeEmail(task.assigneeEmail), })) .filter((task) => task.id && task.title); } +function normalizeEmail(value) { + const email = normalizeString(value).toLowerCase(); + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email.slice(0, 254) : ""; +} + +function normalizeBaseUrl(value) { + return normalizeString(value).replace(/\/+$/, "") || "http://localhost:8000"; +} + function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } @@ -319,6 +594,10 @@ if (require.main === module) { module.exports = { SESSION_COOKIE, + buildAssignmentDigestText, + collectNewAssignments, createServer, + flushDueAssignmentDigests, + queueAssignmentDigests, sanitizeTasks, }; diff --git a/src/app.js b/src/app.js index 46dd724..0c8deaf 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,7 @@ import { createTask, toggleTask, removeTask } from "./tasks.js"; const form = document.getElementById("task-form"); const input = document.getElementById("task-input"); +const assigneeInput = document.getElementById("assignee-input"); const list = document.getElementById("task-list"); const emptyState = document.getElementById("empty-state"); const authStatus = document.getElementById("auth-status"); @@ -69,6 +70,13 @@ function render() { label.className = "task-title"; label.textContent = task.title; + const assignee = document.createElement("span"); + assignee.className = "task-assignee"; + assignee.hidden = !task.assigneeEmail; + assignee.textContent = task.assigneeEmail + ? `Assigned to ${task.assigneeEmail}` + : ""; + const del = document.createElement("button"); del.type = "button"; del.className = "task-delete"; @@ -80,7 +88,7 @@ function render() { render(); }); - li.append(checkbox, label, del); + li.append(checkbox, label, assignee, del); list.appendChild(li); } } @@ -89,9 +97,10 @@ form.addEventListener("submit", async (e) => { e.preventDefault(); const title = input.value.trim(); if (!title) return; - tasks = [createTask(title), ...tasks]; + tasks = [createTask(title, assigneeInput.value), ...tasks]; await save(tasks, user); input.value = ""; + assigneeInput.value = ""; input.focus(); render(); }); diff --git a/src/tasks.js b/src/tasks.js index a54af4a..ba2e756 100644 --- a/src/tasks.js +++ b/src/tasks.js @@ -1,5 +1,6 @@ -export function createTask(title) { +export function createTask(title, assigneeEmail = "") { return { + assigneeEmail: normalizeEmail(assigneeEmail), id: cryptoRandomId(), title: title.trim(), done: false, @@ -21,3 +22,10 @@ function cryptoRandomId() { } return Math.random().toString(36).slice(2) + Date.now().toString(36); } + +function normalizeEmail(value) { + const email = String(value || "") + .trim() + .toLowerCase(); + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email.slice(0, 254) : ""; +} diff --git a/styles.css b/styles.css index a6c7afc..cb2daad 100644 --- a/styles.css +++ b/styles.css @@ -97,6 +97,7 @@ body { .task-form { display: flex; + flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.25rem; } @@ -159,6 +160,13 @@ body { word-break: break-word; } +.task-assignee { + color: var(--muted); + flex: 0 1 12rem; + font-size: 0.85rem; + overflow-wrap: anywhere; +} + .task-delete { appearance: none; background: transparent; @@ -208,4 +216,19 @@ body { flex: 1; max-width: none; } + + .task-form input, + .task-form button { + width: 100%; + } + + .task-item { + align-items: flex-start; + flex-wrap: wrap; + } + + .task-title, + .task-assignee { + flex-basis: 100%; + } } diff --git a/test/server.test.js b/test/server.test.js index c3b9f65..eae7256 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -5,17 +5,36 @@ const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); -const { SESSION_COOKIE, createServer, sanitizeTasks } = require("../server.js"); +const { + SESSION_COOKIE, + createServer, + flushDueAssignmentDigests, + sanitizeTasks, +} = require("../server.js"); test("sanitizeTasks drops malformed rows and trims persisted fields", () => { assert.deepEqual( sanitizeTasks([ - { id: " a ", title: " Write tests ", done: 1, createdAt: 10 }, + { + assigneeEmail: " Dev@Example.COM ", + id: " a ", + title: " Write tests ", + done: 1, + createdAt: 10, + }, { id: "", title: "missing id" }, { id: "missing-title", title: "" }, null, ]), - [{ id: "a", title: "Write tests", done: true, createdAt: 10 }], + [ + { + assigneeEmail: "dev@example.com", + id: "a", + title: "Write tests", + done: true, + createdAt: 10, + }, + ], ); }); @@ -116,6 +135,7 @@ test("authenticated task API persists tasks by GitHub user id", async () => { title: "Ship OAuth", done: false, createdAt: loaded.body.tasks[0].createdAt, + assigneeEmail: "", }, ]); @@ -127,6 +147,152 @@ test("authenticated task API persists tasks by GitHub user id", async () => { } }); +test("assignment updates are queued into delayed email digests", async () => { + const previousClientId = process.env.GITHUB_CLIENT_ID; + process.env.GITHUB_CLIENT_ID = "client-id"; + const dataFile = await tempDataFile(); + const sent = []; + let now = 1000; + const server = await listen( + createServer({ + dataFile, + digestIntervalMs: 60 * 60 * 1000, + emailSender: { send: async (message) => sent.push(message) }, + fetchImpl: async (url) => { + if (url === "https://github.com/login/oauth/access_token") { + return jsonResponse({ access_token: "token" }); + } + return jsonResponse({ id: 7, login: "octo", avatar_url: "" }); + }, + now: () => now, + publicUrl: "https://taskforge.example", + startDigestTimer: false, + }), + ); + + try { + const login = await request(server, "/api/auth/github/exchange", { + method: "POST", + body: { + code: "code", + codeVerifier: "verifier", + redirectUri: "http://127.0.0.1:8000/", + }, + }); + const cookie = login.headers.get("set-cookie").split(";")[0]; + + const saved = await request(server, "/api/tasks", { + method: "PUT", + cookie, + body: { + tasks: [ + { + assigneeEmail: "Dev@Example.COM", + id: "task-1", + title: "Review pull request", + done: false, + }, + ], + }, + }); + + assert.equal(saved.status, 200); + assert.equal(sent.length, 0); + + const queued = JSON.parse(await fs.readFile(dataFile, "utf8")); + assert.equal( + queued.assignmentDigests["dev@example.com"].scheduledFor, + now + 60 * 60 * 1000, + ); + assert.equal( + queued.assignmentDigests["dev@example.com"].assignments[0].taskTitle, + "Review pull request", + ); + + now += 60 * 60 * 1000; + const delivery = await flushDueAssignmentDigests(dataFile, { + emailSender: { send: async (message) => sent.push(message) }, + now: () => now, + publicUrl: "https://taskforge.example", + }); + + assert.deepEqual(delivery, { sent: 1 }); + assert.equal(sent[0].to, "dev@example.com"); + assert.match(sent[0].subject, /assignment digest/); + assert.match(sent[0].text, /Review pull request/); + assert.match( + sent[0].text, + /https:\/\/taskforge\.example\/api\/notifications\/unsubscribe\?token=/, + ); + + const delivered = JSON.parse(await fs.readFile(dataFile, "utf8")); + assert.deepEqual( + delivered.assignmentDigests["dev@example.com"].assignments, + [], + ); + } finally { + await close(server); + restoreEnv("GITHUB_CLIENT_ID", previousClientId); + } +}); + +test("unsubscribe endpoint disables pending assignment digests", async () => { + const dataFile = await tempDataFile(); + await fs.writeFile( + dataFile, + JSON.stringify({ + assignmentDigests: { + "dev@example.com": { + assignments: [ + { + assignedAt: 1000, + assignedBy: "octo", + assigneeEmail: "dev@example.com", + taskId: "task-1", + taskTitle: "Review pull request", + }, + ], + lastSentAt: 0, + scheduledFor: 1000, + unsubscribeToken: "token-123", + unsubscribed: false, + }, + }, + tasksByUser: {}, + }), + ); + const sent = []; + const server = await listen( + createServer({ + dataFile, + emailSender: { send: async (message) => sent.push(message) }, + now: () => 2000, + startDigestTimer: false, + }), + ); + + try { + const response = await request( + server, + "/api/notifications/unsubscribe?token=token-123", + ); + assert.equal(response.status, 200); + + await flushDueAssignmentDigests(dataFile, { + emailSender: { send: async (message) => sent.push(message) }, + now: () => 2000, + publicUrl: "https://taskforge.example", + }); + + const data = JSON.parse(await fs.readFile(dataFile, "utf8")); + assert.equal(data.assignmentDigests["dev@example.com"].unsubscribed, true); + assert.deepEqual(data.assignmentDigests["dev@example.com"].assignments, []); + assert.equal(sent.length, 0); + } finally { + await close(server); + } +}); + test("logout clears the server session and browser cookie", async () => { const previousClientId = process.env.GITHUB_CLIENT_ID; process.env.GITHUB_CLIENT_ID = "client-id"; @@ -202,8 +368,10 @@ async function request(server, pathname, options = {}) { body: options.body ? JSON.stringify(options.body) : undefined, }); const text = await response.text(); + const contentType = response.headers.get("content-type") || ""; return { - body: text ? JSON.parse(text) : null, + body: + text && contentType.includes("application/json") ? JSON.parse(text) : text, headers: response.headers, status: response.status, };