Skip to content

Commit f2a25b4

Browse files
dengzhaofunclaude
andauthored
Add mail/inbox system with broadcast & multicast (#13)
Virtual mailbox design: one row per message plus lazily-written mail_user_states for per-user read/claim state, joined at query time. Avoids fan-out writes for broadcasts (endUserId has no registry) and keeps rare interactions sparse. Server module follows the 7-file pattern (types/errors/validators/ service/routes/client-routes/index). Admin routes under /api/mail, client routes under /api/client/mail. Reward claim reuses itemService.grantItems with (source="mail_claim", sourceId) for idempotency. Programmatic callers get originSource/originSourceId partial-unique dedup so retried activity-settlement emits one mail. Admin UI: list/create/detail pages with reward picker, broadcast vs multicast target switch, requireRead gate, expiry, revoke. Also extract ItemRewardRow shared component and replace inline UUID rendering in check-in, exchange detail, and exchange execute panel so all reward displays show icon + name + quantity uniformly. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5e36183 commit f2a25b4

36 files changed

Lines changed: 22064 additions & 2706 deletions

File tree

apps/admin/messages/en.json

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,68 @@
222222
"nav_checkin": "Check-in",
223223
"nav_item": "Item",
224224
"nav_exchange": "Exchange",
225+
"nav_mail": "Mail",
225226
"nav_api_keys": "API Keys",
226227
"nav_go_to_dashboard": "Go to Dashboard",
227-
"nav_about_starter": "About This Starter"
228+
"nav_about_starter": "About This Starter",
229+
230+
"mail_title": "Mail",
231+
"mail_new_message": "New Mail",
232+
"mail_send": "Send",
233+
"mail_sent": "Mail sent",
234+
"mail_failed_send": "Failed to send mail",
235+
"mail_failed_load": "Failed to load mail:",
236+
"mail_not_found": "Mail not found",
237+
"mail_empty": "No mail yet.",
238+
"mail_all_users": "All users",
239+
"mail_no_rewards": "No rewards attached.",
240+
"mail_stats": "Stats",
241+
"mail_stat_targets": "Targets",
242+
"mail_stat_reads": "Reads",
243+
"mail_stat_claims": "Claims",
244+
"mail_origin_source": "Origin",
245+
246+
"mail_col_title": "Title",
247+
"mail_col_target": "Target",
248+
"mail_col_recipients": "Recipients",
249+
"mail_col_require_read": "Read Required",
250+
"mail_col_sent_at": "Sent At",
251+
252+
"mail_status_active": "Active",
253+
"mail_status_expired": "Expired",
254+
"mail_status_revoked": "Revoked",
255+
256+
"mail_target_broadcast": "Broadcast",
257+
"mail_target_multicast": "Multicast",
258+
"mail_target_unicast": "Unicast",
259+
260+
"mail_require_read_yes": "Read required",
261+
262+
"mail_field_title": "Title",
263+
"mail_field_content": "Content",
264+
"mail_field_target_type": "Target type",
265+
"mail_field_target_hint": "Broadcast reaches every user in the org. Multicast only reaches the listed IDs.",
266+
"mail_field_recipients": "Recipient end user IDs",
267+
"mail_field_recipients_hint": "One per line or comma-separated. Max 5000.",
268+
"mail_field_rewards": "Rewards",
269+
"mail_field_rewards_hint": "Rewards are granted atomically when the user taps Claim. Empty list = announcement-only.",
270+
"mail_field_add_reward": "Add Reward",
271+
"mail_field_select_item": "Select item...",
272+
"mail_field_require_read": "Require read before claim",
273+
"mail_field_require_read_hint": "When on, users must open the mail before Claim becomes active.",
274+
"mail_field_expires_at": "Expires at",
275+
"mail_field_expires_at_hint": "After this time the mail is hidden and unclaimable. Leave blank for no expiry.",
276+
277+
"mail_error_title_content_required": "Title and content are required.",
278+
"mail_error_recipients_required": "At least one recipient is required for multicast.",
279+
280+
"mail_revoke": "Revoke",
281+
"mail_revoked": "Mail revoked",
282+
"mail_failed_revoke": "Failed to revoke mail",
283+
"mail_revoke_confirm_title": "Revoke this mail?",
284+
"mail_revoke_confirm_desc": "Users will no longer see this mail. Claim history is preserved.",
285+
"mail_deleted": "Mail deleted",
286+
"mail_failed_delete": "Failed to delete mail",
287+
"mail_delete_confirm_title": "Permanently delete this mail?",
288+
"mail_delete_confirm_desc": "This removes the message and every user's read/claim state for it. Cannot be undone."
228289
}

