318 lines
22 KiB
TypeScript
318 lines
22 KiB
TypeScript
import "server-only";
|
|
|
|
import { notifyCommentChange } from "./events";
|
|
|
|
import { and, asc, desc, eq, inArray, isNull, lt, or, sql, type SQL } from "drizzle-orm";
|
|
import { alias } from "drizzle-orm/pg-core";
|
|
import { getDb, type Database } from "@/db";
|
|
import { commentThreads, comments, commentRevisions, commentAttachments, commentRevisionAttachments, commentReactions, catalogCharacters, guides, stygianSchedules, users, sessions, notifications } from "@/db/schema";
|
|
import { notifyNotificationChange } from "@/lib/notifications/events";
|
|
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
|
import { getCustomerSession } from "@/lib/auth/server";
|
|
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
|
|
import { auditActor, writeAuditLog } from "@/lib/audit-log";
|
|
import { HttpError } from "@/lib/security/http";
|
|
import { decodeCursor, encodeCursor, parseTarget, uuidSchema } from "./validation";
|
|
import type { CommentHistoryItem, CommentImage, CommentItem, CommentPage, CommentViewer } from "./types";
|
|
|
|
type Reader = Pick<Database, "select" | "selectDistinct" | "insert" | "update" | "delete" | "execute">;
|
|
export async function getCommentViewer(): Promise<CommentViewer | null> {
|
|
const session = await getCustomerSession();
|
|
if (!session) return null;
|
|
const [user] = await getDb().select().from(users).where(eq(users.id, session.user.id)).limit(1);
|
|
if (!user || user.banned) return null;
|
|
return { id: user.id, admin: isAuthorizedAdmin(user), name: user.name, image: user.image };
|
|
}
|
|
export async function requireCommentViewer() {
|
|
const viewer = await getCommentViewer();
|
|
if (!viewer) throw new HttpError(401, "sign-in-required");
|
|
return viewer;
|
|
}
|
|
export function requireCommentAdmin(viewer: CommentViewer | null): asserts viewer is CommentViewer {
|
|
if (!viewer?.admin) throw new HttpError(403, "admin-required");
|
|
}
|
|
export function commentId(value: string) {
|
|
if (!uuidSchema.safeParse(value).success) throw new HttpError(404, "comment-not-found");
|
|
return value;
|
|
}
|
|
|
|
export async function getCommentTarget(target: string, viewer: CommentViewer | null, db: Reader = getDb()) {
|
|
const parsed = parseTarget(target);
|
|
if (parsed.kind === "guide") {
|
|
const [guide] = await db.select({ id: guides.id, name: guides.name, slug: guides.slug, public: guides.isPublic, trashedAt: guides.trashedAt })
|
|
.from(guides).where(eq(guides.id, parsed.id)).limit(1);
|
|
if (!guide || (!viewer?.admin && (!guide.public || guide.trashedAt))) throw new HttpError(404, "guide-not-found");
|
|
return { target: `guide:${guide.id}`, name: guide.name, href: `/${guide.slug}/comment`, guideId: guide.id, scheduleId: null, writable: !guide.trashedAt };
|
|
}
|
|
const [schedule] = await db.select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName })
|
|
.from(stygianSchedules).where(eq(stygianSchedules.scheduleId, parsed.id)).limit(1);
|
|
if (!schedule) throw new HttpError(404, "schedule-not-found");
|
|
return { target: `stygian:${schedule.id}`, name: `Stygian (${schedule.name})`, href: `/stygian/comment?schedule=${schedule.id}`, guideId: null, scheduleId: schedule.id, writable: true };
|
|
}
|
|
|
|
export async function findCommentThread(target: string, viewer: CommentViewer | null) {
|
|
const destination = await getCommentTarget(target, viewer);
|
|
const [thread] = await getDb().select().from(commentThreads).where(destination.guideId
|
|
? eq(commentThreads.guideId, destination.guideId) : eq(commentThreads.scheduleId, destination.scheduleId!)).limit(1);
|
|
return { destination, thread };
|
|
}
|
|
export async function ensureCommentThread(target: string, viewer: CommentViewer) {
|
|
const destination = await getCommentTarget(target, viewer);
|
|
if (!destination.writable) throw new HttpError(409, "guide-trashed");
|
|
await getDb().insert(commentThreads).values({ guideId: destination.guideId, scheduleId: destination.scheduleId }).onConflictDoNothing();
|
|
return (await findCommentThread(target, viewer)).thread!;
|
|
}
|
|
|
|
export async function authorizeComment(id: string, viewer: CommentViewer | null, db: Reader = getDb()) {
|
|
commentId(id);
|
|
const [row] = await db.select({ comment: comments, thread: commentThreads }).from(comments)
|
|
.innerJoin(commentThreads, eq(commentThreads.id, comments.threadId)).where(eq(comments.id, id)).limit(1);
|
|
if (!row) throw new HttpError(404, "comment-not-found");
|
|
const target = row.thread.guideId ? `guide:${row.thread.guideId}` : `stygian:${row.thread.scheduleId}`;
|
|
const destination = await getCommentTarget(target, viewer, db);
|
|
const [root] = row.comment.rootId ? await db.select({ hidden: comments.hidden }).from(comments).where(eq(comments.id, row.comment.rootId)).limit(1) : [];
|
|
if (!viewer?.admin && (row.comment.hidden || root?.hidden)) throw new HttpError(404, "comment-not-found");
|
|
return { ...row, destination, rootHidden: root?.hidden ?? false };
|
|
}
|
|
|
|
const root = alias(comments, "discussion_root");
|
|
const parent = alias(comments, "discussion_parent");
|
|
const recipient = alias(users, "discussion_recipient");
|
|
const likes = sql<number>`(select count(*)::int from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.value} = 1)`;
|
|
const replyCount = sql<number>`(select count(*)::int from ${comments} replies where replies.root_id = ${comments.id})`;
|
|
const publicTarget = sql<boolean>`(${commentThreads.guideId} is null or (${guides.isPublic} and ${guides.trashedAt} is null))`;
|
|
|
|
async function revisionImages(ids: string[], db: Reader = getDb()) {
|
|
const result = new Map<string, CommentImage[]>();
|
|
if (!ids.length) return result;
|
|
const rows = await db.select({ revisionId: commentRevisionAttachments.revisionId, id: commentAttachments.id, objectKey: commentAttachments.objectKey })
|
|
.from(commentRevisionAttachments).innerJoin(commentAttachments, eq(commentAttachments.id, commentRevisionAttachments.attachmentId))
|
|
.where(inArray(commentRevisionAttachments.revisionId, ids)).orderBy(asc(commentRevisionAttachments.position));
|
|
for (const row of rows) {
|
|
const group = result.get(row.revisionId) ?? [];
|
|
group.push({ id: row.id, url: publicMediaUrl(row.objectKey) });
|
|
result.set(row.revisionId, group);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function listComments(options: {
|
|
viewer: CommentViewer | null; target?: string; rootId?: string; id?: string; cursor?: string | null;
|
|
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) {
|
|
const { thread } = await findCommentThread(options.target, viewer);
|
|
if (!thread) return { items: [], nextCursor: null, viewer };
|
|
conditions.push(eq(comments.threadId, thread.id));
|
|
} else if (!options.inbox && !options.rootId) throw new HttpError(400, "target-required");
|
|
if (options.rootId) {
|
|
const context = await authorizeComment(options.rootId, viewer);
|
|
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 (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 (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)`);
|
|
const cursor = decodeCursor(options.cursor ?? null);
|
|
const top = !options.inbox && !options.rootId && options.sort === "top";
|
|
const ascending = Boolean(options.rootId);
|
|
const timeBefore = cursor ? sql`(${comments.createdAt}, ${comments.id}) < (${cursor.time}::timestamptz, ${cursor.id}::uuid)` : undefined;
|
|
if (cursor) conditions.push(top
|
|
? or(sql`${likes} < ${cursor.likes}`, and(sql`${likes} = ${cursor.likes}`, timeBefore))!
|
|
: ascending ? sql`(${comments.createdAt}, ${comments.id}) > (${cursor.time}::timestamptz, ${cursor.id}::uuid)` : timeBefore!);
|
|
const rows = await getDb().select({
|
|
cursorTime: sql<string>`to_char(${comments.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
|
|
comment: comments, revision: commentRevisions, authorName: users.name, authorImage: users.image,
|
|
authorAdmin: sql<boolean>`coalesce(${users.role} = 'admin' and ${users.emailVerified}, false)`,
|
|
authorBanned: users.banned,
|
|
replyToName: recipient.name, rootHidden: root.hidden, likes, replyCount,
|
|
hasReplies: sql<boolean>`exists(select 1 from ${comments} children where children.root_id = coalesce(${comments.rootId}, ${comments.id}) and children.reply_to_id = ${comments.id} and children.deleted_at is null)`,
|
|
reaction: viewer ? sql<number>`coalesce((select value from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.userId} = ${viewer.id}), 0)` : sql<number>`0`,
|
|
guideId: commentThreads.guideId, scheduleId: commentThreads.scheduleId, guideName: guides.name, guideCoverId: guides.coverMediaId, guideSlug: guides.slug, guideTrashedAt: guides.trashedAt,
|
|
scheduleName: stygianSchedules.challengeName, publicTarget,
|
|
}).from(comments)
|
|
.innerJoin(commentThreads, eq(commentThreads.id, comments.threadId))
|
|
.innerJoin(commentRevisions, and(eq(commentRevisions.commentId, comments.id), eq(commentRevisions.version, comments.version)))
|
|
.innerJoin(users, eq(users.id, comments.authorId))
|
|
.leftJoin(root, eq(root.id, comments.rootId)).leftJoin(parent, eq(parent.id, comments.replyToId)).leftJoin(recipient, eq(recipient.id, parent.authorId))
|
|
.leftJoin(guides, eq(guides.id, commentThreads.guideId)).leftJoin(stygianSchedules, eq(stygianSchedules.scheduleId, commentThreads.scheduleId))
|
|
.where(and(...conditions)).orderBy(...(top ? [desc(likes)] : []), ascending ? asc(comments.createdAt) : desc(comments.createdAt), ascending ? asc(comments.id) : desc(comments.id)).limit(21);
|
|
const page = rows.slice(0, 20);
|
|
const images = await revisionImages(page.filter((row) => viewer?.admin || (!row.comment.deletedAt && !row.comment.hidden)).map((row) => row.revision.id));
|
|
const items: CommentItem[] = page.map((row) => {
|
|
const c = row.comment;
|
|
const hidden = c.hidden || Boolean(row.rootHidden);
|
|
const contentAllowed = viewer?.admin || (!c.deletedAt && !hidden);
|
|
return {
|
|
id: c.id, rootId: c.rootId, replyToId: c.replyToId, replyToName: row.replyToName,
|
|
authorName: row.authorName, authorImage: row.authorImage, authorAdmin: row.authorAdmin,
|
|
authorBanned: Boolean(viewer?.admin && row.authorBanned),
|
|
canBanAuthor: Boolean(viewer?.admin && viewer.id !== c.authorId && !row.authorAdmin && !row.guideTrashedAt),
|
|
targetImage: row.guideCoverId ? `/media/${row.guideCoverId}` : null,
|
|
text: contentAllowed ? row.revision.text : "", images: contentAllowed ? images.get(row.revision.id) ?? [] : [],
|
|
version: c.version, createdAt: c.createdAt.toISOString(), editedAt: row.revision.createdAt.toISOString(), hidden, ownHidden: c.hidden,
|
|
deleted: Boolean(c.deletedAt), hearted: Boolean(c.heartedById), likes: Number(row.likes), reaction: Number(row.reaction),
|
|
replyCount: Number(row.replyCount), hasReplies: row.hasReplies, canEdit: viewer?.id === c.authorId && !c.deletedAt && !hidden && !row.guideTrashedAt,
|
|
canModerate: Boolean(viewer?.admin) && !row.guideTrashedAt,
|
|
canInteract: !c.deletedAt && !hidden && !row.guideTrashedAt && (row.publicTarget || Boolean(viewer?.admin)),
|
|
target: row.guideId ? `guide:${row.guideId}` : `stygian:${row.scheduleId}`,
|
|
targetName: row.guideName ?? `Stygian (${row.scheduleName})`,
|
|
href: `${row.guideSlug ? `/${row.guideSlug}/comment` : `/stygian/comment?schedule=${row.scheduleId}`}#comment-${c.rootId ?? c.id}`,
|
|
};
|
|
});
|
|
const last = page.at(-1);
|
|
return { items, nextCursor: rows.length > 20 && last ? encodeCursor({ time: last.cursorTime, id: last.comment.id, likes: Number(last.likes) }) : null, viewer };
|
|
}
|
|
|
|
export async function commentHistory(id: string, viewer: CommentViewer | null, cursor: string | null = null) {
|
|
const context = await authorizeComment(id, viewer);
|
|
if (context.comment.deletedAt && !viewer?.admin) throw new HttpError(404, "comment-not-found");
|
|
const before = cursor ? Number(cursor) : context.comment.version + 1;
|
|
if (!Number.isSafeInteger(before) || before < 1) throw new HttpError(400, "invalid-cursor");
|
|
const rows = await getDb().select().from(commentRevisions).where(and(eq(commentRevisions.commentId, id), lt(commentRevisions.version, before)))
|
|
.orderBy(desc(commentRevisions.version)).limit(21);
|
|
const page = rows.slice(0, 20);
|
|
const images = await revisionImages(page.map((revision) => revision.id));
|
|
return { items: page.map((revision): CommentHistoryItem => ({ id: revision.id, version: revision.version, text: revision.text,
|
|
createdAt: revision.createdAt.toISOString(), images: images.get(revision.id) ?? [] })), nextCursor: rows.length > 20 ? String(page.at(-1)!.version) : null };
|
|
}
|
|
|
|
/** All discussion mutations lock the thread first, including hide/delete and replies. */
|
|
export async function withCommentLock<T>(id: string, viewer: CommentViewer, task: (db: Reader, context: Awaited<ReturnType<typeof authorizeComment>>) => Promise<T>) {
|
|
const initial = await authorizeComment(id, viewer);
|
|
return getDb().transaction(async (tx) => {
|
|
await tx.select({ id: commentThreads.id }).from(commentThreads).where(eq(commentThreads.id, initial.thread.id)).for("update");
|
|
const context = await authorizeComment(id, viewer, tx);
|
|
if (!context.destination.writable) throw new HttpError(409, "guide-trashed");
|
|
return task(tx, context);
|
|
});
|
|
}
|
|
|
|
export async function mutateComment(id: string, viewer: CommentViewer, action: "delete" | "reaction" | "moderation" | "heart" | "ban", value?: number | boolean) {
|
|
let target = "";
|
|
let deletedImages: { objectKey: string }[] = [];
|
|
let notificationUsers: { userId: string }[] = [];
|
|
const result = await withCommentLock(id, viewer, async (tx, context) => {
|
|
target = context.destination.target;
|
|
const c = context.comment;
|
|
if (action === "ban") {
|
|
requireCommentAdmin(viewer);
|
|
const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified })
|
|
.from(users).where(eq(users.id, c.authorId)).limit(1).for("update");
|
|
if (!author || author.id === viewer.id || Boolean(isAuthorizedAdmin(author))) throw new HttpError(403, "comment-author-ban-not-allowed");
|
|
await tx.update(users).set({ banned: Boolean(value), banReason: value ? "Banned by comment moderation" : null, banExpires: null }).where(eq(users.id, author.id));
|
|
if (value) await tx.delete(sessions).where(eq(sessions.userId, author.id));
|
|
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
|
|
await writeAuditLog(tx, auditActor(actor), { action: value ? "comment.author_banned" : "comment.author_unbanned", targetType: "comment", targetId: id,
|
|
metadata: { discussionTarget: target, authorId: author.id } });
|
|
return;
|
|
}
|
|
if (action === "moderation") {
|
|
requireCommentAdmin(viewer);
|
|
notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications)
|
|
.innerJoin(comments, eq(comments.id, notifications.commentId)).where(or(eq(comments.id, id), eq(comments.rootId, id)));
|
|
await tx.update(comments).set({ hidden: value as boolean }).where(eq(comments.id, id));
|
|
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
|
|
await writeAuditLog(tx, auditActor(actor), { action: value ? "comment.hidden" : "comment.restored", targetType: "comment", targetId: id,
|
|
metadata: { discussionTarget: context.destination.target } });
|
|
return;
|
|
}
|
|
if ((c.deletedAt || c.hidden || context.rootHidden) && !(action === "delete" && viewer.admin)) throw new HttpError(409, "comment-unavailable");
|
|
if (action === "delete") {
|
|
if (c.authorId !== viewer.id && !viewer.admin) throw new HttpError(403, "not-comment-author");
|
|
const descendants = sql`with recursive descendants(id) as (
|
|
select ${comments.id} from ${comments} where ${comments.id} = ${id} and ${comments.threadId} = ${c.threadId}
|
|
union
|
|
select child.id from ${comments} child join descendants parent on child.reply_to_id = parent.id
|
|
where child.thread_id = ${c.threadId}
|
|
) select id from descendants`;
|
|
deletedImages = await tx.select({ objectKey: commentAttachments.objectKey }).from(commentAttachments)
|
|
.where(sql`${commentAttachments.commentId} in (${descendants})`);
|
|
notificationUsers = await tx.selectDistinct({ userId: notifications.userId }).from(notifications)
|
|
.where(sql`${notifications.commentId} in (${descendants})`);
|
|
// Delete the entire subtree in one statement so self-referencing foreign keys remain valid.
|
|
// Revisions, attachment metadata, revision links, and reactions cascade automatically.
|
|
await tx.delete(comments).where(sql`${comments.id} in (${descendants})`);
|
|
if (viewer.admin) {
|
|
const [actor] = await tx.select({ id: users.id, name: users.name }).from(users).where(eq(users.id, viewer.id));
|
|
await writeAuditLog(tx, auditActor(actor), { action: "comment.deleted", targetType: "comment", targetId: id,
|
|
metadata: { discussionTarget: context.destination.target } });
|
|
}
|
|
} else if (action === "heart") {
|
|
requireCommentAdmin(viewer);
|
|
await tx.update(comments).set({ heartedById: value ? viewer.id : null }).where(eq(comments.id, id));
|
|
if (value && !c.heartedById && c.authorId !== viewer.id) {
|
|
const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified, banned: users.banned })
|
|
.from(users).where(eq(users.id, c.authorId)).limit(1);
|
|
if (!author || author.banned) return;
|
|
let destination;
|
|
try {
|
|
({ destination } = await authorizeComment(id, { id: author.id, admin: isAuthorizedAdmin(author) }, tx));
|
|
} catch (cause) {
|
|
if (cause instanceof HttpError && cause.status === 404) return;
|
|
throw cause;
|
|
}
|
|
const url = `${destination.href}${c.rootId ? `${destination.href.includes("?") ? "&" : "?"}reply=${id}` : ""}#comment-${c.rootId ?? id}`;
|
|
notificationUsers = await tx.insert(notifications).values({
|
|
userId: author.id, eventKey: `comment:${id}:heart`, kind: "comment_heart",
|
|
title: "คุณได้รับหัวใจจากแอดมิน", body: `แอดมินให้หัวใจความคิดเห็นของคุณใน ${destination.name}`,
|
|
url, commentId: id,
|
|
}).onConflictDoNothing({ target: [notifications.userId, notifications.eventKey] }).returning({ userId: notifications.userId });
|
|
}
|
|
} else if (value === 0) {
|
|
await tx.delete(commentReactions).where(and(eq(commentReactions.commentId, id), eq(commentReactions.userId, viewer.id)));
|
|
} else {
|
|
await tx.insert(commentReactions).values({ commentId: id, userId: viewer.id, value: value as number })
|
|
.onConflictDoUpdate({ target: [commentReactions.commentId, commentReactions.userId], set: { value: value as number } });
|
|
}
|
|
});
|
|
for (let offset = 0; offset < notificationUsers.length; offset += 10)
|
|
await Promise.all(notificationUsers.slice(offset, offset + 10).map(user => notifyNotificationChange(user.userId)));
|
|
await notifyCommentChange(target);
|
|
if (deletedImages.length) {
|
|
try {
|
|
const storage = await getMediaStorage();
|
|
const cleanup = await Promise.allSettled(deletedImages.map((image) => storage.delete(image.objectKey)));
|
|
if (cleanup.some((entry) => entry.status === "rejected")) console.error("Comment image cleanup failed");
|
|
} catch { console.error("Comment image cleanup failed"); }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function commentImage(id: string, viewer: CommentViewer | null) {
|
|
commentId(id);
|
|
const [image] = await getDb().select().from(commentAttachments).where(eq(commentAttachments.id, id)).limit(1);
|
|
if (!image) throw new HttpError(404, "image-not-found");
|
|
const context = await authorizeComment(image.commentId, viewer);
|
|
if (context.comment.deletedAt && !viewer?.admin) throw new HttpError(404, "image-not-found");
|
|
return image;
|
|
}
|
|
|
|
export async function listCommentTargets() {
|
|
const [characters, schedules] = await Promise.all([
|
|
getDb().select({ id: guides.id, name: guides.name, characterKey: guides.characterKey, imageKey: catalogCharacters.imageKey, rarity: catalogCharacters.rarity }).from(guides).leftJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey)).orderBy(asc(guides.name)),
|
|
getDb().select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName }).from(stygianSchedules).orderBy(desc(stygianSchedules.scheduleId)),
|
|
]);
|
|
return [...characters.map((g) => ({ value: `guide:${g.id}`, label: g.name, image: g.imageKey ? publicMediaUrl(g.imageKey) : null, rarity: g.rarity, characterKey: g.characterKey })), ...schedules.map((s) => ({ value: `stygian:${s.id}`, label: `Stygian (${s.name})`, image: null, rarity: null, characterKey: null }))];
|
|
}
|