File size: 28,764 Bytes
97ee7cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
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";

/**
 * Layer-2 entitlement gate: notifications are a PRO feature, but until now
 * the only enforcement was at layer 1 (UI paywall) and layer 3 (relay
 * isUserPro filter). Layer 1 has had at least one hole β€” a 2026-04-28
 * audit found 7 of 28 enabled `alertRules` rows belonged to free-tier
 * users (`tier=0`, never been PRO). The relay's PRO filter has been
 * masking the bug at delivery time, but it fail-opens on entitlement-
 * service errors and shouldn't be the only line of defense.
 *
 * This helper is the WRITE-PATH gate. Throws ConvexError with structured
 * `{code: "PRO_REQUIRED"}` data so the client can detect it and route to
 * the upgrade flow rather than surface a generic 500.
 *
 * Mirrors the FREE_TIER_DEFAULTS semantics in `convex/entitlements.ts`:
 *   - no entitlement row β†’ tier 0 (free)
 *   - validUntil < Date.now() β†’ expired, treat as tier 0
 *   - tier >= 1 β†’ PRO, allowed
 *
 * Kept inline (not imported from entitlements.ts) for security-review
 * readability: every alertRules mutation that calls this should be
 * trivially auditable in one file.
 */
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.",
    });
  }
}

// Cross-field invariant enforcement for (digestMode, sensitivity).
//
// Tightened rule (2026-04-27): real-time delivery is now reserved for
// `critical`-tier events only. `(realtime, all)` and `(realtime, high)` are
// both forbidden. Anything below `critical` lives in a digest cadence
// (daily / twice_daily / weekly).
//
// Why tighter: even on `(realtime, high)`, `high`-severity events fire
// frequently enough on busy days to overload an inbox (severe weather,
// market moves, geopolitics). Real-time is for "interrupt me NOW" content
// only β€” i.e. genuinely critical. High events still reach the user, just
// batched in a digest.
//
// New-row defaults pick `'critical'` on realtime insert; patches preserve
// existing.sensitivity when caller omits the field (no silent narrowing of
// digest users).
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"); // insert-only default β€” patch path never includes sensitivity unless caller passed it
  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).",
    });
  }
}

// Defensive ceiling against patched-client abuse β€” there are ~250 ISO-3166
// countries; 50 is more than any real user opts into and well below any
// validator/storage limit.
const COUNTRIES_MAX = 50;

/**
 * Shape-validate + normalize an inbound `countries` array.
 *  - trim each entry
 *  - uppercase
 *  - keep only ASCII A-Z 2-letter shapes (`^[A-Z]{2}$`); silently drop the rest
 *  - dedupe (preserves first-seen order)
 *  - cap at COUNTRIES_MAX
 *
 * NOT a strict ISO-3166 registry check β€” invalid alpha-2 codes (e.g. "XX")
 * pass shape validation but the relay's includes() check just won't match
 * any real event country. We deliberately don't soft-couple this file to a
 * canonical registry list (that lives elsewhere as part of the
 * followed-countries primitive) β€” keep alertRules independently shippable.
 */
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;
}

// Same defensive ceiling as COUNTRIES_MAX β€” mirrors the client-side market
// watchlist cap (src/services/market-watchlist.ts stops at 50 entries).
const TICKERS_MAX = 50;