apps/admin/messages/zh.json

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,68 @@
222222
"nav_checkin": "签到",
223223
"nav_item": "物品",
224224
"nav_exchange": "兑换",
225+
"nav_mail": "邮件",
225226
"nav_api_keys": "API 密钥",
226227
"nav_go_to_dashboard": "进入仪表盘",
227-
"nav_about_starter": "关于此模板"
228+
"nav_about_starter": "关于此模板",
229+
230+
"mail_title": "邮件",
231+
"mail_new_message": "发送邮件",
232+
"mail_send": "发送",
233+
"mail_sent": "邮件已发送",
234+
"mail_failed_send": "邮件发送失败",
235+
"mail_failed_load": "邮件加载失败:",
236+
"mail_not_found": "未找到该邮件",
237+
"mail_empty": "暂无邮件。",
238+
"mail_all_users": "全部玩家",
239+
"mail_no_rewards": "该邮件未附带奖励。",
240+
"mail_stats": "统计",
241+
"mail_stat_targets": "投递对象",
242+
"mail_stat_reads": "已读数",
243+
"mail_stat_claims": "领取数",
244+
"mail_origin_source": "来源",
245+
246+
"mail_col_title": "标题",
247+
"mail_col_target": "投递方式",
248+
"mail_col_recipients": "收件人数",
249+
"mail_col_require_read": "需先阅读",
250+
"mail_col_sent_at": "发送时间",
251+
252+
"mail_status_active": "有效",
253+
"mail_status_expired": "已过期",
254+
"mail_status_revoked": "已撤回",
255+
256+
"mail_target_broadcast": "全服广播",
257+
"mail_target_multicast": "组播",
258+
"mail_target_unicast": "单播",
259+
260+
"mail_require_read_yes": "需先阅读",
261+
262+
"mail_field_title": "标题",
263+
"mail_field_content": "正文",
264+
"mail_field_target_type": "投递方式",
265+
"mail_field_target_hint": "广播覆盖组织下全部玩家;组播仅投递给列表中的 endUserId。",
266+
"mail_field_recipients": "收件人 endUserId 列表",
267+
"mail_field_recipients_hint": "每行一个或用逗号分隔,最多 5000 个。",
268+
"mail_field_rewards": "奖励",
269+
"mail_field_rewards_hint": "奖励在玩家点击领取时原子发放。留空表示仅公告,无奖励。",
270+
"mail_field_add_reward": "添加奖励",
271+
"mail_field_select_item": "选择物品...",
272+
"mail_field_require_read": "领取前必须先阅读",
273+
"mail_field_require_read_hint": "开启后,玩家必须先打开邮件才能领取奖励。",
274+
"mail_field_expires_at": "过期时间",
275+
"mail_field_expires_at_hint": "到期后邮件对玩家隐藏且不可领取。留空表示永不过期。",
276+
277+
"mail_error_title_content_required": "标题和正文不能为空。",
278+
"mail_error_recipients_required": "组播至少需要一个收件人。",
279+
280+
"mail_revoke": "撤回",
281+
"mail_revoked": "邮件已撤回",
282+
"mail_failed_revoke": "撤回失败",
283+
"mail_revoke_confirm_title": "撤回此邮件?",
284+
"mail_revoke_confirm_desc": "玩家将不再看到此邮件,领取记录会被保留。",
285+
"mail_deleted": "邮件已删除",
286+
"mail_failed_delete": "删除失败",
287+
"mail_delete_confirm_title": "彻底删除此邮件?",
288+
"mail_delete_confirm_desc": "将同时删除消息本体及所有玩家对该邮件的已读/领取状态,且无法恢复。"
228289
}

apps/admin/src/components/AppSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Dices,
77
KeyRound,
88
LayoutDashboard,
9+
Mail,
910
Package,
1011
} from "lucide-react"
1112

@@ -31,6 +32,7 @@ function getNavItems() {
3132
{ title: m.nav_checkin(), to: "/check-in" as const, icon: CalendarCheck },
3233
{ title: m.nav_item(), to: "/item" as const, icon: Package },
3334
{ title: m.nav_exchange(), to: "/exchange" as const, icon: ArrowLeftRight },
35+
{ title: m.nav_mail(), to: "/mail" as const, icon: Mail },
3436
{ title: m.nav_api_keys(), to: "/api-keys" as const, icon: KeyRound },
3537
]
3638
}

apps/admin/src/components/check-in/RewardsSection.tsx

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
useUpdateCheckInReward,
3131
useDeleteCheckInReward,
3232
} from "#/hooks/use-check-in-rewards"
33-
import { useItemDefinitions } from "#/hooks/use-item"
33+
import { ItemRewardRow } from "#/components/item/ItemRewardRow"
3434
import { ApiError } from "#/lib/api-client"
3535
import type { CheckInReward } from "#/lib/types/check-in-reward"
3636

