me.ts130 lines · main
1import { desc, eq } from 'drizzle-orm';
2
3import { getDb } from '../db/client.js';
4import { sessions, users } from '../db/schema.js';
5import { lookupIp } from '../lib/geoip.js';
6import { isSuperadminEmail } from '../lib/superadmin.js';
7import { getDefaultOrgForUser } from './orgs.js';
8
9export 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
27export 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 */
46export 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
116export 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
127export 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}