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_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- 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_URL=http://twenty-ch-server:3000
|
||||||
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN:-}
|
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN:-}
|
||||||
- TRANSAK_API_KEY=${TRANSAK_API_KEY:-}
|
- TRANSAK_API_KEY=${TRANSAK_API_KEY:-}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,21 @@ function getSmtpTransport(): Transporter | null {
|
||||||
return _smtpTransport;
|
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();
|
const routes = new Hono();
|
||||||
|
|
||||||
// Provider registry URL (for fulfillment resolution)
|
// Provider registry URL (for fulfillment resolution)
|
||||||
|
|
@ -1641,11 +1656,33 @@ routes.patch("/api/payments/:id/status", async (c) => {
|
||||||
|
|
||||||
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
|
||||||
|
|
||||||
// Fire-and-forget payment success email
|
// Fire-and-forget payment emails to both payer and recipient
|
||||||
if (status === 'paid' && payerEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payerEmail)) {
|
if (status === 'paid') {
|
||||||
const host = c.req.header("host") || "rspace.online";
|
const host = c.req.header("host") || "rspace.online";
|
||||||
sendPaymentSuccessEmail(payerEmail, updated!.payment, host, space)
|
const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||||
.catch((err) => console.error('[rcart] payment email failed:', err));
|
|
||||||
|
// 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
|
// 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) {
|
function paymentToResponse(p: PaymentRequestMeta) {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
|
|
||||||
|
|
@ -623,37 +623,14 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
|
|
||||||
if (!container || !this._splatUrl) return;
|
if (!container || !this._splatUrl) return;
|
||||||
|
|
||||||
|
const isGlb = this._splatUrl.endsWith(".glb") || this._splatUrl.endsWith(".gltf");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamic import from CDN (via importmap)
|
if (isGlb) {
|
||||||
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
|
await this.initGlbViewer(container, loading);
|
||||||
|
} else {
|
||||||
const viewer = new GaussianSplats3D.Viewer({
|
await this.initSplatViewer(container, loading);
|
||||||
cameraUp: [0, 1, 0],
|
}
|
||||||
initialCameraPosition: [5, 3, 5],
|
|
||||||
initialCameraLookAt: [0, 0, 0],
|
|
||||||
rootElement: container,
|
|
||||||
sharedMemoryForWorkers: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
this._viewer = viewer;
|
|
||||||
|
|
||||||
viewer.addSplatScene(this._splatUrl, {
|
|
||||||
showLoadingUI: false,
|
|
||||||
progressiveLoad: true,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
viewer.start();
|
|
||||||
if (loading) loading.classList.add("hidden");
|
|
||||||
})
|
|
||||||
.catch((e: Error) => {
|
|
||||||
console.error("[rSplat] Scene load error:", e);
|
|
||||||
if (loading) {
|
|
||||||
const text = loading.querySelector(".splat-loading__text");
|
|
||||||
if (text) text.textContent = `Error: ${e.message}`;
|
|
||||||
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
|
|
||||||
if (spinner) spinner.style.display = "none";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[rSplat] Viewer init error:", e);
|
console.error("[rSplat] Viewer init error:", e);
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -664,6 +641,135 @@ export class FolkSplatViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) {
|
||||||
|
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
|
||||||
|
|
||||||
|
const viewer = new GaussianSplats3D.Viewer({
|
||||||
|
cameraUp: [0, 1, 0],
|
||||||
|
initialCameraPosition: [5, 3, 5],
|
||||||
|
initialCameraLookAt: [0, 0, 0],
|
||||||
|
rootElement: container,
|
||||||
|
sharedMemoryForWorkers: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._viewer = viewer;
|
||||||
|
|
||||||
|
viewer.addSplatScene(this._splatUrl!, {
|
||||||
|
showLoadingUI: false,
|
||||||
|
progressiveLoad: true,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
viewer.start();
|
||||||
|
if (loading) loading.classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
console.error("[rSplat] Scene load error:", e);
|
||||||
|
if (loading) {
|
||||||
|
const text = loading.querySelector(".splat-loading__text");
|
||||||
|
if (text) text.textContent = `Error: ${e.message}`;
|
||||||
|
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
|
||||||
|
if (spinner) spinner.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
|
||||||
|
|
@ -726,6 +726,7 @@ export const splatModule: RSpaceModule = {
|
||||||
name: "rSplat",
|
name: "rSplat",
|
||||||
icon: "🔮",
|
icon: "🔮",
|
||||||
description: "3D Gaussian splat viewer",
|
description: "3D Gaussian splat viewer",
|
||||||
|
publicWrite: true,
|
||||||
scoping: { defaultScope: 'global', userConfigurable: true },
|
scoping: { defaultScope: 'global', userConfigurable: true },
|
||||||
docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }],
|
docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }],
|
||||||
routes,
|
routes,
|
||||||
|
|
|
||||||
|
|
@ -1112,6 +1112,7 @@ app.get('/api/account/status', async (c) => {
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
email: hasEmail,
|
email: hasEmail,
|
||||||
|
emailAddress: profile?.profileEmail || null,
|
||||||
multiDevice: hasMultiDevice,
|
multiDevice: hasMultiDevice,
|
||||||
socialRecovery: hasRecovery,
|
socialRecovery: hasRecovery,
|
||||||
credentialCount: creds.length,
|
credentialCount: creds.length,
|
||||||
|
|
@ -3385,6 +3386,15 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => {
|
||||||
return c.json({ success: true });
|
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
|
// USER LOOKUP
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue