import { z } from 'zod'; /** * Server-side schemas for the account-growth routes. * * These mirror `packages/api-contracts/src/growth.ts`, which the dashboard and * the contract tests consume. The duplication is deliberate and matches * `agent-api-contracts.ts`: the server's `rootDir` is `src/`, so it cannot * import the shared package directly. `tests/web/growth-route.test.ts` parses * real responses with the shared schemas so the two cannot drift apart silently. */ const DAY_MS = 24 * 60 * 60_000; export const GrowthQuietHoursSchema = z.strictObject({ enabled: z.boolean(), startHour: z.number().int().min(0).max(23), endHour: z.number().int().min(0).max(23), }); export const BrowsingSettingsUpdateRequestSchema = z.strictObject({ enabled: z.boolean().optional(), dailyTargetMin: z.number().int().min(1).max(12).optional(), dailyTargetMax: z.number().int().min(1).max(12).optional(), postWeight: z.number().min(0).max(100).optional(), commentWeight: z.number().min(0).max(100).optional(), quietHours: GrowthQuietHoursSchema.optional(), minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(), jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(), allowNsfw: z.boolean().optional(), autoVoteEnabled: z.boolean().optional(), }); export const BrowsingSettingsSchema = z.strictObject({ enabled: z.boolean(), dailyTargetMin: z.number().int(), dailyTargetMax: z.number().int(), postWeight: z.number(), commentWeight: z.number(), quietHours: GrowthQuietHoursSchema, minimumGapMs: z.number().int(), jitterMs: z.number().int(), allowNsfw: z.boolean(), autoVoteEnabled: z.boolean(), nextDueAt: z.number().nullable(), updatedAt: z.number(), }); export const BrowsingStatsSchema = z.strictObject({ selected: z.number().int(), posts: z.number().int(), comments: z.number().int(), rejected: z.number().int(), skipped: z.number().int(), }); export const BrowsingDecisionSchema = z.strictObject({ thingFullname: z.string(), thingKind: z.enum(['post', 'comment']), postId: z.string(), subreddit: z.string(), author: z.string(), permalink: z.string(), decision: z.enum(['simulated-upvote', 'skipped', 'rejected']), reason: z.string(), voteSubmitted: z.boolean(), decidedAt: z.number(), }); export const BrowsingSummarySchema = z.strictObject({ settings: BrowsingSettingsSchema, todayTarget: z.number().int(), stats: BrowsingStatsSchema, noVotesSubmitted: z.boolean(), }); export const BrowsingDetailSchema = BrowsingSummarySchema.extend({ recentDecisions: z.array(BrowsingDecisionSchema), }); export const GrowthSettingsUpdateRequestSchema = z.strictObject({ enabled: z.boolean().optional(), minDelayMs: z.number().int().min(0).max(DAY_MS).optional(), jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(), /** Whole comments per day, or null to hand the allowance back to the ladder. */ dailyLimit: z.number().int().min(1).max(50).nullable().optional(), quietHours: GrowthQuietHoursSchema.optional(), allowNsfw: z.boolean().optional(), }); export const GrowthPauseRequestSchema = z.strictObject({ reason: z.string().min(1).max(200).optional(), }); export const GrowthSubredditCreateRequestSchema = z.strictObject({ subreddit: z.string().min(2).max(64), weight: z.number().positive().max(100).optional(), dailyCap: z.number().int().min(1).max(50).optional(), }); export const GrowthSubredditUpdateRequestSchema = z.strictObject({ enabled: z.boolean().optional(), weight: z.number().positive().max(100).optional(), dailyCap: z.number().int().min(1).max(50).optional(), }); export const GrowthSettingsSchema = z.strictObject({ enabled: z.boolean(), trustLevel: z.number().int(), /** Comments per day actually enforced: the override when set, else the earned allowance. */ dailyAllowance: z.number().int(), /** What the adaptive ladder alone would permit. */ adaptiveAllowance: z.number().int(), /** Operator override, or null when the allowance is left adaptive. */ dailyLimit: z.number().int().nullable(), minDelayMs: z.number().int(), jitterMs: z.number().int(), quietHours: GrowthQuietHoursSchema, allowNsfw: z.boolean(), pausedReason: z.string().nullable(), pausedAt: z.number().nullable(), consecutiveRemovals: z.number().int(), levelChangedAt: z.number().nullable(), updatedAt: z.number(), }); export const GrowthStatsSchema = z.strictObject({ postedLast24h: z.number().int(), postedTotal: z.number().int(), removedTotal: z.number().int(), medianScore: z.number().nullable(), queued: z.number().int(), rejectedTotal: z.number().int(), }); export const GrowthSubredditSchema = z.strictObject({ subreddit: z.string(), enabled: z.boolean(), weight: z.number(), dailyCap: z.number().int(), disabledReason: z.string().nullable(), cooldownUntil: z.number().nullable(), addedAt: z.number(), postedLast24h: z.number().int(), }); export const GrowthCommentSchema = z.strictObject({ postId: z.string(), subreddit: z.string(), status: z.enum(['discovered', 'posting', 'posted', 'verified', 'rejected', 'retryable']), threadTitle: z.string(), threadUrl: z.string(), comment: z.string().nullable(), commentPermalink: z.string().nullable(), score: z.number().nullable(), removed: z.boolean(), shadowBanned: z.boolean().nullable(), postedAt: z.number().nullable(), discoveredAt: z.number(), lastError: z.string().nullable(), }); export const GrowthAccountSummarySchema = z.strictObject({ accountId: z.string(), redditUsername: z.string(), settings: GrowthSettingsSchema, stats: GrowthStatsSchema, subredditCount: z.number().int(), activeSubredditCount: z.number().int(), browsing: BrowsingSummarySchema, }); export const GrowthAccountDetailSchema = GrowthAccountSummarySchema.extend({ subreddits: z.array(GrowthSubredditSchema), recentComments: z.array(GrowthCommentSchema), browsing: BrowsingDetailSchema, }); export const GrowthOverviewResponseSchema = z.strictObject({ generatedAt: z.number(), accounts: z.array(GrowthAccountSummarySchema), }); export const GrowthAccountResponseSchema = z.strictObject({ success: z.literal(true), account: GrowthAccountDetailSchema, });