rspace-online/modules/rtasks/checklist-routes.ts

111 lines
3.7 KiB
TypeScript

/**
* Email checklist routes — mounted at top level to bypass space auth middleware.
* GET /rtasks/check/:token — verify HMAC, toggle AC, render page
* POST /api/rtasks/send — build + send checklist email
*/
import { Hono } from "hono";
import { checklistConfig } from "./lib/checklist-config";
import { createToken, verifyToken } from "./lib/token";
import { readTask, toggleAC } from "./lib/backlog";
import { renderEmailHTML, renderWebPage, renderError } from "./lib/render";
export const checklistCheckRoutes = new Hono();
export const checklistApiRoutes = new Hono();
// ── GET /rtasks/check/:token ──
checklistCheckRoutes.get("/:token", async (c) => {
const token = c.req.param("token");
let verified;
try {
verified = await verifyToken(token);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
const title = message === "Token expired" ? "Link Expired" : "Invalid Link";
const body = message === "Token expired"
? "This checklist link has expired. Request a new checklist email."
: "This link is not valid. It may have been corrupted.";
return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400);
}
try {
const result = await toggleAC(verified.repo, verified.taskId, verified.acIndex);
const tokens = new Map<number, string>();
for (const ac of result.criteria) {
tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index));
}
return c.html(renderWebPage(result.title, verified.taskId, result.criteria, tokens, verified.acIndex, result.toggled));
} catch (err) {
console.error("[Tasks/checklist] Check error:", err);
return c.html(renderError("Error", "Could not update task. Please try again."), 500);
}
});
// ── POST /api/rtasks/send ──
checklistApiRoutes.post("/send", async (c) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || authHeader !== `Bearer ${checklistConfig.apiKey}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = await c.req.json<{
repo: string;
taskId: string;
to: string;
subject?: string;
}>();
if (!body.repo || !body.taskId || !body.to) {
return c.json({ error: "Missing required fields: repo, taskId, to" }, 400);
}
try {
const task = await readTask(body.repo, body.taskId);
const tokens = new Map<number, string>();
for (const ac of task.criteria) {
if (!ac.checked) {
tokens.set(ac.index, await createToken(body.repo, body.taskId, ac.index));
}
}
if (tokens.size === 0) {
return c.json({ error: "All acceptance criteria already checked" }, 400);
}
const subject = body.subject || `Checklist: ${task.title}`;
const html = renderEmailHTML(task.title, body.taskId, task.criteria, tokens);
const nodemailer = await import("nodemailer");
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST || "mailcowdockerized-postfix-mailcow-1",
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "noreply@rmail.online",
pass: process.env.SMTP_PASS,
},
tls: { rejectUnauthorized: false },
});
const result = await transport.sendMail({
from: process.env.SMTP_FROM || `rTasks <${process.env.SMTP_USER || "noreply@rmail.online"}>`,
to: body.to,
subject,
html,
});
const firstToken = tokens.values().next().value;
const url = `${checklistConfig.baseUrl}/rtasks/check/${firstToken}`;
return c.json({ ok: true, messageId: result.messageId, url, unchecked: tokens.size });
} catch (err) {
console.error("[Tasks/checklist] Send error:", err);
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ error: message }, 500);
}
});