LoginSignup
1
3

Next.jsとPrismaとNextAuthでログイン機能を作ってuseStateとuseEffectの使い方も知ったあとに公式のチュートリアルを踏破してみた

Last updated at Posted at 2023-11-19

はじめに

仕事のキャッチアップで急速錬成中。バックエンドばっかやってきたから急にフロントのフレームワークつかうことになるとつらーい!(でもチャンスだね)。今回はログイン画面と画面切り替えのあるものを作るぞ

  • ログイン画面で、メールアドレスとパスワードを入力
    image.png

  • Home のページが開く
    image.png

  • 保護されたページが開く(セッション削除してリロードするとログイン画面に戻る)
    image.png

  • パスワードを忘れたらtokenつきメールを作って、パスワードを再設定できる(送信はしない)
    image.png
    image.png
    image.png

ここまでできればかなりいろんなことができるようになるだろう。

Reference

Node

いままで node は exe をダウンロードしてきてインストールしていたが、コマンドラインでできるらしい。pythonの venv みたいな感じ。nvmという

(nvm-setup.exe をダウンロード。npmもインストールされる ※エディタの再起動必須)

console
nvm install 18.17.1
nvm use 18.17.1
nvm list
nvm uninstall 18.17.1
node -v

openSSL

https://slproweb.com/products/Win32OpenSSL.html
Win64 OpenSSL vX.X.X Light
C:\Program Files\OpenSSL-Win64\bin を環境変数に登録

Getting Started

First, run the development server:

プロジェクトフォルダを作成
mkdir nextjs
cd nextjs
console
npx create-next-app@latest .
  √ Would you like to use TypeScript? ... No / [Yes]
  √ Would you like to use ESLint? ... No / [Yes]
  √ Would you like to use Tailwind CSS? ... [No] / Yes
  √ Would you like to use `src/` directory? ... No / [Yes]
  √ Would you like to use App Router? (recommended) ... No / [Yes]
  √ Would you like to customize the default import alias (@/*)? ... [No] / Yes

npm run dev

image.png

.env.example

  • .env.example を作ったあとに、cp コマンドで .env を作成する
  • NEXTAUTH_SECRET は、openssl rand -base64 32 をコンソールで打ち込んで、出てきた文字をペースト
.env.example
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET= # openssl rand -base64 32
BASE_URL=http://localhost:3000
console
cp .env.example .env

.gitignore

.gitignore
    :
# local env files
+.env
.env*.local

package.json

好きにインストールして

package.json
{
  "name": "nextjs",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
+   "format": "prettier --write ."
  },
  "dependencies": {
+   "@emotion/react": "^11.11.1",
+   "@emotion/styled": "^11.11.0",
+   "@mui/icons-material": "^5.14.16",
+   "@mui/material": "^5.14.17",
+   "@mui/x-data-grid": "^6.18.1",
+   "@prisma/client": "^5.5.2",
+   "dayjs": "^1.11.10",
-   "next": "14.0.1"
+   "next": "^13.5.6",
+   "next-auth": "^4.24.5",
    "react": "^18",
    "react-dom": "^18",
+   "react-hook-form": "^7.48.2",
+   "ts-node": "^10.9.1",
+   "zod": "^3.22.4"
  },
  "devDependencies": {
+   "@next/eslint-plugin-next": "^14.0.3",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
+   "@typescript-eslint/eslint-plugin": "^6.13.1",
+   "@typescript-eslint/parser": "^6.13.1",
+   "dotenv": "^16.3.1",
    "eslint": "^8",
    "eslint-config-next": "14.0.2",
+   "eslint-config-prettier": "^9.0.0",
+   "jest": "^29.7.0",
+   "jest-environment-jsdom": "^29.7.0",
+   "prettier": "^3.1.0",
+   "prisma": "^5.5.2",
-   "typescript": "^5"
+   "typescript": "^5.3.2"
  }
}

prettier

prettier.config.js
/** @type {import('prettier').Config} */
module.exports = {
  semi: false,
  singleQuote: true,
  printWidth: 100,
  useTabs: false,
  tabWidth: 2,
  endOfLine: 'lf',
  jsxSingleQuote: true,
}

prisma

console
npx prisma init --datasource-provider mysql
.gitignore(最後に追記)
    :
# prisma
/prisma/migrations/
/src/generated/

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
+ output   = "../src/generated/prismaClient"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

+ ここから下をコピーして追記
model Users {
  id             Int       @id @default(autoincrement())
  name           String?
  role           String    @default("user")
  email          String    @unique
  password       String
  passwordToken  String?   @map("password_token")
  tokenExpiredAt DateTime? @map("token_expired_at")
  createdAt      DateTime  @default(now()) @map("created_at")
  updatedAt      DateTime  @default(now()) @updatedAt @map("updated_at")

  @@map("users")
}

.env
DATABASE_URL="mysql://your-user-name:your-password@localhost:3306/nextjs_db"
console
npx prisma migrate dev --name init
  Environment variables loaded from .env
  Prisma schema loaded from prisma\schema.prisma
  Datasource "db": MySQL database "nextjs_db" at "localhost:3306"
  
  MySQL database nextjs_db created at localhost:3306
  
  Applying migration `20231103015547_init`
  
  The following migration(s) have been created and applied from new schema changes:
  
  migrations/
    └─ 20231103015547_init/
      └─ migration.sql
  
  Your database is now in sync with your schema.
  
  ✔ Generated Prisma Client (v5.5.2) to .\node_modules\@prisma\client in 197ms
  

prisma clientに反映

新規で schema.prisma を作成した際、及び schema.prisma を編集した際には、以下のコマンドを実行して、 prisma client への反映を行う。このコマンドを実行してもgithub管理するコードの差分は発生しないが node_modules が更新されている。prismaがモデル情報を取り込んでメソッドとして使えるようになる、、ような感じかな。Djangoとかと比べると片手落ちというかいまいちな印象だね。

dbスキーマに変更が出たら migrate とセットで実行しろということ

console
npx prisma generate

image.png

seeder

console(package.jsonで入れていたら不要)
npm install ts-node
package.json(pycharmなら左のガターに再生ボタンが出るよ)
    :
"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "format": "prettier --write .",
+   "seed": "ts-node --project tsconfig.seeds.json -r tsconfig-paths/register ./prisma/seeds/seeder.ts"
  },
    :
