Skip to content

Commit 21c9bd5

Browse files
dengzhaofunclaude
andauthored
feat: independent currency system + polymorphic RewardEntry editor (#30)
* feat(server): currency schema + activity binding + drop is_currency Introduces three new tables for the currency subsystem: - currencies: org-scoped catalog (id, alias, name, icon, sort_order, is_active, activity_id, activity_node_id, metadata) - currency_wallets: per-user balance, unique (org, user, currency_id) + version lock, enabling ON CONFLICT DO UPDATE under neon-http's no-transactions constraint - currency_ledger: immutable audit trail mirroring item_grant_logs Also binds activity_id / activity_node_id on the three definition-layer tables (item_definitions, currencies, entity_blueprints) as soft links matching the established check_in_configs / shop_products pattern — lets the activity service's cleanup path archive activity-scoped content by the same mechanism. Finally drops item_definitions.is_currency (currencies are now a first-class table, not an item flag) and swaps the storage_box_deposits.currency_definition_id FK from item_definitions to currencies so deposits can only reference real currencies. Migrations 0028..0031 cover the four schema moves. Applied to local Postgres and Neon dev branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): currency module — CRUD + wallet + ledger + routes New modules/currency/ with the standard service/routes/validators/ errors split. The service is a single `createCurrencyService(deps)` factory (deps: Pick<AppDeps, "db">) exposing: - Definition CRUD (list with activityId filter, get by id or alias, create / update / delete, assertAllExist batch check for other modules) - grant(...) — per-entry loop using ON CONFLICT DO UPDATE on the unique (org, user, currency_id) index; writes a ledger row per grant - deduct(...) — conditional UPDATE with WHERE balance >= amount; a zero-row return becomes CurrencyInsufficientBalance, which makes overdrafts impossible under concurrency without transactions - getBalance / getWallets / listLedger (cursor-paged) Admin routes mounted at /api/currency and client routes at /api/client/currency. Matches the item module surface so the admin UI can lift the same patterns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): RewardEntry gains "currency" type + dispatcher plumbing RewardType = "item" | "entity" | "currency". lib/rewards.ts grows a RewardCurrencySvc interface and makes `currencySvc` a non-optional field on RewardServices — that way a consumer module that forgets to inject currencyService fails at compile time, never silently drops currency rewards at runtime. grantRewards and deductCosts each get a new filterByType("currency") branch that batches entries into a single currencySvc.grant/deduct call, identical in shape to the item branch. All 11 module validators that store RewardEntry[] in jsonb columns have their zod discriminator expanded from ["item", "entity"] to ["item", "entity", "currency"] so the new type is accepted end-to-end. modules/level/client-routes.ts had a handful of inline type literals with the same narrow union — widened alongside. No behavior change for existing item-only or mixed item+entity rewards. Currency entries now flow through the dispatcher untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): bind activityId to item + entity definitions; clean up isCurrency Item and entity CRUD now persist and filter by activity_id / activity_node_id. listDefinitions / listBlueprints accept `activityId: string | null` — passing `null` filters for permanent entries, a string filters to that activity. Standard openapi validators updated on all three layers (validators / service / routes serializer). Removes the last references to `isCurrency` on the item side: - createDefinition / updateDefinition no longer write the column - CreateDefinitionInput / UpdateDefinitionInput / response shapes stripped of isCurrency - routes serializer stops emitting the field The column itself was dropped in the schema commit above; this cleans up the module-layer callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(server): wire storage-box + exchange + task + level to currencyService storage-box: - createStorageBoxService factory takes `currencySvc: CurrencyService` in place of the old `itemSvc: ItemService` - deposit path: currencySvc.deduct (was itemSvc.deductItems) - withdraw path: currencySvc.grant (was itemSvc.grantItems) - validateAcceptedCurrencies now queries the `currencies` table, not `item_definitions.isCurrency` - openapi description & tests updated; service.test.ts rewrites "make currency" fixture to use currencyService.createDefinition exchange: - Factory accepts both itemSvc and currencySvc; internal helpers `deductOne(entry)` / `grantOne(entry)` route per-RewardEntry by type, so a cost/reward list can mix item + currency. Rollback path dispatches by the same helpers, preserving exchange's per-entry rollback semantics (batching via deductCosts would have collapsed the "which ones already succeeded" granularity). task + level: - Barrel wires `currencySvc: currencyService` alongside the existing itemSvc / entitySvc; mock RewardServices in their tests grows the same field (required by the type). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): currency management page + ActivityPicker on item/entity forms New admin currency module: - lib/types/currency.ts — CurrencyDefinition, WalletView, LedgerEntry, grant/deduct payload types - hooks/use-currency.ts — list/get/create/update/delete; useUserWallets, useUserBalance; useGrantCurrency / useDeductCurrency; useCurrencyLedger with cursor-paged filter - components/currency/{DefinitionForm,DefinitionTable,LedgerTable}.tsx - routes/_dashboard/currency/{index,create,$currencyId}.tsx — definitions tab + ledger tab (filter by endUserId / currencyId), create page, detail/edit page with inline DefinitionForm - AppSidebar gains a "货币 / Currency" nav entry (Coins icon) Definition-layer forms on neighboring modules: - item/DefinitionForm: drops the isCurrency Switch (currencies live in their own table now); wires ActivityPicker against activityId - item/DefinitionTable: drops the "货币" badge derivation - entity/BlueprintForm: wires ActivityPicker Wiring fallout: - hooks/use-item drops useCurrencies (moved to use-currency) - lib/types/item + entity: types updated for activityId / activityNodeId and remove isCurrency - storage-box components (StorageBoxConfigForm, StorageBoxDepositLookup) re-point `useCurrencies` import from `use-item` to `use-currency` (same call site, different hook source) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): shared RewardEntry editor — type/currency/item/entity picker Every cost/reward field server-side stores a polymorphic RewardEntry[] = {type, id, count}, but the admin side was fragmented: half the modules used a legacy {definitionId, quantity} shape (shop, exchange, check-in, collection, task, cdkey, mail, lottery, dialogue), and level / leaderboard / activity each defined their own narrow RewardEntry that didn't include "currency". This commit unifies all of them. New shared infrastructure: - lib/types/rewards.ts — canonical RewardEntry + RewardType - hooks/use-reward-catalog.ts — parallel fetch of items + currencies + entity blueprints with a per-type index + resolveLabel helper - components/rewards/RewardEntryEditor.tsx — `[type ▾][target ▾] [count #][🗑]` row-based editor. Second dropdown populates from useRewardCatalog by the currently-selected type; switching type clears the target id. Optional `allowedTypes` prop for callers that want to restrict (e.g. deduct-only fields hiding "entity"). Migrated forms: - shop ProductForm (cost + reward) + StageForm (reward) - exchange OptionForm (cost + reward) — deleted its local handwritten ItemEntryEditor - collection MilestoneForm (reward) — drops its `itemDefinitions` prop; caller routes (albumId/index.tsx) drop the pass-through - check-in RewardForm — single reward list - mail MessageForm — single reward list - lottery PrizeForm — rewards moved from single item-only dropdown to full editor (can now reward currency or entity) - dialogue ScriptEditor (onEnter + option rewards) - cdkey/create — deleted its local handwritten editor Polymorphic display: - components/item/ItemRewardRow accepts `entry: RewardEntry` and resolves name/icon via useRewardCatalog (keeps the legacy {definitionId, quantity} props for call-sites that haven't migrated — they're treated as type="item") - 4 display sites now pass `entry` instead of two separate props: check-in/RewardsSection, exchange/ExecutePanel, mail/$messageId, collection/$albumId (the old "name × quantity" text rendering was also upgraded to use ItemRewardRow). Types unified: - 9 lib/types/*.ts switch `ItemEntry[]` to `RewardEntry[]` and import from ./rewards. item.ts keeps its legacy ItemEntry shape intact — that one is for grant/deduct admin API only (goes straight to itemService, doesn't flow through RewardEntry). - level/leaderboard/activity had each defined a local RewardEntry = {type: "item"|"entity",...} — now re-export the canonical one so they automatically pick up "currency". i18n: - reward_type_item / currency / entity - reward_entry_empty_hint / reward_entry_pick_target / reward_entry_add zh + en. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 19cf34a commit 21c9bd5

102 files changed

Lines changed: 66366 additions & 773 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/admin/messages/en.json

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,5 +975,38 @@
975975
"media_picker_tab_library": "From library",
976976
"media_picker_tab_upload": "Upload new",
977977
"media_picker_upload_hint": "New assets upload to the default folder — you can reorganize them later in the media library.",
978-
"media_folder_default_name": "Default uploads"
978+
"media_folder_default_name": "Default uploads",
979+
"nav_currency": "Currency",
980+
"common_link_activity": "Linked Activity",
981+
"common_link_activity_hint": "Binding to an activity makes this entry follow the activity's cleanup policy when it archives. Leave empty for a permanent entry.",
982+
"currency_definitions": "Currencies",
983+
"currency_ledger": "Ledger",
984+
"currency_new_definition": "New Currency",
985+
"currency_created": "Currency created",
986+
"currency_updated": "Currency updated",
987+
"currency_deleted": "Currency deleted",
988+
"currency_failed_create": "Failed to create currency",
989+
"currency_failed_update": "Failed to update currency",
990+
"currency_failed_delete": "Failed to delete currency",
991+
"currency_delete_confirm": "Delete this currency?",
992+
"currency_delete_hint": "All user wallets and ledger entries will be removed. This cannot be undone.",
993+
"currency_alias_hint": "Optional URL-friendly key. Lowercase letters, digits, hyphens, underscores. Unique per organization.",
994+
"currency_sort_order": "Sort order",
995+
"currency_sort_hint": "Lower values sort first — drives both admin list ordering and the in-game wallet display.",
996+
"currency_permanent": "Permanent",
997+
"currency_empty": "No currencies yet.",
998+
"currency_ledger_empty": "No ledger entries yet.",
999+
"currency_end_user_id": "End user ID",
1000+
"currency_currency": "Currency",
1001+
"currency_delta": "Delta",
1002+
"currency_balance_after": "Balance after",
1003+
"currency_source": "Source",
1004+
"currency_source_id": "Source ID",
1005+
"currency_filter_all": "All",
1006+
"reward_type_item": "Item",
1007+
"reward_type_currency": "Currency",
1008+
"reward_type_entity": "Entity",
1009+
"reward_entry_empty_hint": "No entries yet. Click below to add a reward / cost.",
1010+
"reward_entry_pick_target": "Select target",
1011+
"reward_entry_add": "Add entry"
9791012
}

apps/admin/messages/zh.json

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,5 +975,38 @@
975975
"media_picker_tab_library": "从云盘选择",
976976
"media_picker_tab_upload": "上传新素材",
977977
"media_picker_upload_hint": "新素材会上传到云盘的默认目录,之后也可以在云盘里整理。",
978-
"media_folder_default_name": "默认上传"
978+
"media_folder_default_name": "默认上传",
979+
"nav_currency": "货币",
980+
"common_link_activity": "关联活动",
981+
"common_link_activity_hint": "绑定到某个活动后,活动归档时会按活动的清理策略一起下架;留空 = 常驻。",
982+
"currency_definitions": "货币定义",
983+
"currency_ledger": "流水",
984+
"currency_new_definition": "新建货币",
985+
"currency_created": "货币创建成功",
986+
"currency_updated": "货币已更新",
987+
"currency_deleted": "货币已删除",
988+
"currency_failed_create": "创建货币失败",
989+
"currency_failed_update": "更新货币失败",
990+
"currency_failed_delete": "删除货币失败",
991+
"currency_delete_confirm": "确认删除该货币?",
992+
"currency_delete_hint": "删除后,所有玩家的钱包余额和流水将一并移除,操作不可恢复。",
993+
"currency_alias_hint": "可选的 URL 友好别名。仅小写字母、数字、连字符、下划线,且组织内唯一。",
994+
"currency_sort_order": "排序",
995+
"currency_sort_hint": "数值越小越靠前,用于 Admin 列表与玩家钱包的展示顺序。",
996+
"currency_permanent": "常驻",
997+
"currency_empty": "还没有货币定义。",
998+
"currency_ledger_empty": "暂无流水记录。",
999+
"currency_end_user_id": "玩家 ID",
1000+
"currency_currency": "货币",
1001+
"currency_delta": "变动",
1002+
"currency_balance_after": "变动后余额",
1003+
"currency_source": "来源",
1004+
"currency_source_id": "来源 ID",
1005+
"currency_filter_all": "全部",
1006+
"reward_type_item": "道具",
1007+
"reward_type_currency": "货币",
1008+
"reward_type_entity": "实体",
1009+
"reward_entry_empty_hint": "还没有条目。点击下方按钮添加奖励/消耗。",
1010+
"reward_entry_pick_target": "选择目标",
1011+
"reward_entry_add": "添加条目"
9791012
}

apps/admin/src/components/AppSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ArrowLeftRight,
55
BookOpen,
66
CalendarCheck,
7+
Coins,
78
FolderOpen,
89
GalleryHorizontal,
910
Gift,
@@ -46,6 +47,7 @@ function getNavItems() {
4647
{ title: m.nav_dashboard(), to: "/dashboard" as const, icon: LayoutDashboard },
4748
{ title: m.nav_checkin(), to: "/check-in" as const, icon: CalendarCheck },
4849
{ title: m.nav_item(), to: "/item" as const, icon: Package },
50+
{ title: m.nav_currency(), to: "/currency" as const, icon: Coins },
4951
{ title: m.nav_entity(), to: "/entity" as const, icon: Sparkles },
5052
{ title: m.nav_exchange(), to: "/exchange" as const, icon: ArrowLeftRight },
5153
{ title: m.nav_cdkey(), to: "/cdkey" as const, icon: Ticket },

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

Lines changed: 13 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,12 @@
11
import { useState } from "react"
2-
import { Plus, Trash2 } from "lucide-react"
32

43
import * as m from "#/paraglide/messages.js"
4+
import { RewardEntryEditor } from "#/components/rewards/RewardEntryEditor"
55
import { Button } from "#/components/ui/button"
66
import { Input } from "#/components/ui/input"
77
import { Label } from "#/components/ui/label"
8-
import {
9-
Select,
10-
SelectContent,
11-
SelectItem,
12-
SelectTrigger,
13-
SelectValue,
14-
} from "#/components/ui/select"
15-
import { useItemDefinitions } from "#/hooks/use-item"
168
import type { CreateRewardInput } from "#/lib/types/check-in-reward"
17-
import type { ItemEntry } from "#/lib/types/item"
18-
19-
interface EntryRow {
20-
definitionId: string
21-
quantity: number
22-
}
9+
import type { RewardEntry } from "#/lib/types/rewards"
2310

2411
interface RewardFormProps {
2512
defaultValues?: Partial<CreateRewardInput>
@@ -34,14 +21,11 @@ export function RewardForm({
3421
isPending,
3522
submitLabel = m.common_create(),
3623
}: RewardFormProps) {
37-
const { data: definitions } = useItemDefinitions()
38-
const defs = (definitions ?? []).map((d) => ({ id: d.id, name: d.name }))
39-
4024
const [dayNumber, setDayNumber] = useState(defaultValues?.dayNumber ?? 1)
41-
const [entries, setEntries] = useState<EntryRow[]>(
25+
const [entries, setEntries] = useState<RewardEntry[]>(
4226
defaultValues?.rewardItems?.length
4327
? defaultValues.rewardItems.map((e) => ({ ...e }))
44-
: [{ definitionId: "", quantity: 1 }],
28+
: [],
4529
)
4630
const [dayError, setDayError] = useState("")
4731

@@ -53,12 +37,12 @@ export function RewardForm({
5337
}
5438
setDayError("")
5539

56-
const validEntries = entries.filter((e) => e.definitionId && e.quantity > 0)
57-
if (validEntries.length === 0) return
40+
const valid = entries.filter((e) => e.id && e.count > 0)
41+
if (valid.length === 0) return
5842

5943
onSubmit({
6044
dayNumber,
61-
rewardItems: validEntries as ItemEntry[],
45+
rewardItems: valid,
6246
})
6347
}
6448

@@ -73,75 +57,17 @@ export function RewardForm({
7357
value={dayNumber}
7458
onChange={(e) => setDayNumber(Number(e.target.value))}
7559
/>
76-
{dayError && (
77-
<p className="text-sm text-destructive">{dayError}</p>
78-
)}
60+
{dayError && <p className="text-sm text-destructive">{dayError}</p>}
7961
<p className="text-xs text-muted-foreground">
8062
Which consecutive check-in day triggers this reward.
8163
</p>
8264
</div>
8365

84-
<div className="space-y-3">
85-
<Label>Reward Items *</Label>
86-
{entries.map((entry, i) => (
87-
<div key={i} className="flex items-end gap-2">
88-
<div className="flex-1">
89-
<Select
90-
value={entry.definitionId}
91-
onValueChange={(v) => {
92-
const next = [...entries]
93-
next[i] = { ...entry, definitionId: v }
94-
setEntries(next)
95-
}}
96-
>
97-
<SelectTrigger className="w-full">
98-
<SelectValue placeholder="Select item..." />
99-
</SelectTrigger>
100-
<SelectContent>
101-
{defs.map((def) => (
102-
<SelectItem key={def.id} value={def.id}>
103-
{def.name}
104-
</SelectItem>
105-
))}
106-
</SelectContent>
107-
</Select>
108-
</div>
109-
<div className="w-24">
110-
<Input
111-
type="number"
112-
min={1}
113-
value={entry.quantity}
114-
onChange={(e) => {
115-
const next = [...entries]
116-
next[i] = { ...entry, quantity: Number(e.target.value) || 1 }
117-
setEntries(next)
118-
}}
119-
/>
120-
</div>
121-
<Button
122-
type="button"
123-
variant="ghost"
124-
size="icon"
125-
className="size-9"
126-
onClick={() => {
127-
const next = entries.filter((_, j) => j !== i)
128-
setEntries(next.length > 0 ? next : [{ definitionId: "", quantity: 1 }])
129-
}}
130-
>
131-
<Trash2 className="size-4" />
132-
</Button>
133-
</div>
134-
))}
135-
<Button
136-
type="button"
137-
variant="outline"
138-
size="sm"
139-
onClick={() => setEntries([...entries, { definitionId: "", quantity: 1 }])}
140-
>
141-
<Plus className="size-4" />
142-
Add Item
143-
</Button>
144-
</div>
66+
<RewardEntryEditor
67+
label="Reward Items *"
68+
entries={entries}
69+
onChange={setEntries}
70+
/>
14571

14672
<Button type="submit" disabled={isPending}>
14773
{isPending ? "Saving..." : submitLabel}

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,7 @@ export function RewardsSection({ configKey }: RewardsSectionProps) {
108108
<Badge variant="secondary">Day {reward.dayNumber}</Badge>
109109
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 flex-1 text-sm">
110110
{reward.rewardItems.map((item, i) => (
111-
<ItemRewardRow
112-
key={i}
113-
size="sm"
114-
definitionId={item.definitionId}
115-
quantity={item.quantity}
116-
/>
111+
<ItemRewardRow key={i} size="sm" entry={item} />
117112
))}
118113
</div>
119114

apps/admin/src/components/collection/MilestoneForm.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from "react"
22

3-
import { ItemEntryEditor } from "#/components/shop/ItemEntryEditor"
3+
import { RewardEntryEditor } from "#/components/rewards/RewardEntryEditor"
44
import { Button } from "#/components/ui/button"
55
import { Input } from "#/components/ui/input"
66
import { Label } from "#/components/ui/label"
@@ -20,13 +20,12 @@ import type {
2020
CreateMilestoneInput,
2121
MilestoneScope,
2222
} from "#/lib/types/collection"
23-
import type { ItemDefinition, ItemEntry } from "#/lib/types/item"
23+
import type { RewardEntry } from "#/lib/types/rewards"
2424

2525
interface MilestoneFormProps {
2626
initial?: CollectionMilestone
2727
groups: CollectionGroup[]
2828
entries: CollectionEntry[]
29-
itemDefinitions: ItemDefinition[]
3029
onSubmit: (values: CreateMilestoneInput) => void | Promise<void>
3130
submitLabel: string
3231
isPending?: boolean
@@ -37,7 +36,6 @@ export function MilestoneForm({
3736
initial,
3837
groups,
3938
entries,
40-
itemDefinitions,
4139
onSubmit,
4240
submitLabel,
4341
isPending,
@@ -50,7 +48,7 @@ export function MilestoneForm({
5048
const [entryId, setEntryId] = useState<string>(initial?.entryId ?? "")
5149
const [threshold, setThreshold] = useState<number>(initial?.threshold ?? 1)
5250
const [label, setLabel] = useState<string>(initial?.label ?? "")
53-
const [rewardItems, setRewardItems] = useState<ItemEntry[]>(
51+
const [rewardItems, setRewardItems] = useState<RewardEntry[]>(
5452
initial?.rewardItems ?? [],
5553
)
5654
const [autoClaim, setAutoClaim] = useState<boolean>(
@@ -67,7 +65,7 @@ export function MilestoneForm({
6765
setError(m.collection_milestone_error_no_reward())
6866
return
6967
}
70-
if (rewardItems.some((r) => !r.definitionId)) {
68+
if (rewardItems.some((r) => !r.id)) {
7169
setError(m.collection_milestone_error_reward_def())
7270
return
7371
}
@@ -213,11 +211,10 @@ export function MilestoneForm({
213211
placeholder={m.collection_milestone_label_placeholder()}
214212
/>
215213
</div>
216-
<ItemEntryEditor
214+
<RewardEntryEditor
217215
label={m.collection_milestone_field_rewards()}
218216
entries={rewardItems}
219217
onChange={setRewardItems}
220-
definitions={itemDefinitions}
221218
/>
222219
<div className="grid gap-4 md:grid-cols-2">
223220
<div>

0 commit comments

Comments
 (0)