Wire connect-ring notifications and durable meetup RSVP.

Require login for meetup RSVP with cancel, notify organizers/authors/posters on RSVP/reply/gig/match, optional ntfy + Listmonk hooks, and honest newsletter messaging.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 19:33:07 -05:00
parent 47bbc7dd0f
commit f215b684f6
14 changed files with 275 additions and 73 deletions

View File

@ -21,6 +21,8 @@
- 顶栏:**探索 / 连接 / 成长** 三组下拉,**EN · 主题 · 通知** 固定在右上 - 顶栏:**探索 / 连接 / 成长** 三组下拉,**EN · 主题 · 通知** 固定在右上
- `Ctrl+K` 空态只给三环捷径;次要页面要搜索才出现 - `Ctrl+K` 空态只给三环捷径;次要页面要搜索才出现
- 工具箱默认精选 +「展开全部」,不再当全站站点地图 - 工具箱默认精选 +「展开全部」,不再当全站站点地图
- **连接闭环:** 活动报名需登录(可取消);匹配 / 报名 / 讨论回复 / 赏金申请会写入通知中心;可选 ntfy 推送(`NTFY_ENABLED` + 设置里打开「推送」)
- **订阅:** 写入 PocketBase;配置 `LISTMONK_*` 后同步 Listmonk,未配置时文案如实提示「已登记」
## 技术栈 ## 技术栈

View File

@ -24,6 +24,12 @@ class Settings(BaseSettings):
ntfy_enabled: bool = False ntfy_enabled: bool = False
ntfy_url: str = "http://127.0.0.1:2586" ntfy_url: str = "http://127.0.0.1:2586"
# Optional Listmonk newsletter (leave empty = store only)
listmonk_url: str = ""
listmonk_user: str = ""
listmonk_password: str = ""
listmonk_list_id: int = 1
# Payment (from env on server) # Payment (from env on server)
dev_auto_pay: bool = False dev_auto_pay: bool = False
payment_provider: str = "zpay" payment_provider: str = "zpay"

View File

@ -262,16 +262,30 @@ async def get_meetup(meetup_id: str):
@router.post("/meetups/rsvp") @router.post("/meetups/rsvp")
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)): async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
user_id = None if not authorization or not authorization.startswith("Bearer "):
if authorization and authorization.startswith("Bearer "): raise HTTPException(401, "请先登录后再报名,以便保留名额")
user = get_user_by_token(authorization[7:]) user = get_user_by_token(authorization[7:])
if user: if not user:
user_id = user["id"] raise HTTPException(401, "请先登录后再报名")
user_id = user["id"]
res = community_store.rsvp_meetup(body.meetup_id, user_id) res = community_store.rsvp_meetup(body.meetup_id, user_id)
if not res.get("ok"): if not res.get("ok"):
if res.get("error") == "not_found": if res.get("error") == "not_found":
raise HTTPException(404, "活动不存在") raise HTTPException(404, "活动不存在")
raise HTTPException(400, "活动已满员") raise HTTPException(400, "活动已满员")
meetup = community_store.get_meetup(body.meetup_id) or {}
organizer_id = meetup.get("organizer_id") or ""
if organizer_id and organizer_id != user_id:
from app.services.notify import notify_user
notify_user(
organizer_id,
"新的活动报名",
f"{user.get('name', '游民')} 报名了「{meetup.get('title', '活动')}」",
f"/meetups",
"meetup",
)
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]} return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}

View File