tsconfig.seeds.json
{
  "compilerOptions": {
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "CommonJS",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules"]
}

prisma/seeds/seeder.ts
import { PrismaClient } from '@/generated/prismaClient'

async function main() {
  const prisma = new PrismaClient()
  await prisma.users.deleteMany()
  await prisma.users.create({
    data: {
      name: '開発 太郎',
      email: 'test@example.com',
      password: 'hoge',
    },
  })
  prisma.$disconnect()
}
main().then(() => console.log('seeding ok.'))

Route Handlers

App Router という仕組みがあり、フォルダ構造がそのままいわば Djangoでいう urls.py のような役割になる :open_mouth:
image.png

urlベースで作ったフォルダ構造の下に決まったファイルを作ると dashboard/page.txhttp://localhost:3000/dashboard でページが表示される :flushed:
image.png

さらに、api フォルダを噛ませてから、route.tsx というファイルを作って、HTTPメソッドをオーバーライドしてあげると APIモード になる。Djangoとかやってるやつにはかなり親和性が高い... :flushed: ようになってきたのは実はつい最近らしい

src/app/api/users/route.tsx
import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
  return NextResponse.json({msg: "hello, api/users GET!"})
}

image.png

ちなみに、以下のようなファイルをつくると、Postmanの疎通テストみたいなのができる(PyCharmでやってるけどVSCodeにもあるよ)

src/app/api/users/route.http
GET http://localhost:3000/api/users
Accept: application/json

image.png

.gitignore(ゴリゴリログが溜まっていくので無視)
    :
+ # .idea
+ /.idea/httpRequests/
console(削除)
# linuxだといわゆるこれ: rm -r /your/path/here
Remove-Item -Path "src/app/api/users" -Recurse -Force

ログイン機能とパスワード再設定

どばーっと行くけど、作ってみるとなるほどね!となるはず。

自動生成ファイルの調整

  • app ディレクトリの layout.tsx を加工
  • app ディレクトリの page.tsx を加工
src/app/layout.tsx(加工)
import React from 'react'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body>{children}</body>
    </html>
  )
}

src/app/page.tsx(加工)
import React from 'react'

export default function Page() {
  return (
    <div>
      <p>保護されていないページ</p>
      <p>This is Home</p>
      <p>
        <a href={'/admin/user-list'}>ユーザー管理ページにとぶ</a>
      </p>
    </div>
  )
}

src/app/globals.css(削除)
src/app/page.module.css(削除)

forgot-password(コンポーネント)

  • forgot-password ディレクトリの layout.tsx page.tsx を新規作成する
  • (authenticated) ディレクトリは単なるグループ分け用のフォルダ(言い換えると、マルカッコをつけることで appRouter から除外されるので保護ページとそうじゃないページを見やすくわけている)
  • 同じような理由で先頭アンダーバー _XXXX ディレクトリは appRouter から無視される
src/app/admin/(unauthenticated)/forgot-password/layout.tsx
import { ReactNode } from 'react'

export default async function Layout({ children }: { children: ReactNode }) {
  return <>{children}</>
}

src/app/admin/(unauthenticated)/forgot-password/page.tsx
import React from 'react'
import { ForgotPasswordPage } from './_components/ForgotPasswordPage'

export default function Page() {
  // 無駄な処理に見えるけど、dbからデータをロードする必要があるときにまとめてデータを確保してコンポーネントに流し込むつくりにしよう
  return <ForgotPasswordPage />
}

src/app/admin/(unauthenticated)/forgot-password/_components/ForgotPasswordPage.tsx
'use client'
import React, { useState } from 'react'
import { ForgotPasswordSent } from './ForgotPasswordSent'
import { ForgotPasswordEdit } from './ForgotPasswordEdit'

export function ForgotPasswordPage() {
  const [isSent, setIsSent] = useState(false)
  return isSent ? (
    <ForgotPasswordSent />
  ) : (
    <ForgotPasswordEdit onCompletion={() => setIsSent(true)} />
  )
}

src/app/admin/(unauthenticated)/forgot-password/_components/ForgotPasswordEdit.tsx
import React, { FormEvent } from 'react'
import { Box, Button, TextField, Typography } from '@mui/material'

type Props = {
  onCompletion: () => void
}