/**
 * Shape-validate + normalize an inbound `tickers` array (#4922 U3).
 * Modeled EXACTLY on normalizeCountries above:
 *  - trim each entry
 *  - uppercase
 *  - keep only `^[A-Z][A-Z0-9&-]{0,11}(\.[A-Z]{1,3})?$` shapes β€” plain
 *    symbols (AAPL), share-class/conglomerate forms (BRK-B, M&M.NS) and
 *    dot-suffixed exchange listings (RELIANCE.NS, BHARTIARTL.NS β€” NSE
 *    bases run to 10 chars); silently drop the rest (^GSPC indices, GC=F
 *    futures, cashtags, prose). Every non-index shared/stocks.json symbol
 *    must pass β€” the extractor emits exactly those for company-name hits,
 *    and a dropped shape here means that ticker can never alert.
 *  - dedupe (preserves first-seen order)
 *  - cap at TICKERS_MAX
 *
 * NOT a registry check against shared/stocks.json β€” a shape-valid symbol
 * the extractor never emits simply never intersects at the relay. Keeps
 * alertRules independently shippable, same rationale as countries.
 */
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()),
    // Optional country-scope (ISO-3166 alpha-2). Omit to preserve existing.
    // Pass [] to explicitly reset to "all countries". Pass [...] to restrict.
    countries: v.optional(v.array(v.string())),
    // Optional watchlist ticker-scope (#4922 U3). Omit to preserve existing;
    // [] resets. Unlike countries, [] means "no watchlist story alerts".
    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,
      };
      // Only patch sensitivity when caller explicitly supplied it β€” never silently
      // narrow an existing digest user with sensitivity:'all' just because this
      // mutation got called without the field.
      if (args.sensitivity !== undefined) patch.sensitivity = args.sensitivity;
      if (args.aiDigestEnabled !== undefined) patch.aiDigestEnabled = args.aiDigestEnabled;
      // Preserve-on-omit, mirror sensitivity/digestMode pattern. Caller passing
      // countries:[] writes [] (explicit "all countries" reset). Caller omitting
      // the field leaves the existing value intact.
      if (normalizedCountries !== undefined) patch.countries = normalizedCountries;
      // Same preserve-on-omit contract for tickers ([] = explicit opt-out).
      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,
        // On insert, store undefined when caller omitted the field so existing
        // backward-compat tests (rule has no `countries` field) still hold.
        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,
      };
      // Only patch sensitivity when caller explicitly supplied it β€” preserves
      // existing.sensitivity for digest users on omitted-field calls.
      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();

    // Only enforce start !== end when quiet hours are effectively enabled
    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)");
      }
    }

    // resolveEffectivePair supplies sensitivity:'critical' on fresh insert (compatible
    // by construction under the tightened rule). We DO NOT call assertCompatibleDeliveryMode here β€” quiet-hours
    // mutations don't touch the (digestMode, sensitivity) pair, so blocking unrelated
    // quiet-hours updates on pre-migration forbidden rows would surface as confusing
    // generic 500s ('set-quiet-hours' HTTP action has no INCOMPATIBLE_DELIVERY
    // passthrough). The relay coerce-at-read protects delivery for in-flight forbidden
    // rows; the migration drains them.
    // See docs/archive/plans/forbid-realtime-all-events.md + PR #3461 Greptile P1.
    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();

    // Only enforce start !== end when quiet hours are effectively enabled
    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)");
      }
    }

    // No assertCompatibleDeliveryMode here β€” quiet-hours mutations don't touch
    // the (digestMode, sensitivity) pair. See setQuietHours above for the full
    // rationale. resolveEffectivePair still supplies sensitivity:'critical' on fresh
    // insert (compatible by construction under the tightened rule).
    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,
      });
    }
  },
});

/**
 * Atomic internal mutation that updates BOTH digestMode and sensitivity together
 * (plus optional alert-rule + digest-schedule fields). Used by the settings UI's
 * delivery-mode change flow to avoid the two-call race in setDigestSettings β†’
 * setAlertRules where switching from `daily+all` to `realtime` would otherwise
 * trip the cross-field validator on the first call.
 *
 * All fields optional β€” caller passes only what changed. Patch logic preserves
 * existing.sensitivity when caller omits it (no silent narrowing of digest users).
 *
 * See docs/archive/plans/forbid-realtime-all-events.md Β§1d.
 */
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;
    // Layer-2 gate: this internal mutation is reachable from the public
    // `set-notification-config` HTTP action in convex/http.ts. Even though
    // the HTTP action verifies the Clerk JWT, the entitlement check belongs
    // on the same transaction as the write β€” defense-in-depth against any
    // future caller (a different HTTP action, a webhook handler, etc.).
    // Sister *ForUser internal mutations (setAlertRulesForUser, etc.) are
    // INTENTIONALLY left ungated: they are invoked by trusted operator paths
    // (admin migration scripts, cleanup jobs) where the operator's intent
    // is "manage another user's settings," and entitlement-gating those
    // would block the very cleanup we need to do for free-tier rows that
    // got created before this gate existed.
    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;
      // Only patch sensitivity when caller explicitly supplied it.
      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,
      });
    }
  },
});

/** Returns all enabled rules that have a non-realtime digestMode set. */
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",
    );
  },
});

// INTERNAL ONLY β€” GHSA-r649-4cqj-w93h. This scans the `by_enabled` index and
// returns rows across EVERY user with no per-caller scope, so it must never be
// a public `query()`: that surface is reachable by any anonymous client that
// knows the (non-secret) deployment URL, leaking all users' watchlists +
// Clerk subjects. Service consumers reach it via `ctx.runQuery` behind the
// shared-secret `/relay/enabled-rules` HTTP action (convex/http.ts), or via a
// deploy-key-authenticated `npx convex run` (the migration scripts). Sibling
// `getAlertRules` may stay public because it gates on getUserIdentity() + the
// `by_user` index; this one cannot.
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();
  },
});