From 6d94aa638466d7731098a40ab0c032b7749f28a7 Mon Sep 17 00:00:00 2001 From: Michael Lei Date: Mon, 21 Oct 2024 20:47:12 -0700 Subject: [PATCH 01/26] Add onboarding forms --- app/_layout.tsx | 14 +++ app/index.tsx | 2 +- app/onboarding/name/index.tsx | 80 ++++++++++++++++ app/onboarding/profile/index.tsx | 157 +++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 app/onboarding/name/index.tsx create mode 100644 app/onboarding/profile/index.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index f7073c67..c227df7a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -86,6 +86,20 @@ export default function RootLayout() { animation: "fade", }} /> + + - + ); diff --git a/app/onboarding/name/index.tsx b/app/onboarding/name/index.tsx new file mode 100644 index 00000000..caa319c3 --- /dev/null +++ b/app/onboarding/name/index.tsx @@ -0,0 +1,80 @@ +import { router, SplashScreen } from "expo-router"; +import { AlertCircle } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; +import { SafeAreaView } from "react-native-safe-area-context"; +import FormSubmitButton from "~/components/form-submit-button"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Input } from "~/components/ui/input"; +import { Text } from "~/components/ui/text"; + +export default function OnboardingNamePage() { + useEffect(() => { + SplashScreen.hideAsync(); + }, []); + + SplashScreen.hideAsync(); + return ( + + + + What is your name? + + + + + ); +} + +function OnboardingProfileForm() { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(""); + const [name, setName] = useState(""); + + const handleSaveName = async () => { + try { + setIsPending(true); + console.log(name); + router.replace("/onboarding/profile"); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } + } finally { + setIsPending(false); + } + }; + return ( + + {!!error && ( + + Something went wrong! + {error} + + )} + + + + + Continue + + + ); +} diff --git a/app/onboarding/profile/index.tsx b/app/onboarding/profile/index.tsx new file mode 100644 index 00000000..458abecc --- /dev/null +++ b/app/onboarding/profile/index.tsx @@ -0,0 +1,157 @@ +import { Link, router } from "expo-router"; +import { AlertCircle, CalendarDays } from "lucide-react-native"; +import { useState } from "react"; +import { Pressable, View } from "react-native"; +import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; +import DateTimePicker from "react-native-modal-datetime-picker"; +import { SafeAreaView } from "react-native-safe-area-context"; +import FormSubmitButton from "~/components/form-submit-button"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Text } from "~/components/ui/text"; +import { formatDate } from "~/lib/date"; +import { fontFamily } from "~/lib/font"; +import { useProfileFormStore } from "~/app/profile/edit/profile-form-store"; +import { useShallow } from "zustand/react/shallow"; +import { Button } from "~/components/ui/button"; + +export default function OnboardingProfilePage() { + return ( + + + + Tell us more about you! + + + + + ); +} + +function OnboardingProfileForm() { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(""); + const [isDatePickerVisible, setDatePickerVisibility] = useState(false); + const [dob, setDob, weight, setWeight, height, setHeight] = + useProfileFormStore( + useShallow((s) => [ + s.dob, + s.setDob, + s.weight, + s.setWeight, + s.height, + s.setHeight, + ]) + ); + const showDatePicker = () => { + setDatePickerVisibility(true); + }; + + const hideDatePicker = () => { + setDatePickerVisibility(false); + }; + + const handleConfirm = (date: Date) => { + setDob(date); + hideDatePicker(); + }; + + const handleSaveProfile = async () => { + try { + setIsPending(true); + const profile = { dob, weight, height }; + console.log(profile); + router.replace("/goals"); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } + } finally { + setIsPending(false); + } + }; + return ( + + {!!error && ( + + Something went wrong! + {error} + + )} + + + + + + + + + Date of Birth + + + {formatDate(dob)} + + + + + + + + + + + + + + + + Continue + + + + + + ); +} From 69371afbfb4c1d1635cad0d502d40e9fd4ffc2c8 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Wed, 23 Oct 2024 17:06:03 -0700 Subject: [PATCH 02/26] add email script for development environment --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index aca4a755..618758d9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test": "jest --watchAll", "lint": "expo lint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --list-different --ignore-path .gitignore", - "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\" --ignore-path .gitignore" + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\" --ignore-path .gitignore", + "email": "email dev --dir convex/emails" }, "jest": { "preset": "jest-expo" From 7c18e8f792add06345ef5c8e38786b8a777945bf Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Wed, 23 Oct 2024 17:26:12 -0700 Subject: [PATCH 03/26] rename actions file to invitations and add action for inviting user --- convex/_generated/api.d.ts | 2 +- convex/{actions.tsx => invitations.tsx} | 62 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) rename convex/{actions.tsx => invitations.tsx} (56%) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 80241682..640691f0 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -16,7 +16,7 @@ import type { FunctionReference, } from "convex/server"; import type * as ResendOTP from "../ResendOTP.js"; -import type * as actions from "../actions.js"; +import type * as actions from "../invitations.js"; import type * as auth from "../auth.js"; import type * as challenges from "../challenges.js"; import type * as emails_LTLoginOTP from "../emails/LTLoginOTP.js"; diff --git a/convex/actions.tsx b/convex/invitations.tsx similarity index 56% rename from convex/actions.tsx rename to convex/invitations.tsx index e88b4268..25085c6d 100644 --- a/convex/actions.tsx +++ b/convex/invitations.tsx @@ -78,3 +78,65 @@ export const createMember = internalMutation({ function getSlug(name: string) { return name.toLowerCase().split(" ").join("-"); } + +// === User Invitations === +export const sendUserInvitation = action({ + args: { + email: v.string(), + organizationId: v.id("organizations"), + role: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + await ctx.runMutation(internal.actions.createInvitation, { + email: args.email, + organizationId: args.organizationId, + role: args.role, + expiresAt: args.expiresAt, + }); + + // await resend.emails.send({ + // from: "Live Timeless ", + // to: [args.owner.email], + // subject: "Welcome to Live Timeless", + // react: , + // }); + // optionally return a value + return "success"; + }, +}); + +export const createInvitation = internalMutation({ + args: { + email: v.string(), + organizationId: v.id("organizations"), + role: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + return await ctx.db.insert("invitations", { + email: args.email, + organizationId: args.organizationId, + role: args.role, + status: "pending", + expiresAt: args.expiresAt, + }); + }, +}); + +export const updateInvitation = internalMutation({ + args: { + invitationId: v.id("invitations"), + }, + handler: async (ctx, args) => { + const invitation = await ctx.db.get(args.invitationId); + if (!invitation) { + throw new Error("Invitation not found"); + } + + return await ctx.db.patch(args.invitationId, { + status: "accepted", + expiresAt: Date.now(), + }); + }, +}); From b96fc2b50e4322aaa97ef8fb39df24a4d2abf6f9 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Wed, 23 Oct 2024 17:26:32 -0700 Subject: [PATCH 04/26] add LTUserInvitation email template --- convex/emails/LTUserInvitation.tsx | 168 +++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 convex/emails/LTUserInvitation.tsx diff --git a/convex/emails/LTUserInvitation.tsx b/convex/emails/LTUserInvitation.tsx new file mode 100644 index 00000000..d1271576 --- /dev/null +++ b/convex/emails/LTUserInvitation.tsx @@ -0,0 +1,168 @@ +import { + Body, + Container, + Head, + Html, + Link, + Preview, + Section, + Text, + Img, + Button, +} from "@react-email/components"; + +interface LTInvitationProps { + email: string; + name: string; +} + +export default function LTInvitation({ + email = "example@acme.com", + name = "John Doe", +}: LTInvitationProps) { + return ( + + + Welcome to Live Timeless + + +
+
+ Live Timeless's Logo +
+
+ Hello {name}, + + [Company Name] is excited to invite you to our employee wellness + app, designed to support your health and well-being journey! + + + Getting started is easy: 1. Download our app from: • App Store + (iPhone) • Google Play Store (Android) 2. Open the app and enter + your work email: example@acme.com 3. You'll receive a + verification code by email 4. Enter the code to access your + personalized wellness experience No passwords needed - you'll + use a verification code each time you sign in to keep your + account secure. + +
+
+ Email: {email} +
+
+ +
+
+
+
+ + This message was produced and distributed by Live Timeless. ©{" "} + {new Date().getFullYear()}, Live Timeless, Inc.. All rights + reserved. Live Timeless is a registered trademark of{" "} + + Live Timeless + + , Inc. View our{" "} + + privacy policy + + . + +
+ + + ); +} + +const main = { + backgroundColor: "#fff", + color: "#212121", +}; + +const container = { + padding: "20px", + margin: "0 auto", +}; + +const link = { + color: "#2754C5", + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: "14px", + textDecoration: "underline", +}; + +const text = { + color: "#333", + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: "14px", + margin: "24px 0", +}; + +const imageSection = { + backgroundColor: "#0f2336", + display: "flex", + padding: "20px 20px", + alignItems: "center", + justifyContent: "center", +}; + +const coverSection = { backgroundColor: "#fff" }; + +const upperSection = { padding: "25px 35px" }; + +const footerText = { + ...text, + fontSize: "12px", + padding: "0 20px", +}; + +const codeText = { + ...text, + fontWeight: "bold", + fontSize: "16px", + margin: "20px 0px 20px", + textAlign: "center" as const, +}; + +const validityText = { + ...text, + margin: "0px", + marginBottom: "16px", + textAlign: "left" as const, +}; + +const mainText = { ...text, marginBottom: "14px" }; + +const button = { + backgroundColor: "#0f2336", + borderRadius: "3px", + fontWeight: "600", + color: "#fff", + fontSize: "15px", + textDecoration: "none", + textAlign: "center" as const, + display: "block", + padding: "11px 23px", + width: "380px", +}; From b611b88560e592a2d0ca096eaa7654189877ead7 Mon Sep 17 00:00:00 2001 From: Michael Lei Date: Wed, 23 Oct 2024 18:09:24 -0700 Subject: [PATCH 05/26] Add emails for inviting user and edit email styling --- convex/_generated/api.d.ts | 6 +- convex/emails/LTLoginOTP.tsx | 29 +++++----- convex/emails/LTUserInvitation.tsx | 88 +++++++++++++++++------------- convex/emails/LTWelcome.tsx | 31 +++++------ 4 files changed, 83 insertions(+), 71 deletions(-) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 640691f0..3132fa00 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -16,14 +16,15 @@ import type { FunctionReference, } from "convex/server"; import type * as ResendOTP from "../ResendOTP.js"; -import type * as actions from "../invitations.js"; import type * as auth from "../auth.js"; import type * as challenges from "../challenges.js"; import type * as emails_LTLoginOTP from "../emails/LTLoginOTP.js"; +import type * as emails_LTUserInvitation from "../emails/LTUserInvitation.js"; import type * as emails_LTWelcome from "../emails/LTWelcome.js"; import type * as goalLogs from "../goalLogs.js"; import type * as goals from "../goals.js"; import type * as http from "../http.js"; +import type * as invitations from "../invitations.js"; import type * as organizations from "../organizations.js"; import type * as users from "../users.js"; @@ -37,14 +38,15 @@ import type * as users from "../users.js"; */ declare const fullApi: ApiFromModules<{ ResendOTP: typeof ResendOTP; - actions: typeof actions; auth: typeof auth; challenges: typeof challenges; "emails/LTLoginOTP": typeof emails_LTLoginOTP; + "emails/LTUserInvitation": typeof emails_LTUserInvitation; "emails/LTWelcome": typeof emails_LTWelcome; goalLogs: typeof goalLogs; goals: typeof goals; http: typeof http; + invitations: typeof invitations; organizations: typeof organizations; users: typeof users; }>; diff --git a/convex/emails/LTLoginOTP.tsx b/convex/emails/LTLoginOTP.tsx index 9bc85555..9f999206 100644 --- a/convex/emails/LTLoginOTP.tsx +++ b/convex/emails/LTLoginOTP.tsx @@ -21,15 +21,15 @@ export default function LTLoginOTP({ otp = "12345678" }: LTLoginOTPProps) { Sign in to Live Timeless +
+ Live Timeless's Logo +
-
- Live Timeless's Logo -
To complete your sign-in, please use the following One-Time @@ -85,15 +85,15 @@ const main = { }; const container = { - padding: "20px", margin: "0 auto", + padding: "20px 0 48px", }; const link = { color: "#2754C5", fontFamily: "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", + fontSize: "16px", textDecoration: "underline", }; @@ -101,22 +101,21 @@ const text = { color: "#333", fontFamily: "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", + fontSize: "16px", margin: "24px 0", }; const imageSection = { backgroundColor: "#0f2336", - display: "flex", padding: "20px 20px", - alignItems: "center", - justifyContent: "center", }; +const logo = { margin: "0 auto" }; + const codeContainer = { background: "rgba(0,0,0,.05)", borderRadius: "4px", - margin: "20px auto 20px", + margin: "20px auto 40px", verticalAlign: "middle", width: "280px", }; @@ -153,6 +152,6 @@ const verificationSection = { justifyContent: "center", }; -const mainText = { ...text, marginBottom: "14px" }; +const mainText = { ...text, marginBottom: "16px" }; const cautionText = { ...text, margin: "0px" }; diff --git a/convex/emails/LTUserInvitation.tsx b/convex/emails/LTUserInvitation.tsx index d1271576..6b5e82fb 100644 --- a/convex/emails/LTUserInvitation.tsx +++ b/convex/emails/LTUserInvitation.tsx @@ -12,47 +12,60 @@ import { } from "@react-email/components"; interface LTInvitationProps { - email: string; name: string; + owner: string; + org: string; } export default function LTInvitation({ - email = "example@acme.com", name = "John Doe", + org = "Vero Ventures", + owner = "Yaniv Talmor", }: LTInvitationProps) { return ( Welcome to Live Timeless +
+ Live Timeless's Logo +
-
- Live Timeless's Logo -
Hello {name}, - [Company Name] is excited to invite you to our employee wellness - app, designed to support your health and well-being journey! - - - Getting started is easy: 1. Download our app from: • App Store - (iPhone) • Google Play Store (Android) 2. Open the app and enter - your work email: example@acme.com 3. You'll receive a - verification code by email 4. Enter the code to access your - personalized wellness experience No passwords needed - you'll - use a verification code each time you sign in to keep your - account secure. + + {owner} + {" "} + has invited you to join{" "} + + {org} + {" "} + on Live Timeless - your gateway to workplace wellness and + personal growth. + + What you'll get access to: +
    +
  • Powerful Habit Tracking Features
  • +
  • Wellness challenges and activities
  • +
  • Personalized AI wellness advisor
  • +
  • Health tracking tools
  • +
-
- Email: {email} -
@@ -99,15 +112,15 @@ const main = { }; const container = { - padding: "20px", margin: "0 auto", + padding: "20px 0 48px", }; const link = { color: "#2754C5", fontFamily: "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", + fontSize: "16px", textDecoration: "underline", }; @@ -115,18 +128,17 @@ const text = { color: "#333", fontFamily: "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", + fontSize: "16px", margin: "24px 0", }; const imageSection = { backgroundColor: "#0f2336", - display: "flex", padding: "20px 20px", - alignItems: "center", - justifyContent: "center", }; +const logo = { margin: "0 auto" }; + const coverSection = { backgroundColor: "#fff" }; const upperSection = { padding: "25px 35px" }; @@ -137,14 +149,6 @@ const footerText = { padding: "0 20px", }; -const codeText = { - ...text, - fontWeight: "bold", - fontSize: "16px", - margin: "20px 0px 20px", - textAlign: "center" as const, -}; - const validityText = { ...text, margin: "0px", @@ -166,3 +170,11 @@ const button = { padding: "11px 23px", width: "380px", }; + +const featureList = { + marginBottom: "40px", + gap: 8, + fontSize: "16px", + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", +}; diff --git a/convex/emails/LTWelcome.tsx b/convex/emails/LTWelcome.tsx index df0d4467..0d813df9 100644 --- a/convex/emails/LTWelcome.tsx +++ b/convex/emails/LTWelcome.tsx @@ -18,22 +18,22 @@ interface LTWelcomeProps { export default function LTWelcome({ email = "example@acme.com", - name = "John Doe", + name = "Yaniv Talmor", }: LTWelcomeProps) { return ( Welcome to Live Timeless +
+ Live Timeless's Logo +
-
- Live Timeless's Logo -
Hello {name}, @@ -94,15 +94,15 @@ const main = { }; const container = { - padding: "20px", margin: "0 auto", + padding: "20px 0 48px", }; const link = { color: "#2754C5", fontFamily: "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", + fontSize: "16px", textDecoration: "underline", }; @@ -110,18 +110,17 @@ const text = { color: "#333", fontFamily: "-apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", - fontSize: "14px", + fontSize: "16px", margin: "24px 0", }; const imageSection = { backgroundColor: "#0f2336", - display: "flex", padding: "20px 20px", - alignItems: "center", - justifyContent: "center", }; +const logo = { margin: "0 auto" }; + const coverSection = { backgroundColor: "#fff" }; const upperSection = { padding: "25px 35px" }; @@ -136,7 +135,7 @@ const codeText = { ...text, fontWeight: "bold", fontSize: "16px", - margin: "20px 0px 20px", + margin: "20px 0px 40px", textAlign: "center" as const, }; @@ -147,7 +146,7 @@ const validityText = { textAlign: "left" as const, }; -const mainText = { ...text, marginBottom: "14px" }; +const mainText = { ...text, marginBottom: "16px" }; const button = { backgroundColor: "#0f2336", From 8732d55204b4e57e07ecb7ccf99b34813662a588 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Wed, 23 Oct 2024 21:38:30 -0700 Subject: [PATCH 06/26] refactor invitations module and add functionality for deleting existing invitations --- convex/invitations.tsx | 54 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 25085c6d..63607895 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -1,5 +1,12 @@ +import { addDays } from "date-fns"; + import { v } from "convex/values"; -import { action, internalMutation } from "./_generated/server"; +import { + action, + internalMutation, + internalQuery, + mutation, +} from "./_generated/server"; import { internal } from "./_generated/api"; import { Resend } from "resend"; import LTWelcome from "./emails/LTWelcome"; @@ -88,6 +95,20 @@ export const sendUserInvitation = action({ expiresAt: v.number(), }, handler: async (ctx, args) => { + // delete any existing invitation for this email + const existingInvitation = await ctx.runQuery( + internal.actions.getInvitation, + { + email: args.email, + organizationId: args.organizationId, + } + ); + if (existingInvitation) { + await ctx.runMutation(internal.actions.deleteInvitation, { + invitationId: existingInvitation._id, + }); + } + await ctx.runMutation(internal.actions.createInvitation, { email: args.email, organizationId: args.organizationId, @@ -106,6 +127,22 @@ export const sendUserInvitation = action({ }, }); +export const getInvitation = internalQuery({ + args: { + email: v.string(), + organizationId: v.id("organizations"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("invitations") + .withIndex("by_organization_id", (q) => + q.eq("organizationId", args.organizationId) + ) + .filter((q) => q.eq(q.field("email"), args.email)) + .unique(); + }, +}); + export const createInvitation = internalMutation({ args: { email: v.string(), @@ -114,17 +151,19 @@ export const createInvitation = internalMutation({ expiresAt: v.number(), }, handler: async (ctx, args) => { + const thirtyDaysFromNow = addDays(new Date(), 30).getTime(); + return await ctx.db.insert("invitations", { email: args.email, organizationId: args.organizationId, role: args.role, status: "pending", - expiresAt: args.expiresAt, + expiresAt: thirtyDaysFromNow, }); }, }); -export const updateInvitation = internalMutation({ +export const acceptInvitation = mutation({ args: { invitationId: v.id("invitations"), }, @@ -140,3 +179,12 @@ export const updateInvitation = internalMutation({ }); }, }); + +export const deleteInvitation = internalMutation({ + args: { + invitationId: v.id("invitations"), + }, + handler: async (ctx, { invitationId }) => { + return await ctx.db.delete(invitationId); + }, +}); From f5728320a4abe1b3d124a8e2f1d41a9bf53bea53 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 15:07:25 -0700 Subject: [PATCH 07/26] add functionality for deleting existing invitations adn resend user invitations --- convex/invitations.tsx | 112 +++++++++++++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 63607895..c616c993 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -3,6 +3,7 @@ import { addDays } from "date-fns"; import { v } from "convex/values"; import { action, + internalAction, internalMutation, internalQuery, mutation, @@ -19,15 +20,18 @@ export const sendOwnerInvitation = action({ orgName: v.string(), }, handler: async (ctx, args) => { - const userId = await ctx.runMutation(internal.actions.createUser, { + const userId = await ctx.runMutation(internal.invitations.createUser, { email: args.owner.email, name: args.owner.name, }); - const orgId = await ctx.runMutation(internal.actions.createOrganization, { - name: args.orgName, - }); + const orgId = await ctx.runMutation( + internal.invitations.createOrganization, + { + name: args.orgName, + } + ); // put the newly created user in the members table with the newly created org - await ctx.runMutation(internal.actions.createMember, { + await ctx.runMutation(internal.invitations.createMember, { orgId, userId, }); @@ -87,7 +91,7 @@ function getSlug(name: string) { } // === User Invitations === -export const sendUserInvitation = action({ +export const sendUserInvitation = mutation({ args: { email: v.string(), organizationId: v.id("organizations"), @@ -95,21 +99,49 @@ export const sendUserInvitation = action({ expiresAt: v.number(), }, handler: async (ctx, args) => { - // delete any existing invitation for this email - const existingInvitation = await ctx.runQuery( - internal.actions.getInvitation, + await ctx.scheduler.runAfter( + 0, + internal.invitations.sendUserInvitationAction, { email: args.email, organizationId: args.organizationId, + role: args.role, + expiresAt: args.expiresAt, } ); - if (existingInvitation) { - await ctx.runMutation(internal.actions.deleteInvitation, { - invitationId: existingInvitation._id, - }); - } + }, +}); - await ctx.runMutation(internal.actions.createInvitation, { +export const resendUserInvitation = mutation({ + args: { + email: v.string(), + organizationId: v.id("organizations"), + role: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + await ctx.scheduler.runAfter( + 0, + internal.invitations.resendUserInvitationAction, + { + email: args.email, + organizationId: args.organizationId, + role: args.role, + expiresAt: args.expiresAt, + } + ); + }, +}); + +export const sendUserInvitationAction = internalAction({ + args: { + email: v.string(), + organizationId: v.id("organizations"), + role: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + await ctx.runMutation(internal.invitations.createInvitation, { email: args.email, organizationId: args.organizationId, role: args.role, @@ -127,6 +159,34 @@ export const sendUserInvitation = action({ }, }); +export const resendUserInvitationAction = internalAction({ + args: { + email: v.string(), + organizationId: v.id("organizations"), + role: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + await ctx.runMutation(internal.invitations.deleteExistingInvitation, { + email: args.email, + organizationId: args.organizationId, + }); + + await ctx.scheduler.runAfter( + 0, + internal.invitations.sendUserInvitationAction, + { + email: args.email, + organizationId: args.organizationId, + role: args.role, + expiresAt: args.expiresAt, + } + ); + + return "success"; + }, +}); + export const getInvitation = internalQuery({ args: { email: v.string(), @@ -188,3 +248,25 @@ export const deleteInvitation = internalMutation({ return await ctx.db.delete(invitationId); }, }); + +export const deleteExistingInvitation = internalMutation({ + args: { + email: v.string(), + organizationId: v.id("organizations"), + }, + handler: async (ctx, args) => { + const existingInvitation = await ctx.runQuery( + internal.invitations.getInvitation, + { + email: args.email, + organizationId: args.organizationId, + } + ); + + if (existingInvitation) { + await ctx.runMutation(internal.invitations.deleteInvitation, { + invitationId: existingInvitation._id, + }); + } + }, +}); From 5a6e6d203ca4460f4dd560544605f37474e5e10a Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 15:25:26 -0700 Subject: [PATCH 08/26] refactor resend user invitations --- convex/invitations.tsx | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index c616c993..46a72dc3 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -120,9 +120,14 @@ export const resendUserInvitation = mutation({ expiresAt: v.number(), }, handler: async (ctx, args) => { + await ctx.runMutation(internal.invitations.deleteExistingInvitation, { + email: args.email, + organizationId: args.organizationId, + }); + await ctx.scheduler.runAfter( 0, - internal.invitations.resendUserInvitationAction, + internal.invitations.sendUserInvitationAction, { email: args.email, organizationId: args.organizationId, @@ -159,34 +164,6 @@ export const sendUserInvitationAction = internalAction({ }, }); -export const resendUserInvitationAction = internalAction({ - args: { - email: v.string(), - organizationId: v.id("organizations"), - role: v.string(), - expiresAt: v.number(), - }, - handler: async (ctx, args) => { - await ctx.runMutation(internal.invitations.deleteExistingInvitation, { - email: args.email, - organizationId: args.organizationId, - }); - - await ctx.scheduler.runAfter( - 0, - internal.invitations.sendUserInvitationAction, - { - email: args.email, - organizationId: args.organizationId, - role: args.role, - expiresAt: args.expiresAt, - } - ); - - return "success"; - }, -}); - export const getInvitation = internalQuery({ args: { email: v.string(), From c5d0a66b9afcada375de26ac61cd35e13664d61d Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 15:41:12 -0700 Subject: [PATCH 09/26] refactor LTUserInvitation component and add functionality for inviting users with different roles --- convex/emails/LTUserInvitation.tsx | 32 +++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/convex/emails/LTUserInvitation.tsx b/convex/emails/LTUserInvitation.tsx index 6b5e82fb..a8811252 100644 --- a/convex/emails/LTUserInvitation.tsx +++ b/convex/emails/LTUserInvitation.tsx @@ -12,13 +12,13 @@ import { } from "@react-email/components"; interface LTInvitationProps { - name: string; + role: string; owner: string; org: string; } export default function LTInvitation({ - name = "John Doe", + role = "User", org = "Vero Ventures", owner = "Yaniv Talmor", }: LTInvitationProps) { @@ -37,7 +37,9 @@ export default function LTInvitation({
- Hello {name}, + + You've been invited to join {org} on Live Timeless + {owner} {" "} - has invited you to join{" "} + would like you to join the{" "} {org} {" "} - on Live Timeless - your gateway to workplace wellness and - personal growth. + organization on Live Timeless with the{" "} + + {role} + {" "} + role. What you'll get access to: @@ -151,12 +160,17 @@ const footerText = { const validityText = { ...text, - margin: "0px", - marginBottom: "16px", + margin: "26px 0px", textAlign: "left" as const, }; -const mainText = { ...text, marginBottom: "14px" }; +const mainText = { + ...text, + fontWeight: "600", + fontSize: "30px", + lineHeight: "1.2", + marginBottom: "14px", +}; const button = { backgroundColor: "#0f2336", From eb2286d901ac67103bed0297c7d4a5aee578c4ae Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 16:07:26 -0700 Subject: [PATCH 10/26] relocate createUser, createOrganization,and createMember functions to their corresponding files --- convex/_generated/api.d.ts | 2 ++ convex/invitations.tsx | 38 ++++++-------------------------------- convex/members.ts | 17 +++++++++++++++++ convex/organizations.ts | 17 ++++++++++++++++- convex/users.ts | 15 ++++++++++++++- 5 files changed, 55 insertions(+), 34 deletions(-) create mode 100644 convex/members.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 3132fa00..3b5160eb 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -25,6 +25,7 @@ import type * as goalLogs from "../goalLogs.js"; import type * as goals from "../goals.js"; import type * as http from "../http.js"; import type * as invitations from "../invitations.js"; +import type * as members from "../members.js"; import type * as organizations from "../organizations.js"; import type * as users from "../users.js"; @@ -47,6 +48,7 @@ declare const fullApi: ApiFromModules<{ goals: typeof goals; http: typeof http; invitations: typeof invitations; + members: typeof members; organizations: typeof organizations; users: typeof users; }>; diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 46a72dc3..028ec12e 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -20,20 +20,21 @@ export const sendOwnerInvitation = action({ orgName: v.string(), }, handler: async (ctx, args) => { - const userId = await ctx.runMutation(internal.invitations.createUser, { + const userId = await ctx.runMutation(internal.users.createUser, { email: args.owner.email, name: args.owner.name, }); const orgId = await ctx.runMutation( - internal.invitations.createOrganization, + internal.organizations.createOrganization, { name: args.orgName, } ); // put the newly created user in the members table with the newly created org - await ctx.runMutation(internal.invitations.createMember, { + await ctx.runMutation(internal.members.createMember, { orgId, userId, + role: "owner", }); await resend.emails.send({ @@ -47,31 +48,6 @@ export const sendOwnerInvitation = action({ }, }); -export const createUser = internalMutation({ - args: { - name: v.string(), - email: v.string(), - }, - handler: async (ctx, args) => { - return await ctx.db.insert("users", { - name: args.name, - email: args.email, - }); - }, -}); - -export const createOrganization = internalMutation({ - args: { - name: v.string(), - }, - handler: async (ctx, args) => { - return await ctx.db.insert("organizations", { - name: args.name, - slug: getSlug(args.name), - }); - }, -}); - export const createMember = internalMutation({ args: { orgId: v.id("organizations"), @@ -86,10 +62,6 @@ export const createMember = internalMutation({ }, }); -function getSlug(name: string) { - return name.toLowerCase().split(" ").join("-"); -} - // === User Invitations === export const sendUserInvitation = mutation({ args: { @@ -153,6 +125,8 @@ export const sendUserInvitationAction = internalAction({ expiresAt: args.expiresAt, }); + // const organization = await ctx. + // await resend.emails.send({ // from: "Live Timeless ", // to: [args.owner.email], diff --git a/convex/members.ts b/convex/members.ts new file mode 100644 index 00000000..38c95a91 --- /dev/null +++ b/convex/members.ts @@ -0,0 +1,17 @@ +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; + +export const createMember = internalMutation({ + args: { + orgId: v.id("organizations"), + userId: v.id("users"), + role: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db.insert("members", { + organizationId: args.orgId, + userId: args.userId, + role: args.role, + }); + }, +}); diff --git a/convex/organizations.ts b/convex/organizations.ts index 14f5999f..a301278e 100644 --- a/convex/organizations.ts +++ b/convex/organizations.ts @@ -1,5 +1,5 @@ import { getAuthUserId } from "@convex-dev/auth/server"; -import { mutation, query } from "./_generated/server"; +import { internalMutation, mutation, query } from "./_generated/server"; import { v } from "convex/values"; export const getOrganizationBySlug = query({ @@ -18,6 +18,21 @@ export const getOrganizationBySlug = query({ }, }); +function getSlug(name: string) { + return name.toLowerCase().split(" ").join("-"); +} +export const createOrganization = internalMutation({ + args: { + name: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db.insert("organizations", { + name: args.name, + slug: getSlug(args.name), + }); + }, +}); + export const updateOrganization = mutation({ args: { organizationId: v.id("organizations"), diff --git a/convex/users.ts b/convex/users.ts index 02958068..f8b95120 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,5 +1,5 @@ import { getAuthUserId } from "@convex-dev/auth/server"; -import { query } from "./_generated/server"; +import { internalMutation, query } from "./_generated/server"; import { mutation } from "./_generated/server"; import { v } from "convex/values"; @@ -15,6 +15,19 @@ export const currentUser = query({ }, }); +export const createUser = internalMutation({ + args: { + name: v.string(), + email: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db.insert("users", { + name: args.name, + email: args.email, + }); + }, +}); + export const updateProfile = mutation({ args: { name: v.string(), From 4b56977738fb9d914a05831bbbf4fb79efe34284 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 16:36:11 -0700 Subject: [PATCH 11/26] add getMemberByOrgIdAndRole function --- convex/members.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/convex/members.ts b/convex/members.ts index 38c95a91..c1bbbcbc 100644 --- a/convex/members.ts +++ b/convex/members.ts @@ -1,5 +1,25 @@ import { v } from "convex/values"; -import { internalMutation } from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; + +export const getMemberByOrgIdAndRole = internalQuery({ + args: { + orgId: v.id("organizations"), + role: v.string(), + }, + handler: async (ctx, args) => { + const member = await ctx.db + .query("members") + .withIndex("by_organization_id") + .filter((q) => q.eq(q.field("role"), args.role)) + .unique(); + + if (!member) { + throw new Error("Member not found"); + } + + return member; + }, +}); export const createMember = internalMutation({ args: { From 9c38850acf028deb09887cb645b6f5e0bd6095ce Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 16:39:16 -0700 Subject: [PATCH 12/26] add getOrganizationById function --- convex/organizations.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/convex/organizations.ts b/convex/organizations.ts index a301278e..5eb0dacb 100644 --- a/convex/organizations.ts +++ b/convex/organizations.ts @@ -1,7 +1,25 @@ import { getAuthUserId } from "@convex-dev/auth/server"; -import { internalMutation, mutation, query } from "./_generated/server"; +import { + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; import { v } from "convex/values"; +export const getOrganizationById = internalQuery({ + args: { organizationId: v.id("organizations") }, + handler: async (ctx, { organizationId }) => { + const organization = await ctx.db.get(organizationId); + + if (!organization) { + throw new Error("Organization not found"); + } + + return organization; + }, +}); + export const getOrganizationBySlug = query({ args: { slug: v.string() }, handler: async (ctx, { slug }) => { From 361d2519a12df625815b892b9cd304c3590e1081 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 16:41:04 -0700 Subject: [PATCH 13/26] add getUserById function --- convex/users.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/convex/users.ts b/convex/users.ts index f8b95120..a686b563 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,5 +1,5 @@ import { getAuthUserId } from "@convex-dev/auth/server"; -import { internalMutation, query } from "./_generated/server"; +import { internalMutation, internalQuery, query } from "./_generated/server"; import { mutation } from "./_generated/server"; import { v } from "convex/values"; @@ -15,6 +15,21 @@ export const currentUser = query({ }, }); +export const getUserById = internalQuery({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + + if (!user) { + throw new Error("User not found"); + } + + return user; + }, +}); + export const createUser = internalMutation({ args: { name: v.string(), From 06b4eaacab9fa4c121ebaa6365dc2fdf958bb302 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 16:43:30 -0700 Subject: [PATCH 14/26] refactor LTUserInvitation component --- convex/invitations.tsx | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 028ec12e..2009b28c 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -11,6 +11,7 @@ import { import { internal } from "./_generated/api"; import { Resend } from "resend"; import LTWelcome from "./emails/LTWelcome"; +import LTUserInvitation from "./emails/LTUserInvitation"; const resend = new Resend(process.env.AUTH_RESEND_KEY); @@ -125,15 +126,38 @@ export const sendUserInvitationAction = internalAction({ expiresAt: args.expiresAt, }); - // const organization = await ctx. + const organization = await ctx.runQuery( + internal.organizations.getOrganizationById, + { + organizationId: args.organizationId, + } + ); + + // Get the owner of the organization + const member = await ctx.runQuery( + internal.members.getMemberByOrgIdAndRole, + { + orgId: args.organizationId, + role: "owner", + } + ); + const owner = await ctx.runQuery(internal.users.getUserById, { + userId: member.userId, + }); + + await resend.emails.send({ + from: "Live Timeless ", + to: [args.email], + subject: "Welcome to Live Timeless", + react: ( + + ), + }); - // await resend.emails.send({ - // from: "Live Timeless ", - // to: [args.owner.email], - // subject: "Welcome to Live Timeless", - // react: , - // }); - // optionally return a value return "success"; }, }); From 42b97472eb7c7d4f7f6e74ddb2d3ce96662aeba3 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 16:45:16 -0700 Subject: [PATCH 15/26] remove duplicate createMember function --- convex/invitations.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 2009b28c..1292dad7 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -49,20 +49,6 @@ export const sendOwnerInvitation = action({ }, }); -export const createMember = internalMutation({ - args: { - orgId: v.id("organizations"), - userId: v.id("users"), - }, - handler: async (ctx, args) => { - return await ctx.db.insert("members", { - organizationId: args.orgId, - userId: args.userId, - role: "owner", - }); - }, -}); - // === User Invitations === export const sendUserInvitation = mutation({ args: { From a189a1b3d5cf152bf58dbd04ae1062dade57e1b5 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 17:40:41 -0700 Subject: [PATCH 16/26] add sendOwnerInvitation mutation --- convex/invitations.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 1292dad7..47e2cf23 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -2,7 +2,6 @@ import { addDays } from "date-fns"; import { v } from "convex/values"; import { - action, internalAction, internalMutation, internalQuery, @@ -15,7 +14,25 @@ import LTUserInvitation from "./emails/LTUserInvitation"; const resend = new Resend(process.env.AUTH_RESEND_KEY); -export const sendOwnerInvitation = action({ +// === Owner Invitations === +export const sendOwnerInvitation = mutation({ + args: { + owner: v.object({ email: v.string(), name: v.string() }), + orgName: v.string(), + }, + handler: async (ctx, args) => { + await ctx.scheduler.runAfter( + 0, + internal.invitations.sendOwnerInvitationAction, + { + owner: args.owner, + orgName: args.orgName, + } + ); + }, +}); + +export const sendOwnerInvitationAction = internalAction({ args: { owner: v.object({ email: v.string(), name: v.string() }), orgName: v.string(), From 7cdb3a039fb23284872713cbebcd61b266937025 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 17:46:09 -0700 Subject: [PATCH 17/26] add updateMemberRole mutation --- convex/members.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/convex/members.ts b/convex/members.ts index c1bbbcbc..2020f013 100644 --- a/convex/members.ts +++ b/convex/members.ts @@ -1,5 +1,5 @@ import { v } from "convex/values"; -import { internalMutation, internalQuery } from "./_generated/server"; +import { internalMutation, internalQuery, mutation } from "./_generated/server"; export const getMemberByOrgIdAndRole = internalQuery({ args: { @@ -35,3 +35,15 @@ export const createMember = internalMutation({ }); }, }); + +export const updateMemberRole = mutation({ + args: { + memberId: v.id("members"), + role: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.memberId, { + role: args.role, + }); + }, +}); From 6324aa33a638c2d8f63891a76cc559d84bcfe34a Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Fri, 25 Oct 2024 23:35:29 -0700 Subject: [PATCH 18/26] refactor createUser function to make the 'name' argument optional --- convex/users.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/convex/users.ts b/convex/users.ts index a686b563..a31087d3 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -32,12 +32,12 @@ export const getUserById = internalQuery({ export const createUser = internalMutation({ args: { - name: v.string(), + name: v.optional(v.string()), email: v.string(), }, handler: async (ctx, args) => { return await ctx.db.insert("users", { - name: args.name, + ...(args.name && { name: args.name }), email: args.email, }); }, From cefbc6166a464401323e130b40f8376a2fc6f143 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Sat, 26 Oct 2024 21:55:01 -0700 Subject: [PATCH 19/26] create new user and member entries when the invitation is accepted --- convex/invitations.tsx | 44 ++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 47e2cf23..051d260c 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -114,6 +114,33 @@ export const resendUserInvitation = mutation({ }, }); +export const acceptInvitation = mutation({ + args: { + invitationId: v.id("invitations"), + }, + handler: async (ctx, args) => { + const invitation = await ctx.db.get(args.invitationId); + if (!invitation) { + throw new Error("Invitation not found"); + } + + await ctx.db.patch(args.invitationId, { + status: "accepted", + expiresAt: Date.now(), + }); + + const userId = await ctx.runMutation(internal.users.createUser, { + email: invitation.email, + }); + + await ctx.runMutation(internal.members.createMember, { + orgId: invitation.organizationId, + userId, + role: invitation.role, + }); + }, +}); + export const sendUserInvitationAction = internalAction({ args: { email: v.string(), @@ -201,23 +228,6 @@ export const createInvitation = internalMutation({ }, }); -export const acceptInvitation = mutation({ - args: { - invitationId: v.id("invitations"), - }, - handler: async (ctx, args) => { - const invitation = await ctx.db.get(args.invitationId); - if (!invitation) { - throw new Error("Invitation not found"); - } - - return await ctx.db.patch(args.invitationId, { - status: "accepted", - expiresAt: Date.now(), - }); - }, -}); - export const deleteInvitation = internalMutation({ args: { invitationId: v.id("invitations"), From 9d0200153677f7f12729e1587652fad57f9e4030 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Mon, 28 Oct 2024 09:52:57 -0700 Subject: [PATCH 20/26] refactor updateMemberRole mutation to check user's role before updating member role --- convex/members.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/convex/members.ts b/convex/members.ts index 2020f013..45b75a89 100644 --- a/convex/members.ts +++ b/convex/members.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { internalMutation, internalQuery, mutation } from "./_generated/server"; +import { getAuthUserId } from "@convex-dev/auth/server"; export const getMemberByOrgIdAndRole = internalQuery({ args: { @@ -40,10 +41,28 @@ export const updateMemberRole = mutation({ args: { memberId: v.id("members"), role: v.string(), + organizationId: v.id("organizations"), }, handler: async (ctx, args) => { - await ctx.db.patch(args.memberId, { - role: args.role, - }); + const userId = await getAuthUserId(ctx); + if (userId === null) { + return null; + } + + const member = await ctx.db + .query("members") + .withIndex("by_user_id_organization_id", (q) => + q.eq("userId", userId).eq("organizationId", args.organizationId) + ) + .unique(); + if (!member) { + throw new Error("Not a member of any organization"); + } + + if (member.role === "owner" || member.role === "admin") { + await ctx.db.patch(args.memberId, { + role: args.role, + }); + } }, }); From bae3c33202590a94ad111174e1e50aac460d5895 Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Mon, 28 Oct 2024 09:53:59 -0700 Subject: [PATCH 21/26] refactor deleteInvitation mutation to use ctx.db.delete instead of internal.invitations.deleteInvitation --- convex/invitations.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 051d260c..05176a33 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -141,6 +141,15 @@ export const acceptInvitation = mutation({ }, }); +export const deleteInvitation = mutation({ + args: { + invitationId: v.id("invitations"), + }, + handler: async (ctx, args) => { + await ctx.db.delete(args.invitationId); + }, +}); + export const sendUserInvitationAction = internalAction({ args: { email: v.string(), @@ -228,15 +237,6 @@ export const createInvitation = internalMutation({ }, }); -export const deleteInvitation = internalMutation({ - args: { - invitationId: v.id("invitations"), - }, - handler: async (ctx, { invitationId }) => { - return await ctx.db.delete(invitationId); - }, -}); - export const deleteExistingInvitation = internalMutation({ args: { email: v.string(), @@ -252,9 +252,7 @@ export const deleteExistingInvitation = internalMutation({ ); if (existingInvitation) { - await ctx.runMutation(internal.invitations.deleteInvitation, { - invitationId: existingInvitation._id, - }); + await ctx.db.delete(existingInvitation._id); } }, }); From 888200a921e7744068614ff0ed76a3e439bb048d Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Mon, 28 Oct 2024 10:27:47 -0700 Subject: [PATCH 22/26] include utils module --- convex/_generated/api.d.ts | 2 ++ convex/utils.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 convex/utils.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 3b5160eb..a67877f9 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -28,6 +28,7 @@ import type * as invitations from "../invitations.js"; import type * as members from "../members.js"; import type * as organizations from "../organizations.js"; import type * as users from "../users.js"; +import type * as utils from "../utils.js"; /** * A utility for referencing Convex functions in your app's API. @@ -51,6 +52,7 @@ declare const fullApi: ApiFromModules<{ members: typeof members; organizations: typeof organizations; users: typeof users; + utils: typeof utils; }>; export declare const api: FilterApi< typeof fullApi, diff --git a/convex/utils.ts b/convex/utils.ts new file mode 100644 index 00000000..73162007 --- /dev/null +++ b/convex/utils.ts @@ -0,0 +1,30 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { internalQuery } from "./_generated/server"; +import { v } from "convex/values"; + +export const getMemberOrganizationRole = internalQuery({ + args: { + organizationId: v.id("organizations"), + }, + handler: async (ctx, { organizationId }) => { + const userId = await getAuthUserId(ctx); + if (userId === null) { + return null; + } + + const member = await ctx.db + .query("members") + .withIndex("by_user_id_organization_id", (q) => + q.eq("userId", userId).eq("organizationId", organizationId) + ) + .unique(); + if (!member) { + throw new Error("Not a member of any organization"); + } + + return { + isOwner: member.role === "owner", + isAdmin: member.role === "admin", + }; + }, +}); From 25b7c9464309e1ebab6af5eb565ff407a4562a4e Mon Sep 17 00:00:00 2001 From: scottchen98 Date: Mon, 28 Oct 2024 10:28:13 -0700 Subject: [PATCH 23/26] check user's role before performing actions --- convex/invitations.tsx | 34 ++++++++++++++++++ convex/members.ts | 30 +++++++--------- convex/organizations.ts | 76 ++++++++++++++++------------------------- 3 files changed, 76 insertions(+), 64 deletions(-) diff --git a/convex/invitations.tsx b/convex/invitations.tsx index 05176a33..bbc60f23 100644 --- a/convex/invitations.tsx +++ b/convex/invitations.tsx @@ -75,6 +75,17 @@ export const sendUserInvitation = mutation({ expiresAt: v.number(), }, handler: async (ctx, args) => { + const memberRole = await ctx.runQuery( + internal.utils.getMemberOrganizationRole, + { + organizationId: args.organizationId, + } + ); + + if (!memberRole?.isOwner && !memberRole?.isAdmin) { + throw new Error("Not the owner or admin of the organization"); + } + await ctx.scheduler.runAfter( 0, internal.invitations.sendUserInvitationAction, @@ -96,6 +107,17 @@ export const resendUserInvitation = mutation({ expiresAt: v.number(), }, handler: async (ctx, args) => { + const memberRole = await ctx.runQuery( + internal.utils.getMemberOrganizationRole, + { + organizationId: args.organizationId, + } + ); + + if (!memberRole?.isOwner && !memberRole?.isAdmin) { + throw new Error("Not the owner or admin of the organization"); + } + await ctx.runMutation(internal.invitations.deleteExistingInvitation, { email: args.email, organizationId: args.organizationId, @@ -144,8 +166,20 @@ export const acceptInvitation = mutation({ export const deleteInvitation = mutation({ args: { invitationId: v.id("invitations"), + organizationId: v.id("organizations"), }, handler: async (ctx, args) => { + const memberRole = await ctx.runQuery( + internal.utils.getMemberOrganizationRole, + { + organizationId: args.organizationId, + } + ); + + if (!memberRole?.isOwner && !memberRole?.isAdmin) { + throw new Error("Not the owner or admin of the organization"); + } + await ctx.db.delete(args.invitationId); }, }); diff --git a/convex/members.ts b/convex/members.ts index 45b75a89..46217486 100644 --- a/convex/members.ts +++ b/convex/members.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { internalMutation, internalQuery, mutation } from "./_generated/server"; -import { getAuthUserId } from "@convex-dev/auth/server"; +import { internal } from "./_generated/api"; export const getMemberByOrgIdAndRole = internalQuery({ args: { @@ -44,25 +44,19 @@ export const updateMemberRole = mutation({ organizationId: v.id("organizations"), }, handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (userId === null) { - return null; - } + const memberRole = await ctx.runQuery( + internal.utils.getMemberOrganizationRole, + { + organizationId: args.organizationId, + } + ); - const member = await ctx.db - .query("members") - .withIndex("by_user_id_organization_id", (q) => - q.eq("userId", userId).eq("organizationId", args.organizationId) - ) - .unique(); - if (!member) { - throw new Error("Not a member of any organization"); + if (!memberRole?.isOwner && !memberRole?.isAdmin) { + throw new Error("Not the owner or admin of the organization"); } - if (member.role === "owner" || member.role === "admin") { - await ctx.db.patch(args.memberId, { - role: args.role, - }); - } + await ctx.db.patch(args.memberId, { + role: args.role, + }); }, }); diff --git a/convex/organizations.ts b/convex/organizations.ts index 5eb0dacb..99d7da26 100644 --- a/convex/organizations.ts +++ b/convex/organizations.ts @@ -1,4 +1,3 @@ -import { getAuthUserId } from "@convex-dev/auth/server"; import { internalMutation, internalQuery, @@ -6,6 +5,7 @@ import { query, } from "./_generated/server"; import { v } from "convex/values"; +import { internal } from "./_generated/api"; export const getOrganizationById = internalQuery({ args: { organizationId: v.id("organizations") }, @@ -60,26 +60,18 @@ export const updateOrganization = mutation({ metadata: v.optional(v.string()), }, handler: async (ctx, { organizationId, ...args }) => { - const userId = await getAuthUserId(ctx); - if (userId === null) { - return null; - } + const memberRole = await ctx.runQuery( + internal.utils.getMemberOrganizationRole, + { + organizationId, + } + ); - const member = await ctx.db - .query("members") - .withIndex("by_user_id_organization_id", (q) => - q.eq("userId", userId).eq("organizationId", organizationId) - ) - .unique(); - if (!member) { - throw new Error("Not a member of any organization"); - } - - if (member.role === "owner") { - await ctx.db.patch(organizationId, args); - } else { + if (!memberRole?.isOwner) { throw new Error("Not the owner of the organization"); } + + await ctx.db.patch(organizationId, args); }, }); @@ -88,38 +80,30 @@ export const deleteOrganization = mutation({ organizationId: v.id("organizations"), }, handler: async (ctx, { organizationId }) => { - const userId = await getAuthUserId(ctx); - if (userId === null) { - return null; + const memberRole = await ctx.runQuery( + internal.utils.getMemberOrganizationRole, + { + organizationId, + } + ); + + if (!memberRole?.isOwner) { + throw new Error("Not the owner of the organization"); } - const member = await ctx.db + const organizationMembers = await ctx.db .query("members") - .withIndex("by_user_id_organization_id", (q) => - q.eq("userId", userId).eq("organizationId", organizationId) + .withIndex("by_organization_id", (q) => + q.eq("organizationId", organizationId) ) - .unique(); - if (!member) { - throw new Error("Not a member of any organization"); - } + .collect(); - if (member.role === "owner") { - const organizationMembers = await ctx.db - .query("members") - .withIndex("by_organization_id", (q) => - q.eq("organizationId", organizationId) - ) - .collect(); - - await Promise.all( - organizationMembers.map(async (member) => { - await ctx.db.delete(member._id); - await ctx.db.delete(member.userId); - }) - ); - await ctx.db.delete(organizationId); - } else { - throw new Error("Not the owner of the organization"); - } + await Promise.all( + organizationMembers.map(async (member) => { + await ctx.db.delete(member._id); + await ctx.db.delete(member.userId); + }) + ); + await ctx.db.delete(organizationId); }, }); From 93d04fb99944db863b50685b5f0f23d59cbd9b1f Mon Sep 17 00:00:00 2001 From: Michael Lei Date: Mon, 28 Oct 2024 14:40:53 -0700 Subject: [PATCH 24/26] Implement user onboarding process --- app/(tabs)/goals.tsx | 3 ++- app/onboarding/name/index.tsx | 22 +++++++++++++++++--- app/onboarding/profile/index.tsx | 10 +++++++-- convex/schema.ts | 1 + convex/users.ts | 35 ++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/app/(tabs)/goals.tsx b/app/(tabs)/goals.tsx index 89dd5154..d301298e 100644 --- a/app/(tabs)/goals.tsx +++ b/app/(tabs)/goals.tsx @@ -10,7 +10,8 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { Text } from "~/components/ui/text"; import { Button } from "~/components/ui/button"; import { Link, SplashScreen } from "expo-router"; -import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useRef, useState } from "react"; import { fontFamily } from "~/lib/font"; import { Plus } from "lucide-react-native"; import { Separator } from "~/components/ui/separator"; diff --git a/app/onboarding/name/index.tsx b/app/onboarding/name/index.tsx index caa319c3..3dad5039 100644 --- a/app/onboarding/name/index.tsx +++ b/app/onboarding/name/index.tsx @@ -1,3 +1,4 @@ +import { useMutation, useQuery } from "convex/react"; import { router, SplashScreen } from "expo-router"; import { AlertCircle } from "lucide-react-native"; import { useEffect, useState } from "react"; @@ -8,11 +9,18 @@ import FormSubmitButton from "~/components/form-submit-button"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; +import { api } from "~/convex/_generated/api"; export default function OnboardingNamePage() { + const user = useQuery(api.users.currentUser); useEffect(() => { - SplashScreen.hideAsync(); - }, []); + if (user) { + if (user.hasOnboarded) { + router.replace("/goals"); + } + SplashScreen.hideAsync(); + } + }, [user]); SplashScreen.hideAsync(); return ( @@ -39,6 +47,7 @@ export default function OnboardingNamePage() { } function OnboardingProfileForm() { + const updateUserName = useMutation(api.users.updateUserName); const [isPending, setIsPending] = useState(false); const [error, setError] = useState(""); const [name, setName] = useState(""); @@ -46,7 +55,14 @@ function OnboardingProfileForm() { const handleSaveName = async () => { try { setIsPending(true); - console.log(name); + if (name.trim().length < 2) { + throw new Error("Your name is too short"); + } + const nameRegex = /^[a-zA-Z]+$/; + if (nameRegex.test(name.trim())) { + throw new Error("Your name can only contain letters"); + } + await updateUserName({ name }); router.replace("/onboarding/profile"); } catch (error) { if (error instanceof Error) { diff --git a/app/onboarding/profile/index.tsx b/app/onboarding/profile/index.tsx index 458abecc..d818a608 100644 --- a/app/onboarding/profile/index.tsx +++ b/app/onboarding/profile/index.tsx @@ -15,6 +15,8 @@ import { fontFamily } from "~/lib/font"; import { useProfileFormStore } from "~/app/profile/edit/profile-form-store"; import { useShallow } from "zustand/react/shallow"; import { Button } from "~/components/ui/button"; +import { useMutation } from "convex/react"; +import { api } from "~/convex/_generated/api"; export default function OnboardingProfilePage() { return ( @@ -41,6 +43,7 @@ export default function OnboardingProfilePage() { } function OnboardingProfileForm() { + const updateProfile = useMutation(api.users.updatePartialProfile); const [isPending, setIsPending] = useState(false); const [error, setError] = useState(""); const [isDatePickerVisible, setDatePickerVisibility] = useState(false); @@ -71,8 +74,11 @@ function OnboardingProfileForm() { const handleSaveProfile = async () => { try { setIsPending(true); - const profile = { dob, weight, height }; - console.log(profile); + updateProfile({ + dob: dob.getTime(), + weight: Number(weight), + height: Number(height), + }); router.replace("/goals"); } catch (error) { if (error instanceof Error) { diff --git a/convex/schema.ts b/convex/schema.ts index c33f6166..36b5eb4c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -16,6 +16,7 @@ export default defineSchema({ phone: v.optional(v.string()), phoneVerificationTime: v.optional(v.number()), isAnonymous: v.optional(v.boolean()), + hasOnboarded: v.optional(v.boolean()), }).index("email", ["email"]), goals: defineTable({ _id: v.id("goals"), diff --git a/convex/users.ts b/convex/users.ts index a31087d3..741e28e7 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -65,3 +65,38 @@ export const updateProfile = mutation({ }); }, }); + +export const updateUserName = mutation({ + args: { + name: v.string(), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (userId === null) { + return null; + } + await ctx.db.patch(userId, { + name: args.name, + }); + }, +}); + +export const updatePartialProfile = mutation({ + args: { + dob: v.optional(v.number()), + height: v.optional(v.number()), + weight: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (userId === null) { + return null; + } + await ctx.db.patch(userId, { + dob: args.dob, + height: args.height, + weight: args.weight, + hasOnboarded: true, + }); + }, +}); From 9d6a36a60bae9a65a9f48ed9dc491f14903846c1 Mon Sep 17 00:00:00 2001 From: Michael Lei Date: Mon, 28 Oct 2024 15:03:27 -0700 Subject: [PATCH 25/26] Update onboarding process --- app/index.tsx | 13 +++++++++++-- app/onboarding/name/index.tsx | 13 +++---------- convex/users.ts | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 972b98ea..edc835a5 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,6 +1,7 @@ import { Redirect } from "expo-router"; -import { Authenticated, Unauthenticated } from "convex/react"; +import { Authenticated, Unauthenticated, useQuery } from "convex/react"; import Onboarding from "./onboarding"; +import { api } from "~/convex/_generated/api"; export default function SignInPage() { return ( @@ -9,8 +10,16 @@ export default function SignInPage() { - + ); } + +function CrossRoad() { + const user = useQuery(api.users.currentUser); + if (!user) { + return null; + } + return ; +} diff --git a/app/onboarding/name/index.tsx b/app/onboarding/name/index.tsx index 3dad5039..c1c6d8b1 100644 --- a/app/onboarding/name/index.tsx +++ b/app/onboarding/name/index.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "convex/react"; +import { useMutation } from "convex/react"; import { router, SplashScreen } from "expo-router"; import { AlertCircle } from "lucide-react-native"; import { useEffect, useState } from "react"; @@ -12,17 +12,10 @@ import { Text } from "~/components/ui/text"; import { api } from "~/convex/_generated/api"; export default function OnboardingNamePage() { - const user = useQuery(api.users.currentUser); useEffect(() => { - if (user) { - if (user.hasOnboarded) { - router.replace("/goals"); - } - SplashScreen.hideAsync(); - } - }, [user]); + SplashScreen.hideAsync(); + }, []); - SplashScreen.hideAsync(); return ( Date: Mon, 28 Oct 2024 15:17:34 -0700 Subject: [PATCH 26/26] Remove email script --- app/(tabs)/goals.tsx | 6 +++++- package.json | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/(tabs)/goals.tsx b/app/(tabs)/goals.tsx index d301298e..7b38d51d 100644 --- a/app/(tabs)/goals.tsx +++ b/app/(tabs)/goals.tsx @@ -245,7 +245,11 @@ function GoalItem({ goal, goalLogs }: GoalItemProps) { } router.push({ - pathname: `/goals/${goal._id}/${latestLog._id}/start/logProgress`, + pathname: `/goals/[goalId]/[goalLogId]/start/logProgress`, + params: { + goalId: goal._id, + goalLogId: latestLog._id, + }, }); }; diff --git a/package.json b/package.json index 618758d9..aca4a755 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,7 @@ "test": "jest --watchAll", "lint": "expo lint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\" --list-different --ignore-path .gitignore", - "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\" --ignore-path .gitignore", - "email": "email dev --dir convex/emails" + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\" --ignore-path .gitignore" }, "jest": { "preset": "jest-expo"