phone syncs for shipping and stuff

This commit is contained in:
Nathan 2026-02-12 11:50:59 -05:00
parent 02e66b488f
commit b3b9a475de
10 changed files with 94 additions and 4 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR;
ALTER TABLE shop_orders ADD COLUMN IF NOT EXISTS phone VARCHAR;

View file

@ -14,6 +14,7 @@ import slack from './routes/slack'
import { startHackatimeSync } from './lib/hackatime-sync'
import { startAirtableSync } from './lib/airtable-sync'
import { startScrapsPayout } from './lib/scraps-payout'
import { syncAllPhoneNumbers } from './lib/sync-phones'
const api = new Elysia()
.use(authRoutes)
@ -48,3 +49,6 @@ if (process.env.NODE_ENV !== 'development') {
} else {
console.log('[STARTUP] Skipping background syncs in development mode')
}
// Sync phone numbers from Hack Club Auth on startup
syncAllPhoneNumbers()

View file

@ -40,6 +40,7 @@ function buildJustification(project: {
}, reviews: {
action: string
reviewerName: string | null
internalJustification: string | null
createdAt: Date
}[], effectiveHours: number): string {
const lines: string[] = []
@ -55,6 +56,9 @@ function buildJustification(project: {
const reviewerName = review.reviewerName || 'Unknown'
const date = review.createdAt.toISOString().split('T')[0]
lines.push(`- ${reviewerName} ${review.action} on ${date}`)
if (review.internalJustification) {
lines.push(` Justification: ${review.internalJustification}`)
}
}
}
@ -106,12 +110,13 @@ async function syncProjectsToAirtable(): Promise<void> {
// Fetch all reviews for shipped projects with reviewer usernames
const projectIds = projects.map(p => p.id)
let reviewsByProjectId = new Map<number, { action: string; reviewerName: string | null; createdAt: Date }[]>()
let reviewsByProjectId = new Map<number, { action: string; reviewerName: string | null; internalJustification: string | null; createdAt: Date }[]>()
if (projectIds.length > 0) {
const allReviews = await db
.select({
projectId: reviewsTable.projectId,
action: reviewsTable.action,
internalJustification: reviewsTable.internalJustification,
createdAt: reviewsTable.createdAt,
reviewerUsername: usersTable.username
})
@ -124,6 +129,7 @@ async function syncProjectsToAirtable(): Promise<void> {
existing.push({
action: review.action,
reviewerName: review.reviewerUsername,
internalJustification: review.internalJustification,
createdAt: review.createdAt
})
reviewsByProjectId.set(review.projectId, existing)

View file

@ -23,6 +23,7 @@ interface HackClubIdentity {
verification_status?: string
primary_email?: string
slack_id?: string
phone_number?: string
given_name?: string
family_name?: string
birthdate?: string
@ -160,6 +161,7 @@ export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OID
username,
email: identity.primary_email || "",
avatar: avatarUrl,
phone: identity.phone_number || null,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
@ -172,6 +174,7 @@ export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OID
email: sql`COALESCE(${identity.primary_email || null}, ${usersTable.email})`,
slackId: identity.slack_id,
avatar: sql`COALESCE(${avatarUrl}, ${usersTable.avatar})`,
phone: sql`COALESCE(${identity.phone_number || null}, ${usersTable.phone})`,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,

View file

@ -0,0 +1,47 @@
import { eq } from 'drizzle-orm'
import { db } from '../db'
import { usersTable } from '../schemas/users'
import { fetchUserIdentity } from './auth'
export async function syncAllPhoneNumbers() {
console.log('[SYNC-PHONES] Starting phone number sync...')
try {
const allUsers = await db
.select({
id: usersTable.id,
username: usersTable.username,
accessToken: usersTable.accessToken
})
.from(usersTable)
let updated = 0
let failed = 0
for (const u of allUsers) {
if (!u.accessToken) {
continue
}
const identity = await fetchUserIdentity(u.accessToken)
if (!identity) {
failed++
continue
}
const phone = identity.identity.phone_number || null
if (phone) {
await db
.update(usersTable)
.set({ phone, updatedAt: new Date() })
.where(eq(usersTable.id, u.id))
updated++
}
}
console.log(`[SYNC-PHONES] Done. Updated ${updated} of ${allUsers.length} users (${failed} failed)`)
return { total: allUsers.length, updated, failed }
} catch (err) {
console.error('[SYNC-PHONES] Error syncing phone numbers:', err)
}
}

View file

@ -1511,6 +1511,7 @@ admin.get('/orders', async ({ headers, query, status }) => {
notes: shopOrdersTable.notes,
isFulfilled: shopOrdersTable.isFulfilled,
shippingAddress: shopOrdersTable.shippingAddress,
phone: shopOrdersTable.phone,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,

View file

@ -278,6 +278,15 @@ shop.post('/items/:id/purchase', async ({ params, body, headers }) => {
const totalPrice = item.price * quantity
// Get user's phone number for the order
const userPhone = await db
.select({ phone: usersTable.phone })
.from(usersTable)
.where(eq(usersTable.id, user.id))
.limit(1)
const phone = userPhone[0]?.phone || null
try {
const order = await db.transaction(async (tx) => {
// Lock the user row to serialize spend operations and prevent race conditions
@ -317,6 +326,7 @@ shop.post('/items/:id/purchase', async ({ params, body, headers }) => {
pricePerItem: item.price,
totalPrice,
shippingAddress: shippingAddress || null,
phone,
status: 'pending'
})
.returning()
@ -402,6 +412,15 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => {
return { error: 'Out of stock' }
}
// Get user's phone number for orders
const userPhoneResult = await db
.select({ phone: usersTable.phone })
.from(usersTable)
.where(eq(usersTable.id, user.id))
.limit(1)
const userPhone = userPhoneResult[0]?.phone || null
try {
const result = await db.transaction(async (tx) => {
// Lock the user row to serialize spend operations and prevent race conditions
@ -483,6 +502,7 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => {
pricePerItem: rollCost,
totalPrice: rollCost,
shippingAddress: null,
phone: userPhone,
status: 'pending',
orderType: 'luck_win'
})
@ -539,6 +559,7 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => {
pricePerItem: rollCost,
totalPrice: rollCost,
shippingAddress: null,
phone: userPhone,
status: 'pending',
orderType: 'consolation',
notes: `Consolation scrap paper - rolled ${rolled}, needed ${effectiveProbability} or less`

View file

@ -36,6 +36,7 @@ export const shopOrdersTable = pgTable('shop_orders', {
status: varchar().notNull().default('pending'),
orderType: varchar('order_type').notNull().default('purchase'),
shippingAddress: text('shipping_address'),
phone: varchar(),
notes: text(),
isFulfilled: boolean('is_fulfilled').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),

View file

@ -11,6 +11,7 @@ export const usersTable = pgTable('users', {
username: varchar(),
email: varchar().notNull(),
avatar: varchar(),
phone: varchar(),
// OAuth tokens
accessToken: text('access_token'),

View file

@ -28,6 +28,7 @@
notes: string | null;
isFulfilled: boolean;
shippingAddress: string | null;
phone: string | null;
createdAt: string;
itemId: number;
itemName: string;
@ -276,15 +277,18 @@
<div class="mt-2 rounded-lg border border-gray-300 bg-gray-100 p-2">
<p class="mb-1 text-xs font-bold text-gray-500">shipping address</p>
<p class="text-sm">{formatAddress(shippingAddr)}</p>
{#if shippingAddr.phone}
<p class="mt-1 text-xs text-gray-500">📞 {shippingAddr.phone}</p>
{#if order.phone || shippingAddr.phone}
<p class="mt-1 text-xs text-gray-500">phone: {order.phone || shippingAddr.phone}</p>
{/if}
</div>
{:else if order.orderType === 'win'}
<div class="mt-2 rounded-lg border border-yellow-300 bg-yellow-100 p-2">
<p class="text-xs font-bold text-yellow-700">⚠️ no shipping address provided</p>
<p class="text-xs font-bold text-yellow-700">no shipping address provided</p>
</div>
{/if}
{#if order.phone && !parseShippingAddress(order.shippingAddress)}
<p class="mt-1 text-xs text-gray-500">phone: {order.phone}</p>
{/if}
</div>
<!-- Actions -->