Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions api/notification-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ async function publishWelcome(userId: string, channelType: string): Promise<void
}
}

async function publishFlushHeld(userId: string, variant: string): Promise<void> {
if (!UPSTASH_URL || !UPSTASH_TOKEN) return;
const msg = JSON.stringify({ eventType: 'flush_quiet_held', userId, variant });
try {
await fetch(`${UPSTASH_URL}/lpush/wm:events:queue/${encodeURIComponent(msg)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}`, 'User-Agent': 'worldmonitor-edge/1.0' },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
console.warn('[notification-channels] publishFlushHeld LPUSH failed:', (err as Error).message);
}
}

function json(body: unknown, status: number, cors: Record<string, string>, noCache = false): Response {
return new Response(JSON.stringify(body), {
status,
Expand Down Expand Up @@ -100,6 +114,11 @@ interface PostBody {
eventTypes?: string[];
sensitivity?: string;
channels?: string[];
quietHoursEnabled?: boolean;
quietHoursStart?: number;
quietHoursEnd?: number;
quietHoursTimezone?: string;
quietHoursOverride?: string;
}

export default async function handler(req: Request, ctx: { waitUntil: (p: Promise<unknown>) => void }): Promise<Response> {
Expand Down Expand Up @@ -219,6 +238,36 @@ export default async function handler(req: Request, ctx: { waitUntil: (p: Promis
return json({ ok: true }, 200, corsHeaders);
}

if (action === 'set-quiet-hours') {
const VALID_OVERRIDE = new Set(['critical_only', 'silence_all', 'batch_on_wake']);
const { variant, quietHoursEnabled, quietHoursStart, quietHoursEnd, quietHoursTimezone, quietHoursOverride } = body;
if (!variant || quietHoursEnabled === undefined) {
return json({ error: 'variant and quietHoursEnabled required' }, 400, corsHeaders);
}
if (quietHoursOverride !== undefined && !VALID_OVERRIDE.has(quietHoursOverride)) {
return json({ error: 'invalid quietHoursOverride' }, 400, corsHeaders);
}
const resp = await convexRelay({
action: 'set-quiet-hours',
userId: session.userId,
variant,
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
quietHoursTimezone,
quietHoursOverride,
});
if (!resp.ok) {
console.error('[notification-channels] POST set-quiet-hours relay error:', resp.status);
return json({ error: 'Operation failed' }, 500, corsHeaders);
}
// If quiet hours were disabled or override changed away from batch_on_wake,
// flush any held events so they're delivered rather than expiring silently.
const abandonsBatch = !quietHoursEnabled || quietHoursOverride !== 'batch_on_wake';
if (abandonsBatch) ctx.waitUntil(publishFlushHeld(session.userId, variant));
return json({ ok: true }, 200, corsHeaders);
}

return json({ error: 'Unknown action' }, 400, corsHeaders);
} catch (err) {
console.error('[notification-channels] POST error:', err);
Expand Down
107 changes: 106 additions & 1 deletion convex/alertRules.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ConvexError, v } from "convex/values";
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { channelTypeValidator, sensitivityValidator } from "./constants";
import { channelTypeValidator, quietHoursOverrideValidator, sensitivityValidator } from "./constants";

export const getAlertRules = query({
args: {},
Expand Down Expand Up @@ -100,6 +100,111 @@ export const setAlertRulesForUser = internalMutation({
},
});

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)");
}
}
}
Comment on lines +112 to +130
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No timezone validation — invalid timezone silently stored and causes quiet hours to silently fail

validateQuietHoursArgs validates the numeric range of quietHoursStart/quietHoursEnd but accepts any string for quietHoursTimezone. An invalid timezone (e.g. "America/Fake") is persisted to the database, and at enforcement time toLocalHour catches the RangeError and returns -1, causing isInQuietHours to always return false. Quiet hours silently never fire.

Consider validating the timezone at write time:

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 is not a valid IANA timezone"); }
  }
}

Note: validateQuietHoursArgs is called in both setQuietHours and setQuietHoursForUser; the same fix covers both paths.


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;
validateQuietHoursArgs(args);

