diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index af4affa..6a7212d 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -4,17 +4,65 @@ import { sveltekitCookies } from 'better-auth/svelte-kit'; import { env } from '$env/dynamic/private'; import { getRequestEvent } from '$app/server'; import { db } from '$lib/server/db'; +import { genericOAuth } from 'better-auth/plugins'; export const auth = betterAuth({ baseURL: env.ORIGIN, secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: 'pg' }), emailAndPassword: { enabled: true }, - socialProviders: { - github: { - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET - } - }, - plugins: [sveltekitCookies(getRequestEvent)] // make sure this is the last plugin in the array + plugins: [ + genericOAuth({ + config: [ + { + providerId: 'osu', + clientId: env.OSU_CLIENT_ID!, + clientSecret: env.OSU_CLIENT_SECRET!, + authorizationUrl: 'https://osu.ppy.sh/oauth/authorize', + tokenUrl: 'https://osu.ppy.sh/oauth/token', + userInfoUrl: 'https://osu.ppy.sh/api/v2/me', + scopes: ['identify', 'public'], + pkce: false, + authentication: 'post', + redirectURI: 'http://localhost:5173/api/auth/oauth2/callback/osu', + getToken: async ({ code, redirectURI }) => { + const res = await fetch('https://osu.ppy.sh/oauth/token', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: env.OSU_CLIENT_ID!, + client_secret: env.OSU_CLIENT_SECRET!, + code, + grant_type: 'authorization_code', + redirect_uri: redirectURI + }) + }); + const data = await res.json(); + if (!res.ok) { + console.error('osu! token exchange failed:', data); + throw new Error('Token exchange failed'); + } + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000), + scopes: ['identify', 'public'], + raw: data + }; + }, + mapProfileToUser(profile) { + return { + name: profile.username, + image: profile.avatar_url, + email: `${profile.id}@osu.local` + }; + } + } + ] + }), + sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array + ] }); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 5d1a8e0..0cb8087 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,9 +1,159 @@ -import { pgTable, serial, integer, text } from 'drizzle-orm/pg-core'; - -export const task = pgTable('task', { - id: serial('id').primaryKey(), - title: text('title').notNull(), - priority: integer('priority').notNull().default(1) -}); +import { + pgTable, + integer, + text, + uuid, + boolean, + timestamp, + jsonb, + index, + uniqueIndex, + real +} from 'drizzle-orm/pg-core'; export * from './auth.schema'; + +// ── Teams ─────────────────────────────────────────────────────────────── +export const team = pgTable('team', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + isPersonal: boolean('is_personal').default(false).notNull(), + ownerId: text('owner_id'), + avatarUrl: text('avatar_url'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() +}); + +// ── Team Members ──────────────────────────────────────────────────────── +export const teamMember = pgTable('team_member', { + id: uuid('id').primaryKey().defaultRandom(), + teamId: uuid('team_id') + .notNull() + .references(() => team.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), + role: text('role').default('member'), + joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow() +}); + +// ── Mappools ──────────────────────────────────────────────────────────── +export const mappool = pgTable('mappool', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + createdBy: text('created_by'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() +}); + +// ── Mappool Slots ─────────────────────────────────────────────────────── +export const mappoolSlot = pgTable('mappool_slot', { + id: uuid('id').primaryKey().defaultRandom(), + mappoolId: uuid('mappool_id') + .notNull() + .references(() => mappool.id, { onDelete: 'cascade' }), + category: text('category').notNull(), + orderInCategory: integer('order_in_category').notNull(), + beatmapId: text('beatmap_id').notNull(), + mods: text('mods').array().default([]).notNull() +}); + +// ── Matches ───────────────────────────────────────────────────────────── +export const match = pgTable( + 'match', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name'), + state: text('state').default('CREATED').notNull(), + config: jsonb('config').notNull(), + mappoolId: uuid('mappool_id').references(() => mappool.id), + osuLobbyId: integer('osu_lobby_id'), + scheduledAt: timestamp('scheduled_at', { withTimezone: true }), + startedAt: timestamp('started_at', { withTimezone: true }), + finishedAt: timestamp('finished_at', { withTimezone: true }), + winnerId: uuid('winner_id').references(() => team.id), + createdBy: text('created_by'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (t) => [index('idx_match_state').on(t.state)] +); + +// ── Match Participants (a team in a match) ────────────────────────────── +export const matchParticipant = pgTable( + 'match_participant', + { + id: uuid('id').primaryKey().defaultRandom(), + matchId: uuid('match_id') + .notNull() + .references(() => match.id, { onDelete: 'cascade' }), + teamId: uuid('team_id') + .notNull() + .references(() => team.id), + slot: integer('slot').notNull(), + score: integer('score').default(0).notNull(), + rollValue: integer('roll_value'), + pickOrder: integer('pick_order') + }, + (t) => [ + uniqueIndex('idx_mp_match_team').on(t.matchId, t.teamId), + uniqueIndex('idx_mp_match_slot').on(t.matchId, t.slot), + index('idx_mp_match').on(t.matchId) + ] +); + +// ── Match Participant Players (a player fielded for this match) ───────── +export const matchParticipantPlayer = pgTable( + 'match_participant_player', + { + id: uuid('id').primaryKey().defaultRandom(), + participantId: uuid('participant_id') + .notNull() + .references(() => matchParticipant.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull() + }, + (t) => [ + uniqueIndex('idx_mpp_participant_user').on(t.participantId, t.userId), + index('idx_mpp_participant').on(t.participantId) + ] +); + +// ── Match Games ───────────────────────────────────────────────────────── +export const matchGame = pgTable( + 'match_game', + { + id: uuid('id').primaryKey().defaultRandom(), + matchId: uuid('match_id') + .notNull() + .references(() => match.id, { onDelete: 'cascade' }), + gameNumber: integer('game_number').notNull(), + mappoolSlotId: uuid('mappool_slot_id') + .notNull() + .references(() => mappoolSlot.id), + pickedByParticipantId: uuid('picked_by_participant_id').references(() => matchParticipant.id), + winnerParticipantId: uuid('winner_participant_id').references(() => matchParticipant.id), + state: text('state').default('PENDING').notNull(), + startedAt: timestamp('started_at', { withTimezone: true }), + finishedAt: timestamp('finished_at', { withTimezone: true }) + }, + (t) => [index('idx_match_game_match').on(t.matchId)] +); + +// ── Match Game Scores (per-player per-map) ────────────────────────────── +export const matchGameScore = pgTable( + 'match_game_score', + { + id: uuid('id').primaryKey().defaultRandom(), + matchGameId: uuid('match_game_id') + .notNull() + .references(() => matchGame.id, { onDelete: 'cascade' }), + playerId: uuid('player_id') + .notNull() + .references(() => matchParticipantPlayer.id), + score: integer('score').default(0).notNull(), + accuracy: real('accuracy').default(0).notNull(), + maxCombo: integer('max_combo').default(0).notNull(), + count300: integer('count_300').default(0).notNull(), + count100: integer('count_100').default(0).notNull(), + count50: integer('count_50').default(0).notNull(), + countMiss: integer('count_miss').default(0).notNull(), + mods: text('mods').array().default([]).notNull(), + passed: boolean('passed').default(false).notNull() + }, + (t) => [index('idx_mgs_game').on(t.matchGameId)] +); diff --git a/src/routes/demo/better-auth/login/+page.server.ts b/src/routes/demo/better-auth/login/+page.server.ts index 18ca5cb..9c25def 100644 --- a/src/routes/demo/better-auth/login/+page.server.ts +++ b/src/routes/demo/better-auth/login/+page.server.ts @@ -60,12 +60,12 @@ export const actions: Actions = { }, signInSocial: async (event) => { const formData = await event.request.formData(); - const provider = formData.get('provider')?.toString() ?? 'github'; + const provider = formData.get('provider')?.toString() ?? 'osu'; const callbackURL = formData.get('callbackURL')?.toString() ?? '/demo/better-auth'; const result = await auth.api.signInSocial({ body: { - provider: provider as 'github', + provider: provider as 'osu', callbackURL } }); diff --git a/src/routes/demo/better-auth/login/+page.svelte b/src/routes/demo/better-auth/login/+page.svelte index f7eb32a..e7f8079 100644 --- a/src/routes/demo/better-auth/login/+page.svelte +++ b/src/routes/demo/better-auth/login/+page.svelte @@ -43,9 +43,9 @@