From a659401bde472dc832b3b98a008fe87abb1c2073 Mon Sep 17 00:00:00 2001 From: Brian Nelson Date: Tue, 24 Jun 2025 16:31:13 -0400 Subject: [PATCH] user creation and invites --- .env.example | 10 ++- README.md | 29 +++++++- app/(auth)/admin/create-user/page.tsx | 31 ++++++++ app/(auth)/dashboard/page.tsx | 22 +++--- app/(auth)/events/[eventId]/page.tsx | 10 ++- app/(auth)/events/page.tsx | 24 ++++++- app/(auth)/user/[username]/page.tsx | 35 +++++++++ app/(public)/invite/accept/page.tsx | 12 ++++ app/(public)/setup/page.tsx | 12 +++- app/(public)/signup/page.tsx | 23 ++++++ app/api/admin/create-user/route.ts | 19 +++++ app/api/auth/[...nextauth]/route.ts | 9 +-- app/api/invite/send/route.ts | 52 ++++++++++++++ app/api/invite/validate/route.ts | 20 ++++++ app/api/setup/route.ts | 10 ++- app/api/signup/from-invite/route.ts | 55 ++++++++++++++ app/api/test-email/route.ts | 12 ++++ bun.lockb | Bin 176775 -> 177860 bytes components/Navbar.tsx | 10 +-- components/SendInviteForm.tsx | 62 ++++++++++++++++ components/SignupForm.tsx | 68 ++++++++++++++++++ lib/email.ts | 38 ++++++++++ lib/invite.ts | 32 +++++++++ lib/mutations.ts | 28 +++++++- lib/queries.ts | 13 ++++ package.json | 3 + .../20250624131750_add_username/migration.sql | 11 +++ .../20250624133959_add_username/migration.sql | 8 +++ .../migration.sql | 16 +++++ .../migration.sql | 8 +++ prisma/schema.prisma | 12 ++++ types/next-auth.d.ts | 3 + 32 files changed, 667 insertions(+), 30 deletions(-) create mode 100644 app/(auth)/admin/create-user/page.tsx create mode 100644 app/(auth)/user/[username]/page.tsx create mode 100644 app/(public)/invite/accept/page.tsx create mode 100644 app/(public)/signup/page.tsx create mode 100644 app/api/admin/create-user/route.ts create mode 100644 app/api/invite/send/route.ts create mode 100644 app/api/invite/validate/route.ts create mode 100644 app/api/signup/from-invite/route.ts create mode 100644 app/api/test-email/route.ts create mode 100644 components/SendInviteForm.tsx create mode 100644 components/SignupForm.tsx create mode 100644 lib/email.ts create mode 100644 lib/invite.ts create mode 100644 prisma/migrations/20250624131750_add_username/migration.sql create mode 100644 prisma/migrations/20250624133959_add_username/migration.sql create mode 100644 prisma/migrations/20250624161651_invite_tokens/migration.sql create mode 100644 prisma/migrations/20250624193636_make_email_unique/migration.sql diff --git a/.env.example b/.env.example index ee81ea5..088e8d0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,11 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wedding_planner NEXTAUTH_SECRET=your-secret -NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file +NEXTAUTH_URL=http://localhost:3000 +NEXT_PUBLIC_BASE_URL=http://localhost:3000 + +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your@email.com +SMTP_PASS=yourpassword +SMTP_FROM_NAME=Wedding Planner +SMTP_FROM_EMAIL=your@email.com \ No newline at end of file diff --git a/README.md b/README.md index 2937010..92464fa 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,15 @@ My goal for this project is to be an all-in-one self hosted event planner for ma ## Table of Contents - [Planned Features](#planned-features) +- [Updates](#updates) - [Getting Started](#getting-started) - [Built With](#built-with) ## Planned Features - [x] Account Creation - [x] First time setup to create the admin user - - [ ] Invite partner and or "Planner" via email (smtp) + - [x] Invite users via email (smtp) users can be COUPLE, PLANNER, GUEST + - [x] Create local accounts (no use of SMTP) - [ ] Creating custom events - [ ] Information about each event - Date/Time @@ -29,6 +31,15 @@ My goal for this project is to be an all-in-one self hosted event planner for ma - Seating Charts - Calendar/Timeline Builder +## Updates +#### 6.24.25 +- added ability to invite users via email making use of a smtp server and nodemailer + - inviteTokens added to db which are used to sign up and expire onDate and after use +- added ability to create local users if you don't want to use smtp `/admin/create-user` +- created user pages +- added usernames to `Users` table +- updated first time setup to include username creation + ## Getting Started This is very much a work in progress but this `README` will stay up to date on working features and steps to get it running **in its current state**. That being said if you're interested in starting it as is, you can follow these instructions. @@ -42,6 +53,14 @@ git clone https://github.com/briannelson95/wedding-planner.git DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wedding_planner NEXTAUTH_SECRET=your-secret NEXTAUTH_URL=http://localhost:3000 +NEXT_PUBLIC_BASE_URL=http://localhost:3000 + +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your@email.com +SMTP_PASS=yourpassword +SMTP_FROM_NAME=Wedding Planner +SMTP_FROM_EMAIL=your@email.com ``` 3. Start the database @@ -49,7 +68,13 @@ NEXTAUTH_URL=http://localhost:3000 docker compose up -d ``` -4. Install dependencies and start the front end with `npm i && npm run dev` or `bun i && bun dev` +4. Migrate and Generate the Prima files +``` +npx prisma migrate dev --name init +npx prisma generate +``` + +5. Install dependencies and start the front end with `npm i && npm run dev` or `bun i && bun dev` ## Built With - NextJS 15 diff --git a/app/(auth)/admin/create-user/page.tsx b/app/(auth)/admin/create-user/page.tsx new file mode 100644 index 0000000..c0ac47b --- /dev/null +++ b/app/(auth)/admin/create-user/page.tsx @@ -0,0 +1,31 @@ +'use client' +import { useState } from 'react' + +export default function CreateUserPage() { + const [form, setForm] = useState({ username: '', password: '', role: 'GUEST' }) + const [success, setSuccess] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + await fetch('/api/admin/create-user', { + method: 'POST', + body: JSON.stringify(form), + }) + setSuccess(true) + } + + return ( +
+

