feat(guides): add structured image-first editor
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
|
||||
import { getDb } from "@/db/client";
|
||||
import {
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogSyncJobs,
|
||||
catalogVersions,
|
||||
catalogWeapons,
|
||||
} from "@/db/schema";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
|
||||
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
||||
const API_ROOT = "https://api.lunaris.moe/data";
|
||||
|
||||
const versionSchema = z.object({
|
||||
version: z.string().regex(/^\d+(?:\.\d+)+$/u),
|
||||
timestamp: z.number().int().optional(),
|
||||
});
|
||||
const characterSchema = z.record(z.string(), z.object({
|
||||
enName: z.string(),
|
||||
CardImg: z.string(),
|
||||
element: z.string().optional(),
|
||||
qualityType: z.string().optional(),
|
||||
weaponType: z.string().optional(),
|
||||
}));
|
||||
const weaponSchema = z.record(z.string(), z.object({
|
||||
enName: z.string(),
|
||||
weaponIcon: z.string(),
|
||||
qualityType: z.string().optional(),
|
||||
weaponType: z.string().optional(),
|
||||
}));
|
||||
const artifactSchema = z.record(z.string(), z.object({
|
||||
enName: z.string(),
|
||||
setIcon: z.string(),
|
||||
}));
|
||||
|
||||
async function getJson(url: string): Promise<unknown> {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`Lunaris returned ${response.status} for ${url}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function webpName(value: string): string {
|
||||
return value.endsWith(".webp") ? value : `${value}.webp`;
|
||||
}
|
||||
|
||||
function rarity(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
return { QUALITY_WHITE: 1, QUALITY_GREEN: 2, QUALITY_BLUE: 3, QUALITY_PURPLE: 4, QUALITY_ORANGE: 5 }[value] ?? null;
|
||||
}
|
||||
|
||||
async function uploadAssets(
|
||||
jobId: string,
|
||||
assets: Array<{ source: string; target: string }>,
|
||||
): Promise<Set<string>> {
|
||||
const storage = await getMediaStorage();
|
||||
let next = 0;
|
||||
let completed = 0;
|
||||
const missing = new Set<string>();
|
||||
await getDb().update(catalogSyncJobs).set({ total: assets.length, progress: 0 }).where(eq(catalogSyncJobs.id, jobId));
|
||||
async function worker() {
|
||||
while (next < assets.length) {
|
||||
const asset = assets[next++];
|
||||
const response = await fetch(asset.source, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
|
||||
if (response.status === 404) missing.add(asset.target);
|
||||
else if (!response.ok) throw new Error(`Asset returned ${response.status}: ${asset.source}`);
|
||||
else await storage.write(asset.target, response, { type: "image/webp", acl: "public-read" });
|
||||
completed += 1;
|
||||
if (completed % 10 === 0 || completed === assets.length) {
|
||||
await getDb().update(catalogSyncJobs).set({ progress: completed }).where(eq(catalogSyncJobs.id, jobId));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(4, assets.length) }, () => worker()));
|
||||
return missing;
|
||||
}
|
||||
|
||||
export async function claimCatalogSyncJob() {
|
||||
const [pending] = await getDb().select().from(catalogSyncJobs).where(eq(catalogSyncJobs.status, "pending")).orderBy(asc(catalogSyncJobs.createdAt)).limit(1);
|
||||
if (!pending) return null;
|
||||
const [claimed] = await getDb().update(catalogSyncJobs).set({ status: "processing", startedAt: new Date(), error: null }).where(eq(catalogSyncJobs.id, pending.id)).returning();
|
||||
return claimed ?? null;
|
||||
}
|
||||
|
||||
export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
try {
|
||||
const versionInfo = versionSchema.parse(await getJson(VERSION_URL));
|
||||
const version = versionInfo.version;
|
||||
await getDb().update(catalogSyncJobs).set({ version }).where(eq(catalogSyncJobs.id, jobId));
|
||||
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
|
||||
getJson(`${API_ROOT}/${version}/charlist.json`),
|
||||
getJson(`${API_ROOT}/${version}/weaponlist.json`),
|
||||
getJson(`${API_ROOT}/${version}/artifactlist.json`),
|
||||
]);
|
||||
const characterData = characterSchema.parse(charactersRaw);
|
||||
const weaponData = weaponSchema.parse(weaponsRaw);
|
||||
const artifactData = artifactSchema.parse(artifactsRaw);
|
||||
|
||||
let characters = Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.CardImg).map(([key, value]) => ({
|
||||
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/characters/${webpName(value.CardImg)}`,
|
||||
sourceImage: `${API_ROOT}/assets/gachaicon/${encodeURIComponent(webpName(value.CardImg))}`,
|
||||
}));
|
||||
let weapons = Object.entries(weaponData).filter(([, value]) => value.enName && value.enName !== "???" && value.weaponIcon).map(([key, value]) => ({
|
||||
version, key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/weapons/${webpName(value.weaponIcon)}`,
|
||||
sourceImage: `${API_ROOT}/assets/weaponicon/${encodeURIComponent(webpName(value.weaponIcon))}`,
|
||||
}));
|
||||
let artifacts = Object.entries(artifactData).filter(([, value]) => value.enName && value.enName !== "???" && value.setIcon).map(([key, value]) => ({
|
||||
version, key, name: value.enName,
|
||||
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`,
|
||||
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
|
||||
}));
|
||||
const uniqueAssets = new Map<string, { source: string; target: string }>();
|
||||
for (const item of [...characters, ...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
|
||||
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
|
||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
||||
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
|
||||
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
|
||||
|
||||
const storage = await getMediaStorage();
|
||||
await Promise.all([
|
||||
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/weapons.json`, JSON.stringify(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/artifacts.json`, JSON.stringify(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
]);
|
||||
|
||||
await getDb().transaction(async (tx) => {
|
||||
await tx.insert(catalogVersions).values({ version, sourceTimestamp: versionInfo.timestamp ?? null }).onConflictDoUpdate({ target: catalogVersions.version, set: { sourceTimestamp: versionInfo.timestamp ?? null, syncedAt: new Date() } });
|
||||
await tx.delete(catalogCharacters).where(eq(catalogCharacters.version, version));
|
||||
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
|
||||
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.version, version));
|
||||
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
|
||||
if (weapons.length) await tx.insert(catalogWeapons).values(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
|
||||
if (artifacts.length) await tx.insert(catalogArtifacts).values(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey })));
|
||||
await tx.update(catalogVersions).set({ active: false });
|
||||
await tx.update(catalogVersions).set({ active: true, syncedAt: new Date() }).where(eq(catalogVersions.version, version));
|
||||
await tx.update(catalogSyncJobs).set({ status: "completed", completedAt: new Date(), progress: uniqueAssets.size, total: uniqueAssets.size }).where(eq(catalogSyncJobs.id, jobId));
|
||||
});
|
||||
await storage.write("catalog/current.json", JSON.stringify({ version }), { type: "application/json", acl: "public-read" });
|
||||
} catch (cause) {
|
||||
await getDb().update(catalogSyncJobs).set({ status: "failed", completedAt: new Date(), error: cause instanceof Error ? cause.message.slice(0, 4_000) : "Unknown sync failure" }).where(eq(catalogSyncJobs.id, jobId));
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function calculateTeamMetrics(damage: number[], rotationSeconds: number) {
|
||||
const totalDamage = damage.reduce((sum, value) => sum + value, 0);
|
||||
if (!Number.isFinite(rotationSeconds) || rotationSeconds <= 0 || damage.some((value) => !Number.isFinite(value) || value < 0) || totalDamage <= 0) {
|
||||
throw new Error("Team damage and rotation must be positive finite values.");
|
||||
}
|
||||
return {
|
||||
totalDamage,
|
||||
totalDps: totalDamage / rotationSeconds,
|
||||
shares: damage.map((value) => value / totalDamage * 100),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
artifactGroups,
|
||||
artifactProfiles,
|
||||
artifactRecommendations,
|
||||
catalogCharacters,
|
||||
catalogSyncJobs,
|
||||
catalogVersions,
|
||||
constellationRecommendations,
|
||||
extraSectionRows,
|
||||
extraSections,
|
||||
guides,
|
||||
guideSections,
|
||||
media,
|
||||
outboxEvents,
|
||||
teamCompositions,
|
||||
teamMembers,
|
||||
weaponRecommendations,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
artifactSectionSchema,
|
||||
constellationSectionSchema,
|
||||
createGuideSchema,
|
||||
extraSectionSchema,
|
||||
slugFromCharacterName,
|
||||
teamSectionSchema,
|
||||
updateGuideSchema,
|
||||
weaponSectionSchema,
|
||||
} from "@/lib/guides/schemas";
|
||||
|
||||
const DEFAULT_SECTIONS = [
|
||||
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
|
||||
{ kind: "artifact", slug: "artifact", title: "Artifacts & Stats", sortOrder: 1 },
|
||||
{ kind: "constellations", slug: "constellations", title: "Constellations", sortOrder: 2 },
|
||||
{ kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 },
|
||||
] as const;
|
||||
|
||||
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number) {
|
||||
await tx.insert(outboxEvents).values([
|
||||
{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } },
|
||||
{ topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } },
|
||||
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } },
|
||||
]);
|
||||
}
|
||||
|
||||
export async function createGuide(input: unknown) {
|
||||
const parsed = createGuideSchema.parse(input);
|
||||
const db = getDb();
|
||||
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
|
||||
if (!version) throw new Error("กรุณา Sync ข้อมูล Lunaris ก่อนสร้างคู่มือ");
|
||||
const [character] = await db.select().from(catalogCharacters).where(and(eq(catalogCharacters.version, version.version), eq(catalogCharacters.key, parsed.characterKey))).limit(1);
|
||||
if (!character) throw new Error("ไม่พบตัวละครใน Catalog เวอร์ชันปัจจุบัน");
|
||||
const [cover] = await db.select().from(media).where(and(eq(media.id, parsed.coverMediaId), eq(media.status, "ready"))).limit(1);
|
||||
if (!cover) throw new Error("กรุณาอัปโหลดภาพ Guide ให้สำเร็จ");
|
||||
const baseSlug = slugFromCharacterName(character.name, character.key);
|
||||
const [slugCollision] = await db.select({ id: guides.id }).from(guides).where(eq(guides.slug, baseSlug)).limit(1);
|
||||
const slug = slugCollision ? `${baseSlug}-${character.key.toLowerCase()}` : baseSlug;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [guide] = await tx.insert(guides).values({
|
||||
characterKey: character.key,
|
||||
catalogVersion: version.version,
|
||||
slug,
|
||||
name: character.name,
|
||||
overview: parsed.overview,
|
||||
coverMediaId: parsed.coverMediaId,
|
||||
}).returning();
|
||||
await tx.insert(guideSections).values(DEFAULT_SECTIONS.map((section) => ({ ...section, guideId: guide.id })));
|
||||
await tx.insert(artifactProfiles).values({ guideId: guide.id });
|
||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
|
||||
await emitGuideEvents(tx, guide.id, guide.version);
|
||||
return guide;
|
||||
});
|
||||
}
|
||||
|
||||
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number) {
|
||||
const [guide] = await tx.update(guides).set({ version: expectedVersion + 1 }).where(and(eq(guides.id, guideId), eq(guides.version, expectedVersion), sql`${guides.trashedAt} is null`)).returning();
|
||||
if (!guide) throw new Error("ข้อมูลถูกแก้ไขจากแท็บอื่น กรุณาโหลดหน้าใหม่");
|
||||
await emitGuideEvents(tx, guide.id, guide.version);
|
||||
return guide;
|
||||
}
|
||||
|
||||
export async function saveWeapons(guideId: string, input: unknown) {
|
||||
const data = weaponSectionSchema.parse(input);
|
||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId));
|
||||
if (data.rows.length) await tx.insert(weaponRecommendations).values(data.rows.map((row, sortOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, guideId, catalogVersion: guide.catalogVersion, sortOrder })));
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateGuideBasics(guideId: string, input: unknown) {
|
||||
const data = updateGuideSchema.parse(input);
|
||||
const [current] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!current) throw new Error("ไม่พบ Guide");
|
||||
const [cover] = await getDb().select().from(media).where(and(eq(media.id, data.coverMediaId), eq(media.status, "ready"))).limit(1);
|
||||
if (!cover) throw new Error("ไม่พบภาพ Guide ที่อัปโหลดแล้ว");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.update(guides).set({ overview: data.overview, coverMediaId: data.coverMediaId }).where(eq(guides.id, guideId));
|
||||
if (current.coverMediaId !== data.coverMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId));
|
||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveArtifacts(guideId: string, input: unknown) {
|
||||
const data = artifactSectionSchema.parse(input);
|
||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId));
|
||||
for (const [sortOrder, group] of data.groups.entries()) {
|
||||
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
|
||||
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, catalogVersion: guide.catalogVersion, sortOrder: rowOrder })));
|
||||
}
|
||||
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note } });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveConstellations(guideId: string, input: unknown) {
|
||||
const data = constellationSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.delete(constellationRecommendations).where(eq(constellationRecommendations.guideId, guideId));
|
||||
if (data.rows.length) await tx.insert(constellationRecommendations).values(data.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), guideId })));
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveTeams(guideId: string, input: unknown) {
|
||||
const data = teamSectionSchema.parse(input);
|
||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId));
|
||||
for (const [sortOrder, team] of data.teams.entries()) {
|
||||
const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
|
||||
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ teamId: created.id, position, catalogVersion: guide.catalogVersion, characterKey: member.characterKey, damage: member.damage.toString() })));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveExtraSections(guideId: string, input: unknown) {
|
||||
const data = extraSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.delete(guideSections).where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
|
||||
for (const [sortOrder, section] of data.sections.entries()) {
|
||||
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
|
||||
await tx.insert(extraSections).values({ sectionId: createdSection.id });
|
||||
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({ ...row, percent: row.percent?.toString() ?? null, sectionId: createdSection.id, sortOrder: rowOrder })));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function trashGuide(guideId: string) {
|
||||
return getDb().transaction(async (tx) => {
|
||||
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
await emitGuideEvents(tx, guide.id, guide.version);
|
||||
return guide;
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreGuide(guideId: string) {
|
||||
return getDb().transaction(async (tx) => {
|
||||
const [guide] = await tx.update(guides).set({ trashedAt: null, version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
await emitGuideEvents(tx, guide.id, guide.version);
|
||||
return guide;
|
||||
});
|
||||
}
|
||||
|
||||
export async function permanentlyDeleteGuide(guideId: string) {
|
||||
return getDb().transaction(async (tx) => {
|
||||
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
||||
await tx.delete(guides).where(eq(guides.id, guideId));
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId));
|
||||
await emitGuideEvents(tx, guide.id, guide.version + 1);
|
||||
return { version: guide.version + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueCatalogSync(userId: string) {
|
||||
const [running] = await getDb().select().from(catalogSyncJobs).where(sql`${catalogSyncJobs.status} in ('pending', 'processing')`).limit(1);
|
||||
if (running) return running;
|
||||
const [job] = await getDb().insert(catalogSyncJobs).values({ triggeredById: userId }).returning();
|
||||
return job;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, desc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
artifactGroups,
|
||||
artifactProfiles,
|
||||
artifactRecommendations,
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogSyncJobs,
|
||||
catalogVersions,
|
||||
catalogWeapons,
|
||||
constellationRecommendations,
|
||||
extraSectionRows,
|
||||
guides,
|
||||
guideSections,
|
||||
media,
|
||||
teamCompositions,
|
||||
teamMembers,
|
||||
weaponRecommendations,
|
||||
} from "@/db/schema";
|
||||
import { publicMediaUrl } from "@/lib/media/storage";
|
||||
|
||||
export async function getActiveCatalog() {
|
||||
const db = getDb();
|
||||
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
|
||||
if (!version) return null;
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
db.select().from(catalogCharacters).where(eq(catalogCharacters.version, version.version)).orderBy(asc(catalogCharacters.name)),
|
||||
db.select().from(catalogWeapons).where(eq(catalogWeapons.version, version.version)).orderBy(asc(catalogWeapons.name)),
|
||||
db.select().from(catalogArtifacts).where(eq(catalogArtifacts.version, version.version)).orderBy(asc(catalogArtifacts.name)),
|
||||
]);
|
||||
return {
|
||||
version: version.version,
|
||||
characters: characters.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listGuideCards(includeTrash = false) {
|
||||
const rows = await getDb()
|
||||
.select({ guide: guides, cover: media })
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.where(includeTrash ? isNotNull(guides.trashedAt) : isNull(guides.trashedAt))
|
||||
.orderBy(asc(guides.sortOrder), asc(guides.name));
|
||||
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
|
||||
}
|
||||
|
||||
export async function getGuideByCharacterKey(characterKey: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides, cover: media })
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.where(and(eq(guides.characterKey, characterKey), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const sections = await getDb().select().from(guideSections).where(eq(guideSections.guideId, row.guide.id)).orderBy(asc(guideSections.sortOrder));
|
||||
return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), sections };
|
||||
}
|
||||
|
||||
export async function getPublicGuide(slug: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides, cover: media, character: catalogCharacters })
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.innerJoin(catalogCharacters, and(eq(catalogCharacters.version, guides.catalogVersion), eq(catalogCharacters.key, guides.characterKey)))
|
||||
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const [sections, weaponRows, groups, profile, constellations, teams] = await Promise.all([
|
||||
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
|
||||
getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, and(eq(catalogWeapons.version, weaponRecommendations.catalogVersion), eq(catalogWeapons.key, weaponRecommendations.weaponKey))).where(eq(weaponRecommendations.guideId, row.guide.id)).orderBy(asc(weaponRecommendations.sortOrder)),
|
||||
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
|
||||
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
|
||||
getDb().select().from(constellationRecommendations).where(eq(constellationRecommendations.guideId, row.guide.id)).orderBy(asc(constellationRecommendations.level)),
|
||||
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
|
||||
]);
|
||||
|
||||
const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, and(eq(catalogArtifacts.version, artifactRecommendations.catalogVersion), eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey))).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : [];
|
||||
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
|
||||
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, secondKeys))) : [];
|
||||
const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacters }).from(teamMembers).innerJoin(catalogCharacters, and(eq(catalogCharacters.version, teamMembers.catalogVersion), eq(catalogCharacters.key, teamMembers.characterKey))).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : [];
|
||||
const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id);
|
||||
const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : [];
|
||||
|
||||
return {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
character: { ...row.character, imageUrl: publicMediaUrl(row.character.imageKey) },
|
||||
sections,
|
||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
|
||||
artifactProfile: profile[0] ?? null,
|
||||
constellations,
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => ({ ...member, character: { ...character, imageUrl: publicMediaUrl(character.imageKey) } })) })),
|
||||
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestCatalogSyncJob() {
|
||||
const [job] = await getDb().select().from(catalogSyncJobs).orderBy(desc(catalogSyncJobs.createdAt)).limit(1);
|
||||
return job ?? null;
|
||||
}
|
||||
|
||||
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
||||
import { artifactSectionSchema, teamSectionSchema, weaponSectionSchema } from "@/lib/guides/schemas";
|
||||
|
||||
describe("structured guide validation", () => {
|
||||
it("requires at least one weapon percentage", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null }] }).success).toBe(false);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null }] }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("limits artifact main stats to two per slot", () => {
|
||||
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: [], sands: ["EM", "ER", "ATK%"], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires four unique team members and non-zero damage", () => {
|
||||
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map((characterKey) => ({ characterKey, damage: 100 })) }] };
|
||||
expect(teamSectionSchema.safeParse(base).success).toBe(true);
|
||||
expect(teamSectionSchema.safeParse({ ...base, teams: [{ ...base.teams[0], members: ["1", "1", "3", "4"].map((characterKey) => ({ characterKey, damage: 100 })) }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team Calc", () => {
|
||||
it("calculates exact shares and total DPS", () => {
|
||||
expect(calculateTeamMetrics([50, 25, 15, 10], 20)).toEqual({ totalDamage: 100, totalDps: 5, shares: [50, 25, 15, 10] });
|
||||
});
|
||||
|
||||
it("rejects an empty damage rotation", () => {
|
||||
expect(() => calculateTeamMetrics([0, 0, 0, 0], 20)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
||||
export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
||||
|
||||
const percent = z.coerce.number().finite().min(0).max(9999);
|
||||
const catalogKey = z.string().trim().min(1).max(64);
|
||||
|
||||
export const createGuideSchema = z.strictObject({
|
||||
characterKey: catalogKey,
|
||||
coverMediaId: z.string().uuid(),
|
||||
overview: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export const updateGuideSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
coverMediaId: z.string().uuid(),
|
||||
overview: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
export const weaponSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
rows: z.array(z.strictObject({
|
||||
weaponKey: catalogKey,
|
||||
refinement: z.number().int().min(1).max(5),
|
||||
overallPercent: percent.nullable(),
|
||||
personalPercent: percent.nullable(),
|
||||
}).refine((row) => row.overallPercent !== null || row.personalPercent !== null, {
|
||||
message: "อาวุธต้องมีเปอร์เซ็นต์อย่างน้อยหนึ่งค่า",
|
||||
})).max(100),
|
||||
});
|
||||
|
||||
export const mainStatOptions = {
|
||||
sands: ["EM", "ER", "ATK%", "HP%", "DEF%"],
|
||||
goblet: ["EM", "ATK%", "DEF%", "HP%", "Pyro DMG%", "Hydro DMG%", "Cryo DMG%", "Electro DMG%", "Anemo DMG%", "Geo DMG%", "Dendro DMG%", "Physical DMG%"],
|
||||
circlet: ["HP%", "ATK%", "DEF%", "EM", "CRIT Rate%/CRIT DMG%", "Healing Bonus%"],
|
||||
} as const;
|
||||
|
||||
export const substatOptions = ["CRIT Rate%/CRIT DMG%", "HP%", "ATK%", "DEF%", "EM", "ER"] as const;
|
||||
|
||||
export const artifactSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
groups: z.array(z.strictObject({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
rows: z.array(z.strictObject({
|
||||
firstArtifactKey: catalogKey,
|
||||
secondArtifactKey: catalogKey.nullable(),
|
||||
percent,
|
||||
})).max(50),
|
||||
})).max(12),
|
||||
sands: z.array(z.enum(mainStatOptions.sands)).max(2),
|
||||
goblet: z.array(z.enum(mainStatOptions.goblet)).max(2),
|
||||
circlet: z.array(z.enum(mainStatOptions.circlet)).max(2),
|
||||
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
|
||||
targets: z.array(z.strictObject({
|
||||
stat: z.string().trim().min(1).max(80),
|
||||
value: z.string().trim().min(1).max(80),
|
||||
})).max(20),
|
||||
talents: z.array(z.strictObject({
|
||||
type: z.enum(["NA", "E", "Q"]),
|
||||
priority: z.number().int().min(1).max(3),
|
||||
level: z.string().trim().max(24).optional(),
|
||||
})).max(3),
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
});
|
||||
|
||||
export const constellationSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
rows: z.array(z.strictObject({
|
||||
level: z.number().int().min(1).max(6),
|
||||
detail: z.string().trim().min(1).max(2_000),
|
||||
cumulativePercent: percent,
|
||||
})).max(6),
|
||||
});
|
||||
|
||||
export const teamSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
teams: z.array(z.strictObject({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
rotationSeconds: z.coerce.number().finite().positive().max(9999),
|
||||
combo: z.string().trim().min(1).max(2_000),
|
||||
members: z.array(z.strictObject({
|
||||
characterKey: catalogKey,
|
||||
damage: z.coerce.number().finite().nonnegative().max(999_999_999_999),
|
||||
})).length(4).refine((members) => new Set(members.map((member) => member.characterKey)).size === 4, {
|
||||
message: "Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
|
||||
}).refine((members) => members.some((member) => member.damage > 0), {
|
||||
message: "ความเสียหายรวมต้องมากกว่า 0",
|
||||
}),
|
||||
})).max(30),
|
||||
});
|
||||
|
||||
export const extraSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
sections: z.array(z.strictObject({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
rows: z.array(z.strictObject({
|
||||
mediaId: z.string().uuid().nullable(),
|
||||
heading: z.string().trim().min(1).max(200),
|
||||
body: z.string().trim().max(2_000),
|
||||
percent: percent.nullable(),
|
||||
})).max(50),
|
||||
})).max(20),
|
||||
});
|
||||
|
||||
export type WeaponSectionInput = z.output<typeof weaponSectionSchema>;
|
||||
export type ArtifactSectionInput = z.output<typeof artifactSectionSchema>;
|
||||
export type ConstellationSectionInput = z.output<typeof constellationSectionSchema>;
|
||||
export type TeamSectionInput = z.output<typeof teamSectionSchema>;
|
||||
|
||||
export function slugFromCharacterName(name: string, key: string): string {
|
||||
const slug = name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "");
|
||||
return slug || `character-${key.toLowerCase()}`;
|
||||
}
|
||||
+46
-2
@@ -1,13 +1,14 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import { media } from "@/db/schema";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||
import {
|
||||
hasValidImageSignature,
|
||||
MAX_MEDIA_BYTES,
|
||||
mediaUploadSchema,
|
||||
safeObjectFileName,
|
||||
type MediaUploadInput,
|
||||
} from "@/lib/media/validation";
|
||||
@@ -37,6 +38,49 @@ export async function createPendingMedia(
|
||||
return { id, objectKey, uploadUrl, expiresIn };
|
||||
}
|
||||
|
||||
export async function uploadPublicMedia(
|
||||
file: File,
|
||||
authorId: string,
|
||||
): Promise<{ id: string; url: string; fileName: string }> {
|
||||
const parsed = mediaUploadSchema.parse({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
byteSize: file.size,
|
||||
});
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!hasValidImageSignature(bytes.subarray(0, 16), parsed.mimeType)) {
|
||||
throw new Error("ไฟล์ไม่ตรงกับชนิดรูปภาพที่ระบุ");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const objectKey = `uploads/${id}/${safeObjectFileName(parsed.fileName)}`;
|
||||
const storage = await getMediaStorage();
|
||||
await storage.write(objectKey, bytes, { type: parsed.mimeType, acl: "public-read" });
|
||||
try {
|
||||
await getDb().insert(media).values({
|
||||
id,
|
||||
objectKey,
|
||||
fileName: parsed.fileName,
|
||||
mimeType: parsed.mimeType,
|
||||
byteSize: parsed.byteSize,
|
||||
status: "ready",
|
||||
currentReferenceCount: 0,
|
||||
createdById: authorId,
|
||||
});
|
||||
} catch (cause) {
|
||||
await storage.delete(objectKey).catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
return { id, url: publicMediaUrl(objectKey), fileName: parsed.fileName };
|
||||
}
|
||||
|
||||
export async function addCurrentMediaReference(mediaId: string): Promise<void> {
|
||||
await getDb()
|
||||
.update(media)
|
||||
.set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` })
|
||||
.where(eq(media.id, mediaId));
|
||||
}
|
||||
|
||||
export async function completePendingMedia(mediaId: string) {
|
||||
const [record] = await getDb()
|
||||
.select()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
import type { S3Client } from "bun";
|
||||
|
||||
let client: S3Client | undefined;
|
||||
@@ -26,3 +24,8 @@ export async function getMediaStorage(): Promise<S3Client> {
|
||||
client = await loading;
|
||||
return client;
|
||||
}
|
||||
|
||||
export function publicMediaUrl(objectKey: string): string {
|
||||
const base = required("S3_PUBLIC_URL").replace(/\/+$/u, "");
|
||||
return `${base}/${objectKey.split("/").map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user