export function ForgotPasswordEdit({ onCompletion }: Props) {
  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const formData = new FormData(event.currentTarget)
    const data = {
      email: formData.get('email'),
    }
    fetch('/api/admin/forgot-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ to: data.email }),
    })
      .then((response) => response.json())
      .then((data) => {
        console.log(`data: ${JSON.stringify(data)}`)
        onCompletion()
      })
      .catch((error) => {
        console.error('Fetch error:', error)
      })
  }

  return (
    <Box
      component='main'
      sx={{
        flexGrow: 1,
        height: '100vh',
        overflow: 'auto',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <Typography variant='h5' textAlign='center' sx={{ mb: 1 }}>
        パスワードを忘れた場合
      </Typography>
      <Box
        component='form'
        sx={{ width: 345, display: 'flex', flexDirection: 'column', alignItems: 'center' }}
        onSubmit={handleSubmit}
      >
        <TextField
          id='email'
          name='email'
          label='メールアドレス'
          variant='outlined'
          required
          sx={{ width: '100%' }}
        />
        <Box sx={{ m: 1 }} />
        <Button type='submit' variant='outlined'>
          送信
        </Button>
      </Box>
    </Box>
  )
}

src/app/admin/(unauthenticated)/forgot-password/_components/ForgotPasswordSent.tsx
import React from 'react'
import { Box, Button, Typography } from '@mui/material'

export function ForgotPasswordSent() {
  return (
    <Box
      component='main'
      sx={{
        flexGrow: 1,
        height: '100vh',
        overflow: 'auto',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <Typography variant='h5' textAlign='center' sx={{ mb: 1 }}>
        送信ボタンが押されました(画面2)
      </Typography>
      <Box sx={{ width: 345, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
        <Typography sx={{ textAlign: 'center', width: 310, fontSize: '16px' }}>
          送信ボタンが押された結果、画面が遷移しました。ブラウザの開発者コンソールを開いてURLをクリックしてください
        </Typography>
        <Box sx={{ m: 1 }} />
        <Button variant='outlined' href='/admin/signin' sx={{ width: '100%' }}>
          ログイン画面へ
        </Button>
      </Box>
    </Box>
  )
}

apiHandler

src/util/apiHandler.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

export async function validate<T>(req: NextRequest, schema: z.ZodType) {
  const { data, success, error } = schema.safeParse(await req.json()) as {
    data: JSON
    success: boolean
    error: { errors: z.ZodIssue[] }
  }
  if (!success) {
    throw new Error(JSON.stringify({ message: 'Invalid request', errors: error.errors }))
  }
  return data as T
}

export function requestError(e: unknown): NextResponse {
  if (e instanceof Error) {
    return NextResponse.json({ error: JSON.parse(e.message) }, { status: 400 })
  } else {
    console.error('Unexpected error type:', e)
    return NextResponse.json({ error: 'Unexpected error type' }, { status: 500 })
  }
}

export function serverError(e: unknown): NextResponse {
  if (e instanceof Error) {
    return NextResponse.json({ error: JSON.parse(e.message) }, { status: 500 })
  } else {
    console.error('Unexpected error type:', e)
    return NextResponse.json({ error: 'Unexpected error type' }, { status: 500 })
  }
}

forgot-password(API)

src/util/date.ts
import dayjs, { Dayjs, ManipulateType } from 'dayjs'

export function dateOffset(
  n: number = 0,
  unit: ManipulateType = 'minute',
  base: Date = new Date(),
): Date {
  let shiftedNow: Dayjs
  if (n >= 0) {
    shiftedNow = dayjs(base).add(n, unit)
  } else {
    shiftedNow = dayjs(base).subtract(Math.abs(n), unit)
  }

  return new Date(shiftedNow.format())
}

src/util/token.ts
import { randomBytes } from 'crypto'

export function generateToken(): string {
  const buffer: Buffer = randomBytes(32)
  return buffer.toString('hex')
}

src/app/api/admin/forgot-password/route.ts
import { requestError, serverError, validate } from '@/util/apiHandler'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PrismaClient } from '@/generated/prismaClient'
import { generateToken } from '@/util/token'
import { dateOffset } from '@/util/date'

export async function POST(req: NextRequest) {
  const schema = z.object({
    to: z.string().email(),
  })
  type RequestType = z.infer<typeof schema>
  let data: RequestType

  // 送信先メールアドレスのuserを抽出
  let userName: string
  const prisma = new PrismaClient()
  try {
    data = await validate<RequestType>(req, schema)
    const { name } = await prisma.users.findFirstOrThrow({ where: { email: data.to } })
    userName = name || ''
  } catch (error) {
    return requestError(error)
  }

  // TODO: メール送信
  const passwordToken = generateToken()
  const tokenExpiredAt: Date = dateOffset(30, 'minute')
  let message: object = {
    to: data.to,
    from: 'noreply@example.com',
    subject: 'パスワード再設定用のURLをくれてやろう',
    body: `${userName} さん、パスワード再設定用のURLです http://localhost:3000/admin/reset-password?token=${passwordToken}`,
  }
  try {
    await prisma.users.update({
      where: { email: data.to },
      data: {
        passwordToken,
        tokenExpiredAt,
      },
    })

    return NextResponse.json(message)
  } catch (error) {
    return serverError(error)
  } finally {
    await prisma.$disconnect()
  }
}

src/app/api/admin/forgot-password/route.http
POST http://localhost:3000/api/admin/forgot-password
Content-Type: application/json

{"to":  "test@example.com"}

reset-password(コンポーネント)

src/app/admin/(unauthenticated)/reset-password/page.tsx
import React from 'react'
import { ResetPasswordEdit } from './_components/ResetPasswordEdit'

export default function Page() {
  // 無駄な処理に見えるけど、dbからデータをロードする必要があるときにまとめてデータを確保してコンポーネントに流し込むつくりにしよう
  return <ResetPasswordEdit />
}

src/app/admin/(unauthenticated)/reset-password/_components/ResetPasswordEdit.tsx
'use client'

import { useRouter, useSearchParams } from 'next/navigation'
import React, { FormEvent, useEffect } from 'react'
import { Box, Button, TextField, Typography } from '@mui/material'

type ResetPasswordEditForm = {
  token: string
  password: string
  passwordConfirm: string
}

export function ResetPasswordEdit() {
  const router = useRouter()
  const forceRedirect = useSearchParams().size == 0
  const token = useSearchParams().get('token')

  // エフェクトは通常、コンポーネントを外部システム(=外部コンポーネント)と同期させるのに使います。
  // see: https://ja.react.dev/learn/synchronizing-with-effects
  useEffect(() => {
    if (forceRedirect) {
      router.push('/admin/signin')
    }
  }, [forceRedirect, router])

  // クエリストリング(=token)がない状態でたどりついたらねずみ返し
  if (forceRedirect) return <></>

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const formData = new FormData(event.currentTarget)
    const data = {
      password: formData.get('password'),
      passwordConfirm: formData.get('passwordConfirm'),
    }
    fetch('/api/admin/reset-password', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
      body: JSON.stringify({ password: data.password, passwordConfirm: data.passwordConfirm }),
    })
      .then(() => {
        router.push('/admin/signin')
      })
      .catch((error) => {
        console.error('Fetch error:', error)
      })
  }

  return (
    <Box
      component='main'
      sx={{
        flexGrow: 1,
        height: '100vh',
        overflow: 'auto',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <Typography variant='h5' textAlign='center' sx={{ mb: 1 }}>
        パスワード再設定
      </Typography>
      <Box
        component='form'
        sx={{ width: 345, display: 'flex', flexDirection: 'column', alignItems: 'center' }}
        onSubmit={handleSubmit}
      >
        <TextField
          id='password'
          name='password'
          type='password'
          label='パスワード'
          variant='outlined'
          required
          sx={{ width: '100%' }}
        />
        <TextField
          id='passwordConfirm'
          name='passwordConfirm'
          type='password'
          label='パスワード再入力'
          variant='outlined'
          required
          sx={{ width: '100%' }}
        />
        <Box sx={{ m: 1 }} />
        <Button type='submit' variant='outlined'>
          送信
        </Button>
      </Box>
    </Box>
  )
}

reset-password(API)

src/app/api/admin/reset-password/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PrismaClient } from '@/generated/prismaClient'
import { requestError, serverError, validate } from '@/util/apiHandler'

export async function PUT(req: NextRequest) {
  const schema = z.object({
    password: z.string(),
    passwordConfirm: z.string(),
  })
  type RequestType = z.infer<typeof schema>
  let data: RequestType

  let userId: number
  const prisma = new PrismaClient()
  try {
    data = await validate<RequestType>(req, schema)

    let token = req.headers.get('Authorization')
    if (token && token.startsWith('Bearer ')) {
      token = token.slice(7)
    } else {
      throw new Error(JSON.stringify({ message: 'Invalid or missing Bearer token.' }))
    }
    const { id, tokenExpiredAt } = await prisma.users.findFirstOrThrow({
      where: { passwordToken: token },
    })
    userId = id

    if (tokenExpiredAt && new Date() > tokenExpiredAt) {
      throw new Error(JSON.stringify({ message: 'Token has expired.' }))
    }
    if (data.password != data.passwordConfirm) {
      throw new Error(
        JSON.stringify({
          message: 'The password and password confirmation do not match. Please try again.',
        }),
      )
    }
  } catch (error) {
    return requestError(error)
  }

  try {
    const { id } = await prisma.users.update({
      where: { id: userId },
      data: { password: data.password },
    })
    return NextResponse.json({ id })
  } catch (error) {
    return serverError(error)
  }
}

src/app/api/admin/reset-password/route.http
### 200 OK
PUT http://localhost:3000/api/admin/reset-password
Content-Type: application/json
Authorization: Bearer 5698f13bcaa5b0208f031423a87f57a1229b6a8a31a390925a49927c28532b80

{
"password": "abc",
"passwordConfirm": "abc"
}

### 400 Bad Request: "Token has expired." or "The password and password confirmation do not match. Please try again."
PUT http://localhost:3000/api/admin/reset-password
Content-Type: application/json
Authorization: Bearer 5698f13bcaa5b0208f031423a87f57a1229b6a8a31a390925a49927c28532b80

{
"password": "a",
"passwordConfirm": "b"
}

### 400 Bad Request: Invalid or missing Bearer token.
PUT http://localhost:3000/api/admin/reset-password
Content-Type: application/json

{
"password": "a",
"passwordConfirm": "a"
}

NextAuth

src/app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import options from '@/auth/authOptions'

const handler = NextAuth(options)
export { handler as GET, handler as POST }

src/app/api/auth/route.ts
import { requestError, validate } from '@/util/apiHandler'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PrismaClient } from '@/generated/prismaClient'

export async function GET(req: NextRequest) {
  return NextResponse.json({ msg: 'hello, api/auth GET!' })
}

export async function POST(req: NextRequest) {
  const schema = z.object({
    email: z.string(),
    password: z.string(),
  })
  type RequestType = z.infer<typeof schema>
  let data: RequestType

  try {
    data = await validate<RequestType>(req, schema)
  } catch (e: unknown) {
    return requestError(e)
  }

  const prisma = new PrismaClient()
  try {
    const user = await prisma.users.findFirstOrThrow({
      where: {
        email: data.email,
        password: data.password,
      },
    })
    return NextResponse.json(user)
  } catch (error) {
    console.error(error)
  } finally {
    await prisma.$disconnect()
  }
}

src/app/api/auth/route.http
POST http://localhost:3000/api/auth
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "hoge"
}

###
GET http://localhost:3000/api/auth
Accept: application/json

src/auth/authOptions.ts
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { UserSession } from '@/auth/UserSession'

const authOptions: NextAuthOptions = {
  session: {
    strategy: 'jwt',
  },
  providers: [
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: {
          label: 'Email',
          type: 'email',
        },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }
        let userSession: UserSession
        try {
          const options = {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({ email: credentials.email, password: credentials.password }),
          }
          const result = await fetch(`${process.env.BASE_URL}/api/auth`, options)
          userSession = await result.json()
        } catch (e) {
          return null
        }

        const user: UserSession = {
          id: userSession.id,
          name: userSession.name,
          email: userSession.email,
        }
        return user
      },
    }),
  ],
  callbacks: {
    jwt: ({ token, user, account }) => {
      if (user) {
        token.user = user
        token.id = user.id
      }
      if (account) {
        token.accessToken = account.access_token
      }
      return token
    },
    session: ({ session, token }) => {
      const tokenInUser: UserSession = token.user as UserSession
      return {
        ...session,
        user: {
          ...tokenInUser,
          ...session.user,
        },
      }
    },
  },
}

