me.ts130 lines · main
| 1 | import { desc, eq } from 'drizzle-orm'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { sessions, users } from '../db/schema.js'; |
| 5 | import { lookupIp } from '../lib/geoip.js'; |
| 6 | import { isSuperadminEmail } from '../lib/superadmin.js'; |
| 7 | import { getDefaultOrgForUser } from './orgs.js'; |
| 8 | |
| 9 | export interface ProfilePatch { |
| 10 | name?: string | null; |
| 11 | legalName?: string | null; |
| 12 | companyName?: string | null; |
| 13 | companyRegistrationNumber?: string | null; |
| 14 | vatId?: string | null; |
| 15 | vatVerifiedAt?: Date | null; |
| 16 | addressLine1?: string | null; |
| 17 | addressLine2?: string | null; |
| 18 | addressCity?: string | null; |
| 19 | addressPostalCode?: string | null; |
| 20 | addressRegion?: string | null; |
| 21 | addressCountry?: string | null; |
| 22 | dateOfBirth?: string | null; |
| 23 | countryOfBirth?: string | null; |
| 24 | timezone?: string | null; |
| 25 | } |
| 26 | |
| 27 | export async function getCurrentVat( |
| 28 | userId: string, |
| 29 | ): Promise<{ vatId: string | null; vatVerifiedAt: Date | null }> { |
| 30 | const db = getDb(); |
| 31 | const [row] = await db |
| 32 | .select({ vatId: users.vatId, vatVerifiedAt: users.vatVerifiedAt }) |
| 33 | .from(users) |
| 34 | .where(eq(users.id, userId)) |
| 35 | .limit(1); |
| 36 | return row ?? { vatId: null, vatVerifiedAt: null }; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Fetch the KYC / profile block for the signed-in user. |
| 41 | * Returns what's persisted in `users` plus the most recent session's |
| 42 | * `nearBy` city (resolved from the IP via GeoIP). The raw IP is NEVER |
| 43 | * surfaced — CLAUDE.md §5.1 forbids IP addresses in any public-facing |
| 44 | * response, including the account holder's own. |
| 45 | */ |
| 46 | export async function getProfile(userId: string) { |
| 47 | const db = getDb(); |
| 48 | const [row] = await db |
| 49 | .select({ |
| 50 | id: users.id, |
| 51 | email: users.email, |
| 52 | emailVerified: users.emailVerified, |
| 53 | name: users.name, |
| 54 | image: users.image, |
| 55 | isAdmin: users.isAdmin, |
| 56 | suspendedAt: users.suspendedAt, |
| 57 | legalName: users.legalName, |
| 58 | companyName: users.companyName, |
| 59 | companyRegistrationNumber: users.companyRegistrationNumber, |
| 60 | vatId: users.vatId, |
| 61 | vatVerifiedAt: users.vatVerifiedAt, |
| 62 | addressLine1: users.addressLine1, |
| 63 | addressLine2: users.addressLine2, |
| 64 | addressCity: users.addressCity, |
| 65 | addressPostalCode: users.addressPostalCode, |
| 66 | addressRegion: users.addressRegion, |
| 67 | addressCountry: users.addressCountry, |
| 68 | dateOfBirth: users.dateOfBirth, |
| 69 | countryOfBirth: users.countryOfBirth, |
| 70 | timezone: users.timezone, |
| 71 | createdAt: users.createdAt, |
| 72 | }) |
| 73 | .from(users) |
| 74 | .where(eq(users.id, userId)) |
| 75 | .limit(1); |
| 76 | if (!row) throw new Error('user vanished mid-request'); |
| 77 | |
| 78 | // Env-pinned superadmin allowlist: when BRIVEN_SUPERADMIN_EMAILS is set, |
| 79 | // non-listed accounts are never reported as admin — so the web never |
| 80 | // renders the admin link for them (lib/superadmin.ts holds the why). |
| 81 | row.isAdmin = row.isAdmin && isSuperadminEmail(row.email); |
| 82 | |
| 83 | const [last] = await db |
| 84 | .select({ |
| 85 | createdAt: sessions.createdAt, |
| 86 | ipAddress: sessions.ipAddress, |
| 87 | userAgent: sessions.userAgent, |
| 88 | }) |
| 89 | .from(sessions) |
| 90 | .where(eq(sessions.userId, userId)) |
| 91 | .orderBy(desc(sessions.createdAt)) |
| 92 | .limit(1); |
| 93 | |
| 94 | const nearBy = last ? await lookupIp(last.ipAddress) : null; |
| 95 | |
| 96 | // Default org is resolved once per /v1/me call. It's always the user's |
| 97 | // personal org (auto-created by migration 0010). Web uses this as the |
| 98 | // implicit org-context for every billing + project route — URLs stay |
| 99 | // org-less until Phase 3 adds a switcher. |
| 100 | const defaultOrg = await getDefaultOrgForUser(userId); |
| 101 | |
| 102 | return { |
| 103 | ...row, |
| 104 | defaultOrgId: defaultOrg.id, |
| 105 | lastSignIn: last |
| 106 | ? { |
| 107 | at: last.createdAt, |
| 108 | ipAddress: last.ipAddress, |
| 109 | userAgent: last.userAgent, |
| 110 | nearBy, |
| 111 | } |
| 112 | : null, |
| 113 | }; |
| 114 | } |
| 115 | |
| 116 | export async function updateProfile(userId: string, patch: ProfilePatch): Promise<void> { |
| 117 | const db = getDb(); |
| 118 | await db |
| 119 | .update(users) |
| 120 | .set({ |
| 121 | ...patch, |
| 122 | updatedAt: new Date(), |
| 123 | }) |
| 124 | .where(eq(users.id, userId)); |
| 125 | } |
| 126 | |
| 127 | export async function setAvatar(userId: string, dataUri: string | null): Promise<void> { |
| 128 | const db = getDb(); |
| 129 | await db.update(users).set({ image: dataUri, updatedAt: new Date() }).where(eq(users.id, userId)); |
| 130 | } |