@@ -40,15 +40,10 @@ interface RewardsSectionProps {
4040

4141
export function RewardsSection({ configKey }: RewardsSectionProps) {
4242
const { data: rewards, isPending } = useCheckInRewards(configKey)
43-
const { data: definitions } = useItemDefinitions()
4443
const createMutation = useCreateCheckInReward()
4544
const updateMutation = useUpdateCheckInReward()
4645
const deleteMutation = useDeleteCheckInReward()
4746

48-
const defNameMap = new Map(
49-
(definitions ?? []).map((d) => [d.id, d.name]),
50-
)
51-
5247
const [createOpen, setCreateOpen] = useState(false)
5348
const [editingReward, setEditingReward] = useState<CheckInReward | null>(null)
5449

@@ -111,17 +106,14 @@ export function RewardsSection({ configKey }: RewardsSectionProps) {
111106
className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3"
112107
>
113108
<Badge variant="secondary">Day {reward.dayNumber}</Badge>
114-
<div className="flex-1 text-sm">
109+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 flex-1 text-sm">
115110
{reward.rewardItems.map((item, i) => (
116-
<span key={i}>
117-
{i > 0 && ", "}
118-
{item.quantity}x{" "}
119-
{defNameMap.get(item.definitionId) ?? (
120-
<code className="rounded bg-muted px-1 py-0.5 text-xs">
121-
{item.definitionId.slice(0, 8)}...
122-
</code>
123-
)}
124-
</span>
111+
<ItemRewardRow
112+
key={i}
113+
size="sm"
114+
definitionId={item.definitionId}
115+
quantity={item.quantity}
116+
/>
125117
))}
126118
</div>
127119

apps/admin/src/components/exchange/ExecutePanel.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Input } from "#/components/ui/input"
88
import { Label } from "#/components/ui/label"
99
import { Badge } from "#/components/ui/badge"
1010
import { useExecuteExchange } from "#/hooks/use-exchange"
11+
import { ItemRewardRow } from "#/components/item/ItemRewardRow"
1112
import { ApiError } from "#/lib/api-client"
1213
import type { ExchangeResult } from "#/lib/types/exchange"
1314

@@ -84,20 +85,28 @@ export function ExecutePanel({ optionId }: ExecutePanelProps) {
8485
</span>
8586
</div>
8687
<div className="grid gap-2 text-sm sm:grid-cols-2">
87-
<div>
88+
<div className="space-y-1">
8889
<p className="text-xs font-medium text-muted-foreground">Consumed</p>
8990
{result.costItems.map((item, i) => (
90-
<p key={i}>
91-
{item.quantity}x {item.definitionId.slice(0, 8)}...
92-
</p>
91+
<div key={i}>
92+
<ItemRewardRow
93+
size="sm"
94+
definitionId={item.definitionId}
95+
quantity={item.quantity}
96+
/>
97+
</div>
9398
))}
9499
</div>
95-
<div>
100+
<div className="space-y-1">
96101
<p className="text-xs font-medium text-muted-foreground">Granted</p>
97102
{result.rewardItems.map((item, i) => (
98-
<p key={i}>
99-
{item.quantity}x {item.definitionId.slice(0, 8)}...
100-
</p>
103+
<div key={i}>
104+
<ItemRewardRow
105+
size="sm"
106+
definitionId={item.definitionId}
107+
quantity={item.quantity}
108+
/>
109+
</div>
101110
))}
102111
</div>
103112
</div>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useItemDefinitions } from "#/hooks/use-item"
2+
3+
interface Props {
4+
definitionId: string
5+
quantity: number
6+
/** sm: size-4 icon, tighter gap (for inline / tables). md: size-6 icon (for detail cards). */
7+
size?: "sm" | "md"
8+
className?: string
9+
}
10+
11+
/**
12+
* Renders a single reward entry as: [icon] Name × quantity.
13+
* Falls back to a muted square + truncated id when the definition can't be resolved
14+
* (e.g. the item was deleted). Uses `useItemDefinitions` which is react-query-cached.
15+
*/
16+
export function ItemRewardRow({
17+
definitionId,
18+
quantity,
19+
size = "md",
20+
className,
21+
}: Props) {
22+
const { data: definitions } = useItemDefinitions()
23+
const def = (definitions ?? []).find((d) => d.id === definitionId)
24+
25+
const iconClass =
26+
size === "sm" ? "size-4 shrink-0" : "size-6 shrink-0"
27+
const gapClass = size === "sm" ? "gap-1.5" : "gap-2"
28+
29+
return (
30+
<span className={`inline-flex items-center ${gapClass} ${className ?? ""}`}>
31+
{def?.icon ? (
32+
<img src={def.icon} alt="" className={`${iconClass} rounded object-cover`} />
33+
) : (
34+
<span className={`${iconClass} rounded bg-muted`} aria-hidden />
35+
)}
36+
{def ? (
37+
<span className="font-medium">{def.name}</span>
38+
) : (
39+
<code className="rounded bg-muted px-1 py-0.5 text-xs">
40+
{definitionId.slice(0, 8)}
41+
</code>
42+
)}
43+
<span className="text-muted-foreground">× {quantity}</span>
44+
</span>
45+
)
46+
}

0 commit comments

Comments
 (0)