@ -38,6 +38,18 @@ async def post_reply(
reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji) reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji)
if not reply: if not reply:
raise HTTPException(404, "讨论不存在") raise HTTPException(404, "讨论不存在")
discussion = community_store.get_discussion(discussion_id, increment_view=False) or {}
author_id = discussion.get("author_id") or ""
if author_id and author_id != user["id"]:
from app.services.notify import notify_user
notify_user(
author_id,
"讨论有新回复",
f"{user['name']} 回复了「{discussion.get('title', '话题')}」",
f"/community/{discussion_id}",
"community",
)
return {"success": True, "reply": reply} return {"success": True, "reply": reply}
@ -87,7 +99,18 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non
res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message) res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message)
if not res.get("ok"): if not res.get("ok"):
raise HTTPException(400, res.get("error", "申请失败")) raise HTTPException(400, res.get("error", "申请失败"))
return res poster_id = res.get("poster_id") or ""
if poster_id and poster_id != user["id"]:
from app.services.notify import notify_user
notify_user(
poster_id,
"赏金任务有新申请",
f"{user['name']} 申请了「{res.get('title') or '任务'}」",
"/gigs",
"gig",
)
return {"ok": True, "message": res.get("message", "申请已提交")}
@router.get("/notifications", response_model=list[NotificationItem]) @router.get("/notifications", response_model=list[NotificationItem])

View File

@ -412,7 +412,12 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
"gig_applications", "gig_applications",
{"gigId": g["id"], "userId": user_id, "applicant": user_name, "message": message, "status": "pending"}, {"gigId": g["id"], "userId": user_id, "applicant": user_name, "message": message, "status": "pending"},
) )
return {"ok": True, "message": "申请已提交"} return {
"ok": True,
"message": "申请已提交",
"poster_id": g.get("posterId", ""),
"title": g.get("title", ""),
}
# ── Notifications ────────────────────────────────────────────────────── # ── Notifications ──────────────────────────────────────────────────────
@ -727,9 +732,15 @@ def _json_create_gig(user_id: str, user_name: str, payload: dict) -> dict:
def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
_jload() _jload()
gig = next((g for g in (_j.get("gigs") or []) if g.get("id") == gig_id), {}) or {}
_j.setdefault("gig_apps", []).append({"gig_id": gig_id, "user_id": user_id, "user_name": user_name, "message": message}) _j.setdefault("gig_apps", []).append({"gig_id": gig_id, "user_id": user_id, "user_name": user_name, "message": message})
_jsave() _jsave()
return {"ok": True, "message": "申请已提交"} return {
"ok": True,
"message": "申请已提交",
"poster_id": gig.get("poster_id", ""),
"title": gig.get("title", ""),
}
def _json_add_notification(user_id: str, title: str, body: str, link: str, category: str) -> None: def _json_add_notification(user_id: str, title: str, body: str, link: str, category: str) -> None:

View File

@ -0,0 +1,58 @@
"""Unified in-app + optional ntfy push notifications."""
from __future__ import annotations
import asyncio
import re
from app.config import settings
from app.services import community_store, platform_store
from app.services.ntfy_push import send_push
_PREF_KEY = {
"match": "match",
"meetup": "meetup",
"community": "community",
"gig": "community",
"general": None,
}
def _topic_for(user_id: str) -> str:
safe = re.sub(r"[^a-zA-Z0-9_-]", "", user_id)[:48] or "guest"
return f"nomadro-{safe}"
def notify_user(
user_id: str,
title: str,
body: str,
link: str = "",
category: str = "general",
) -> bool:
"""Persist inbox notification; optionally fire ntfy when push prefs allow."""
if not user_id:
return False
prefs = platform_store.get_notif_prefs(user_id)
pref_key = _PREF_KEY.get(category)
if pref_key is not None and not prefs.get(pref_key, True):
return False
community_store.add_notification(user_id, title, body, link, category)
if prefs.get("push") and settings.ntfy_enabled:
click = link if link.startswith("http") else f"{settings.site_base_url.rstrip('/')}{link or '/notifications'}"
try:
loop = asyncio.get_running_loop()
loop.create_task(
send_push(
_topic_for(user_id),
title,
body,
tags=["nomadro", category],
click_url=click,
)
)
except RuntimeError:
# Outside async context — best-effort sync skip; inbox already saved
pass
return True

View File

