| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { mutation } from "./_generated/server"; |
| import { v } from "convex/values"; |
|
|
| |
| |
| const MAX_LOCALE_TAG_LEN = 64; |
| const MAX_LOCALE_PRIMARY_LEN = 8; |
| const MAX_TIMEZONE_LEN = 64; |
|
|
| |
| |
| const LOCALE_TAG_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/; |
| |
| const LOCALE_PRIMARY_RE = /^[a-z]{2,3}$/; |
| |
| const COUNTRY_RE = /^[A-Z]{2}$/; |
|
|
| function isValidTimezone(tz: string): boolean { |
| |
| |
| |
| |
| |
| try { |
| new Intl.DateTimeFormat(undefined, { timeZone: tz }); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| export const ensureRecord = mutation({ |
| args: { |
| localeTag: v.string(), |
| localePrimary: v.string(), |
| timezone: v.optional(v.string()), |
| country: v.optional(v.string()), |
| }, |
| handler: async (ctx, args) => { |
| |
| |
| |
| |
| if ( |
| args.localeTag.length > MAX_LOCALE_TAG_LEN || |
| !LOCALE_TAG_RE.test(args.localeTag) |
| ) { |
| console.warn( |
| `[users:ensureRecord] invalid localeTag rejected: ${args.localeTag.slice(0, 64)}`, |
| ); |
| return { ok: false as const, reason: "invalid-input" as const, field: "localeTag" }; |
| } |
| if ( |
| args.localePrimary.length > MAX_LOCALE_PRIMARY_LEN || |
| !LOCALE_PRIMARY_RE.test(args.localePrimary) |
| ) { |
| console.warn( |
| `[users:ensureRecord] invalid localePrimary rejected: ${args.localePrimary.slice(0, 64)}`, |
| ); |
| return { ok: false as const, reason: "invalid-input" as const, field: "localePrimary" }; |
| } |
| if (args.timezone !== undefined) { |
| if (args.timezone.length > MAX_TIMEZONE_LEN || !isValidTimezone(args.timezone)) { |
| console.warn( |
| `[users:ensureRecord] invalid timezone rejected: ${args.timezone.slice(0, 64)}`, |
| ); |
| return { ok: false as const, reason: "invalid-input" as const, field: "timezone" }; |
| } |
| } |
| if (args.country !== undefined && !COUNTRY_RE.test(args.country)) { |
| console.warn( |
| `[users:ensureRecord] invalid country rejected: ${args.country.slice(0, 64)}`, |
| ); |
| return { ok: false as const, reason: "invalid-input" as const, field: "country" }; |
| } |
|
|
| |
| const identity = await ctx.auth.getUserIdentity(); |
| if (!identity) { |
| return { ok: false as const, reason: "unauthenticated" as const }; |
| } |
| const userId = identity.subject; |
| |
| |
| const incomingEmail = (identity.email ?? "").trim(); |
| const incomingNormalizedEmail = incomingEmail.toLowerCase(); |
|
|
| |
| const now = Date.now(); |
| const existing = await ctx.db |
| .query("users") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .unique(); |
|
|
| if (existing) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const patch: Record<string, unknown> = { |
| localeTag: args.localeTag, |
| localePrimary: args.localePrimary, |
| lastSeenAt: now, |
| }; |
| if (args.timezone !== undefined) patch.timezone = args.timezone; |
| if (args.country !== undefined) patch.country = args.country; |
| if (incomingEmail.length > 0) { |
| patch.email = incomingEmail; |
| patch.normalizedEmail = incomingNormalizedEmail; |
| } |
| await ctx.db.patch(existing._id, patch); |
| return { ok: true as const, action: "patched" as const }; |
| } |
|
|
| await ctx.db.insert("users", { |
| userId, |
| email: incomingEmail.length > 0 ? incomingEmail : undefined, |
| normalizedEmail: |
| incomingNormalizedEmail.length > 0 ? incomingNormalizedEmail : undefined, |
| localeTag: args.localeTag, |
| localePrimary: args.localePrimary, |
| timezone: args.timezone, |
| country: args.country, |
| firstSeenAt: now, |
| lastSeenAt: now, |
| }); |
| return { ok: true as const, action: "inserted" as const }; |
| }, |
| }); |
|
|