export default authOptions

src/auth/getUserSession.ts
import { UserSession } from '@/auth/UserSession'
import options from '@/auth/authOptions'
import { getServerSession } from 'next-auth'

export async function getUserSession(): Promise<UserSession | null> {
  const session = await getServerSession(options)
  if (!session) return null
  return session.user as UserSession
}

src/auth/UserSession.ts
export type UserSession = {
  id: string
  name: string
  email: string
}
src/auth/SessionProvider.ts
'use client'
export { SessionProvider } from 'next-auth/react'

signin

src/app/admin/signin/page.tsx
import { redirect } from 'next/navigation'
import SigninForm from '@/app/admin/signin/_components/SigninForm'
import { getUserSession } from '@/auth/getUserSession'
import React from 'react'

export default async function Page() {
  const userSession = await getUserSession()
  if (userSession) {
    redirect('/')
  }
  return <SigninForm />
}

src/app/admin/signin/_components/SigninForm.tsx
'use client'

import React, { FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { Box, Button, FormHelperText, Link, TextField, Typography } from '@mui/material'

export default function SigninForm() {
  const [errorText, setErrorText] = React.useState('')
  const router = useRouter()
  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const formData = new FormData(event.currentTarget)
    const data = {
      email: formData.get('email'),
      password: formData.get('password'),
    }
    signIn('credentials', {
      redirect: false,
      ...data,
    })
      .then((res) => {
        if (!res || res?.error) {
          setErrorText('Authentication failed.')
          return
        }
        router.push('/')
      })
      .catch(() => {
        setErrorText('System error.')
      })
  }

  return (
    <Box
      component='main'
      sx={{
        flexGrow: 1,
        height: '100vh',
        overflow: 'auto',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <Box sx={{ width: 345, display: 'flex', flexDirection: 'column' }}>
        <Typography variant='h5' textAlign='center'>
          ログイン
        </Typography>
        <Box sx={{ m: 2 }} />
        <Box
          component='form'
          onSubmit={handleSubmit}
          sx={{ display: 'flex', flexDirection: 'column' }}
        >
          <TextField id='email' name='email' label='メールアドレス' variant='outlined' />
          <Box sx={{ m: 1 }} />
          <TextField
            id='password'
            name='password'
            label='パスワード'
            type='password'
            autoComplete='current-password'
          />
          <FormHelperText error>{errorText}</FormHelperText>
          <Box sx={{ m: 2 }} />
          <Button type='submit' variant='outlined'>
            サインイン
          </Button>
          <Link href={'/admin/forgot-password'}>パスワードを忘れました</Link>
        </Box>
      </Box>
    </Box>
  )
}

user-list(コンポーネント)

src/util/date.ts
    :
+ ここから下を追記
/**
 * yyyy-mm-dd hh:mm:ss で出力
 */
export function dateFormatHelper(d: Date | null): string {
  if (d === null) {
    return ''
  }
  const options = {
    year: 'numeric' as const,
    month: '2-digit' as const,
    day: '2-digit' as const,
    hour: '2-digit' as const,
    minute: '2-digit' as const,
    second: '2-digit' as const,
    hour12: false,
  }

  return new Intl.DateTimeFormat('ja-JP', options).format(d).replace(/\//g, '-')
}

src/app/admin/(authenticated)/user-list/layout.tsx
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { ReactNode } from 'react'
import options from '@/auth/authOptions'

export default async function Layout({ children }: { children: ReactNode }) {
  const session = await getServerSession(options)
  if (!session) redirect('/admin/signin')
  return <>{children}</>
}

src/app/admin/(authenticated)/user-list/page.tsx
import React from 'react'
import { UserListPage } from './_components/UserListPage'
import { PrismaClient } from '@/generated/prismaClient'

export default async function Page() {
  const prisma = new PrismaClient()
  const props = {
    users: await prisma.users.findMany(),
  }
  return <UserListPage {...props} />
}

src/app/admin/(authenticated)/user-list/_components/UserListPage.tsx
'use client'
import { Users } from '@/generated/prismaClient'
import React from 'react'
import { Box, Typography } from '@mui/material'
import { DataGrid, GridColDef, GridRowsProp } from '@mui/x-data-grid'
import { dateFormatHelper } from '@/util/date'

type Props = {
  users: Users[]
}
export function UserListPage({ users }: Props) {
  if (!users || users.length === 0) {
    return <Typography variant='h5'>No data available.</Typography>
  }
  const rows: GridRowsProp = users.map((row) => ({
    id: row.id,
    name: row.name,
    role: row.role,
    email: row.email,
    password: row.password,
    passwordToken: row.passwordToken,
    tokenExpiredAt: dateFormatHelper(row.tokenExpiredAt),
    createdAt: dateFormatHelper(row.createdAt),
    updatedAt: dateFormatHelper(row.updatedAt),
  }))
  const columns: GridColDef[] = [
    { field: 'id', headerName: '#' },
    { field: 'name', headerName: '名前' },
    { field: 'role', headerName: '役割' },
    { field: 'email', headerName: 'メール' },
    { field: 'password', headerName: 'パスワード' },
    { field: 'passwordToken', headerName: 'トークン' },
    { field: 'tokenExpiredAt', headerName: 'トークン有効期限', width: 160 },
    { field: 'createdAt', headerName: '作成日時', width: 160 },
    { field: 'updatedAt', headerName: '更新日時', width: 160 },
  ]
  return (
    <>
      <p>保護されたページ</p>
      <Box width='100%'>
        <Typography variant='h6'>Users</Typography>
        <DataGrid columns={columns} rows={rows} />
      </Box>
    </>
  )
}

Next.js のチュートリアル

prismaを使うなどのアレンジを入れながら。公式が使ってるTailwind を剥いだり大変だったけどいい訓練になったわ。基本、できあがりをベタ貼りするから差分はじぶんで調べてくれ

Chapter 2 CSS Styling

package.json
  "dependencies": {
    "@emotion/react": "^11.11.1",
    "@emotion/styled": "^11.11.0",
+   "@heroicons/react": "^2.0.18",
    "@mui/icons-material": "^5.14.16",
        :
  },

src/app/page.tsx
import React from 'react'
import { Box, Button, Grid, Link, Typography } from '@mui/material'
import { ArrowRightIcon } from '@heroicons/react/24/outline'

export default function Page() {
  return (
    <Box display='flex' flexDirection='column'>
      {/* 青い領域(ヘッダ) */}
      <Grid item xs={12}>
        <Box height={200} width='100%' bgcolor='primary.main'>
          <Typography sx={{ color: 'white' }}>保護されていないページ</Typography>
          <Typography variant='h5' sx={{ color: 'white', marginBottom: 2 }}>
            This is Header
          </Typography>
        </Box>
      </Grid>

      {/* ライトグレーの領域(コンテンツ領域) */}
      <Box display='flex' flexDirection='row'>
        <Box
          width={400}
          height={500}
          bgcolor='#f0f0f0'
          display='flex'
          flexDirection='column'
          padding='30px'
          justifyContent='center'
        >
          <Box display='flex' flexDirection='column'>
            <Typography variant='h5' sx={{ color: 'text.primary', marginBottom: 2 }}>
              <strong>Welcome to Acme.</strong> This is the example for the{' '}
              <Link href='https://nextjs.org/learn' color='primary'>
                Next.js Learn Course
              </Link>
              , brought to you by Vercel.
            </Typography>
            <Link href='/admin/signin' underline='none'>
              <Button
                variant='contained'
                color='primary'
                sx={{ width: '200px', borderRedius: 2, textTransform: 'none' }}
              >
                Log in
                <ArrowRightIcon
                  className='text-white'
                  style={{ width: '1rem', height: '1rem', paddingLeft: '10px' }}
                />
              </Button>
            </Link>
          </Box>
        </Box>

        {/* ホワイトの領域 */}
        <Box display='flex' alignItems='center' justifyContent='center'>
          {/*  ヒーローイメージを追加*/}
        </Box>
      </Box>
    </Box>
  )
}

Chapter 3 Optimizing Fonts and Images

  • 上記リンクから絵をダウンロードし、public に保存
    • hero-desktop.png
    • hero-mobile.png
src/app/_component/fonts.ts
import { Inter, Lusitana } from 'next/font/google'

export const inter = Inter({ subsets: ['latin'] })

export const lusitana = Lusitana({
  weight: ['400', '700'],
  subsets: ['latin'],
})

src/app/_component/acme-logo.tsx
import { Box, Typography } from '@mui/material'
import { Language } from '@mui/icons-material'
import { lusitana } from './fonts'
import React from 'react'

export default function AcmeLogo() {
  return (
    <Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', color: 'white' }}>
      <Language sx={{ fontSize: '3rem', transform: 'rotate(15deg)' }} />
      <Typography
        className={`${lusitana.className}`}
        variant='h2'
        sx={{ fontSize: '2.75rem', marginLeft: '8px' }}
      >
        Acme
      </Typography>
    </Box>
  )
}

src/app/layout.tsx
import React from 'react'
import { inter } from './_component/fonts'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body className={`${inter.className} antialiasing`}>{children}</body>
    </html>
  )
}

src/app/page.tsx
import React from 'react'
import { Box, Button, Grid, Hidden, Link, Typography } from '@mui/material'
import { ArrowRightIcon } from '@heroicons/react/24/outline'
import AcmeLogo from './_component/acme-logo'
import { lusitana } from './_component/fonts'
import Image from 'next/image'

export default function Page() {
  return (
    <Box display='flex' flexDirection='column'>
      {/* 青い領域(ヘッダ) */}
      <Grid item xs={12}>
        <Box height={200} width='100%' bgcolor='primary.main'>
          <Typography sx={{ color: 'white' }}>保護されていないページ</Typography>
          <Typography variant='h5' sx={{ color: 'white', marginBottom: 2 }}>
            <AcmeLogo />
          </Typography>
        </Box>
      </Grid>

      {/* ライトグレーの領域(コンテンツ領域) */}
      <Box display='flex' flexDirection='row'>
        <Box
          width={400}
          height={760}
          bgcolor='#f0f0f0'
          display='flex'
          flexDirection='column'
          padding='30px'
          justifyContent='center'
        >
          <Box display='flex' flexDirection='column'>
            <Typography
              className={`${lusitana.className}`}
              variant='h5'
              sx={{ color: 'text.primary', marginBottom: 2 }}
            >
              <strong>Welcome to Acme.</strong> This is the example for the{' '}
              <Link href='https://nextjs.org/learn' color='primary'>
                Next.js Learn Course
              </Link>
              , brought to you by Vercel.
            </Typography>
            <Link href='/admin/signin' underline='none'>
              <Button
                variant='contained'
                color='primary'
                sx={{ width: '200px', borderRedius: 2, textTransform: 'none' }}
              >
                Log in
                <ArrowRightIcon
                  className='text-white'
                  style={{ width: '1rem', height: '1rem', paddingLeft: '10px' }}
                />
              </Button>
            </Link>
          </Box>
        </Box>

        {/* ホワイトの領域 */}
        <Box display='flex' alignItems='center' justifyContent='center'>
          <Hidden mdDown>
            <Image
              src='/hero-desktop.png'
              width={1000}
              height={760}
              className='hidden md:block'
              alt='Screenshots of the dashboard project showing desktop version'
            />
          </Hidden>
          <Hidden mdUp>
            <Image
              src='/hero-mobile.png'
              width={560}
              height={620}
              className='block md:hidden'
              alt='Screenshots of the dashboard project showing mobile version'
            />
          </Hidden>
        </Box>
      </Box>
    </Box>
  )
}

image.png

image.png

Chapter 4 Creating Layouts and Pages

src/app/_component/nav-links.tsx
'use client'
import { PresentationChartBarIcon, HomeIcon, UserGroupIcon } from '@heroicons/react/24/outline'
import { Box, Link, Typography } from '@mui/material'
import { usePathname } from 'next/navigation'
import React from 'react'

// いったん固定値で書くけど本来はdbから引っ張ってきてね
const links = [
  { name: 'Home', href: '/', icon: HomeIcon },
  { name: 'Dashboard', href: '/dashboard', icon: PresentationChartBarIcon },
  { name: 'Users', href: '/user-list', icon: UserGroupIcon },
]

export default function NavLinks() {
  const pathname = usePathname()
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon
        return (
          <Link key={link.name} href={link.href}>
            <Box display='flex' flexDirection='row' marginTop='20px'>
              <LinkIcon style={{ width: '2.5rem', height: '2.5rem', marginRight: '10px' }} />
              <Typography
                variant='h6'
                color={pathname === link.href ? 'primary.main' : 'text.primary'}
              >
                {link.name}
              </Typography>
            </Box>
          </Link>
        )
      })}
    </>
  )
}

