Skip to content

Commit 0468181

Browse files
committed
Require a reason for manual balance changes, and let gold move too
Adjusting a balance from the user admin panel now asks for a reason, and that reason is stored on the currency_transactions row for that user alongside the amount, the currency, and who made the change. The modal lists the last five adjustments so the history is visible where the change is made. A bread/gold toggle picks which balance the buttons operate on, so gold no longer has to be granted through a review payout. The three balance actions share one helper now. It takes a row lock before the read-modify-write instead of the old upsert-then-update dance, which could otherwise lose a concurrent payout or purchase. While in here: the actions return their validation failures as data rather than throwing. Production replaces a thrown Server Action message with an opaque digest, so an admin who left the amount at 0 saw "An error occurred in the Server Components render" instead of "Amount must be greater than zero", which read as a broken button. The amount field also keeps its raw string, so clearing it no longer snaps to 0, and the modal tracks the selected user by id so balances and history refresh in place after each change.
1 parent bf1170e commit 0468181

5 files changed

Lines changed: 314 additions & 123 deletions

File tree

src/actions/admin/users.ts

Lines changed: 122 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
"use server";
22

3-
import { eq, sql } from "drizzle-orm";
3+
import { eq } from "drizzle-orm";
44
import { revalidatePath } from "next/cache";
55
import { requireAdminSession } from "@/lib/auth/guards";
66
import { db } from "@/lib/db/db";
77
import { user, userBread } from "@/lib/db/schema";
8-
import { isValidEmail, normalizeBread } from "@/lib/utils";
8+
import {
9+
isValidEmail,
10+
MAX_ADJUSTMENT_REASON_LENGTH,
11+
normalizeBread,
12+
} from "@/lib/utils";
913
import { audit } from "@/lib/audit";
1014
import { recordCurrencyTransaction } from "@/lib/projects/ledger";
1115

