feat(rtime): commitment notifications + email delivery channel

- Fire commitment_accepted/commitment_declined notifications when solver
  results are accepted/rejected in intent-routes.ts
- Fire commitment_declined when a connection is deleted in mod.ts
- Add metadata (resultId, fromCommitmentId) to commitment_requested notify
- Fix actionUrl to use /rtime (subdomain-relative), not /{space}/rtime
- Add Accept/Decline action buttons in notification bell for
  commitment_requested events (same pattern as space_invite)
- Add email delivery channel to notification-service: sends from
  {space}-agent@rspace.online via SMTP, respects emailEnabled preference,
  inline HTML template with dark theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-01 14:59:10 -07:00
parent c4b85a82e6
commit 3b0999da64
4 changed files with 213 additions and 6 deletions

View File

@ -25,6 +25,7 @@ import { getSkillPrice, calculateIntentCost, getAllSkillPrices, getSkillCurveCon
import { getMemberSkillReputation, getMemberOverallReputation } from './reputation'; import { getMemberSkillReputation, getMemberOverallReputation } from './reputation';
import { solve } from './solver'; import { solve } from './solver';
import { settleResult, isReadyForSettlement, updateSkillCurves } from './settlement'; import { settleResult, isReadyForSettlement, updateSkillCurves } from './settlement';
import { notify } from '../../server/notification-service';
const VALID_SKILLS: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics']; const VALID_SKILLS: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
@ -295,6 +296,23 @@ export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono
d.results[resultId].acceptances[auth.did] = true; d.results[resultId].acceptances[auth.did] = true;
}); });
// Notify other members that this member accepted
const otherMembers = result.members.filter(m => m !== auth.did);
for (const memberDid of otherMembers) {
notify({
userDid: memberDid,
category: 'module',
eventType: 'commitment_accepted',
title: `Collaboration accepted`,
body: `${auth.username} accepted the ${result.totalHours}hr collaboration`,
spaceSlug: space,
moduleId: 'rtime',
actionUrl: `/rtime`,
actorDid: auth.did,
metadata: { resultId },
}).catch(() => {});
}
// Check if all accepted → auto-settle // Check if all accepted → auto-settle
const updated = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!; const updated = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const updatedResult = updated.results[resultId]; const updatedResult = updated.results[resultId];
@ -336,6 +354,23 @@ export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono
d.results[resultId].status = 'rejected'; d.results[resultId].status = 'rejected';
}); });
// Notify other members that this member declined
const otherMembers = result.members.filter(m => m !== auth.did);
for (const memberDid of otherMembers) {
notify({
userDid: memberDid,
category: 'module',
eventType: 'commitment_declined',
title: `Collaboration declined`,
body: `${auth.username} declined the ${result.totalHours}hr collaboration`,
spaceSlug: space,
moduleId: 'rtime',
actionUrl: `/rtime`,
actorDid: auth.did,
metadata: { resultId },
}).catch(() => {});
}
return c.json({ ok: true }); return c.json({ ok: true });
}); });

View File

@ -279,8 +279,9 @@ routes.post("/api/connections", async (c) => {
body: task ? `Task: ${task.name}` : undefined, body: task ? `Task: ${task.name}` : undefined,
spaceSlug: space, spaceSlug: space,
moduleId: 'rtime', moduleId: 'rtime',
actionUrl: `/${space}/rtime`, actionUrl: `/rtime`,
actorDid: claims.did as string | undefined, actorDid: claims.did as string | undefined,
metadata: { resultId: id, fromCommitmentId },
}).catch(() => {}); }).catch(() => {});
} }
@ -290,19 +291,40 @@ routes.post("/api/connections", async (c) => {
routes.delete("/api/connections/:id", async (c) => { routes.delete("/api/connections/:id", async (c) => {
const token = extractToken(c.req.raw.headers); const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401); if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } let claims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const id = c.req.param("id"); const id = c.req.param("id");
ensureTasksDoc(space); ensureTasksDoc(space);
ensureCommitmentsDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
if (!doc.connections[id]) return c.json({ error: "Not found" }, 404); const connection = doc.connections[id];
if (!connection) return c.json({ error: "Not found" }, 404);
// Look up commitment owner to notify them
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
const commitment = cDoc?.items?.[connection.fromCommitmentId];
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'remove connection', (d) => { _syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'remove connection', (d) => {
delete d.connections[id]; delete d.connections[id];
}); });
// Notify commitment owner that the request was declined
if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) {
notify({
userDid: commitment.ownerDid,
category: 'module',
eventType: 'commitment_declined',
title: `Your ${commitment.hours}hr ${commitment.skill} commitment request was declined`,
spaceSlug: space,
moduleId: 'rtime',
actionUrl: `/rtime`,
actorDid: claims.did as string | undefined,
}).catch(() => {});
}
return c.json({ ok: true }); return c.json({ ok: true });
}); });

