From d818c7d9536b733a345f0639353f4b1b9ec3ce7e Mon Sep 17 00:00:00 2001 From: Flavio Silva Date: Fri, 21 Jul 2023 10:57:59 -0300 Subject: [PATCH] feat(users): Add public.users table and new users to it Add public.users table because supabase auth.users is private. Add users to it after sign up. Handle database migrations and queries with prisma. Add prisma (CLI) and @prisma/client dependencies. --- .github/workflows/deploy.yaml | 25 ++++++++ .github/workflows/preview-deploy.yaml | 16 +++++ .github/workflows/production-deploy.yaml | 16 +++++ package-lock.json | 50 +++++++++++++++ package.json | 7 ++- .../20230721144417_init/migration.sql | 17 +++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 21 +++++++ src/app/app/shared/user/user-model.ts | 24 +++++++ src/app/auth/callback/route.ts | 16 +++++ .../auth/sign-in/check-email-link/page.tsx | 6 +- src/app/auth/sign-in/page.tsx | 6 +- src/lib/database.types.ts | 61 +++++++++++++++++- src/temp-fix/tailwind-datepicker-react.d.ts | 62 +++++++++++++++++++ 14 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/deploy.yaml create mode 100644 .github/workflows/preview-deploy.yaml create mode 100644 .github/workflows/production-deploy.yaml create mode 100644 prisma/migrations/20230721144417_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/app/app/shared/user/user-model.ts create mode 100644 src/temp-fix/tailwind-datepicker-react.d.ts diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..29925fa --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,25 @@ +name: deploy +run-name: Reusable deploy workflow to deploy database changes to the target Supabase database. +on: + workflow_call: + secrets: + DATABASE_URL: + required: true + type: string + DATABASE_DIRECT_URL: + required: true + type: string +jobs: + prisma-migrate-deploy: + name: Run prisma migrations + runs-on: ubuntu-latest + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_DIRECT_URL: ${{ secrets.DATABASE_DIRECT_URL }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + cache: 'npm' + node-version-file: '.nvmrc' + - run: npx prisma@5.0.0 migrate deploy diff --git a/.github/workflows/preview-deploy.yaml b/.github/workflows/preview-deploy.yaml new file mode 100644 index 0000000..e94d41f --- /dev/null +++ b/.github/workflows/preview-deploy.yaml @@ -0,0 +1,16 @@ +name: staging-deploy +run-name: Deploy database changes to Supabase's Staging database. +on: + push: + branches-ignore: + - 'main' +jobs: + call-deploy-workflow: + name: Call reusable deploy workflow + runs-on: ubuntu-latest + environment: + name: staging + uses: ./.github/workflows/deploy.yaml + secrets: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_DIRECT_URL: ${{ secrets.DATABASE_DIRECT_URL }} diff --git a/.github/workflows/production-deploy.yaml b/.github/workflows/production-deploy.yaml new file mode 100644 index 0000000..3eaa525 --- /dev/null +++ b/.github/workflows/production-deploy.yaml @@ -0,0 +1,16 @@ +name: production-deploy +run-name: Deploy database changes to Supabase's Production database. +on: + push: + branches-ignore: + - 'main' +jobs: + call-deploy-workflow: + name: Call reusable deploy workflow + runs-on: ubuntu-latest + environment: + name: production + uses: ./.github/workflows/deploy.yaml + secrets: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_DIRECT_URL: ${{ secrets.DATABASE_DIRECT_URL }} diff --git a/package-lock.json b/package-lock.json index f25c252..978097c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@headlessui/react": "1.7.15", + "@prisma/client": "5.0.0", "@supabase/auth-helpers-nextjs": "0.7.2", "@supabase/supabase-js": "2.26.0", "@tailwindcss/forms": "0.5.3", @@ -34,6 +35,7 @@ "typescript": "5.1.3" }, "devDependencies": { + "prisma": "5.0.0", "supabase": "1.77.9" } }, @@ -403,6 +405,38 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@prisma/client": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.0.0.tgz", + "integrity": "sha512-XlO5ELNAQ7rV4cXIDJUNBEgdLwX3pjtt9Q/RHqDpGf43szpNJx2hJnggfFs7TKNx0cOFsl6KJCSfqr5duEU/bQ==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines-version": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584" + }, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/engines": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.0.0.tgz", + "integrity": "sha512-kyT/8fd0OpWmhAU5YnY7eP31brW1q1YrTGoblWrhQJDiN/1K+Z8S1kylcmtjqx5wsUGcP1HBWutayA/jtyt+sg==", + "devOptional": true, + "hasInstallScript": true + }, + "node_modules/@prisma/engines-version": { + "version": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584.tgz", + "integrity": "sha512-HHiUF6NixsldsP3JROq07TYBLEjXFKr6PdH8H4gK/XAoTmIplOJBCgrIUMrsRAnEuGyRoRLXKXWUb943+PFoKQ==" + }, "node_modules/@rushstack/eslint-patch": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.3.2.tgz", @@ -3786,6 +3820,22 @@ } } }, + "node_modules/prisma": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.0.0.tgz", + "integrity": "sha512-KYWk83Fhi1FH59jSpavAYTt2eoMVW9YKgu8ci0kuUnt6Dup5Qy47pcB4/TLmiPAbhGrxxSz7gsSnJcCmkyPANA==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.0.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index 313e31f..14212a0 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,14 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "prisma generate && next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "postinstall": "prisma generate" }, "dependencies": { "@headlessui/react": "1.7.15", + "@prisma/client": "5.0.0", "@supabase/auth-helpers-nextjs": "0.7.2", "@supabase/supabase-js": "2.26.0", "@tailwindcss/forms": "0.5.3", @@ -35,6 +37,7 @@ "typescript": "5.1.3" }, "devDependencies": { + "prisma": "5.0.0", "supabase": "1.77.9" } } diff --git a/prisma/migrations/20230721144417_init/migration.sql b/prisma/migrations/20230721144417_init/migration.sql new file mode 100644 index 0000000..0d58197 --- /dev/null +++ b/prisma/migrations/20230721144417_init/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "email" VARCHAR(254) NOT NULL, + "name" VARCHAR(500), + "provider" VARCHAR(100) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_provider_key" ON "User"("provider"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..020869f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,21 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DATABASE_DIRECT_URL") +} + +generator client { + provider = "prisma-client-js" +} + +// We use Supabase's authentication solution, which create users in a private "auth" schema. +// So we create this User table in our "public" schema and add users to it after +// they're created by Supabase's auth solution, using the same "id". +model User { + id String @id @db.Uuid + email String @unique @db.VarChar(254) + name String? @db.VarChar(500) + provider String @unique @db.VarChar(100) + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt +} diff --git a/src/app/app/shared/user/user-model.ts b/src/app/app/shared/user/user-model.ts new file mode 100644 index 0000000..2e5dacc --- /dev/null +++ b/src/app/app/shared/user/user-model.ts @@ -0,0 +1,24 @@ +import { PrismaClient, User } from '@prisma/client'; + +export interface CreateUserData { + readonly email: string; + readonly id: string; + readonly name?: string; + readonly provider: string; +} + +export const createUser = async (data: CreateUserData) => { + const prisma = new PrismaClient(); + + const user = await prisma.user.findUnique({ where: { id: data.id } }); + + if (user) return user; + + const newUser = await prisma.user.create({ data }); + return newUser; +}; + +export const findUserById = async (id: string) => { + const prisma = new PrismaClient(); + return await prisma.user.findUnique({ where: { id } }); +}; diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 303e422..515b89c 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import type { Database } from '@/lib/database.types'; +import { createUser } from '@/app/app/shared/user/user-model'; export async function GET(request: NextRequest) { const requestUrl = new URL(request.url); @@ -12,6 +13,21 @@ export async function GET(request: NextRequest) { if (code) { const supabase = createRouteHandlerClient({ cookies }); await supabase.auth.exchangeCodeForSession(code); + + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (session) { + const { user } = session; + + createUser({ + id: user.id, + email: user.email as string, + name: user.user_metadata.full_name as string, + provider: user.app_metadata.provider as string, + }); + } } // URL to redirect to after sign in process completes diff --git a/src/app/auth/sign-in/check-email-link/page.tsx b/src/app/auth/sign-in/check-email-link/page.tsx index 8db503e..35a7340 100644 --- a/src/app/auth/sign-in/check-email-link/page.tsx +++ b/src/app/auth/sign-in/check-email-link/page.tsx @@ -13,10 +13,8 @@ export default function CheckEmailLink({ searchParams }: { searchParams: { email

Please check your email

-

You're almost there!

-

- We just emailed a link to {getEmailText()}. Click the link, and you'll be signed in. -

+

We just emailed a link to {getEmailText()}.

+

Click on it, and you'll be signed in.

If you don't see it, you may need to{' '} check your spam folder. diff --git a/src/app/auth/sign-in/page.tsx b/src/app/auth/sign-in/page.tsx index f36e809..91f8857 100644 --- a/src/app/auth/sign-in/page.tsx +++ b/src/app/auth/sign-in/page.tsx @@ -20,6 +20,8 @@ interface OAuthProviderButtonProps extends ChildrenProps { } export default function SignIn() { + const redirectTo = `${process.env.NEXT_PUBLIC_VERCEL_URL}/auth/callback`; + const signInWithEmailHandler = async (formData: FormData) => { 'use server'; const email = String(formData.get('email')); @@ -29,7 +31,7 @@ export default function SignIn() { const { data, error } = await supabase.auth.signInWithOtp({ email, options: { - emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, + emailRedirectTo: redirectTo, }, }); @@ -50,7 +52,7 @@ export default function SignIn() { const { data, error } = await supabase.auth.signInWithOAuth({ provider, options: { - redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, + redirectTo, }, }); diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index a6ae0b0..f087005 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -34,7 +34,66 @@ export interface Database { } public: { Tables: { - [_ in never]: never + _prisma_migrations: { + Row: { + applied_steps_count: number + checksum: string + finished_at: string | null + id: string + logs: string | null + migration_name: string + rolled_back_at: string | null + started_at: string + } + Insert: { + applied_steps_count?: number + checksum: string + finished_at?: string | null + id: string + logs?: string | null + migration_name: string + rolled_back_at?: string | null + started_at?: string + } + Update: { + applied_steps_count?: number + checksum?: string + finished_at?: string | null + id?: string + logs?: string | null + migration_name?: string + rolled_back_at?: string | null + started_at?: string + } + Relationships: [] + } + User: { + Row: { + createdAt: string + email: string + id: string + name: string | null + provider: string + updatedAt: string | null + } + Insert: { + createdAt?: string + email: string + id: string + name?: string | null + provider: string + updatedAt?: string | null + } + Update: { + createdAt?: string + email?: string + id?: string + name?: string | null + provider?: string + updatedAt?: string | null + } + Relationships: [] + } } Views: { [_ in never]: never diff --git a/src/temp-fix/tailwind-datepicker-react.d.ts b/src/temp-fix/tailwind-datepicker-react.d.ts new file mode 100644 index 0000000..bc23efd --- /dev/null +++ b/src/temp-fix/tailwind-datepicker-react.d.ts @@ -0,0 +1,62 @@ +/* + * Flavio Silva on July 22, 2023: + * Temporary fix. + * Issue: + * https://github.com/OMikkel/tailwind-datepicker-react/issues/19 + * Solution provided here: + * https://github.com/OMikkel/tailwind-datepicker-react/issues/19#issuecomment-1621474204 + */ +declare module 'tailwind-datepicker-react' { + import { ReactElement } from 'react'; + + interface ITheme { + background?: string; + todayBtn?: string; + clearBtn?: string; + icons?: string; + text?: string; + disabledText?: string; + input?: string; + inputIcon?: string; + selected?: string; + } + + interface IIcons { + prev: () => ReactElement; + next: () => ReactElement; + } + + export interface IOptions { + title?: string; + autoHide?: boolean; + todayBtn?: boolean; + todayBtnText?: string; + clearBtn?: boolean; + clearBtnText?: string; + maxDate?: Date; + minDate?: Date; + theme?: ITheme; + icons?: IIcons; + datepickerClassNames?: string; + defaultDate?: Date | null; + language?: string; + weekDays?: string[]; + disabledDates?: Date[]; + inputNameProp?: string; + inputIdProp?: string; + inputPlaceholderProp?: string; + inputDateFormatProp?: Intl.DateTimeFormatOptions; + } + + type DatepickerProps = { + children?: ReactElement | ReactNode; + options?: IOptions; + onChange?: (date: Date) => void; + show: boolean; + setShow: (show: boolean) => void; + classNames?: string; + selectedDateState?: [Date, (date: Date) => void]; + }; + + export default function Datepicker(props: DatepickerProps): ReactElement; +}