diff --git a/frontend/app/api/user-profile/route.ts b/frontend/app/api/user-profile/route.ts
index 3b23289..1da9568 100644
--- a/frontend/app/api/user-profile/route.ts
+++ b/frontend/app/api/user-profile/route.ts
@@ -10,7 +10,7 @@ export async function GET(req: NextRequest) {
const { data, error } = await getAdminClient()
.from("user_profiles")
- .select("wallet_address, email, notification_preferences")
+ .select("wallet_address, email, notification_preferences, muted_pools")
.eq("wallet_address", wallet)
.maybeSingle()
diff --git a/frontend/components/group/GroupMuteNotificationsToggle.tsx b/frontend/components/group/GroupMuteNotificationsToggle.tsx
new file mode 100644
index 0000000..60d4edc
--- /dev/null
+++ b/frontend/components/group/GroupMuteNotificationsToggle.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import { useStellar } from "@/components/web3-provider"
+import { useUserProfile } from "@/hooks/useUserProfile"
+import { Button } from "@/components/ui/button"
+
+export function GroupMuteNotificationsToggle({ poolId }: { poolId: string }) {
+ const { address } = useStellar()
+ const { profile, loading, saving, saveProfile } = useUserProfile(address || null)
+ const [localMuted, setLocalMuted] = useState(false)
+
+ useEffect(() => {
+ const muted = !!profile?.muted_pools?.includes(poolId)
+ setLocalMuted(muted)
+ }, [profile?.muted_pools, poolId])
+
+ const isMuted = useMemo(() => localMuted, [localMuted])
+
+ const toggle = async () => {
+ if (!address || !poolId) return
+ const current = profile?.muted_pools ?? []
+ const next = isMuted
+ ? current.filter((id) => id !== poolId)
+ : Array.from(new Set([...current, poolId]))
+
+ setLocalMuted(!isMuted)
+ await saveProfile({ muted_pools: next })
+ }
+
+ return (
+
+
+
Mute notifications for this pool
+
+ Stops email notifications for this pool only.
+
+
+
+
+ )
+}
diff --git a/frontend/components/group/group-details.tsx b/frontend/components/group/group-details.tsx
index 3e45001..49c8ecd 100644
--- a/frontend/components/group/group-details.tsx
+++ b/frontend/components/group/group-details.tsx
@@ -30,6 +30,7 @@ import {
import { usePoolData } from "@/lib/data-layer/PoolDataProvider"
import { useToast } from "@/hooks/use-toast"
import { useOptimisticTransactions } from "@/hooks/useOptimisticTransactions"
+import { GroupMuteNotificationsToggle } from "@/components/group/GroupMuteNotificationsToggle"
interface GroupDetailsProps {
groupId: string
@@ -483,6 +484,12 @@ export function GroupDetails({ groupId, contractAddress }: GroupDetailsProps) {
)}
+ {/* Per-pool notification mute (email only) */}
+
+ {/* pool_id in DB is the same as groupId route param */}
+
+
+
{stats.map((stat, i) => (
>) => {
+ async (
+ updates: Partial>
+ ) => {
if (!walletAddress) return
if (IS_E2E) {
- setProfile((prev) => (prev ? { ...prev, ...updates } : null))
+ setProfile((prev: UserProfile | null) => (prev ? { ...prev, ...updates } : null))
return
}
+
setSaving(true)
await fetch("/api/user-profile", {
method: "POST",
diff --git a/supabase/functions/notify-pool-event/index.ts b/supabase/functions/notify-pool-event/index.ts
index 95c6516..5166c04 100644
--- a/supabase/functions/notify-pool-event/index.ts
+++ b/supabase/functions/notify-pool-event/index.ts
@@ -9,60 +9,66 @@
// RESEND_API_KEY — set in Supabase dashboard > Edge Functions > Secrets
// RESEND_FROM_EMAIL — e.g. "JointSave "
-import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
-import { serve } from "https://deno.land/std@0.177.0/http/server.ts"
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
+import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
-const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") ?? ""
-const RESEND_FROM = Deno.env.get("RESEND_FROM_EMAIL") ?? "JointSave "
-const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""
-const SUPABASE_SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
+const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY") ?? "";
+const RESEND_FROM =
+ Deno.env.get("RESEND_FROM_EMAIL") ?? "JointSave ";
+const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
+const SUPABASE_SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
-const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)
+const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
// ── Types ────────────────────────────────────────────────────────────────────
interface ActivityRecord {
- id: string
- pool_id: string
- activity_type: string
- user_address: string | null
- amount: number | null
- description: string | null
- created_at: string
+ id: string;
+ pool_id: string;
+ activity_type: string;
+ user_address: string | null;
+ amount: number | null;
+ description: string | null;
+ created_at: string;
}
interface WebhookPayload {
- type: "INSERT" | "UPDATE" | "DELETE"
- table: string
- record: ActivityRecord
+ type: "INSERT" | "UPDATE" | "DELETE";
+ table: string;
+ record: ActivityRecord;
}
interface NotificationPreferences {
- email_on_payout: boolean
- email_on_deposit: boolean
- email_on_round: boolean
- email_on_target: boolean
+ email_on_payout: boolean;
+ email_on_deposit: boolean;
+ email_on_round: boolean;
+ email_on_target: boolean;
}
interface UserProfile {
- wallet_address: string
- email: string | null
- notification_preferences: NotificationPreferences
+ wallet_address: string;
+ email: string | null;
+ notification_preferences: NotificationPreferences;
+ muted_pools: string[] | null;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function shortAddress(addr: string): string {
- return `${addr.slice(0, 6)}…${addr.slice(-4)}`
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
}
function xlmFromStroops(stroops: number | null): string {
- if (stroops == null) return "?"
- return (stroops / 10_000_000).toFixed(2)
+ if (stroops == null) return "?";
+ return (stroops / 10_000_000).toFixed(2);
}
-async function sendEmail(to: string, subject: string, html: string): Promise {
- if (!RESEND_API_KEY || !to) return
+async function sendEmail(
+ to: string,
+ subject: string,
+ html: string,
+): Promise {
+ if (!RESEND_API_KEY || !to) return;
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
@@ -70,9 +76,9 @@ async function sendEmail(to: string, subject: string, html: string): Promise
- `
+ `;
}
// ── Main handler ─────────────────────────────────────────────────────────────
serve(async (req) => {
try {
- const payload: WebhookPayload = await req.json()
- if (payload.type !== "INSERT") return new Response("ok", { status: 200 })
+ const payload: WebhookPayload = await req.json();
+ if (payload.type !== "INSERT") return new Response("ok", { status: 200 });
- const act = payload.record
- const { activity_type, pool_id, user_address, amount } = act
+ const act = payload.record;
+ const { activity_type, pool_id, user_address, amount } = act;
- const HANDLED = ["payout", "deposit", "round_advance", "target_reached"]
+ const HANDLED = ["payout", "deposit", "round_advance", "target_reached"];
if (!HANDLED.includes(activity_type)) {
- return new Response("ok", { status: 200 })
+ return new Response("ok", { status: 200 });
}
// Fetch pool info
@@ -109,72 +115,74 @@ serve(async (req) => {
.from("pools")
.select("id, name")
.eq("id", pool_id)
- .single()
- if (!pool) return new Response("pool not found", { status: 200 })
+ .single();
+ if (!pool) return new Response("pool not found", { status: 200 });
// Fetch all pool members
const { data: members } = await sb
.from("pool_members")
.select("member_address")
- .eq("pool_id", pool_id)
- const allMembers: string[] = (members ?? []).map((m: { member_address: string }) => m.member_address)
+ .eq("pool_id", pool_id);
+ const allMembers: string[] = (members ?? []).map(
+ (m: { member_address: string }) => m.member_address,
+ );
// Fetch user profiles for email + preferences lookup
const { data: profiles } = await sb
.from("user_profiles")
- .select("wallet_address, email, notification_preferences")
- .in("wallet_address", allMembers)
+ .select("wallet_address, email, notification_preferences, muted_pools")
+ .in("wallet_address", allMembers);
const profileMap = new Map(
- (profiles ?? []).map((p: UserProfile) => [p.wallet_address, p])
- )
+ (profiles ?? []).map((p: UserProfile) => [p.wallet_address, p]),
+ );
- const poolName = pool.name
- const xlm = xlmFromStroops(amount)
- const senderShort = user_address ? shortAddress(user_address) : "A member"
+ const poolName = pool.name;
+ const xlm = xlmFromStroops(amount);
+ const senderShort = user_address ? shortAddress(user_address) : "A member";
// Determine recipients, preference key, message content
- let recipients: string[] = []
- let prefKey: keyof NotificationPreferences = "email_on_deposit"
- let subject = ""
- let inAppMsg = ""
- let bodyHtml = ""
+ let recipients: string[] = [];
+ let prefKey: keyof NotificationPreferences = "email_on_deposit";
+ let subject = "";
+ let inAppMsg = "";
+ let bodyHtml = "";
if (activity_type === "payout") {
- recipients = user_address ? [user_address] : []
- prefKey = "email_on_payout"
- subject = `You received ${xlm} XLM from ${poolName}`
- inAppMsg = subject
+ recipients = user_address ? [user_address] : [];
+ prefKey = "email_on_payout";
+ subject = `You received ${xlm} XLM from ${poolName}`;
+ inAppMsg = subject;
bodyHtml = emailHtml(
`Great news! You received ${xlm} XLM from your savings pool ${poolName}.
- Log in to view your updated balance.
`
- )
+ Log in to view your updated balance.
`,
+ );
} else if (activity_type === "deposit") {
- recipients = allMembers.filter((a) => a !== user_address)
- prefKey = "email_on_deposit"
- subject = `${senderShort} deposited to ${poolName}`
- inAppMsg = subject
+ recipients = allMembers.filter((a) => a !== user_address);
+ prefKey = "email_on_deposit";
+ subject = `${senderShort} deposited to ${poolName}`;
+ inAppMsg = subject;
bodyHtml = emailHtml(
- `${senderShort} made a deposit of ${xlm} XLM to ${poolName}.
`
- )
+ `${senderShort} made a deposit of ${xlm} XLM to ${poolName}.
`,
+ );
} else if (activity_type === "round_advance") {
- recipients = allMembers
- prefKey = "email_on_round"
- const nextMember = act.description ?? "the next member"
- subject = `Round complete in ${poolName} — ${nextMember} is next`
- inAppMsg = subject
+ recipients = allMembers;
+ prefKey = "email_on_round";
+ const nextMember = act.description ?? "the next member";
+ subject = `Round complete in ${poolName} — ${nextMember} is next`;
+ inAppMsg = subject;
bodyHtml = emailHtml(
`A round is complete in ${poolName}.
- The next beneficiary is ${nextMember}.
`
- )
+ The next beneficiary is ${nextMember}.
`,
+ );
} else if (activity_type === "target_reached") {
- recipients = allMembers
- prefKey = "email_on_target"
- subject = `${poolName} reached its target! You can now withdraw.`
- inAppMsg = subject
+ recipients = allMembers;
+ prefKey = "email_on_target";
+ subject = `${poolName} reached its target! You can now withdraw.`;
+ inAppMsg = subject;
bodyHtml = emailHtml(
`Your savings pool ${poolName} has reached its savings target!
- You are now eligible to withdraw your funds. Log in to proceed.
`
- )
+ You are now eligible to withdraw your funds. Log in to proceed.
`,
+ );
}
// Write in-app notifications for all recipients
@@ -185,33 +193,40 @@ serve(async (req) => {
pool_id,
activity_type,
message: inAppMsg,
- }))
- )
+ })),
+ );
}
- // Send emails, respecting per-user opt-out preferences
+ // Send emails, respecting global preferences AND per-pool mute.
await Promise.all(
recipients.map(async (addr) => {
- const profile = profileMap.get(addr)
- if (!profile?.email) return
+ const profile = profileMap.get(addr);
+ if (!profile?.email) return;
+
+ const isMutedForThisPool = (profile.muted_pools ?? []).includes(
+ pool_id,
+ );
+ if (isMutedForThisPool) return;
+
const prefs: NotificationPreferences = {
email_on_payout: true,
email_on_deposit: true,
email_on_round: true,
email_on_target: true,
...(profile.notification_preferences ?? {}),
- }
- if (!prefs[prefKey]) return
- await sendEmail(profile.email, subject, bodyHtml)
- })
- )
+ };
+ if (!prefs[prefKey]) return;
+
+ await sendEmail(profile.email, subject, bodyHtml);
+ }),
+ );
return new Response(
JSON.stringify({ ok: true, notified: recipients.length }),
- { status: 200, headers: { "Content-Type": "application/json" } }
- )
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
} catch (err) {
- console.error("notify-pool-event error:", err)
- return new Response("internal error", { status: 500 })
+ console.error("notify-pool-event error:", err);
+ return new Response("internal error", { status: 500 });
}
-})
+});
diff --git a/supabase/migrations/20260629010000_add_muted_pools_to_user_profiles.sql b/supabase/migrations/20260629010000_add_muted_pools_to_user_profiles.sql
new file mode 100644
index 0000000..ad90b4b
--- /dev/null
+++ b/supabase/migrations/20260629010000_add_muted_pools_to_user_profiles.sql
@@ -0,0 +1,5 @@
+-- Add per-pool mute preferences to user_profiles
+
+ALTER TABLE user_profiles
+ ADD COLUMN IF NOT EXISTS muted_pools JSONB NOT NULL DEFAULT '[]'::jsonb;
+