From ff90dae955e971d8802c1ce8cc4304d565b17714 Mon Sep 17 00:00:00 2001 From: Tim Leslie Date: Mon, 8 Feb 2021 09:12:25 +1100 Subject: [PATCH] Update ecommerce example to match Wes' course --- .changeset/fair-countries-rhyme.md | 5 + .changeset/tame-radios-exercise.md | 5 + examples-next/ecommerce/access.ts | 102 ++++++------- examples-next/ecommerce/keystone.ts | 56 ++++---- examples-next/ecommerce/lib/mail.ts | 39 ++++- .../ecommerce/lib/sendPasswordResetEmail.ts | 56 -------- examples-next/ecommerce/mutations.ts | 121 ---------------- .../ecommerce/mutations/addToCart.ts | 51 ++++--- examples-next/ecommerce/mutations/checkout.ts | 134 ++++++++++-------- examples-next/ecommerce/mutations/index.ts | 9 +- .../ecommerce/mutations/resetPassword.ts | 56 -------- examples-next/ecommerce/schemas/CartItem.ts | 31 ++-- examples-next/ecommerce/schemas/Order.ts | 15 +- examples-next/ecommerce/schemas/OrderItem.ts | 35 ++--- examples-next/ecommerce/schemas/Product.ts | 41 +++--- .../ecommerce/schemas/ProductImage.ts | 21 +-- examples-next/ecommerce/schemas/Role.ts | 10 +- examples-next/ecommerce/schemas/User.ts | 52 +++---- examples-next/ecommerce/schemas/fields.ts | 10 +- examples-next/ecommerce/seed-data/data.ts | 4 +- examples-next/ecommerce/seed-data/index.ts | 11 +- .../ecommerce/tests/mutations.test.ts | 20 ++- 22 files changed, 332 insertions(+), 552 deletions(-) create mode 100644 .changeset/fair-countries-rhyme.md create mode 100644 .changeset/tame-radios-exercise.md delete mode 100644 examples-next/ecommerce/lib/sendPasswordResetEmail.ts delete mode 100644 examples-next/ecommerce/mutations.ts delete mode 100644 examples-next/ecommerce/mutations/resetPassword.ts diff --git a/.changeset/fair-countries-rhyme.md b/.changeset/fair-countries-rhyme.md new file mode 100644 index 00000000000..b4324f94ddc --- /dev/null +++ b/.changeset/fair-countries-rhyme.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/example-ecommerce': major +--- + +Updated all code to match the released version of Wes' course. diff --git a/.changeset/tame-radios-exercise.md b/.changeset/tame-radios-exercise.md new file mode 100644 index 00000000000..2c4b12e863b --- /dev/null +++ b/.changeset/tame-radios-exercise.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/example-ecommerce': patch +--- + +Added missing `returnFields` in `addToCart` resolver. diff --git a/examples-next/ecommerce/access.ts b/examples-next/ecommerce/access.ts index 01f6ca46f76..2fa5e1c9d59 100644 --- a/examples-next/ecommerce/access.ts +++ b/examples-next/ecommerce/access.ts @@ -1,92 +1,82 @@ import { permissionsList } from './schemas/fields'; import { ListAccessArgs } from './types'; +// At it's simplest, the access control returns a yes or no value depending on the users session -/* - The basic level of access to the system is being signed in as a valid user. This gives you access - to the Admin UI, access to your own User and Todo items, and read access to roles. -*/ -export const isSignedIn = ({ session }: ListAccessArgs) => { +export function isSignedIn({ session }: ListAccessArgs) { return !!session; -}; +} -/* - Permissions are shorthand functions for checking that the current user's role has the specified - permission boolean set to true -*/ const generatedPermissions = Object.fromEntries( permissionsList.map(permission => [ permission, function ({ session }: ListAccessArgs) { - // Do they have that Permission? Yes or no return !!session?.data.role?.[permission]; }, ]) ); +// Permissions check if someone meets a criteria - yes or no. export const permissions = { - // We create a permission for each can* field on the Role type ...generatedPermissions, - // we can also add additional permissions as we need them - isAwesome({ session }: ListAccessArgs) { - if (session?.data.name?.includes('wes') || session?.data.name?.includes('jed')) { - // they are awesome, let them have access - return true; - } - return false; // not awesome, no access + isAwesome({ session }: ListAccessArgs): boolean { + return !!session?.data.name.includes('wes'); }, }; -/* - Rules are logical functions that can be used for list access, and may return a boolean (meaning - all or no items are available) or a set of filters that limit the available items -*/ +// Rule based function +// Rules can return a boolean - yes or no - or a filter which limits which products they can CRUD. export const rules = { - canOrder: ({ session }: ListAccessArgs) => { - if (!session) return false; // not signed in - if (permissions.canManageCart(session)) return true; // if they have the permission - // otherwise we only show them cart items that they own - return { - user: { id: session.itemId }, - }; + canManageProducts({ session }: ListAccessArgs) { + if (!isSignedIn({ session })) { + return false; + } + // 1. Do they have the permission of canManageProducts + if (permissions.canManageProducts({ session })) { + return true; + } + // 2. If not, do they own this item? + return { user: { id: session?.itemId } }; }, - canReadUsers: ({ session }: ListAccessArgs) => { - if (!session) { - // No session? No people. + canOrder({ session }: ListAccessArgs) { + if (!isSignedIn({ session })) { return false; - } else if (session.data.role?.canSeeOtherUsers) { - // Can see everyone + } + // 1. Do they have the permission of canManageProducts + if (permissions.canManageCart({ session })) { return true; - } else { - // Can only see yourself - return { id: session.itemId }; } + // 2. If not, do they own this item? + return { user: { id: session?.itemId } }; }, - canUpdateUsers: ({ session }: ListAccessArgs) => { - if (!session) { - // No session? No people. + canManageOrderItems({ session }: ListAccessArgs) { + if (!isSignedIn({ session })) { return false; - } else if (session.data.role?.canManageUsers) { - // Can update everyone + } + // 1. Do they have the permission of canManageProducts + if (permissions.canManageCart({ session })) { return true; - } else { - // Can update yourself - return { id: session.itemId }; } + // 2. If not, do they own this item? + return { order: { user: { id: session?.itemId } } }; }, - canUpdateProducts({ session }: ListAccessArgs) { - // Do they have access? + canReadProducts({ session }: ListAccessArgs) { + if (!isSignedIn({ session })) { + return false; + } if (permissions.canManageProducts({ session })) { - // They have the permission - return true; + return true; // They can read everything! } - // Otherwise, only allow them to manage their own products - return { user: { id: session?.itemId } }; + // They should only see available products (based on the status field) + return { status: 'AVAILABLE' }; }, - canReadProducts: ({ session }: ListAccessArgs) => { - if (session?.data.role?.canManageProducts) { + canManageUsers({ session }: ListAccessArgs) { + if (!isSignedIn({ session })) { + return false; + } + if (permissions.canManageUsers({ session })) { return true; - } else { - return { status: 'AVAILABLE' }; } + // Otherwise they may only update themselves! + return { id: session?.itemId }; }, }; diff --git a/examples-next/ecommerce/keystone.ts b/examples-next/ecommerce/keystone.ts index ecb8a3bafc2..68149a67497 100644 --- a/examples-next/ecommerce/keystone.ts +++ b/examples-next/ecommerce/keystone.ts @@ -1,3 +1,7 @@ +import { createAuth } from '@keystone-next/auth'; +import { config, createSchema } from '@keystone-next/keystone/schema'; +import { withItemData, statelessSessions } from '@keystone-next/keystone/session'; +import { permissionsList } from './schemas/fields'; import { Role } from './schemas/Role'; import { OrderItem } from './schemas/OrderItem'; import { Order } from './schemas/Order'; @@ -6,80 +10,72 @@ import { ProductImage } from './schemas/ProductImage'; import { Product } from './schemas/Product'; import { User } from './schemas/User'; import 'dotenv/config'; - -import { config, createSchema } from '@keystone-next/keystone/schema'; -import { statelessSessions, withItemData } from '@keystone-next/keystone/session'; -import { extendGraphqlSchema } from './mutations'; -import { createAuth } from '@keystone-next/auth'; import { insertSeedData } from './seed-data'; -import { permissionsList } from './schemas/fields'; -import sendPasswordResetEmail from './lib/sendPasswordResetEmail'; +import { sendPasswordResetEmail } from './lib/mail'; +import { extendGraphqlSchema } from './mutations'; + +const databaseURL = process.env.DATABASE_URL || 'mongodb://localhost/keystone-sick-fits-tutorial'; -const databaseUrl = process.env.DATABASE_URL || 'mongodb://localhost/keystone-examples-ecommerce'; -const protectIdentities = process.env.NODE_ENV === 'production'; const sessionConfig = { - maxAge: 60 * 60 * 24 * 30, // 30 days - secret: process.env.COOKIE_SECRET || '', + maxAge: 60 * 60 * 24 * 360, // How long they stay signed in? + secret: process.env.COOKIE_SECRET!, }; const { withAuth } = createAuth({ listKey: 'User', identityField: 'email', secretField: 'password', - protectIdentities, initFirstItem: { fields: ['name', 'email', 'password'], - itemData: { - role: { - create: { - name: 'Admin Role', - ...Object.fromEntries(permissionsList.map(i => [i, true])), - }, - }, - }, + // TODO: Add in inital roles here }, passwordResetLink: { async sendToken(args) { + // send the email await sendPasswordResetEmail(args.token, args.identity); - console.log(`Password reset info:`, args); }, }, }); export default withAuth( config({ + // @ts-ignore server: { cors: { - origin: ['http://localhost:2223'], + origin: [process.env.FRONTEND_URL!], credentials: true, }, }, db: { adapter: 'mongoose', - url: databaseUrl, - onConnect: async ({ keystone }) => { + url: databaseURL, + async onConnect(keystone) { + console.log('Connected to the database!'); if (process.argv.includes('--seed-data')) { - insertSeedData(keystone); + await insertSeedData(keystone); } }, }, lists: createSchema({ + // Schema items go in here User, Product, ProductImage, CartItem, - Order, OrderItem, + Order, Role, }), extendGraphqlSchema, ui: { - // Anyone who has been assigned a role can access the Admin UI - isAccessAllowed: ({ session }) => !!session?.data.role, + // Show the UI only for poeple who pass this test + isAccessAllowed: ({ session }) => + // console.log(session); + !!session?.data, }, session: withItemData(statelessSessions(sessionConfig), { - // Cache the permission fields from the role in the session so we don't have to look them up again in access checks - User: `id name role { ${permissionsList.join(' ')} }`, + // GraphQL Query + User: `id name email role { ${permissionsList.join(' ')} }`, }), }) ); diff --git a/examples-next/ecommerce/lib/mail.ts b/examples-next/ecommerce/lib/mail.ts index 5f1afc58325..8d8eb31c21f 100644 --- a/examples-next/ecommerce/lib/mail.ts +++ b/examples-next/ecommerce/lib/mail.ts @@ -1,14 +1,14 @@ -import nodemailer from 'nodemailer'; -import SMTPTransport from 'nodemailer/lib/smtp-transport'; +import { createTransport, getTestMessageUrl } from 'nodemailer'; -const transport = nodemailer.createTransport({ +const transport = createTransport({ + // @ts-ignore host: process.env.MAIL_HOST, port: process.env.MAIL_PORT, auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS, }, -} as SMTPTransport.Options); +}); function makeANiceEmail(text: string) { return ` @@ -27,4 +27,33 @@ function makeANiceEmail(text: string) { `; } -export { makeANiceEmail, transport }; +export interface MailResponse { + accepted?: string[] | null; + rejected?: null[] | null; + envelopeTime: number; + messageTime: number; + messageSize: number; + response: string; + envelope: Envelope; + messageId: string; +} +export interface Envelope { + from: string; + to?: string[] | null; +} + +export async function sendPasswordResetEmail(resetToken: string, to: string): Promise { + // email the user a token + const info = (await transport.sendMail({ + to, + from: 'wes@wesbos.com', + subject: 'Your password reset token!', + html: makeANiceEmail(`Your Password Reset Token is here! + Click Here to reset + `), + })) as MailResponse; + if (process.env.MAIL_USER?.includes('ethereal.email')) { + // @ts-ignore + console.log(`� Message Sent! Preview it at ${getTestMessageUrl(info)}`); + } +} diff --git a/examples-next/ecommerce/lib/sendPasswordResetEmail.ts b/examples-next/ecommerce/lib/sendPasswordResetEmail.ts deleted file mode 100644 index af8fbe8c709..00000000000 --- a/examples-next/ecommerce/lib/sendPasswordResetEmail.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getTestMessageUrl } from 'nodemailer'; -import { transport, makeANiceEmail } from './mail'; - -export default async function sendPasswordResetEmail(resetToken: string, to: string) { - // 3. Email them that reset token - const info = await transport - .sendMail({ - from: 'wes@wesbos.com', - to, - subject: 'Your Password Reset Token', - html: makeANiceEmail(`Your Password Reset Token is here! - \n\n - Click Here to Reset`), - }) - .catch(err => { - console.log(`Error Sending Mail: `, err); - }); - - if (process.env.MAIL_USER?.includes('ethereal.email')) { - console.log(`💌 Sent! Preview: `, getTestMessageUrl(info)); - } -} - -// export default async function requestReset(root: any, { email }: { email: string }, context: any) { -// const response = await context.lists.User.findMany({ -// where: { email } -// }); -// console.log(response); - -// const [user] = response; - -// if (!user) { -// throw new Error(`No such user found for email ${email}`); -// } -// // 2. Set a reset token and expiry on that user -// const resetToken = (await promisify(randomBytes)(20)).toString('hex'); -// // const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 hour from now -// await context.lists.User.updateOne({ -// id: user.id, -// // data: { resetToken, resetTokenExpiry } -// // TODO: Expiry? -// data: { passwordResetToken: resetToken } -// }); - -// // 3. Email them that reset token -// await transport.sendMail({ -// from: 'wes@wesbos.com', -// to: user.email, -// subject: 'Your Password Reset Token', -// html: makeANiceEmail(`Your Password Reset Token is here! -// \n\n -// Click Here to Reset`), -// }); -// // // 4. Return the message -// return { message: 'Check your email!' }; -// } diff --git a/examples-next/ecommerce/mutations.ts b/examples-next/ecommerce/mutations.ts deleted file mode 100644 index f5ac5f54839..00000000000 --- a/examples-next/ecommerce/mutations.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { graphQLSchemaExtension } from '@keystone-next/keystone/schema'; - -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: ` - type Mutation { - addToCart(productId: ID!): CartItem - checkout(token: String!): Order - } - `, - resolvers: { - Mutation: { - checkout: async (root, { token }: { token: string }, context) => { - const { session } = context; - - // Users can't create orders directly because of our access control, so here we create a - // new context with full access and use the items API it provides - const sudoContext = context.createContext({ skipAccessControl: true }); - - // 1. Query the current user and make sure they are signed in - const userId = session.itemId; - if (!userId) throw new Error('You must be signed in to complete this order.'); - - const User = await sudoContext.lists.User.findOne({ - where: { id: userId }, - resolveFields: ` - id - name - email - cart { - id - quantity - product { name price id description photo { id publicUrlTransformed } } - }`, - }); - - // 2. recalculate the total for the price - const amount = User.cart.reduce( - (tally: number, cartItem: any) => tally + cartItem.product.price * cartItem.quantity, - 0 - ); - console.log(`Going to charge for a total of ${amount}`); - - // 3. Create the Payment Intent, given the Payment Method ID - // by passing confirm: true, We do stripe.paymentIntent.create() and stripe.paymentIntent.confirm() in 1 go. - // FIXME: How do we test this? Is this going to charge someone's card? - const charge = { id: 'MADE UP', amount, token }; - // const charge = await stripe.paymentIntents.create({ - // amount, - // currency: 'USD', - // confirm: true, - // payment_method: token, - // }); - - // 4. Convert the CartItems to OrderItems - const orderItems = User.cart.map((cartItem: any) => { - const orderItem = { - name: cartItem.product.name, - description: cartItem.product.description, - price: cartItem.product.price, - quantity: cartItem.quantity, - image: { connect: { id: cartItem.product.photo.id } }, - user: { connect: { id: userId } }, - }; - return orderItem; - }); - - // 5. create the Order - console.log('Creating the order'); - const order = await sudoContext.lists.Order.createOne({ - data: { - total: charge.amount, - charge: `${charge.id}`, - items: { create: orderItems }, - user: { connect: { id: userId } }, - }, - resolveFields: false, - }); - // 6. Clean up - clear the users cart, delete cartItems - const cartItemIds = User.cart.map((cartItem: any) => cartItem.id); - await sudoContext.lists.CartItem.deleteMany({ ids: cartItemIds }); - - // 7. Return the Order to the client - return order; - }, - addToCart: async (root, { productId }: { productId: string }, context) => { - const { session } = context; - // 1. Make sure they are signed in - const userId = session.itemId; - if (!userId) { - throw new Error('You must be signed in to add cart items.'); - } - // 2. Query the users current cart, to see if they already have that item - const allCartItems = await context.lists.CartItem.findMany({ - where: { user: { id: userId }, product: { id: productId } }, - resolveFields: 'id quantity', - }); - - // 3. Check if that item is already in their cart and increment by 1 if it is - const [existingCartItem] = allCartItems; - if (existingCartItem) { - const { quantity } = existingCartItem; - console.log(`There are already ${quantity} of these items in their cart`); - return await context.lists.CartItem.updateOne({ - id: existingCartItem.id, - data: { quantity: quantity + 1 }, - resolveFields: false, - }); - } else { - // 4. If its not, create a fresh CartItem for that user! - return await context.lists.CartItem.createOne({ - data: { - product: { connect: { id: productId } }, - user: { connect: { id: userId } }, - }, - resolveFields: false, - }); - } - }, - }, - }, -}); diff --git a/examples-next/ecommerce/mutations/addToCart.ts b/examples-next/ecommerce/mutations/addToCart.ts index 9d67525d4a4..76095051a57 100644 --- a/examples-next/ecommerce/mutations/addToCart.ts +++ b/examples-next/ecommerce/mutations/addToCart.ts @@ -1,38 +1,45 @@ import { KeystoneContext } from '@keystone-next/types'; +import { Session } from '../types'; -export default async function addToCart( +import { CartItemCreateInput } from '../.keystone/schema-types'; + +async function addToCart( root: any, { productId }: { productId: string }, context: KeystoneContext -) { - const { session } = context; - console.log('adding to cart...'); - // 1. Make sure they are signed in - const userId = session.itemId; - if (!userId) { - throw new Error('You must be signed in!'); +): Promise { + console.log('ADDING TO CART!'); + // 1. Query the current user see if they are signed in + const sesh = context.session as Session; + if (!sesh.itemId) { + throw new Error('You must be logged in to do this!'); } - // 2. Query the users current cart, to see if they already have that item + // 2. Query the current users cart const allCartItems = await context.lists.CartItem.findMany({ - where: { user: { id: userId }, product: { id: productId } }, + where: { user: { id: sesh.itemId }, product: { id: productId } }, + resolveFields: 'id quantity', }); - // 3. Check if that item is already in their cart and increment by 1 if it is const [existingCartItem] = allCartItems; if (existingCartItem) { - const { quantity } = existingCartItem; - console.log(`There are already ${quantity} of these items in their cart`); + console.log(existingCartItem); + console.log(`There are already ${existingCartItem.quantity}, increment by 1!`); + // 3. See if the current item is in their cart + // 4. if itis, increment by 1 return await context.lists.CartItem.updateOne({ id: existingCartItem.id, - data: { quantity: quantity + 1 }, - }); - } else { - // 4. If its not, create a fresh CartItem for that user! - return await context.lists.CartItem.createOne({ - data: { - product: { connect: { id: productId } }, - user: { connect: { id: userId } }, - }, + data: { quantity: existingCartItem.quantity + 1 }, + resolveFields: false, }); } + // 4. if it isnt, create a new cart item! + return await context.lists.CartItem.createOne({ + data: { + product: { connect: { id: productId } }, + user: { connect: { id: sesh.itemId } }, + }, + resolveFields: false, + }); } + +export default addToCart; diff --git a/examples-next/ecommerce/mutations/checkout.ts b/examples-next/ecommerce/mutations/checkout.ts index f85e7a964b7..e1c36ba0ae3 100644 --- a/examples-next/ecommerce/mutations/checkout.ts +++ b/examples-next/ecommerce/mutations/checkout.ts @@ -1,92 +1,102 @@ -// @ts-ignore -import { getItem, deleteItems } from '@keystonejs/server-side-graphql-client'; -import stripe from '../lib/stripe'; -const graphql = String.raw; +import { OrderCreateInput } from '../.keystone/schema-types'; -export default async function checkout(root: any, { token }: { token: string }, context: any) { - const { session } = context; - // 1. Query the current user and make sure they are signed in - const userId = session.itemId; - if (!userId) throw new Error('You must be signed in to complete this order.'); +import { KeystoneContext } from '@keystone-next/types'; +// import stripeConfig from '../lib/stripe'; - // TODO: How do I use findOne but populate the cart → product → image relation? - // const User2 = await context.lists.User.findOne({ - // where: { id: userId }, - // }); +const graphql = String.raw; - // console.log(User2); +interface Arguments { + token: string; +} - const User = await getItem({ - context, - listKey: 'User', - itemId: userId, - returnFields: graphql` +async function checkout( + root: any, + { token }: Arguments, + context: KeystoneContext +): Promise { + // 1. Make sure they are signed in + const userId = context.session.itemId; + if (!userId) { + throw new Error('Sorry! You must be signed in to create an order!'); + } + // 1.5 Query the current user + const user = await context.lists.User.findOne({ + where: { id: userId }, + resolveFields: graphql` id name email cart { id quantity - product { name price id description photo { + product { + name + price + description id - image { - id publicUrlTransformed + photo { + id + image { + id + publicUrlTransformed + } } - } } - }`, + } + } + `, }); - - // 2. recalculate the total for the price - const amount = User.cart.reduce( - (tally: number, cartItem: any) => tally + cartItem.product.price * cartItem.quantity, - 0 - ); - console.log(`Going to charge for a total of ${amount}`); - - // 3. Create the Payment Intent, given the Payment Method ID - // by passing confirm: true, We do stripe.paymentIntent.create() and stripe.paymentIntent.confirm() in 1 go. - // You can bypass stripe by using this: - // const charge = { id: `fake-charge-${Date.now()}`, amount, token }; - const charge = (await stripe.paymentIntents - .create({ - amount, - currency: 'USD', - confirm: true, - payment_method: token, - }) - .catch(err => { - console.log('shoot!'); - console.log(err); - })) as any; // TODO: Stripe Type? - console.log(`Back from stripe!`, charge.id); - - // 4. Convert the CartItems to OrderItems - // TODO Type CartItem and OrderItem. Can they be generated? - const orderItems = User.cart.map((cartItem: any) => { + console.dir(user, { depth: null }); + // 2. calc the total price for their order + const cartItems = user.cart.filter((cartItem: any) => cartItem.product); + const amount = cartItems.reduce(function (tally: number, cartItem: any) { + return tally + cartItem.quantity * cartItem.product.price; + }, 0); + console.log(amount); + // 3. create the charge with the stripe library + // const charge = await stripeConfig.paymentIntents + // .create({ + // amount, + // currency: 'USD', + // confirm: true, + // payment_method: token, + // }) + // .catch(err => { + // console.log(err); + // throw new Error(err.message); + // }); + console.log({ token }); // Use the "unused" variable + const charge = { amount, id: 'MADE UP' }; + console.log(charge); + // 4. Convert the cartItems to OrderItems + const orderItems = cartItems.map((cartItem: any) => { const orderItem = { name: cartItem.product.name, description: cartItem.product.description, price: cartItem.product.price, quantity: cartItem.quantity, - image: { connect: { id: cartItem.product.photo.id } }, - user: { connect: { id: userId } }, + photo: { connect: { id: cartItem.product.photo.id } }, }; return orderItem; }); - console.dir(orderItems, { depth: null }); - // 5. create the Order + console.log('gonna create the order'); + // 5. Create the order and return it const order = await context.lists.Order.createOne({ data: { total: charge.amount, - charge: `${charge.id}`, + charge: charge.id, items: { create: orderItems }, user: { connect: { id: userId } }, }, + resolveFields: false, + }); + console.log({ order }); + // 6. Clean up any old cart item + const cartItemIds = user.cart.map((cartItem: any) => cartItem.id); + console.log('gonna create delete cartItems'); + await context.lists.CartItem.deleteMany({ + ids: cartItemIds, }); - // 6. Clean up - clear the users cart, delete cartItems - const cartItemIds = User.cart.map((cartItem: any) => cartItem.id); - await deleteItems({ context, listKey: 'CartItem', items: cartItemIds }); - - // 7. Return the Order to the client return order; } + +export default checkout; diff --git a/examples-next/ecommerce/mutations/index.ts b/examples-next/ecommerce/mutations/index.ts index 5355384ebf5..39b85742828 100644 --- a/examples-next/ecommerce/mutations/index.ts +++ b/examples-next/ecommerce/mutations/index.ts @@ -1,16 +1,11 @@ import { graphQLSchemaExtension } from '@keystone-next/keystone/schema'; import addToCart from './addToCart'; import checkout from './checkout'; -// import requestReset from './requestReset'; -// This is a "Fake graphql" hack so we get highlighting of strings in vs code +// make a fake graphql tagged template literal const graphql = String.raw; - export const extendGraphqlSchema = graphQLSchemaExtension({ typeDefs: graphql` - type Message { - message: String - } type Mutation { addToCart(productId: ID): CartItem checkout(token: String!): Order @@ -18,8 +13,8 @@ export const extendGraphqlSchema = graphQLSchemaExtension({ `, resolvers: { Mutation: { - checkout, addToCart, + checkout, }, }, }); diff --git a/examples-next/ecommerce/mutations/resetPassword.ts b/examples-next/ecommerce/mutations/resetPassword.ts deleted file mode 100644 index 6eafc194e92..00000000000 --- a/examples-next/ecommerce/mutations/resetPassword.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @ts-ignore -// TODO: Type this mutation -export default async function resetPassword(parent, args: any, ctx, info, { query }: any) { - console.log(args); - // 1. check if the passwords match - console.info('1. Checking is passwords match'); - if (args.password !== args.confirmPassword) { - throw new Error("Yo Passwords don't match!"); - } - // 2. check if its a legit reset token - console.info('1. Checking if legit token'); - const userResponse = await query(`query { - allUsers(where: { - resetToken: "${args.resetToken}", - }) { - id - resetTokenExpiry - } - }`); - const [user] = userResponse.data.allUsers; - if (!user) { - throw new Error('This token is invalid.'); - } - // 3. Check if its expired - console.info('check if expired'); - const now = Date.now(); - const expiry = new Date(user.resetTokenExpiry).getTime(); - if (now >= expiry) { - throw new Error('This token is expired'); - } - // 4. Save the new password to the user and remove old resetToken fields - console.log(`4. Saving new password`); - const updatedUserResponse = await query(` - mutation { - updateUser( - id: "${user.id}", - data: { - password: "${args.password}", - resetToken: null, - resetTokenExpiry: null, - } - ) { - password_is_set - name - } - } - `); - const { errors } = updatedUserResponse; - if (errors) { - throw new Error(errors); - } - console.info('Sending success response'); - return { - message: 'Your password has been reset', - }; -} diff --git a/examples-next/ecommerce/schemas/CartItem.ts b/examples-next/ecommerce/schemas/CartItem.ts index 5a69e0c7f1a..680232582e2 100644 --- a/examples-next/ecommerce/schemas/CartItem.ts +++ b/examples-next/ecommerce/schemas/CartItem.ts @@ -1,7 +1,6 @@ -import { virtual, integer, relationship } from '@keystone-next/fields'; +import { integer, relationship } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; -import { isSignedIn, rules } from '../access'; -import { ListsAPI } from '../types'; +import { rules, isSignedIn } from '../access'; export const CartItem = list({ access: { @@ -10,28 +9,18 @@ export const CartItem = list({ update: rules.canOrder, delete: rules.canOrder, }, + ui: { + listView: { + initialColumns: ['product', 'quantity', 'user'], + }, + }, fields: { - label: virtual({ - graphQLReturnType: 'String', - resolver: async (cartItem, args, ctx) => { - const lists: ListsAPI = ctx.lists; - if (!cartItem.product) { - return `🛒 ${cartItem.quantity} of (invalid product)`; - } - let product = await lists.Product.findOne({ - where: { id: String(cartItem.product) }, - }); - if (product?.name) { - return `🛒 ${cartItem.quantity} of ${product.name}`; - } - return `🛒 ${cartItem.quantity} of (invalid product)`; - }, - }), + // TODO: Custom Label in here quantity: integer({ defaultValue: 1, isRequired: true, }), - product: relationship({ ref: 'Product' /* , isRequired: true */ }), - user: relationship({ ref: 'User.cart' /* , isRequired: true */ }), + product: relationship({ ref: 'Product' }), + user: relationship({ ref: 'User.cart' }), }, }); diff --git a/examples-next/ecommerce/schemas/Order.ts b/examples-next/ecommerce/schemas/Order.ts index b1a2aabab76..0cf872582dc 100644 --- a/examples-next/ecommerce/schemas/Order.ts +++ b/examples-next/ecommerce/schemas/Order.ts @@ -1,24 +1,21 @@ -import { virtual, integer, relationship, text } from '@keystone-next/fields'; +import { integer, text, relationship, virtual } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; -import { rules } from '../access'; +import { isSignedIn, rules } from '../access'; import formatMoney from '../lib/formatMoney'; export const Order = list({ access: { - create: () => false, + create: isSignedIn, read: rules.canOrder, update: () => false, delete: () => false, }, - ui: { - hideCreate: true, - hideDelete: true, - listView: { initialColumns: ['label', 'user', 'items'] }, - }, fields: { label: virtual({ graphQLReturnType: 'String', - resolver: item => formatMoney(item.total), + resolver(item) { + return `${formatMoney(item.total)}`; + }, }), total: integer(), items: relationship({ ref: 'OrderItem.order', many: true }), diff --git a/examples-next/ecommerce/schemas/OrderItem.ts b/examples-next/ecommerce/schemas/OrderItem.ts index 7acec2646e1..60458995d5a 100644 --- a/examples-next/ecommerce/schemas/OrderItem.ts +++ b/examples-next/ecommerce/schemas/OrderItem.ts @@ -1,29 +1,32 @@ -import { text, relationship, integer } from '@keystone-next/fields'; +import { integer, text, relationship } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; -import { rules } from '../access'; +import { isSignedIn, rules } from '../access'; export const OrderItem = list({ access: { - create: () => false, - read: rules.canOrder, + create: isSignedIn, + read: rules.canManageOrderItems, update: () => false, delete: () => false, }, - ui: { - hideCreate: true, - hideDelete: true, - listView: { initialColumns: ['name', 'price', 'quantity'] }, - }, fields: { name: text({ isRequired: true }), - order: relationship({ ref: 'Order.items' }), - user: relationship({ ref: 'User' }), - description: text({ ui: { displayMode: 'textarea' } }), - price: integer(), - quantity: integer({ isRequired: true }), - image: relationship({ + description: text({ + ui: { + displayMode: 'textarea', + }, + }), + photo: relationship({ ref: 'ProductImage', - ui: { displayMode: 'cards', cardFields: ['image'] }, + ui: { + displayMode: 'cards', + cardFields: ['image', 'altText'], + inlineCreate: { fields: ['image', 'altText'] }, + inlineEdit: { fields: ['image', 'altText'] }, + }, }), + price: integer(), + quantity: integer(), + order: relationship({ ref: 'Order.items' }), }, }); diff --git a/examples-next/ecommerce/schemas/Product.ts b/examples-next/ecommerce/schemas/Product.ts index 5e7efa06947..8a376544fee 100644 --- a/examples-next/ecommerce/schemas/Product.ts +++ b/examples-next/ecommerce/schemas/Product.ts @@ -1,16 +1,30 @@ -import { text, select, integer, relationship } from '@keystone-next/fields'; +import { integer, select, text, relationship } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; -import { permissions, rules } from '../access'; +import { rules, isSignedIn } from '../access'; export const Product = list({ access: { - create: permissions.canManageProducts, + create: isSignedIn, read: rules.canReadProducts, - update: permissions.canManageProducts, - delete: permissions.canManageProducts, + update: rules.canManageProducts, + delete: rules.canManageProducts, }, fields: { name: text({ isRequired: true }), + description: text({ + ui: { + displayMode: 'textarea', + }, + }), + photo: relationship({ + ref: 'ProductImage.product', + ui: { + displayMode: 'cards', + cardFields: ['image', 'altText'], + inlineCreate: { fields: ['image', 'altText'] }, + inlineEdit: { fields: ['image', 'altText'] }, + }, + }), status: select({ options: [ { label: 'Draft', value: 'DRAFT' }, @@ -23,20 +37,11 @@ export const Product = list({ createView: { fieldMode: 'hidden' }, }, }), - description: text({ ui: { displayMode: 'textarea' } }), price: integer(), - photo: relationship({ - ref: 'ProductImage.product', - ui: { - createView: { fieldMode: 'hidden' }, - displayMode: 'cards', - cardFields: ['image', 'altText'], - inlineCreate: { fields: ['image', 'altText'] }, - inlineEdit: { fields: ['altText'] }, - }, + user: relationship({ + ref: 'User.products', + defaultValue: ({ context }) => + context.session?.itemId ? { connect: { id: context.session?.itemId } } : null, }), }, - ui: { - listView: { initialColumns: ['name', 'status'] }, - }, }); diff --git a/examples-next/ecommerce/schemas/ProductImage.ts b/examples-next/ecommerce/schemas/ProductImage.ts index 0a8f72e405c..73f6f44b649 100644 --- a/examples-next/ecommerce/schemas/ProductImage.ts +++ b/examples-next/ecommerce/schemas/ProductImage.ts @@ -1,33 +1,34 @@ import 'dotenv/config'; -import { cloudinaryImage } from '@keystone-next/cloudinary'; import { relationship, text } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; -import { permissions } from '../access'; +import { cloudinaryImage } from '@keystone-next/cloudinary'; +import { isSignedIn, permissions } from '../access'; export const cloudinary = { cloudName: process.env.CLOUDINARY_CLOUD_NAME || '', apiKey: process.env.CLOUDINARY_KEY || '', apiSecret: process.env.CLOUDINARY_SECRET || '', + folder: 'sickfits', }; export const ProductImage = list({ access: { - // signed in - create: permissions.canManageProducts, - read: true, - // can manage products + create: isSignedIn, + read: () => true, update: permissions.canManageProducts, delete: permissions.canManageProducts, }, - ui: { - isHidden: true, - }, fields: { - product: relationship({ ref: 'Product.photo' }), image: cloudinaryImage({ cloudinary, label: 'Source', }), altText: text(), + product: relationship({ ref: 'Product.photo' }), + }, + ui: { + listView: { + initialColumns: ['image', 'altText', 'product'], + }, }, }); diff --git a/examples-next/ecommerce/schemas/Role.ts b/examples-next/ecommerce/schemas/Role.ts index 44cb0b4bbf1..5a8be066525 100644 --- a/examples-next/ecommerce/schemas/Role.ts +++ b/examples-next/ecommerce/schemas/Role.ts @@ -1,4 +1,4 @@ -import { text, relationship } from '@keystone-next/fields'; +import { relationship, text } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; import { permissions } from '../access'; import { permissionFields } from './fields'; @@ -14,18 +14,12 @@ export const Role = list({ hideCreate: args => !permissions.canManageRoles(args), hideDelete: args => !permissions.canManageRoles(args), isHidden: args => !permissions.canManageRoles(args), - listView: { - initialColumns: ['name', 'assignedTo'], - }, - itemView: { - defaultFieldMode: args => (permissions.canManageRoles(args) ? 'edit' : 'read'), - }, }, fields: { name: text({ isRequired: true }), ...permissionFields, assignedTo: relationship({ - ref: 'User.role', + ref: 'User.role', // TODO: Add this to the User many: true, ui: { itemView: { fieldMode: 'read' }, diff --git a/examples-next/ecommerce/schemas/User.ts b/examples-next/ecommerce/schemas/User.ts index d68e63b41bf..438be88bfc6 100644 --- a/examples-next/ecommerce/schemas/User.ts +++ b/examples-next/ecommerce/schemas/User.ts @@ -1,40 +1,25 @@ -import { text, password, relationship } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; -import { rules, permissions } from '../access'; +import { text, password, relationship } from '@keystone-next/fields'; +import { permissions, rules } from '../access'; export const User = list({ access: { - // anyone should be able to create a user (sign up) - create: true, - // only admins can see the list of users, but people should be able to see themselves - read: rules.canReadUsers, - update: rules.canUpdateUsers, + create: () => true, + read: rules.canManageUsers, + update: rules.canManageUsers, + // only people with the permission can delete themselves! + // You can't delete yourself delete: permissions.canManageUsers, }, ui: { - // only admins can create and delete users in the Admin UI + // hide the backend UI from regular users hideCreate: args => !permissions.canManageUsers(args), hideDelete: args => !permissions.canManageUsers(args), - listView: { - initialColumns: ['name', 'email'], - }, }, fields: { name: text({ isRequired: true }), email: text({ isRequired: true, isUnique: true }), password: password(), - role: relationship({ - ref: 'Role.assignedTo', - access: { - create: permissions.canManageUsers, - update: permissions.canManageUsers, - }, - ui: { - itemView: { - fieldMode: args => (permissions.canManageUsers(args) ? 'edit' : 'read'), - }, - }, - }), cart: relationship({ ref: 'CartItem.user', many: true, @@ -43,20 +28,17 @@ export const User = list({ itemView: { fieldMode: 'read' }, }, }), - orders: relationship({ - ref: 'Order.user', - many: true, + orders: relationship({ ref: 'Order.user', many: true }), + role: relationship({ + ref: 'Role.assignedTo', access: { - create: () => false, - read: true, - update: () => false, - }, - ui: { - createView: { fieldMode: 'hidden' }, - itemView: { fieldMode: 'read' }, + create: permissions.canManageUsers, + update: permissions.canManageUsers, }, }), - // resetToken: text(), - // resetTokenExpiry: timestamp() + products: relationship({ + ref: 'Product.user', + many: true, + }), }, }); diff --git a/examples-next/ecommerce/schemas/fields.ts b/examples-next/ecommerce/schemas/fields.ts index 0955b9b0fa7..1a8c0a29824 100644 --- a/examples-next/ecommerce/schemas/fields.ts +++ b/examples-next/ecommerce/schemas/fields.ts @@ -13,12 +13,18 @@ export const permissionFields = { defaultValue: false, label: 'User can Edit other users', }), - canManageRoles: checkbox({ defaultValue: false, label: 'User can CRUD roles' }), + canManageRoles: checkbox({ + defaultValue: false, + label: 'User can CRUD roles', + }), canManageCart: checkbox({ defaultValue: false, label: 'User can see and manage cart and cart items', }), - canManageOrders: checkbox({ defaultValue: false, label: 'User can see and manage orders' }), + canManageOrders: checkbox({ + defaultValue: false, + label: 'User can see and manage orders', + }), }; export type Permission = keyof typeof permissionFields; diff --git a/examples-next/ecommerce/seed-data/data.ts b/examples-next/ecommerce/seed-data/data.ts index e2ccaf00def..98dbe7b880c 100644 --- a/examples-next/ecommerce/seed-data/data.ts +++ b/examples-next/ecommerce/seed-data/data.ts @@ -1,7 +1,7 @@ function timestamp() { // sometime in the last 30 days - const timestamp = Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30); - return new Date(timestamp).toISOString(); + const stampy = Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 30); + return new Date(stampy).toISOString(); } export const products = [ diff --git a/examples-next/ecommerce/seed-data/index.ts b/examples-next/ecommerce/seed-data/index.ts index 39985b09414..d62951e79bb 100644 --- a/examples-next/ecommerce/seed-data/index.ts +++ b/examples-next/ecommerce/seed-data/index.ts @@ -1,14 +1,17 @@ -import { BaseKeystone } from '@keystone-next/types'; import { products } from './data'; -export async function insertSeedData(keystone: BaseKeystone) { +export async function insertSeedData(ks: any) { + // Keystone API changed, so we need to check for both versions to get keystone + const keystone = ks.keystone || ks; + const adapter = keystone.adapters?.MongooseAdapter || keystone.adapter; + console.log(`🌱 Inserting Seed Data: ${products.length} Products`); - const { mongoose } = keystone.adapter; + const { mongoose } = adapter; for (const product of products) { console.log(` 🛍️ Adding Product: ${product.name}`); const { _id } = await mongoose .model('ProductImage') - .create({ photo: product.photo, altText: product.description }); + .create({ image: product.photo, altText: product.description }); product.photo = _id; await mongoose.model('Product').create(product); } diff --git a/examples-next/ecommerce/tests/mutations.test.ts b/examples-next/ecommerce/tests/mutations.test.ts index 7ca2356ed49..a553cb91ad2 100644 --- a/examples-next/ecommerce/tests/mutations.test.ts +++ b/examples-next/ecommerce/tests/mutations.test.ts @@ -24,7 +24,7 @@ multiAdapterRunners('mongoose').map(({ runner }) => id label total - items(sortBy: [name_ASC]) { name user { id } description price quantity image { id } } + items(sortBy: [name_ASC]) { name description price quantity photo { id } } user { id } charge } }`; @@ -38,7 +38,7 @@ multiAdapterRunners('mongoose').map(({ runner }) => }); expect(data).toEqual({ checkout: null }); expect(errors).toHaveLength(1); - expect(errors![0].message).toEqual('You must be signed in to complete this order.'); + expect(errors![0].message).toEqual('Sorry! You must be signed in to create an order!'); }) ); test( @@ -105,17 +105,15 @@ multiAdapterRunners('mongoose').map(({ runner }) => expect(result1.data!.checkout.user.id).toEqual(user1.id); expect(result1.data!.checkout.items).toHaveLength(2); expect(result1.data!.checkout.items[0].name).toEqual(product1.name); - expect(result1.data!.checkout.items[0].user.id).toEqual(user1.id); expect(result1.data!.checkout.items[0].description).toEqual(product1.description); expect(result1.data!.checkout.items[0].price).toEqual(product1.price); expect(result1.data!.checkout.items[0].quantity).toEqual(3); - expect(result1.data!.checkout.items[0].image.id).toEqual(product1.photo.id); + expect(result1.data!.checkout.items[0].photo.id).toEqual(product1.photo.id); expect(result1.data!.checkout.items[1].name).toEqual(product2.name); - expect(result1.data!.checkout.items[1].user.id).toEqual(user1.id); expect(result1.data!.checkout.items[1].description).toEqual(product2.description); expect(result1.data!.checkout.items[1].price).toEqual(product2.price); expect(result1.data!.checkout.items[1].quantity).toEqual(2); - expect(result1.data!.checkout.items[1].image.id).toEqual(product2.photo.id); + expect(result1.data!.checkout.items[1].photo.id).toEqual(product2.photo.id); // Checkout user 2 const result2 = await asUser(context, user2.id).graphql.raw({ @@ -130,17 +128,15 @@ multiAdapterRunners('mongoose').map(({ runner }) => expect(result2.data!.checkout.user.id).toEqual(user2.id); expect(result2.data!.checkout.items).toHaveLength(2); expect(result2.data!.checkout.items[0].name).toEqual(product1.name); - expect(result2.data!.checkout.items[0].user.id).toEqual(user2.id); expect(result2.data!.checkout.items[0].description).toEqual(product1.description); expect(result2.data!.checkout.items[0].price).toEqual(product1.price); expect(result2.data!.checkout.items[0].quantity).toEqual(2); - expect(result2.data!.checkout.items[0].image.id).toEqual(product1.photo.id); + expect(result2.data!.checkout.items[0].photo.id).toEqual(product1.photo.id); expect(result2.data!.checkout.items[1].name).toEqual(product2.name); - expect(result2.data!.checkout.items[1].user.id).toEqual(user2.id); expect(result2.data!.checkout.items[1].description).toEqual(product2.description); expect(result2.data!.checkout.items[1].price).toEqual(product2.price); expect(result2.data!.checkout.items[1].quantity).toEqual(3); - expect(result2.data!.checkout.items[1].image.id).toEqual(product2.photo.id); + expect(result2.data!.checkout.items[1].photo.id).toEqual(product2.photo.id); }) ); }); @@ -156,7 +152,7 @@ multiAdapterRunners('mongoose').map(({ runner }) => const { data, errors } = await graphql.raw({ query, variables: { productId } }); expect(data).toEqual({ addToCart: null }); expect(errors).toHaveLength(1); - expect(errors![0].message).toEqual('You must be signed in to add cart items.'); + expect(errors![0].message).toEqual('You must be logged in to do this!'); }) ); @@ -198,7 +194,7 @@ multiAdapterRunners('mongoose').map(({ runner }) => expect(data).toEqual({ addToCart: null }); expect(errors).toHaveLength(1); expect(errors![0].message).toEqual( - 'Argument "productId" of non-null type "ID!" must not be null.' + 'Variable "$data" got invalid value null at "data.product.connect.id"; Expected non-nullable type "ID!" not to be null.' ); }) );