diff --git a/.changeset/fifty-planets-sing.md b/.changeset/fifty-planets-sing.md new file mode 100644 index 0000000..b611a2d --- /dev/null +++ b/.changeset/fifty-planets-sing.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Allow setting and modifying user state of residence and state of birth diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 5286508..ac33677 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -22,8 +22,9 @@ import type * as passwordReset from "../passwordReset.js"; import type * as questFields from "../questFields.js"; import type * as quests from "../quests.js"; import type * as seed from "../seed.js"; -import type * as userData from "../userData.js"; +import type * as userEncryptedData from "../userEncryptedData.js"; import type * as userQuests from "../userQuests.js"; +import type * as userSettings from "../userSettings.js"; import type * as users from "../users.js"; import type * as validators from "../validators.js"; @@ -45,8 +46,9 @@ declare const fullApi: ApiFromModules<{ questFields: typeof questFields; quests: typeof quests; seed: typeof seed; - userData: typeof userData; + userEncryptedData: typeof userEncryptedData; userQuests: typeof userQuests; + userSettings: typeof userSettings; users: typeof users; validators: typeof validators; }>; diff --git a/convex/auth.ts b/convex/auth.ts index 385b795..d991103 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -23,13 +23,20 @@ export const { auth, signIn, signOut, store } = convexAuth({ } // Create a new user with defaults - return ctx.db.insert("users", { - email: args.profile.email, - emailVerified: args.profile.emailVerified ?? false, - role: "user", - theme: "system", - groupQuestsBy: "dateAdded", - }); + return ctx.db + .insert("users", { + email: args.profile.email, + emailVerified: args.profile.emailVerified ?? false, + role: "user", + }) + .then((userId) => { + ctx.db.insert("userSettings", { + userId, + theme: "system", + groupQuestsBy: "dateAdded", + }); + return userId; + }); }, async redirect({ redirectTo }) { diff --git a/convex/schema.ts b/convex/schema.ts index 3df07d6..1de6d81 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -84,15 +84,15 @@ const forms = defineTable({ }); /** - * Represents a user of Namesake. + * Represents a user of Namesake's identity. * @param name - The user's preferred first name. * @param role - The user's role: "admin", "editor", or "user". * @param image - A URL to the user's profile picture. * @param email - The user's email address. - * @param emailVerificationTime - Time in ms since epoch when the user verified their email. - * @param isAnonymous - Denotes anonymous/unauthenticated users. + * @param emailVerified - Denotes whether the user has verified their email. + * @param residence - The US State the user lives in. + * @param birthplace - The US State the user was born in. * @param isMinor - Denotes users under 18. - * @param preferredTheme - The user's preferred color scheme. */ const users = defineTable({ name: v.optional(v.string()), @@ -100,12 +100,33 @@ const users = defineTable({ image: v.optional(v.string()), email: v.optional(v.string()), emailVerified: v.boolean(), - jurisdiction: v.optional(jurisdiction), + residence: v.optional(jurisdiction), + birthplace: v.optional(jurisdiction), isMinor: v.optional(v.boolean()), - theme: theme, - groupQuestsBy: v.optional(groupQuestsBy), }).index("email", ["email"]); +/** + * Pre-fillable user data entered throughout quests. + * All data in this table is end-to-end encrypted. + */ +const userEncryptedData = defineTable({ + userId: v.id("users"), + fieldId: v.id("questFields"), + value: v.string(), +}).index("userId", ["userId"]); + +/** + * Store user preferences. + * @param userId + * @param theme - The user's preferred color scheme. + * @param groupQuestsBy - The user's preferred way to group quests. + */ +const userSettings = defineTable({ + userId: v.id("users"), + theme: v.optional(theme), + groupQuestsBy: v.optional(groupQuestsBy), +}).index("userId", ["userId"]); + /** * Represents a user's unique progress in completing a quest. * @param userId @@ -122,22 +143,13 @@ const userQuests = defineTable({ .index("userId", ["userId"]) .index("questId", ["questId"]); -/** - * Pre-fillable user data entered throughout quests. - * All data in this table is end-to-end encrypted. - */ -const userData = defineTable({ - userId: v.id("users"), - fieldId: v.id("questFields"), - value: v.string(), -}).index("userId", ["userId"]); - export default defineSchema({ ...authTables, forms, quests, questFields, users, + userEncryptedData, + userSettings, userQuests, - userData, }); diff --git a/convex/seed.ts b/convex/seed.ts index a9593aa..cddfce4 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -32,8 +32,6 @@ const seed = internalMutation(async (ctx) => { image: faker.image.avatar(), role: "admin", emailVerified: faker.datatype.boolean(), - theme: faker.helpers.arrayElement(["system", "light", "dark"]), - groupQuestsBy: "dateAdded", }); console.log(`Created user ${firstName} ${lastName}`); diff --git a/convex/userData.ts b/convex/userEncryptedData.ts similarity index 88% rename from convex/userData.ts rename to convex/userEncryptedData.ts index 4e89090..81636b9 100644 --- a/convex/userData.ts +++ b/convex/userEncryptedData.ts @@ -4,7 +4,7 @@ export const getUserData = userQuery({ args: {}, handler: async (ctx, _args) => { const userData = await ctx.db - .query("userData") + .query("userEncryptedData") .withIndex("userId", (q) => q.eq("userId", ctx.userId)) .first(); diff --git a/convex/userSettings.ts b/convex/userSettings.ts new file mode 100644 index 0000000..6e39a32 --- /dev/null +++ b/convex/userSettings.ts @@ -0,0 +1,30 @@ +import { userMutation } from "./helpers"; +import { groupQuestsBy, theme } from "./validators"; + +export const setTheme = userMutation({ + args: { theme: theme }, + handler: async (ctx, args) => { + const userSettings = await ctx.db + .query("userSettings") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .first(); + + if (!userSettings) throw new Error("User settings not found"); + + await ctx.db.patch(userSettings._id, { theme: args.theme }); + }, +}); + +export const setGroupQuestsBy = userMutation({ + args: { groupQuestsBy: groupQuestsBy }, + handler: async (ctx, args) => { + const userSettings = await ctx.db + .query("userSettings") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .first(); + + if (!userSettings) throw new Error("User settings not found"); + + await ctx.db.patch(userSettings._id, { groupQuestsBy: args.groupQuestsBy }); + }, +}); diff --git a/convex/users.ts b/convex/users.ts index 528ec19..4ee21d8 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -3,7 +3,7 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; import type { Role } from "./constants"; import { userMutation, userQuery } from "./helpers"; -import { groupQuestsBy, theme } from "./validators"; +import { jurisdiction } from "./validators"; // TODO: Add `returns` value validation // https://docs.convex.dev/functions/validation @@ -43,31 +43,31 @@ export const getUserByEmail = query({ }, }); -export const setCurrentUserName = userMutation({ +export const setName = userMutation({ args: { name: v.optional(v.string()) }, handler: async (ctx, args) => { await ctx.db.patch(ctx.userId, { name: args.name }); }, }); -export const setCurrentUserIsMinor = userMutation({ - args: { isMinor: v.boolean() }, +export const setResidence = userMutation({ + args: { residence: jurisdiction }, handler: async (ctx, args) => { - await ctx.db.patch(ctx.userId, { isMinor: args.isMinor }); + await ctx.db.patch(ctx.userId, { residence: args.residence }); }, }); -export const setUserTheme = userMutation({ - args: { theme: theme }, +export const setBirthplace = userMutation({ + args: { birthplace: jurisdiction }, handler: async (ctx, args) => { - await ctx.db.patch(ctx.userId, { theme: args.theme }); + await ctx.db.patch(ctx.userId, { birthplace: args.birthplace }); }, }); -export const setGroupQuestsBy = userMutation({ - args: { groupQuestsBy: groupQuestsBy }, +export const setCurrentUserIsMinor = userMutation({ + args: { isMinor: v.boolean() }, handler: async (ctx, args) => { - await ctx.db.patch(ctx.userId, { groupQuestsBy: args.groupQuestsBy }); + await ctx.db.patch(ctx.userId, { isMinor: args.isMinor }); }, }); @@ -84,6 +84,13 @@ export const deleteCurrentUser = userMutation({ .collect(); for (const userQuest of userQuests) await ctx.db.delete(userQuest._id); + // Delete userSettings + const userSettings = await ctx.db + .query("userSettings") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .first(); + if (userSettings) await ctx.db.delete(userSettings._id); + // Delete authAccounts const authAccounts = await ctx.db .query("authAccounts") diff --git a/src/routes/_authenticated/_home.tsx b/src/routes/_authenticated/_home.tsx index ac2a54b..cb03a02 100644 --- a/src/routes/_authenticated/_home.tsx +++ b/src/routes/_authenticated/_home.tsx @@ -94,21 +94,6 @@ function IndexRoute() { if (groupedQuests === undefined) return; - if (groupedQuests === null || Object.keys(groupedQuests).length === 0) - return ( - - ); - return (
@@ -150,46 +135,63 @@ function IndexRoute() {
); diff --git a/src/routes/_authenticated/settings/account.tsx b/src/routes/_authenticated/settings/account.tsx index 437490e..040f4ad 100644 --- a/src/routes/_authenticated/settings/account.tsx +++ b/src/routes/_authenticated/settings/account.tsx @@ -4,6 +4,8 @@ import { Form, Modal, PageHeader, + Select, + SelectItem, Switch, TextField, ToggleButton, @@ -12,7 +14,7 @@ import { import { useTheme } from "@/utils/useTheme"; import { useAuthActions } from "@convex-dev/auth/react"; import { api } from "@convex/_generated/api"; -import { THEMES } from "@convex/constants"; +import { JURISDICTIONS, type Jurisdiction, THEMES } from "@convex/constants"; import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import { Pencil, Trash } from "lucide-react"; @@ -71,10 +73,11 @@ const EditNameDialog = ({ onOpenChange: (isOpen: boolean) => void; onSubmit: () => void; }) => { - const updateName = useMutation(api.users.setCurrentUserName); + const updateName = useMutation(api.users.setName); const [name, setName] = useState(defaultName); - const handleSubmit = () => { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); updateName({ name }); onSubmit(); }; @@ -97,6 +100,106 @@ const EditNameDialog = ({ ); }; +const EditResidenceDialog = ({ + defaultResidence, + isOpen, + onOpenChange, + onSubmit, +}: { + defaultResidence: Jurisdiction; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onSubmit: () => void; +}) => { + const updateResidence = useMutation(api.users.setResidence); + const [residence, setResidence] = useState(defaultResidence); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateResidence({ residence }); + onSubmit(); + }; + + return ( + +
+ Edit residence + +
+ + +
+
+
+ ); +}; + +const EditBirthplaceDialog = ({ + defaultBirthplace, + isOpen, + onOpenChange, + onSubmit, +}: { + defaultBirthplace: Jurisdiction; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onSubmit: () => void; +}) => { + const updateBirthplace = useMutation(api.users.setBirthplace); + const [birthplace, setBirthplace] = useState(defaultBirthplace); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateBirthplace({ birthplace }); + onSubmit(); + }; + + return ( + +
+ Edit birthplace + +
+ + +
+
+
+ ); +}; + const DeleteAccountDialog = ({ isOpen, onOpenChange, @@ -140,7 +243,8 @@ function SettingsAccountRoute() { const [isNameDialogOpen, setIsNameDialogOpen] = useState(false); const [isDeleteAccountDialogOpen, setIsDeleteAccountDialogOpen] = useState(false); - + const [isResidenceDialogOpen, setIsResidenceDialogOpen] = useState(false); + const [isBirthplaceDialogOpen, setIsBirthplaceDialogOpen] = useState(false); const updateIsMinor = useMutation(api.users.setCurrentUserIsMinor); return ( @@ -158,7 +262,7 @@ function SettingsAccountRoute() { description="How should Namesake refer to you? This can be different from your legal name." > Is minor + + + setIsResidenceDialogOpen(false)} + /> + + + + setIsBirthplaceDialogOpen(false)} + /> + diff --git a/src/utils/useTheme.ts b/src/utils/useTheme.ts index f9e06ad..e9dd538 100644 --- a/src/utils/useTheme.ts +++ b/src/utils/useTheme.ts @@ -7,7 +7,7 @@ import type { Selection } from "react-aria-components"; export function useTheme() { const { theme: nextTheme, setTheme: setNextTheme } = useNextTheme(); - const updateTheme = useMutation(api.users.setUserTheme); + const updateTheme = useMutation(api.userSettings.setTheme); const theme = (nextTheme ?? "system") as Theme; const themeSelection = new Set([theme]);