Search code examples
reactjsnext.jsprismat3trpc

New to prisma, are these models feasible? T3 stack app


So I want to make an app where a user can create a 'Household' where they can invite other members of their family to have a shared database where they can store items in their pantry/kitchen.

The family members would have their emails added to an invite list, and upon their first log in, their email would be checked to see if it is on an invite list, and if it is, given the option to join the Household, and if not, the ability to create their own household.

When creating a new household, I would like to have the user supply a name for their household, their name and email (from next-auth session), and optionally a list of emails to send invites to.

I am using nextjs, prisma and trpc, all of which I am fairly new with, and I am wondering if the schema I have come up with is feasible, and what info I should add into a trpc procedure to take in the User info (from session data) and household name from the front end form.

Here is my prisma schema:

model Account {
    id                String  @id @default(cuid())
    userId            String
    type              String
    provider          String
    providerAccountId String
    refresh_token     String? @db.Text
    access_token      String? @db.Text
    expires_at        Int?
    token_type        String?
    scope             String?
    id_token          String? @db.Text
    session_state     String?
    user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)

    @@unique([provider, providerAccountId])
}

model Session {
    id           String   @id @default(cuid())
    sessionToken String   @unique
    userId       String
    expires      DateTime
    user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
    id            String     @id @default(cuid())
    name          String?
    email         String?    @unique
    emailVerified DateTime?
    image         String?
    accounts      Account[]
    sessions      Session[]
    founder       Boolean?
    household     Household? @relation(fields: [householdId], references: [householdId])
    householdId   String?
    onInviteList  Boolean?   @default(false)
}

model VerificationToken {
    identifier String
    token      String   @unique
    expires    DateTime

    @@unique([identifier, token])
}

model Household {
    name         String
    householdId  String        @id @default(cuid())
    members      User[] 
    invitedList  Invite[]
    storageAreas StorageArea[]
}

model Invite {
    email       String     @unique
    isVerified  Boolean?
    Household   Household? @relation(fields: [householdId], references: [householdId])
    householdId String?
}

EDIT

This is how I was attempting to create the household:

My household.ts router:

export const householdRouter = createTRPCRouter({
  create: protectedProcedure
    .input(
      z.object({
        name: z.string(),
        members: z.object({ name: z.string(), email: z.string() }),
      })
    )
    .mutation(({ ctx, input }) => {
      return ctx.prisma.household.create({
        data: {
          name: input.name,
          members: input.members
        },
      });
    }),
});

and on the front end

// for storing the sessionData to pass to backend
const [householdFounder, setHouseholdFounder] = useState<HouseholdFounder>({
    name: "",
    email: "",
  });
useEffect(() => {
    status === "authenticated" &&
      setHouseholdFounder({
        name: sessionData.user.name,
        email: sessionData.user.email,
      });
  }, [status, sessionData]);

// and here's the input for receiving the household name and the trpc call to the backend 
<input
        type="text"
        onChange={(e) => setHouseholdName(e.currentTarget.value)}
        value={householdName ?? ""}
      />
      <button
        onClick={(e) => {
          newHousehold.mutate({
            name: householdName ?? "",
            members: householdFounder,
          });
          setHouseholdName("");
        }}
      >
        Create New Household
      </button>

This throws a type error in the router when taking in the input.members:

Type '{ email: string; name: string; }' is not assignable to type 'UserUncheckedCreateNestedManyWithoutHouseholdInput | UserCreateNestedManyWithoutHouseholdInput | undefined'.ts(2322)

Thank you for an insight you can shed on this for me, I've been trying all day to get a household created with the proper info sent.


Solution

  • I am not an sql expert so I won't comment on the tables other than if prisma db push works its probably fine.

    Based on the context of your question I presume you want to have all the user data, including the associated data from other tables inside the session user object (that gets passed to a protectedProcedure route in the t3 stack).

    I suggest you forget about this and do it another way because next-auth stores session data in cookies (read here https://next-auth.js.org/configuration/options#cookies)

    Cookies are not meant to be used for large ammounts of data, and shouldn't hold any particularaly sensitive data.

    There are 2 important things to note that you may not be aware of.

    1.

    The user object in the session object is only a subset of your db user object. You can find (and add to) the session user type in server/auth.ts.

    2.

    Even if you chose to do it this way you are not saving a query.

    I will give some guidance on how to go about it anyway.

    In next-auth, you can define callbacks. https://next-auth.js.org/configuration/callbacks

    The callback you will be interested in is session, you can find it in server/auth.ts

    You can extend the user type I discussed erlier to include everything you want in the object, then in the session callback make a query to prisma and add that data into it. Now whenever you access session.user all this data will be available. It will also be available from the useSession hook.

    Alternativly you can modify the enforceIsAuthed function in server/api/trpc to do the query there.

    Now, you can do this however as previously stated I reccomend you forget about it and write a trpc query which fetches the data you need when you need it.

    EDIT

    Ok,

    you need to look into many-to-many relationships. The prisma docs will help you alot.

    Here is an example, based on the code in your edit

      create: protectedProcedure.input(z.string()).mutation(async ({ ctx, input }) => {
        const userId = ctx.session.user.id;
    
        return ctx.prisma.household.create({
          data: {
            name: input,
            members: {
              connect: { id: userId } // set the table join
            }
          },
          include: {
            members: true // join the household and members in the return
          }
        });
      }),
      getUser: protectedProcedure.query(({ ctx }) => {
        const userId = ctx.session.user.id;
    
        return ctx.prisma.user.findUnique({
          where: { id: userId },
          include: { household: true } // include household. Note this object won't include members or invitedList as we need to explicitly join those also
        });
      })
    
    

    Here we are calling protectedProcedure, which will fail with no user.

    We get household name from the input and create a new household. members is a relation table so we connect the user who called the function with the new household record by the id, which is the join.

    Now the frontend:

    import { useSession } from "next-auth/react";
    import { useState } from "react";
    import { api } from "~/utils/api";
    
    const Index = () => {
      const { status } = useSession();
      const utils = api.useContext();
      const { mutate: createHousehold } = api.example.create.useMutation({
        onSuccess: () => utils.example.getUser.invalidate() // invalidate the user query when we create so it fetches the updated user
      });
      const [householdName, setHouseholdName] = useState("");
      const { data } = api.example.getUser.useQuery(undefined, {
        enabled: status === "authenticated" // only get user data if logged in
      });
    
      if (status === "loading") return <div>loading...</div>;
    
      if (status === "unauthenticated") return <div>unauthenticated</div>;
    
      const handleSubmit = () => createHousehold(householdName);
    
      return (
        <>
          <form onSubmit={handleSubmit}>
            <label>Household name</label>
            <input value={householdName} onChange={(e) => setHouseholdName(e.target.value)} />
            <button type="submit">Create</button>
          </form>
    
          {data?.household && ( // only show this if the user data includes household (only will after the createHoldehold function)
            <div>
              <h2>You now belong to household {data.household.name}!</h2>
            </div>
          )}
        </>
      );
    };
    
    export default Index;
    
    

    We don't need to hold the user data in state, we will get that server side. All we need is the user to be logged in and the household name.

    I have used a form, which is bet practice but not required.