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; +