src/app/_component/sidenav.tsx
'use client'

import React, { FormEvent } from 'react'
import { Box, Link, Typography } from '@mui/material'
import { PowerIcon } from '@heroicons/react/24/outline'
import NavLinks from './nav-links'
import AcmeLogo from './acme-logo'
import { useRouter } from 'next/navigation'

export default function SideNav() {
  const router = useRouter()
  const handleLogout = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    router.push('/')
  }
  return (
    <Box display='flex' height='100%' flexDirection='column'>
      {/* ACMEロゴ */}
      <Box bgcolor='primary.main' height='200px'>
        <Link href='/'>
          <AcmeLogo />
        </Link>
      </Box>
      <div>
        {/* Home, Invoices, Customers のメニュー */}
        <Box height='600px'>
          <NavLinks />
        </Box>

        {/* サインアウト */}
        <form onSubmit={handleLogout}>
          <button type='submit'>
            <PowerIcon
              style={{ width: '2.5rem', height: '2.5rem', marginRight: '10px' }}
            ></PowerIcon>
            <Typography variant='h5'>Sign Out</Typography>
          </button>
        </form>
      </div>
    </Box>
  )
}

src/app/layout.tsx
import React from 'react'
import { inter } from './_component/fonts'
import { Box } from '@mui/material'
import SideNav from './_component/sidenav'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body className={`${inter.className} antialiasing`}>
        <Box display='flex' height='100%' flexDirection='row' overflow='hidden'>
          {/* サイドバー表示部 */}
          <Box width='300px' border='1px solid blue' padding='10px'>
            <SideNav />
          </Box>

          {/* コンテンツ表示部 */}
          <Box>{children}</Box>
        </Box>
      </body>
    </html>
  )
}

