diff --git a/modules/rtime/intent-routes.ts b/modules/rtime/intent-routes.ts index 5c8f348..468f6c9 100644 --- a/modules/rtime/intent-routes.ts +++ b/modules/rtime/intent-routes.ts @@ -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(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 }); }); diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 508c902..b5628e1 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -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(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(commitmentsDocId(space)); + const commitment = cDoc?.items?.[connection.fromCommitmentId]; _syncServer!.changeDoc(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 }); }); diff --git a/server/notification-service.ts b/server/notification-service.ts index 770cf16..bf901a7 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -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 { 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 { + // 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 = ` +
+
+

${escapeHtml(stored.title)}

+ ${stored.body ? `

${escapeHtml(stored.body)}

` : ""} + View in rSpace +
+

+ Sent by ${space}.rspace.online +

+
`; + + 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, """); +} + // ============================================================================ // CONVENIENCE: NOTIFY SPACE ADMINS/MODS // ============================================================================ diff --git a/shared/components/rstack-notification-bell.ts b/shared/components/rstack-notification-bell.ts index 2222e15..068b2dc 100644 --- a/shared/components/rstack-notification-bell.ts +++ b/shared/components/rstack-notification-bell.ts @@ -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 ? `
` : ""; + const commitmentButtons = isCommitment ? ` +
+ + +
` : ""; return ` -
+
${this.#categoryIcon(n.category)}
${n.title}
${n.body ? `
${n.body}
` : ""} ${inviteButtons} + ${commitmentButtons}
${n.actorUsername ? `${n.actorUsername}` : ""} ${this.#timeAgo(n.createdAt)} @@ -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!;