const existing = await ctx.db
.query("alertRules")
.withIndex("by_user_variant", (q) =>
q.eq("userId", userId).eq("variant", args.variant),
)
.unique();

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: "all",
channels: [],
...patch,
});
}
},
});

export const setQuietHoursForUser = internalMutation({
args: { userId: v.string(), ...QUIET_HOURS_ARGS },
handler: async (ctx, args) => {
const { userId, ...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 now = Date.now();
const patch = {
quietHoursEnabled: rest.quietHoursEnabled,
quietHoursStart: rest.quietHoursStart,
quietHoursEnd: rest.quietHoursEnd,
quietHoursTimezone: rest.quietHoursTimezone,
quietHoursOverride: rest.quietHoursOverride,
updatedAt: now,
};

if (existing) {
await ctx.db.patch(existing._id, patch);
} else {
await ctx.db.insert("alertRules", {
userId, variant: rest.variant, enabled: true,
eventTypes: [], sensitivity: "all", channels: [],
...patch,
});
}
},
});

export const getByEnabled = query({
args: { enabled: v.boolean() },
handler: async (ctx, args) => {
Expand Down
6 changes: 6 additions & 0 deletions convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const sensitivityValidator = v.union(
v.literal("critical"),
);

export const quietHoursOverrideValidator = v.union(
v.literal("critical_only"),
v.literal("silence_all"),
v.literal("batch_on_wake"),
);

export const CURRENT_PREFS_SCHEMA_VERSION = 1;

export const MAX_PREFS_BLOB_SIZE = 65536;
25 changes: 25 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@ http.route({
slackConfigurationUrl?: string;
discordGuildId?: string;
discordChannelId?: string;
quietHoursEnabled?: boolean;
quietHoursStart?: number;
quietHoursEnd?: number;
quietHoursTimezone?: string;
quietHoursOverride?: string;
};
try {
body = await request.json() as typeof body;
Expand Down Expand Up @@ -435,6 +440,26 @@ http.route({
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
}

if (action === "set-quiet-hours") {
const VALID_OVERRIDE = new Set(["critical_only", "silence_all", "batch_on_wake"]);
if (typeof body.variant !== "string" || !body.variant || typeof body.quietHoursEnabled !== "boolean") {
return new Response(JSON.stringify({ error: "variant and quietHoursEnabled required" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
if (body.quietHoursOverride !== undefined && !VALID_OVERRIDE.has(body.quietHoursOverride)) {
return new Response(JSON.stringify({ error: "invalid quietHoursOverride" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
await ctx.runMutation(internal.alertRules.setQuietHoursForUser, {
userId,
variant: body.variant,
quietHoursEnabled: body.quietHoursEnabled,
quietHoursStart: body.quietHoursStart,
quietHoursEnd: body.quietHoursEnd,
quietHoursTimezone: body.quietHoursTimezone,
quietHoursOverride: body.quietHoursOverride as "critical_only" | "silence_all" | "batch_on_wake" | undefined,
});
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
}

return new Response(JSON.stringify({ error: "Unknown action" }), { status: 400, headers: { "Content-Type": "application/json" } });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
Expand Down
7 changes: 6 additions & 1 deletion convex/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { channelTypeValidator, sensitivityValidator } from "./constants";
import { channelTypeValidator, quietHoursOverrideValidator, sensitivityValidator } from "./constants";

export default defineSchema({
userPreferences: defineTable({
Expand Down Expand Up @@ -60,6 +60,11 @@ export default defineSchema({
sensitivity: sensitivityValidator,
channels: v.array(channelTypeValidator),
updatedAt: v.number(),
quietHoursEnabled: v.optional(v.boolean()),
quietHoursStart: v.optional(v.number()),
quietHoursEnd: v.optional(v.number()),
quietHoursTimezone: v.optional(v.string()),
quietHoursOverride: v.optional(quietHoursOverrideValidator),
})
.index("by_user", ["userId"])
.index("by_user_variant", ["userId", "variant"])
Expand Down
Loading
Loading