lookup-user.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { Database, and, eq, sql } from "../src/drizzle/index.js"
  2. import { AuthTable } from "../src/schema/auth.sql.js"
  3. import { UserTable } from "../src/schema/user.sql.js"
  4. import {
  5. BillingTable,
  6. PaymentTable,
  7. SubscriptionTable,
  8. SubscriptionPlan,
  9. UsageTable,
  10. } from "../src/schema/billing.sql.js"
  11. import { WorkspaceTable } from "../src/schema/workspace.sql.js"
  12. import { BlackData } from "../src/black.js"
  13. import { centsToMicroCents } from "../src/util/price.js"
  14. import { getWeekBounds } from "../src/util/date.js"
  15. // get input from command line
  16. const identifier = process.argv[2]
  17. if (!identifier) {
  18. console.error("Usage: bun lookup-user.ts <email|workspaceID>")
  19. process.exit(1)
  20. }
  21. if (identifier.startsWith("wrk_")) {
  22. await printWorkspace(identifier)
  23. } else {
  24. const authData = await Database.use(async (tx) =>
  25. tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
  26. )
  27. if (authData.length === 0) {
  28. console.error("Email not found")
  29. process.exit(1)
  30. }
  31. if (authData.length > 1) console.warn("Multiple users found for email", identifier)
  32. // Get all auth records for email
  33. const accountID = authData[0].accountID
  34. await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)))
  35. // Get all workspaces for this account
  36. const users = await printTable("Workspaces", (tx) =>
  37. tx
  38. .select({
  39. userID: UserTable.id,
  40. workspaceID: UserTable.workspaceID,
  41. workspaceName: WorkspaceTable.name,
  42. role: UserTable.role,
  43. subscribed: SubscriptionTable.timeCreated,
  44. })
  45. .from(UserTable)
  46. .rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
  47. .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
  48. .where(eq(UserTable.accountID, accountID))
  49. .then((rows) =>
  50. rows.map((row) => ({
  51. userID: row.userID,
  52. workspaceID: row.workspaceID,
  53. workspaceName: row.workspaceName,
  54. role: row.role,
  55. subscribed: formatDate(row.subscribed),
  56. })),
  57. ),
  58. )
  59. for (const user of users) {
  60. await printWorkspace(user.workspaceID)
  61. }
  62. }
  63. async function printWorkspace(workspaceID: string) {
  64. const workspace = await Database.use((tx) =>
  65. tx
  66. .select()
  67. .from(WorkspaceTable)
  68. .where(eq(WorkspaceTable.id, workspaceID))
  69. .then((rows) => rows[0]),
  70. )
  71. printHeader(`Workspace "${workspace.name}" (${workspace.id})`)
  72. await printTable("Users", (tx) =>
  73. tx
  74. .select({
  75. authEmail: AuthTable.subject,
  76. inviteEmail: UserTable.email,
  77. role: UserTable.role,
  78. timeSeen: UserTable.timeSeen,
  79. monthlyLimit: UserTable.monthlyLimit,
  80. monthlyUsage: UserTable.monthlyUsage,
  81. timeDeleted: UserTable.timeDeleted,
  82. fixedUsage: SubscriptionTable.fixedUsage,
  83. rollingUsage: SubscriptionTable.rollingUsage,
  84. timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
  85. timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
  86. timeSubscriptionCreated: SubscriptionTable.timeCreated,
  87. subscription: BillingTable.subscription,
  88. })
  89. .from(UserTable)
  90. .innerJoin(BillingTable, eq(BillingTable.workspaceID, workspace.id))
  91. .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
  92. .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
  93. .where(eq(UserTable.workspaceID, workspace.id))
  94. .then((rows) =>
  95. rows.map((row) => {
  96. const subStatus = getSubscriptionStatus(row)
  97. return {
  98. email: (row.timeDeleted ? "❌ " : "") + (row.authEmail ?? row.inviteEmail),
  99. role: row.role,
  100. timeSeen: formatDate(row.timeSeen),
  101. monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
  102. subscribed: formatDate(row.timeSubscriptionCreated),
  103. subWeekly: subStatus.weekly,
  104. subRolling: subStatus.rolling,
  105. rateLimited: subStatus.rateLimited,
  106. retryIn: subStatus.retryIn,
  107. }
  108. }),
  109. ),
  110. )
  111. await printTable("Billing", (tx) =>
  112. tx
  113. .select({
  114. balance: BillingTable.balance,
  115. customerID: BillingTable.customerID,
  116. reload: BillingTable.reload,
  117. subscriptionID: BillingTable.subscriptionID,
  118. subscription: {
  119. plan: BillingTable.subscriptionPlan,
  120. booked: BillingTable.timeSubscriptionBooked,
  121. enrichment: BillingTable.subscription,
  122. },
  123. timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
  124. })
  125. .from(BillingTable)
  126. .where(eq(BillingTable.workspaceID, workspace.id))
  127. .then(
  128. (rows) =>
  129. rows.map((row) => ({
  130. balance: `$${(row.balance / 100000000).toFixed(2)}`,
  131. reload: row.reload ? "yes" : "no",
  132. customerID: row.customerID,
  133. subscriptionID: row.subscriptionID,
  134. subscription: row.subscriptionID
  135. ? [
  136. `Black ${row.subscription.enrichment!.plan}`,
  137. row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
  138. row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
  139. `(ref: ${row.subscriptionID})`,
  140. ].join(" ")
  141. : row.subscription.booked
  142. ? `Waitlist ${row.subscription.plan} plan${row.timeSubscriptionSelected ? " (selected)" : ""}`
  143. : undefined,
  144. }))[0],
  145. ),
  146. )
  147. await printTable("Payments", (tx) =>
  148. tx
  149. .select({
  150. amount: PaymentTable.amount,
  151. paymentID: PaymentTable.paymentID,
  152. invoiceID: PaymentTable.invoiceID,
  153. customerID: PaymentTable.customerID,
  154. timeCreated: PaymentTable.timeCreated,
  155. timeRefunded: PaymentTable.timeRefunded,
  156. })
  157. .from(PaymentTable)
  158. .where(eq(PaymentTable.workspaceID, workspace.id))
  159. .orderBy(sql`${PaymentTable.timeCreated} DESC`)
  160. .limit(100)
  161. .then((rows) =>
  162. rows.map((row) => ({
  163. ...row,
  164. amount: `$${(row.amount / 100000000).toFixed(2)}`,
  165. paymentID: row.paymentID
  166. ? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
  167. : null,
  168. })),
  169. ),
  170. )
  171. /*
  172. await printTable("Usage", (tx) =>
  173. tx
  174. .select({
  175. model: UsageTable.model,
  176. provider: UsageTable.provider,
  177. inputTokens: UsageTable.inputTokens,
  178. outputTokens: UsageTable.outputTokens,
  179. reasoningTokens: UsageTable.reasoningTokens,
  180. cacheReadTokens: UsageTable.cacheReadTokens,
  181. cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
  182. cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
  183. cost: UsageTable.cost,
  184. timeCreated: UsageTable.timeCreated,
  185. })
  186. .from(UsageTable)
  187. .where(eq(UsageTable.workspaceID, workspace.id))
  188. .orderBy(sql`${UsageTable.timeCreated} DESC`)
  189. .limit(10)
  190. .then((rows) =>
  191. rows.map((row) => ({
  192. ...row,
  193. cost: `$${(row.cost / 100000000).toFixed(2)}`,
  194. })),
  195. ),
  196. )
  197. */
  198. }
  199. function formatMicroCents(value: number | null | undefined) {
  200. if (value === null || value === undefined) return null
  201. return `$${(value / 100000000).toFixed(2)}`
  202. }
  203. function formatDate(value: Date | null | undefined) {
  204. if (!value) return null
  205. return value.toISOString().split("T")[0]
  206. }
  207. function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
  208. const usageText = formatMicroCents(usage) ?? "$0.00"
  209. if (limit === null || limit === undefined) return `${usageText} / no limit`
  210. return `${usageText} / $${limit.toFixed(2)}`
  211. }
  212. function formatRetryTime(seconds: number) {
  213. const days = Math.floor(seconds / 86400)
  214. if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
  215. const hours = Math.floor(seconds / 3600)
  216. const minutes = Math.ceil((seconds % 3600) / 60)
  217. if (hours >= 1) return `${hours}hr ${minutes}min`
  218. return `${minutes}min`
  219. }
  220. function getSubscriptionStatus(row: {
  221. subscription: {
  222. plan: (typeof SubscriptionPlan)[number]
  223. } | null
  224. timeSubscriptionCreated: Date | null
  225. fixedUsage: number | null
  226. rollingUsage: number | null
  227. timeFixedUpdated: Date | null
  228. timeRollingUpdated: Date | null
  229. }) {
  230. if (!row.timeSubscriptionCreated || !row.subscription) {
  231. return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
  232. }
  233. const black = BlackData.getLimits({ plan: row.subscription.plan })
  234. const now = new Date()
  235. const week = getWeekBounds(now)
  236. const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
  237. const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
  238. const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
  239. // Calculate current weekly usage (reset if outside current week)
  240. const currentWeekly =
  241. row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
  242. // Calculate current rolling usage
  243. const windowStart = new Date(now.getTime() - rollingWindowMs)
  244. const currentRolling =
  245. row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
  246. // Check rate limiting
  247. const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
  248. const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
  249. let retryIn: string | null = null
  250. if (isWeeklyLimited) {
  251. const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
  252. retryIn = formatRetryTime(retryAfter)
  253. } else if (isRollingLimited && row.timeRollingUpdated) {
  254. const retryAfter = Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
  255. retryIn = formatRetryTime(retryAfter)
  256. }
  257. return {
  258. weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
  259. rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
  260. rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
  261. retryIn,
  262. }
  263. }
  264. function printHeader(title: string) {
  265. console.log()
  266. console.log("─".repeat(title.length))
  267. console.log(`${title}`)
  268. console.log("─".repeat(title.length))
  269. }
  270. function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise<any>): Promise<any> {
  271. return Database.use(async (tx) => {
  272. const data = await callback(tx)
  273. console.log(`\n== ${title} ==`)
  274. if (data.length === 0) {
  275. console.log("(no data)")
  276. } else {
  277. console.table(data)
  278. }
  279. return data
  280. })
  281. }