lookup-user.ts 11 KB

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