Create Local User

+ setForm({ ...form, username: e.target.value })} /> + setForm({ ...form, password: e.target.value })} /> + + + {success &&

User created

} +
+ ) +} diff --git a/app/(auth)/dashboard/page.tsx b/app/(auth)/dashboard/page.tsx index 0696fb4..0a217c9 100644 --- a/app/(auth)/dashboard/page.tsx +++ b/app/(auth)/dashboard/page.tsx @@ -4,7 +4,7 @@ import React from 'react' export default async function DashboardPage() { const events = await queries.fetchEvents() - console.log(events) + // console.log(events) return (
@@ -12,15 +12,17 @@ export default async function DashboardPage() {

Your Events

{events.map((item) => ( -
-

ID: {item.id}

-

Name: {item.name}

-

Date: {item.date ? item.date.toISOString() : 'null'}

-

Location: {item.location ? item.location : 'null'}

-

Creator ID:{item.creatorId}

-

Created At:{item.createdAt.toISOString()}

- -
+ +
+

ID: {item.id}

+

Name: {item.name}

+

Date: {item.date ? item.date.toISOString() : 'null'}

+

Location: {item.location ? item.location : 'null'}

+

Creator ID:{item.creatorId}

+

Created At:{item.createdAt.toISOString()}

+ +
+ ))} - SINGLE EVENT PAGE +

+ {data?.name} +

) } diff --git a/app/(auth)/events/page.tsx b/app/(auth)/events/page.tsx index 1f97056..b50e4b3 100644 --- a/app/(auth)/events/page.tsx +++ b/app/(auth)/events/page.tsx @@ -10,10 +10,32 @@ export default async function EventsPage() {
Events
- {allEvents.length == 0 && ( + {allEvents.length == 0 ? ( <> You don't have any events yet. Create One! + ) : ( + + + + + + + + + + {allEvents.map((item) => ( + + + + + + ))} + +
Event NameEvent DateCreated by
{item.name}{item.date?.toDateString()}{item.creatorId}
)}
diff --git a/app/(auth)/user/[username]/page.tsx b/app/(auth)/user/[username]/page.tsx new file mode 100644 index 0000000..2725f60 --- /dev/null +++ b/app/(auth)/user/[username]/page.tsx @@ -0,0 +1,35 @@ +import SendInviteForm from '@/components/SendInviteForm'; +import { prisma } from '@/lib/prisma'; +import { notFound } from 'next/navigation'; +import React from 'react' + +export default async function UserPage({ params }: { params: { username: string } }) { + const raw = params.username + const username = raw.startsWith('@') ? raw.slice(1) : raw + + const user = await prisma.user.findUnique({ + where: { username }, + select: { + id: true, + email: true, + name: true, + role: true, + createdAt: true, + }, + }) + + if (!user) notFound() + + return ( + <> +
+

@{username}

+

Email: {user.email}

+

Role: {user.role}

+

Joined: {user.createdAt.toDateString()}

+
+

Invite More People

+ + + ) +} diff --git a/app/(public)/invite/accept/page.tsx b/app/(public)/invite/accept/page.tsx new file mode 100644 index 0000000..3477bfa --- /dev/null +++ b/app/(public)/invite/accept/page.tsx @@ -0,0 +1,12 @@ +import { verifyInvite } from '@/lib/invite'; +import { redirect } from 'next/navigation'; + +export default async function AcceptInvitePage({ searchParams }: { searchParams: { token?: string } }) { + const invite = searchParams.token ? await verifyInvite(searchParams.token) : null + + if (!invite || invite.accepted || new Date(invite.expiresAt) < new Date()) { + return
Invalid or expired invitation.
+ } + + redirect(`/signup?token=${invite.token}`) +} \ No newline at end of file diff --git a/app/(public)/setup/page.tsx b/app/(public)/setup/page.tsx index 77973ce..637c66b 100644 --- a/app/(public)/setup/page.tsx +++ b/app/(public)/setup/page.tsx @@ -7,6 +7,7 @@ export default function SetupPage() { const [role, setRole] = useState<'COUPLE' | 'PLANNER' | null>(null); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [username, setUsername] = useState(''); async function handleSetup(e:React.FormEvent) { e.preventDefault(); @@ -31,13 +32,13 @@ export default function SetupPage() { className='hover:cursor-pointer dark:bg-white dark:text-black rounded px-4 py-2' onClick={() => setRole('COUPLE')} > - I'm part of the Couple + I'm part of the Couple
); @@ -45,6 +46,13 @@ export default function SetupPage() { return (

Create your account ({role})

+ setUsername(e.target.value)} + required + /> Invalid or expired invitation. + } + + return ( +
+

Complete Your Signup

+ +
+ ) +} diff --git a/app/api/admin/create-user/route.ts b/app/api/admin/create-user/route.ts new file mode 100644 index 0000000..3827f83 --- /dev/null +++ b/app/api/admin/create-user/route.ts @@ -0,0 +1,19 @@ +import { mutations } from '@/lib/mutations' +import { NextRequest, NextResponse } from 'next/server' + +export async function POST(req: NextRequest) { + const body = await req.json() + + if (!body.username || !body.password || !body.role) { + return new NextResponse('Missing fields', { status: 400 }) + } + + const user = await mutations.createUser({ + username: body.username, + password: body.password, + role: body.role, + email: '', + }) + + return NextResponse.json({ id: user.id }) +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index f58d6e0..8a35123 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -15,7 +15,6 @@ export const authOptions: NextAuthOptions = { }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) { - console.log('[AUTH] Missing credentials') return null } @@ -24,28 +23,24 @@ export const authOptions: NextAuthOptions = { }) if (!user) { - console.log('[AUTH] User not found') return null } if (!user.password) { - console.log('[AUTH] User has no password set') return null } const isValid = await bcrypt.compare(credentials.password, user.password) if (!isValid) { - console.log('[AUTH] Invalid password') return null } - console.log('[AUTH] Successful login', user.email) - return { id: user.id, email: user.email, name: user.name, role: user.role, + username: user.username!, } }, }), @@ -58,6 +53,7 @@ export const authOptions: NextAuthOptions = { if (user) { token.id = user.id token.role = user.role + token.username = user.username } return token }, @@ -65,6 +61,7 @@ export const authOptions: NextAuthOptions = { if (session.user) { session.user.id = token.id as string session.user.role = token.role as "COUPLE" | "PLANNER" | "GUEST" + session.user.username = token.username as string } return session }, diff --git a/app/api/invite/send/route.ts b/app/api/invite/send/route.ts new file mode 100644 index 0000000..8f514fa --- /dev/null +++ b/app/api/invite/send/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '../../auth/[...nextauth]/route' +import { sendInviteEmail } from '@/lib/email' +import { createInvite } from '@/lib/invite' +import { prisma } from '@/lib/prisma' + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user || !['COUPLE', 'PLANNER'].includes(session.user.role)) { + return new NextResponse('Unauthorized', { status: 403 }) + } + + const { email, role } = await req.json() + if (!email || !role) { + return NextResponse.json({ message: 'Missing email or role' }, { status: 400 }) + } + + const existingUser = await prisma.user.findUnique({ where: { email } }) + if (existingUser) { + return NextResponse.json({ message: 'User with this email already exists' }, { status: 400 }) + } + + const existingInvite = await prisma.inviteToken.findFirst({ + where: { + email, + accepted: false, + }, + }) + + if (existingInvite) { + return NextResponse.json({ message: 'An invite already exists for this email' }, { status: 400 }) + } + + const invite = await createInvite({ email, role }) + const inviteUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/invite/accept?token=${invite.token}` + + await sendInviteEmail({ + to: email, + inviterName: session.user.email || 'A wedding planner', + inviteUrl, + role, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('[INVITE SEND ERROR]', error) + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/app/api/invite/validate/route.ts b/app/api/invite/validate/route.ts new file mode 100644 index 0000000..05690eb --- /dev/null +++ b/app/api/invite/validate/route.ts @@ -0,0 +1,20 @@ +// app/api/invite/validate/route.ts +import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server' + +export async function POST(req: NextRequest) { + const { token } = await req.json() + + const invite = await prisma.inviteToken.findUnique({ + where: { token }, + }) + + if (!invite || invite.accepted) { + return new NextResponse('Invalid or expired invite', { status: 400 }) + } + + return NextResponse.json({ + email: invite.email, + role: invite.role, + }) +} diff --git a/app/api/setup/route.ts b/app/api/setup/route.ts index f628037..beba9cc 100644 --- a/app/api/setup/route.ts +++ b/app/api/setup/route.ts @@ -3,7 +3,14 @@ import { prisma } from '@/lib/prisma'; import bcrypt from 'bcrypt'; export async function POST(req: NextRequest) { - const { email, password, role } = await req.json(); + const { email, username, password, role } = await req.json(); + + const existingUsername = await prisma.user.findUnique({ + where: { username }, + }) + if (existingUsername) { + return new NextResponse('Username already taken', { status: 400 }) + } const existing = await prisma.user.findUnique({ where: { email }}); if (existing) return new NextResponse('User already exists', { status: 400 }); @@ -12,6 +19,7 @@ export async function POST(req: NextRequest) { const user = await prisma.user.create({ data: { email, + username, password: hashed, role } diff --git a/app/api/signup/from-invite/route.ts b/app/api/signup/from-invite/route.ts new file mode 100644 index 0000000..5809329 --- /dev/null +++ b/app/api/signup/from-invite/route.ts @@ -0,0 +1,55 @@ +import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server' +import bcrypt from 'bcrypt' + +export async function POST(req: NextRequest) { + try { + const { token, username, password } = await req.json() + + if (!token || !username || !password) { + return NextResponse.json({ message: 'Missing fields' }, { status: 400 }) + } + + const invite = await prisma.inviteToken.findUnique({ + where: { token }, + }) + + if (!invite || invite.accepted || new Date(invite.expiresAt) < new Date()) { + return NextResponse.json({ message: 'Invalid or expired invite' }, { status: 400 }) + } + + const existingUser = await prisma.user.findFirst({ + where: { + OR: [ + { email: invite.email }, + { username }, + ], + }, + }) + + if (existingUser) { + return NextResponse.json({ message: 'A user with this email or username already exists' }, { status: 400 }) + } + + const hashedPassword = await bcrypt.hash(password, 10) + + await prisma.user.create({ + data: { + email: invite.email, + username, + role: invite.role, + password: hashedPassword, + }, + }) + + await prisma.inviteToken.update({ + where: { token }, + data: { accepted: true }, + }) + + return NextResponse.json({ success: true }) + } catch (err) { + console.error('[SIGNUP ERROR]', err) + return new NextResponse('Internal Server Error', { status: 500 }) + } +} diff --git a/app/api/test-email/route.ts b/app/api/test-email/route.ts new file mode 100644 index 0000000..acf0929 --- /dev/null +++ b/app/api/test-email/route.ts @@ -0,0 +1,12 @@ +import { sendInviteEmail } from '@/lib/email' +import { NextResponse } from 'next/server' + +export async function GET() { + await sendInviteEmail({ + to: 'brian@briannelson.dev', + token: 'testtoken123', + inviterName: 'Test Admin', + }) + + return NextResponse.json({ status: 'sent' }) +} diff --git a/bun.lockb b/bun.lockb index 15d497708d6067c5abe819587502a8c30043a60e..81907f5fea626fd01f87ea951e130bd4b2d6111d 100755 GIT binary patch delta 33551 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 jSbO7B?ae4-
- - - {session.user.email} ({session.user.role}) - + + + + {session.user.email} ({session.user.role}) + + + + ) +} diff --git a/components/SignupForm.tsx b/components/SignupForm.tsx new file mode 100644 index 0000000..f1e06b2 --- /dev/null +++ b/components/SignupForm.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' + +interface Props { + invite: { + token: string + email: string + role: 'COUPLE' | 'PLANNER' | 'GUEST' + } +} + +export default function SignupForm({ invite }: Props) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const router = useRouter() + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + const res = await fetch('/api/signup/from-invite', { + method: 'POST', + body: JSON.stringify({ token: invite.token, username, password }), + headers: { 'Content-Type': 'application/json' }, + }) + + if (res.ok) { + router.push('/login') + } else { + const { message } = await res.json() + setError(message || 'Signup failed') + } + } + + return ( +
+

+ Invited as {invite.email} ({invite.role}) +

+ + setUsername(e.target.value)} + required + /> + + setPassword(e.target.value)} + required + /> + + {error &&

{error}

} + + +
+ ) +} diff --git a/lib/email.ts b/lib/email.ts new file mode 100644 index 0000000..b551cd4 --- /dev/null +++ b/lib/email.ts @@ -0,0 +1,38 @@ +import nodemailer from 'nodemailer' + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + } +}) + +export async function sendInviteEmail({ + to, + inviterName, + inviteUrl, + role, +}: { + to: string + inviterName: string + inviteUrl: string + role: string +}) { + const fromName = process.env.SMTP_FROM_NAME || 'Wedding Planner' + const fromEmail = process.env.SMTP_FROM_EMAIL || 'noreply@example.com' + + await transporter.sendMail({ + from: `"${fromName}" <${fromEmail}>`, + to, + subject: `${inviterName} invited you to join their wedding planner`, + html: ` +

