| import { ConvexError, v } from "convex/values"; |
| import { |
| internalMutation, |
| internalQuery, |
| type MutationCtx, |
| mutation, |
| query, |
| } from "./_generated/server"; |
| import { channelTypeValidator, digestModeValidator, quietHoursOverrideValidator, sensitivityValidator } from "./constants"; |
|
|
| type DigestMode = "realtime" | "daily" | "twice_daily" | "weekly"; |
| type Sensitivity = "all" | "high" | "critical"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function assertProEntitlement( |
| ctx: MutationCtx, |
| userId: string, |
| ): Promise<void> { |
| const entitlement = await ctx.db |
| .query("entitlements") |
| .withIndex("by_userId", (q) => q.eq("userId", userId)) |
| .first(); |
| const tier = |
| entitlement && entitlement.validUntil >= Date.now() |
| ? entitlement.features.tier |
| : 0; |
| if (tier < 1) { |
| throw new ConvexError({ |
| code: "PRO_REQUIRED", |
| message: |
| "Notifications are a PRO feature. Upgrade to enable real-time and digest alerts.", |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function resolveEffectivePair(args: { |
| incomingDigestMode?: DigestMode; |
| incomingSensitivity?: Sensitivity; |
| existing?: { digestMode?: DigestMode | string; sensitivity?: Sensitivity | string }; |
| }): { digestMode: DigestMode; sensitivity: Sensitivity } { |
| const digestMode = (args.incomingDigestMode |
| ?? (args.existing?.digestMode as DigestMode | undefined) |
| ?? "realtime"); |
| const sensitivity = (args.incomingSensitivity |
| ?? (args.existing?.sensitivity as Sensitivity | undefined) |
| ?? "critical"); |
| return { digestMode, sensitivity }; |
| } |
|
|
| function assertCompatibleDeliveryMode(pair: { digestMode: DigestMode; sensitivity: Sensitivity }) { |
| if (pair.digestMode === "realtime" && (pair.sensitivity === "all" || pair.sensitivity === "high")) { |
| throw new ConvexError({ |
| code: "INCOMPATIBLE_DELIVERY", |
| message: |
| "Real-time delivery is for Critical events only. " + |
| "To receive High or All events, choose a digest cadence (Daily, Twice daily, or Weekly).", |
| }); |
| } |
| } |
|
|
| |
| |
| |
| const COUNTRIES_MAX = 50; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function normalizeCountries(input: string[]): string[] { |
| const cleaned: string[] = []; |
| const seen = new Set<string>(); |
| for (const raw of input) { |
| if (typeof raw !== "string") continue; |
| const upper = raw.trim().toUpperCase(); |
| if (!/^[A-Z]{2}$/.test(upper)) continue; |
| if (seen.has(upper)) continue; |
| seen.add(upper); |
| cleaned.push(upper); |
| } |
| if (cleaned.length > COUNTRIES_MAX) { |
| throw new ConvexError({ |
| code: "COUNTRIES_LIMIT_EXCEEDED", |
| message: `countries list capped at ${COUNTRIES_MAX} entries`, |
| }); |
| } |
| return cleaned; |
| } |
|
|
| |
| |
| const TICKERS_MAX = 50; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function normalizeTickers(input: string[]): string[] { |
| const cleaned: string[] = []; |
| const seen = new Set<string>(); |
| for (const raw of input) { |
| if (typeof raw !== "string") continue; |
| const upper = raw.trim().toUpperCase(); |
| if (!/^[A-Z][A-Z0-9&-]{0,11}(\.[A-Z]{1,3})?$/.test(upper)) continue; |
| if (seen.has(upper)) continue; |
| seen.add(upper); |
| cleaned.push(upper); |
| } |
| if (cleaned.length > TICKERS_MAX) { |
| throw new ConvexError({ |
| code: "TICKERS_LIMIT_EXCEEDED", |
| message: `tickers list capped at ${TICKERS_MAX} entries`, |
| }); |
| } |
| return cleaned; |
| } |
|
|
| export const getAlertRules = query({ |
| args: {}, |
| handler: async (ctx) => { |
| const identity = await ctx.auth.getUserIdentity(); |
| if (!identity) return []; |
| return await ctx.db |
| .query("alertRules") |
| .withIndex("by_user", (q) => q.eq("userId", identity.subject)) |
| .collect(); |
| }, |
| }); |
|
|
| export const setAlertRules = mutation({ |
| args: { |
| variant: v.string(), |
| enabled: v.boolean(), |
| eventTypes: v.array(v.string()), |
| sensitivity: v.optional(sensitivityValidator), |
| channels: v.array(channelTypeValidator), |
| aiDigestEnabled: v.optional(v.boolean()), |
| |
| |
| countries: v.optional(v.array(v.string())), |
| |
| |
| tickers: v.optional(v.array(v.string())), |
| }, |
| handler: async (ctx, args) => { |
| const identity = await ctx.auth.getUserIdentity(); |
| if (!identity) throw new ConvexError("UNAUTHENTICATED"); |
| const userId = identity.subject; |
| await assertProEntitlement(ctx, userId); |
|
|
| const existing = await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => |
| q.eq("userId", userId).eq("variant", args.variant), |
| ) |
| .unique(); |
|
|
| const pair = resolveEffectivePair({ |
| incomingSensitivity: args.sensitivity, |
| existing: existing ?? undefined, |
| }); |
| assertCompatibleDeliveryMode(pair); |
|
|
| const normalizedCountries = args.countries !== undefined |
| ? normalizeCountries(args.countries) |
| : undefined; |
| const normalizedTickers = args.tickers !== undefined |
| ? normalizeTickers(args.tickers) |
| : undefined; |
|
|
| const now = Date.now(); |
|
|
| if (existing) { |
| const patch: Record<string, unknown> = { |
| enabled: args.enabled, |
| eventTypes: args.eventTypes, |
| channels: args.channels, |
| updatedAt: now, |
| }; |
| |
| |
| |
| if (args.sensitivity !== undefined) patch.sensitivity = args.sensitivity; |
| if (args.aiDigestEnabled !== undefined) patch.aiDigestEnabled = args.aiDigestEnabled; |
| |
| |
| |
| if (normalizedCountries !== undefined) patch.countries = normalizedCountries; |
| |
| if (normalizedTickers !== undefined) patch.tickers = normalizedTickers; |
| await ctx.db.patch(existing._id, patch); |
| } else { |
| await ctx.db.insert("alertRules", { |
| userId, |
| variant: args.variant, |
| enabled: args.enabled, |
| eventTypes: args.eventTypes, |
| sensitivity: pair.sensitivity, |
| channels: args.channels, |
| aiDigestEnabled: args.aiDigestEnabled ?? true, |
| |
| |
| countries: normalizedCountries, |
| tickers: normalizedTickers, |
| updatedAt: now, |
| }); |
| } |
| }, |
| }); |
|
|
| export const setDigestSettings = mutation({ |
| args: { |
| variant: v.string(), |
| digestMode: digestModeValidator, |
| digestHour: v.optional(v.number()), |
| digestTimezone: v.optional(v.string()), |
| }, |
| handler: async (ctx, args) => { |
| const identity = await ctx.auth.getUserIdentity(); |
| if (!identity) throw new ConvexError("UNAUTHENTICATED"); |
| const userId = identity.subject; |
| await assertProEntitlement(ctx, userId); |
|
|
| if (args.digestHour !== undefined && (args.digestHour < 0 || args.digestHour > 23 || !Number.isInteger(args.digestHour))) { |
| throw new ConvexError("digestHour must be an integer 0–23"); |
| } |
| if (args.digestTimezone !== undefined) { |
| try { |
| Intl.DateTimeFormat(undefined, { timeZone: args.digestTimezone }); |
| } catch { |
| throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)"); |
| } |
| } |
|
|
| const existing = await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => |
| q.eq("userId", userId).eq("variant", args.variant), |
| ) |
| .unique(); |
|
|
| const pair = resolveEffectivePair({ |
| incomingDigestMode: args.digestMode, |
| existing: existing ?? undefined, |
| }); |
| assertCompatibleDeliveryMode(pair); |
|
|
| const now = Date.now(); |
| const patch = { |
| digestMode: args.digestMode, |
| digestHour: args.digestHour, |
| digestTimezone: args.digestTimezone, |
| updatedAt: now, |
| }; |
|
|
| if (existing) { |
| await ctx.db.patch(existing._id, patch); |
| } else { |
| await ctx.db.insert("alertRules", { |
| userId, |
| variant: args.variant, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: pair.sensitivity, |
| channels: [], |
| ...patch, |
| }); |
| } |
| }, |
| }); |
|
|
| export const getAlertRulesByUserId = internalQuery({ |
| args: { userId: v.string() }, |
| handler: async (ctx, args) => { |
| return await ctx.db |
| .query("alertRules") |
| .withIndex("by_user", (q) => q.eq("userId", args.userId)) |
| .collect(); |
| }, |
| }); |
|
|
| export const setAlertRulesForUser = internalMutation({ |
| args: { |
| userId: v.string(), |
| variant: v.string(), |
| enabled: v.boolean(), |
| eventTypes: v.array(v.string()), |
| sensitivity: v.optional(sensitivityValidator), |
| channels: v.array(channelTypeValidator), |
| aiDigestEnabled: v.optional(v.boolean()), |
| countries: v.optional(v.array(v.string())), |
| tickers: v.optional(v.array(v.string())), |
| }, |
| handler: async (ctx, args) => { |
| const { userId, ...rest } = args; |
| const existing = await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => |
| q.eq("userId", userId).eq("variant", rest.variant), |
| ) |
| .unique(); |
|
|
| const pair = resolveEffectivePair({ |
| incomingSensitivity: rest.sensitivity, |
| existing: existing ?? undefined, |
| }); |
| assertCompatibleDeliveryMode(pair); |
|
|
| const normalizedCountries = rest.countries !== undefined |
| ? normalizeCountries(rest.countries) |
| : undefined; |
| const normalizedTickers = rest.tickers !== undefined |
| ? normalizeTickers(rest.tickers) |
| : undefined; |
|
|
| const now = Date.now(); |
| if (existing) { |
| const patch: Record<string, unknown> = { |
| enabled: rest.enabled, |
| eventTypes: rest.eventTypes, |
| channels: rest.channels, |
| updatedAt: now, |
| }; |
| |
| |
| if (rest.sensitivity !== undefined) patch.sensitivity = rest.sensitivity; |
| if (rest.aiDigestEnabled !== undefined) patch.aiDigestEnabled = rest.aiDigestEnabled; |
| if (normalizedCountries !== undefined) patch.countries = normalizedCountries; |
| if (normalizedTickers !== undefined) patch.tickers = normalizedTickers; |
| await ctx.db.patch(existing._id, patch); |
| } else { |
| await ctx.db.insert("alertRules", { |
| userId, |
| variant: rest.variant, |
| enabled: rest.enabled, |
| eventTypes: rest.eventTypes, |
| sensitivity: pair.sensitivity, |
| channels: rest.channels, |
| aiDigestEnabled: rest.aiDigestEnabled, |
| countries: normalizedCountries, |
| tickers: normalizedTickers, |
| updatedAt: now, |
| }); |
| } |
| }, |
| }); |
|
|
| const QUIET_HOURS_ARGS = { |
| variant: v.string(), |
| quietHoursEnabled: v.boolean(), |
| quietHoursStart: v.optional(v.number()), |
| quietHoursEnd: v.optional(v.number()), |
| quietHoursTimezone: v.optional(v.string()), |
| quietHoursOverride: v.optional(quietHoursOverrideValidator), |
| } as const; |
|
|
| function validateQuietHoursArgs(args: { |
| quietHoursStart?: number; |
| quietHoursEnd?: number; |
| quietHoursTimezone?: string; |
| }) { |
| if (args.quietHoursStart !== undefined && (args.quietHoursStart < 0 || args.quietHoursStart > 23 || !Number.isInteger(args.quietHoursStart))) { |
| throw new ConvexError("quietHoursStart must be an integer 0–23"); |
| } |
| if (args.quietHoursEnd !== undefined && (args.quietHoursEnd < 0 || args.quietHoursEnd > 23 || !Number.isInteger(args.quietHoursEnd))) { |
| throw new ConvexError("quietHoursEnd must be an integer 0–23"); |
| } |
| if (args.quietHoursTimezone !== undefined) { |
| try { |
| Intl.DateTimeFormat(undefined, { timeZone: args.quietHoursTimezone }); |
| } catch { |
| throw new ConvexError("quietHoursTimezone must be a valid IANA timezone (e.g. America/New_York)"); |
| } |
| } |
| } |
|
|
| export const setQuietHours = mutation({ |
| args: QUIET_HOURS_ARGS, |
| handler: async (ctx, args) => { |
| const identity = await ctx.auth.getUserIdentity(); |
| if (!identity) throw new ConvexError("UNAUTHENTICATED"); |
| const userId = identity.subject; |
| await assertProEntitlement(ctx, userId); |
| validateQuietHoursArgs(args); |
|
|
| const existing = await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => |
| q.eq("userId", userId).eq("variant", args.variant), |
| ) |
| .unique(); |
|
|
| |
| const effectiveEnabled = args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false; |
| if (effectiveEnabled) { |
| const effectiveStart = args.quietHoursStart ?? existing?.quietHoursStart; |
| const effectiveEnd = args.quietHoursEnd ?? existing?.quietHoursEnd; |
| if (effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) { |
| throw new ConvexError("quietHoursStart and quietHoursEnd must differ (same value = no quiet window)"); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const pair = resolveEffectivePair({ existing: existing ?? undefined }); |
|
|
| const now = Date.now(); |
| const patch = { |
| quietHoursEnabled: args.quietHoursEnabled, |
| quietHoursStart: args.quietHoursStart, |
| quietHoursEnd: args.quietHoursEnd, |
| quietHoursTimezone: args.quietHoursTimezone, |
| quietHoursOverride: args.quietHoursOverride, |
| updatedAt: now, |
| }; |
|
|
| if (existing) { |
| await ctx.db.patch(existing._id, patch); |
| } else { |
| await ctx.db.insert("alertRules", { |
| userId, |
| variant: args.variant, |
| enabled: true, |
| eventTypes: [], |
| sensitivity: pair.sensitivity, |
| channels: [], |
| ...patch, |
| }); |
| } |
| }, |
| }); |
|
|
| export const setDigestSettingsForUser = internalMutation({ |
| args: { |
| userId: v.string(), |
| variant: v.string(), |
| digestMode: digestModeValidator, |
| digestHour: v.optional(v.number()), |
| digestTimezone: v.optional(v.string()), |
| countries: v.optional(v.array(v.string())), |
| }, |
| handler: async (ctx, args) => { |
| const { userId, variant, countries, ...digest } = args; |
| if (digest.digestHour !== undefined && (digest.digestHour < 0 || digest.digestHour > 23 || !Number.isInteger(digest.digestHour))) { |
| throw new ConvexError("digestHour must be an integer 0–23"); |
| } |
| if (digest.digestTimezone !== undefined) { |
| try { |
| Intl.DateTimeFormat(undefined, { timeZone: digest.digestTimezone }); |
| } catch { |
| throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)"); |
| } |
| } |
| const existing = await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => |
| q.eq("userId", userId).eq("variant", variant), |
| ) |
| .unique(); |
|
|
| const pair = resolveEffectivePair({ |
| incomingDigestMode: digest.digestMode, |
| existing: existing ?? undefined, |
| }); |
| assertCompatibleDeliveryMode(pair); |
|
|
| const now = Date.now(); |
| const normalizedCountries = countries !== undefined |
| ? normalizeCountries(countries) |
| : undefined; |
| if (existing) { |
| const patch: Record<string, unknown> = { ...digest, updatedAt: now }; |
| if (normalizedCountries !== undefined) patch.countries = normalizedCountries; |
| await ctx.db.patch(existing._id, patch); |
| } else { |
| await ctx.db.insert("alertRules", { |
| userId, variant, enabled: true, eventTypes: [], sensitivity: pair.sensitivity, channels: [], |
| ...digest, countries: normalizedCountries, updatedAt: now, |
| }); |
| } |
| }, |
| }); |
|
|
| export const setQuietHoursForUser = internalMutation({ |
| args: { userId: v.string(), ...QUIET_HOURS_ARGS, countries: v.optional(v.array(v.string())) }, |
| handler: async (ctx, args) => { |
| const { userId, countries, ...rest } = args; |
| validateQuietHoursArgs(rest); |
|
|
| const existing = await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => |
| q.eq("userId", userId).eq("variant", rest.variant), |
| ) |
| .unique(); |
|
|
| |
| const effectiveEnabled = rest.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false; |
| if (effectiveEnabled) { |
| const effectiveStart = rest.quietHoursStart ?? existing?.quietHoursStart; |
| const effectiveEnd = rest.quietHoursEnd ?? existing?.quietHoursEnd; |
| if (effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) { |
| throw new ConvexError("quietHoursStart and quietHoursEnd must differ (same value = no quiet window)"); |
| } |
| } |
|
|
| |
| |
| |
| |
| const pair = resolveEffectivePair({ existing: existing ?? undefined }); |
|
|
| const now = Date.now(); |
| const normalizedCountries = countries !== undefined |
| ? normalizeCountries(countries) |
| : undefined; |
| const patch = { |
| quietHoursEnabled: rest.quietHoursEnabled, |
| quietHoursStart: rest.quietHoursStart, |
| quietHoursEnd: rest.quietHoursEnd, |
| quietHoursTimezone: rest.quietHoursTimezone, |
| quietHoursOverride: rest.quietHoursOverride, |
| updatedAt: now, |
| ...(normalizedCountries !== undefined ? { countries: normalizedCountries } : {}), |
| }; |
|
|
| if (existing) { |
| await ctx.db.patch(existing._id, patch); |
| } else { |
| await ctx.db.insert("alertRules", { |
| userId, variant: rest.variant, enabled: true, |
| eventTypes: [], sensitivity: pair.sensitivity, channels: [], |
| ...patch, |
| }); |
| } |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const setNotificationConfigForUser = internalMutation({ |
| args: { |
| userId: v.string(), |
| variant: v.string(), |
| enabled: v.optional(v.boolean()), |
| eventTypes: v.optional(v.array(v.string())), |
| sensitivity: v.optional(sensitivityValidator), |
| channels: v.optional(v.array(channelTypeValidator)), |
| aiDigestEnabled: v.optional(v.boolean()), |
| digestMode: v.optional(digestModeValidator), |
| digestHour: v.optional(v.number()), |
| digestTimezone: v.optional(v.string()), |
| countries: v.optional(v.array(v.string())), |
| tickers: v.optional(v.array(v.string())), |
| }, |
| handler: async (ctx, args) => { |
| const { userId, variant } = args; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| await assertProEntitlement(ctx, userId); |
|
|
| if (args.digestHour !== undefined && (args.digestHour < 0 || args.digestHour > 23 || !Number.isInteger(args.digestHour))) { |
| throw new ConvexError("digestHour must be an integer 0–23"); |
| } |
| if (args.digestTimezone !== undefined) { |
| try { |
| Intl.DateTimeFormat(undefined, { timeZone: args.digestTimezone }); |
| } catch { |
| throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)"); |
| } |
| } |
|
|
| const existing = await ctx.db |
| .query("alertRules") |
| .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", variant)) |
| .unique(); |
|
|
| const pair = resolveEffectivePair({ |
| incomingDigestMode: args.digestMode, |
| incomingSensitivity: args.sensitivity, |
| existing: existing ?? undefined, |
| }); |
| assertCompatibleDeliveryMode(pair); |
|
|
| const normalizedCountries = args.countries !== undefined |
| ? normalizeCountries(args.countries) |
| : undefined; |
| const normalizedTickers = args.tickers !== undefined |
| ? normalizeTickers(args.tickers) |
| : undefined; |
|
|
| const now = Date.now(); |
|
|
| if (existing) { |
| const patch: Record<string, unknown> = { updatedAt: now }; |
| if (args.enabled !== undefined) patch.enabled = args.enabled; |
| if (args.eventTypes !== undefined) patch.eventTypes = args.eventTypes; |
| |
| if (args.sensitivity !== undefined) patch.sensitivity = args.sensitivity; |
| if (args.channels !== undefined) patch.channels = args.channels; |
| if (args.aiDigestEnabled !== undefined) patch.aiDigestEnabled = args.aiDigestEnabled; |
| if (args.digestMode !== undefined) patch.digestMode = args.digestMode; |
| if (args.digestHour !== undefined) patch.digestHour = args.digestHour; |
| if (args.digestTimezone !== undefined) patch.digestTimezone = args.digestTimezone; |
| if (normalizedCountries !== undefined) patch.countries = normalizedCountries; |
| if (normalizedTickers !== undefined) patch.tickers = normalizedTickers; |
| await ctx.db.patch(existing._id, patch); |
| } else { |
| await ctx.db.insert("alertRules", { |
| userId, |
| variant, |
| enabled: args.enabled ?? true, |
| eventTypes: args.eventTypes ?? [], |
| sensitivity: pair.sensitivity, |
| channels: args.channels ?? [], |
| aiDigestEnabled: args.aiDigestEnabled, |
| digestMode: args.digestMode, |
| digestHour: args.digestHour, |
| digestTimezone: args.digestTimezone, |
| countries: normalizedCountries, |
| tickers: normalizedTickers, |
| updatedAt: now, |
| }); |
| } |
| }, |
| }); |
|
|
| |
| export const getDigestRules = internalQuery({ |
| args: {}, |
| handler: async (ctx) => { |
| const enabled = await ctx.db |
| .query("alertRules") |
| .withIndex("by_enabled", (q) => q.eq("enabled", true)) |
| .collect(); |
| return enabled.filter( |
| (r) => r.digestMode !== undefined && r.digestMode !== "realtime", |
| ); |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const getByEnabled = internalQuery({ |
| args: { enabled: v.boolean() }, |
| handler: async (ctx, args) => { |
| return await ctx.db |
| .query("alertRules") |
| .withIndex("by_enabled", (q) => q.eq("enabled", args.enabled)) |
| .collect(); |
| }, |
| }); |
|
|