Skip to content

Commit 0f92ab3

Browse files
feat(tasks): add user preferences migration
Accept both old { notifications } and new { notifyOn* } preference shapes in schema via v.union. Add one-off migration mutation to convert existing users. Handles both shapes in notification logic and settings UI during transition. Run: bunx convex run migrations:migrateUserPreferences Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cf85f27 commit 0f92ab3

5 files changed

Lines changed: 81 additions & 13 deletions

File tree

convex/_generated/api.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
*/
1010

1111
import type * as auth from "../auth.js";
12+
import type * as crons from "../crons.js";
13+
import type * as email from "../email.js";
1214
import type * as http from "../http.js";
15+
import type * as migrations from "../migrations.js";
16+
import type * as model_activity from "../model/activity.js";
17+
import type * as model_tasks from "../model/tasks.js";
18+
import type * as notifications from "../notifications.js";
1319
import type * as tasks from "../tasks.js";
1420
import type * as users from "../users.js";
1521

@@ -21,7 +27,13 @@ import type {
2127

2228
declare const fullApi: ApiFromModules<{
2329
auth: typeof auth;
30+
crons: typeof crons;
31+
email: typeof email;
2432
http: typeof http;
33+
migrations: typeof migrations;
34+
"model/activity": typeof model_activity;
35+
"model/tasks": typeof model_tasks;
36+
notifications: typeof notifications;
2537
tasks: typeof tasks;
2638
users: typeof users;
2739
}>;
@@ -54,4 +66,5 @@ export declare const internal: FilterApi<
5466

5567
export declare const components: {
5668
workOSAuthKit: import("@convex-dev/workos-authkit/_generated/component.js").ComponentApi<"workOSAuthKit">;
69+
resend: import("@convex-dev/resend/_generated/component.js").ComponentApi<"resend">;
5770
};

convex/migrations.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { internalMutation } from "./_generated/server";
2+
3+
/**
4+
* One-off migration: convert user preferences from old shape
5+
* { notifications: boolean } to new shape { notifyOnShare, notifyOnComment, notifyOnDueDate }.
6+
*
7+
* Run with: bunx convex run migrations:migrateUserPreferences
8+
*/
9+
export const migrateUserPreferences = internalMutation({
10+
args: {},
11+
handler: async (ctx) => {
12+
const users = await ctx.db.query("users").collect();
13+
let migrated = 0;
14+
15+
for (const user of users) {
16+
if (
17+
user.preferences &&
18+
"notifications" in user.preferences &&
19+
!("notifyOnShare" in user.preferences)
20+
) {
21+
const enabled = (user.preferences as { notifications: boolean })
22+
.notifications;
23+
await ctx.db.patch(user._id, {
24+
preferences: {
25+
notifyOnShare: enabled,
26+
notifyOnComment: enabled,
27+
notifyOnDueDate: enabled,
28+
},
29+
});
30+
migrated++;
31+
}
32+
}
33+
34+
return { migrated, total: users.length };
35+
},
36+
});

convex/notifications.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@ const DEFAULT_PREFS = {
88
notifyOnDueDate: true,
99
};
1010

11+
type NewPrefs = typeof DEFAULT_PREFS;
12+
1113
function getUserPrefs(user: {
12-
preferences?: {
13-
notifyOnShare: boolean;
14-
notifyOnComment: boolean;
15-
notifyOnDueDate: boolean;
16-
} | null;
17-
}) {
18-
return user.preferences ?? DEFAULT_PREFS;
14+
preferences?: NewPrefs | { notifications: boolean } | null;
15+
}): NewPrefs {
16+
const p = user.preferences;
17+
if (!p) {
18+
return DEFAULT_PREFS;
19+
}
20+
// Old shape: map single boolean to all three
21+
if ("notifications" in p) {
22+
return {
23+
notifyOnShare: p.notifications,
24+
notifyOnComment: p.notifications,
25+
notifyOnDueDate: p.notifications,
26+
};
27+
}
28+
return p;
1929
}
2030

2131
/**

convex/schema.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,18 @@ export default defineSchema({
4242
// Optional avatar image (Convex file storage)
4343
avatarId: v.optional(v.id("_storage")),
4444
// User notification preferences (per event type)
45+
// Accepts both old shape { notifications } and new shape for migration
4546
preferences: v.optional(
46-
v.object({
47-
notifyOnShare: v.boolean(),
48-
notifyOnComment: v.boolean(),
49-
notifyOnDueDate: v.boolean(),
50-
})
47+
v.union(
48+
v.object({
49+
notifyOnShare: v.boolean(),
50+
notifyOnComment: v.boolean(),
51+
notifyOnDueDate: v.boolean(),
52+
}),
53+
v.object({
54+
notifications: v.boolean(),
55+
})
56+
)
5157
),
5258
})
5359
.index("by_auth_id", ["authId"])

src/app/[locale]/(authenticated)/settings/settings-client.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ export function SettingsClient() {
3636
api.users.updateNotificationPreferences
3737
);
3838

39-
const prefs = user?.preferences ?? {
39+
const defaults = {
4040
notifyOnShare: true,
4141
notifyOnComment: true,
4242
notifyOnDueDate: true,
4343
};
44+
const rawPrefs = user?.preferences;
45+
// Handle both old { notifications } and new { notifyOn* } shapes
46+
const prefs = rawPrefs && "notifyOnShare" in rawPrefs ? rawPrefs : defaults;
4447

4548
const handleAvatarUpload = useCallback(
4649
async (storageId: Id<"_storage">) => {

0 commit comments

Comments
 (0)