import { bigint, boolean, foreignKey, 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"; import type { CommissionRequest } from "@/lib/commission/request"; export type AuditLogValue = | string | number | boolean | null | AuditLogValue[] | { [key: string]: AuditLogValue }; const authSchema = pgSchema("auth"); const catalogSchema = pgSchema("catalog"); const guidesSchema = pgSchema("guides"); const commissionSchema = pgSchema("commission"); 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 accounts default to the customer role. 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("user"), 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"), 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_provider_unique").on(table.providerId, 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 = pgTable( "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(), 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 commissionCheckouts = commissionSchema.table("checkout", { id: uuid("id").defaultRandom().primaryKey(), userId: text("user_id").notNull().references(() => users.id), request: jsonb("request").$type().notNull(), amountBaht: integer("amount_baht").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }, (table) => [index("commission_checkout_user_idx").on(table.userId, table.createdAt)]); export const commissionSettings = commissionSchema.table("settings", { id: integer("id").primaryKey(), isOpen: boolean("is_open").default(true).notNull(), closureMessage: text("closure_message").default("").notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); export const commissionTickets = commissionSchema.table("ticket", { id: uuid("id").defaultRandom().primaryKey(), checkoutId: uuid("checkout_id").notNull().references(() => commissionCheckouts.id), userId: text("user_id").notNull().references(() => users.id), title: varchar("title", { length: 80 }), status: varchar("status", { length: 16 }).default("open").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }, (table) => [ uniqueIndex("commission_ticket_checkout_unique").on(table.checkoutId), index("commission_ticket_user_idx").on(table.userId, table.createdAt), index("commission_ticket_status_idx").on(table.status, table.updatedAt), ]); export const commissionPayments = commissionSchema.table("payment", { id: uuid("id").defaultRandom().primaryKey(), checkoutId: uuid("checkout_id").notNull().references(() => commissionCheckouts.id), ticketId: uuid("ticket_id").notNull().references(() => commissionTickets.id), referenceId: text("reference_id").notNull(), transRef: text("trans_ref").notNull(), amountBaht: integer("amount_baht").notNull(), transferredAt: timestamp("transferred_at", { withTimezone: true }).notNull(), slipObjectKey: text("slip_object_key").notNull(), slipMimeType: varchar("slip_mime_type", { length: 32 }).notNull(), providerData: jsonb("provider_data").$type>().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }, (table) => [ uniqueIndex("commission_payment_checkout_unique").on(table.checkoutId), uniqueIndex("commission_payment_reference_unique").on(table.referenceId), uniqueIndex("commission_payment_trans_ref_unique").on(table.transRef), ]); export const commissionPushSubscriptions = commissionSchema.table("push_subscription", { endpoint: text("endpoint").primaryKey(), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), scope: varchar("scope", { length: 8 }).notNull(), p256dh: text("p256dh").notNull(), auth: text("auth").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }, (table) => [index("commission_push_subscription_user_idx").on(table.userId)]); export const commissionMessages = commissionSchema.table("message", { id: uuid("id").defaultRandom().primaryKey(), ticketId: uuid("ticket_id").notNull().references(() => commissionTickets.id), authorId: text("author_id").notNull().references(() => users.id), replyToId: uuid("reply_to_id"), text: text("text"), imageObjectKey: text("image_object_key"), imageMimeType: varchar("image_mime_type", { length: 32 }), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), }, (table) => [ index("commission_message_ticket_time_idx").on(table.ticketId, table.createdAt), index("commission_message_reply_idx").on(table.replyToId), foreignKey({ columns: [table.replyToId], foreignColumns: [table.id] }).onDelete("set null"), ]); export const commissionReactions = commissionSchema.table("reaction", { messageId: uuid("message_id").notNull().references(() => commissionMessages.id, { onDelete: "cascade" }), userId: text("user_id").notNull().references(() => users.id), emoji: text("emoji").notNull(), }, (table) => [primaryKey({ columns: [table.messageId, table.userId, table.emoji] })]); 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 auditLog = pgTable( "audit_log", { id: bigint("id", { mode: "number" }) .primaryKey() .generatedAlwaysAsIdentity(), authorId: text("author_id"), author: text("author"), text: text("text").notNull(), details: jsonb("details") .$type>() .default({}), time: timestamp("time", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("audit_log_time_idx").on(table.time, table.id), index("audit_log_author_time_idx").on(table.authorId, table.time), ], ); 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 interface StygianMechanic { name: string; description: string; } export const stygianSchedules = guidesSchema.table( "stygian_schedule", { scheduleId: integer("schedule_id").primaryKey(), challengeName: varchar("challenge_name", { length: 32 }).notNull(), scheduleStartTime: timestamp("schedule_start_time", { withTimezone: true }).notNull(), scheduleEndTime: timestamp("schedule_end_time", { withTimezone: true }).notNull(), version: integer("version").default(1).notNull(), ...timestamps, }, ); export const stygianBosses = guidesSchema.table( "stygian_boss", { scheduleId: integer("schedule_id").notNull().references(() => stygianSchedules.scheduleId, { onDelete: "cascade" }), level: integer("level").notNull(), position: integer("position").notNull(), configId: integer("config_id").notNull(), name: text("name").notNull(), hp: numeric("hp", { precision: 18, scale: 2 }).notNull(), description: text("description").notNull(), mechanics: jsonb("mechanics").$type().default([]).notNull(), recommendedMechanics: jsonb("recommended_mechanics").$type().default([]).notNull(), recommendedMechanicTypes: jsonb("recommended_mechanic_types").$type().default([]).notNull(), imageKey: text("image_key").notNull(), }, (table) => [ primaryKey({ columns: [table.scheduleId, table.level, table.position] }), index("stygian_boss_schedule_idx").on(table.scheduleId, table.level), ], ); export const stygianRecommendationGroups = guidesSchema.table( "stygian_recommendation_group", { id: uuid("id").defaultRandom().primaryKey(), scheduleId: integer("schedule_id").notNull().references(() => stygianSchedules.scheduleId, { onDelete: "cascade" }), level: integer("level").notNull(), bossPosition: integer("boss_position").notNull(), kind: varchar("kind", { length: 16 }).notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("stygian_recommendation_group_order_idx").on(table.scheduleId, table.level, table.bossPosition, table.kind, table.sortOrder)], ); export const stygianRecommendationItems = guidesSchema.table( "stygian_recommendation_item", { groupId: uuid("group_id").notNull().references(() => stygianRecommendationGroups.id, { onDelete: "cascade" }), position: integer("position").notNull(), kind: varchar("kind", { length: 24 }).notNull(), catalogKey: varchar("catalog_key", { length: 64 }), weaponKey: varchar("weapon_key", { length: 64 }), artifactKey: varchar("artifact_key", { length: 64 }), refinement: integer("refinement"), constellationLevel: integer("constellation_level"), element: varchar("element", { length: 24 }), }, (table) => [primaryKey({ columns: [table.groupId, table.position] })], ); 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_item_normalized_unique").on( table.kind, table.catalogKey, 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(), posterArtRotation: integer("poster_art_rotation").default(0).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(), publishedAt: timestamp("published_at", { withTimezone: true }), 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" }), name: varchar("name", { length: 120 }), 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 artifactConditions = guidesSchema.table( "artifact_condition", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), name: varchar("name", { length: 120 }), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("artifact_condition_order_idx").on(table.guideId, table.sortOrder)], ); export const artifactGroups = guidesSchema.table( "artifact_group", { id: uuid("id").defaultRandom().primaryKey(), conditionId: uuid("condition_id").notNull().references(() => artifactConditions.id, { onDelete: "cascade" }), name: text("name").notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("artifact_group_order_idx").on(table.conditionId, 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(), recommendedConditions: jsonb("recommended_conditions") .$type>() .default([]) .notNull(), targets: jsonb("targets").$type>().default([]).notNull(), targetConditions: jsonb("target_conditions") .$type }>>() .default([]) .notNull(), talents: jsonb("talents").$type>().default([]).notNull(), note: text("note"), ...timestamps, }); export const teamConditions = guidesSchema.table( "team_condition", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), name: varchar("name", { length: 120 }), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("team_condition_order_idx").on(table.guideId, table.sortOrder)], ); export const constellationConditions = guidesSchema.table( "constellation_condition", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), name: varchar("name", { length: 120 }), 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" }), conditionId: uuid("condition_id").references(() => teamConditions.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 }).notNull(), artifactKey: varchar("artifact_key", { length: 64 }).notNull(), secondArtifactKey: varchar("second_artifact_key", { length: 64 }), constellation: varchar("constellation", { length: 8 }).default("0").notNull(), refinement: varchar("refinement", { length: 8 }).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)], );