feat : update

This commit is contained in:
2026-09-03 21:39:51 +07:00 Unverified
parent 1b014d7c8f
commit ecc2b1749a
8 changed files with 1818 additions and 8 deletions
+1
View File
@@ -110,6 +110,7 @@ and external services. Never enable it in a deployed environment.
| `bun run db:generate` | Generate a Drizzle migration from schema changes |
| `bun run db:migrate` | Apply committed Drizzle migrations |
| `bun run db:seed` | Upsert the built-in templates |
| `bun run import:guides` | Preview the one-time Google Sheets guide migration (`-- --apply` to write) |
| `bun run worker:discord` | Listen for Lunaris update announcements |
| `bun run worker:outbox` | Process cache and event outbox records |
+7 -7
View File
@@ -9,12 +9,12 @@ export default async function RegisterPage() {
await connection();
if (await getAdminSession()) redirect("/admin");
if (await !getAdminSession()) redirect("/");
return ( null
// <div className="min-h-svh">
// <SiteHeader />
// <main className="grid min-h-[calc(100svh-4rem)] place-items-center px-4 py-12">
// <RegisterCard />
// </main>
// </div>
return (
<div className="min-h-svh">
<SiteHeader />
<main className="grid min-h-[calc(100svh-4rem)] place-items-center px-4 py-12">
<RegisterCard />
</main>
</div>
);
}
+3
View File
@@ -17,6 +17,7 @@
"discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0",
"fflate": "^0.8.2",
"ioredis": "^6.0.0",
"lucide-react": "^1.35.0",
"modern-screenshot": "^4.7.0",
@@ -953,6 +954,8 @@
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["[email protected]", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"figures": ["[email protected]", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+8 -1
View File
@@ -148,6 +148,13 @@
"when": 1788411911516,
"tag": "0020_aberrant_dark_beast",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1788420000000,
"tag": "0021_optional_team_rotation",
"breakpoints": true
}
]
}
}
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildGuideImportDraft,
charactersFromCombo,
parseCsv,
type GuideImportSource,
type ImportCatalog,
} from "@/lib/guides/google-sheet-import";
afterEach(() => vi.unstubAllGlobals());
describe("Google Sheet guide import", () => {
it("parses quoted commas, escaped quotes, and multiline cells", () => {
expect(parseCsv('"name","note"\n"A, B","line 1\nline ""2"""\n')).toEqual([
["name", "note"],
["A, B", 'line 1\nline "2"'],
]);
});
it("builds supported compact fields and reports missing catalog rows", async () => {
const source: GuideImportSource = {
characterName: "Hero",
overview: "Guide Hero",
workbookId: "sheet-id",
coverId: "cover-id",
layout: "compact",
infoSheet: "Hero Info",
teamSheets: [],
comboSheets: [],
weaponMetric: "overall-personal",
};
const catalog: ImportCatalog = {
characters: [{ key: "hero", name: "Hero", constellations: [] }],
weapons: [{ key: "weapon", name: "Known Weapon" }],
artifacts: [{ key: "artifact", name: "Known Set" }],
};
const rows = Array.from({ length: 24 }, () => Array.from({ length: 21 }, () => ""));
rows[0][10] = "Weapons อาวุธที่แนะนำ";
rows[1][11] = "Known Weapon R5";
rows[1][13] = "110%";
rows[1][14] = "105%";
rows[3][11] = "Missing Weapon R1";
rows[3][13] = "100%";
rows[5][10] = "Artifacts อาร์ติแฟกต์ที่แนะนำ (เลทเกม)";
rows[6][11] = "4 Known Set";
rows[6][13] = "100%";
rows[8][5] = "ATK (พลังโจมตี)";
rows[8][6] = "2000+";
rows[8][7] = "สแตทที่แนะนำ เลทเกม (AR 45+)";
vi.stubGlobal("fetch", vi.fn(async () => new Response(rows.map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n"))));
const draft = await buildGuideImportDraft(source, catalog);
expect(draft.weapons).toEqual([expect.objectContaining({ weaponKey: "weapon", refinement: 5, overallPercent: 110, personalPercent: 105 })]);
expect(draft.artifacts.groups[0].rows).toEqual([expect.objectContaining({ firstArtifactKey: "artifact", percent: 100 })]);
expect(draft.artifacts.targets).toEqual([{ stat: "ATK", value: "2000+" }]);
expect(draft.skipped).toContain("weapon not in catalog: Missing Weapon R1");
});
it("does not fetch or populate a keep-existing source", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const draft = await buildGuideImportDraft({
characterName: "Yumemizuki Mizuki",
overview: "existing",
workbookId: "sheet-id",
coverId: "cover-id",
layout: "full",
infoSheet: "Mizuki Info",
teamSheets: [],
comboSheets: [],
skipWrite: true,
}, {
characters: [{ key: "mizuki", name: "Yumemizuki Mizuki", constellations: [] }],
weapons: [],
artifacts: [],
});
expect(fetchMock).not.toHaveBeenCalled();
expect(draft.weapons).toEqual([]);
});
it("resolves the unique characters named by a combo", () => {
const characters = ["Yae Miko", "Odette", "Alyosha", "Sandrone"].map((name, index) => ({
key: String(index),
name,
constellations: [],
}));
expect(charactersFromCombo("Yae EEE > Odette EE > Alyosha EQ > Sandrone 3CE Q", {
characters,
weapons: [],
artifacts: [],
}).map((item) => item.name)).toEqual(["Yae Miko", "Odette", "Alyosha", "Sandrone"]);
});
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,6 +16,7 @@
"db:migrate": "bun scripts/migrate.ts",
"db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-templates.ts",
"import:guides": "bun scripts/import-google-guides.ts",
"worker:discord": "bun backend/discord.ts",
"worker:outbox": "bun scripts/outbox-worker.ts"
},
+315
View File
@@ -0,0 +1,315 @@
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { closeDb, getDb } from "@/db/client";
import {
artifactGroups,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
catalogCharacters,
catalogWeapons,
constellationConditions,
constellationRecommendations,
extraSectionRows,
extraSections,
guides,
guideSections,
media,
outboxEvents,
teamCompositions,
teamMembers,
weaponRecommendations,
} from "@/db/schema";
import {
artifactSectionSchema,
constellationSectionSchema,
extraSectionSchema,
slugFromCharacterName,
teamSectionSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import {
buildGuideImportDraft,
guideImportSources,
type GuideImportDraft,
type ImportCatalog,
} from "@/lib/guides/google-sheet-import";
import { getMediaStorage } from "@/lib/media/storage";
import { hasValidImageSignature, MAX_MEDIA_BYTES } from "@/lib/media/validation";
const apply = process.argv.includes("--apply");
interface CoverAsset {
bytes: Uint8Array;
extension: "gif" | "jpeg" | "png" | "webp";
mimeType: "image/gif" | "image/jpeg" | "image/png" | "image/webp";
objectKey: string;
}
function coverType(bytes: Uint8Array): Pick<CoverAsset, "extension" | "mimeType"> | null {
const types = [
["png", "image/png"],
["jpeg", "image/jpeg"],
["webp", "image/webp"],
["gif", "image/gif"],
] as const;
for (const [extension, mimeType] of types) {
if (hasValidImageSignature(bytes.subarray(0, 16), mimeType)) return { extension, mimeType };
}
return null;
}
async function downloadCover(draft: GuideImportDraft): Promise<CoverAsset> {
const response = await fetch(`https://buzz.sudloh.com/cdn/${draft.source.coverId}`, {
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`${draft.source.characterName}: cover returned ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw new Error(`${draft.source.characterName}: invalid cover size ${bytes.length}`);
const type = coverType(bytes);
if (!type) throw new Error(`${draft.source.characterName}: unsupported cover image`);
return {
...type,
bytes,
objectKey: `imports/buzz-guide/${draft.characterKey}/cover.${type.extension}`,
};
}
function validateDraft(draft: GuideImportDraft): void {
weaponSectionSchema.parse({ expectedVersion: 1, note: null, rows: draft.weapons });
artifactSectionSchema.parse({ expectedVersion: 1, ...draft.artifacts });
constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: draft.constellationGroups });
teamSectionSchema.parse({ expectedVersion: 1, teams: draft.teams });
extraSectionSchema.parse({
expectedVersion: 1,
sections: draft.extras.map((section) => ({
title: section.title,
rows: section.rows.map((row) => ({ ...row, mediaId: null, ratio: "16:9" as const })),
})),
});
}
function reportFor(draft: GuideImportDraft) {
return {
character: draft.source.characterName,
action: draft.source.skipWrite ? "keep-existing" : draft.source.characterName === "Columbina" ? "restore-or-update" : "create-or-update",
weapons: draft.weapons.length,
artifacts: draft.artifacts.groups.reduce((total, group) => total + group.rows.length, 0),
constellations: draft.constellationGroups.reduce((total, group) => total + group.rows.length, 0),
teams: draft.teams.length,
extras: draft.extras.reduce((total, section) => total + section.rows.length, 0),
skipped: [...new Set(draft.skipped)],
};
}
async function loadCatalog(): Promise<ImportCatalog> {
const db = getDb();
const [characters, weapons, artifacts] = await Promise.all([
db.select({ key: catalogCharacters.key, name: catalogCharacters.name, constellations: catalogCharacters.constellations }).from(catalogCharacters),
db.select({ key: catalogWeapons.key, name: catalogWeapons.name }).from(catalogWeapons),
db.select({ key: catalogArtifacts.key, name: catalogArtifacts.name }).from(catalogArtifacts),
]);
return { characters, weapons, artifacts };
}
async function uniqueSlug(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], name: string, key: string): Promise<string> {
const base = slugFromCharacterName(name, key);
const [collision] = await tx.select({ id: guides.id }).from(guides).where(eq(guides.slug, base)).limit(1);
return collision ? `${base}-${key.toLowerCase()}` : base;
}
async function replaceGuide(
tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
draft: GuideImportDraft,
coverMediaId: string,
sortOrder: number,
): Promise<void> {
const [catalogCharacter] = await tx.select().from(catalogCharacters).where(eq(catalogCharacters.key, draft.characterKey)).limit(1);
if (!catalogCharacter) throw new Error(`${draft.source.characterName}: character disappeared from catalog`);
const [existing] = await tx.select().from(guides).where(eq(guides.characterKey, draft.characterKey)).limit(1);
let guideId: string;
let version: number;
if (existing) {
guideId = existing.id;
version = existing.version + 1;
await tx.update(guides).set({
overview: draft.source.overview,
coverMediaId,
sortOrder,
trashedAt: null,
version,
}).where(eq(guides.id, guideId));
if (existing.coverMediaId !== coverMediaId) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, existing.coverMediaId));
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId));
}
} else {
const slug = await uniqueSlug(tx, catalogCharacter.name, catalogCharacter.key);
const [created] = await tx.insert(guides).values({
characterKey: catalogCharacter.key,
slug,
name: catalogCharacter.name,
overview: draft.source.overview,
coverMediaId,
sortOrder,
}).returning();
guideId = created.id;
version = created.version;
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId));
}
await Promise.all([
tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId)),
tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId)),
tx.delete(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)),
tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId)),
tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId)),
tx.delete(guideSections).where(eq(guideSections.guideId, guideId)),
]);
const defaultSections = [
{ 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 },
];
await tx.insert(guideSections).values(defaultSections.map((section) => ({ ...section, guideId })));
if (draft.weapons.length) {
await tx.insert(weaponRecommendations).values(draft.weapons.map((row, rowIndex) => ({
...row,
guideId,
sortOrder: rowIndex,
overallPercent: row.overallPercent?.toString() ?? null,
personalPercent: row.personalPercent?.toString() ?? null,
})));
}
for (const [groupIndex, group] of draft.artifacts.groups.entries()) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder: groupIndex }).returning();
if (group.rows.length) {
await tx.insert(artifactRecommendations).values(group.rows.map((row, rowIndex) => ({
...row,
groupId: created.id,
sortOrder: rowIndex,
percent: row.percent.toString(),
})));
}
}
await tx.insert(artifactProfiles).values({
guideId,
sands: draft.artifacts.sands,
goblet: draft.artifacts.goblet,
circlet: draft.artifacts.circlet,
substats: draft.artifacts.substats,
targets: draft.artifacts.targets,
talents: draft.artifacts.talents,
note: draft.artifacts.note,
});
for (const [groupIndex, group] of draft.constellationGroups.entries()) {
const [created] = await tx.insert(constellationConditions).values({ guideId, note: group.note, sortOrder: groupIndex }).returning();
if (group.rows.length) {
await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({
...row,
conditionId: created.id,
cumulativePercent: row.cumulativePercent.toString(),
})));
}
}
for (const [teamIndex, team] of draft.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({
guideId,
title: team.title,
rotationSeconds: team.rotationSeconds?.toString() ?? null,
combo: team.combo,
sortOrder: teamIndex,
}).returning();
await tx.insert(teamMembers).values(team.members.map((member, position) => ({
...member,
teamId: created.id,
position,
damage: member.damage.toString(),
})));
}
for (const [extraIndex, section] of draft.extras.entries()) {
const [createdSection] = await tx.insert(guideSections).values({
guideId,
kind: "extra",
slug: `extra-${extraIndex + 1}`,
title: section.title,
sortOrder: extraIndex + 4,
}).returning();
await tx.insert(extraSections).values({ sectionId: createdSection.id });
await tx.insert(extraSectionRows).values(section.rows.map((row, rowIndex) => ({
sectionId: createdSection.id,
heading: row.heading,
body: row.body,
percent: row.percent?.toString() ?? null,
ratio: "16:9",
sortOrder: rowIndex,
})));
}
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 } },
]);
}
async function applyDrafts(drafts: GuideImportDraft[]): Promise<void> {
const writable = drafts.filter((draft) => !draft.source.skipWrite);
const covers = new Map<string, CoverAsset>();
for (const draft of writable) covers.set(draft.characterKey, await downloadCover(draft));
const storage = await getMediaStorage();
for (const cover of covers.values()) {
await storage.write(cover.objectKey, cover.bytes, { type: cover.mimeType, acl: "public-read" });
}
const db = getDb();
const [{ maximum }] = await db.select({ maximum: sql<number>`coalesce(max(${guides.sortOrder}), 0)` }).from(guides).where(isNull(guides.trashedAt));
await db.transaction(async (tx) => {
for (const [index, draft] of writable.entries()) {
const cover = covers.get(draft.characterKey)!;
let [coverRecord] = await tx.select().from(media).where(eq(media.objectKey, cover.objectKey)).limit(1);
if (!coverRecord) {
[coverRecord] = await tx.insert(media).values({
objectKey: cover.objectKey,
fileName: `cover.${cover.extension}`,
mimeType: cover.mimeType,
byteSize: cover.bytes.length,
status: "ready",
}).returning();
} else {
[coverRecord] = await tx.update(media).set({
mimeType: cover.mimeType,
byteSize: cover.bytes.length,
status: "ready",
}).where(eq(media.id, coverRecord.id)).returning();
}
await replaceGuide(tx, draft, coverRecord.id, Number(maximum) + index + 1);
}
});
}
async function main(): Promise<void> {
const catalog = await loadCatalog();
const drafts: GuideImportDraft[] = [];
for (const source of guideImportSources) {
const draft = await buildGuideImportDraft(source, catalog);
validateDraft(draft);
drafts.push(draft);
}
console.log(JSON.stringify({ mode: apply ? "apply" : "dry-run", guides: drafts.map(reportFor) }, null, 2));
if (!apply) return;
await applyDrafts(drafts);
const sourceKeys = drafts.map((draft) => draft.characterKey);
const active = await getDb().select({ characterKey: guides.characterKey }).from(guides).where(and(inArray(guides.characterKey, sourceKeys), isNull(guides.trashedAt)));
if (active.length !== sourceKeys.length) throw new Error(`Expected ${sourceKeys.length} active source guides, found ${active.length}`);
console.log(`Imported ${drafts.filter((draft) => !draft.source.skipWrite).length} guides; ${active.length} source guides are active.`);
}
try {
await main();
} finally {
await closeDb();
}