Files
esports/src/lib/server/db/schema.ts

620 lines
24 KiB
TypeScript

import {
pgTable,
integer,
text,
uuid,
boolean,
timestamp,
jsonb,
index,
uniqueIndex,
real
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export * from './auth.schema';
import { user } 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(),
verifiedAt: timestamp('verified_at', { withTimezone: true })
});
// ── 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(),
starRating: real('star_rating'),
bpm: real('bpm'),
totalLength: integer('total_length'),
mods: text('mods').array().default([]).notNull(),
// ── Cached beatmap metadata (stored at insert time → zero API calls on page load) ──
title: text('title'),
artist: text('artist'),
version: text('version'),
coverUrl: text('cover_url'),
listCoverUrl: text('list_cover_url')
},
(t) => [index('idx_mappool_slot_mappool').on(t.mappoolId)]
);
// ── 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), index('idx_match_created_at').on(t.createdAt)]
);
// ── 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) => [
index('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),
index('idx_mpp_user').on(t.userId)
]
);
// ── 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(),
pp: real('pp')
},
(t) => [index('idx_mgs_game').on(t.matchGameId)]
);
// ── Match Queue ─────────────────────────────────────────────────────────
export const matchQueue = pgTable(
'match_queue',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
elo: integer('elo').default(1000).notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [uniqueIndex('idx_queue_user').on(t.userId), index('idx_queue_elo').on(t.elo)]
);
// ── Player ELO ──────────────────────────────────────────────────────────
export const playerRating = pgTable('player_rating', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().unique(),
elo: integer('elo').default(1000).notNull(),
wins: integer('wins').default(0).notNull(),
losses: integer('losses').default(0).notNull(),
initialElo: integer('initial_elo'),
osuRankAtSeed: integer('osu_rank_at_seed'),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
// ── Notifications ────────────────────────────────────────────────────
export const notification = pgTable(
'notification',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
type: text('type').notNull(),
title: text('title').notNull(),
message: text('message'),
referenceId: text('reference_id'),
read: boolean('read').default(false).notNull(),
actionedAt: timestamp('actioned_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_notification_user').on(t.userId),
index('idx_notification_user_read').on(t.userId, t.read)
]
);
// ── Match Invites ────────────────────────────────────────────────────
export const matchInvite = pgTable(
'match_invite',
{
id: uuid('id').primaryKey().defaultRandom(),
createdBy: text('created_by').notNull(),
creatorTeamId: uuid('creator_team_id')
.notNull()
.references(() => team.id),
invitedTeamId: uuid('invited_team_id')
.notNull()
.references(() => team.id),
config: jsonb('config').notNull(),
mappoolId: uuid('mappool_id')
.notNull()
.references(() => mappool.id),
name: text('name'),
message: text('message'),
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
status: text('status').default('pending').notNull(),
matchId: uuid('match_id').references(() => match.id),
respondedAt: timestamp('responded_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_invite_invited_team').on(t.invitedTeamId),
index('idx_invite_created_by').on(t.createdBy),
index('idx_invite_status').on(t.status)
]
);
// ── Tournaments ────────────────────────────────────────────────────────
export const tournament = pgTable(
'tournament',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
format: text('format').notNull(), // single_elim | double_elim | groups_bracket
state: text('state').default('DRAFT').notNull(),
config: jsonb('config').notNull(),
maxSlots: integer('max_slots').notNull(),
bannerUrl: text('banner_url'),
createdBy: text('created_by').notNull(),
registrationOpenAt: timestamp('registration_open_at', { withTimezone: true }),
registrationCloseAt: timestamp('registration_close_at', { withTimezone: true }),
startAt: timestamp('start_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
winnerId: uuid('winner_id').references(() => team.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_tournament_state').on(t.state),
index('idx_tournament_created_at').on(t.createdAt)
]
);
// ── Tournament Staff ───────────────────────────────────────────────────
export const tournamentStaff = pgTable(
'tournament_staff',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
role: text('role').notNull() // organizer | admin | referee | pooler | streamer
},
(t) => [
uniqueIndex('idx_tstaff_tournament_user').on(t.tournamentId, t.userId),
index('idx_tstaff_tournament').on(t.tournamentId)
]
);
// ── Tournament Rounds ──────────────────────────────────────────────────
export const tournamentRound = pgTable(
'tournament_round',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
abbreviation: text('abbreviation'),
roundOrder: integer('round_order').notNull(),
bestOf: integer('best_of').notNull(),
mappoolId: uuid('mappool_id').references(() => mappool.id),
bracketType: text('bracket_type'), // winners | losers | grand_final | group | null
scheduledAt: timestamp('scheduled_at', { withTimezone: true })
},
(t) => [index('idx_tround_tournament').on(t.tournamentId)]
);
// ── Tournament Registrations ───────────────────────────────────────────
export const tournamentRegistration = pgTable(
'tournament_registration',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
userId: text('user_id').notNull(),
seed: integer('seed'),
qualifierScore: real('qualifier_score'),
status: text('status').default('registered').notNull(),
registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
uniqueIndex('idx_treg_tournament_team').on(t.tournamentId, t.teamId),
index('idx_treg_tournament').on(t.tournamentId)
]
);
// ── Tournament Matches (bracket structure) ─────────────────────────────
export const tournamentMatch = pgTable(
'tournament_match',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
roundId: uuid('round_id')
.notNull()
.references(() => tournamentRound.id, { onDelete: 'cascade' }),
matchId: uuid('match_id').references(() => match.id),
bracketPosition: integer('bracket_position').notNull(),
winnerGoesToId: uuid('winner_goes_to_id'),
loserGoesToId: uuid('loser_goes_to_id'),
winnerSlot: integer('winner_slot'),
loserSlot: integer('loser_slot'),
team1Id: uuid('team1_id').references(() => team.id),
team2Id: uuid('team2_id').references(() => team.id),
winnerId: uuid('winner_id').references(() => team.id),
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
state: text('state').default('PENDING').notNull()
},
(t) => [
index('idx_tmatch_tournament').on(t.tournamentId),
index('idx_tmatch_round').on(t.roundId),
index('idx_tmatch_match').on(t.matchId),
index('idx_tmatch_state').on(t.state)
]
);
// ── Tournament Groups ──────────────────────────────────────────────────
export const tournamentGroup = pgTable(
'tournament_group',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
groupOrder: integer('group_order').notNull()
},
(t) => [index('idx_tgroup_tournament').on(t.tournamentId)]
);
// ── Tournament Group Entries ───────────────────────────────────────────
export const tournamentGroupEntry = pgTable(
'tournament_group_entry',
{
id: uuid('id').primaryKey().defaultRandom(),
groupId: uuid('group_id')
.notNull()
.references(() => tournamentGroup.id, { onDelete: 'cascade' }),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
wins: integer('wins').default(0).notNull(),
losses: integer('losses').default(0).notNull(),
mapWins: integer('map_wins').default(0).notNull(),
mapLosses: integer('map_losses').default(0).notNull(),
seed: integer('seed')
},
(t) => [
uniqueIndex('idx_tge_group_team').on(t.groupId, t.teamId),
index('idx_tge_group').on(t.groupId)
]
);
// ── Tournament Qualifier Scores ────────────────────────────────────────
export const tournamentQualifierScore = pgTable(
'tournament_qualifier_score',
{
id: uuid('id').primaryKey().defaultRandom(),
tournamentId: uuid('tournament_id')
.notNull()
.references(() => tournament.id, { onDelete: 'cascade' }),
teamId: uuid('team_id')
.notNull()
.references(() => team.id),
mappoolSlotId: uuid('mappool_slot_id')
.notNull()
.references(() => mappoolSlot.id),
totalScore: integer('total_score').notNull(),
accuracy: real('accuracy'),
playedAt: timestamp('played_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_tqs_tournament_team').on(t.tournamentId, t.teamId),
index('idx_tqs_tournament').on(t.tournamentId)
]
);
// ── Relations ───────────────────────────────────────────────────────────
export const teamRelations = relations(team, ({ many }) => ({
members: many(teamMember)
}));
export const teamMemberRelations = relations(teamMember, ({ one }) => ({
team: one(team, { fields: [teamMember.teamId], references: [team.id] })
}));
export const mappoolRelations = relations(mappool, ({ many }) => ({
slots: many(mappoolSlot)
}));
export const mappoolSlotRelations = relations(mappoolSlot, ({ one }) => ({
mappool: one(mappool, { fields: [mappoolSlot.mappoolId], references: [mappool.id] })
}));
export const matchRelations = relations(match, ({ one, many }) => ({
mappool: one(mappool, { fields: [match.mappoolId], references: [mappool.id] }),
participants: many(matchParticipant),
games: many(matchGame)
}));
export const matchParticipantRelations = relations(matchParticipant, ({ one, many }) => ({
match: one(match, { fields: [matchParticipant.matchId], references: [match.id] }),
team: one(team, { fields: [matchParticipant.teamId], references: [team.id] }),
players: many(matchParticipantPlayer)
}));
export const matchParticipantPlayerRelations = relations(matchParticipantPlayer, ({ one }) => ({
participant: one(matchParticipant, {
fields: [matchParticipantPlayer.participantId],
references: [matchParticipant.id]
})
}));
export const matchGameRelations = relations(matchGame, ({ one, many }) => ({
match: one(match, { fields: [matchGame.matchId], references: [match.id] }),
slot: one(mappoolSlot, { fields: [matchGame.mappoolSlotId], references: [mappoolSlot.id] }),
scores: many(matchGameScore)
}));
export const matchGameScoreRelations = relations(matchGameScore, ({ one }) => ({
game: one(matchGame, { fields: [matchGameScore.matchGameId], references: [matchGame.id] }),
player: one(matchParticipantPlayer, {
fields: [matchGameScore.playerId],
references: [matchParticipantPlayer.id]
})
}));
export const matchQueueRelations = relations(matchQueue, ({ one }) => ({
team: one(team, { fields: [matchQueue.teamId], references: [team.id] })
}));
export const notificationRelations = relations(notification, ({}) => ({}));
export const matchInviteRelations = relations(matchInvite, ({ one }) => ({
creatorTeam: one(team, {
fields: [matchInvite.creatorTeamId],
references: [team.id],
relationName: 'inviteCreatorTeam'
}),
invitedTeam: one(team, {
fields: [matchInvite.invitedTeamId],
references: [team.id],
relationName: 'inviteInvitedTeam'
}),
mappool: one(mappool, { fields: [matchInvite.mappoolId], references: [mappool.id] }),
match: one(match, { fields: [matchInvite.matchId], references: [match.id] })
}));
// ── Tournament Relations ────────────────────────────────────────────────
export const tournamentRelations = relations(tournament, ({ many }) => ({
rounds: many(tournamentRound),
registrations: many(tournamentRegistration),
matches: many(tournamentMatch),
groups: many(tournamentGroup),
staff: many(tournamentStaff),
qualifierScores: many(tournamentQualifierScore)
}));
export const tournamentStaffRelations = relations(tournamentStaff, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentStaff.tournamentId],
references: [tournament.id]
})
}));
export const tournamentRoundRelations = relations(tournamentRound, ({ one, many }) => ({
tournament: one(tournament, {
fields: [tournamentRound.tournamentId],
references: [tournament.id]
}),
mappool: one(mappool, { fields: [tournamentRound.mappoolId], references: [mappool.id] }),
matches: many(tournamentMatch)
}));
export const tournamentRegistrationRelations = relations(tournamentRegistration, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentRegistration.tournamentId],
references: [tournament.id]
}),
team: one(team, { fields: [tournamentRegistration.teamId], references: [team.id] })
}));
export const tournamentMatchRelations = relations(tournamentMatch, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentMatch.tournamentId],
references: [tournament.id]
}),
round: one(tournamentRound, {
fields: [tournamentMatch.roundId],
references: [tournamentRound.id]
}),
match: one(match, { fields: [tournamentMatch.matchId], references: [match.id] }),
team1: one(team, {
fields: [tournamentMatch.team1Id],
references: [team.id],
relationName: 'tournamentMatchTeam1'
}),
team2: one(team, {
fields: [tournamentMatch.team2Id],
references: [team.id],
relationName: 'tournamentMatchTeam2'
}),
winner: one(team, {
fields: [tournamentMatch.winnerId],
references: [team.id],
relationName: 'tournamentMatchWinner'
})
}));
export const tournamentGroupRelations = relations(tournamentGroup, ({ one, many }) => ({
tournament: one(tournament, {
fields: [tournamentGroup.tournamentId],
references: [tournament.id]
}),
entries: many(tournamentGroupEntry)
}));
export const tournamentGroupEntryRelations = relations(tournamentGroupEntry, ({ one }) => ({
group: one(tournamentGroup, {
fields: [tournamentGroupEntry.groupId],
references: [tournamentGroup.id]
}),
team: one(team, { fields: [tournamentGroupEntry.teamId], references: [team.id] })
}));
// ── Profile Comments ────────────────────────────────────────────────────
export const profileComment = pgTable(
'profile_comment',
{
id: uuid('id').primaryKey().defaultRandom(),
// 'user' or 'team'
targetType: text('target_type').notNull(),
targetId: text('target_id').notNull(),
authorId: text('author_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(t) => [
index('idx_profile_comment_target').on(t.targetType, t.targetId),
index('idx_profile_comment_author').on(t.authorId)
]
);
export const profileCommentRelations = relations(profileComment, ({ one }) => ({
author: one(user, { fields: [profileComment.authorId], references: [user.id] })
}));
export const tournamentQualifierScoreRelations = relations(tournamentQualifierScore, ({ one }) => ({
tournament: one(tournament, {
fields: [tournamentQualifierScore.tournamentId],
references: [tournament.id]
}),
team: one(team, { fields: [tournamentQualifierScore.teamId], references: [team.id] }),
slot: one(mappoolSlot, {
fields: [tournamentQualifierScore.mappoolSlotId],
references: [mappoolSlot.id]
})
}));