feat : copy youtube studio
CI / Verify (push) Successful in 2m8s
CI / Build immutable images and deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-08 03:26:00 +07:00 Unverified
parent 9bc710c94b
commit b665fe7497
16 changed files with 196 additions and 46 deletions
+23 -4
View File
@@ -16,6 +16,24 @@ type ParsedForm = ReturnType<typeof parseCommentForm>;
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
type Writer = Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0];
async function uploadCommentImage(storage: Awaited<ReturnType<typeof getMediaStorage>>, image: Upload, bytes: Uint8Array) {
for (let attempt = 0; ; attempt++) {
try {
// Retry the same object key, never the database mutation.
await storage.write(image.objectKey, bytes, { type: image.mimeType, acl: "public-read", retry: 0 });
return;
} catch (cause) {
const error = cause as { code?: string; name?: string; statusCode?: number } | null;
const transient = error && (
["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "ConnectionClosed", "SocketClosed", "InternalError", "SlowDown", "ServiceUnavailable", "RequestTimeout"].includes(error.code ?? "") ||
error.name === "TimeoutError" || error.statusCode === 429 || (error.statusCode !== undefined && error.statusCode >= 500)
);
if (!transient || attempt === 2) throw new HttpError(503, "comment-image-upload-failed");
await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)));
}
}
}
async function saveRevision(tx: Pick<Writer, "insert" | "select">, id: string, version: number, form: ParsedForm, uploaded: Upload[]) {
if (form.keep.length) {
const own = await tx.select({ id: commentAttachments.id }).from(commentAttachments)
@@ -45,18 +63,19 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
const image = { id: crypto.randomUUID(), objectKey: `comments/${crypto.randomUUID()}`, mimeType: file.type, byteSize: file.size };
uploaded.push(image);
// Browser images load directly from the configured S3 public CDN.
await storage!.write(image.objectKey, bytes, { type: file.type, acl: "public-read" });
await uploadCommentImage(storage!, image, bytes);
}
if (options.id) {
await withCommentLock(options.id, viewer, async (tx, context) => {
const version = await withCommentLock(options.id, viewer, async (tx, context) => {
const c = context.comment;
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
if (c.version !== form.version) throw new HttpError(409, "comment-edited-reload");
await saveRevision(tx, c.id, c.version + 1, form, uploaded);
await tx.update(comments).set({ version: c.version + 1 }).where(eq(comments.id, c.id));
return c.version + 1;
});
return { id: options.id };
return { id: options.id, version };
}
const thread = await ensureCommentThread(options.target!, viewer);
return await getDb().transaction(async (tx) => {
@@ -72,7 +91,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
}
const [comment] = await tx.insert(comments).values({ threadId: thread.id, authorId: viewer.id, rootId, replyToId: form.replyToId }).returning({ id: comments.id });
await saveRevision(tx, comment.id, 1, form, uploaded);
return { id: comment.id };
return { id: comment.id, version: 1 };
});
} catch (cause) {
if (uploaded.length) {
+8 -5
View File
@@ -8,9 +8,11 @@ import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push";
import { createNotifications } from "@/lib/notifications/repository";
import { authorizeComment } from "./repository";
export async function sendCommentPush(id: string) {
export async function sendCommentPush(id: string, version?: number) {
const [reply] = await getDb().select().from(comments).where(eq(comments.id, id)).limit(1);
if (!reply || reply.hidden || reply.deletedAt) return;
const notificationVersion = version ?? reply.version;
const edited = notificationVersion > 1;
let recipients: { id: string; admin: boolean }[];
let url: string;
let title: string;
@@ -25,7 +27,7 @@ export async function sendCommentPush(id: string) {
} catch { return; }
recipients = [{ id: recipient.id, admin: false }];
url = `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}`;
title = "ตอบกลับความคิดเห็นของคุณ";
title = edited ? "แก้ไขคำตอบกลับความคิดเห็นของคุณ" : "ตอบกลับความคิดเห็นของคุณ";
} else {
recipients = (await getDb().select({ id: users.id }).from(users)
.where(and(eq(users.role, "admin"), eq(users.emailVerified, true), ne(users.id, reply.authorId), sql`${users.banned} is not true`)))
@@ -37,15 +39,16 @@ export async function sendCommentPush(id: string) {
} catch { return; }
if (!destination.writable) return;
url = `/admin/comments?target=${encodeURIComponent(destination.target)}&comment=${id}`;
title = `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
title = edited ? `แก้ไขความคิดเห็นใน ${destination.name}` : `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
}
const [author] = await getDb().select({ name: users.name }).from(users).where(eq(users.id, reply.authorId)).limit(1);
const [revision] = await getDb().select({ text: commentRevisions.text }).from(commentRevisions)
.where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, reply.version))).limit(1);
.where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, notificationVersion))).limit(1);
if (!revision) return;
const notificationTitle = `${author?.name ?? "ผู้ใช้"} ${title}`;
const body = revision?.text.slice(0, 300) || "ส่งรูปภาพ";
await createNotifications(recipients.map(recipient => ({ userId: recipient.id, adminOnly: recipient.admin, url })), {
eventKey: `comment:${id}`, kind: reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
eventKey: edited ? `comment:${id}:edit:${notificationVersion}` : `comment:${id}`, kind: edited ? "comment_edit" : reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
});
if (!pushPublicKey()) return;
const subscriptions = await getDb().select().from(commentPushSubscriptions).where(inArray(commentPushSubscriptions.userId, recipients.map(recipient => recipient.id)));
+12 -4
View File
@@ -98,10 +98,11 @@ async function revisionImages(ids: string[], db: Reader = getDb()) {
export async function listComments(options: {
viewer: CommentViewer | null; target?: string; rootId?: string; id?: string; cursor?: string | null;
sort?: string; inbox?: boolean; status?: string; unanswered?: boolean;
sort?: string; inbox?: boolean; grouped?: boolean; status?: string; unanswered?: boolean;
}): Promise<CommentPage> {
const { viewer } = options;
if (options.inbox) requireCommentAdmin(viewer);
const grouped = Boolean(options.inbox && options.grouped && !options.id && !options.rootId);
const conditions: SQL[] = [];
if (options.id) conditions.push(eq(comments.id, commentId(options.id)));
if (options.target) {
@@ -114,14 +115,21 @@ export async function listComments(options: {
if (context.comment.rootId) throw new HttpError(400, "root-required");
if (options.target && context.destination.target !== options.target) throw new HttpError(404, "comment-not-found");
conditions.push(eq(comments.rootId, options.rootId));
} else if (!options.inbox && !options.id) conditions.push(isNull(comments.rootId));
} else if (grouped || (!options.inbox && !options.id)) conditions.push(isNull(comments.rootId));
if (!viewer?.admin) {
conditions.push(sql`coalesce(${root.hidden}, false) = false`, publicTarget);
if (!options.rootId) conditions.push(eq(comments.hidden, false));
if (!options.rootId && !options.id) conditions.push(or(isNull(comments.deletedAt), sql`${replyCount} > 0`)!);
}
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!);
if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt));
if (grouped) {
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), sql`exists (
select 1 from ${comments} reply where reply.root_id = ${comments.id} and reply.hidden)`)!);
if (options.status === "visible") conditions.push(eq(comments.hidden, false), or(isNull(comments.deletedAt), sql`exists (
select 1 from ${comments} reply where reply.root_id = ${comments.id} and not reply.hidden and reply.deleted_at is null)`)!);
} else {
if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!);
if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt));
}
if (options.unanswered) conditions.push(isNull(comments.rootId), isNull(comments.deletedAt), sql`not exists (
select 1 from ${comments} reply join ${users} author on author.id = reply.author_id
where reply.root_id = ${comments.id} and author.role = 'admin' and author.email_verified and not reply.hidden and reply.deleted_at is null)`);