Files
RedditBot/src/web/growth-contracts.ts
T

169 lines
6.1 KiB
TypeScript

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,
});