@ -7,6 +7,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
from app.config import settings
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json" _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json"
_j: dict = {} _j: dict = {}
@ -117,19 +118,55 @@ def add_report(reporter_id: str | None, target_type: str, target_id: str, reason
def subscribe_newsletter(email: str, source: str = "site") -> dict: def subscribe_newsletter(email: str, source: str = "site") -> dict:
"""Save subscription locally; sync to Listmonk when configured."""
email = email.strip().lower()
synced = False
if settings.listmonk_url and settings.listmonk_user:
try:
import httpx
base = settings.listmonk_url.rstrip("/")
auth = (settings.listmonk_user, settings.listmonk_password)
payload = {
"email": email,
"name": email.split("@")[0],
"status": "enabled",
"lists": [settings.listmonk_list_id],
}
with httpx.Client(timeout=8.0) as client:
r = client.post(f"{base}/api/subscribers", json=payload, auth=auth)
if r.status_code in (200, 409):
synced = True
elif r.status_code == 400 and "exists" in (r.text or "").lower():
synced = True
except Exception:
synced = False
if use_pb(): if use_pb():
dup = safe_first("subscriptions", filter=f"email={q(email)}") dup = safe_first("subscriptions", filter=f"email={q(email)}")
if dup: if dup:
return {"ok": True, "message": "已订阅"} return {
"ok": True,
"message": "已订阅" if synced else "已登记订阅(邮件系统稍后开通)",
"synced": synced,
}
safe_create("subscriptions", {"email": email}) safe_create("subscriptions", {"email": email})
return {"ok": True, "message": "订阅成功"} return {
"ok": True,
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
"synced": synced,
}
_jload() _jload()
for n in _j.get("newsletter") or []: for n in _j.get("newsletter") or []:
if n["email"] == email: if n["email"] == email:
return {"ok": True, "message": "已订阅"} return {"ok": True, "message": "已订阅", "synced": synced}
_j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()}) _j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()})
_jsave() _jsave()
return {"ok": True, "message": "订阅成功"} return {
"ok": True,
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
"synced": synced,
}
def get_notif_prefs(user_id: str) -> dict: def get_notif_prefs(user_id: str) -> dict:

View File

