diff --git a/db/migrations/20260511200000_notification_orphans_restored_kind.sql b/db/migrations/20260511200000_notification_orphans_restored_kind.sql new file mode 100644 index 000000000..867a793ad --- /dev/null +++ b/db/migrations/20260511200000_notification_orphans_restored_kind.sql @@ -0,0 +1,12 @@ +-- Allow ORPHANS_RESTORED inbox notifications (payload matches tachi-common NotificationBody). + +ALTER TABLE notification DROP CONSTRAINT IF EXISTS notification_kind_check; + +ALTER TABLE notification ADD CONSTRAINT notification_kind_check CHECK ( + kind IN ( + 'rivaled_by', + 'quest_changed', + 'site_announcement', + 'orphans_restored' + ) +); diff --git a/typescript/client/src/components/game/folder/FolderMinimap.tsx b/typescript/client/src/components/game/folder/FolderMinimap.tsx index a48c9c42c..03c130ff8 100644 --- a/typescript/client/src/components/game/folder/FolderMinimap.tsx +++ b/typescript/client/src/components/game/folder/FolderMinimap.tsx @@ -125,13 +125,11 @@ function FolderMinimapMain({ // Switchboard tabs / unmount can leave portalled Bootstrap tooltips in while React // state still has show=true; drop those nodes so nothing sticks. - useLayoutEffect(() => { - return () => { + useLayoutEffect(() => () => { document.querySelectorAll(".tooltip-folder-minimap").forEach((el) => { el.remove(); }); - }; - }, [enumMetric]); + }, [enumMetric]); return (
diff --git a/typescript/client/src/components/layout/misc/QuickTooltip.tsx b/typescript/client/src/components/layout/misc/QuickTooltip.tsx index 0e8841fad..621d1e1a1 100644 --- a/typescript/client/src/components/layout/misc/QuickTooltip.tsx +++ b/typescript/client/src/components/layout/misc/QuickTooltip.tsx @@ -14,11 +14,11 @@ export default function QuickTooltip({ }: { children: JSX.Element; delay?: number | { hide?: number; show?: number }; + /** When false, the tooltip hides as soon as the pointer leaves the trigger (overlay is not hoverable). */ + keepOpenWhenHoveringTooltip?: boolean; max?: boolean; style?: CSSProperties; tooltipClassName?: string; - /** When false, the tooltip hides as soon as the pointer leaves the trigger (overlay is not hoverable). */ - keepOpenWhenHoveringTooltip?: boolean; tooltipContent: React.ReactChild | undefined; wide?: boolean; }) { @@ -88,14 +88,10 @@ export default function QuickTooltip({ className={overlayClass} id={nanoid()} onMouseEnter={ - keepOpenWhenHoveringTooltip - ? () => setMousedOver(true) - : undefined + keepOpenWhenHoveringTooltip ? () => setMousedOver(true) : undefined } onMouseLeave={ - keepOpenWhenHoveringTooltip - ? () => setMousedOver(false) - : undefined + keepOpenWhenHoveringTooltip ? () => setMousedOver(false) : undefined } style={style} > diff --git a/typescript/client/src/components/notifications/NotificationRow.tsx b/typescript/client/src/components/notifications/NotificationRow.tsx index 5ecc741b1..1932c9ac5 100644 --- a/typescript/client/src/components/notifications/NotificationRow.tsx +++ b/typescript/client/src/components/notifications/NotificationRow.tsx @@ -43,5 +43,7 @@ function NotifToURL(notif: NotificationDocument) { return `/u/${notif.body.content.userID}/games/${notif.body.content.game}`; case "SITE_ANNOUNCEMENT": return null; + case "ORPHANS_RESTORED": + return `/u/${notif.sentTo}/orphans`; } } diff --git a/typescript/common/src/lib/schemas.ts b/typescript/common/src/lib/schemas.ts index 8e6a02ec1..1bca15d4e 100644 --- a/typescript/common/src/lib/schemas.ts +++ b/typescript/common/src/lib/schemas.ts @@ -823,7 +823,7 @@ const PRE_SCHEMAS = { sentAt: p.isPositive, read: "boolean", body: { - type: p.isIn("RIVALED_BY", "QUEST_CHANGED"), + type: p.isIn("RIVALED_BY", "QUEST_CHANGED", "SITE_ANNOUNCEMENT", "ORPHANS_RESTORED"), content: (self, parent) => { const type = parent.type as NotificationBody["type"]; @@ -850,6 +850,13 @@ const PRE_SCHEMAS = { subSchema = {}; break; } + + case "ORPHANS_RESTORED": { + subSchema = { + scoreCount: p.isPositiveNonZeroInteger, + }; + break; + } } const err = p(self, subSchema); diff --git a/typescript/common/src/types/notifications.ts b/typescript/common/src/types/notifications.ts index a5ecca013..75165be60 100644 --- a/typescript/common/src/types/notifications.ts +++ b/typescript/common/src/types/notifications.ts @@ -25,6 +25,12 @@ export type NotificationBody = }; type: "RIVALED_BY"; // Emitted when the user is rivalled by someone. } + | { + content: { + scoreCount: integer; + }; + type: "ORPHANS_RESTORED"; // Emitted after orphaned scores were successfully imported. + } | { content: Record; type: "SITE_ANNOUNCEMENT"; // Emitted as a site announcement diff --git a/typescript/server/src/lib/notifications/notifications.test.ts b/typescript/server/src/lib/notifications/notifications.test.ts index ecfbb8728..024d5c967 100644 --- a/typescript/server/src/lib/notifications/notifications.test.ts +++ b/typescript/server/src/lib/notifications/notifications.test.ts @@ -31,6 +31,25 @@ describe("SendNotification", () => { content: { questID: "q1", game: "iidx-sp" }, }); }); + + it("inserts ORPHANS_RESTORED with scoreCount payload", async () => { + await SendNotification("3 orphan scores were restored", userId, { + type: "ORPHANS_RESTORED", + content: { scoreCount: 3 }, + }); + + const row = await DB.selectFrom("notification") + .select(SELECT_NOTIFICATION) + .where("notification.sent_to", "=", userId) + .where("notification.kind", "=", "orphans_restored") + .executeTakeFirstOrThrow(); + + expect(row.title).toBe("3 orphan scores were restored"); + expect(row.payload).toEqual({ + type: "ORPHANS_RESTORED", + content: { scoreCount: 3 }, + }); + }); }); describe("BulkSendNotification", () => { diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts index c0903332d..5f81873e3 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts @@ -6,10 +6,10 @@ import type { ImportTypeDataMap, OrphanScoreDocument, } from "#lib/score-import/import-types/common/types"; -import type { GameGroup, ImportTypes, integer } from "tachi-common"; import type { OrphanScore as PgOrphanScoreRow } from "tachi-db"; import { SELECT_ORPHAN_SCORE } from "#lib/db-formats/orphan-score"; +import { SendNotification } from "#lib/notifications/notifications"; import { Converters } from "#lib/score-import/import-types/converters"; import DB from "#services/pg/db"; import { GetBlacklist } from "#utils/queries/blacklist"; @@ -17,6 +17,7 @@ import { GetUserWithID } from "#utils/user"; import { ExpectedErr } from "bliss"; import fjsh from "fast-json-stable-hash"; import { sql } from "kysely"; +import { type GameGroup, GetGameGroupConfig, type ImportTypes, type integer } from "tachi-common"; import { type ConverterFailure, IsConverterFailure } from "../common/converter-failures"; import { HandlePostImportSteps } from "../score-importing/score-import-main"; @@ -427,6 +428,8 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger let removed = 0; let processed = 0; + const restoredScoreCountByUser = new Map(); + for (const or of orphans) { // We have to await like this to avoid mid-air race conditions, // where two orphans attempt to deorphan to the same scoreID @@ -445,6 +448,12 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger failed++; } else { success++; + if (!GetGameGroupConfig(or.game).dynamicContent) { + restoredScoreCountByUser.set( + or.userID, + (restoredScoreCountByUser.get(or.userID) ?? 0) + 1, + ); + } } } catch (err) { log.error({ orphanID: or.orphanID, err }, `Failed to reprocess orphan.`); @@ -452,5 +461,16 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger } } + await Promise.all( + [...restoredScoreCountByUser.entries()].map(([userID, scoreCount]) => { + const title = `We've added new song data to the site, and ${scoreCount} of your scores have now been resolved to your profile.`; + + return SendNotification(title, userID, { + type: "ORPHANS_RESTORED", + content: { scoreCount }, + }); + }), + ); + return { processed, removed, failed, success }; }