Hello!

+

${inviterName} invited you to join their wedding planning space as a ${role}.

+

Click here to accept your invite

+

This link will expire in 72 hours.

+ `, + }) +} \ No newline at end of file diff --git a/lib/invite.ts b/lib/invite.ts new file mode 100644 index 0000000..cd1ac51 --- /dev/null +++ b/lib/invite.ts @@ -0,0 +1,32 @@ +import { prisma } from './prisma'; +import { randomBytes } from 'crypto'; + +export async function createInvite({ + email, + role, + eventId +}: { + email: string + role: 'COUPLE' | 'PLANNER' | 'GUEST' + eventId?: string +}) { + const token = randomBytes(32).toString('hex'); + + const invite = await prisma.inviteToken.create({ + data: { + email, + role, + token, + eventId, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + }, + }) + + return invite +} + +export async function verifyInvite(token:string) { + return await prisma.inviteToken.findUnique({ + where: { token } + }) +} \ No newline at end of file diff --git a/lib/mutations.ts b/lib/mutations.ts index c97e1e5..3994602 100644 --- a/lib/mutations.ts +++ b/lib/mutations.ts @@ -1,4 +1,5 @@ import { prisma } from './prisma'; +import bcrypt from 'bcrypt' export const mutations = { async createEvent(data: { @@ -30,5 +31,30 @@ export const mutations = { email: data.email, }, }); - } + }, + + async createUser({ + username, + email, + password, + role, + }: { + username: string + email?: string + password: string + role: 'COUPLE' | 'PLANNER' | 'GUEST' + }) { + const hashedPassword = await bcrypt.hash(password, 10) + + const user = await prisma.user.create({ + data: { + username, + email: email || '', + password: hashedPassword, + role, + }, + }) + + return user + }, }; \ No newline at end of file diff --git a/lib/queries.ts b/lib/queries.ts index fd888df..270a517 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -5,5 +5,18 @@ export const queries = { const allEvents = await prisma.event.findMany() console.log(allEvents) return allEvents; + }, + + async singleEvent(id: string) { + const event = await prisma.event.findUnique({ + where: { id }, + include: { + creator: { + select: { id: true, email: true, name: true, role: true }, + }, + guests: true + } + }) + return event } } \ No newline at end of file diff --git a/package.json b/package.json index 61dd651..4e20437 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,12 @@ }, "dependencies": { "@next-auth/prisma-adapter": "^1.0.7", + "@types/nodemailer": "^6.4.17", "bcrypt": "^6.0.0", + "crypto": "^1.0.1", "next": "15.3.4", "next-auth": "^4.24.11", + "nodemailer": "^7.0.3", "prisma": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/prisma/migrations/20250624131750_add_username/migration.sql b/prisma/migrations/20250624131750_add_username/migration.sql new file mode 100644 index 0000000..9e1a38e --- /dev/null +++ b/prisma/migrations/20250624131750_add_username/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "username" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/prisma/migrations/20250624133959_add_username/migration.sql b/prisma/migrations/20250624133959_add_username/migration.sql new file mode 100644 index 0000000..9a69639 --- /dev/null +++ b/prisma/migrations/20250624133959_add_username/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `username` on table `User` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "username" SET NOT NULL; diff --git a/prisma/migrations/20250624161651_invite_tokens/migration.sql b/prisma/migrations/20250624161651_invite_tokens/migration.sql new file mode 100644 index 0000000..5f94e06 --- /dev/null +++ b/prisma/migrations/20250624161651_invite_tokens/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "InviteToken" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "Role" NOT NULL, + "token" TEXT NOT NULL, + "eventId" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "accepted" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "InviteToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "InviteToken_token_key" ON "InviteToken"("token"); diff --git a/prisma/migrations/20250624193636_make_email_unique/migration.sql b/prisma/migrations/20250624193636_make_email_unique/migration.sql new file mode 100644 index 0000000..13dd289 --- /dev/null +++ b/prisma/migrations/20250624193636_make_email_unique/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[email]` on the table `InviteToken` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "InviteToken_email_key" ON "InviteToken"("email"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4a1629f..e1829bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,6 +12,7 @@ model User { email String @unique password String? // hashed password name String? + username String @unique role Role @default(GUEST) events Event[] @relation("EventCreator") createdAt DateTime @default(now()) @@ -48,3 +49,14 @@ enum RsvpStatus { NO PENDING } + +model InviteToken { + id String @id @default(cuid()) + email String @unique + role Role + token String @unique + eventId String? + expiresAt DateTime + accepted Boolean @default(false) + createdAt DateTime @default(now()) +} diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index 618203b..f8d6db4 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -6,16 +6,19 @@ declare module "next-auth" { id: string email: string role: "COUPLE" | "PLANNER" | "GUEST" + username: string } } interface User { id: string role: "COUPLE" | "PLANNER" | "GUEST" + username: string } interface JWT { id: string role: "COUPLE" | "PLANNER" | "GUEST" + username: string } }