auth-user-emails.ts161 lines · main
1/**
2 * User emails service — multiple verified email addresses per user.
3 *
4 * The primary email (the `email` column on `_briven_auth_users`) is the
5 * sign-in identifier. This service manages *additional* emails.
6 *
7 * Phase 3 — BUILD_PLAN.md.
8 */
9
10import { ValidationError } from '@briven/shared';
11
12import { runInProjectDatabase } from '../db/data-plane.js';
13
14export interface UserEmail {
15 id: string;
16 email: string;
17 verified: boolean;
18 primary: boolean;
19 createdAt: Date;
20}
21
22const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23
24function normalizeEmail(email: string): string {
25 return email.trim().toLowerCase();
26}
27
28/**
29 * List all additional emails for a user.
30 */
31export async function listUserEmails(
32 projectId: string,
33 userId: string,
34): Promise<UserEmail[]> {
35 const rows = await runInProjectDatabase<
36 Array<{
37 id: string;
38 email: string;
39 verified: boolean;
40 primary: boolean;
41 created_at: Date;
42 }>
43 >(projectId, async (tx) =>
44 tx.unsafe(
45 `SELECT id, email, verified, primary, created_at
46 FROM "_briven_auth_user_emails"
47 WHERE user_id = $1
48 ORDER BY primary DESC, created_at DESC`,
49 [userId] as never,
50 ) as never,
51 );
52 return rows.map((r) => ({
53 id: r.id,
54 email: r.email,
55 verified: r.verified,
56 primary: r.primary,
57 createdAt: r.created_at,
58 }));
59}
60
61/**
62 * Add an email address to a user. Idempotent — if the email already
63 * exists for this user, returns the existing row.
64 */
65export async function addUserEmail(
66 projectId: string,
67 userId: string,
68 email: string,
69): Promise<UserEmail> {
70 const normalized = normalizeEmail(email);
71 if (!EMAIL_RE.test(normalized)) {
72 throw new ValidationError('invalid email address');
73 }
74
75 const rows = await runInProjectDatabase<
76 Array<{
77 id: string;
78 email: string;
79 verified: boolean;
80 primary: boolean;
81 created_at: Date;
82 }>
83 >(projectId, async (tx) =>
84 tx.unsafe(
85 `INSERT INTO "_briven_auth_user_emails" (id, user_id, email, verified, primary, created_at, updated_at)
86 VALUES (gen_random_uuid()::text, $1, $2, false, false, now(), now())
87 ON CONFLICT (user_id, email) DO UPDATE SET updated_at = now()
88 RETURNING id, email, verified, primary, created_at`,
89 [userId, normalized] as never,
90 ) as never,
91 );
92 const row = rows[0];
93 if (!row) throw new Error('email insert returned no row');
94 return {
95 id: row.id,
96 email: row.email,
97 verified: row.verified,
98 primary: row.primary,
99 createdAt: row.created_at,
100 };
101}
102
103/**
104 * Mark an email as verified.
105 */
106export async function verifyUserEmail(
107 projectId: string,
108 userId: string,
109 emailId: string,
110): Promise<void> {
111 await runInProjectDatabase(projectId, async (tx) => {
112 await tx.unsafe(
113 `UPDATE "_briven_auth_user_emails"
114 SET verified = true, updated_at = now()
115 WHERE id = $1 AND user_id = $2`,
116 [emailId, userId] as never,
117 );
118 });
119}
120
121/**
122 * Set an email as the primary additional email. Unsets primary on all
123 * other emails for this user.
124 */
125export async function setPrimaryEmail(
126 projectId: string,
127 userId: string,
128 emailId: string,
129): Promise<void> {
130 await runInProjectDatabase(projectId, async (tx) => {
131 await tx.unsafe(
132 `UPDATE "_briven_auth_user_emails"
133 SET primary = false, updated_at = now()
134 WHERE user_id = $1`,
135 [userId] as never,
136 );
137 await tx.unsafe(
138 `UPDATE "_briven_auth_user_emails"
139 SET primary = true, updated_at = now()
140 WHERE id = $1 AND user_id = $2`,
141 [emailId, userId] as never,
142 );
143 });
144}
145
146/**
147 * Remove an email address from a user.
148 */
149export async function removeUserEmail(
150 projectId: string,
151 userId: string,
152 emailId: string,
153): Promise<void> {
154 await runInProjectDatabase(projectId, async (tx) => {
155 await tx.unsafe(
156 `DELETE FROM "_briven_auth_user_emails"
157 WHERE id = $1 AND user_id = $2`,
158 [emailId, userId] as never,
159 );
160 });
161}