16+
// Production strips thrown Server Action messages down to an opaque digest, so
17+
// anything the admin needs to read (validation, conflicts) comes back as data.
18+
export type AdminUserActionResult =
19+
| { success: true }
20+
| { success: false; message: string };
21+
22+
const failed = (message: string): AdminUserActionResult => ({
23+
success: false,
24+
message,
25+
});
26+
1227
export async function updateUserProfile(
1328
userId: string,
1429
data: {
@@ -19,16 +34,16 @@ export async function updateUserProfile(
1934
admin: boolean;
2035
yswsExempt: boolean;
2136
},
22-
) {
37+
): Promise<AdminUserActionResult> {
2338
const session = await requireAdminSession();
2439
const name = data.name.trim();
2540
const email = data.email.trim().toLowerCase();
2641
const image = data.image.trim();
2742

28-
if (!name) throw new Error("Name is required");
29-
if (!isValidEmail(email)) throw new Error("Valid email is required");
43+
if (!name) return failed("Name is required");
44+
if (!isValidEmail(email)) return failed("Valid email is required");
3045
if (session.user.id === userId && !data.admin) {
31-
throw new Error("You cannot remove your own admin access");
46+
return failed("You cannot remove your own admin access");
3247
}
3348

3449
const [updatedUser] = await db
@@ -44,7 +59,7 @@ export async function updateUserProfile(
4459
})
4560
.where(eq(user.id, userId))
4661
.returning({ id: user.id });
47-
if (!updatedUser) throw new Error("User not found");
62+
if (!updatedUser) return failed("User not found");
4863

4964
await audit("admin.user.profile_updated", "user", userId, {
5065
name,
@@ -53,126 +68,145 @@ export async function updateUserProfile(
5368
yswsExempt: data.yswsExempt,
5469
});
5570
revalidatePath("/platform/admin/users");
71+
return { success: true };
5672
}
5773

58-
export async function addUserBread(userId: string, amount: number) {
59-
const session = await requireAdminSession();
60-
const bread = normalizeBread(amount);
61-
if (bread <= 0) throw new Error("Amount must be greater than zero");
74+
export type BalanceCurrency = "bread" | "gold";
6275

63-
await db.transaction(async (tx) => {
64-
const [updated] = await tx
65-
.insert(userBread)
66-
.values({ userId, balance: bread, updatedAt: new Date() })
67-
.onConflictDoUpdate({
68-
target: userBread.userId,
69-
set: {
70-
balance: sql`${userBread.balance} + ${bread}`,
71-
updatedAt: new Date(),
72-
},
73-
})
74-
.returning({ balance: userBread.balance });
75-
await recordCurrencyTransaction(tx, {
76-
userId,
77-
actorId: session.user.id,
78-
type: "admin_adjustment",
79-
amount: bread,
80-
balanceAfter: updated?.balance ?? null,
81-
note: "Admin added bread",
82-
});
83-
});
76+
type AdjustMode = "add" | "deduct" | "set";
8477

85-
await audit("admin.user.bread_add", "user", userId, { amount: bread });
86-
revalidatePath("/platform/admin/users");
87-
}
78+
const AUDIT_ACTION: Record<AdjustMode, string> = {
79+
add: "admin.user.bread_add",
80+
deduct: "admin.user.bread_deduct",
81+
set: "admin.user.bread_set",
82+
};
8883

89-
export async function deductUserBread(userId: string, amount: number) {
84+
// Every manual balance move goes through here so the reason, the currency, and
85+
// the resulting balance always land on the same currency_transactions row. That
86+
// ledger row is the per-user record of why an admin touched the balance.
87+
async function adjustBalance(
88+
mode: AdjustMode,
89+
userId: string,
90+
amount: number,
91+
currency: BalanceCurrency,
92+
rawReason: string,
93+
): Promise<AdminUserActionResult> {
9094
const session = await requireAdminSession();
91-
const bread = normalizeBread(amount);
92-
if (bread <= 0) throw new Error("Amount must be greater than zero");
93-
94-
await db.transaction(async (tx) => {
95-
const [existing] = await tx
96-
.insert(userBread)
97-
.values({ userId, balance: 0, updatedAt: new Date() })
98-
.onConflictDoUpdate({
99-
target: userBread.userId,
100-
set: { updatedAt: new Date() },
101-
})
102-
.returning({ balance: userBread.balance });
103-
const before = existing?.balance ?? 0;
104-
105-
const [updated] = await tx
106-
.update(userBread)
107-
.set({
108-
balance: sql`greatest(${userBread.balance} - ${bread}, 0)`,
109-
updatedAt: new Date(),
110-
})
111-
.where(eq(userBread.userId, userId))
112-
.returning({ balance: userBread.balance });
95+
const value = normalizeBread(amount);
96+
const reason = String(rawReason ?? "").trim();
11397

114-
// Balance is floored at 0, so the amount actually removed can be less than
115-
// the requested deduction when the user had a smaller balance.
116-
const removed = before - (updated?.balance ?? 0);
117-
await recordCurrencyTransaction(tx, {
118-
userId,
119-
actorId: session.user.id,
120-
type: "admin_adjustment",
121-
amount: -removed,
122-
balanceAfter: updated?.balance ?? null,
123-
note: "Admin deducted bread",
124-
});
125-
});
126-
127-
await audit("admin.user.bread_deduct", "user", userId, { amount: bread });
128-
revalidatePath("/platform/admin/users");
129-
}
98+
if (currency !== "bread" && currency !== "gold") {
99+
return failed("Unknown currency");
100+
}
101+
if (!reason) return failed("A reason is required");
102+
if (reason.length > MAX_ADJUSTMENT_REASON_LENGTH) {
103+
return failed(
104+
`Reason must be ${MAX_ADJUSTMENT_REASON_LENGTH} characters or fewer`,
105+
);
106+
}
107+
if (mode !== "set" && value <= 0) {
108+
return failed("Amount must be greater than zero");
109+
}
130110

131-
export async function setUserBread(userId: string, amount: number) {
132-
const session = await requireAdminSession();
133-
const bread = normalizeBread(amount);
111+
const gold = currency === "gold";
112+
const column = gold ? userBread.goldBalance : userBread.balance;
134113

135114
await db.transaction(async (tx) => {
115+
// Lock the row for the transaction so the read-modify-write below can't
116+
// race another payout or purchase touching the same balance.
136117
const [existing] = await tx
137-
.select({ balance: userBread.balance })
118+
.select({ balance: column })
138119
.from(userBread)
139120
.where(eq(userBread.userId, userId))
140-
.limit(1);
121+
.limit(1)
122+
.for("update");
141123
const before = existing?.balance ?? 0;
142124

125+
// Deducting is floored at zero, so the amount actually removed can be less
126+
// than what was asked for when the user had a smaller balance.
127+
const after =
128+
mode === "add"
129+
? before + value
130+
: mode === "deduct"
131+
? Math.max(before - value, 0)
132+
: value;
133+
143134
await tx
144135
.insert(userBread)
145-
.values({ userId, balance: bread, updatedAt: new Date() })
136+
.values({
137+
userId,
138+
...(gold ? { goldBalance: after } : { balance: after }),
139+
updatedAt: new Date(),
140+
})
146141
.onConflictDoUpdate({
147142
target: userBread.userId,
148-
set: { balance: bread, updatedAt: new Date() },
143+
set: {
144+
...(gold ? { goldBalance: after } : { balance: after }),
145+
updatedAt: new Date(),
146+
},
149147
});
150148

151149
await recordCurrencyTransaction(tx, {
152150
userId,
153151
actorId: session.user.id,
154152
type: "admin_adjustment",
155-
amount: bread - before,
156-
balanceAfter: bread,
157-
note: `Admin set bread to ${bread}`,
153+
currency,
154+
amount: after - before,
155+
balanceAfter: after,
156+
note: reason,
158157
});
159158
});
160159

161-
await audit("admin.user.bread_set", "user", userId, { amount: bread });
160+
await audit(AUDIT_ACTION[mode], "user", userId, {
161+
amount: value,
162+
currency,
163+
reason,
164+
});
162165
revalidatePath("/platform/admin/users");
166+
return { success: true };
167+
}
168+
169+
export async function addUserBread(
170+
userId: string,
171+
amount: number,
172+
currency: BalanceCurrency,
173+
reason: string,
174+
) {
175+
return adjustBalance("add", userId, amount, currency, reason);
176+
}
177+
178+
export async function deductUserBread(
179+
userId: string,
180+
amount: number,
181+
currency: BalanceCurrency,
182+
reason: string,
183+
) {
184+
return adjustBalance("deduct", userId, amount, currency, reason);
185+
}
186+
187+
export async function setUserBread(
188+
userId: string,
189+
amount: number,
190+
currency: BalanceCurrency,
191+
reason: string,
192+
) {
193+
return adjustBalance("set", userId, amount, currency, reason);
163194
}
164195

165-
export async function deleteUser(userId: string) {
196+
export async function deleteUser(
197+
userId: string,
198+
): Promise<AdminUserActionResult> {
166199
const session = await requireAdminSession();
167-
if (session.user.id === userId) throw new Error("You cannot delete yourself");
200+
if (session.user.id === userId) return failed("You cannot delete yourself");
168201

169202
const [deletedUser] = await db
170203
.delete(user)
171204
.where(eq(user.id, userId))
172205
.returning({ id: user.id });
173-
if (!deletedUser) throw new Error("User not found");
206+
if (!deletedUser) return failed("User not found");
174207
await audit("admin.user.deleted", "user", userId);
175208
revalidatePath("/platform/admin/users");
176209
revalidatePath("/platform/admin/orders");
177210
revalidatePath("/platform/admin/fulfillment");
211+
return { success: true };
178212
}

src/app/(platform)/platform/admin/users/page.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { desc } from "drizzle-orm";
1+
import { aliasedTable, desc, eq } from "drizzle-orm";
22
import Link from "next/link";
33
import { BreadAmount } from "@/components/shared/bread-amount";
44
import { LoginButton } from "@/components/shared/auth-buttons";
@@ -10,6 +10,7 @@ import { getSession, isAdminSession } from "@/lib/auth/guards";
1010
import { db } from "@/lib/db/db";
1111
import {
1212
account,
13+
currencyTransactions,
1314
orders,
1415
projects,
1516
projectSubmissions,
@@ -18,6 +19,7 @@ import {
1819
userBread,
1920
} from "@/lib/db/schema";
2021
import { AdminUsersTable } from "@/components/platform/admin-users-table";
22+
import type { AdminAdjustment } from "@/components/platform/admin-users-controls";
2123

2224
export default async function AdminUsersPage() {
2325
const currentSession = await getSession();
@@ -43,6 +45,10 @@ export default async function AdminUsersPage() {
4345
);
4446
}
4547

48+
// Manual balance moves carry the reason the admin typed. Only the newest slice
49+
// is loaded; the modal shows the last few per user.
50+
const actor = aliasedTable(user, "actor");
51+
4652
const [
4753
allUsers,
4854
balances,
@@ -51,6 +57,7 @@ export default async function AdminUsersPage() {
5157
activeSessions,
5258
allProjects,
5359
allSubmissions,
60+
recentAdjustments,
5461
] = await Promise.all([
5562
db.select().from(user).orderBy(desc(user.createdAt)),
5663
db.select().from(userBread),
@@ -75,6 +82,20 @@ export default async function AdminUsersPage() {
7582
hoursSpent: projectSubmissions.hoursSpent,
7683
})
7784
.from(projectSubmissions),
85+
db
86+
.select({
87+
userId: currencyTransactions.userId,
88+
amount: currencyTransactions.amount,
89+
currency: currencyTransactions.currency,
90+
reason: currencyTransactions.note,
91+
actorName: actor.name,
92+
createdAt: currencyTransactions.createdAt,
93+
})
94+
.from(currencyTransactions)
95+
.leftJoin(actor, eq(actor.id, currencyTransactions.actorId))
96+
.where(eq(currencyTransactions.type, "admin_adjustment"))
97+
.orderBy(desc(currencyTransactions.createdAt))
98+
.limit(500),
7899
]);
79100

80101
const balanceByUser = new Map(
@@ -144,6 +165,21 @@ export default async function AdminUsersPage() {
144165
]);
145166
}
146167

168+
const ADJUSTMENTS_PER_USER = 5;
169+
const adjustmentsByUser = new Map<string, AdminAdjustment[]>();
170+
for (const row of recentAdjustments) {
171+
const list = adjustmentsByUser.get(row.userId) ?? [];
172+
if (list.length >= ADJUSTMENTS_PER_USER) continue;
173+
list.push({
174+
amount: row.amount,
175+
currency: row.currency,
176+
reason: row.reason,
177+
actorName: row.actorName ?? "Unknown admin",
178+
at: row.createdAt.toLocaleString(),
179+
});
180+
adjustmentsByUser.set(row.userId, list);
181+
}
182+
147183
const sessionsByUser = new Map<string, number>();
148184
for (const sessionRow of activeSessions) {
149185
sessionsByUser.set(
@@ -189,6 +225,7 @@ export default async function AdminUsersPage() {
189225
pendingOrderCount: stats.pendingOrderCount,
190226
accountProviders: providersByUser.get(row.id) ?? [],
191227
activeSessionCount: sessionsByUser.get(row.id) ?? 0,
228+
adjustments: adjustmentsByUser.get(row.id) ?? [],
192229
};
193230
});
194231

0 commit comments

Comments
 (0)