feat(notifications): settings notification preferences
This commit is contained in:
@@ -3,7 +3,15 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
|
import {
|
||||||
|
Bell,
|
||||||
|
Brain,
|
||||||
|
FileCog,
|
||||||
|
Github,
|
||||||
|
ServerCog,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@spoon/ui';
|
import { cn } from '@spoon/ui';
|
||||||
|
|
||||||
@@ -12,6 +20,7 @@ const settingsItems = [
|
|||||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||||
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||||
|
{ href: '/settings/notifications', label: 'Notifications', icon: Bell },
|
||||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NotificationPreferencesPanel } from '@/components/settings/notification-preferences-panel';
|
||||||
|
|
||||||
|
const SettingsNotificationsPage = () => {
|
||||||
|
return (
|
||||||
|
<section className='max-w-3xl space-y-4'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-xl font-semibold'>Notifications</h2>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Choose which notifications also reach you by email. In-app
|
||||||
|
notifications always show in the bell; these toggles only control the
|
||||||
|
email copies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NotificationPreferencesPanel />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsNotificationsPage;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Card, Label, Switch } from '@spoon/ui';
|
||||||
|
|
||||||
|
type PreferenceField =
|
||||||
|
| 'emailEnabled'
|
||||||
|
| 'emailMaintenance'
|
||||||
|
| 'emailAgentTurnFinished'
|
||||||
|
| 'emailAgentNeedsInput'
|
||||||
|
| 'emailSyncFailed'
|
||||||
|
| 'emailConnectionReauth';
|
||||||
|
|
||||||
|
const perKindToggles: {
|
||||||
|
field: Exclude<PreferenceField, 'emailEnabled'>;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
field: 'emailMaintenance',
|
||||||
|
label: 'Maintenance threads',
|
||||||
|
description: 'A maintenance review thread opens for one of your Spoons.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailAgentTurnFinished',
|
||||||
|
label: 'Agent finished a turn',
|
||||||
|
description: 'An agent finishes a turn and changes are ready to review.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailAgentNeedsInput',
|
||||||
|
label: 'Agent needs your input',
|
||||||
|
description: 'An agent pauses and needs your input to continue.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailSyncFailed',
|
||||||
|
label: 'A sync fails',
|
||||||
|
description: 'A scheduled sync with upstream fails for one of your Spoons.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'emailConnectionReauth',
|
||||||
|
label: 'A connection needs re-authentication',
|
||||||
|
description: 'A git connection is revoked and needs to be reconnected.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NotificationPreferencesPanel = () => {
|
||||||
|
// Render directly from the reactive query: after a save resolves, the query
|
||||||
|
// re-emits the stored value, so there is no local copy to fall out of sync.
|
||||||
|
const prefs = useQuery(api.notifications.getPreferences);
|
||||||
|
const updatePreferences = useMutation(api.notifications.updatePreferences);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const onToggle = async (field: PreferenceField, next: boolean) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updatePreferences({ [field]: next });
|
||||||
|
toast.success('Preferences saved.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not save preferences.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prefs === undefined) {
|
||||||
|
return (
|
||||||
|
<Card className='p-4 shadow-none'>
|
||||||
|
<p className='text-muted-foreground text-sm'>Loading preferences…</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const perKindDisabled = saving || !prefs.emailEnabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='divide-border divide-y p-0 shadow-none'>
|
||||||
|
<div className='flex items-center justify-between gap-4 p-4'>
|
||||||
|
<div className='space-y-0.5'>
|
||||||
|
<Label className='text-sm font-medium'>Email notifications</Label>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
Send email for your notifications. Turn this off to silence every
|
||||||
|
kind below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
aria-label='Email notifications'
|
||||||
|
checked={prefs.emailEnabled}
|
||||||
|
disabled={saving}
|
||||||
|
onCheckedChange={(next) => void onToggle('emailEnabled', next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{perKindToggles.map(({ field, label, description }) => (
|
||||||
|
<div
|
||||||
|
key={field}
|
||||||
|
className='flex items-center justify-between gap-4 p-4'
|
||||||
|
>
|
||||||
|
<div className='space-y-0.5'>
|
||||||
|
<Label className='text-sm font-medium'>{label}</Label>
|
||||||
|
<p className='text-muted-foreground text-sm'>{description}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
aria-label={label}
|
||||||
|
checked={prefs.emailEnabled && prefs[field]}
|
||||||
|
disabled={perKindDisabled}
|
||||||
|
onCheckedChange={(next) => void onToggle(field, next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { NotificationPreferencesPanel } from '../../src/components/settings/notification-preferences-panel';
|
||||||
|
|
||||||
|
// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on
|
||||||
|
// every access, so `===` comparisons in the mocks below would never match.
|
||||||
|
vi.mock('@spoon/backend/convex/_generated/api.js', () => ({
|
||||||
|
api: {
|
||||||
|
notifications: {
|
||||||
|
getPreferences: 'notifications:getPreferences',
|
||||||
|
updatePreferences: 'notifications:updatePreferences',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockUpdatePreferences, mockUseMutation, mockUseQuery } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
mockUpdatePreferences: vi.fn(),
|
||||||
|
mockUseMutation: vi.fn(),
|
||||||
|
mockUseQuery: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('convex/react', () => ({
|
||||||
|
useMutation: mockUseMutation,
|
||||||
|
useQuery: mockUseQuery,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Pass-through UI primitives so the switches are deterministically present and
|
||||||
|
// clickable under jsdom (Radix pointer behavior is flaky here). We exercise the
|
||||||
|
// panel's logic, not the design-system components.
|
||||||
|
vi.mock('@spoon/ui', () => ({
|
||||||
|
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||||
|
Label: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
|
||||||
|
Switch: ({
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
} & ComponentProps<'button'>) => (
|
||||||
|
<button
|
||||||
|
role='switch'
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onCheckedChange?.(!checked)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const prefs = {
|
||||||
|
emailEnabled: true,
|
||||||
|
emailMaintenance: true,
|
||||||
|
emailAgentTurnFinished: true,
|
||||||
|
emailAgentNeedsInput: false,
|
||||||
|
emailSyncFailed: true,
|
||||||
|
emailConnectionReauth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const seed = () => {
|
||||||
|
mockUpdatePreferences.mockResolvedValue(undefined);
|
||||||
|
mockUseMutation.mockReturnValue(mockUpdatePreferences);
|
||||||
|
mockUseQuery.mockImplementation((reference: unknown) =>
|
||||||
|
reference === api.notifications.getPreferences ? prefs : undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotificationPreferencesPanel', () => {
|
||||||
|
it('renders toggle state from the query values', () => {
|
||||||
|
seed();
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
const needsInput = screen.getByRole('switch', {
|
||||||
|
name: /needs your input/i,
|
||||||
|
});
|
||||||
|
expect(needsInput).toHaveAttribute('aria-checked', 'false');
|
||||||
|
|
||||||
|
const master = screen.getByRole('switch', { name: /email notifications/i });
|
||||||
|
expect(master).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls updatePreferences with the flipped field when a toggle changes', () => {
|
||||||
|
seed();
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('switch', { name: /sync fails/i }),
|
||||||
|
);
|
||||||
|
expect(mockUpdatePreferences).toHaveBeenCalledWith({
|
||||||
|
emailSyncFailed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render toggles until the query resolves', () => {
|
||||||
|
mockUseMutation.mockReturnValue(mockUpdatePreferences);
|
||||||
|
mockUseQuery.mockReturnValue(undefined);
|
||||||
|
render(<NotificationPreferencesPanel />);
|
||||||
|
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -199,6 +199,73 @@ export const markRead = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
emailEnabled: boolean;
|
||||||
|
emailMaintenance: boolean;
|
||||||
|
emailAgentTurnFinished: boolean;
|
||||||
|
emailAgentNeedsInput: boolean;
|
||||||
|
emailSyncFailed: boolean;
|
||||||
|
emailConnectionReauth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authed caller's email notification preferences, resolved for display.
|
||||||
|
* Unset preferences default to enabled, so a caller with no stored row gets an
|
||||||
|
* all-true object. Scoped to the caller via `getRequiredUserId`; never accepts
|
||||||
|
* a userId argument.
|
||||||
|
*/
|
||||||
|
export const getPreferences = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx): Promise<NotificationPreferences> => {
|
||||||
|
const userId = await getRequiredUserId(ctx);
|
||||||
|
const prefs = await ctx.db
|
||||||
|
.query('notificationPreferences')
|
||||||
|
.withIndex('by_user', (q) => q.eq('userId', userId))
|
||||||
|
.unique();
|
||||||
|
return {
|
||||||
|
emailEnabled: prefs?.emailEnabled !== false,
|
||||||
|
emailMaintenance: prefs?.emailMaintenance !== false,
|
||||||
|
emailAgentTurnFinished: prefs?.emailAgentTurnFinished !== false,
|
||||||
|
emailAgentNeedsInput: prefs?.emailAgentNeedsInput !== false,
|
||||||
|
emailSyncFailed: prefs?.emailSyncFailed !== false,
|
||||||
|
emailConnectionReauth: prefs?.emailConnectionReauth !== false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upserts the authed caller's email notification preferences. Only the provided
|
||||||
|
* fields are written; the row is patched when it exists and inserted otherwise.
|
||||||
|
* Scoped to the caller via `getRequiredUserId`; never accepts a userId argument.
|
||||||
|
*/
|
||||||
|
export const updatePreferences = mutation({
|
||||||
|
args: {
|
||||||
|
emailEnabled: v.optional(v.boolean()),
|
||||||
|
emailMaintenance: v.optional(v.boolean()),
|
||||||
|
emailAgentTurnFinished: v.optional(v.boolean()),
|
||||||
|
emailAgentNeedsInput: v.optional(v.boolean()),
|
||||||
|
emailSyncFailed: v.optional(v.boolean()),
|
||||||
|
emailConnectionReauth: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<void> => {
|
||||||
|
const userId = await getRequiredUserId(ctx);
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('notificationPreferences')
|
||||||
|
.withIndex('by_user', (q) => q.eq('userId', userId))
|
||||||
|
.unique();
|
||||||
|
const now = Date.now();
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, { ...args, updatedAt: now });
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert('notificationPreferences', {
|
||||||
|
userId,
|
||||||
|
...args,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks all of the caller's unread notifications read. Iterates the caller's
|
* Marks all of the caller's unread notifications read. Iterates the caller's
|
||||||
* unread rows via the `by_user_unread` index; only ever touches the caller's
|
* unread rows via the `by_user_unread` index; only ever touches the caller's
|
||||||
|
|||||||
@@ -377,6 +377,141 @@ describe('emit points', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('notification preferences query + mutation', () => {
|
||||||
|
const countPreferenceRows = async (
|
||||||
|
t: ReturnType<typeof convexTest>,
|
||||||
|
userId: Id<'users'>,
|
||||||
|
) =>
|
||||||
|
await t.run(async (ctx) => {
|
||||||
|
const rows = await ctx.db.query('notificationPreferences').collect();
|
||||||
|
return rows.filter((r) => r.userId === userId).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPreferences returns all-true defaults when the caller has no row', async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
const userId = await t.run(
|
||||||
|
async (ctx) =>
|
||||||
|
await ctx.db.insert('users', { email: 'pref-a@b.c', name: 'PrefA' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefs = await authed(t, userId).query(
|
||||||
|
api.notifications.getPreferences,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(prefs).toEqual({
|
||||||
|
emailEnabled: true,
|
||||||
|
emailMaintenance: true,
|
||||||
|
emailAgentTurnFinished: true,
|
||||||
|
emailAgentNeedsInput: true,
|
||||||
|
emailSyncFailed: true,
|
||||||
|
emailConnectionReauth: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePreferences persists a change that getPreferences reflects', async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
const userId = await t.run(
|
||||||
|
async (ctx) =>
|
||||||
|
await ctx.db.insert('users', { email: 'pref-b@b.c', name: 'PrefB' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await authed(t, userId).mutation(api.notifications.updatePreferences, {
|
||||||
|
emailSyncFailed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefs = await authed(t, userId).query(
|
||||||
|
api.notifications.getPreferences,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(prefs.emailSyncFailed).toBe(false);
|
||||||
|
// Unset fields remain enabled by default.
|
||||||
|
expect(prefs.emailEnabled).toBe(true);
|
||||||
|
expect(prefs.emailAgentTurnFinished).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePreferences upserts a single row across repeated updates', async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
const userId = await t.run(
|
||||||
|
async (ctx) =>
|
||||||
|
await ctx.db.insert('users', { email: 'pref-c@b.c', name: 'PrefC' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await authed(t, userId).mutation(api.notifications.updatePreferences, {
|
||||||
|
emailSyncFailed: false,
|
||||||
|
});
|
||||||
|
await authed(t, userId).mutation(api.notifications.updatePreferences, {
|
||||||
|
emailMaintenance: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await countPreferenceRows(t, userId)).toBe(1);
|
||||||
|
const prefs = await authed(t, userId).query(
|
||||||
|
api.notifications.getPreferences,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
// The second update patches without discarding the first change.
|
||||||
|
expect(prefs.emailSyncFailed).toBe(false);
|
||||||
|
expect(prefs.emailMaintenance).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePreferences only touches the callers own row', async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
const { userA, userB } = await t.run(async (ctx) => {
|
||||||
|
const userA = await ctx.db.insert('users', {
|
||||||
|
email: 'pref-iso-a@b.c',
|
||||||
|
name: 'IsoA',
|
||||||
|
});
|
||||||
|
const userB = await ctx.db.insert('users', {
|
||||||
|
email: 'pref-iso-b@b.c',
|
||||||
|
name: 'IsoB',
|
||||||
|
});
|
||||||
|
return { userA, userB };
|
||||||
|
});
|
||||||
|
|
||||||
|
await authed(t, userA).mutation(api.notifications.updatePreferences, {
|
||||||
|
emailEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefsA = await authed(t, userA).query(
|
||||||
|
api.notifications.getPreferences,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const prefsB = await authed(t, userB).query(
|
||||||
|
api.notifications.getPreferences,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(prefsA.emailEnabled).toBe(false);
|
||||||
|
// B is untouched: still on all-true defaults with no row of its own.
|
||||||
|
expect(prefsB.emailEnabled).toBe(true);
|
||||||
|
expect(await countPreferenceRows(t, userB)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sync_failed emit respects a stored emailSyncFailed=false preference', async () => {
|
||||||
|
const t = convexTest(schema, modules);
|
||||||
|
const userId = await t.run(
|
||||||
|
async (ctx) =>
|
||||||
|
await ctx.db.insert('users', { email: 'pref-gate@b.c', name: 'Gate' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await authed(t, userId).mutation(api.notifications.updatePreferences, {
|
||||||
|
emailSyncFailed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationId = await t.mutation(internal.notifications.emit, {
|
||||||
|
userId,
|
||||||
|
kind: 'sync_failed',
|
||||||
|
title: 'Sync failed',
|
||||||
|
body: 'Your sync failed.',
|
||||||
|
});
|
||||||
|
// No email scheduled; draining is a safe no-op.
|
||||||
|
await t.finishAllScheduledFunctions(() => undefined);
|
||||||
|
|
||||||
|
const notification = await t.run(
|
||||||
|
async (ctx) => await ctx.db.get(notificationId),
|
||||||
|
);
|
||||||
|
expect(notification?.emailedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('inbox query + mark-read mutations', () => {
|
describe('inbox query + mark-read mutations', () => {
|
||||||
const seedInbox = async (t: ReturnType<typeof convexTest>) =>
|
const seedInbox = async (t: ReturnType<typeof convexTest>) =>
|
||||||
await t.run(async (ctx) => {
|
await t.run(async (ctx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user