fix(chat): isolate pending flows and cancel stale requests
This commit is contained in:
@@ -14,6 +14,7 @@ export type PendingChatUnlockStage = "auth" | "payment";
|
||||
export type PendingChatPromotionType = "voice" | "image" | "private";
|
||||
|
||||
export const PendingChatPromotionSchema = z.object({
|
||||
characterId: z.string().min(1),
|
||||
promotionType: z.enum(["voice", "image", "private"]),
|
||||
lockType: ChatLockTypeSchema,
|
||||
clientLockId: z.string().min(1),
|
||||
@@ -23,6 +24,7 @@ export const PendingChatPromotionSchema = z.object({
|
||||
const PendingChatUnlockSchema = z
|
||||
.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
characterId: z.string().min(1),
|
||||
displayMessageId: z.string().min(1),
|
||||
messageId: z.string().min(1).optional(),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
@@ -45,6 +47,7 @@ const PendingChatUnlockSchema = z
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
characterId: z.string().min(1),
|
||||
messageId: z.string().min(1),
|
||||
returnUrl: z
|
||||
.string()
|
||||
@@ -75,6 +78,7 @@ export class NavigationStorage {
|
||||
private constructor() {}
|
||||
|
||||
static async savePendingChatUnlock(input: {
|
||||
characterId: string;
|
||||
displayMessageId?: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
@@ -91,8 +95,15 @@ export class NavigationStorage {
|
||||
if (!displayMessageId) {
|
||||
throw new Error("displayMessageId is required");
|
||||
}
|
||||
if (
|
||||
input.promotion &&
|
||||
input.promotion.characterId !== input.characterId
|
||||
) {
|
||||
throw new Error("Pending promotion belongs to a different character.");
|
||||
}
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
characterId: input.characterId,
|
||||
displayMessageId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
kind: input.kind,
|
||||
@@ -110,31 +121,49 @@ export class NavigationStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
static async consumePendingChatUnlock(
|
||||
characterId: string,
|
||||
): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
if (Result.isErr(result)) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
return null;
|
||||
}
|
||||
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
if (!value) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
return null;
|
||||
}
|
||||
if (value.characterId !== characterId) return null;
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
return value;
|
||||
}
|
||||
|
||||
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
||||
static async peekPendingChatUnlock(
|
||||
characterId: string,
|
||||
): Promise<PendingChatUnlock | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatUnlock,
|
||||
PendingChatUnlockSchema,
|
||||
);
|
||||
if (Result.isErr(result)) return null;
|
||||
if (Result.isErr(result)) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
return null;
|
||||
}
|
||||
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||
if (!value) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
return value;
|
||||
return value?.characterId === characterId ? value : null;
|
||||
}
|
||||
|
||||
static async hasPendingChatUnlock(): Promise<boolean> {
|
||||
return (await NavigationStorage.peekPendingChatUnlock()) !== null;
|
||||
static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
||||
return (
|
||||
(await NavigationStorage.peekPendingChatUnlock(characterId)) !== null
|
||||
);
|
||||
}
|
||||
|
||||
static async clearPendingChatUnlock(): Promise<void> {
|
||||
@@ -143,8 +172,10 @@ export class NavigationStorage {
|
||||
|
||||
static async savePendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
characterId: string,
|
||||
): Promise<PendingChatPromotion> {
|
||||
const promotion: PendingChatPromotion = {
|
||||
characterId,
|
||||
promotionType,
|
||||
lockType: toPromotionLockType(promotionType),
|
||||
clientLockId: `promotion_${createUuidV4()}`,
|
||||
@@ -158,14 +189,25 @@ export class NavigationStorage {
|
||||
return promotion;
|
||||
}
|
||||
|
||||
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
||||
static async consumePendingChatPromotion(
|
||||
characterId: string,
|
||||
): Promise<PendingChatPromotion | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
PendingChatPromotionSchema,
|
||||
);
|
||||
if (Result.isErr(result)) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
return null;
|
||||
}
|
||||
const value = NavigationStorage.parsePendingChatPromotion(result.data);
|
||||
if (!value) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
return null;
|
||||
}
|
||||
if (value.characterId !== characterId) return null;
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatPromotion(result.data);
|
||||
return value;
|
||||
}
|
||||
|
||||
static async clearPendingChatPromotion(): Promise<void> {
|
||||
@@ -173,11 +215,13 @@ export class NavigationStorage {
|
||||
}
|
||||
|
||||
static async savePendingChatImageReturn(input: {
|
||||
characterId: string;
|
||||
messageId: string;
|
||||
returnUrl: string;
|
||||
}): Promise<void> {
|
||||
const payload: PendingChatImageReturn = {
|
||||
reason: "image_paywall",
|
||||
characterId: input.characterId,
|
||||
messageId: input.messageId,
|
||||
returnUrl: input.returnUrl,
|
||||
createdAt: Date.now(),
|
||||
@@ -189,14 +233,25 @@ export class NavigationStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static async consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
||||
static async consumePendingChatImageReturn(
|
||||
characterId: string,
|
||||
): Promise<PendingChatImageReturn | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatImageReturn,
|
||||
PendingChatImageReturnSchema,
|
||||
);
|
||||
if (Result.isErr(result)) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
return null;
|
||||
}
|
||||
const value = NavigationStorage.parsePendingChatImageReturn(result.data);
|
||||
if (!value) {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
return null;
|
||||
}
|
||||
if (value.characterId !== characterId) return null;
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatImageReturn(result.data);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parsePendingChatUnlock(
|
||||
|
||||
Reference in New Issue
Block a user