import { relations, sql } from "drizzle-orm"; import { bigint, boolean, index, integer, jsonb, numeric, pgSchema, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; import type { CatalogAscensionGroup, CatalogBaseStat, CatalogConstellation, CatalogTalent, } from "@/lib/catalog/lunaris"; import type { ExtraSheet } from "@/lib/guides/extra-sheet"; const authSchema = pgSchema("auth"); const mediaSchema = pgSchema("media"); const editorSchema = pgSchema("editor"); const catalogSchema = pgSchema("catalog"); const guidesSchema = pgSchema("guides"); const timestamps = { createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }; // Better Auth core tables. Public registration is disabled; authenticated // administrators create additional credential accounts from the admin area. export const users = authSchema.table( "user", { id: text("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull(), emailVerified: boolean("email_verified").default(false).notNull(), image: text("image"), role: text("role").default("admin"), banned: boolean("banned").default(false), banReason: text("ban_reason"), banExpires: timestamp("ban_expires", { withTimezone: true }), ...timestamps, }, (table) => [uniqueIndex("user_email_unique").on(table.email)], ); export const sessions = authSchema.table( "session", { id: text("id").primaryKey(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), token: text("token").notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), impersonatedBy: text("impersonated_by"), ...timestamps, }, (table) => [ uniqueIndex("session_token_unique").on(table.token), index("session_user_id_idx").on(table.userId), ], ); export const accounts = authSchema.table( "account", { id: text("id").primaryKey(), issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), accessToken: text("access_token"), refreshToken: text("refresh_token"), idToken: text("id_token"), accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true, }), refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true, }), scope: text("scope"), password: text("password"), ...timestamps, }, (table) => [ uniqueIndex("account_issuer_unique").on(table.issuer, table.accountId), index("account_user_id_idx").on(table.userId), ], ); export const verifications = authSchema.table( "verification", { id: text("id").primaryKey(), identifier: text("identifier").notNull(), value: text("value").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), ...timestamps, }, (table) => [index("verification_identifier_idx").on(table.identifier)], ); export const media = mediaSchema.table( "media", { id: uuid("id").defaultRandom().primaryKey(), objectKey: text("object_key").notNull(), fileName: text("file_name").notNull(), mimeType: varchar("mime_type", { length: 64 }).notNull(), byteSize: bigint("byte_size", { mode: "number" }).notNull(), checksum: varchar("checksum", { length: 128 }), width: integer("width"), height: integer("height"), status: varchar("status", { length: 24 }).default("pending").notNull(), currentReferenceCount: integer("current_reference_count") .default(0) .notNull(), revisionReferenceCount: integer("revision_reference_count") .default(0) .notNull(), createdById: text("created_by_id").references(() => users.id, { onDelete: "set null", }), ...timestamps, }, (table) => [ uniqueIndex("media_object_key_unique").on(table.objectKey), index("media_visibility_idx").on( table.status, table.currentReferenceCount, ), ], ); export const characters = editorSchema.table( "character", { id: uuid("id").defaultRandom().primaryKey(), slug: varchar("slug", { length: 120 }).notNull(), nameTh: text("name_th").notNull(), nameEn: text("name_en"), description: text("description"), portraitMediaId: uuid("portrait_media_id").references(() => media.id, { onDelete: "set null", }), element: varchar("element", { length: 48 }), role: varchar("role", { length: 80 }), rarity: integer("rarity"), sortOrder: integer("sort_order").default(0).notNull(), visible: boolean("visible").default(false).notNull(), ...timestamps, }, (table) => [ uniqueIndex("character_slug_unique").on(table.slug), index("character_directory_idx").on(table.visible, table.sortOrder), ], ); export const pages = editorSchema.table( "page", { id: uuid("id").defaultRandom().primaryKey(), characterId: uuid("character_id") .notNull() .references(() => characters.id, { onDelete: "cascade" }), slug: varchar("slug", { length: 120 }).notNull(), title: text("title").notNull(), navOrder: integer("nav_order").default(0).notNull(), visible: boolean("visible").default(false).notNull(), publicNote: text("public_note"), version: integer("version").default(1).notNull(), ...timestamps, }, (table) => [ uniqueIndex("page_character_slug_unique").on( table.characterId, table.slug, ), index("page_navigation_idx").on( table.characterId, table.visible, table.navOrder, ), ], ); export const blocks = editorSchema.table( "block", { id: uuid("id").primaryKey(), pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), type: varchar("type", { length: 64 }).notNull(), schemaVersion: integer("schema_version").default(1).notNull(), config: jsonb("config").$type>().notNull(), responsiveLayout: jsonb("responsive_layout") .$type>() .default({}) .notNull(), sortOrder: integer("sort_order").default(0).notNull(), ...timestamps, }, (table) => [index("block_page_order_idx").on(table.pageId, table.sortOrder)], ); export const dataSources = editorSchema.table( "data_source", { id: uuid("id").defaultRandom().primaryKey(), name: text("name").notNull(), description: text("description"), fields: jsonb("fields").$type().default([]).notNull(), currentVersion: integer("current_version").default(1).notNull(), ...timestamps, }, (table) => [index("data_source_name_idx").on(table.name)], ); export const dataSourceVersions = editorSchema.table( "data_source_version", { id: uuid("id").defaultRandom().primaryKey(), dataSourceId: uuid("data_source_id") .notNull() .references(() => dataSources.id, { onDelete: "cascade" }), version: integer("version").notNull(), rows: jsonb("rows").$type().default([]).notNull(), evaluation: jsonb("evaluation").$type>(), createdById: text("created_by_id").references(() => users.id, { onDelete: "set null", }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ uniqueIndex("data_source_version_unique").on( table.dataSourceId, table.version, ), ], ); export const pageDataSources = editorSchema.table( "page_data_source", { pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), dataSourceId: uuid("data_source_id") .notNull() .references(() => dataSources.id, { onDelete: "restrict" }), pinnedVersion: integer("pinned_version"), }, (table) => [ primaryKey({ columns: [table.pageId, table.dataSourceId] }), index("page_data_source_dependency_idx").on(table.dataSourceId), ], ); export const templates = editorSchema.table( "template", { id: uuid("id").defaultRandom().primaryKey(), slug: varchar("slug", { length: 120 }).notNull(), name: text("name").notNull(), description: text("description"), category: varchar("category", { length: 64 }).notNull(), structure: jsonb("structure").$type>().notNull(), builtIn: boolean("built_in").default(false).notNull(), ...timestamps, }, (table) => [uniqueIndex("template_slug_unique").on(table.slug)], ); export const revisions = editorSchema.table( "revision", { id: uuid("id").defaultRandom().primaryKey(), pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), pageVersion: integer("page_version").notNull(), kind: varchar("kind", { length: 24 }).notNull(), label: text("label"), authorId: text("author_id").references(() => users.id, { onDelete: "set null", }), sourceRevisionId: uuid("source_revision_id"), checkpointHour: timestamp("checkpoint_hour", { withTimezone: true }), snapshot: jsonb("snapshot").$type>().notNull(), dataSourceVersions: jsonb("data_source_versions") .$type>() .default({}) .notNull(), mediaIds: jsonb("media_ids").$type().default([]).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }), }, (table) => [ index("revision_page_created_idx").on(table.pageId, table.createdAt), uniqueIndex("revision_checkpoint_hour_unique") .on(table.pageId, table.checkpointHour) .where(sql`${table.kind} = 'checkpoint'`), ], ); export const publicSnapshots = editorSchema.table( "public_snapshot", { id: uuid("id").defaultRandom().primaryKey(), pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), pageVersion: integer("page_version").notNull(), versionVector: jsonb("version_vector") .$type>() .default({}) .notNull(), vectorHash: varchar("vector_hash", { length: 64 }).notNull(), dto: jsonb("dto").$type>().notNull(), visible: boolean("visible").default(false).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ uniqueIndex("public_snapshot_version_unique").on( table.pageId, table.pageVersion, table.vectorHash, ), index("public_snapshot_lookup_idx").on( table.pageId, table.visible, table.createdAt, ), ], ); export const slugAliases = editorSchema.table( "slug_alias", { id: uuid("id").defaultRandom().primaryKey(), kind: varchar("kind", { length: 24 }).notNull(), oldCharacterSlug: varchar("old_character_slug", { length: 120 }).notNull(), oldPageSlug: varchar("old_page_slug", { length: 120 }), targetCharacterId: uuid("target_character_id") .notNull() .references(() => characters.id, { onDelete: "cascade" }), targetPageId: uuid("target_page_id").references(() => pages.id, { onDelete: "cascade", }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ uniqueIndex("slug_alias_path_unique").on( table.oldCharacterSlug, table.oldPageSlug, ), ], ); export const mediaReferences = mediaSchema.table( "media_reference", { mediaId: uuid("media_id") .notNull() .references(() => media.id, { onDelete: "cascade" }), ownerType: varchar("owner_type", { length: 24 }).notNull(), ownerId: uuid("owner_id").notNull(), referenceKind: varchar("reference_kind", { length: 24 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ primaryKey({ columns: [ table.mediaId, table.ownerType, table.ownerId, table.referenceKind, ], }), index("media_reference_owner_idx").on(table.ownerType, table.ownerId), ], ); export const outboxEvents = pgTable( "outbox_event", { id: bigint("id", { mode: "number" }) .primaryKey() .generatedAlwaysAsIdentity(), topic: varchar("topic", { length: 160 }).notNull(), aggregateId: uuid("aggregate_id").notNull(), eventType: varchar("event_type", { length: 64 }).notNull(), payload: jsonb("payload").$type>().notNull(), status: varchar("status", { length: 24 }).default("pending").notNull(), attempts: integer("attempts").default(0).notNull(), availableAt: timestamp("available_at", { withTimezone: true }) .defaultNow() .notNull(), processedAt: timestamp("processed_at", { withTimezone: true }), lastError: text("last_error"), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("outbox_pending_idx").on( table.status, table.availableAt, table.id, ), ], ); export const catalogCharacters = catalogSchema.table( "character", { key: varchar("key", { length: 64 }).notNull(), name: text("name").notNull(), element: varchar("element", { length: 48 }), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), elementIconKey: text("element_icon_key"), coopImageKey: text("coop_image_key"), nameCardImageKey: text("name_card_image_key"), constellations: jsonb("constellations").$type().default([]).notNull(), talents: jsonb("talents").$type().default([]).notNull(), ascension: jsonb("ascension").$type().default([]).notNull(), leveling: jsonb("leveling").$type().default([]).notNull(), baseStats: jsonb("base_stats").$type().default([]).notNull(), }, (table) => [ primaryKey({ columns: [table.key] }), index("catalog_character_name_idx").on(table.name), ], ); export const catalogMaterials = catalogSchema.table( "material", { key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), description: text("description"), rarity: integer("rarity"), type: varchar("type", { length: 64 }), imageKey: text("image_key").notNull(), ...timestamps, }, (table) => [index("catalog_material_name_idx").on(table.name)], ); export const catalogWeapons = catalogSchema.table( "weapon", { key: varchar("key", { length: 64 }).notNull(), name: text("name").notNull(), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), }, (table) => [ primaryKey({ columns: [table.key] }), index("catalog_weapon_name_idx").on(table.name), ], ); export const catalogArtifacts = catalogSchema.table( "artifact", { key: varchar("key", { length: 64 }).notNull(), name: text("name").notNull(), rarity: integer("rarity"), imageKey: text("image_key").notNull(), }, (table) => [ primaryKey({ columns: [table.key] }), index("catalog_artifact_name_idx").on(table.name), ], ); export const glossaryAliases = guidesSchema.table( "glossary_alias", { id: uuid("id").defaultRandom().primaryKey(), kind: varchar("kind", { length: 16 }).notNull(), catalogKey: varchar("catalog_key", { length: 64 }).notNull(), alias: varchar("alias", { length: 120 }).notNull(), normalizedAlias: varchar("normalized_alias", { length: 120 }).notNull(), ...timestamps, }, (table) => [ uniqueIndex("glossary_alias_kind_normalized_unique").on( table.kind, table.normalizedAlias, ), index("glossary_alias_item_idx").on(table.kind, table.catalogKey), ], ); export const guides = guidesSchema.table( "guide", { id: uuid("id").defaultRandom().primaryKey(), characterKey: varchar("character_key", { length: 64 }).notNull(), slug: varchar("slug", { length: 120 }).notNull(), name: text("name").notNull(), overview: text("overview").notNull(), youtubeUrls: jsonb("youtube_urls").$type().default([]).notNull(), coverMediaId: uuid("cover_media_id") .notNull() .references(() => media.id, { onDelete: "restrict" }), posterArtMediaId: uuid("poster_art_media_id").references(() => media.id, { onDelete: "set null", }), posterArtOffsetX: integer("poster_art_offset_x").default(0).notNull(), posterArtOffsetY: integer("poster_art_offset_y").default(0).notNull(), posterArtScale: integer("poster_art_scale").default(100).notNull(), posterTopColors: jsonb("poster_top_colors") .$type<[string, string]>() .default(["#000000", "#000000"]) .notNull(), posterBottomColors: jsonb("poster_bottom_colors") .$type<[string, string]>() .default(["#000000", "#000000"]) .notNull(), version: integer("version").default(1).notNull(), isPublic: boolean("is_public").default(false).notNull(), sortOrder: integer("sort_order").default(0).notNull(), trashedAt: timestamp("trashed_at", { withTimezone: true }), ...timestamps, }, (table) => [ uniqueIndex("guide_character_key_unique").on(table.characterKey), uniqueIndex("guide_slug_unique").on(table.slug), index("guide_directory_idx").on(table.trashedAt, table.sortOrder), ], ); export const guidePosterBleeding = guidesSchema.table( "guide_poster_bleeding", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), mediaId: uuid("media_id").notNull().references(() => media.id, { onDelete: "restrict" }), amount: integer("amount").default(100).notNull(), scale: integer("scale").default(100).notNull(), blendMode: varchar("blend_mode", { length: 32 }).default("multiply").notNull(), sortOrder: integer("sort_order").default(0).notNull(), ...timestamps, }, (table) => [ index("guide_poster_bleeding_order_idx").on(table.guideId, table.sortOrder), ], ); export const guideSections = guidesSchema.table( "guide_section", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), kind: varchar("kind", { length: 32 }).notNull(), slug: varchar("slug", { length: 120 }).notNull(), title: text("title").notNull(), note: text("note"), enabled: boolean("enabled").default(true).notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [ uniqueIndex("guide_section_slug_unique").on(table.guideId, table.slug), index("guide_section_order_idx").on(table.guideId, table.sortOrder), ], ); export const weaponConditions = guidesSchema.table( "weapon_condition", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("weapon_condition_order_idx").on(table.guideId, table.sortOrder)], ); export const weaponRecommendations = guidesSchema.table( "weapon_recommendation", { id: uuid("id").defaultRandom().primaryKey(), conditionId: uuid("condition_id").notNull().references(() => weaponConditions.id, { onDelete: "cascade" }), weaponKey: varchar("weapon_key", { length: 64 }).notNull(), refinement: varchar("refinement", { length: 8 }).notNull(), overallPercent: numeric("overall_percent", { precision: 8, scale: 2 }), personalPercent: numeric("personal_percent", { precision: 8, scale: 2 }), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("weapon_recommendation_order_idx").on(table.conditionId, table.sortOrder)], ); export const artifactGroups = guidesSchema.table( "artifact_group", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), name: text("name").notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("artifact_group_order_idx").on(table.guideId, table.sortOrder)], ); export const artifactRecommendations = guidesSchema.table( "artifact_recommendation", { id: uuid("id").defaultRandom().primaryKey(), groupId: uuid("group_id").notNull().references(() => artifactGroups.id, { onDelete: "cascade" }), firstArtifactKey: varchar("first_artifact_key", { length: 64 }).notNull(), secondArtifactKey: varchar("second_artifact_key", { length: 64 }), percent: numeric("percent", { precision: 8, scale: 2 }).notNull(), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("artifact_recommendation_order_idx").on(table.groupId, table.sortOrder)], ); export const artifactProfiles = guidesSchema.table("artifact_profile", { guideId: uuid("guide_id").primaryKey().references(() => guides.id, { onDelete: "cascade" }), sands: jsonb("sands").$type().default([]).notNull(), goblet: jsonb("goblet").$type().default([]).notNull(), circlet: jsonb("circlet").$type().default([]).notNull(), substats: jsonb("substats").$type().default([]).notNull(), targets: jsonb("targets").$type>().default([]).notNull(), talents: jsonb("talents").$type>().default([]).notNull(), note: text("note"), ...timestamps, }); export const constellationConditions = guidesSchema.table( "constellation_condition", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("constellation_condition_order_idx").on(table.guideId, table.sortOrder)], ); export const constellationRecommendations = guidesSchema.table( "constellation_recommendation", { conditionId: uuid("condition_id").notNull().references(() => constellationConditions.id, { onDelete: "cascade" }), level: integer("level").notNull(), detail: text("detail").notNull(), cumulativePercent: numeric("cumulative_percent", { precision: 8, scale: 2 }).notNull(), isGreat: boolean("is_great").default(false).notNull(), }, (table) => [primaryKey({ columns: [table.conditionId, table.level] })], ); export const teamCompositions = guidesSchema.table( "team_composition", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), title: text("title").notNull(), rotationSeconds: numeric("rotation_seconds", { precision: 8, scale: 2 }).notNull(), combo: text("combo").notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("team_composition_order_idx").on(table.guideId, table.sortOrder)], ); export const teamMembers = guidesSchema.table( "team_member", { teamId: uuid("team_id").notNull().references(() => teamCompositions.id, { onDelete: "cascade" }), position: integer("position").notNull(), characterKey: varchar("character_key", { length: 64 }).notNull(), weaponKey: varchar("weapon_key", { length: 64 }), artifactKey: varchar("artifact_key", { length: 64 }), secondArtifactKey: varchar("second_artifact_key", { length: 64 }), constellation: integer("constellation").default(0).notNull(), refinement: integer("refinement").default(1).notNull(), damage: numeric("damage", { precision: 18, scale: 2 }).notNull(), }, (table) => [primaryKey({ columns: [table.teamId, table.position] })], ); export const extraSections = guidesSchema.table("extra_section", { sectionId: uuid("section_id").primaryKey().references(() => guideSections.id, { onDelete: "cascade" }), ...timestamps, }); export const extraSectionRows = guidesSchema.table( "extra_section_row", { id: uuid("id").defaultRandom().primaryKey(), sectionId: uuid("section_id").notNull().references(() => extraSections.sectionId, { onDelete: "cascade" }), mediaId: uuid("media_id").references(() => media.id, { onDelete: "set null" }), heading: text("heading").notNull(), body: text("body").notNull(), percent: numeric("percent", { precision: 8, scale: 2 }), backgroundColor: varchar("background_color", { length: 7 }), textColor: varchar("text_color", { length: 7 }), highlights: jsonb("highlights").$type>().default([]).notNull(), catalogItems: jsonb("catalog_items") .$type>() .default([]) .notNull(), sheet: jsonb("sheet").$type(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)], ); export const characterRelations = relations(characters, ({ many, one }) => ({ pages: many(pages), portrait: one(media, { fields: [characters.portraitMediaId], references: [media.id], }), })); export const pageRelations = relations(pages, ({ many, one }) => ({ character: one(characters, { fields: [pages.characterId], references: [characters.id], }), blocks: many(blocks), dependencies: many(pageDataSources), revisions: many(revisions), })); export const blockRelations = relations(blocks, ({ one }) => ({ page: one(pages, { fields: [blocks.pageId], references: [pages.id], }), })); export const dataSourceRelations = relations(dataSources, ({ many }) => ({ versions: many(dataSourceVersions), pageDependencies: many(pageDataSources), })); export const dataSourceVersionRelations = relations( dataSourceVersions, ({ one }) => ({ dataSource: one(dataSources, { fields: [dataSourceVersions.dataSourceId], references: [dataSources.id], }), }), );