{"record":{"id":"8f34623b65defe43","repo":"koala73/worldmonitor","slug":"quiethoursstart-and-quiethoursend-must-differ-sam","errorCode":null,"errorMessage":"quietHoursStart and quietHoursEnd must differ (same value = no quiet window)","messagePattern":"quietHoursStart and quietHoursEnd must differ \\(same value = no quiet window\\)","errorType":"validation","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/alertRules.ts","lineNumber":461,"sourceCode":"    if (!identity) throw new ConvexError(\"UNAUTHENTICATED\");\n    const userId = identity.subject;\n    await assertProEntitlement(ctx, userId);\n    validateQuietHoursArgs(args);\n\n    const existing = await ctx.db\n      .query(\"alertRules\")\n      .withIndex(\"by_user_variant\", (q) =>\n        q.eq(\"userId\", userId).eq(\"variant\", args.variant),\n      )\n      .unique();\n\n    // Only enforce start !== end when quiet hours are effectively enabled\n    const effectiveEnabled = args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false;\n    if (effectiveEnabled) {\n      const effectiveStart = args.quietHoursStart ?? existing?.quietHoursStart;\n      const effectiveEnd = args.quietHoursEnd ?? existing?.quietHoursEnd;\n      if (effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) {\n        throw new ConvexError(\"quietHoursStart and quietHoursEnd must differ (same value = no quiet window)\");\n      }\n    }\n\n    // resolveEffectivePair supplies sensitivity:'critical' on fresh insert (compatible\n    // by construction under the tightened rule). We DO NOT call assertCompatibleDeliveryMode here — quiet-hours\n    // mutations don't touch the (digestMode, sensitivity) pair, so blocking unrelated\n    // quiet-hours updates on pre-migration forbidden rows would surface as confusing\n    // generic 500s ('set-quiet-hours' HTTP action has no INCOMPATIBLE_DELIVERY\n    // passthrough). The relay coerce-at-read protects delivery for in-flight forbidden\n    // rows; the migration drains them.\n    // See docs/archive/plans/forbid-realtime-all-events.md + PR #3461 Greptile P1.\n    const pair = resolveEffectivePair({ existing: existing ?? undefined });\n\n    const now = Date.now();\n    const patch = {\n      quietHoursEnabled: args.quietHoursEnabled,\n      quietHoursStart: args.quietHoursStart,\n      quietHoursEnd: args.quietHoursEnd,","sourceCodeStart":443,"sourceCodeEnd":479,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/alertRules.ts#L443-L479","documentation":"Thrown in setQuietHours when quiet hours are effectively enabled (args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false is true) and the effective start and end hours resolve to the same value. An identical start/end denotes a zero-length window, which is treated as 'no quiet window' and rejected rather than silently stored. The check uses effective values — args override existing — so it can fire even when only one of the two is supplied.","triggerScenarios":"Calling setQuietHours with quietHoursEnabled:true and quietHoursStart === quietHoursEnd (both 7, both 22, etc.); calling with quietHoursEnabled:true and only quietHoursStart supplied when the stored quietHoursEnd equals the new start; calling with quietHoursEnabled:true and only quietHoursEnd supplied when the stored quietHoursStart equals the new end.","commonSituations":"UI defaulting both fields to the same hour; a copy/clone operation that duplicates the start into end; a rounding step that collapses both to the same value; partial update that happens to collide with the stored counterpart.","solutions":["Ensure the start and end hours differ when enabling quiet hours.","When patching only one of start/end, verify the other (stored) value differs from the new one before submitting.","If the user genuinely wants quiet hours disabled, set quietHoursEnabled:false instead of equal start/end."],"exampleFix":"// before\nawait setQuietHours({ variant, quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 22 });\n// after\nawait setQuietHours({ variant, quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 7 });","handlingStrategy":"validation","validationCode":"function willQuietHoursBeEnabled(args, existing) {\n  return args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false;\n}\nfunction effectiveStartEnd(args, existing) {\n  return {\n    start: args.quietHoursStart ?? existing?.quietHoursStart,\n    end: args.quietHoursEnd ?? existing?.quietHoursEnd,\n  };\n}\n// before submit:\nif (willQuietHoursBeEnabled(args, existing)) {\n  const { start, end } = effectiveStartEnd(args, existing);\n  if (start !== undefined && end !== undefined && start === end) {\n    // surface 'start and end must differ'\n  }\n}","typeGuard":"function isValidQuietWindow(args, existing): boolean {\n  const enabled = args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false;\n  if (!enabled) return true;\n  const start = args.quietHoursStart ?? existing?.quietHoursStart;\n  const end = args.quietHoursEnd ?? existing?.quietHoursEnd;\n  if (start === undefined || end === undefined) return true;\n  return start !== end;\n}","tryCatchPattern":"try {\n  await setQuietHours(args);\n} catch (e) {\n  if (e instanceof ConvexError && /must differ/.test(String(e.message))) {\n    // prompt user to pick a distinct end hour\n  } else throw e;\n}","preventionTips":["When patching only start or only end, read the stored counterpart and assert they differ before submitting.","Disable the submit button in the UI when both fields resolve equal and quiet hours are enabled.","If the user wants no quiet window, set quietHoursEnabled:false rather than equal start/end."],"tags":["convex","validation","business-rule","notifications","quiet-hours"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}