image.png

Chapter 5 Navigating Between Pages

src/app/_component/nav-links.tsx
'use client'
import { DocumentDuplicateIcon, HomeIcon, UserGroupIcon } from '@heroicons/react/24/outline'
import { Box, Link, Typography } from '@mui/material'
import { usePathname } from 'next/navigation'
import React from 'react'

// いったん固定値で書くけど本来はdbから引っ張ってきてね
const links = [
  { name: 'Home', href: '/', icon: HomeIcon },
  { name: 'Invoices', href: '/invoices', icon: DocumentDuplicateIcon },
  { name: 'Customers', href: '/customers', icon: UserGroupIcon },
]

export default function NavLinks() {
  const pathname = usePathname()
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon
        return (
          <Link key={link.name} href={link.href}>
            <Box display='flex' flexDirection='row' marginTop='20px'>
              <LinkIcon style={{ width: '2.5rem', height: '2.5rem', marginRight: '10px' }} />
              <Typography
                variant='h6'
                color={pathname === link.href ? 'primary.main' : 'text.primary'}
              >
                {link.name}
              </Typography>
            </Box>
          </Link>
        )
      })}
    </>
  )
}

image.png

Chapter 7 Fetching Data

prisma/schema.prisma(追記)
    :
model Revenue {
  id      Int    @id @default(autoincrement())
  month   String
  revenue Int

  @@map("revenue")
}

