142 lines
3.9 KiB
TypeScript
142 lines
3.9 KiB
TypeScript
import { v } from 'convex/values';
|
|
|
|
import type { Doc, Id } from './_generated/dataModel';
|
|
import type { MutationCtx } from './_generated/server';
|
|
import { internal } from './_generated/api';
|
|
import { internalMutation, internalQuery } from './_generated/server';
|
|
|
|
export type NotificationKind =
|
|
| 'maintenance_thread'
|
|
| 'agent_turn_finished'
|
|
| 'agent_needs_input'
|
|
| 'sync_failed'
|
|
| 'connection_needs_reauth';
|
|
|
|
// Maps a kind to the notificationPreferences field that gates its email.
|
|
const EMAIL_PREF_FIELD: Record<
|
|
NotificationKind,
|
|
keyof Doc<'notificationPreferences'>
|
|
> = {
|
|
maintenance_thread: 'emailMaintenance',
|
|
agent_turn_finished: 'emailAgentTurnFinished',
|
|
agent_needs_input: 'emailAgentNeedsInput',
|
|
sync_failed: 'emailSyncFailed',
|
|
connection_needs_reauth: 'emailConnectionReauth',
|
|
};
|
|
|
|
const kindValidator = v.union(
|
|
v.literal('maintenance_thread'),
|
|
v.literal('agent_turn_finished'),
|
|
v.literal('agent_needs_input'),
|
|
v.literal('sync_failed'),
|
|
v.literal('connection_needs_reauth'),
|
|
);
|
|
|
|
const emitArgs = {
|
|
userId: v.id('users'),
|
|
kind: kindValidator,
|
|
title: v.string(),
|
|
body: v.string(),
|
|
link: v.optional(v.string()),
|
|
spoonId: v.optional(v.id('spoons')),
|
|
threadId: v.optional(v.id('threads')),
|
|
};
|
|
|
|
export interface EmitNotificationArgs {
|
|
userId: Id<'users'>;
|
|
kind: NotificationKind;
|
|
title: string;
|
|
body: string;
|
|
link?: string;
|
|
spoonId?: Id<'spoons'>;
|
|
threadId?: Id<'threads'>;
|
|
}
|
|
|
|
/**
|
|
* Single choke point for creating a notification. Inserts the row, then
|
|
* best-effort schedules an email when the user's preferences allow it and the
|
|
* user has an email address. Callable directly inside any mutation; actions go
|
|
* through the `emit` wrapper below.
|
|
*/
|
|
export async function emitNotification(
|
|
ctx: MutationCtx,
|
|
args: EmitNotificationArgs,
|
|
): Promise<Id<'notifications'>> {
|
|
const notificationId = await ctx.db.insert('notifications', {
|
|
userId: args.userId,
|
|
kind: args.kind,
|
|
title: args.title,
|
|
body: args.body,
|
|
link: args.link,
|
|
spoonId: args.spoonId,
|
|
threadId: args.threadId,
|
|
createdAt: Date.now(),
|
|
});
|
|
|
|
const prefs = await ctx.db
|
|
.query('notificationPreferences')
|
|
.withIndex('by_user', (q) => q.eq('userId', args.userId))
|
|
.unique();
|
|
|
|
// Unset preferences default to enabled, so email is allowed unless a stored
|
|
// preference explicitly opts out (globally via emailEnabled, or per-kind).
|
|
const emailAllowed =
|
|
prefs?.emailEnabled !== false &&
|
|
prefs?.[EMAIL_PREF_FIELD[args.kind]] !== false;
|
|
|
|
if (emailAllowed) {
|
|
const user = await ctx.db.get(args.userId);
|
|
if (user?.email) {
|
|
await ctx.scheduler.runAfter(
|
|
0,
|
|
internal.notificationsNode.sendNotificationEmail,
|
|
{ notificationId },
|
|
);
|
|
}
|
|
}
|
|
|
|
return notificationId;
|
|
}
|
|
|
|
/**
|
|
* Thin internalMutation wrapper so `'use node'` actions (e.g. githubSync) can
|
|
* emit via `ctx.runMutation(internal.notifications.emit, ...)`.
|
|
*/
|
|
export const emit = internalMutation({
|
|
args: emitArgs,
|
|
handler: async (ctx, args): Promise<Id<'notifications'>> =>
|
|
await emitNotification(ctx, args),
|
|
});
|
|
|
|
export const markEmailed = internalMutation({
|
|
args: { notificationId: v.id('notifications') },
|
|
handler: async (ctx, { notificationId }) => {
|
|
await ctx.db.patch(notificationId, { emailedAt: Date.now() });
|
|
},
|
|
});
|
|
|
|
export const getForEmail = internalQuery({
|
|
args: { notificationId: v.id('notifications') },
|
|
handler: async (
|
|
ctx,
|
|
{ notificationId },
|
|
): Promise<{
|
|
to: string | null;
|
|
title: string;
|
|
body: string;
|
|
link: string | null;
|
|
}> => {
|
|
const notification = await ctx.db.get(notificationId);
|
|
if (!notification) {
|
|
return { to: null, title: '', body: '', link: null };
|
|
}
|
|
const user = await ctx.db.get(notification.userId);
|
|
return {
|
|
to: user?.email ?? null,
|
|
title: notification.title,
|
|
body: notification.body,
|
|
link: notification.link ?? null,
|
|
};
|
|
},
|
|
});
|