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 { solve } from './solver';
|
||||
import { settleResult, isReadyForSettlement, updateSkillCurves } from './settlement';
|
||||
import { notify } from '../../server/notification-service';
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// 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
|
||||
const updated = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
|
||||
const updatedResult = updated.results[resultId];
|
||||
|
|
@ -336,6 +354,23 @@ export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono
|
|||
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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -279,8 +279,9 @@ routes.post("/api/connections", async (c) => {
|
|||
body: task ? `Task: ${task.name}` : undefined,
|
||||
spaceSlug: space,
|
||||
moduleId: 'rtime',
|
||||
actionUrl: `/${space}/rtime`,
|
||||
actionUrl: `/rtime`,
|
||||
actorDid: claims.did as string | undefined,
|
||||
metadata: { resultId: id, fromCommitmentId },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
|
@ -290,19 +291,40 @@ routes.post("/api/connections", async (c) => {
|
|||
routes.delete("/api/connections/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
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 id = c.req.param("id");
|
||||
ensureTasksDoc(space);
|
||||
ensureCommitmentsDoc(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) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
deletePushSubscriptionByEndpoint,
|
||||
updatePushSubscriptionLastUsed,
|
||||
getNotificationPreferences,
|
||||
getProfileEmailsByDids,
|
||||
type StoredNotification,
|
||||
} 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");
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// ============================================================================
|
||||
|
|
@ -160,6 +190,9 @@ export async function notify(opts: NotifyOptions): Promise<StoredNotification> {
|
|||
sendWebPush(stored, opts).catch(() => {});
|
||||
}
|
||||
|
||||
// 4. Attempt email delivery (non-blocking)
|
||||
sendEmailNotification(stored, opts).catch(() => {});
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const token = this.#getToken();
|
||||
if (!token) return;
|
||||
|
|
@ -375,18 +415,26 @@ export class RStackNotificationBell extends HTMLElement {
|
|||
} else {
|
||||
body = this.#notifications.map(n => {
|
||||
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 ? `
|
||||
<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-decline" data-decline="${n.id}">Decline</button>
|
||||
</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 `
|
||||
<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-content">
|
||||
<div class="notif-title">${n.title}</div>
|
||||
${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
|
||||
${inviteButtons}
|
||||
${commitmentButtons}
|
||||
<div class="notif-meta">
|
||||
${n.actorUsername ? `<span class="notif-actor">${n.actorUsername}</span>` : ""}
|
||||
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
|
||||
|
|
@ -461,7 +509,7 @@ export class RStackNotificationBell extends HTMLElement {
|
|||
});
|
||||
|
||||
// Accept invite buttons
|
||||
this.#shadow.querySelectorAll(".notif-accept").forEach((btn) => {
|
||||
this.#shadow.querySelectorAll("[data-accept]").forEach((btn) => {
|
||||
const el = btn as HTMLElement;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -470,7 +518,7 @@ export class RStackNotificationBell extends HTMLElement {
|
|||
});
|
||||
|
||||
// Decline invite buttons
|
||||
this.#shadow.querySelectorAll(".notif-decline").forEach((btn) => {
|
||||
this.#shadow.querySelectorAll("[data-decline]").forEach((btn) => {
|
||||
const id = (btn as HTMLElement).dataset.decline!;
|
||||
btn.addEventListener("click", (e) => {
|
||||
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
|
||||
this.#shadow.querySelectorAll(".notif-dismiss").forEach((btn) => {
|
||||
const id = (btn as HTMLElement).dataset.dismiss!;
|
||||
|
|
|
|||
Loading…
Reference in New Issue