model Customer {
  id       Int       @id @default(autoincrement())
  invoices Invoice[]
  name     String
  email    String
  imageUrl String    @map("image_url")

  @@map("customer")
}

model Invoice {
  id         Int      @id @default(autoincrement())
  customer   Customer @relation(fields: [customerId], references: [id])
  customerId Int      @map("customer_id")
  amount     Int
  date       String
  status     String   @default("pending")

  @@map("invoice")
}

console
npx prisma migrate dev --name dashboard
npx prisma generate
src/types/types.ts
export type Revenue = {
  month: string
  revenue: number
}

export type Invoice = {
  id: number
  amount: number
  customer: {
    name: string
    imageUrl: string
    email: string
  }
}

src/app/dashboard/_component/InvoiceDataTable.tsx
'use client'

import React from 'react'
import { Box, Link, Typography } from '@mui/material'
import { DataGrid, GridColDef, GridRenderCellParams, GridRowsProp } from '@mui/x-data-grid'
import { Invoice } from '@/types/types'

export default function InvoiceDataTable({ invoice }: { invoice: Invoice[] }) {
  if (!invoice || invoice.length === 0) {
    return <Typography variant='h5'>No data available.</Typography>
  }

  const rows: GridRowsProp = invoice.map((row) => ({
    id: row.id,
    customerName: row.customer.name,
    amount: row.amount,
  }))

  const columns: GridColDef[] = [
    {
      field: 'id',
      headerName: '請求額No.',
      width: 100,
      renderCell: (params: GridRenderCellParams<any>) => (
        <>
          <Link href={`/invoices/${params.id}`}>{params.value}</Link>
        </>
      ),
    },
    { field: 'customerName', headerName: '名前', width: 150 },
    { field: 'amount', headerName: 'amount', width: 150 },
  ]

  return (
    <Box width='100%'>
      <Typography variant='h6'>Recent Invoice</Typography>
      <DataGrid rows={rows} columns={columns}></DataGrid>
    </Box>
  )
}

src/app/dashboard/_component/RevenueDataTable.tsx
'use client'

import React from 'react'
import { Box, Typography } from '@mui/material'
import { DataGrid, GridColDef, GridRowsProp } from '@mui/x-data-grid'
import { Revenue } from '@/types/types'

export default function RevenueDataTable({ revenue }: { revenue: Revenue[] }) {
  if (!revenue || revenue.length === 0) {
    return <Typography variant='h5'>No data available.</Typography>
  }

  const rows: GridRowsProp = revenue
  const columns: GridColDef[] = [
    {
      field: 'month',
      headerName: '',
      width: 150,
    },
    { field: 'revenue', headerName: '収益', width: 150 },
  ]

  return (
    <Box width='100%'>
      <Typography variant='h6'>Recent Revenue</Typography>
      <DataGrid rows={rows} columns={columns}></DataGrid>
    </Box>
  )
}

src/app/dashboard/page.tsx
import React from 'react'
import { Box, Typography } from '@mui/material'
import InvoiceDataTable from './_component/InvoiceDataTable'
import RevenueDataTable from './_component/RevenueDataTable'

