@@ -1,15 +1,23 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import type {
|
||||
CustomWorldProfileRecord,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
type CustomWorldSessionRecord,
|
||||
type CustomWorldGalleryCard,
|
||||
type CustomWorldLibraryEntry,
|
||||
type CustomWorldPublicationStatus,
|
||||
type CustomWorldSessionRecord,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
SAVE_SNAPSHOT_VERSION,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
@@ -72,12 +80,62 @@ type CustomWorldCardRow = QueryResultRow & {
|
||||
landmarkCount: number;
|
||||
};
|
||||
|
||||
type PlatformBrowseHistoryRow = QueryResultRow & {
|
||||
ownerUserId: string;
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeMode: PlatformBrowseHistoryEntry['themeMode'];
|
||||
authorDisplayName: string;
|
||||
visitedAt: string;
|
||||
};
|
||||
|
||||
type ProfileDashboardStateRow = QueryResultRow & {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number | string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ProfileWalletLedgerRow = QueryResultRow & {
|
||||
id: string;
|
||||
amountDelta: number;
|
||||
balanceAfter: number;
|
||||
sourceType: ProfileWalletLedgerEntry['sourceType'];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProfilePlayedWorldRow = QueryResultRow & {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldTitle: string;
|
||||
worldSubtitle: string;
|
||||
firstPlayedAt: string;
|
||||
lastPlayedAt: string;
|
||||
lastObservedPlayTimeMs: number | string;
|
||||
};
|
||||
|
||||
type ProfileWorldSnapshotMeta = {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldTitle: string;
|
||||
worldSubtitle: string;
|
||||
};
|
||||
|
||||
export type RuntimeRepositoryPort = {
|
||||
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
||||
putSnapshot(
|
||||
userId: string,
|
||||
payload: Omit<SavedSnapshot, 'version'>,
|
||||
): Promise<SavedSnapshot>;
|
||||
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>;
|
||||
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>;
|
||||
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>;
|
||||
deleteSnapshot(userId: string): Promise<void>;
|
||||
getSettings(userId: string): Promise<RuntimeSettings>;
|
||||
putSettings(
|
||||
@@ -87,6 +145,14 @@ export type RuntimeRepositoryPort = {
|
||||
listCustomWorldProfiles(
|
||||
userId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
listPlatformBrowseHistory(
|
||||
userId: string,
|
||||
): Promise<PlatformBrowseHistoryEntry[]>;
|
||||
upsertPlatformBrowseHistoryEntries(
|
||||
userId: string,
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
): Promise<PlatformBrowseHistoryEntry[]>;
|
||||
clearPlatformBrowseHistory(userId: string): Promise<void>;
|
||||
upsertCustomWorldProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
@@ -100,9 +166,7 @@ export type RuntimeRepositoryPort = {
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||
listCustomWorldSessions(
|
||||
userId: string,
|
||||
): Promise<CustomWorldSessionRecord[]>;
|
||||
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
|
||||
getCustomWorldSession(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
@@ -168,7 +232,9 @@ function toCustomWorldLibraryEntry(
|
||||
? row.playableNpcCount
|
||||
: fallbackMetadata.playableNpcCount,
|
||||
landmarkCount:
|
||||
row.landmarkCount > 0 ? row.landmarkCount : fallbackMetadata.landmarkCount,
|
||||
row.landmarkCount > 0
|
||||
? row.landmarkCount
|
||||
: fallbackMetadata.landmarkCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,13 +258,159 @@ function toCustomWorldGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
function toPlatformBrowseHistoryEntry(
|
||||
row: PlatformBrowseHistoryRow,
|
||||
): PlatformBrowseHistoryEntry {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldName: row.worldName || '未命名世界',
|
||||
subtitle: row.subtitle || '',
|
||||
summaryText: row.summaryText || '',
|
||||
coverImageSrc: row.coverImageSrc || null,
|
||||
themeMode: row.themeMode || 'mythic',
|
||||
authorDisplayName: row.authorDisplayName || '玩家',
|
||||
visitedAt: row.visitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizePlatformBrowseHistoryWriteEntry(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
): PlatformBrowseHistoryEntry | null {
|
||||
const ownerUserId = readString(entry.ownerUserId);
|
||||
const profileId = readString(entry.profileId);
|
||||
const worldName = readString(entry.worldName);
|
||||
|
||||
if (!ownerUserId || !profileId || !worldName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visitedAt = readString(entry.visitedAt) || new Date().toISOString();
|
||||
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
worldName,
|
||||
subtitle: readString(entry.subtitle),
|
||||
summaryText: readString(entry.summaryText),
|
||||
coverImageSrc: readString(entry.coverImageSrc) || null,
|
||||
themeMode:
|
||||
(readString(
|
||||
entry.themeMode,
|
||||
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
|
||||
authorDisplayName: readString(entry.authorDisplayName) || '玩家',
|
||||
visitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeDashboardNumber(value: unknown) {
|
||||
return Math.max(0, Math.round(readFiniteNumber(value)));
|
||||
}
|
||||
|
||||
function buildBuiltinWorldTitle(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠世界';
|
||||
case 'XIANXIA':
|
||||
return '仙侠世界';
|
||||
default:
|
||||
return '叙事世界';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfileWorldSnapshotMeta(
|
||||
snapshot: SavedSnapshot,
|
||||
): ProfileWorldSnapshotMeta | null {
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
if (!gameState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customWorldProfile = asRecord(gameState.customWorldProfile);
|
||||
if (customWorldProfile) {
|
||||
const profileId = readString(customWorldProfile.id);
|
||||
const worldTitle =
|
||||
readString(customWorldProfile.name) ||
|
||||
readString(customWorldProfile.title);
|
||||
if (profileId || worldTitle) {
|
||||
return {
|
||||
worldKey: profileId ? `custom:${profileId}` : `custom:${worldTitle}`,
|
||||
ownerUserId: null,
|
||||
profileId: profileId || null,
|
||||
worldType: 'CUSTOM',
|
||||
worldTitle: worldTitle || '自定义世界',
|
||||
worldSubtitle:
|
||||
readString(customWorldProfile.summary) ||
|
||||
readString(customWorldProfile.settingText),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const worldType = readString(gameState.worldType);
|
||||
if (!worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentScenePreset = asRecord(gameState.currentScenePreset);
|
||||
const worldTitle =
|
||||
readString(currentScenePreset?.name) || buildBuiltinWorldTitle(worldType);
|
||||
|
||||
return {
|
||||
worldKey: `builtin:${worldType}`,
|
||||
ownerUserId: null,
|
||||
profileId: null,
|
||||
worldType,
|
||||
worldTitle,
|
||||
worldSubtitle:
|
||||
readString(currentScenePreset?.summary) ||
|
||||
readString(currentScenePreset?.description),
|
||||
};
|
||||
}
|
||||
|
||||
function toProfilePlayedWorkSummary(
|
||||
row: ProfilePlayedWorldRow,
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: row.worldKey,
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldType: row.worldType,
|
||||
worldTitle: row.worldTitle,
|
||||
worldSubtitle: row.worldSubtitle,
|
||||
firstPlayedAt: row.firstPlayedAt,
|
||||
lastPlayedAt: row.lastPlayedAt,
|
||||
lastObservedPlayTimeMs: normalizeDashboardNumber(
|
||||
row.lastObservedPlayTimeMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
private async findCustomWorldProfileEntry(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
) {
|
||||
private async findCustomWorldProfileEntry(userId: string, profileId: string) {
|
||||
const result = await this.db.query<CustomWorldEntryRow>(
|
||||
`SELECT user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
@@ -224,6 +436,164 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
return row ? toCustomWorldLibraryEntry(row) : null;
|
||||
}
|
||||
|
||||
private async getProfileDashboardState(userId: string) {
|
||||
const result = await this.db.query<ProfileDashboardStateRow>(
|
||||
`SELECT wallet_balance AS "walletBalance",
|
||||
total_play_time_ms AS "totalPlayTimeMs",
|
||||
updated_at AS "updatedAt"
|
||||
FROM profile_dashboard_state
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findProfilePlayedWorld(userId: string, worldKey: string) {
|
||||
const result = await this.db.query<ProfilePlayedWorldRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_title AS "worldTitle",
|
||||
world_subtitle AS "worldSubtitle",
|
||||
first_played_at AS "firstPlayedAt",
|
||||
last_played_at AS "lastPlayedAt",
|
||||
last_observed_play_time_ms AS "lastObservedPlayTimeMs"
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1
|
||||
AND world_key = $2`,
|
||||
[userId, worldKey],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async upsertProfileDashboardState(
|
||||
userId: string,
|
||||
state: {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
updatedAt: string;
|
||||
},
|
||||
) {
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_dashboard_state (
|
||||
user_id,
|
||||
wallet_balance,
|
||||
total_play_time_ms,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
wallet_balance = EXCLUDED.wallet_balance,
|
||||
total_play_time_ms = EXCLUDED.total_play_time_ms,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[userId, state.walletBalance, state.totalPlayTimeMs, state.updatedAt],
|
||||
);
|
||||
}
|
||||
|
||||
private async syncProfileDashboardFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const state = (await this.getProfileDashboardState(userId)) ?? {
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
updatedAt: snapshot.savedAt,
|
||||
};
|
||||
const syncedAt = snapshot.savedAt || new Date().toISOString();
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
const nextWalletBalance = normalizeDashboardNumber(
|
||||
gameState?.playerCurrency,
|
||||
);
|
||||
let nextTotalPlayTimeMs = normalizeDashboardNumber(state.totalPlayTimeMs);
|
||||
|
||||
if (nextWalletBalance !== state.walletBalance) {
|
||||
const amountDelta = nextWalletBalance - state.walletBalance;
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_wallet_ledger (
|
||||
id,
|
||||
user_id,
|
||||
amount_delta,
|
||||
balance_after,
|
||||
source_type,
|
||||
source_key,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id, source_key) DO NOTHING`,
|
||||
[
|
||||
randomUUID(),
|
||||
userId,
|
||||
amountDelta,
|
||||
nextWalletBalance,
|
||||
'snapshot_sync',
|
||||
`snapshot:${syncedAt}:wallet:${nextWalletBalance}`,
|
||||
syncedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const worldMeta = resolveProfileWorldSnapshotMeta(snapshot);
|
||||
if (worldMeta) {
|
||||
const currentPlayTimeMs = normalizeDashboardNumber(
|
||||
asRecord(gameState?.runtimeStats)?.playTimeMs,
|
||||
);
|
||||
const currentWorld = await this.findProfilePlayedWorld(
|
||||
userId,
|
||||
worldMeta.worldKey,
|
||||
);
|
||||
const incrementalPlayTimeMs = Math.max(
|
||||
0,
|
||||
currentPlayTimeMs -
|
||||
normalizeDashboardNumber(currentWorld?.lastObservedPlayTimeMs ?? 0),
|
||||
);
|
||||
|
||||
nextTotalPlayTimeMs += incrementalPlayTimeMs;
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_played_worlds (
|
||||
user_id,
|
||||
world_key,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_type,
|
||||
world_title,
|
||||
world_subtitle,
|
||||
first_played_at,
|
||||
last_played_at,
|
||||
last_observed_play_time_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $9)
|
||||
ON CONFLICT (user_id, world_key) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
world_type = EXCLUDED.world_type,
|
||||
world_title = EXCLUDED.world_title,
|
||||
world_subtitle = EXCLUDED.world_subtitle,
|
||||
last_played_at = EXCLUDED.last_played_at,
|
||||
last_observed_play_time_ms = GREATEST(
|
||||
profile_played_worlds.last_observed_play_time_ms,
|
||||
EXCLUDED.last_observed_play_time_ms
|
||||
)`,
|
||||
[
|
||||
userId,
|
||||
worldMeta.worldKey,
|
||||
worldMeta.ownerUserId,
|
||||
worldMeta.profileId,
|
||||
worldMeta.worldType,
|
||||
worldMeta.worldTitle,
|
||||
worldMeta.worldSubtitle,
|
||||
syncedAt,
|
||||
currentPlayTimeMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await this.upsertProfileDashboardState(userId, {
|
||||
walletBalance: nextWalletBalance,
|
||||
totalPlayTimeMs: nextTotalPlayTimeMs,
|
||||
updatedAt: syncedAt,
|
||||
});
|
||||
}
|
||||
|
||||
async getSnapshot(userId: string) {
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`SELECT version,
|
||||
@@ -288,18 +658,89 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
const persistedSnapshot = {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
|
||||
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
||||
|
||||
return persistedSnapshot;
|
||||
}
|
||||
|
||||
async getProfileDashboard(userId: string) {
|
||||
const state = await this.getProfileDashboardState(userId);
|
||||
const playedWorldsResult = await this.db.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return {
|
||||
walletBalance: normalizeDashboardNumber(state?.walletBalance ?? 0),
|
||||
totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0),
|
||||
playedWorldCount:
|
||||
Number.parseInt(playedWorldsResult.rows[0]?.count ?? '0', 10) || 0,
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
} satisfies ProfileDashboardSummary;
|
||||
}
|
||||
|
||||
async listProfileWalletLedger(userId: string) {
|
||||
const result = await this.db.query<ProfileWalletLedgerRow>(
|
||||
`SELECT id,
|
||||
amount_delta AS "amountDelta",
|
||||
balance_after AS "balanceAfter",
|
||||
source_type AS "sourceType",
|
||||
created_at AS "createdAt"
|
||||
FROM profile_wallet_ledger
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
amountDelta: row.amountDelta,
|
||||
balanceAfter: row.balanceAfter,
|
||||
sourceType: row.sourceType,
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getProfilePlayStats(userId: string) {
|
||||
const state = await this.getProfileDashboardState(userId);
|
||||
const result = await this.db.query<ProfilePlayedWorldRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_title AS "worldTitle",
|
||||
world_subtitle AS "worldSubtitle",
|
||||
first_played_at AS "firstPlayedAt",
|
||||
last_played_at AS "lastPlayedAt",
|
||||
last_observed_play_time_ms AS "lastObservedPlayTimeMs"
|
||||
FROM profile_played_worlds
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_played_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return {
|
||||
totalPlayTimeMs: normalizeDashboardNumber(state?.totalPlayTimeMs ?? 0),
|
||||
playedWorks: result.rows.map((row) => toProfilePlayedWorkSummary(row)),
|
||||
updatedAt: state?.updatedAt ?? null,
|
||||
} satisfies ProfilePlayStatsResponse;
|
||||
}
|
||||
|
||||
async deleteSnapshot(userId: string) {
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]);
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
async getSettings(userId: string) {
|
||||
@@ -339,6 +780,95 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
async listPlatformBrowseHistory(userId: string) {
|
||||
const result = await this.db.query<PlatformBrowseHistoryRow>(
|
||||
`SELECT owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_name AS "worldName",
|
||||
subtitle,
|
||||
summary_text AS "summaryText",
|
||||
cover_image_src AS "coverImageSrc",
|
||||
theme_mode AS "themeMode",
|
||||
author_display_name AS "authorDisplayName",
|
||||
visited_at AS "visitedAt"
|
||||
FROM user_browse_history
|
||||
WHERE user_id = $1
|
||||
ORDER BY visited_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => toPlatformBrowseHistoryEntry(row));
|
||||
}
|
||||
|
||||
async upsertPlatformBrowseHistoryEntries(
|
||||
userId: string,
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
) {
|
||||
const dedupedEntries = [
|
||||
...new Map(
|
||||
entries
|
||||
.map((entry) => normalizePlatformBrowseHistoryWriteEntry(entry))
|
||||
.filter((entry): entry is PlatformBrowseHistoryEntry =>
|
||||
Boolean(entry),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.visitedAt).getTime() -
|
||||
new Date(left.visitedAt).getTime(),
|
||||
)
|
||||
.map(
|
||||
(entry) =>
|
||||
[`${entry.ownerUserId}:${entry.profileId}`, entry] as const,
|
||||
),
|
||||
).values(),
|
||||
];
|
||||
|
||||
for (const entry of dedupedEntries) {
|
||||
await this.db.query(
|
||||
`INSERT INTO user_browse_history (
|
||||
user_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_name,
|
||||
subtitle,
|
||||
summary_text,
|
||||
cover_image_src,
|
||||
theme_mode,
|
||||
author_display_name,
|
||||
visited_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (user_id, owner_user_id, profile_id) DO UPDATE SET
|
||||
world_name = EXCLUDED.world_name,
|
||||
subtitle = EXCLUDED.subtitle,
|
||||
summary_text = EXCLUDED.summary_text,
|
||||
cover_image_src = EXCLUDED.cover_image_src,
|
||||
theme_mode = EXCLUDED.theme_mode,
|
||||
author_display_name = EXCLUDED.author_display_name,
|
||||
visited_at = EXCLUDED.visited_at`,
|
||||
[
|
||||
userId,
|
||||
entry.ownerUserId,
|
||||
entry.profileId,
|
||||
entry.worldName,
|
||||
entry.subtitle,
|
||||
entry.summaryText,
|
||||
entry.coverImageSrc,
|
||||
entry.themeMode,
|
||||
entry.authorDisplayName,
|
||||
entry.visitedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return this.listPlatformBrowseHistory(userId);
|
||||
}
|
||||
|
||||
async clearPlatformBrowseHistory(userId: string) {
|
||||
await this.db.query(`DELETE FROM user_browse_history WHERE user_id = $1`, [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
async listCustomWorldProfiles(userId: string) {
|
||||
const result = await this.db.query<CustomWorldEntryRow>(
|
||||
`SELECT user_id AS "ownerUserId",
|
||||
@@ -514,7 +1044,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
@@ -569,7 +1102,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
profileId: string,
|
||||
authorDisplayName: string,
|
||||
) {
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
|
||||
const existingEntry = await this.findCustomWorldProfileEntry(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
if (!existingEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user