feat : comment feature
CI / Verify (push) Successful in 2m30s
CI / Build immutable images and deploy (push) Successful in 3m9s

This commit is contained in:
2026-10-08 01:40:08 +07:00 Unverified
parent 8c8815cda5
commit 5812d99715
65 changed files with 27013 additions and 10 deletions
+96
View File
@@ -1,5 +1,7 @@
import { sql } from "drizzle-orm";
import {
bigint,
check,
boolean,
foreignKey,
index,
@@ -35,6 +37,7 @@ export type AuditLogValue =
const authSchema = pgSchema("auth");
const catalogSchema = pgSchema("catalog");
const guidesSchema = pgSchema("guides");
const commentsSchema = pgSchema("comments");
const commissionSchema = pgSchema("commission");
const timestamps = {
@@ -697,3 +700,96 @@ export const extraSectionRows = guidesSchema.table(
},
(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] })]);