export default async function Page() {
  const response = await fetch('http://localhost:3000/api/dashboard')
  const { revenue, invoice } = await response.json()
  return (
    <Box display='flex' flexDirection='column'>
      <Typography variant='h4'>Dashboard</Typography>
      <Box>
        <RevenueDataTable revenue={revenue}></RevenueDataTable>
      </Box>
      <Box>
        <InvoiceDataTable invoice={invoice}></InvoiceDataTable>
      </Box>
    </Box>
  )
}

src/app/api/dashboard/route.ts
import { PrismaClient } from '@/generated/prismaClient'
import { NextRequest, NextResponse } from 'next/server'
import { serverError } from '@/util/apiHandler'

export async function GET(req: NextRequest) {
  const prisma = new PrismaClient()
  try {
    const invoice = await prisma.invoice.findMany({
      select: {
        amount: true,
        customer: {
          select: {
            name: true,
            imageUrl: true,
            email: true,
          },
        },
        id: true,
      },
      orderBy: {
        date: 'desc',
      },
      take: 5,
    })
    const revenue = await prisma.revenue.findMany()
    return NextResponse.json({ invoice, revenue })
  } catch (error) {
    return serverError(error)
  } finally {
    await prisma.$disconnect()
  }
}

src/app/api/dashboard/route.http
### 200 OK
GET http://localhost:3000/api/dashboard
Accept: application/json

prisma/seeds/seeder.ts
import { PrismaClient } from '@/generated/prismaClient'

async function main() {
  const prisma = new PrismaClient()
  await prisma.users.deleteMany()
  await prisma.users.create({
    data: {
      name: '開発 太郎',
      email: 'test@example.com',
      password: 'hoge',
    },
  })
  await prisma.customer.createMany({
    data: [
      {
        id: 1,
        name: 'Delba de Oliveira',
        email: 'delba@olivaira.com',
        imageUrl: '/customers/delba-de-oliveira.png',
      },
      {
        id: 2,
        name: 'Lee Robinson',
        email: 'lee@robinson.com',
        imageUrl: '/customers/lee-robinson.png',
      },
      {
        id: 3,
        name: 'Hector Simpson',
        email: 'hector@simpson.com',
        imageUrl: '/customers/hector-simpson.png',
      },
      {
        id: 4,
        name: 'Steven Tey',
        email: 'steven@tey.com',
        imageUrl: '/customers/steven-tey.png',
      },
      {
        id: 5,
        name: 'Steph Dietz',
        email: 'steph@dietz.com',
        imageUrl: '/customers/steph-diets.png',
      },
      {
        id: 6,
        name: 'Michael Novotny',
        email: 'michael@novotny',
        imageUrl: '/customers/michael-novotny.png',
      },
      {
        id: 7,
        name: 'Evil Rabbit',
        email: 'evil@rabbit.com',
        imageUrl: '/customers/evil-rabbit.png',
      },
      {
        id: 8,
        name: 'Emil komalski',
        email: 'emil@komalski.com',
        imageUrl: '/customers/emil-komalski.png',
      },
      {
        id: 9,
        name: 'Amy Burns',
        email: 'amy@burns.com',
        imageUrl: '/customers/amy-burns.png',
      },
      {
        id: 10,
        name: 'Balazs Orban',
        email: 'balazs@orban.com',
        imageUrl: '/customers/balazs-orban.png',
      },
    ],
  })
  await prisma.invoice.createMany({
    data: [
      {
        customerId: 1,
        amount: 15795,
        date: '2022-11-14',
        status: 'pending',
      },
      {
        customerId: 2,
        amount: 20348,
        date: '2022-11-14',
        status: 'pending',
      },
      {
        customerId: 5,
        amount: 3040,
        date: '2022-10-29',
        status: 'paid',
      },

      {
        customerId: 4,
        amount: 44000,
        date: '2023-09-10',
        status: 'paid',
      },
      {
        customerId: 6,
        amount: 34577,
        date: '2023-09-10',
        status: 'paid',
      },
      {
        customerId: 8,
        amount: 54246,
        date: '2023-07-16',
        status: 'pending',
      },
      {
        customerId: 7,
        amount: 666,
        date: '2023-06-27',
        status: 'pending',
      },
      {
        customerId: 4,
        amount: 32545,
        date: '2023-06-09',
        status: 'paid',
      },
      {
        customerId: 5,
        amount: 1250,
        date: '2023-06-17',
        status: 'paid',
      },
      {
        customerId: 6,
        amount: 8546,
        date: '2023-06-07',
        status: 'paid',
      },
      {
        customerId: 2,
        amount: 500,
        date: '2023-08-19',
        status: 'paid',
      },
      {
        customerId: 6,
        amount: 8945,
        date: '2023-06-03',
        status: 'paid',
      },
      {
        customerId: 3,
        amount: 8945,
        date: '2023-06-18',
        status: 'paid',
      },
      {
        customerId: 1,
        amount: 8945,
        date: '2023-10-04',
        status: 'paid',
      },
      {
        customerId: 3,
        amount: 1000,
        date: '2022-06-05',
        status: 'paid',
      },
    ],
  })
  await prisma.revenue.createMany({
    data: [
      {
        month: 'Jan',
        revenue: 2000,
      },
      {
        month: 'Feb',
        revenue: 1800,
      },
      {
        month: 'Mar',
        revenue: 2200,
      },
      {
        month: 'Apr',
        revenue: 2500,
      },
      {
        month: 'May',
        revenue: 2300,
      },
      {
        month: 'Jun',
        revenue: 3200,
      },
      {
        month: 'Jul',
        revenue: 3500,
      },
      {
        month: 'Aug',
        revenue: 3700,
      },
      {
        month: 'Sep',
        revenue: 2500,
      },
      {
        month: 'Oct',
        revenue: 2800,
      },
      {
        month: 'Nov',
        revenue: 3000,
      },
      {
        month: 'Dec',
        revenue: 4800,
      },
    ],
  })
  prisma.$disconnect()
}
main().then(() => console.log('seeding ok.'))

いったん休憩

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3