- 在 3 个旧恢复路由上标记 [废弃] 注释,指向新的 ad-recovery 两步流程 - Plus 用户调用广告恢复接口时返回 subscriptionBenefits 权益摘要 - 包含 tier、unlimitedHearts、dailyHighRewardSessions 供客户端展示
568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
||
import { v4 as uuid } from 'uuid';
|
||
import { db } from '../../db/client.js';
|
||
import { adRecoverySessions, rewardLedger, users } from '../../db/schema.js';
|
||
import { AD_RECOVERY_RULES, HEART_RULES } from '../gamification/rules.js';
|
||
import { getDailyAttempts, getProgressSummary } from '../learning/progress-summary-service.js';
|
||
import { getSubscriptionStatus } from '../payment/subscription-service.js';
|
||
import { freezeStreak } from '../progress/streak-service.js';
|
||
import type { ProgressSummaryDto } from '../../types/app-api.js';
|
||
|
||
export type AdRecoveryType = 'hearts' | 'bonusAttempts' | 'streakProtection';
|
||
export type AdRecoveryPlatform = 'ios' | 'android' | 'harmony' | 'web';
|
||
|
||
export type AdRecoveryReason =
|
||
| 'ad_not_completed'
|
||
| 'provider_verification_failed'
|
||
| 'session_expired'
|
||
| 'daily_limit_reached'
|
||
| 'cooldown_active'
|
||
| 'already_subscribed'
|
||
| 'invalid_type';
|
||
|
||
export interface CreateAdRecoverySessionInput {
|
||
type: AdRecoveryType;
|
||
clientRequestId: string;
|
||
platform: AdRecoveryPlatform;
|
||
adProvider: string;
|
||
}
|
||
|
||
export interface CompleteAdRecoveryInput {
|
||
sessionId: string;
|
||
clientRequestId: string;
|
||
adProvider: string;
|
||
providerRewardToken?: string;
|
||
completedAt: string;
|
||
}
|
||
|
||
export interface AdRecoverySessionResponse {
|
||
sessionId: string | null;
|
||
eligible: boolean;
|
||
type?: AdRecoveryType;
|
||
adPlacementId?: string;
|
||
remainingToday?: number;
|
||
expiresAt?: string;
|
||
reason?: AdRecoveryReason;
|
||
nextAvailableAt?: string;
|
||
/** Plus 用户被拦截时返回订阅权益摘要,客户端可据此展示替代提示。 */
|
||
subscriptionBenefits?: {
|
||
tier: string;
|
||
unlimitedHearts: boolean;
|
||
dailyHighRewardSessions: number | null;
|
||
};
|
||
}
|
||
|
||
export interface AdRecoveryCompleteResponse {
|
||
status: 'completed' | 'failed';
|
||
type?: AdRecoveryType;
|
||
reward?: {
|
||
heartsDelta: number;
|
||
dailyAttemptsDelta: number;
|
||
streakProtectionGranted: boolean;
|
||
};
|
||
reason?: AdRecoveryReason;
|
||
message?: string;
|
||
progress: ProgressSummaryDto;
|
||
limits?: AdRecoveryLimits;
|
||
}
|
||
|
||
export interface AdRecoveryLimits {
|
||
remainingHeartsRecoveriesToday: number;
|
||
remainingAttemptRecoveriesToday: number;
|
||
nextStreakProtectionAvailableAt: string | null;
|
||
}
|
||
|
||
const SESSION_TTL_MS = AD_RECOVERY_RULES.sessionTtlMs;
|
||
const STREAK_PROTECTION_COOLDOWN_MS = AD_RECOVERY_RULES.streakProtectionCooldownMs;
|
||
const TRUSTED_TEST_PROVIDERS: ReadonlySet<string> = new Set(AD_RECOVERY_RULES.trustedTestProviders);
|
||
|
||
type SessionRecord = typeof adRecoverySessions.$inferSelect;
|
||
type UserTier = 'free' | 'pro' | 'proplus';
|
||
|
||
function now(): Date {
|
||
return new Date();
|
||
}
|
||
|
||
function todayStart(): Date {
|
||
const date = now();
|
||
date.setUTCHours(0, 0, 0, 0);
|
||
return date;
|
||
}
|
||
|
||
function tomorrowStart(): Date {
|
||
const date = todayStart();
|
||
date.setUTCDate(date.getUTCDate() + 1);
|
||
return date;
|
||
}
|
||
|
||
function toIso(value: Date | string | null): string | null {
|
||
if (!value) return null;
|
||
return typeof value === 'string' ? new Date(value).toISOString() : value.toISOString();
|
||
}
|
||
|
||
function toDate(value: Date | string): Date {
|
||
return typeof value === 'string' ? new Date(value) : value;
|
||
}
|
||
|
||
function placementId(type: AdRecoveryType, platform: AdRecoveryPlatform): string {
|
||
const suffixByType: Record<AdRecoveryType, string> = {
|
||
hearts: 'restore_hearts',
|
||
bonusAttempts: 'restore_bonus_attempts',
|
||
streakProtection: 'streak_protection',
|
||
};
|
||
return `duoqi_${suffixByType[type]}_${platform}`;
|
||
}
|
||
|
||
function isSubscribed(tier: UserTier | null | undefined, subscription: Awaited<ReturnType<typeof getSubscriptionStatus>>): boolean {
|
||
return tier === 'pro' || tier === 'proplus' || (subscription.status === 'active' && subscription.tier !== 'free');
|
||
}
|
||
|
||
/** 获取 Plus 用户的订阅权益摘要,供广告恢复接口在被拦截时返回给客户端。 */
|
||
async function getSubscriptionBenefits(userId: string): Promise<{ tier: string; unlimitedHearts: boolean; dailyHighRewardSessions: number | null }> {
|
||
const [tier, subscription] = await Promise.all([
|
||
getUserTier(userId),
|
||
getSubscriptionStatus(userId),
|
||
]);
|
||
const effectiveTier = (tier ?? 'free') as UserTier;
|
||
const isPlus = isSubscribed(effectiveTier, subscription);
|
||
return {
|
||
tier: isPlus ? (effectiveTier ?? 'pro') : 'free',
|
||
unlimitedHearts: isPlus,
|
||
dailyHighRewardSessions: isPlus ? null : 3,
|
||
};
|
||
}
|
||
|
||
async function getUserTier(userId: string): Promise<UserTier | null> {
|
||
const [user] = await db
|
||
.select({ tier: users.tier })
|
||
.from(users)
|
||
.where(eq(users.id, userId))
|
||
.limit(1);
|
||
return (user?.tier ?? 'free') as UserTier;
|
||
}
|
||
|
||
async function completedCountToday(userId: string, type: Extract<AdRecoveryType, 'hearts' | 'bonusAttempts'>): Promise<number> {
|
||
const rows = await db
|
||
.select({ id: adRecoverySessions.id })
|
||
.from(adRecoverySessions)
|
||
.where(and(
|
||
eq(adRecoverySessions.userId, userId),
|
||
eq(adRecoverySessions.type, type),
|
||
eq(adRecoverySessions.status, 'completed'),
|
||
gte(adRecoverySessions.completedAt, todayStart()),
|
||
lt(adRecoverySessions.completedAt, tomorrowStart()),
|
||
));
|
||
return rows.length;
|
||
}
|
||
|
||
async function getLastStreakProtection(userId: string): Promise<SessionRecord | null> {
|
||
const [session] = await db
|
||
.select()
|
||
.from(adRecoverySessions)
|
||
.where(and(
|
||
eq(adRecoverySessions.userId, userId),
|
||
eq(adRecoverySessions.type, 'streakProtection'),
|
||
eq(adRecoverySessions.status, 'completed'),
|
||
))
|
||
.orderBy(desc(adRecoverySessions.completedAt))
|
||
.limit(1);
|
||
return session ?? null;
|
||
}
|
||
|
||
async function getLimits(userId: string): Promise<AdRecoveryLimits> {
|
||
const [heartCount, attemptCount, lastStreak] = await Promise.all([
|
||
completedCountToday(userId, 'hearts'),
|
||
completedCountToday(userId, 'bonusAttempts'),
|
||
getLastStreakProtection(userId),
|
||
]);
|
||
const lastCompletedAt = lastStreak?.completedAt ? toDate(lastStreak.completedAt) : null;
|
||
const nextStreakProtectionAvailableAt = lastCompletedAt
|
||
? new Date(lastCompletedAt.getTime() + STREAK_PROTECTION_COOLDOWN_MS).toISOString()
|
||
: null;
|
||
|
||
return {
|
||
remainingHeartsRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.heartsDailyLimit - heartCount),
|
||
remainingAttemptRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.bonusAttemptsDailyLimit - attemptCount),
|
||
nextStreakProtectionAvailableAt,
|
||
};
|
||
}
|
||
|
||
async function checkEligibility(userId: string, type: AdRecoveryType): Promise<{ eligible: true; remainingToday?: number } | { eligible: false; reason: AdRecoveryReason; nextAvailableAt?: string }> {
|
||
const [tier, subscription, progress, limits] = await Promise.all([
|
||
getUserTier(userId),
|
||
getSubscriptionStatus(userId),
|
||
getProgressSummary(userId),
|
||
getLimits(userId),
|
||
]);
|
||
|
||
if (isSubscribed(tier, subscription)) {
|
||
return { eligible: false, reason: 'already_subscribed' };
|
||
}
|
||
|
||
if (type === 'hearts') {
|
||
if (limits.remainingHeartsRecoveriesToday <= 0) {
|
||
return { eligible: false, reason: 'daily_limit_reached', nextAvailableAt: tomorrowStart().toISOString() };
|
||
}
|
||
if (progress.hearts >= progress.maxHearts) {
|
||
return { eligible: false, reason: 'invalid_type' };
|
||
}
|
||
return { eligible: true, remainingToday: limits.remainingHeartsRecoveriesToday - 1 };
|
||
}
|
||
|
||
if (type === 'bonusAttempts') {
|
||
if (limits.remainingAttemptRecoveriesToday <= 0) {
|
||
return { eligible: false, reason: 'daily_limit_reached', nextAvailableAt: tomorrowStart().toISOString() };
|
||
}
|
||
if (progress.dailyAttemptsLeft >= progress.dailyAttemptsMax) {
|
||
return { eligible: false, reason: 'invalid_type' };
|
||
}
|
||
return { eligible: true, remainingToday: limits.remainingAttemptRecoveriesToday - 1 };
|
||
}
|
||
|
||
const nextAvailableAt = limits.nextStreakProtectionAvailableAt;
|
||
if (nextAvailableAt && new Date(nextAvailableAt).getTime() > Date.now()) {
|
||
return { eligible: false, reason: 'cooldown_active', nextAvailableAt };
|
||
}
|
||
|
||
return { eligible: true };
|
||
}
|
||
|
||
function sessionToCreateResponse(session: SessionRecord): AdRecoverySessionResponse {
|
||
return {
|
||
sessionId: session.id,
|
||
eligible: true,
|
||
type: session.type,
|
||
adPlacementId: session.adPlacementId,
|
||
expiresAt: toIso(session.expiresAt) ?? undefined,
|
||
};
|
||
}
|
||
|
||
function completionFailed(reason: AdRecoveryReason, progress: ProgressSummaryDto, message = '广告未完整播放,未发放奖励。'): AdRecoveryCompleteResponse {
|
||
return { status: 'failed', reason, message, progress };
|
||
}
|
||
|
||
function providerCompletionVerified(adProvider: string, providerRewardToken?: string): boolean {
|
||
return TRUSTED_TEST_PROVIDERS.has(adProvider) || Boolean(providerRewardToken?.trim());
|
||
}
|
||
|
||
function affectedRows(result: unknown): number | null {
|
||
if (Array.isArray(result)) return affectedRows(result[0]);
|
||
if (result && typeof result === 'object') {
|
||
const value = 'affectedRows' in result
|
||
? (result as { affectedRows?: unknown }).affectedRows
|
||
: (result as { rowsAffected?: unknown }).rowsAffected;
|
||
return typeof value === 'number' ? value : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function markFailed(session: SessionRecord, reason: AdRecoveryReason, progress: ProgressSummaryDto, providerError?: string): Promise<AdRecoveryCompleteResponse> {
|
||
await db
|
||
.update(adRecoverySessions)
|
||
.set({
|
||
status: reason === 'session_expired' ? 'expired' : 'failed',
|
||
failureReason: reason,
|
||
providerError,
|
||
progressAfter: progress as unknown as Record<string, unknown>,
|
||
})
|
||
.where(eq(adRecoverySessions.id, session.id));
|
||
return completionFailed(reason, progress, reason === 'session_expired' ? '广告会话已过期,请重新加载广告。' : undefined);
|
||
}
|
||
|
||
async function getSession(userId: string, sessionId: string): Promise<SessionRecord | null> {
|
||
const [session] = await db
|
||
.select()
|
||
.from(adRecoverySessions)
|
||
.where(and(
|
||
eq(adRecoverySessions.id, sessionId),
|
||
eq(adRecoverySessions.userId, userId),
|
||
))
|
||
.limit(1);
|
||
return session ?? null;
|
||
}
|
||
|
||
async function completedResponse(userId: string, session: SessionRecord): Promise<AdRecoveryCompleteResponse> {
|
||
return {
|
||
status: 'completed',
|
||
type: session.type,
|
||
reward: session.rewardSnapshot as NonNullable<AdRecoveryCompleteResponse['reward']>,
|
||
progress: session.progressAfter as unknown as ProgressSummaryDto,
|
||
limits: await getLimits(userId),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 通过统一奖励结算层发放广告恢复奖励。
|
||
*
|
||
* 每次恢复都会写入 rewardLedger,以 ad_recovery:{sessionId} 为幂等 key,
|
||
* 确保同一 session 只能结算一次。流水记录包含奖励快照和发放前后状态,
|
||
* 方便审计追溯。
|
||
*/
|
||
async function applyReward(
|
||
sessionId: string,
|
||
userId: string,
|
||
type: AdRecoveryType,
|
||
before: ProgressSummaryDto,
|
||
): Promise<{ reward: NonNullable<AdRecoveryCompleteResponse['reward']>; progress: ProgressSummaryDto }> {
|
||
// 幂等 key 绑定 sessionId,与 adRecoverySessions 的 CAS 状态机配合双保险。
|
||
const idempotencyKey = `ad_recovery:${sessionId}`;
|
||
const [existingLedger] = await db
|
||
.select({ id: rewardLedger.id })
|
||
.from(rewardLedger)
|
||
.where(and(
|
||
eq(rewardLedger.userId, userId),
|
||
eq(rewardLedger.idempotencyKey, idempotencyKey),
|
||
))
|
||
.limit(1);
|
||
|
||
// 已有流水记录说明该 session 已结算过,直接返回当前状态,不重复发放。
|
||
if (existingLedger) {
|
||
const progress = await getProgressSummary(userId);
|
||
return {
|
||
reward: {
|
||
heartsDelta: Math.max(0, progress.hearts - before.hearts),
|
||
dailyAttemptsDelta: Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft),
|
||
streakProtectionGranted: false,
|
||
},
|
||
progress,
|
||
};
|
||
}
|
||
|
||
// 记录发放前的资源快照。
|
||
const stateBefore = {
|
||
hearts: before.hearts,
|
||
maxHearts: before.maxHearts,
|
||
dailyAttemptsLeft: before.dailyAttemptsLeft,
|
||
dailyAttemptsMax: before.dailyAttemptsMax,
|
||
streakDays: before.streakDays,
|
||
streakProtectedUntil: before.streakProtectedUntil,
|
||
} as Record<string, unknown>;
|
||
|
||
let reward: NonNullable<AdRecoveryCompleteResponse['reward']>;
|
||
let resourceDeltas: Record<string, unknown>;
|
||
|
||
if (type === 'hearts') {
|
||
// 恢复爱心到上限。
|
||
const heartsBefore = before.hearts;
|
||
await db
|
||
.update(users)
|
||
.set({
|
||
heartsRemaining: HEART_RULES.freeMax,
|
||
heartsLastRestore: sql`NOW()`,
|
||
})
|
||
.where(eq(users.id, userId));
|
||
const progress = await getProgressSummary(userId);
|
||
const heartsDelta = Math.max(0, progress.hearts - heartsBefore);
|
||
reward = { heartsDelta, dailyAttemptsDelta: 0, streakProtectionGranted: false };
|
||
resourceDeltas = { hearts: heartsDelta };
|
||
} else if (type === 'bonusAttempts') {
|
||
// 恢复 1 组高奖励挑战次数。
|
||
const attempts = await getDailyAttempts(userId);
|
||
const next = Math.min(attempts.left + AD_RECOVERY_RULES.bonusAttemptsPerRecovery, attempts.max);
|
||
await db
|
||
.update(users)
|
||
.set({
|
||
dailyAttemptsLeft: next,
|
||
dailyAttemptsDate: sql`CAST(${new Date().toISOString().slice(0, 10)} AS DATE)`,
|
||
})
|
||
.where(eq(users.id, userId));
|
||
const progress = await getProgressSummary(userId);
|
||
const attemptsDelta = Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft);
|
||
reward = { heartsDelta: 0, dailyAttemptsDelta: attemptsDelta, streakProtectionGranted: false };
|
||
resourceDeltas = { bonusAttempts: attemptsDelta };
|
||
} else {
|
||
// 连胜保护:冻结签到并设置保护期。
|
||
await freezeStreak(userId);
|
||
const protectedUntil = new Date();
|
||
protectedUntil.setUTCHours(24, 0, 0, 0);
|
||
await db
|
||
.update(users)
|
||
.set({ streakProtectedUntil: protectedUntil })
|
||
.where(eq(users.id, userId));
|
||
reward = { heartsDelta: 0, dailyAttemptsDelta: 0, streakProtectionGranted: true };
|
||
resourceDeltas = { streakProtection: true };
|
||
}
|
||
|
||
const progress = await getProgressSummary(userId);
|
||
|
||
// 记录发放后的资源快照。
|
||
const stateAfter = {
|
||
hearts: progress.hearts,
|
||
maxHearts: progress.maxHearts,
|
||
dailyAttemptsLeft: progress.dailyAttemptsLeft,
|
||
dailyAttemptsMax: progress.dailyAttemptsMax,
|
||
streakDays: progress.streakDays,
|
||
streakProtectedUntil: progress.streakProtectedUntil,
|
||
} as Record<string, unknown>;
|
||
|
||
// 写入统一奖励流水,sourceType 为 ad_recovery,与 schema 中的枚举一致。
|
||
await db.insert(rewardLedger).values({
|
||
id: uuid(),
|
||
userId,
|
||
sourceType: 'ad_recovery',
|
||
sourceId: sessionId,
|
||
idempotencyKey,
|
||
status: 'completed',
|
||
rewardSnapshot: {
|
||
type,
|
||
reward,
|
||
},
|
||
resourceDeltas,
|
||
stateBefore,
|
||
stateAfter,
|
||
settledAt: sql`NOW()`,
|
||
});
|
||
|
||
return { reward, progress };
|
||
}
|
||
|
||
export async function createAdRecoverySession(userId: string, input: CreateAdRecoverySessionInput): Promise<AdRecoverySessionResponse> {
|
||
const [existing] = await db
|
||
.select()
|
||
.from(adRecoverySessions)
|
||
.where(and(
|
||
eq(adRecoverySessions.userId, userId),
|
||
eq(adRecoverySessions.clientRequestId, input.clientRequestId),
|
||
))
|
||
.limit(1);
|
||
|
||
if (existing) {
|
||
await db
|
||
.update(adRecoverySessions)
|
||
.set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` })
|
||
.where(eq(adRecoverySessions.id, existing.id));
|
||
return sessionToCreateResponse(existing);
|
||
}
|
||
|
||
const eligibility = await checkEligibility(userId, input.type);
|
||
if (!eligibility.eligible) {
|
||
// Plus 用户不需要广告恢复,返回订阅权益摘要供客户端展示。
|
||
const subscriptionBenefits = eligibility.reason === 'already_subscribed'
|
||
? await getSubscriptionBenefits(userId)
|
||
: undefined;
|
||
|
||
return {
|
||
sessionId: null,
|
||
eligible: false,
|
||
reason: eligibility.reason,
|
||
nextAvailableAt: eligibility.nextAvailableAt,
|
||
subscriptionBenefits,
|
||
};
|
||
}
|
||
|
||
const id = uuid();
|
||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||
const adPlacementId = placementId(input.type, input.platform);
|
||
|
||
await db.insert(adRecoverySessions).values({
|
||
id,
|
||
userId,
|
||
type: input.type,
|
||
status: 'pending',
|
||
clientRequestId: input.clientRequestId,
|
||
platform: input.platform,
|
||
adProvider: input.adProvider,
|
||
adPlacementId,
|
||
expiresAt,
|
||
});
|
||
|
||
return {
|
||
sessionId: id,
|
||
eligible: true,
|
||
type: input.type,
|
||
adPlacementId,
|
||
remainingToday: eligibility.remainingToday,
|
||
expiresAt: expiresAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
export async function completeAdRecoverySession(userId: string, input: CompleteAdRecoveryInput): Promise<AdRecoveryCompleteResponse> {
|
||
const session = await getSession(userId, input.sessionId);
|
||
const progress = await getProgressSummary(userId);
|
||
if (!session) {
|
||
return completionFailed('invalid_type', progress, '广告恢复会话不存在。');
|
||
}
|
||
|
||
if (session.status === 'completed' && session.progressAfter && session.rewardSnapshot) {
|
||
await db
|
||
.update(adRecoverySessions)
|
||
.set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` })
|
||
.where(eq(adRecoverySessions.id, session.id));
|
||
return completedResponse(userId, session);
|
||
}
|
||
|
||
if (session.status === 'failed' || session.status === 'expired') {
|
||
await db
|
||
.update(adRecoverySessions)
|
||
.set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` })
|
||
.where(eq(adRecoverySessions.id, session.id));
|
||
return completionFailed((session.failureReason as AdRecoveryReason | null) ?? 'ad_not_completed', progress);
|
||
}
|
||
|
||
if (session.clientRequestId !== input.clientRequestId) {
|
||
return markFailed(session, 'provider_verification_failed', progress, 'clientRequestId mismatch');
|
||
}
|
||
|
||
if (session.adProvider !== input.adProvider) {
|
||
return markFailed(session, 'provider_verification_failed', progress, 'adProvider mismatch');
|
||
}
|
||
|
||
if (toDate(session.expiresAt).getTime() < Date.now()) {
|
||
return markFailed(session, 'session_expired', progress);
|
||
}
|
||
|
||
if (!providerCompletionVerified(input.adProvider, input.providerRewardToken)) {
|
||
return markFailed(session, 'provider_verification_failed', progress, 'missing provider reward token');
|
||
}
|
||
|
||
const claimResult = await db
|
||
.update(adRecoverySessions)
|
||
.set({
|
||
status: 'settling',
|
||
completeRequestId: input.clientRequestId,
|
||
providerRewardToken: input.providerRewardToken ?? null,
|
||
})
|
||
.where(and(
|
||
eq(adRecoverySessions.id, session.id),
|
||
eq(adRecoverySessions.status, 'pending'),
|
||
));
|
||
const claimedRows = affectedRows(claimResult);
|
||
if (claimedRows === 0) {
|
||
const current = await getSession(userId, input.sessionId);
|
||
if (current?.status === 'completed' && current.progressAfter && current.rewardSnapshot) {
|
||
return completedResponse(userId, current);
|
||
}
|
||
return completionFailed('ad_not_completed', progress, '广告恢复会话正在结算,请稍后重试。');
|
||
}
|
||
|
||
const eligibility = await checkEligibility(userId, session.type);
|
||
if (!eligibility.eligible) {
|
||
return markFailed(session, eligibility.reason, progress);
|
||
}
|
||
|
||
const before = progress;
|
||
const { reward, progress: after } = await applyReward(session.id, userId, session.type, before);
|
||
const completedAt = new Date(input.completedAt);
|
||
const safeCompletedAt = Number.isNaN(completedAt.getTime()) ? new Date() : completedAt;
|
||
|
||
await db
|
||
.update(adRecoverySessions)
|
||
.set({
|
||
status: 'completed',
|
||
completeRequestId: input.clientRequestId,
|
||
rewardSnapshot: reward,
|
||
progressBefore: before as unknown as Record<string, unknown>,
|
||
progressAfter: after as unknown as Record<string, unknown>,
|
||
completedAt: safeCompletedAt,
|
||
})
|
||
.where(eq(adRecoverySessions.id, session.id));
|
||
|
||
return {
|
||
status: 'completed',
|
||
type: session.type,
|
||
reward,
|
||
progress: after,
|
||
limits: await getLimits(userId),
|
||
};
|
||
}
|