Files
buzz-sheet/db/schema.ts
T
gunshiz 9bc710c94b
CI / Verify (push) Successful in 1m50s
CI / Build immutable images and deploy (push) Successful in 2m59s
feat : ask for noti
2026-10-08 02:23:59 +07:00

817 lines
33 KiB
TypeScript

import { sql } from "drizzle-orm";
import {
bigint,
check,
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 commentsSchema = pgSchema("comments");
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<CommissionRequest>().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<Record<string, unknown>>().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),
index("commission_payment_transferred_at_idx").on(table.transferredAt),
]);
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 }),
imageObjectKeys: jsonb("image_object_keys").$type<string[]>().default([]).notNull(),
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<Record<string, unknown>>().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<Record<string, AuditLogValue>>()
.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<CatalogConstellation[]>().default([]).notNull(),
talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(),
ascension: jsonb("ascension").$type<CatalogAscensionGroup[]>().default([]).notNull(),
leveling: jsonb("leveling").$type<CatalogAscensionGroup[]>().default([]).notNull(),
baseStats: jsonb("base_stats").$type<CatalogBaseStat[]>().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<StygianMechanic[]>().default([]).notNull(),
recommendedMechanics: jsonb("recommended_mechanics").$type<string[]>().default([]).notNull(),
recommendedMechanicTypes: jsonb("recommended_mechanic_types").$type<string[]>().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<string[]>().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"),
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
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"),
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
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<string[]>().default([]).notNull(),
goblet: jsonb("goblet").$type<string[]>().default([]).notNull(),
circlet: jsonb("circlet").$type<string[]>().default([]).notNull(),
substats: jsonb("substats").$type<string[]>().default([]).notNull(),
recommendedConditions: jsonb("recommended_conditions")
.$type<Array<{ name?: string | null; note: string | null; iconMediaId?: string | null; sands: string[]; goblet: string[]; circlet: string[]; substats: string[] }>>()
.default([])
.notNull(),
targets: jsonb("targets").$type<Array<{ stat: string; value: string }>>().default([]).notNull(),
targetConditions: jsonb("target_conditions")
.$type<Array<{ name?: string | null; note: string | null; iconMediaId?: string | null; rows: Array<{ stat: string; value: string }> }>>()
.default([])
.notNull(),
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string; relationToNext?: "gt" | "gte" | "eq" }>>().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"),
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
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"),
iconMediaId: uuid("icon_media_id").references(() => media.id, { onDelete: "restrict" }),
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<Array<{ text: string; color: string }>>().default([]).notNull(),
catalogItems: jsonb("catalog_items")
.$type<Array<{ kind: "character" | "weapon" | "artifact"; key: string }>>()
.default([])
.notNull(),
sheet: jsonb("sheet").$type<ExtraSheet>(),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)],
);
// Discussion data is separate from guide versions and cached guide content.
export const commentThreads = commentsSchema.table("thread", {
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").references(() => guides.id, { onDelete: "cascade" }),
scheduleId: integer("schedule_id").references(() => stygianSchedules.scheduleId, { onDelete: "cascade" }),
}, (table) => [
check("comment_thread_one_target", sql`(${table.guideId} is null) <> (${table.scheduleId} is null)`),
uniqueIndex("comment_thread_guide_unique").on(table.guideId),
uniqueIndex("comment_thread_schedule_unique").on(table.scheduleId),
]);
export const comments = commentsSchema.table("comment", {
id: uuid("id").defaultRandom().primaryKey(),
threadId: uuid("thread_id").notNull().references(() => commentThreads.id, { onDelete: "cascade" }),
authorId: text("author_id").notNull().references(() => users.id),
rootId: uuid("root_id"),
replyToId: uuid("reply_to_id"),
version: integer("version").default(1).notNull(),
hidden: boolean("hidden").default(false).notNull(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
heartedById: text("hearted_by_id").references(() => users.id, { onDelete: "set null" }),
...timestamps,
}, (table) => [
check("comment_reply_shape", sql`(${table.rootId} is null) = (${table.replyToId} is null)`),
check("comment_version_positive", sql`${table.version} > 0`),
uniqueIndex("comment_thread_id_unique").on(table.threadId, table.id),
foreignKey({ columns: [table.threadId, table.rootId], foreignColumns: [table.threadId, table.id] }),
foreignKey({ columns: [table.threadId, table.replyToId], foreignColumns: [table.threadId, table.id] }),
index("comment_thread_root_time_idx").on(table.threadId, table.rootId, table.createdAt, table.id),
index("comment_root_time_idx").on(table.rootId, table.createdAt, table.id),
index("comment_inbox_time_idx").on(table.createdAt, table.id),
]);
export const commentRevisions = commentsSchema.table("revision", {
id: uuid("id").defaultRandom().primaryKey(),
commentId: uuid("comment_id").notNull().references(() => comments.id, { onDelete: "cascade" }),
version: integer("version").notNull(),
text: text("text").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
uniqueIndex("comment_revision_version_unique").on(table.commentId, table.version),
check("comment_text_length", sql`length(${table.text}) <= 4000`),
]);
// Attachment metadata references Bun S3 objects; image bytes stay in the bucket.
export const commentAttachments = commentsSchema.table("attachment", {
id: uuid("id").defaultRandom().primaryKey(),
commentId: uuid("comment_id").notNull().references(() => comments.id, { onDelete: "cascade" }),
objectKey: text("object_key").notNull(),
mimeType: text("mime_type").notNull(),
byteSize: integer("byte_size").notNull(),
}, (table) => [
uniqueIndex("comment_attachment_key_unique").on(table.objectKey),
index("comment_attachment_comment_idx").on(table.commentId),
check("comment_image_size", sql`${table.byteSize} > 0 and ${table.byteSize} <= 10485760`),
check("comment_image_type", sql`${table.mimeType} in ('image/jpeg', 'image/png', 'image/webp')`),
]);
export const commentRevisionAttachments = commentsSchema.table("revision_attachment", {
revisionId: uuid("revision_id").notNull().references(() => commentRevisions.id, { onDelete: "cascade" }),
attachmentId: uuid("attachment_id").notNull().references(() => commentAttachments.id, { onDelete: "cascade" }),
position: integer("position").notNull(),
}, (table) => [
primaryKey({ columns: [table.revisionId, table.attachmentId] }),
uniqueIndex("comment_revision_attachment_position_unique").on(table.revisionId, table.position),
check("comment_attachment_position", sql`${table.position} >= 0 and ${table.position} < 5`),
]);
export const commentReactions = commentsSchema.table("reaction", {
commentId: uuid("comment_id").notNull().references(() => comments.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
value: integer("value").notNull(),
}, (table) => [
primaryKey({ columns: [table.commentId, table.userId] }),
check("comment_reaction_value", sql`${table.value} in (-1, 1)`),
]);
export const commentPushSubscriptions = commentsSchema.table("push_subscription", {
endpoint: text("endpoint").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [index("comment_push_subscription_user_idx").on(table.userId)]);
export const commentReadState = commentsSchema.table("read_state", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
target: text("target").notNull(),
seenAt: timestamp("seen_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [primaryKey({ columns: [table.userId, table.target] })]);
export const notifications = pgTable("notification", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
eventKey: text("event_key").notNull(),
kind: text("kind").notNull(),
title: text("title").notNull(),
body: text("body").notNull(),
url: text("url").notNull(),
adminOnly: boolean("admin_only").default(false).notNull(),
commentId: uuid("comment_id").references(() => comments.id, { onDelete: "cascade" }),
messageId: uuid("message_id").references(() => commissionMessages.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
readAt: timestamp("read_at", { withTimezone: true }),
}, (table) => [
uniqueIndex("notification_user_event_unique").on(table.userId, table.eventKey),
index("notification_user_time_idx").on(table.userId, table.createdAt, table.id),
index("notification_user_unread_idx").on(table.userId).where(sql`${table.readAt} is null`),
index("notification_comment_idx").on(table.commentId),
index("notification_message_idx").on(table.messageId),
]);