feat: payment email notifications, GLB viewer, and EncryptID email lookup
- Add EncryptID internal endpoint for email lookup by userId - rcart: send "Payment Sent" to payer and "Payment Received" to recipient - rcart: resolve emails via EncryptID when not provided in request - rsplat: add GLB/GLTF 3D viewer using Three.js GLTFLoader - rsplat: enable publicWrite for photo uploads without space membership - docker-compose: add SITE_URL and SPLAT_NOTIFY_EMAIL env vars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
44e7639124
commit
ccca8318a3
|
|
@ -42,6 +42,8 @@ services:
|
|||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SITE_URL=https://rspace.online
|
||||
- SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com
|
||||
- TWENTY_API_URL=http://twenty-ch-server:3000
|
||||
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN:-}
|
||||
- TRANSAK_API_KEY=${TRANSAK_API_KEY:-}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,21 @@ function getSmtpTransport(): Transporter | null {
|
|||
return _smtpTransport;
|
||||
}
|
||||
|
||||
// ── EncryptID internal email lookup ──
|
||||
|
||||
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||||
|
||||
async function lookupEncryptIDEmail(userId: string): Promise<{ email: string | null; username: string | null }> {
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_INTERNAL}/api/internal/user-email/${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) return { email: null, username: null };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.warn('[rcart] EncryptID email lookup failed:', e);
|
||||
return { email: null, username: null };
|
||||
}
|
||||
}
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// Provider registry URL (for fulfillment resolution)
|
||||
|
|
@ -1641,11 +1656,33 @@ routes.patch("/api/payments/:id/status", async (c) => {
|
|||
|
||||
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
||||
|
||||
// Fire-and-forget payment success email
|
||||
if (status === 'paid' && payerEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payerEmail)) {
|
||||
// Fire-and-forget payment emails to both payer and recipient
|
||||
if (status === 'paid') {
|
||||
const host = c.req.header("host") || "rspace.online";
|
||||
sendPaymentSuccessEmail(payerEmail, updated!.payment, host, space)
|
||||
.catch((err) => console.error('[rcart] payment email failed:', err));
|
||||
const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
|
||||
// Resolve payer email: from request body, or look up via EncryptID
|
||||
(async () => {
|
||||
let resolvedPayerEmail = payerEmail;
|
||||
if (!resolvedPayerEmail && payerIdentity) {
|
||||
const lookup = await lookupEncryptIDEmail(payerIdentity);
|
||||
if (lookup.email) resolvedPayerEmail = lookup.email;
|
||||
}
|
||||
// Send "Payment Sent" email to payer
|
||||
if (resolvedPayerEmail && isValidEmail(resolvedPayerEmail)) {
|
||||
sendPaymentSuccessEmail(resolvedPayerEmail, updated!.payment, host, space)
|
||||
.catch((err) => console.error('[rcart] payer email failed:', err));
|
||||
}
|
||||
// Send "Payment Received" email to recipient (creator)
|
||||
const creatorDid = updated!.payment.creatorDid;
|
||||
if (creatorDid) {
|
||||
const creatorLookup = await lookupEncryptIDEmail(creatorDid);
|
||||
if (creatorLookup.email && isValidEmail(creatorLookup.email)) {
|
||||
sendPaymentReceivedEmail(creatorLookup.email, updated!.payment, host, space, resolvedPayerEmail)
|
||||
.catch((err) => console.error('[rcart] recipient email failed:', err));
|
||||
}
|
||||
}
|
||||
})().catch((err) => console.error('[rcart] payment email resolution failed:', err));
|
||||
}
|
||||
|
||||
// Auto-record contribution on linked shopping cart
|
||||
|
|
@ -2091,6 +2128,83 @@ async function sendPaymentSuccessEmail(
|
|||
});
|
||||
}
|
||||
|
||||
async function sendPaymentReceivedEmail(
|
||||
email: string,
|
||||
p: PaymentRequestMeta,
|
||||
host: string,
|
||||
space: string,
|
||||
payerEmail?: string,
|
||||
) {
|
||||
const transport = getSmtpTransport();
|
||||
if (!transport) return;
|
||||
|
||||
const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
|
||||
const explorer = CHAIN_EXPLORERS[p.chainId];
|
||||
const txLink = explorer && p.txHash
|
||||
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
|
||||
: (p.txHash || 'N/A');
|
||||
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
|
||||
const dashboardUrl = `https://${host}/${space}/rcart`;
|
||||
const payerLabel = payerEmail || p.payerIdentity?.slice(0, 10) + '...' || 'Anonymous';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
|
||||
<tr><td style="background:linear-gradient(135deg,#10b981,#06b6d4);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
|
||||
<div style="font-size:48px;margin-bottom:8px">💰</div>
|
||||
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">You Received a Payment</h1>
|
||||
</td></tr>
|
||||
<tr><td style="background:#1a1a24;padding:28px 24px">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
|
||||
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
|
||||
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">From</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${payerLabel}</td></tr>
|
||||
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
|
||||
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
|
||||
<tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
|
||||
<td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
|
||||
</table>
|
||||
<div style="text-align:center;margin-bottom:8px">
|
||||
<a href="${dashboardUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#10b981,#06b6d4);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View Dashboard</a>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
|
||||
<p style="margin:0;color:#6b7280;font-size:12px">
|
||||
Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
|
||||
</p>
|
||||
</td></tr>
|
||||
</table></td></tr></table></body></html>`;
|
||||
|
||||
const text = [
|
||||
`You Received a Payment`,
|
||||
``,
|
||||
`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
|
||||
`From: ${payerLabel}`,
|
||||
`Network: ${chainName}`,
|
||||
`Transaction: ${p.txHash || 'N/A'}`,
|
||||
`Date: ${paidDate}`,
|
||||
``,
|
||||
`View Dashboard: ${dashboardUrl}`,
|
||||
``,
|
||||
`Sent by rSpace — ${dashboardUrl}`,
|
||||
].join('\n');
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'rSpace <noreply@rspace.online>',
|
||||
to: email,
|
||||
subject: `Payment received \u2014 ${p.amount} ${p.token}`,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
console.log(`[rcart] Payment received email sent to ${email}`);
|
||||
}
|
||||
|
||||
function paymentToResponse(p: PaymentRequestMeta) {
|
||||
return {
|
||||
id: p.id,
|
||||
|
|
|
|||
|
|
@ -623,8 +623,26 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
|
||||
if (!container || !this._splatUrl) return;
|
||||
|
||||
const isGlb = this._splatUrl.endsWith(".glb") || this._splatUrl.endsWith(".gltf");
|
||||
|
||||
try {
|
||||
// Dynamic import from CDN (via importmap)
|
||||
if (isGlb) {
|
||||
await this.initGlbViewer(container, loading);
|
||||
} else {
|
||||
await this.initSplatViewer(container, loading);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[rSplat] Viewer init error:", e);
|
||||
if (loading) {
|
||||
const text = loading.querySelector(".splat-loading__text");
|
||||
if (text) text.textContent = `Error loading viewer: ${(e as Error).message}`;
|
||||
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
|
||||
if (spinner) spinner.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) {
|
||||
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
|
||||
|
||||
const viewer = new GaussianSplats3D.Viewer({
|
||||
|
|
@ -637,7 +655,7 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
|
||||
this._viewer = viewer;
|
||||
|
||||
viewer.addSplatScene(this._splatUrl, {
|
||||
viewer.addSplatScene(this._splatUrl!, {
|
||||
showLoadingUI: false,
|
||||
progressiveLoad: true,
|
||||
})
|
||||
|
|
@ -654,15 +672,103 @@ export class FolkSplatViewer extends HTMLElement {
|
|||
if (spinner) spinner.style.display = "none";
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[rSplat] Viewer init error:", e);
|
||||
}
|
||||
|
||||
private async initGlbViewer(container: HTMLElement, loading: HTMLElement | null) {
|
||||
const THREE = await import("three");
|
||||
const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js");
|
||||
const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js");
|
||||
|
||||
const w = container.clientWidth || 800;
|
||||
const h = container.clientHeight || 600;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0f0f14);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 1000);
|
||||
camera.position.set(3, 2, 3);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(w, h);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.2;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.target.set(0, 0, 0);
|
||||
|
||||
// Lighting
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambient);
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
dirLight.position.set(5, 10, 7);
|
||||
scene.add(dirLight);
|
||||
const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4);
|
||||
fillLight.position.set(-5, 3, -5);
|
||||
scene.add(fillLight);
|
||||
|
||||
// Load GLB
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(
|
||||
this._splatUrl!,
|
||||
(gltf: any) => {
|
||||
const model = gltf.scene;
|
||||
// Auto-center and scale
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 2 / maxDim;
|
||||
model.scale.setScalar(scale);
|
||||
model.position.sub(center.multiplyScalar(scale));
|
||||
scene.add(model);
|
||||
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
|
||||
if (loading) loading.classList.add("hidden");
|
||||
},
|
||||
undefined,
|
||||
(err: any) => {
|
||||
console.error("[rSplat] GLB load error:", err);
|
||||
if (loading) {
|
||||
const text = loading.querySelector(".splat-loading__text");
|
||||
if (text) text.textContent = `Error loading viewer: ${(e as Error).message}`;
|
||||
if (text) text.textContent = `Error loading GLB: ${err.message || err}`;
|
||||
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
|
||||
if (spinner) spinner.style.display = "none";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Animation loop
|
||||
let animId: number;
|
||||
const animate = () => {
|
||||
animId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
// Handle resize
|
||||
const onResize = () => {
|
||||
const rw = container.clientWidth || 800;
|
||||
const rh = container.clientHeight || 600;
|
||||
camera.aspect = rw / rh;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(rw, rh);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
// Store cleanup reference
|
||||
(this as any)._glbCleanup = () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", onResize);
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -726,6 +726,7 @@ export const splatModule: RSpaceModule = {
|
|||
name: "rSplat",
|
||||
icon: "🔮",
|
||||
description: "3D Gaussian splat viewer",
|
||||
publicWrite: true,
|
||||
scoping: { defaultScope: 'global', userConfigurable: true },
|
||||
docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }],
|
||||
routes,
|
||||
|
|
|
|||
|
|
@ -1112,6 +1112,7 @@ app.get('/api/account/status', async (c) => {
|
|||
|
||||
return c.json({
|
||||
email: hasEmail,
|
||||
emailAddress: profile?.profileEmail || null,
|
||||
multiDevice: hasMultiDevice,
|
||||
socialRecovery: hasRecovery,
|
||||
credentialCount: creds.length,
|
||||
|
|
@ -3385,6 +3386,15 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => {
|
|||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Internal: email lookup by userId (no auth, internal network only) ──
|
||||
app.get('/api/internal/user-email/:userId', async (c) => {
|
||||
const userId = c.req.param('userId');
|
||||
if (!userId) return c.json({ error: 'userId required' }, 400);
|
||||
const profile = await getUserProfile(userId);
|
||||
if (!profile) return c.json({ error: 'User not found' }, 404);
|
||||
return c.json({ email: profile.profileEmail || null, username: profile.username || null });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// USER LOOKUP
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue