@supa-media/convex 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -194,6 +194,7 @@ function createSupaAuth(config?: {
194
194
  methods?: Array<"email" | "phone">; // default ["email", "phone"]
195
195
  magicLink?: SupaAuthMagicLinkConfig; // off unless present
196
196
  admission?: SupaAuthAdmission; // invite-only; open sign-up unless present
197
+ onUserCreated?: SupaAuthUserCreated; // once per brand-new user, same transaction
197
198
  resend?: { fromAddress: string; emailSubject?: (code) => string;
198
199
  renderHtml?: (p: { code, email }) => string };
199
200
  twilio?: { tokenBridgePath?: string }; // default "/api/internal/phone-token"
@@ -265,6 +266,22 @@ that throws refuses. Ask your own "is this address let in?" before calling
265
266
  `signIn` and draw the answer yourself; the error message is redacted in
266
267
  production.
267
268
 
269
+ ### New accounts (`onUserCreated`)
270
+
271
+ ```ts
272
+ createSupaAuth({
273
+ onUserCreated: async (ctx, { userId, email }) => {
274
+ await ctx.scheduler.runAfter(0, internal.alerts.newAccount, { userId });
275
+ },
276
+ });
277
+ ```
278
+
279
+ Runs once for each brand-new user row, after the insert and inside the same
280
+ mutation, so whatever it writes or schedules commits only with the account. A
281
+ returning account, or a new sign-in method linked to an existing user, never
282
+ calls it. A hook that throws fails the sign-in, so keep it to quick writes and
283
+ `ctx.scheduler`.
284
+
268
285
  ### Magic link (opt-in)
269
286
 
270
287
  Set `magicLink: {}` and a **second** email provider is registered under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supa-media/convex",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Backend package for the Supa framework — OTP auth, schema helpers, and backend utilities for Convex",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/auth/index.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  SupaAuthResendConfig,
12
12
  SupaAuthTestEmailConfig,
13
13
  SupaAuthTwilioConfig,
14
+ SupaAuthUserCreated,
14
15
  } from "./setup";
15
16
  export {
16
17
  requireAuth,
package/src/auth/setup.ts CHANGED
@@ -27,9 +27,10 @@ import { Email } from "@convex-dev/auth/providers/Email";
27
27
  import { Phone } from "@convex-dev/auth/providers/Phone";
28
28
  import { assertMayReceiveEmailCode, type SupaAuthAdmission } from "./admission";
29
29
  import { createTestEmailOtps, type SupaAuthTestEmailConfig } from "./testEmail";
30
- import { userCallback } from "./users";
30
+ import { userCallback, type SupaAuthUserCreated } from "./users";
31
31
 
32
32
  export { NOT_ADMITTED_MESSAGE, type SupaAuthAdmission } from "./admission";
33
+ export type { SupaAuthUserCreated } from "./users";
33
34
 
34
35
  export {
35
36
  createTestEmailOtp,
@@ -124,6 +125,15 @@ export interface SupaAuthConfig {
124
125
  * get a brand-new account. Omitted, anybody may sign up. See `admission.ts`.
125
126
  */
126
127
  admission?: SupaAuthAdmission;
128
+ /**
129
+ * Called once for each brand-new user row, never for a returning account or
130
+ * a new sign-in method linked to an existing one. Runs inside the sign-in
131
+ * mutation, after the insert: what it writes or schedules commits only with
132
+ * the account, and a hook that throws fails the sign-in. Keep it to quick
133
+ * writes and `ctx.scheduler` — a welcome email or a staff alert belongs in a
134
+ * scheduled action, not here.
135
+ */
136
+ onUserCreated?: SupaAuthUserCreated;
127
137
  /** Resend email OTP configuration. */
128
138
  resend?: SupaAuthResendConfig;
129
139
  /** Twilio phone OTP configuration. */
@@ -440,6 +450,6 @@ export function createSupaAuth(config: SupaAuthConfig = {}) {
440
450
 
441
451
  return convexAuth({
442
452
  providers,
443
- callbacks: { createOrUpdateUser: userCallback(config.admission) },
453
+ callbacks: { createOrUpdateUser: userCallback(config.admission, config.onUserCreated) },
444
454
  });
445
455
  }
package/src/auth/users.ts CHANGED
@@ -6,18 +6,29 @@
6
6
  * who signs in two ways is one user. Only when nobody matches is a user row
7
7
  * created — and that is the one place `admission.canCreateUser` is asked, so
8
8
  * an invite-only app can refuse a stranger without ever locking out somebody
9
- * who already has an account.
9
+ * who already has an account. It is also the one place `onUserCreated` runs,
10
+ * so an app hears about each brand-new account exactly once.
10
11
  */
12
+ import type { AnyDataModel, GenericMutationCtx } from "convex/server";
11
13
  import type { GenericId } from "convex/values";
12
14
  import type { convexAuth } from "@convex-dev/auth/server";
13
15
 
14
16
  import { assertMayCreateUser, type SupaAuthAdmission } from "./admission";
15
17
 
18
+ /** See `SupaAuthConfig.onUserCreated`. */
19
+ export type SupaAuthUserCreated = (
20
+ ctx: GenericMutationCtx<AnyDataModel>,
21
+ created: { userId: GenericId<"users">; provider: string; email?: string; phone?: string },
22
+ ) => Promise<void>;
23
+
16
24
  type Handler = NonNullable<
17
25
  NonNullable<Parameters<typeof convexAuth>[0]["callbacks"]>["createOrUpdateUser"]
18
26
  >;
19
27
 
20
- export function userCallback(admission: SupaAuthAdmission | undefined): Handler {
28
+ export function userCallback(
29
+ admission: SupaAuthAdmission | undefined,
30
+ onUserCreated?: SupaAuthUserCreated,
31
+ ): Handler {
21
32
  async function createOrUpdateUser(
22
33
  ctx: Parameters<Handler>[0],
23
34
  { existingUserId, type, provider, profile }: Parameters<Handler>[1],
@@ -106,6 +117,16 @@ export function userCallback(admission: SupaAuthAdmission | undefined): Handler
106
117
  phone?: string;
107
118
  },
108
119
  );
120
+ // Same transaction as the insert: a hook that throws undoes the account,
121
+ // and anything it writes or schedules lands only if the account does.
122
+ if (onUserCreated !== undefined) {
123
+ await onUserCreated(ctx, {
124
+ userId: userId as GenericId<"users">,
125
+ provider: provider.id,
126
+ ...(typeof profile.email === "string" ? { email: profile.email } : {}),
127
+ ...(typeof profile.phone === "string" ? { phone: profile.phone } : {}),
128
+ });
129
+ }
109
130
  return userId as GenericId<"users">;
110
131
  }
111
132