11"use server" ;
22
3- import { eq , sql } from "drizzle-orm" ;
3+ import { eq } from "drizzle-orm" ;
44import { revalidatePath } from "next/cache" ;
55import { requireAdminSession } from "@/lib/auth/guards" ;
66import { db } from "@/lib/db/db" ;
77import { 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" ;
913import { audit } from "@/lib/audit" ;
1014import { 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+
1227export 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}
0 commit comments