@ -139,9 +139,8 @@ class PocketBaseService:
result = await self.create_record("subscriptions", {"email": email}) result = await self.create_record("subscriptions", {"email": email})
if result: if result:
return True, "订阅成功!欢迎加入 nomadro 社区 🎉" return True, "已登记订阅。邮件推送开通后会同步发送。"
# fallback: always succeed in demo mode return False, "订阅暂时不可用,请稍后再试"
return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
def _map_destination(self, r: dict) -> dict: def _map_destination(self, r: dict) -> dict:
return { return {

View File

@ -396,17 +396,21 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id): if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
matched = _ensure_match(user_id, profile, intent) matched = _ensure_match(user_id, profile, intent)
if matched: if matched:
from app.services import community_store from app.services.notify import notify_user
my_prof = get_public_profile(user_id) or {} my_prof = get_public_profile(user_id) or {}
peer_name = profile.get("name", "游民") peer_name = profile.get("name", "游民")
conv_id = matched.get("conversationId", "") conv_id = matched.get("conversationId", "")
link = f"/chat/{conv_id}" if conv_id else "/chat" link = f"/chat/{conv_id}" if conv_id else "/chat"
community_store.add_notification(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link) notify_user(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link, "match")
peer_uid = profile.get("userId") peer_uid = profile.get("userId")
if peer_uid: if peer_uid:
community_store.add_notification( notify_user(
peer_uid, "匹配成功 🎉", f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link peer_uid,
"匹配成功 🎉",
f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧",
link,
"match",
) )
return {"ok": True, "matched": bool(matched), "match": matched} return {"ok": True, "matched": bool(matched), "match": matched}

View File

@ -24,6 +24,12 @@ S3_UPLOAD_PREFIX=nomadweb
NTFY_ENABLED=true NTFY_ENABLED=true
NTFY_URL=http://127.0.0.1:2586 NTFY_URL=http://127.0.0.1:2586
# Optional Listmonk (leave empty = store subscriptions only)
LISTMONK_URL=
LISTMONK_USER=
LISTMONK_PASSWORD=
LISTMONK_LIST_ID=1
ZPAY_PID= ZPAY_PID=
ZPAY_KEY= ZPAY_KEY=
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php

View File

@ -11004,3 +11004,13 @@ img { max-width: 100%; display: block; }
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.meetups-login-hint {
margin: 8px 0 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.meetups-login-hint a {
color: var(--accent-3, #4ecdc4);
text-decoration: underline;
}

View File

@ -2,6 +2,7 @@
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
@ -26,11 +27,16 @@ export default function MeetupsClient({ meetups }: Props) {
const rings = useRingSteps(); const rings = useRingSteps();
const { toast } = useToast(); const { toast } = useToast();
const { token } = useAuth(); const { token } = useAuth();
const router = useRouter();
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all"); const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set()); const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) {
setRsvpIds(new Set());
return;
}
api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {}); api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {});
}, [token]); }, [token]);
@ -40,17 +46,35 @@ export default function MeetupsClient({ meetups }: Props) {
}, [meetups, mode]); }, [meetups, mode]);
const handleRsvp = async (meetup: Meetup) => { const handleRsvp = async (meetup: Meetup) => {
if (rsvpIds.has(meetup.id)) { if (!token) {
toast(t.meetups.alreadyRsvp, "info"); toast("登录后报名,名额才会保留", "info");
router.push(`/login?next=${encodeURIComponent("/meetups")}`);
return; return;
} }
if (rsvpIds.has(meetup.id)) {
setBusyId(meetup.id);
try {
await api.cancelMeetupRsvp(token, meetup.id);
const next = new Set(rsvpIds);
next.delete(meetup.id);
setRsvpIds(next);
toast("已取消报名", "success");
} catch {
toast(t.meetups.rsvpFail, "error");
} finally {
setBusyId(null);
}
return;
}
setBusyId(meetup.id);
try { try {
const res = await api.rsvpMeetup(meetup.id, undefined, token || undefined); const res = await api.rsvpMeetup(meetup.id, undefined, token);
const next = new Set(rsvpIds).add(meetup.id); setRsvpIds(new Set(rsvpIds).add(meetup.id));
setRsvpIds(next);
toast(res.message || t.meetups.rsvpOk, "success"); toast(res.message || t.meetups.rsvpOk, "success");
} catch { } catch {
toast(t.meetups.rsvpFail, "error"); toast(t.meetups.rsvpFail, "error");
} finally {
setBusyId(null);
} }
}; };
@ -67,6 +91,11 @@ export default function MeetupsClient({ meetups }: Props) {
<span className="section-tag">{t.meetups.tag}</span> <span className="section-tag">{t.meetups.tag}</span>
<h1>{t.meetups.title}</h1> <h1>{t.meetups.title}</h1>
<p>{t.meetups.subtitle}</p> <p>{t.meetups.subtitle}</p>
{!token && (
<p className="meetups-login-hint">
<Link href="/login?next=/meetups">登录</Link> 后报名,名额会同步到你的账号
</p>
)}
</div> </div>
<div className="meetups-toolbar reveal"> <div className="meetups-toolbar reveal">
@ -84,57 +113,58 @@ export default function MeetupsClient({ meetups }: Props) {
</div> </div>
<div className="meetups-grid"> <div className="meetups-grid">
{filtered.map((m) => ( {filtered.map((m) => {
<article key={m.id} className="meetup-card reveal"> const joined = rsvpIds.has(m.id);
<div className="meetup-card-top"> return (
<span className="meetup-emoji">{m.emoji}</span> <article key={m.id} className="meetup-card reveal">
<div> <div className="meetup-card-top">
<h3>{m.title}</h3> <span className="meetup-emoji">{m.emoji}</span>
<p className="meetup-meta"> <div>
{MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time} <h3>{m.title}</h3>
</p> <p className="meetup-meta">
{MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time}
</p>
</div>
</div> </div>
</div> <p className="meetup-desc">{m.description}</p>
<p className="meetup-desc">{m.description}</p> <p className="meetup-venue">📍 {m.venue}</p>
<p className="meetup-venue">📍 {m.venue}</p> <div className="meetup-tags">
<div className="meetup-tags"> {m.tags.map((tag) => (
{m.tags.map((tag) => ( <span key={tag} className="meetup-tag">{tag}</span>
<span key={tag} className="meetup-tag">{tag}</span> ))}
))}
</div>
<footer className="meetup-footer">
<span>
{m.rsvp_count}/{m.max_attendees} {t.meetups.rsvp}
</span>
<span>{m.organizer}</span>
<div className="meetup-footer-actions">
{(m.mode === "online" || m.mode === "hybrid") && (
<Link href={`/meetups/${m.id}/live`} className="btn btn-sm meetup-live-btn">
{t.meetups.liveBtn}
</Link>
)}
<button
type="button"
className={`btn btn-primary btn-sm${rsvpIds.has(m.id) ? " is-done" : ""}`}
onClick={() => handleRsvp(m)}
disabled={rsvpIds.has(m.id)}
>
{rsvpIds.has(m.id) ? t.meetups.rsvpDone : t.meetups.rsvpBtn}
</button>
</div> </div>
</footer> <footer className="meetup-footer">
{m.destination_slug && ( <span>
<Link href={`/destinations/${m.destination_slug}`} className="meetup-city-link"> {m.rsvp_count}/{m.max_attendees} {t.meetups.rsvp}
{t.meetups.viewCity} → </span>
</Link> <span>{m.organizer}</span>
)} <div className="meetup-footer-actions">
</article> {(m.mode === "online" || m.mode === "hybrid") && (
))} <Link href={`/meetups/${m.id}/live`} className="btn btn-sm meetup-live-btn">
{t.meetups.liveBtn}
</Link>
)}
<button
type="button"
className={`btn btn-sm${joined ? " btn-ghost is-done" : " btn-primary"}`}
onClick={() => handleRsvp(m)}
disabled={busyId === m.id}
>
{joined ? "取消报名" : t.meetups.rsvpBtn}
</button>
</div>
</footer>
{m.destination_slug && (
<Link href={`/destinations/${m.destination_slug}`} className="meetup-city-link">
{t.meetups.viewCity} →
</Link>
)}
</article>
);
})}
</div> </div>
{filtered.length === 0 && ( {filtered.length === 0 && <p className="dest-empty">{t.meetups.empty}</p>}
<p className="dest-empty">{t.meetups.empty}</p>
)}
<NewsletterSubscribe source="meetups" /> <NewsletterSubscribe source="meetups" />
<RingNext steps={rings.afterMeetups} /> <RingNext steps={rings.afterMeetups} />

View File

@ -52,7 +52,7 @@ export default function NotificationSettingsClient() {
<h1>{t.notifSettings.title}</h1> <h1>{t.notifSettings.title}</h1>
</div> </div>
<div className="join-card reveal"> <div className="join-card reveal">
{(["match", "meetup", "community", "email", "marketing"] as const).map((k) => ( {(["match", "meetup", "community", "push", "email", "marketing"] as const).map((k) => (
<label key={k} className="notif-pref-row"> <label key={k} className="notif-pref-row">
<span>{t.notifSettings[k]}</span> <span>{t.notifSettings[k]}</span>
<input type="checkbox" checked={!!prefs[k]} onChange={() => toggle(k)} /> <input type="checkbox" checked={!!prefs[k]} onChange={() => toggle(k)} />

View File

@ -617,6 +617,7 @@ export const zh = {
match: "匹配通知", match: "匹配通知",
meetup: "活动提醒", meetup: "活动提醒",
community: "社区动态", community: "社区动态",
push: "推送通知 (ntfy)",
email: "邮件摘要", email: "邮件摘要",
marketing: "活动营销", marketing: "活动营销",
}, },
@ -1256,6 +1257,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
match: "Match alerts", match: "Match alerts",
meetup: "Event reminders", meetup: "Event reminders",
community: "Community", community: "Community",
push: "Push (ntfy)",
email: "Email digest", email: "Email digest",
marketing: "Marketing", marketing: "Marketing",
}, },