Initial commit v1.1
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { magicLinkClient } from 'better-auth/client/plugins'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [magicLinkClient()],
|
||||
})
|
||||
|
||||
export type { Session } from 'better-auth/types'
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { magicLink } from 'better-auth/plugins'
|
||||
import { db } from './db'
|
||||
import * as schema from './db/schema'
|
||||
import { sendEmail } from './email'
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
user: schema.user,
|
||||
session: schema.session,
|
||||
verification: schema.verification,
|
||||
},
|
||||
}),
|
||||
|
||||
// Disable password-based signup — invite-only, magic link only
|
||||
emailAndPassword: { enabled: false },
|
||||
|
||||
plugins: [
|
||||
magicLink({
|
||||
disableSignUp: true, // only existing (invited) users can sign in
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Sign in to MulliganMates',
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||
<div style="font-size:32px;margin-bottom:8px">⛳</div>
|
||||
<h1 style="font-size:24px;font-weight:700;color:#16a34a;margin:0 0 8px">MulliganMates</h1>
|
||||
<p style="color:#374151;margin:0 0 24px">Click the button below to sign in. This link expires in 1 hour.</p>
|
||||
<a href="${url}"
|
||||
style="display:inline-block;background:#16a34a;color:#fff;text-decoration:none;
|
||||
font-weight:600;font-size:16px;padding:14px 28px;border-radius:8px">
|
||||
Sign in
|
||||
</a>
|
||||
<p style="color:#6b7280;font-size:13px;margin-top:24px">
|
||||
Or copy this link:<br>
|
||||
<a href="${url}" style="color:#16a34a;word-break:break-all">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
user: {
|
||||
additionalFields: {
|
||||
handicapIndex: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
fieldName: 'handicap_index',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type Session = typeof auth.$Infer.Session
|
||||
@@ -0,0 +1,6 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import * as schema from './schema'
|
||||
|
||||
const client = postgres(process.env.DATABASE_URL!)
|
||||
export const db = drizzle(client, { schema })
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
pgTable, pgEnum, text, boolean, integer,
|
||||
numeric, timestamp, date, unique,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
// ── better-auth managed tables ────────────────────────────────
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: boolean('email_verified').notNull().default(false),
|
||||
image: text('image'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
// App-specific additional field
|
||||
handicapIndex: numeric('handicap_index', { precision: 4, scale: 1 }),
|
||||
})
|
||||
|
||||
export const session = pgTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
})
|
||||
|
||||
export const verification = pgTable('verification', {
|
||||
id: text('id').primaryKey(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at'),
|
||||
updatedAt: timestamp('updated_at'),
|
||||
})
|
||||
|
||||
// ── App tables ────────────────────────────────────────────────
|
||||
export const courses = pgTable('courses', {
|
||||
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||
name: text('name').notNull(),
|
||||
par: integer('par').notNull(),
|
||||
createdBy: text('created_by').notNull().references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const tees = pgTable('tees', {
|
||||
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||
courseId: uuid('course_id').notNull().references(() => courses.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
color: text('color').notNull().default('#888888'),
|
||||
courseRating: numeric('course_rating', { precision: 4, scale: 1 }).notNull(),
|
||||
slopeRating: integer('slope_rating').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const holes = pgTable('holes', {
|
||||
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||
courseId: uuid('course_id').notNull().references(() => courses.id, { onDelete: 'cascade' }),
|
||||
holeNumber: integer('hole_number').notNull(),
|
||||
par: integer('par').notNull(),
|
||||
strokeIndex: integer('stroke_index').notNull(),
|
||||
}, (t) => [unique().on(t.courseId, t.holeNumber)])
|
||||
|
||||
export const roundStatusEnum = pgEnum('round_status', ['lobby', 'active', 'completed'])
|
||||
|
||||
export const rounds = pgTable('rounds', {
|
||||
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||
courseId: uuid('course_id').notNull().references(() => courses.id),
|
||||
teeId: uuid('tee_id').notNull().references(() => tees.id),
|
||||
name: text('name'),
|
||||
date: date('date').notNull().default(sql`current_date`),
|
||||
holesCount: integer('holes_count').notNull(),
|
||||
status: roundStatusEnum('status').notNull().default('lobby'),
|
||||
createdBy: text('created_by').notNull().references(() => user.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const roundPlayers = pgTable('round_players', {
|
||||
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||
roundId: uuid('round_id').notNull().references(() => rounds.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull().default('player'),
|
||||
handicapIndex: numeric('handicap_index', { precision: 4, scale: 1 }),
|
||||
courseHandicap: integer('course_handicap'),
|
||||
joinedAt: timestamp('joined_at').notNull().defaultNow(),
|
||||
}, (t) => [unique().on(t.roundId, t.userId)])
|
||||
|
||||
export const roundInvites = pgTable('round_invites', {
|
||||
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||
roundId: uuid('round_id').notNull().references(() => rounds.id, { onDelete: 'cascade' }),
|
||||
email: text('email').notNull(),
|
||||
invitedBy: text('invited_by').notNull().references(() => user.id),
|
||||
acceptedAt: timestamp('accepted_at'),
|
||||
expiresAt: timestamp('expires_at').notNull().default(sql`now() + interval '7 days'`),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const scores = pgTable('scores', {
|
||||
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
|
||||
roundId: uuid('round_id').notNull().references(() => rounds.id, { onDelete: 'cascade' }),
|
||||
holeId: uuid('hole_id').notNull().references(() => holes.id, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
strokes: integer('strokes').notNull(),
|
||||
updatedBy: text('updated_by').notNull().references(() => user.id),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
}, (t) => [unique().on(t.roundId, t.holeId, t.playerId)])
|
||||
@@ -0,0 +1,28 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST ?? 'mailpit',
|
||||
port: parseInt(process.env.SMTP_PORT ?? '1025'),
|
||||
secure: process.env.SMTP_PORT === '465',
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
}: {
|
||||
to: string
|
||||
subject: string
|
||||
html: string
|
||||
}) {
|
||||
await transporter.sendMail({
|
||||
from: `MulliganMates <${process.env.SMTP_FROM ?? 'noreply@golf.gy.gl'}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import type { Database } from './types'
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import type { Database } from './types'
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options),
|
||||
)
|
||||
} catch {
|
||||
// Server Component — cookie setting ignored (middleware handles refresh)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function createAdminClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options),
|
||||
)
|
||||
} catch {}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Auto-generated types will go here after running: supabase gen types typescript
|
||||
// For now, a placeholder that will be replaced once the DB schema is applied.
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: Record<string, never>
|
||||
Views: Record<string, never>
|
||||
Functions: Record<string, never>
|
||||
Enums: Record<string, never>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user