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:
parent
c4b85a82e6
commit
3b0999da64
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CONVENIENCE: NOTIFY SPACE ADMINS/MODS
|
// CONVENIENCE: NOTIFY SPACE ADMINS/MODS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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!;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue