Skip to content

Commit eabf18d

Browse files
committed
2 parents c52b639 + 3ea974c commit eabf18d

35 files changed

+1204
-49
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type APIHandler } from './helpers/endpoint'
2+
import { createSupabaseDirectClient } from 'shared/supabase/init'
3+
import { throwErrorIfNotAdmin } from 'shared/helpers/auth'
4+
5+
export const getTicketOrders: APIHandler<'get-ticket-orders'> = async (
6+
{ itemId },
7+
auth
8+
) => {
9+
throwErrorIfNotAdmin(auth.uid)
10+
11+
const pg = createSupabaseDirectClient()
12+
13+
const itemFilter = itemId ? `AND so.item_id = $1` : `AND so.item_id = 'manifest-ticket'`
14+
const params = itemId ? [itemId] : []
15+
16+
const rows = await pg.manyOrNone<{
17+
id: string
18+
user_id: string
19+
username: string
20+
display_name: string
21+
email: string | null
22+
item_id: string
23+
price_mana: string
24+
status: string
25+
created_time: Date
26+
}>(
27+
`SELECT
28+
so.id,
29+
so.user_id,
30+
u.username,
31+
u.data->>'name' as display_name,
32+
pu.data->>'email' as email,
33+
so.item_id,
34+
so.price_mana,
35+
so.status,
36+
so.created_time
37+
FROM shop_orders so
38+
JOIN users u ON u.id = so.user_id
39+
LEFT JOIN private_users pu ON pu.id = so.user_id
40+
WHERE 1=1 ${itemFilter}
41+
ORDER BY so.created_time DESC`,
42+
params
43+
)
44+
45+
const orders = rows.map((row) => ({
46+
id: row.id,
47+
userId: row.user_id,
48+
username: row.username,
49+
displayName: row.display_name ?? row.username,
50+
email: row.email,
51+
itemId: row.item_id,
52+
priceMana: Number(row.price_mana),
53+
status: row.status,
54+
createdTime: new Date(row.created_time).getTime(),
55+
}))
56+
57+
return { orders, total: orders.length }
58+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { APIError, type APIHandler } from './helpers/endpoint'
2+
import { createSupabaseDirectClient } from 'shared/supabase/init'
3+
import { getShopItem, isTicketItem } from 'common/shop/items'
4+
5+
export const getTicketStock: APIHandler<'get-ticket-stock'> = async ({
6+
itemId,
7+
}) => {
8+
const item = getShopItem(itemId)
9+
if (!item || !isTicketItem(item)) {
10+
throw new APIError(404, 'Ticket item not found')
11+
}
12+
const maxStock = item.maxStock ?? 0
13+
14+
const pg = createSupabaseDirectClient()
15+
const { count } = await pg.one<{ count: number }>(
16+
`SELECT count(*)::int AS count FROM shop_orders
17+
WHERE item_id = $1
18+
AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED')`,
19+
[itemId]
20+
)
21+
22+
return {
23+
sold: count,
24+
maxStock,
25+
available: Math.max(0, maxStock - count),
26+
}
27+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { type APIHandler } from './helpers/endpoint'
2+
import { createSupabaseDirectClient } from 'shared/supabase/init'
3+
import { getTicketItems } from 'common/shop/items'
4+
5+
export const getUserTicketPurchased: APIHandler<
6+
'get-user-ticket-purchased'
7+
> = async (_props, auth) => {
8+
// Returns true if the user has purchased ANY Manifest ticket — all variants
9+
// share one purchase slot (early-bird blocks standard and vice versa).
10+
const pg = createSupabaseDirectClient()
11+
const allTicketIds = getTicketItems().map((t) => t.id)
12+
const row = await pg.oneOrNone(
13+
`SELECT 1 FROM shop_orders
14+
WHERE user_id = $1 AND item_id = ANY($2::text[])
15+
AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED')
16+
LIMIT 1`,
17+
[auth.uid, allTicketIds]
18+
)
19+
return { purchased: !!row }
20+
}

backend/api/src/get-users-by-ids.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { removeNullOrUndefinedProps } from 'common/util/object'
55
export const getUsersByIds: APIHandler<'users/by-id'> = async (props) => {
66
const pg = createSupabaseDirectClient()
77
const users = await pg.manyOrNone(
8-
`select id, name, username, data->>'avatarUrl' as "avatarUrl", data->'isBannedFromPosting' as "isBannedFromPosting"
8+
`select id, name, username, is_bot as "isBot", data->>'avatarUrl' as "avatarUrl", data->'isBannedFromPosting' as "isBannedFromPosting"
99
from users
1010
where id = any($1)`,
1111
[props.ids]

backend/api/src/helpers/bets.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { ContractMetric, isSummary } from 'common/contract-metric'
1010
import { getUniqueBettorBonusAmount } from 'common/economy'
1111
import {
1212
BANNED_TRADING_USER_IDS,
13-
BOT_USERNAMES,
1413
PARTNER_USER_IDS,
1514
} from 'common/envs/constants'
1615
import { CandidateBet } from 'common/new-bet'
@@ -456,7 +455,7 @@ export const getUniqueBettorBonusQuery = (
456455
) => {
457456
const { answerId, isRedemption, isApi } = bet
458457

459-
const isBot = BOT_USERNAMES.includes(bettor.username)
458+
const isBot = bettor.isBot ?? false
460459
const isUnlisted = contract.visibility === 'unlisted'
461460

462461
const answer =

backend/api/src/routes.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { addOrRemoveTopicFromContract } from './add-topic-to-market'
5454
import { addOrRemoveTopicFromTopic } from './add-topic-to-topic'
5555
import { awardBounty } from './award-bounty'
5656
import { banuser } from './ban-user'
57+
import { setbotstatus } from './set-bot-status'
5758
import { blockGroup, unblockGroup } from './block-group'
5859
import { blockMarket, unblockMarket } from './block-market'
5960
import { blockUser, unblockUser } from './block-user'
@@ -258,6 +259,10 @@ import { referUser } from './refer-user'
258259
import { shopCancelSubscription } from './shop-cancel-subscription'
259260
import { shopPurchase } from './shop-purchase'
260261
import { shopPurchaseMerch } from './shop-purchase-merch'
262+
import { shopPurchaseTicket } from './shop-purchase-ticket'
263+
import { getTicketStock } from './get-ticket-stock'
264+
import { getTicketOrders } from './get-ticket-orders'
265+
import { getUserTicketPurchased } from './get-user-ticket-purchased'
261266
import { shopResetAll } from './shop-reset-all'
262267
import { shopShippingRates } from './shop-shipping-rates'
263268
import { shopToggle } from './shop-toggle'
@@ -376,6 +381,7 @@ export const handlers: { [k in APIPath]: APIHandler<k> } = {
376381
'get-related-markets': getRelatedMarkets,
377382
'get-related-markets-by-group': getRelatedMarketsByGroup,
378383
'get-market-context': getMarketContext,
384+
'set-bot-status': setbotstatus,
379385
'ban-user': banuser,
380386
'dismiss-mod-alert': dismissmodalert,
381387
'super-ban-user': superBanUser,
@@ -524,6 +530,10 @@ export const handlers: { [k in APIPath]: APIHandler<k> } = {
524530
'get-shop-stats': getShopStats,
525531
'shop-purchase': shopPurchase,
526532
'shop-purchase-merch': shopPurchaseMerch,
533+
'shop-purchase-ticket': shopPurchaseTicket,
534+
'get-ticket-stock': getTicketStock,
535+
'get-ticket-orders': getTicketOrders,
536+
'get-user-ticket-purchased': getUserTicketPurchased,
527537
'shop-shipping-rates': shopShippingRates,
528538
'shop-reset-all': shopResetAll,
529539
'shop-toggle': shopToggle,

backend/api/src/set-bot-status.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { APIError, type APIHandler } from 'api/helpers/endpoint'
2+
import { isAdminId } from 'common/envs/constants'
3+
import { trackPublicEvent } from 'shared/analytics'
4+
import { throwErrorIfNotMod } from 'shared/helpers/auth'
5+
import { createSupabaseDirectClient } from 'shared/supabase/init'
6+
import { updateUser } from 'shared/supabase/users'
7+
import { getUser, log } from 'shared/utils'
8+
9+
export const setbotstatus: APIHandler<'set-bot-status'> = async (
10+
props,
11+
auth
12+
) => {
13+
const { userId, isBot } = props
14+
15+
// Allow users to self-mark as bot (one-way only), mods can toggle anyone
16+
const isSelfMark = auth.uid === userId && isBot === true
17+
if (!isSelfMark) {
18+
throwErrorIfNotMod(auth.uid)
19+
}
20+
if (isAdminId(userId))
21+
throw new APIError(403, 'Cannot modify admin bot status')
22+
23+
const user = await getUser(userId)
24+
if (!user) throw new APIError(404, 'User not found')
25+
26+
const pg = createSupabaseDirectClient()
27+
await updateUser(pg, userId, { isBot })
28+
29+
await trackPublicEvent(auth.uid, 'set bot status', { userId, isBot })
30+
log('set bot status', { userId, isBot, by: auth.uid })
31+
return { success: true }
32+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { APIError, type APIHandler } from './helpers/endpoint'
2+
import { runTxnOutsideBetQueue, type TxnData } from 'shared/txn/run-txn'
3+
import { runTransactionWithRetries } from 'shared/transact-with-retries'
4+
import { getUser } from 'shared/utils'
5+
import { getShopItem, getTicketItems, isTicketItem } from 'common/shop/items'
6+
import { betsQueue } from 'shared/helpers/fn-queue'
7+
import { getActiveSupporterEntitlements } from 'shared/supabase/entitlements'
8+
import { getBenefit } from 'common/supporter-config'
9+
10+
export const shopPurchaseTicket: APIHandler<'shop-purchase-ticket'> = async (
11+
{ itemId },
12+
auth
13+
) => {
14+
const item = getShopItem(itemId)
15+
if (!item) throw new APIError(404, 'Item not found')
16+
if (!isTicketItem(item)) throw new APIError(400, 'Item is not a ticket')
17+
if (item.comingSoon) throw new APIError(403, 'This ticket is not yet available')
18+
if (!auth) throw new APIError(401, 'Must be logged in')
19+
20+
// Serialize ALL ticket purchases for this item globally (not per-user) so two
21+
// different users racing for the last slot can't both pass the stock check.
22+
const { orderId, remainingStock } = await betsQueue.enqueueFn(
23+
() =>
24+
runTransactionWithRetries(async (tx) => {
25+
const user = await getUser(auth.uid, tx)
26+
if (!user) throw new APIError(401, 'Your account was not found')
27+
if (user.isBannedFromPosting)
28+
throw new APIError(403, 'Your account is banned')
29+
30+
// One-per-user check across ALL ticket variants — buying early-bird
31+
// blocks standard (and vice versa). Unique partial index handles the
32+
// same-item case; this handles cross-variant.
33+
const allTicketIds = getTicketItems().map((t) => t.id)
34+
const existing = await tx.oneOrNone(
35+
`SELECT item_id FROM shop_orders
36+
WHERE user_id = $1 AND item_id = ANY($2::text[])
37+
AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED')
38+
LIMIT 1`,
39+
[auth.uid, allTicketIds]
40+
)
41+
if (existing) {
42+
throw new APIError(
43+
403,
44+
'You have already purchased a Manifest ticket (limit 1 per person)'
45+
)
46+
}
47+
48+
// Global stock check. Safe without FOR UPDATE because betsQueue serializes
49+
// all ticket purchases for this itemId, and runTransactionWithRetries uses
50+
// serializable isolation as a backstop.
51+
if (item.maxStock) {
52+
const { count } = await tx.one<{ count: number }>(
53+
`SELECT count(*)::int AS count FROM shop_orders
54+
WHERE item_id = $1
55+
AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED')`,
56+
[itemId]
57+
)
58+
if (count >= item.maxStock) {
59+
throw new APIError(403, 'Sold out — all tickets have been claimed')
60+
}
61+
}
62+
63+
const supporterEntitlements = await getActiveSupporterEntitlements(
64+
tx,
65+
auth.uid
66+
)
67+
const shopDiscount = getBenefit(
68+
supporterEntitlements,
69+
'shopDiscount',
70+
0
71+
)
72+
const price =
73+
shopDiscount > 0
74+
? Math.floor(item.price * (1 - shopDiscount))
75+
: item.price
76+
77+
if (user.balance < price) {
78+
throw new APIError(403, 'Insufficient balance')
79+
}
80+
81+
const discountPct = Math.round(shopDiscount * 100)
82+
const description =
83+
discountPct > 0
84+
? `Purchased ${item.name} (${discountPct}% supporter discount)`
85+
: `Purchased ${item.name}`
86+
87+
const txnData: TxnData = {
88+
category: 'SHOP_PURCHASE',
89+
fromType: 'USER',
90+
fromId: auth.uid,
91+
toType: 'BANK',
92+
toId: 'BANK',
93+
amount: price,
94+
token: 'M$',
95+
description,
96+
data: { itemId, ticketOrder: true, supporterDiscount: shopDiscount },
97+
}
98+
const txn = await runTxnOutsideBetQueue(tx, txnData)
99+
100+
const order = await tx.one<{ id: string }>(
101+
`INSERT INTO shop_orders (user_id, item_id, price_mana, txn_id, status)
102+
VALUES ($1, $2, $3, $4, 'COMPLETED')
103+
RETURNING id`,
104+
[auth.uid, itemId, price, txn.id]
105+
)
106+
107+
const { count: newCount } = await tx.one<{ count: number }>(
108+
`SELECT count(*)::int AS count FROM shop_orders
109+
WHERE item_id = $1
110+
AND status NOT IN ('FAILED', 'REFUNDED', 'CANCELLED')`,
111+
[itemId]
112+
)
113+
114+
return {
115+
orderId: order.id,
116+
remainingStock: Math.max(0, (item.maxStock ?? 0) - newCount),
117+
}
118+
}),
119+
[`ticket:${itemId}`]
120+
)
121+
122+
return {
123+
success: true,
124+
orderId,
125+
discountCode: process.env.MANIFEST_DISCOUNT_CODE ?? null,
126+
remainingStock,
127+
}
128+
}

backend/scripts/add-reactivated-to-league.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { BOT_USERNAMES } from 'common/envs/constants'
21
import { groupBy, mapValues } from 'lodash'
32
import { runScript } from 'run-script'
43
import {
@@ -37,9 +36,8 @@ const _reassignBots = (pg: SupabaseDirectClient) => {
3736
cohort = 'bots'
3837
where user_id in (
3938
select id from users
40-
where username in ($1:csv)
39+
where is_bot = true
4140
limit 40
42-
)`,
43-
[BOT_USERNAMES]
41+
)`
4442
)
4543
}

backend/shared/src/generate-leagues.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BOT_USERNAMES, OPTED_OUT_OF_LEAGUES } from 'common/envs/constants'
1+
import { OPTED_OUT_OF_LEAGUES } from 'common/envs/constants'
22
import {
33
getCohortSize,
44
getDemotionAndPromotionCount,
@@ -50,9 +50,9 @@ export async function generateNextSeason(
5050
join users on users.id = user_id
5151
where coalesce(users.data->>'isBannedFromPosting', 'false') = 'false'
5252
and coalesce(users.data->>'userDeleted', 'false') = 'false'
53-
and users.username not in ($2:csv)
53+
and users.is_bot = false
5454
`,
55-
[startDate, BOT_USERNAMES]
55+
[startDate]
5656
)
5757
const activeUserIdsSet = new Set(activeUserIds.map((u) => u.user_id))
5858

@@ -218,7 +218,6 @@ export const insertBots = async (pg: SupabaseDirectClient, season: number) => {
218218

219219
// console.log('alreadyAssignedBotIds', alreadyAssignedBotIds)
220220

221-
const botUsernamesExcludingAcc = BOT_USERNAMES.filter((u) => u !== 'acc')
222221
const prevBoundaries = await getSeasonStartAndEnd(pg, prevSeason)
223222
if (!prevBoundaries) {
224223
log(`Error: Season ${prevSeason} not found in leagues_season_end_times`)
@@ -232,10 +231,11 @@ export const insertBots = async (pg: SupabaseDirectClient, season: number) => {
232231
where contract_bets.created_time > $1
233232
)
234233
select id from users
235-
where users.username in ($2:csv)
234+
where users.is_bot = true
235+
and users.username != 'acc'
236236
and id in (select user_id from active_user_ids)
237237
`,
238-
[startDate, botUsernamesExcludingAcc],
238+
[startDate],
239239
(r) => r.id
240240
)
241241

0 commit comments

Comments
 (0)