View File

@ -17,6 +17,7 @@ import {
deletePushSubscriptionByEndpoint, deletePushSubscriptionByEndpoint,
updatePushSubscriptionLastUsed, updatePushSubscriptionLastUsed,
getNotificationPreferences, getNotificationPreferences,
getProfileEmailsByDids,
type StoredNotification, type StoredNotification,
} from "../src/encryptid/db"; } from "../src/encryptid/db";
@ -32,6 +33,35 @@ if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
console.warn("[push] VAPID keys not set — Web Push disabled"); console.warn("[push] VAPID keys not set — Web Push disabled");
} }
// ── SMTP setup (email delivery) ──
const SMTP_HOST = process.env.SMTP_HOST || "mail.rmail.online";
const SMTP_PORT = Number(process.env.SMTP_PORT) || 587;
const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASS = process.env.SMTP_PASS || "";
let _smtpTransport: any = null;
async function getSmtpTransport() {
if (_smtpTransport) return _smtpTransport;
if (!SMTP_PASS) return null;
try {
const nodemailer = await import("nodemailer");
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
_smtpTransport = createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
tls: { rejectUnauthorized: false },
});
console.log("[email] SMTP transport configured");
return _smtpTransport;
} catch (e) {
console.warn("[email] Failed to create SMTP transport:", e);
return null;
}
}
// ============================================================================ // ============================================================================
// TYPES // TYPES
// ============================================================================ // ============================================================================
@ -160,6 +190,9 @@ export async function notify(opts: NotifyOptions): Promise<StoredNotification> {
sendWebPush(stored, opts).catch(() => {}); sendWebPush(stored, opts).catch(() => {});
} }
// 4. Attempt email delivery (non-blocking)
sendEmailNotification(stored, opts).catch(() => {});
return stored; return stored;
} }
@ -211,6 +244,57 @@ async function sendWebPush(stored: StoredNotification, opts: NotifyOptions): Pro
} }
} }
/** Send email notification from {space}-agent@rspace.online. */
async function sendEmailNotification(stored: StoredNotification, opts: NotifyOptions): Promise<void> {
// Check if user has email enabled
const prefs = await getNotificationPreferences(opts.userDid);
if (prefs && !prefs.emailEnabled) return;
// Look up user's email
const emailMap = await getProfileEmailsByDids([opts.userDid]);
const userEmail = emailMap.get(opts.userDid);
if (!userEmail) return;
const transport = await getSmtpTransport();
if (!transport) return;
const space = opts.spaceSlug || "rspace";
const fromAddr = `${space} agent <${space}-agent@rspace.online>`;
const actionLink = opts.actionUrl
? `https://${space}.rspace.online${opts.actionUrl}`
: `https://${space}.rspace.online`;
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<div style="background: #1e293b; border-radius: 10px; padding: 20px; color: #e2e8f0;">
<h2 style="margin: 0 0 8px; font-size: 16px; color: #f1f5f9;">${escapeHtml(stored.title)}</h2>
${stored.body ? `<p style="margin: 0 0 16px; font-size: 14px; color: #94a3b8;">${escapeHtml(stored.body)}</p>` : ""}
<a href="${actionLink}" style="display: inline-block; padding: 8px 20px; background: linear-gradient(135deg, #14b8a6, #0d9488); color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600;">View in rSpace</a>
</div>
<p style="margin: 12px 0 0; font-size: 11px; color: #64748b; text-align: center;">
Sent by ${space}.rspace.online
</p>
</div>`;
try {
await transport.sendMail({
from: fromAddr,
to: userEmail,
subject: stored.title,
html,
replyTo: `${space}-agent@rspace.online`,
});
await markNotificationDelivered(stored.id, 'email');
console.log(`[email] Sent "${stored.title}" to ${opts.userDid}`);
} catch (err: any) {
console.error(`[email] Failed to send to ${opts.userDid}:`, err.message);
}
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ============================================================================ // ============================================================================
// CONVENIENCE: NOTIFY SPACE ADMINS/MODS // CONVENIENCE: NOTIFY SPACE ADMINS/MODS
// ============================================================================ // ============================================================================

View File

@ -212,6 +212,46 @@ export class RStackNotificationBell extends HTMLElement {
} }
} }
async #acceptCommitment(notifId: string, spaceSlug: string, resultId: string) {
const token = this.#getToken();
if (!token) return;
try {
const res = await fetch(`https://${spaceSlug}.rspace.online/rtime/api/solver-results/${resultId}/accept`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
await this.#markRead(notifId);
} else {
const err = await res.json().catch(() => ({ error: "Failed to accept" }));
console.error("[commitment] accept failed:", (err as any).error);
}
} catch (e) {
console.error("[commitment] accept error:", e);
}
}
async #declineCommitment(notifId: string, spaceSlug: string, resultId: string) {
const token = this.#getToken();
if (!token) return;
try {
const res = await fetch(`https://${spaceSlug}.rspace.online/rtime/api/solver-results/${resultId}/reject`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
await this.#markRead(notifId);
} else {
const err = await res.json().catch(() => ({ error: "Failed to decline" }));
console.error("[commitment] decline failed:", (err as any).error);
}
} catch (e) {
console.error("[commitment] decline error:", e);
}
}
async #acceptInvite(notifId: string, spaceSlug: string, inviteToken: string) { async #acceptInvite(notifId: string, spaceSlug: string, inviteToken: string) {
const token = this.#getToken(); const token = this.#getToken();
if (!token) return; if (!token) return;
@ -375,18 +415,26 @@ export class RStackNotificationBell extends HTMLElement {
} else { } else {
body = this.#notifications.map(n => { body = this.#notifications.map(n => {
const isInvite = n.eventType === "space_invite" && n.metadata?.inviteToken && !n.read; const isInvite = n.eventType === "space_invite" && n.metadata?.inviteToken && !n.read;
const isCommitment = n.eventType === "commitment_requested" && n.metadata?.resultId && !n.read;
const hasActions = isInvite || isCommitment;
const inviteButtons = isInvite ? ` const inviteButtons = isInvite ? `
<div class="notif-actions"> <div class="notif-actions">
<button class="notif-accept" data-accept="${n.id}" data-space="${n.spaceSlug}" data-token="${n.metadata.inviteToken}">Accept</button> <button class="notif-accept" data-accept="${n.id}" data-space="${n.spaceSlug}" data-token="${n.metadata.inviteToken}">Accept</button>
<button class="notif-decline" data-decline="${n.id}">Decline</button> <button class="notif-decline" data-decline="${n.id}">Decline</button>
</div>` : ""; </div>` : "";
const commitmentButtons = isCommitment ? `
<div class="notif-actions">
<button class="notif-accept" data-commit-accept="${n.id}" data-space="${n.spaceSlug}" data-result="${n.metadata.resultId}">Accept</button>
<button class="notif-decline" data-commit-decline="${n.id}" data-space="${n.spaceSlug}" data-result="${n.metadata.resultId}">Decline</button>
</div>` : "";
return ` return `
<div class="notif-item ${n.read ? "read" : "unread"} ${isInvite ? "invite" : ""}" data-id="${n.id}" ${isInvite ? 'data-no-nav="true"' : ""}> <div class="notif-item ${n.read ? "read" : "unread"} ${hasActions ? "invite" : ""}" data-id="${n.id}" ${hasActions ? 'data-no-nav="true"' : ""}>
<div class="notif-icon">${this.#categoryIcon(n.category)}</div> <div class="notif-icon">${this.#categoryIcon(n.category)}</div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-title">${n.title}</div> <div class="notif-title">${n.title}</div>
${n.body ? `<div class="notif-body">${n.body}</div>` : ""} ${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
${inviteButtons} ${inviteButtons}
${commitmentButtons}
<div class="notif-meta"> <div class="notif-meta">
${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""} ${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""}
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span> <span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
@ -461,7 +509,7 @@ export class RStackNotificationBell extends HTMLElement {
}); });
// Accept invite buttons // Accept invite buttons
this.#shadow.querySelectorAll(".notif-accept").forEach((btn) => { this.#shadow.querySelectorAll("[data-accept]").forEach((btn) => {
const el = btn as HTMLElement; const el = btn as HTMLElement;
btn.addEventListener("click", (e) => { btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@ -470,7 +518,7 @@ export class RStackNotificationBell extends HTMLElement {
}); });
// Decline invite buttons // Decline invite buttons
this.#shadow.querySelectorAll(".notif-decline").forEach((btn) => { this.#shadow.querySelectorAll("[data-decline]").forEach((btn) => {
const id = (btn as HTMLElement).dataset.decline!; const id = (btn as HTMLElement).dataset.decline!;
btn.addEventListener("click", (e) => { btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@ -478,6 +526,24 @@ export class RStackNotificationBell extends HTMLElement {
}); });
}); });
// Accept commitment buttons
this.#shadow.querySelectorAll("[data-commit-accept]").forEach((btn) => {
const el = btn as HTMLElement;
btn.addEventListener("click", (e) => {
e.stopPropagation();
this.#acceptCommitment(el.dataset.commitAccept!, el.dataset.space!, el.dataset.result!);
});
});
// Decline commitment buttons
this.#shadow.querySelectorAll("[data-commit-decline]").forEach((btn) => {
const el = btn as HTMLElement;
btn.addEventListener("click", (e) => {
e.stopPropagation();
this.#declineCommitment(el.dataset.commitDecline!, el.dataset.space!, el.dataset.result!);
});
});
// Dismiss buttons // Dismiss buttons
this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => { this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => {
const id = (btn as HTMLElement).dataset.dismiss!; const id = (btn as HTMLElement).dataset.dismiss!;