From 107e0e66f1eb121aae8f7a0d28252daf98bcbbec Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 2 Feb 2026 14:32:51 -0500 Subject: [PATCH 01/13] Add FeatureFlagService for PostHog and tier-based feature gating Introduces a unified FeatureFlagService Effect layer that supports two categories of feature flags: PostHog flags (open string keys, gradual rollout) and tier flags (closed set via Schema.Literal, paid entitlement checks). The service is added to the ManagedRuntime so all effects get feature flag access automatically. - Add FeatureDisabledError tagged error for gate failures - Export getPostHogClient() from metrics.ts - Create FeatureFlagServiceLive with scoped lifecycle (initial load + periodic refresh with cleanup) - Rename Features to SettingsFeature with backward-compat alias Co-Authored-By: Claude Opus 4.5 --- app/AppRuntime.ts | 18 +++- app/effects/errors.ts | 8 ++ app/effects/featureFlags.ts | 170 +++++++++++++++++++++++++++++++++++ app/effects/posthog.ts | 40 +++++++++ app/helpers/featuresFlags.ts | 5 +- app/helpers/metrics.ts | 27 +----- app/server.ts | 37 ++++---- 7 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 app/effects/featureFlags.ts create mode 100644 app/effects/posthog.ts diff --git a/app/AppRuntime.ts b/app/AppRuntime.ts index 3eaa5df6..e53d1e6b 100644 --- a/app/AppRuntime.ts +++ b/app/AppRuntime.ts @@ -1,11 +1,21 @@ import { Effect, Layer, ManagedRuntime } from "effect"; +import type { PostHog } from "posthog-node"; import { DatabaseLayer, DatabaseService, type EffectKysely } from "#~/Database"; import { NotFoundError } from "#~/effects/errors"; +import { FeatureFlagServiceLive } from "#~/effects/featureFlags"; +import { PostHogService, PostHogServiceLive } from "#~/effects/posthog"; // App layer: database + PostHog + feature flags // FeatureFlagServiceLive depends on both DatabaseService and PostHogService -const AppLayer = Layer.mergeAll(DatabaseLayer); +const AppLayer = Layer.mergeAll( + DatabaseLayer, + PostHogServiceLive, + Layer.provide( + FeatureFlagServiceLive, + Layer.mergeAll(DatabaseLayer, PostHogServiceLive), + ), +); // ManagedRuntime keeps the AppLayer scope alive for the process lifetime. // Unlike Effect.runSync which closes the scope (and thus the SQLite connection) @@ -19,7 +29,11 @@ export type RuntimeContext = ManagedRuntime.ManagedRuntime.Context< >; // Extract the PostHog client for use by metrics.ts (null when no API key configured). -export const db: EffectKysely = await runtime.runPromise(DatabaseService); +export const [posthogClient, db]: [PostHog | null, EffectKysely] = + await Promise.all([ + runtime.runPromise(PostHogService), + runtime.runPromise(DatabaseService), + ]); // --- Bridge functions for legacy async/await code --- diff --git a/app/effects/errors.ts b/app/effects/errors.ts index b386f78b..98b26f35 100644 --- a/app/effects/errors.ts +++ b/app/effects/errors.ts @@ -63,3 +63,11 @@ export class ResolutionExecutionError extends Data.TaggedError( resolution: string; cause: unknown; }> {} + +export class FeatureDisabledError extends Data.TaggedError( + "FeatureDisabledError", +)<{ + feature: string; + guildId: string; + reason: "not_in_rollout" | "tier_required" | "flag_unavailable"; +}> {} diff --git a/app/effects/featureFlags.ts b/app/effects/featureFlags.ts new file mode 100644 index 00000000..40e373b8 --- /dev/null +++ b/app/effects/featureFlags.ts @@ -0,0 +1,170 @@ +import { Context, Effect, Layer, Schema, type ParseResult } from "effect"; + +import { DatabaseService } from "#~/Database"; +import { FeatureDisabledError } from "#~/effects/errors"; +import { logEffect } from "#~/effects/observability"; +import { PostHogService } from "#~/effects/posthog"; + +export const TierFlag = Schema.Literal( + "advanced_analytics", + "premium_moderation", +); +export type TierFlag = typeof TierFlag.Type; + +const PAID_FEATURES: ReadonlySet = new Set(TierFlag.literals); + +export interface IFeatureFlagService { + /** Check any PostHog flag by name. Never fails — returns false on error. */ + readonly isPostHogEnabled: ( + flag: string, + guildId: string, + ) => Effect.Effect; + + /** Multivariate value decoded through a Schema for type safety. */ + readonly getPostHogValue: ( + flag: string, + guildId: string, + schema: Schema.Schema, + ) => Effect.Effect; + + /** Check tier-based entitlement. Never fails — returns false on error. */ + readonly isTierEnabled: ( + flag: TierFlag, + guildId: string, + ) => Effect.Effect; + + /** Guard that fails with FeatureDisabledError if tier check fails. */ + readonly requireTierFeature: ( + flag: TierFlag, + guildId: string, + ) => Effect.Effect; +} + +export class FeatureFlagService extends Context.Tag("FeatureFlagService")< + FeatureFlagService, + IFeatureFlagService +>() {} + +export const FeatureFlagServiceLive = Layer.scoped( + FeatureFlagService, + Effect.gen(function* () { + const db = yield* DatabaseService; + const posthog = yield* PostHogService; + + // Await initial PostHog flag load during layer construction — no startup race + if (posthog) { + yield* Effect.tryPromise(() => posthog.reloadFeatureFlags()).pipe( + Effect.tapError((e) => + logEffect( + "warn", + "FeatureFlagService", + "Initial PostHog flag load failed, flags will default to off", + { error: String(e) }, + ), + ), + Effect.catchAll(() => Effect.void), + ); + } + + // Periodic refresh with scope-managed cleanup + if (posthog) { + yield* Effect.acquireRelease( + Effect.sync(() => + setInterval(() => { + posthog.reloadFeatureFlags().catch(() => { + // Silently swallow — flags stay at their last known state + }); + }, 30_000), + ), + (interval) => Effect.sync(() => clearInterval(interval)), + ); + } + + const checkTier = (flag: TierFlag, guildId: string) => + Effect.gen(function* () { + if (!PAID_FEATURES.has(flag)) return false; + + const [row] = yield* db + .selectFrom("guild_subscriptions") + .select(["product_tier", "status"]) + .where("guild_id", "=", guildId) + .where("status", "=", "active"); + + if (!row) return false; + + // For now, any active subscription grants all paid features. + // Tier-specific gating can be added here later when multiple tiers exist. + return true; + }).pipe( + Effect.catchAll((e) => + logEffect( + "warn", + "FeatureFlagService", + "Tier check failed, defaulting to disabled", + { flag, guildId, error: String(e) }, + ).pipe(Effect.map(() => false)), + ), + Effect.withSpan("FeatureFlagService.checkTier", { + attributes: { flag, guildId }, + }), + ); + + return { + isPostHogEnabled: (flag, guildId) => { + if (!posthog) return Effect.succeed(false as boolean); + return Effect.tryPromise(() => + posthog.isFeatureEnabled(flag, guildId, { + onlyEvaluateLocally: true, + sendFeatureFlagEvents: false, + }), + ).pipe( + Effect.map((result) => result ?? false), + Effect.catchAll(() => Effect.succeed(false as boolean)), + Effect.withSpan("FeatureFlagService.isPostHogEnabled", { + attributes: { flag, guildId }, + }), + ); + }, + + getPostHogValue: (flag, guildId, schema) => + Effect.gen(function* () { + let raw: unknown = undefined; + if (posthog) { + const result = yield* Effect.tryPromise(() => + posthog.getFeatureFlag(flag, guildId, { + onlyEvaluateLocally: true, + sendFeatureFlagEvents: false, + }), + ).pipe(Effect.catchAll(() => Effect.succeed(undefined))); + raw = result ?? undefined; + } + return yield* Schema.decodeUnknown(schema)(raw); + }).pipe( + Effect.withSpan("FeatureFlagService.getPostHogValueDecoded", { + attributes: { flag, guildId }, + }), + ), + + isTierEnabled: (flag: TierFlag, guildId: string) => + checkTier(flag, guildId), + + requireTierFeature: (flag, guildId) => + Effect.gen(function* () { + const enabled = yield* checkTier(flag, guildId); + if (!enabled) { + return yield* Effect.fail( + new FeatureDisabledError({ + feature: flag, + guildId, + reason: "tier_required", + }), + ); + } + }).pipe( + Effect.withSpan("FeatureFlagService.requireTierFeature", { + attributes: { flag, guildId }, + }), + ), + }; + }), +); diff --git a/app/effects/posthog.ts b/app/effects/posthog.ts new file mode 100644 index 00000000..07ce925d --- /dev/null +++ b/app/effects/posthog.ts @@ -0,0 +1,40 @@ +import { Context, Effect, Layer } from "effect"; +import { PostHog } from "posthog-node"; + +import { posthogApiKey, posthogHost } from "#~/helpers/env.server"; +import { log } from "#~/helpers/observability"; + +export class PostHogService extends Context.Tag("PostHogService")< + PostHogService, + PostHog | null +>() {} + +export const PostHogServiceLive = Layer.scoped( + PostHogService, + Effect.acquireRelease( + Effect.sync(() => { + if (!posthogApiKey) { + log( + "info", + "PostHogService", + "No PostHog API key configured, metrics disabled", + ); + return null; + } + const client = new PostHog(posthogApiKey, { + host: posthogHost || "https://us.i.posthog.com", + flushAt: 20, + flushInterval: 10000, + }); + log("info", "PostHogService", "PostHog client initialized"); + return client; + }), + (client) => + Effect.promise(async () => { + if (client) { + await client.shutdown(); + log("info", "PostHogService", "PostHog client shut down"); + } + }), + ), +); diff --git a/app/helpers/featuresFlags.ts b/app/helpers/featuresFlags.ts index 16cbc516..84f323f4 100644 --- a/app/helpers/featuresFlags.ts +++ b/app/helpers/featuresFlags.ts @@ -1 +1,4 @@ -export type Features = "restrict" | "escalate-level-1"; +export type SettingsFeature = "restrict" | "escalate-level-1"; + +/** @deprecated Use SettingsFeature instead. */ +export type Features = SettingsFeature; diff --git a/app/helpers/metrics.ts b/app/helpers/metrics.ts index 49e94652..e309b7d5 100644 --- a/app/helpers/metrics.ts +++ b/app/helpers/metrics.ts @@ -6,12 +6,10 @@ import type { ThreadChannel, UserContextMenuCommandInteraction, } from "discord.js"; -import { PostHog } from "posthog-node"; +import { posthogClient } from "#~/AppRuntime.ts"; import { log } from "#~/helpers/observability"; -import { posthogApiKey, posthogHost } from "./env.server"; - type EventValue = string | number | boolean; type EmitEventData = Record; @@ -41,23 +39,6 @@ const events = { spamKicked: "spam kicked", }; -// PostHog client singleton -let posthogClient: PostHog | null = null; - -function getPostHog(): PostHog | null { - if (!posthogApiKey) return null; - posthogClient ??= new PostHog(posthogApiKey, { - host: posthogHost ?? "https://us.i.posthog.com", - flushAt: 20, - flushInterval: 10000, - }); - return posthogClient; -} - -export async function shutdownMetrics() { - await posthogClient?.shutdown(); -} - export const threadStats = { messageTracked: (message: Message) => emitEvent(events.messageTracked, { @@ -290,16 +271,14 @@ const emitEvent = ( guildId, }: { data?: EmitEventData; userId?: string; guildId?: string } = {}, ) => { - const client = getPostHog(); - log("info", "Metrics", "event emitted", { user_id: userId, event_type: eventName, event_properties: data, - client: Boolean(client), + client: Boolean(posthogClient), }); - client?.capture({ + posthogClient?.capture({ distinctId: userId ?? "system", event: eventName, properties: { diff --git a/app/server.ts b/app/server.ts index c0051a9f..5f4d1f89 100644 --- a/app/server.ts +++ b/app/server.ts @@ -34,7 +34,7 @@ import { checkpointWal, runIntegrityCheck } from "./Database"; import { startHoneypotTracking } from "./discord/honeypotTracker"; import { DiscordApiError } from "./effects/errors"; import { logEffect } from "./effects/observability"; -import { botStats, shutdownMetrics } from "./helpers/metrics"; +import { botStats } from "./helpers/metrics"; export const app = express(); @@ -125,25 +125,22 @@ const startup = Effect.gen(function* () { // Graceful shutdown handler to checkpoint WAL and dispose the runtime // (tears down PostHog finalizer, feature flag interval, and SQLite connection) const handleShutdown = (signal: string) => - Promise.all([ - shutdownMetrics(), - runtime - .runPromise( - Effect.gen(function* () { - yield* logEffect("info", "Server", `Received ${signal}`); - try { - yield* checkpointWal(); - yield* logEffect("info", "Server", "Database WAL checkpointed"); - } catch (e) { - yield* logEffect("error", "Server", "Error checkpointing WAL", { - error: String(e), - }); - } - process.exit(0); - }), - ) - .then(() => runtime.dispose().then(() => console.log("ok"))), - ]); + runtime + .runPromise( + Effect.gen(function* () { + yield* logEffect("info", "Server", `Received ${signal}`); + try { + yield* checkpointWal(); + yield* logEffect("info", "Server", "Database WAL checkpointed"); + } catch (e) { + yield* logEffect("error", "Server", "Error checkpointing WAL", { + error: String(e), + }); + } + process.exit(0); + }), + ) + .then(() => runtime.dispose().then(() => console.log("ok"))); yield* logEffect("debug", "Server", "setting signal handlers"); process.on("SIGTERM", () => void handleShutdown("SIGTERM")); From 55f6fb215f5978732719f586d6caab7fe37c4052 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 3 Feb 2026 19:42:44 -0500 Subject: [PATCH 02/13] Consolidate runtime.ts into AppRuntime.ts Merge tracing, logging, and prod log-level layers directly into AppLayer so runEffect/runEffectExit no longer need to provide them per-call. Drop the unused runEffectSync and the now-unnecessary try/catch wrapper. Co-Authored-By: Claude Opus 4.5 --- app/AppRuntime.ts | 27 ++++++++-- app/commands/report/modActionLogger.ts | 2 +- app/commands/report/userLog.ts | 2 +- app/discord/automod.ts | 2 +- app/discord/escalationResolver.ts | 2 +- app/discord/gateway.ts | 2 +- app/effects/runtime.ts | 71 -------------------------- 7 files changed, 29 insertions(+), 79 deletions(-) delete mode 100644 app/effects/runtime.ts diff --git a/app/AppRuntime.ts b/app/AppRuntime.ts index e53d1e6b..a30bee2b 100644 --- a/app/AppRuntime.ts +++ b/app/AppRuntime.ts @@ -1,13 +1,23 @@ -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, Logger, LogLevel, ManagedRuntime } from "effect"; import type { PostHog } from "posthog-node"; import { DatabaseLayer, DatabaseService, type EffectKysely } from "#~/Database"; import { NotFoundError } from "#~/effects/errors"; import { FeatureFlagServiceLive } from "#~/effects/featureFlags"; import { PostHogService, PostHogServiceLive } from "#~/effects/posthog"; +import { TracingLive } from "#~/effects/tracing.js"; +import { isProd } from "#~/helpers/env.server.js"; -// App layer: database + PostHog + feature flags -// FeatureFlagServiceLive depends on both DatabaseService and PostHogService +// Infrastructure layer: tracing + structured logging + prod log level +const InfraLayer = isProd() + ? Layer.mergeAll( + TracingLive, + Logger.json, + Logger.minimumLogLevel(LogLevel.Info), + ) + : Layer.mergeAll(TracingLive, Logger.json); + +// App layer: database + PostHog + feature flags + infrastructure const AppLayer = Layer.mergeAll( DatabaseLayer, PostHogServiceLive, @@ -15,6 +25,7 @@ const AppLayer = Layer.mergeAll( FeatureFlagServiceLive, Layer.mergeAll(DatabaseLayer, PostHogServiceLive), ), + InfraLayer, ); // ManagedRuntime keeps the AppLayer scope alive for the process lifetime. @@ -57,3 +68,13 @@ export const runTakeFirstOrThrow = ( : Effect.fail(new NotFoundError({ resource: "db record", id: "" })), ), ); + +// Run an Effect through the ManagedRuntime, returning a Promise. +export const runEffect = ( + effect: Effect.Effect, +): Promise => runtime.runPromise(effect); + +// Run an Effect through the ManagedRuntime, returning a Promise. +export const runEffectExit = ( + effect: Effect.Effect, +) => runtime.runPromiseExit(effect); diff --git a/app/commands/report/modActionLogger.ts b/app/commands/report/modActionLogger.ts index f7859706..07cdd8b8 100644 --- a/app/commands/report/modActionLogger.ts +++ b/app/commands/report/modActionLogger.ts @@ -13,10 +13,10 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { runEffect } from "#~/AppRuntime"; import { logAutomod } from "#~/commands/report/automodLog.ts"; import { fetchUser } from "#~/effects/discordSdk.ts"; import { logEffect } from "#~/effects/observability.ts"; -import { runEffect } from "#~/effects/runtime.ts"; import { logModAction } from "./modActionLog"; diff --git a/app/commands/report/userLog.ts b/app/commands/report/userLog.ts index 96e224d0..af3e7bda 100644 --- a/app/commands/report/userLog.ts +++ b/app/commands/report/userLog.ts @@ -6,11 +6,11 @@ import { } from "discord.js"; import { Effect } from "effect"; +import { runEffect } from "#~/AppRuntime"; import { type DatabaseService, type SqlError } from "#~/Database"; import { forwardMessageSafe, sendMessage } from "#~/effects/discordSdk.ts"; import { DiscordApiError, type NotFoundError } from "#~/effects/errors"; import { logEffect } from "#~/effects/observability"; -import { runEffect } from "#~/effects/runtime"; import { describeAttachments, describeReactions, diff --git a/app/discord/automod.ts b/app/discord/automod.ts index 7aa75345..63c4a972 100644 --- a/app/discord/automod.ts +++ b/app/discord/automod.ts @@ -1,7 +1,7 @@ import { Events, type Client } from "discord.js"; +import { runEffect } from "#~/AppRuntime"; import { logUserMessageLegacy } from "#~/commands/report/userLog.ts"; -import { runEffect } from "#~/effects/runtime.js"; import { isStaff } from "#~/helpers/discord"; import { isSpam } from "#~/helpers/isSpam"; import { featureStats } from "#~/helpers/metrics"; diff --git a/app/discord/escalationResolver.ts b/app/discord/escalationResolver.ts index 81006e7b..38815b71 100644 --- a/app/discord/escalationResolver.ts +++ b/app/discord/escalationResolver.ts @@ -1,10 +1,10 @@ import type { Client } from "discord.js"; import { Effect, Layer } from "effect"; +import { runEffectExit } from "#~/AppRuntime"; import { checkPendingEscalationsEffect } from "#~/commands/escalate/escalationResolver"; import { getFailure } from "#~/commands/escalate/index"; import { EscalationServiceLive } from "#~/commands/escalate/service.ts"; -import { runEffectExit } from "#~/effects/runtime.ts"; import { log } from "#~/helpers/observability"; import { scheduleTask } from "#~/helpers/schedule"; diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index bced7960..837085fd 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -1,10 +1,10 @@ import { Events, InteractionType, type Client } from "discord.js"; import { Effect } from "effect"; +import { runEffect } from "#~/AppRuntime"; import { client, login } from "#~/discord/client.server"; import { matchCommand } from "#~/discord/deployCommands.server"; import { logEffect } from "#~/effects/observability.ts"; -import { runEffect } from "#~/effects/runtime"; import { type AnyCommand } from "#~/helpers/discord.ts"; import { botStats } from "#~/helpers/metrics"; import { log } from "#~/helpers/observability"; diff --git a/app/effects/runtime.ts b/app/effects/runtime.ts deleted file mode 100644 index 4fb5b8f2..00000000 --- a/app/effects/runtime.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Effect, Layer, Logger, LogLevel } from "effect"; - -import { runtime, type RuntimeContext } from "#~/AppRuntime.js"; -import { isProd } from "#~/helpers/env.server.js"; -import { log } from "#~/helpers/observability.js"; - -import { TracingLive } from "./tracing.js"; - -/** - * Runtime helpers for running Effects in the Promise-based codebase. - * These provide the bridge between Effect-based code and legacy async/await code. - * - * The runtime layer includes: - * - TracingLive: Exports spans to Sentry via OpenTelemetry - * - LoggerLive: Structured JSON logging to stdout - * - * All effects run through these helpers get both tracing and logging automatically. - * - * Database access is provided by the ManagedRuntime from Database.ts, which holds - * a single SQLite connection open for the process lifetime. - */ - -/** - * Combined runtime layer providing tracing and logging. - * TracingLive is a NodeSdk layer that needs to be provided first, - * LoggerLive is a simple logger replacement layer. - */ -const RuntimeLive = Layer.merge(TracingLive, Logger.json); - -/** - * Run an Effect and return a Promise that resolves with the success value. - * Automatically provides tracing (Sentry), logging (JSON to stdout), and - * database access (via the ManagedRuntime). - * Throws if the Effect fails. - */ -export const runEffect = async ( - effect: Effect.Effect, -): Promise => { - try { - const program = effect.pipe(Effect.provide(RuntimeLive)); - return runtime.runPromise( - isProd() - ? program.pipe(Logger.withMinimumLogLevel(LogLevel.Info)) - : program, - ); - } catch (error) { - log("error", "runtime", "Caught an error while executing Effect", { - error, - }); - throw error; - } -}; - -/** - * Run an Effect and return a Promise that resolves with an Exit value. - * Automatically provides tracing (Sentry), logging (JSON to stdout), and - * database access (via the ManagedRuntime). - * Never throws - use this when you need to inspect failures. - */ -export const runEffectExit = ( - effect: Effect.Effect, -) => runtime.runPromiseExit(effect.pipe(Effect.provide(RuntimeLive))); - -/** - * Run an Effect synchronously. - * Note: Tracing and logging layers are not provided for sync execution. - * Use runEffect for effects that need tracing/logging. - * Only use for Effects that are guaranteed to be synchronous. - */ -export const runEffectSync = (effect: Effect.Effect): A => - Effect.runSync(effect); From 9bfb7ff5ca5ceb98a335d895daff1edb1cbc99c7 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 4 Feb 2026 01:10:10 -0500 Subject: [PATCH 03/13] Add admin subscription management pages New /app/admin route shows all bot guilds with subscription status in an accordion UI. /app/admin/:guildId shows full detail with Stripe billing info. Restricted to @reactiflux.com emails. Co-Authored-By: Claude Opus 4.5 --- app/models/stripe.server.ts | 45 +++ app/models/subscriptions.server.ts | 20 ++ app/routes.ts | 3 + app/routes/__auth/admin.$guildId.tsx | 360 +++++++++++++++++++++ app/routes/__auth/admin.tsx | 461 +++++++++++++++++++++++++++ 5 files changed, 889 insertions(+) create mode 100644 app/routes/__auth/admin.$guildId.tsx create mode 100644 app/routes/__auth/admin.tsx diff --git a/app/models/stripe.server.ts b/app/models/stripe.server.ts index 0db7a0be..ac4873d3 100644 --- a/app/models/stripe.server.ts +++ b/app/models/stripe.server.ts @@ -229,6 +229,51 @@ export const StripeService = { ); }, + async listInvoices(customerId: string) { + return trackPerformance( + "listInvoices", + async () => { + log("debug", "Stripe", "Listing invoices", { customerId }); + try { + const invoices = await stripe.invoices.list({ + customer: customerId, + limit: 20, + }); + return invoices.data; + } catch (error) { + log("error", "Stripe", "Failed to list invoices", { + customerId, + error, + }); + Sentry.captureException(error); + return []; + } + }, + { customerId }, + ); + }, + + async listPaymentMethods(customerId: string) { + return trackPerformance( + "listPaymentMethods", + async () => { + log("debug", "Stripe", "Listing payment methods", { customerId }); + try { + const methods = await stripe.customers.listPaymentMethods(customerId); + return methods.data; + } catch (error) { + log("error", "Stripe", "Failed to list payment methods", { + customerId, + error, + }); + Sentry.captureException(error); + return []; + } + }, + { customerId }, + ); + }, + /** * Construct webhook event from raw body and signature */ diff --git a/app/models/subscriptions.server.ts b/app/models/subscriptions.server.ts index cdc3f49b..d2cbc25c 100644 --- a/app/models/subscriptions.server.ts +++ b/app/models/subscriptions.server.ts @@ -387,6 +387,26 @@ export const SubscriptionService = { ); }, + async getAllSubscriptions() { + return trackPerformance( + "getAllSubscriptions", + async () => { + log("debug", "Subscription", "Fetching all guild subscriptions"); + + const results = await run( + db.selectFrom("guild_subscriptions").selectAll(), + ); + + log("debug", "Subscription", "Fetched all subscriptions", { + count: results.length, + }); + + return results; + }, + {}, + ); + }, + async auditSubscriptionChanges( guildId: string, action: string, diff --git a/app/routes.ts b/app/routes.ts index 9b9c19c6..9b49ffa5 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -5,6 +5,9 @@ export default [ route("login", "routes/__auth/login.tsx"), route("app/", "routes/__auth/app.tsx"), + route("app/admin", "routes/__auth/admin.tsx"), + route("app/admin/:guildId", "routes/__auth/admin.$guildId.tsx"), + route("app/:guildId/onboard", "routes/onboard.tsx"), route("app/:guildId/sh", "routes/__auth/dashboard.tsx"), route("app/:guildId/sh/:userId", "routes/__auth/sh-user.tsx"), diff --git a/app/routes/__auth/admin.$guildId.tsx b/app/routes/__auth/admin.$guildId.tsx new file mode 100644 index 00000000..c20c7b91 --- /dev/null +++ b/app/routes/__auth/admin.$guildId.tsx @@ -0,0 +1,360 @@ +import { Routes, type APIGuild } from "discord-api-types/v10"; +import { data, Link } from "react-router"; + +import { Page } from "#~/basics/page.js"; +import { ssrDiscordSdk } from "#~/discord/api.js"; +import { log } from "#~/helpers/observability"; +import { requireUser } from "#~/models/session.server"; +import { StripeService } from "#~/models/stripe.server"; +import { SubscriptionService } from "#~/models/subscriptions.server"; + +import type { Route } from "./+types/admin.$guildId"; + +async function requireAdmin(request: Request) { + const user = await requireUser(request); + if (!user.email?.endsWith("@reactiflux.com")) { + throw data({ message: "Forbidden" }, { status: 403 }); + } + return user; +} + +export async function loader({ params, request }: Route.LoaderArgs) { + await requireAdmin(request); + const { guildId } = params; + + const subscription = await SubscriptionService.getGuildSubscription(guildId); + + // Fetch guild info from Discord + let guildInfo: { name: string; icon: string | null } = { + name: guildId, + icon: null, + }; + try { + const guild = (await ssrDiscordSdk.get(Routes.guild(guildId))) as APIGuild; + guildInfo = { name: guild.name, icon: guild.icon ?? null }; + } catch (e) { + log("warn", "admin", "Failed to fetch guild info from Discord", { + guildId, + error: e, + }); + } + + let paymentMethods: Awaited< + ReturnType + > = []; + let invoices: Awaited> = []; + let stripeCustomerUrl: string | null = null; + let stripeSubscriptionUrl: string | null = null; + + if (subscription?.stripe_customer_id) { + [paymentMethods, invoices] = await Promise.all([ + StripeService.listPaymentMethods(subscription.stripe_customer_id), + StripeService.listInvoices(subscription.stripe_customer_id), + ]); + stripeCustomerUrl = `https://dashboard.stripe.com/customers/${subscription.stripe_customer_id}`; + } + + if (subscription?.stripe_subscription_id) { + stripeSubscriptionUrl = `https://dashboard.stripe.com/subscriptions/${subscription.stripe_subscription_id}`; + } + + log("info", "admin", "Guild detail page accessed", { + guildId, + hasSubscription: !!subscription, + hasStripeCustomer: !!subscription?.stripe_customer_id, + }); + + return { + guildId, + guildInfo, + subscription, + paymentMethods, + invoices, + stripeCustomerUrl, + stripeSubscriptionUrl, + }; +} + +function ExternalLink({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) { + return ( + + {children} + + + + + ); +} + +export default function AdminGuildDetail({ + loaderData: { + guildId, + guildInfo, + subscription, + paymentMethods, + invoices, + stripeCustomerUrl, + stripeSubscriptionUrl, + }, +}: Route.ComponentProps) { + const tier = subscription?.product_tier ?? "free"; + const status = subscription?.status ?? null; + + return ( + +
+
+ + ← All Guilds + +
+ +
+ {guildInfo.icon ? ( + + ) : ( +
+ {guildInfo.name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + .toUpperCase()} +
+ )} +
+

+ {guildInfo.name} +

+

ID: {guildId}

+
+
+ + {/* Subscription Info */} +
+

Subscription

+
+
+
Tier
+
+ {tier === "paid" ? ( + + Paid + + ) : tier === "custom" ? ( + + Custom + + ) : ( + + Free + + )} +
+
+
+
Status
+
+ {status === "active" ? ( + + + Active + + ) : status ? ( + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ) : ( + None + )} +
+
+
+
Amount
+
+ {tier === "paid" + ? "$100/yr" + : tier === "custom" + ? "Custom" + : "$0"} +
+
+
+
Next Payment
+
+ {subscription?.current_period_end + ? new Date( + subscription.current_period_end, + ).toLocaleDateString() + : "-"} +
+
+
+ {subscription && ( +
+
+
Created
+
+ {subscription.created_at + ? new Date(subscription.created_at).toLocaleString() + : "-"} +
+
+
+
Updated
+
+ {subscription.updated_at + ? new Date(subscription.updated_at).toLocaleString() + : "-"} +
+
+
+ )} +
+ + {/* Stripe Links */} + {(stripeCustomerUrl ?? stripeSubscriptionUrl) && ( +
+

+ Stripe Dashboard +

+
+ {stripeCustomerUrl && ( + + Customer Page + + )} + {stripeSubscriptionUrl && ( + + Subscription Page + + )} +
+
+ )} + + {/* Payment Methods */} +
+

+ Payment Methods +

+ {paymentMethods.length === 0 ? ( +

No payment methods on file

+ ) : ( +
    + {paymentMethods.map((pm) => ( +
  • + + {pm.type} + + {pm.type === "card" && pm.card ? ( + + {pm.card.brand?.toUpperCase()} ****{pm.card.last4} (exp{" "} + {pm.card.exp_month}/{pm.card.exp_year}) + + ) : ( + {pm.type} + )} +
  • + ))} +
+ )} +
+ + {/* Invoices */} +
+

Invoices

+ {invoices.length === 0 ? ( +

No invoices

+ ) : ( + + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
DateNumberAmountStatusInvoice
+ {inv.created + ? new Date(inv.created * 1000).toLocaleDateString() + : "-"} + + {inv.number ?? "-"} + + {inv.amount_due != null + ? `$${(inv.amount_due / 100).toFixed(2)}` + : "-"} + + + {inv.status ?? "-"} + + + {inv.hosted_invoice_url && ( + + View + + )} +
+ )} +
+
+
+ ); +} diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx new file mode 100644 index 00000000..e6cec5da --- /dev/null +++ b/app/routes/__auth/admin.tsx @@ -0,0 +1,461 @@ +import { Routes, type APIGuild } from "discord-api-types/v10"; +import { data, Link, useFetcher, useSearchParams } from "react-router"; + +import { Page } from "#~/basics/page.js"; +import { ssrDiscordSdk } from "#~/discord/api.js"; +import { log } from "#~/helpers/observability"; +import { requireUser } from "#~/models/session.server"; +import { StripeService } from "#~/models/stripe.server"; +import { SubscriptionService } from "#~/models/subscriptions.server"; + +import type { Route } from "./+types/admin"; +import type { loader as guildDetailLoader } from "./admin.$guildId"; + +async function requireAdmin(request: Request) { + const user = await requireUser(request); + if (!user.email?.endsWith("@reactiflux.com")) { + throw data({ message: "Forbidden" }, { status: 403 }); + } + return user; +} + +export async function loader({ request }: Route.LoaderArgs) { + await requireAdmin(request); + + const url = new URL(request.url); + const expandedGuildIds = url.searchParams.getAll("guildId[]"); + + // Fetch bot guilds and all subscriptions in parallel + const [rawBotGuilds, subscriptions] = await Promise.all([ + ssrDiscordSdk.get(Routes.userGuilds()) as Promise, + SubscriptionService.getAllSubscriptions(), + ]); + + const subscriptionsByGuildId = new Map( + subscriptions.map((s) => [s.guild_id, s]), + ); + + const guilds = rawBotGuilds.map((g) => ({ + id: g.id, + name: g.name, + icon: g.icon ?? null, + subscription: subscriptionsByGuildId.get(g.id) ?? null, + })); + + // Sort: subscribed guilds first, then by name + guilds.sort((a, b) => { + const aHasSub = a.subscription ? 1 : 0; + const bHasSub = b.subscription ? 1 : 0; + if (aHasSub !== bHasSub) return bHasSub - aHasSub; + return a.name.localeCompare(b.name); + }); + + // For expanded guilds, fetch Stripe details + const expandedDetails: Record< + string, + { + paymentMethods: Awaited< + ReturnType + >; + invoices: Awaited>; + } + > = {}; + + for (const guildId of expandedGuildIds) { + const sub = subscriptionsByGuildId.get(guildId); + if (sub?.stripe_customer_id) { + const [paymentMethods, invoices] = await Promise.all([ + StripeService.listPaymentMethods(sub.stripe_customer_id), + StripeService.listInvoices(sub.stripe_customer_id), + ]); + expandedDetails[guildId] = { paymentMethods, invoices }; + } + } + + log("info", "admin", "Admin page accessed", { + guildCount: guilds.length, + expandedCount: expandedGuildIds.length, + }); + + return { guilds, expandedGuildIds, expandedDetails }; +} + +function TierBadge({ tier }: { tier: string | null }) { + if (!tier || tier === "free") { + return ( + + Free + + ); + } + if (tier === "paid") { + return ( + + Paid + + ); + } + return ( + + Custom + + ); +} + +function StatusDot({ status }: { status: string | null }) { + if (status === "active") { + return ( + + + Active + + ); + } + if (status && status !== "active") { + return ( + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + } + return ( + + + No subscription + + ); +} + +function tierAmount(tier: string | null) { + if (tier === "paid") return "$100/yr"; + if (tier === "custom") return "Custom"; + return "$0"; +} + +function GuildIcon({ + guildId, + icon, + name, +}: { + guildId: string; + icon: string | null; + name: string; +}) { + if (icon) { + return ( + + ); + } + return ( +
+ {name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + .toUpperCase()} +
+ ); +} + +function StripeDetails({ + guildId, + serverData, + fetcherData, +}: { + guildId: string; + serverData?: { + paymentMethods: Awaited< + ReturnType + >; + invoices: Awaited>; + }; + fetcherData?: Awaited> | undefined; +}) { + const paymentMethods = + serverData?.paymentMethods ?? fetcherData?.paymentMethods ?? []; + const invoices = serverData?.invoices ?? fetcherData?.invoices ?? []; + const stripeCustomerUrl = fetcherData?.stripeCustomerUrl ?? null; + const stripeSubscriptionUrl = fetcherData?.stripeSubscriptionUrl ?? null; + + return ( +
+ {(stripeCustomerUrl ?? stripeSubscriptionUrl) && ( +
+ {stripeCustomerUrl && ( + + Stripe Customer + + + + + )} + {stripeSubscriptionUrl && ( + + Stripe Subscription + + + + + )} +
+ )} + +
+

+ Payment Methods +

+ {paymentMethods.length === 0 ? ( +

No payment methods on file

+ ) : ( +
    + {paymentMethods.map((pm) => ( +
  • + {pm.type === "card" && pm.card + ? `${pm.card.brand?.toUpperCase()} ****${pm.card.last4} (exp ${pm.card.exp_month}/${pm.card.exp_year})` + : pm.type} +
  • + ))} +
+ )} +
+ +
+

+ Recent Invoices +

+ {invoices.length === 0 ? ( +

No invoices

+ ) : ( + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + ))} + +
DateAmountStatusInvoice
+ {inv.created + ? new Date(inv.created * 1000).toLocaleDateString() + : "-"} + + {inv.amount_due != null + ? `$${(inv.amount_due / 100).toFixed(2)}` + : "-"} + + + {inv.status ?? "-"} + + + {inv.hosted_invoice_url && ( + + View + + )} +
+ )} +
+ +
+ + View full details → + +
+
+ ); +} + +function GuildRow({ + guild, + isExpanded, + expandedDetail, +}: { + guild: { + id: string; + name: string; + icon: string | null; + subscription: { + guild_id: string | null; + product_tier: string; + status: string; + current_period_end: string | null; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + } | null; + }; + isExpanded: boolean; + expandedDetail?: { + paymentMethods: Awaited< + ReturnType + >; + invoices: Awaited>; + }; +}) { + const [searchParams, setSearchParams] = useSearchParams(); + const fetcher = useFetcher(); + + const handleToggle = (e: { currentTarget: HTMLDetailsElement }) => { + const open = e.currentTarget.open; + const current = searchParams.getAll("guildId[]"); + + if (open) { + if (!current.includes(guild.id)) { + const next = new URLSearchParams(searchParams); + next.append("guildId[]", guild.id); + setSearchParams(next, { replace: true }); + } + // Lazy-load detail data if we don't already have it from the server + if (!expandedDetail && fetcher.state === "idle" && !fetcher.data) { + void fetcher.load(`/app/admin/${guild.id}`); + } + } else { + const next = new URLSearchParams(); + for (const [key, val] of searchParams.entries()) { + if (key === "guildId[]" && val === guild.id) continue; + next.append(key, val); + } + setSearchParams(next, { replace: true }); + } + }; + + const sub = guild.subscription; + const tier = sub?.product_tier ?? null; + const status = sub?.status ?? null; + + return ( +
+ + +
+ {guild.name} +
+ + + + {sub?.current_period_end + ? `Next: ${new Date(sub.current_period_end).toLocaleDateString()}` + : ""} + + + {tierAmount(tier)} + +
+ +
+ {expandedDetail ? ( + + ) : fetcher.data ? ( + + ) : fetcher.state === "loading" ? ( +

Loading details...

+ ) : sub?.stripe_customer_id ? ( +

Loading details...

+ ) : ( +

+ No Stripe customer linked +

+ )} +
+
+ ); +} + +export default function Admin({ + loaderData: { guilds, expandedGuildIds, expandedDetails }, +}: Route.ComponentProps) { + return ( + +
+
+

+ Guild Subscriptions +

+ + {guilds.length} guild{guilds.length !== 1 ? "s" : ""} + +
+ +
+ {guilds.map((guild) => ( + + ))} + {guilds.length === 0 && ( +

+ No guilds found. The bot may not be installed in any servers. +

+ )} +
+
+
+ ); +} From 13de4f3b7fa5e5866f5c9ae4bf92e067e3031509 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 4 Feb 2026 01:21:26 -0500 Subject: [PATCH 04/13] Show Stripe dashboard links in accordion for server-rendered expansions The StripeDetails component now constructs customer/subscription URLs from the subscription data directly, so links appear whether the accordion was pre-expanded on page load or opened client-side. Co-Authored-By: Claude Opus 4.5 --- app/routes/__auth/admin.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx index e6cec5da..4c05ac12 100644 --- a/app/routes/__auth/admin.tsx +++ b/app/routes/__auth/admin.tsx @@ -165,10 +165,15 @@ function GuildIcon({ function StripeDetails({ guildId, + subscription, serverData, fetcherData, }: { guildId: string; + subscription: { + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + } | null; serverData?: { paymentMethods: Awaited< ReturnType @@ -180,8 +185,12 @@ function StripeDetails({ const paymentMethods = serverData?.paymentMethods ?? fetcherData?.paymentMethods ?? []; const invoices = serverData?.invoices ?? fetcherData?.invoices ?? []; - const stripeCustomerUrl = fetcherData?.stripeCustomerUrl ?? null; - const stripeSubscriptionUrl = fetcherData?.stripeSubscriptionUrl ?? null; + const stripeCustomerUrl = subscription?.stripe_customer_id + ? `https://dashboard.stripe.com/customers/${subscription.stripe_customer_id}` + : null; + const stripeSubscriptionUrl = subscription?.stripe_subscription_id + ? `https://dashboard.stripe.com/subscriptions/${subscription.stripe_subscription_id}` + : null; return (
@@ -408,9 +417,17 @@ function GuildRow({
{expandedDetail ? ( - + ) : fetcher.data ? ( - + ) : fetcher.state === "loading" ? (

Loading details...

) : sub?.stripe_customer_id ? ( From c1d9bba6afde9917aa72ec5f0123575646993064 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 4 Feb 2026 11:58:14 -0500 Subject: [PATCH 05/13] Add PostHog group analytics for guilds at startup Calls groupIdentify for each guild on bot startup, enriching groups with name, member count, and subscription tier from the database. Co-Authored-By: Claude Opus 4.5 --- app/effects/posthog.ts | 33 +++++++++++++++++++++++++++++++++ app/server.ts | 14 ++++++-------- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/effects/posthog.ts b/app/effects/posthog.ts index 07ce925d..4a3fd5b0 100644 --- a/app/effects/posthog.ts +++ b/app/effects/posthog.ts @@ -1,8 +1,10 @@ +import type { Collection, Guild } from "discord.js"; import { Context, Effect, Layer } from "effect"; import { PostHog } from "posthog-node"; import { posthogApiKey, posthogHost } from "#~/helpers/env.server"; import { log } from "#~/helpers/observability"; +import { SubscriptionService } from "#~/models/subscriptions.server"; export class PostHogService extends Context.Tag("PostHogService")< PostHogService, @@ -38,3 +40,34 @@ export const PostHogServiceLive = Layer.scoped( }), ), ); + +export const initializeGroups = (guilds: Collection) => + Effect.gen(function* () { + const posthog = yield* PostHogService; + if (!posthog) return; + + const subscriptions = yield* Effect.tryPromise(() => + SubscriptionService.getAllSubscriptions(), + ); + const subByGuild = new Map(subscriptions.map((s) => [s.guild_id, s])); + + for (const [guildId, guild] of guilds) { + const sub = subByGuild.get(guildId); + posthog.groupIdentify({ + groupType: "guild", + groupKey: guildId, + properties: { + name: guild.name, + member_count: guild.memberCount, + subscription_tier: sub?.product_tier ?? "free", + subscription_status: sub?.status ?? "none", + }, + }); + } + + log( + "info", + "PostHogService", + `Initialized ${guilds.size} guild groups in PostHog`, + ); + }); diff --git a/app/server.ts b/app/server.ts index 5f4d1f89..fbadd331 100644 --- a/app/server.ts +++ b/app/server.ts @@ -34,6 +34,7 @@ import { checkpointWal, runIntegrityCheck } from "./Database"; import { startHoneypotTracking } from "./discord/honeypotTracker"; import { DiscordApiError } from "./effects/errors"; import { logEffect } from "./effects/observability"; +import { initializeGroups } from "./effects/posthog"; import { botStats } from "./helpers/metrics"; export const app = express(); @@ -119,6 +120,9 @@ const startup = Effect.gen(function* () { discordClient.users.cache.size, ); + // Initialize PostHog group analytics for guilds + yield* initializeGroups(discordClient.guilds.cache); + yield* logEffect("debug", "Server", "scheduling integrity check"); yield* runtime.runFork(runIntegrityCheck); @@ -129,14 +133,8 @@ const startup = Effect.gen(function* () { .runPromise( Effect.gen(function* () { yield* logEffect("info", "Server", `Received ${signal}`); - try { - yield* checkpointWal(); - yield* logEffect("info", "Server", "Database WAL checkpointed"); - } catch (e) { - yield* logEffect("error", "Server", "Error checkpointing WAL", { - error: String(e), - }); - } + yield* checkpointWal(); + yield* logEffect("info", "Server", "Database WAL checkpointed"); process.exit(0); }), ) From c2e9b52edf01372db4a13bbdad9713297cc4d7ff Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 4 Feb 2026 11:59:21 -0500 Subject: [PATCH 06/13] Load feature flag details --- app/routes/__auth/admin.$guildId.tsx | 81 ++++++++++++++++++++++++++++ app/routes/__auth/admin.tsx | 29 ++++++++-- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/app/routes/__auth/admin.$guildId.tsx b/app/routes/__auth/admin.$guildId.tsx index c20c7b91..59daf0d7 100644 --- a/app/routes/__auth/admin.$guildId.tsx +++ b/app/routes/__auth/admin.$guildId.tsx @@ -1,6 +1,7 @@ import { Routes, type APIGuild } from "discord-api-types/v10"; import { data, Link } from "react-router"; +import { posthogClient } from "#~/AppRuntime"; import { Page } from "#~/basics/page.js"; import { ssrDiscordSdk } from "#~/discord/api.js"; import { log } from "#~/helpers/observability"; @@ -39,6 +40,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { }); } + const featureFlags: Record | null = posthogClient + ? ((await posthogClient.getAllFlags(guildId, { + onlyEvaluateLocally: true, + })) as Record) + : null; + let paymentMethods: Awaited< ReturnType > = []; @@ -72,6 +79,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { invoices, stripeCustomerUrl, stripeSubscriptionUrl, + featureFlags, }; } @@ -107,6 +115,75 @@ function ExternalLink({ ); } +export function FeatureFlagsSection({ + featureFlags, + compact, +}: { + featureFlags: Record | null; + compact?: boolean; +}) { + const Heading = compact ? "h4" : "h2"; + const headingClass = compact + ? "mb-2 text-sm font-medium text-gray-300" + : "text-lg font-semibold text-gray-200"; + const wrapperClass = compact + ? "" + : "space-y-3 rounded-md border border-gray-600 bg-gray-800 p-4"; + + if (featureFlags === null) { + return ( +
+ Feature Flags +

PostHog not configured

+
+ ); + } + + const entries = Object.entries(featureFlags); + + return ( +
+ Feature Flags + {entries.length === 0 ? ( +

No feature flags evaluated

+ ) : ( + + + + + + + + + {entries.map(([name, value]) => ( + + + + + ))} + +
FlagValue
+ {name} + + {value === true ? ( + + Enabled + + ) : value === false ? ( + + Disabled + + ) : ( + + {value} + + )} +
+ )} +
+ ); +} + export default function AdminGuildDetail({ loaderData: { guildId, @@ -116,6 +193,7 @@ export default function AdminGuildDetail({ invoices, stripeCustomerUrl, stripeSubscriptionUrl, + featureFlags, }, }: Route.ComponentProps) { const tier = subscription?.product_tier ?? "free"; @@ -354,6 +432,9 @@ export default function AdminGuildDetail({ )} + + {/* Feature Flags */} +
); diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx index 4c05ac12..55ec68a5 100644 --- a/app/routes/__auth/admin.tsx +++ b/app/routes/__auth/admin.tsx @@ -1,6 +1,7 @@ import { Routes, type APIGuild } from "discord-api-types/v10"; import { data, Link, useFetcher, useSearchParams } from "react-router"; +import { posthogClient } from "#~/AppRuntime"; import { Page } from "#~/basics/page.js"; import { ssrDiscordSdk } from "#~/discord/api.js"; import { log } from "#~/helpers/observability"; @@ -9,7 +10,10 @@ import { StripeService } from "#~/models/stripe.server"; import { SubscriptionService } from "#~/models/subscriptions.server"; import type { Route } from "./+types/admin"; -import type { loader as guildDetailLoader } from "./admin.$guildId"; +import { + FeatureFlagsSection, + type loader as guildDetailLoader, +} from "./admin.$guildId"; async function requireAdmin(request: Request) { const user = await requireUser(request); @@ -50,7 +54,7 @@ export async function loader({ request }: Route.LoaderArgs) { return a.name.localeCompare(b.name); }); - // For expanded guilds, fetch Stripe details + // For expanded guilds, fetch Stripe details and feature flags const expandedDetails: Record< string, { @@ -58,17 +62,30 @@ export async function loader({ request }: Route.LoaderArgs) { ReturnType >; invoices: Awaited>; + featureFlags: Record | null; } > = {}; for (const guildId of expandedGuildIds) { const sub = subscriptionsByGuildId.get(guildId); + const featureFlags: Record | null = posthogClient + ? ((await posthogClient.getAllFlags(guildId, { + onlyEvaluateLocally: true, + })) as Record) + : null; + if (sub?.stripe_customer_id) { const [paymentMethods, invoices] = await Promise.all([ StripeService.listPaymentMethods(sub.stripe_customer_id), StripeService.listInvoices(sub.stripe_customer_id), ]); - expandedDetails[guildId] = { paymentMethods, invoices }; + expandedDetails[guildId] = { paymentMethods, invoices, featureFlags }; + } else { + expandedDetails[guildId] = { + paymentMethods: [], + invoices: [], + featureFlags, + }; } } @@ -179,12 +196,15 @@ function StripeDetails({ ReturnType >; invoices: Awaited>; + featureFlags?: Record | null; }; fetcherData?: Awaited> | undefined; }) { const paymentMethods = serverData?.paymentMethods ?? fetcherData?.paymentMethods ?? []; const invoices = serverData?.invoices ?? fetcherData?.invoices ?? []; + const featureFlags = + serverData?.featureFlags ?? fetcherData?.featureFlags ?? null; const stripeCustomerUrl = subscription?.stripe_customer_id ? `https://dashboard.stripe.com/customers/${subscription.stripe_customer_id}` : null; @@ -323,6 +343,8 @@ function StripeDetails({ )}
+ +
>; invoices: Awaited>; + featureFlags?: Record | null; }; }) { const [searchParams, setSearchParams] = useSearchParams(); From 027edf90f371afb775190a010f67a3dd318c00c7 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 4 Feb 2026 12:55:13 -0500 Subject: [PATCH 07/13] Fix settings page failing to load guild roles and channels Use ssrDiscordSdk (standalone REST client) instead of the gateway client for fetching guild data in web routes. The gateway client's token isn't available until login completes, causing REST calls to fail with "Expected token to be set for this request." Co-Authored-By: Claude Opus 4.5 --- app/helpers/guildData.server.ts | 112 ++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 41 deletions(-) diff --git a/app/helpers/guildData.server.ts b/app/helpers/guildData.server.ts index 6b08bad5..8c174368 100644 --- a/app/helpers/guildData.server.ts +++ b/app/helpers/guildData.server.ts @@ -1,6 +1,11 @@ -import { ChannelType } from "discord.js"; - -import { client } from "#~/discord/client.server.js"; +import { + ChannelType, + Routes, + type APIChannel, + type APIRole, +} from "discord-api-types/v10"; + +import { ssrDiscordSdk } from "#~/discord/api"; import { log, trackPerformance } from "#~/helpers/observability"; export interface GuildRole { @@ -29,62 +34,87 @@ export interface GuildData { channels: ProcessedChannel[]; } +function toGuildChannel(ch: APIChannel): GuildChannel { + return { + id: ch.id, + name: ch.name ?? "", + position: "position" in ch ? (ch.position ?? 0) : 0, + type: ch.type, + parentId: "parent_id" in ch ? (ch.parent_id ?? null) : null, + }; +} + export async function fetchGuildData(guildId: string): Promise { try { - const guild = await client.guilds.fetch(guildId); - - const [guildRoles, rawGuildChannels] = await trackPerformance( + const [apiRoles, apiChannels] = await trackPerformance( "discord.fetchGuildData", - () => Promise.all([guild.roles.fetch(), guild.channels.fetch()]), + () => + Promise.all([ + ssrDiscordSdk.get(Routes.guildRoles(guildId)) as Promise, + ssrDiscordSdk.get(Routes.guildChannels(guildId)) as Promise< + APIChannel[] + >, + ]), ); - const guildChannels = rawGuildChannels.filter((x) => x !== null); - - const roles = guildRoles + const roles = apiRoles .filter((role) => role.name !== "@everyone") - .sort((a, b) => b.position - a.position); - - const categories = guildChannels - .filter((channel) => channel.type === ChannelType.GuildCategory) - .sort((a, b) => a.position - b.position); - - const allChannels = guildChannels - .filter((channel) => channel.type === ChannelType.GuildText) - .sort((a, b) => a.position - b.position); + .sort((a, b) => b.position - a.position) + .map((r) => ({ + id: r.id, + name: r.name, + position: r.position, + color: r.color, + })); + + const categories = apiChannels + .filter((ch) => ch.type === ChannelType.GuildCategory) + .sort( + (a, b) => + ("position" in a ? (a.position ?? 0) : 0) - + ("position" in b ? (b.position ?? 0) : 0), + ); + + const textChannels = apiChannels + .filter((ch) => ch.type === ChannelType.GuildText) + .sort( + (a, b) => + ("position" in a ? (a.position ?? 0) : 0) - + ("position" in b ? (b.position ?? 0) : 0), + ); log("info", "guildData", "Guild data fetched successfully", { guildId, - rolesCount: roles.size, - channelsCount: allChannels.size, - categoriesCount: categories.size, + rolesCount: roles.length, + channelsCount: textChannels.length, + categoriesCount: categories.length, }); const channelsByCategory = new Map(); - - allChannels.forEach((channel) => { - if (channel.parentId) { - if (!channelsByCategory.has(channel.parentId)) { - channelsByCategory.set(channel.parentId, []); + for (const ch of textChannels) { + const parentId = "parent_id" in ch ? ch.parent_id : null; + if (parentId) { + if (!channelsByCategory.has(parentId)) { + channelsByCategory.set(parentId, []); } - channelsByCategory.get(channel.parentId)!.push(channel); + channelsByCategory.get(parentId)!.push(toGuildChannel(ch)); } - }); + } const channels: ProcessedChannel[] = [ - ...allChannels - .filter((channel) => !channel.parentId) - .map((channel) => ({ type: "channel", data: channel }) as const), - ...categories.map((category) => { - const categoryChannels = channelsByCategory.get(category.id) ?? []; - return { - type: "category", - data: category, - children: categoryChannels.sort((a, b) => a.position - b.position), - } as const; - }), + ...textChannels + .filter((ch) => !("parent_id" in ch) || !ch.parent_id) + .map((ch) => ({ type: "channel" as const, data: toGuildChannel(ch) })), + ...categories.map((cat) => ({ + type: "category" as const, + data: toGuildChannel(cat), + children: (channelsByCategory.get(cat.id) ?? []).sort( + (a, b) => a.position - b.position, + ), + })), ]; - return { roles: [...roles.values()], channels }; + return { roles, channels }; } catch (error) { log("error", "guildData", "Failed to fetch guild data", { guildId, From c70ea372e2d0f877968bfe7a0a265b3b669cc73a Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 4 Feb 2026 13:18:20 -0500 Subject: [PATCH 08/13] Show PostHog group properties and sorted feature flags in admin pages Rename FeatureFlagsSection to PostHogSection and extend it to display group properties alongside feature flag chiclets. Add group properties to the admin overview expandos. Sort flags alphabetically. Use server-side flag evaluation for admin pages instead of local-only. Co-Authored-By: Claude Opus 4.5 --- app/routes/__auth/admin.$guildId.tsx | 121 +++++++++++++++++---------- app/routes/__auth/admin.tsx | 34 ++++++-- 2 files changed, 104 insertions(+), 51 deletions(-) diff --git a/app/routes/__auth/admin.$guildId.tsx b/app/routes/__auth/admin.$guildId.tsx index 59daf0d7..811d556c 100644 --- a/app/routes/__auth/admin.$guildId.tsx +++ b/app/routes/__auth/admin.$guildId.tsx @@ -26,13 +26,20 @@ export async function loader({ params, request }: Route.LoaderArgs) { const subscription = await SubscriptionService.getGuildSubscription(guildId); // Fetch guild info from Discord - let guildInfo: { name: string; icon: string | null } = { + let guildInfo: { name: string; icon: string | null; memberCount: number } = { name: guildId, icon: null, + memberCount: 0, }; try { - const guild = (await ssrDiscordSdk.get(Routes.guild(guildId))) as APIGuild; - guildInfo = { name: guild.name, icon: guild.icon ?? null }; + const guild = (await ssrDiscordSdk.get(Routes.guild(guildId), { + query: new URLSearchParams({ with_counts: "true" }), + })) as APIGuild; + guildInfo = { + name: guild.name, + icon: guild.icon ?? null, + memberCount: guild.approximate_member_count ?? 0, + }; } catch (e) { log("warn", "admin", "Failed to fetch guild info from Discord", { guildId, @@ -41,9 +48,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { } const featureFlags: Record | null = posthogClient - ? ((await posthogClient.getAllFlags(guildId, { - onlyEvaluateLocally: true, - })) as Record) + ? ((await posthogClient.getAllFlags(guildId)) as Record< + string, + string | boolean + >) : null; let paymentMethods: Awaited< @@ -71,6 +79,13 @@ export async function loader({ params, request }: Route.LoaderArgs) { hasStripeCustomer: !!subscription?.stripe_customer_id, }); + const groupProperties = { + name: guildInfo.name, + member_count: guildInfo.memberCount, + subscription_tier: subscription?.product_tier ?? "free", + subscription_status: subscription?.status ?? "none", + }; + return { guildId, guildInfo, @@ -80,6 +95,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { stripeCustomerUrl, stripeSubscriptionUrl, featureFlags, + groupProperties, }; } @@ -115,17 +131,23 @@ function ExternalLink({ ); } -export function FeatureFlagsSection({ +export function PostHogSection({ featureFlags, + groupProperties, compact, }: { featureFlags: Record | null; + groupProperties?: Record | null; compact?: boolean; }) { const Heading = compact ? "h4" : "h2"; + const SubHeading = compact ? "h5" : "h3"; const headingClass = compact ? "mb-2 text-sm font-medium text-gray-300" : "text-lg font-semibold text-gray-200"; + const subHeadingClass = compact + ? "mb-1 text-xs font-medium text-gray-400" + : "mb-2 text-sm font-medium text-gray-400"; const wrapperClass = compact ? "" : "space-y-3 rounded-md border border-gray-600 bg-gray-800 p-4"; @@ -133,53 +155,58 @@ export function FeatureFlagsSection({ if (featureFlags === null) { return (
- Feature Flags + PostHog

PostHog not configured

); } - const entries = Object.entries(featureFlags); + const flagEntries = Object.entries(featureFlags).sort(([a], [b]) => + a.localeCompare(b), + ); + const propEntries = groupProperties ? Object.entries(groupProperties) : []; return (
- Feature Flags - {entries.length === 0 ? ( -

No feature flags evaluated

- ) : ( - - - - - - - - - {entries.map(([name, value]) => ( - - - - + PostHog + + {propEntries.length > 0 && ( +
+ Group Properties +
+ {propEntries.map(([key, value]) => ( +
+
{key}
+
{String(value)}
+
))} -
-
FlagValue
- {name} - - {value === true ? ( - - Enabled - - ) : value === false ? ( - - Disabled - - ) : ( - - {value} - - )} -
+ +
)} + +
+ Feature Flags + {flagEntries.length === 0 ? ( +

No feature flags evaluated

+ ) : ( +
+ {flagEntries.map(([name, value]) => ( + + {name} + + ))} +
+ )} +
); } @@ -194,6 +221,7 @@ export default function AdminGuildDetail({ stripeCustomerUrl, stripeSubscriptionUrl, featureFlags, + groupProperties, }, }: Route.ComponentProps) { const tier = subscription?.product_tier ?? "free"; @@ -434,7 +462,10 @@ export default function AdminGuildDetail({ {/* Feature Flags */} - + ); diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx index 55ec68a5..12da2ed2 100644 --- a/app/routes/__auth/admin.tsx +++ b/app/routes/__auth/admin.tsx @@ -11,7 +11,7 @@ import { SubscriptionService } from "#~/models/subscriptions.server"; import type { Route } from "./+types/admin"; import { - FeatureFlagsSection, + PostHogSection, type loader as guildDetailLoader, } from "./admin.$guildId"; @@ -63,28 +63,42 @@ export async function loader({ request }: Route.LoaderArgs) { >; invoices: Awaited>; featureFlags: Record | null; + groupProperties: Record; } > = {}; for (const guildId of expandedGuildIds) { const sub = subscriptionsByGuildId.get(guildId); + const guild = guilds.find((g) => g.id === guildId); const featureFlags: Record | null = posthogClient - ? ((await posthogClient.getAllFlags(guildId, { - onlyEvaluateLocally: true, - })) as Record) + ? ((await posthogClient.getAllFlags(guildId)) as Record< + string, + string | boolean + >) : null; + const groupProperties = { + name: guild?.name ?? guildId, + subscription_tier: sub?.product_tier ?? "free", + subscription_status: sub?.status ?? "none", + }; if (sub?.stripe_customer_id) { const [paymentMethods, invoices] = await Promise.all([ StripeService.listPaymentMethods(sub.stripe_customer_id), StripeService.listInvoices(sub.stripe_customer_id), ]); - expandedDetails[guildId] = { paymentMethods, invoices, featureFlags }; + expandedDetails[guildId] = { + paymentMethods, + invoices, + featureFlags, + groupProperties, + }; } else { expandedDetails[guildId] = { paymentMethods: [], invoices: [], featureFlags, + groupProperties, }; } } @@ -197,6 +211,7 @@ function StripeDetails({ >; invoices: Awaited>; featureFlags?: Record | null; + groupProperties?: Record | null; }; fetcherData?: Awaited> | undefined; }) { @@ -205,6 +220,8 @@ function StripeDetails({ const invoices = serverData?.invoices ?? fetcherData?.invoices ?? []; const featureFlags = serverData?.featureFlags ?? fetcherData?.featureFlags ?? null; + const groupProperties = + serverData?.groupProperties ?? fetcherData?.groupProperties ?? null; const stripeCustomerUrl = subscription?.stripe_customer_id ? `https://dashboard.stripe.com/customers/${subscription.stripe_customer_id}` : null; @@ -343,7 +360,11 @@ function StripeDetails({ )} - +
; invoices: Awaited>; featureFlags?: Record | null; + groupProperties?: Record | null; }; }) { const [searchParams, setSearchParams] = useSearchParams(); From cd346bfa381be1947e2203b690ff4ccb9734704b Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 5 Feb 2026 13:35:05 -0500 Subject: [PATCH 09/13] Correct group identification logic --- app/effects/featureFlags.ts | 2 ++ app/routes/__auth/admin.$guildId.tsx | 7 +++---- app/routes/__auth/admin.tsx | 7 +++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/effects/featureFlags.ts b/app/effects/featureFlags.ts index 40e373b8..0bcbae06 100644 --- a/app/effects/featureFlags.ts +++ b/app/effects/featureFlags.ts @@ -114,6 +114,7 @@ export const FeatureFlagServiceLive = Layer.scoped( if (!posthog) return Effect.succeed(false as boolean); return Effect.tryPromise(() => posthog.isFeatureEnabled(flag, guildId, { + groups: { guild: guildId }, onlyEvaluateLocally: true, sendFeatureFlagEvents: false, }), @@ -132,6 +133,7 @@ export const FeatureFlagServiceLive = Layer.scoped( if (posthog) { const result = yield* Effect.tryPromise(() => posthog.getFeatureFlag(flag, guildId, { + groups: { guild: guildId }, onlyEvaluateLocally: true, sendFeatureFlagEvents: false, }), diff --git a/app/routes/__auth/admin.$guildId.tsx b/app/routes/__auth/admin.$guildId.tsx index 811d556c..9151b878 100644 --- a/app/routes/__auth/admin.$guildId.tsx +++ b/app/routes/__auth/admin.$guildId.tsx @@ -48,10 +48,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { } const featureFlags: Record | null = posthogClient - ? ((await posthogClient.getAllFlags(guildId)) as Record< - string, - string | boolean - >) + ? ((await posthogClient.getAllFlags(guildId, { + groups: { guild: guildId }, + })) as Record) : null; let paymentMethods: Awaited< diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx index 12da2ed2..17938276 100644 --- a/app/routes/__auth/admin.tsx +++ b/app/routes/__auth/admin.tsx @@ -71,10 +71,9 @@ export async function loader({ request }: Route.LoaderArgs) { const sub = subscriptionsByGuildId.get(guildId); const guild = guilds.find((g) => g.id === guildId); const featureFlags: Record | null = posthogClient - ? ((await posthogClient.getAllFlags(guildId)) as Record< - string, - string | boolean - >) + ? ((await posthogClient.getAllFlags(guildId, { + groups: { guild: guildId }, + })) as Record) : null; const groupProperties = { name: guild?.name ?? guildId, From 2bd75518e3d7fbf0e5e611a03f1036d5679788cd Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 5 Feb 2026 14:53:16 -0500 Subject: [PATCH 10/13] Add composable feature flag effects and gate escalate/analytics features - Add BooleanFlag type for typed flag names (mod-log, anon-report, escalate, ticketing, analytics) - Add withFeatureFlag (soft gate) and guardFeature (hard gate) composable helpers - Add runGatedFeature to AppRuntime for convenient feature-gated effect execution - Track "premium gate hit" metrics when features are blocked - Gate escalation creation behind "escalate" flag - Gate all activity tracking handlers behind "analytics" flag - Refactor activityTracker to use Effect pipelines with proper spans/logging Co-Authored-By: Claude Opus 4.5 --- app/AppRuntime.ts | 46 ++++- app/commands/escalate/escalate.ts | 5 + app/commands/escalate/handlers.ts | 5 + app/discord/activityTracker.ts | 299 +++++++++++++++++------------- app/effects/featureFlags.ts | 44 ++++- 5 files changed, 262 insertions(+), 137 deletions(-) diff --git a/app/AppRuntime.ts b/app/AppRuntime.ts index a30bee2b..824499ca 100644 --- a/app/AppRuntime.ts +++ b/app/AppRuntime.ts @@ -3,7 +3,11 @@ import type { PostHog } from "posthog-node"; import { DatabaseLayer, DatabaseService, type EffectKysely } from "#~/Database"; import { NotFoundError } from "#~/effects/errors"; -import { FeatureFlagServiceLive } from "#~/effects/featureFlags"; +import { + FeatureFlagService, + FeatureFlagServiceLive, + type BooleanFlag, +} from "#~/effects/featureFlags"; import { PostHogService, PostHogServiceLive } from "#~/effects/posthog"; import { TracingLive } from "#~/effects/tracing.js"; import { isProd } from "#~/helpers/env.server.js"; @@ -48,16 +52,27 @@ export const [posthogClient, db]: [PostHog | null, EffectKysely] = // --- Bridge functions for legacy async/await code --- -// Convenience helpers for legacy async/await code that needs to run -// EffectKysely query builders as Promises. +/** + * Convenience helpers for legacy async/await code that needs to run + * EffectKysely query builders as Promises. + * + * @deprecated + * @param effect + */ export const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); +/** + * @deprecated + */ export const runTakeFirst = ( effect: Effect.Effect, ): Promise => Effect.runPromise(Effect.map(effect, (rows) => rows[0])); +/** + * @deprecated + */ export const runTakeFirstOrThrow = ( effect: Effect.Effect, ): Promise => @@ -78,3 +93,28 @@ export const runEffect = ( export const runEffectExit = ( effect: Effect.Effect, ) => runtime.runPromiseExit(effect); + +/** + * Run an effect only if the specified feature flag is enabled for the guild. + * Returns void if the flag is disabled, otherwise returns the effect result. + */ +export const runGatedFeature = ( + flag: BooleanFlag, + guildId: string, + effect: Effect.Effect, +): Promise => + runtime.runPromise( + Effect.gen(function* () { + const flags = yield* FeatureFlagService; + const enabled = yield* flags.isPostHogEnabled(flag, guildId); + if (!enabled) { + posthogClient?.capture({ + distinctId: guildId, + event: "premium gate hit", + properties: { flag, $groups: { guild: guildId } }, + }); + return; + } + return yield* effect; + }), + ); diff --git a/app/commands/escalate/escalate.ts b/app/commands/escalate/escalate.ts index 22dc8688..643807c8 100644 --- a/app/commands/escalate/escalate.ts +++ b/app/commands/escalate/escalate.ts @@ -10,6 +10,7 @@ import { sendMessage, } from "#~/effects/discordSdk"; import { DiscordApiError } from "#~/effects/errors"; +import { FeatureFlagService, guardFeature } from "#~/effects/featureFlags"; import { logEffect } from "#~/effects/observability"; import { calculateScheduledFor } from "#~/helpers/escalationVotes"; import type { Features } from "#~/helpers/featuresFlags"; @@ -41,6 +42,10 @@ export const createEscalationEffect = ( Effect.gen(function* () { const escalationService = yield* EscalationService; const guildId = interaction.guildId!; + + // Check if escalation feature is enabled for this guild + const flags = yield* FeatureFlagService; + yield* guardFeature(flags, "escalate", guildId); const threadId = interaction.channelId; const features: Features[] = []; diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index 34d9f035..a111dae1 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -215,6 +215,11 @@ const escalate = (interaction: MessageComponentInteraction) => attributes: { guildId: interaction.guildId, userId: interaction.user.id }, }), Effect.provide(EscalationServiceLive), + Effect.catchTag("FeatureDisabledError", () => + interactionEditReply(interaction, { + content: "This is a paid feature. Upgrade with `/upgrade`", + }).pipe(Effect.catchAll(() => Effect.void)), + ), Effect.catchTag("NotFoundError", () => interactionEditReply(interaction, { content: "Failed to re-escalate, couldn't find escalation", diff --git a/app/discord/activityTracker.ts b/app/discord/activityTracker.ts index 5c82bd81..6763548b 100644 --- a/app/discord/activityTracker.ts +++ b/app/discord/activityTracker.ts @@ -1,10 +1,11 @@ import { ChannelType, Events, type Client } from "discord.js"; import { Effect } from "effect"; -import { db, run } from "#~/AppRuntime"; +import { db, runGatedFeature } from "#~/AppRuntime"; +import { logEffect } from "#~/effects/observability"; import { getMessageStats } from "#~/helpers/discord.js"; import { threadStats } from "#~/helpers/metrics"; -import { log, trackPerformance } from "#~/helpers/observability"; +import { log } from "#~/helpers/observability"; import { getOrFetchChannel } from "./utils"; @@ -23,176 +24,208 @@ export async function startActivityTracking(client: Client) { guildCount: client.guilds.cache.size, }); - client.on(Events.MessageCreate, async (msg) => { + client.on(Events.MessageCreate, (msg) => { // Filter non-human messages if ( msg.author.system || msg.author.bot || msg.webhookId || - !msg.guildId || + !msg.inGuild() || !TRACKABLE_CHANNEL_TYPES.has(msg.channel.type) ) { return; } - const info = await Effect.runPromise(getMessageStats(msg)); + void runGatedFeature( + "analytics", + msg.guildId, + Effect.gen(function* () { + const info = yield* getMessageStats(msg); + const channelInfo = yield* Effect.promise(() => getOrFetchChannel(msg)); + + yield* db.insertInto("message_stats").values({ + ...info, + code_stats: JSON.stringify(info.code_stats), + link_stats: JSON.stringify(info.link_stats), + message_id: msg.id, + author_id: msg.author.id, + guild_id: msg.guildId, + channel_id: msg.channelId, + recipient_id: msg.mentions.repliedUser?.id ?? null, + channel_category: channelInfo.category, + }); - const channelInfo = await trackPerformance( - "startActivityTracking: getOrFetchChannel", - async () => getOrFetchChannel(msg), - ); + yield* logEffect("debug", "ActivityTracker", "Message stats stored", { + messageId: msg.id, + authorId: msg.author.id, + guildId: msg.guildId, + channelId: msg.channelId, + charCount: info.char_count, + wordCount: info.word_count, + hasCode: info.code_stats.length > 0, + hasLinks: info.link_stats.length > 0, + }); - await run( - db.insertInto("message_stats").values({ - ...info, - code_stats: JSON.stringify(info.code_stats), - link_stats: JSON.stringify(info.link_stats), - message_id: msg.id, - author_id: msg.author.id, - guild_id: msg.guildId, - channel_id: msg.channelId, - recipient_id: msg.mentions.repliedUser?.id ?? null, - channel_category: channelInfo.category, - }), + // Track message in business analytics + threadStats.messageTracked(msg); + }).pipe( + Effect.catchAll((e) => + logEffect("warn", "ActivityTracker", "Failed to track message", { + messageId: msg.id, + error: String(e), + }), + ), + Effect.withSpan("ActivityTracker.trackMessage", { + attributes: { messageId: msg.id, guildId: msg.guildId }, + }), + ), ); - - log("debug", "ActivityTracker", "Message stats stored", { - messageId: msg.id, - authorId: msg.author.id, - guildId: msg.guildId, - channelId: msg.channelId, - charCount: info.char_count, - wordCount: info.word_count, - hasCode: info.code_stats.length > 0, - hasLinks: info.link_stats.length > 0, - }); - - // Track message in business analytics - threadStats.messageTracked(msg); }); - client.on(Events.MessageUpdate, async (msg) => { - await trackPerformance( - "processMessageUpdate", - async () => { - const info = await Effect.runPromise(getMessageStats(msg)); + client.on(Events.MessageUpdate, (msg) => { + if (!msg.guildId) return; + + void runGatedFeature( + "analytics", + msg.guildId, + Effect.gen(function* () { + const info = yield* getMessageStats(msg); - await run( - updateStatsById(msg.id).set({ + yield* db + .updateTable("message_stats") + .where("message_id", "=", msg.id) + .set({ ...info, code_stats: JSON.stringify(info.code_stats), link_stats: JSON.stringify(info.link_stats), - }), - ); + }); - log("debug", "ActivityTracker", "Message stats updated", { + yield* logEffect("debug", "ActivityTracker", "Message stats updated", { messageId: msg.id, charCount: info.char_count, wordCount: info.word_count, }); - }, - { messageId: msg.id }, + }).pipe( + Effect.catchAll((e) => + logEffect( + "warn", + "ActivityTracker", + "Failed to update message stats", + { + messageId: msg.id, + error: String(e), + }, + ), + ), + Effect.withSpan("ActivityTracker.updateMessage", { + attributes: { messageId: msg.id }, + }), + ), ); }); - client.on(Events.MessageDelete, async (msg) => { - if (msg.system || msg.author?.bot) { - return; - } - await trackPerformance( - "processMessageDelete", - async () => { - await run( - db.deleteFrom("message_stats").where("message_id", "=", msg.id), - ); + client.on(Events.MessageDelete, (msg) => { + if (msg.system || msg.author?.bot || !msg.guildId) return; - log("debug", "ActivityTracker", "Message stats deleted", { + void runGatedFeature( + "analytics", + msg.guildId, + Effect.gen(function* () { + yield* db.deleteFrom("message_stats").where("message_id", "=", msg.id); + + yield* logEffect("debug", "ActivityTracker", "Message stats deleted", { messageId: msg.id, }); - }, - { messageId: msg.id }, + }).pipe( + Effect.catchAll((e) => + logEffect( + "warn", + "ActivityTracker", + "Failed to delete message stats", + { + messageId: msg.id, + error: String(e), + }, + ), + ), + Effect.withSpan("ActivityTracker.deleteMessage", { + attributes: { messageId: msg.id }, + }), + ), ); }); - client.on(Events.MessageReactionAdd, async (msg) => { - await trackPerformance( - "processReactionAdd", - async () => { - await run( - updateStatsById(msg.message.id).set({ - react_count: (eb) => eb(eb.ref("react_count"), "+", 1), + client.on(Events.MessageReactionAdd, (reaction) => { + const guildId = reaction.message.guildId; + if (!guildId) return; + + void runGatedFeature( + "analytics", + guildId, + Effect.gen(function* () { + yield* db + .updateTable("message_stats") + .where("message_id", "=", reaction.message.id) + .set({ react_count: (eb) => eb(eb.ref("react_count"), "+", 1) }); + + yield* logEffect("debug", "ActivityTracker", "Reaction added"); + }).pipe( + Effect.catchAll((e) => + logEffect("warn", "ActivityTracker", "Failed to track reaction add", { + messageId: reaction.message.id, + error: String(e), }), - ); - - log("debug", "ActivityTracker", "Reaction added to message", { - messageId: msg.message.id, - userId: msg.users.cache.last()?.id, - emoji: msg.emoji.name, - }); - }, - { messageId: msg.message.id }, + ), + Effect.withSpan("ActivityTracker.reactionAdd", { + attributes: { + messageId: reaction.message.id, + emoji: reaction.emoji.name, + }, + }), + ), ); }); - client.on(Events.MessageReactionRemove, async (msg) => { - await trackPerformance( - "processReactionRemove", - async () => { - await run( - updateStatsById(msg.message.id).set({ + client.on(Events.MessageReactionRemove, (reaction) => { + const guildId = reaction.message.guildId; + if (!guildId) return; + + void runGatedFeature( + "analytics", + guildId, + Effect.gen(function* () { + yield* db + .updateTable("message_stats") + .where("message_id", "=", reaction.message.id) + .set({ react_count: (eb) => eb(eb.ref("react_count"), "-", 1), - }), + }); + + yield* logEffect( + "debug", + "ActivityTracker", + "Reaction removed from message", + { + messageId: reaction.message.id, + emoji: reaction.emoji.name, + }, ); - - log("debug", "ActivityTracker", "Reaction removed from message", { - messageId: msg.message.id, - emoji: msg.emoji.name, - }); - }, - { messageId: msg.message.id }, + }).pipe( + Effect.catchAll((e) => + logEffect( + "warn", + "ActivityTracker", + "Failed to track reaction remove", + { + messageId: reaction.message.id, + error: String(e), + }, + ), + ), + Effect.withSpan("ActivityTracker.reactionRemove", { + attributes: { messageId: reaction.message.id }, + }), + ), ); }); } - -function updateStatsById(id: string) { - return db.updateTable("message_stats").where("message_id", "=", id); -} - -export async function reportByGuild(guildId: string) { - return trackPerformance( - "reportByGuild", - async () => { - log("info", "ActivityTracker", "Generating guild report", { - guildId, - }); - - const result = await run( - db - .selectFrom("message_stats") - .select((eb) => [ - eb.fn.countAll().as("message_count"), - eb.fn.sum("char_count").as("char_total"), - eb.fn.sum("word_count").as("word_total"), - eb.fn.sum("react_count").as("react_total"), - eb.fn.avg("char_count").as("avg_chars"), - eb.fn.avg("word_count").as("avg_words"), - eb.fn.avg("react_count").as("avg_reacts"), - ]) - .where("guild_id", "=", guildId) - .groupBy("author_id"), - ); - - log("info", "ActivityTracker", "Guild report generated", { - guildId, - authorCount: result.length, - totalMessages: result.reduce( - (sum, r) => sum + Number(r.message_count), - 0, - ), - }); - - return result; - }, - { guildId }, - ); -} diff --git a/app/effects/featureFlags.ts b/app/effects/featureFlags.ts index 0bcbae06..adb1355c 100644 --- a/app/effects/featureFlags.ts +++ b/app/effects/featureFlags.ts @@ -11,12 +11,21 @@ export const TierFlag = Schema.Literal( ); export type TierFlag = typeof TierFlag.Type; +export const BooleanFlag = Schema.Literal( + "mod-log", + "anon-report", + "escalate", + "ticketing", + "analytics", +); +export type BooleanFlag = typeof BooleanFlag.Type; + const PAID_FEATURES: ReadonlySet = new Set(TierFlag.literals); export interface IFeatureFlagService { /** Check any PostHog flag by name. Never fails — returns false on error. */ readonly isPostHogEnabled: ( - flag: string, + flag: BooleanFlag, guildId: string, ) => Effect.Effect; @@ -170,3 +179,36 @@ export const FeatureFlagServiceLive = Layer.scoped( }; }), ); + +/** + * Soft gate for conditional behavior based on a boolean check. + * Runs onEnabled if check is true, onDisabled otherwise. + */ +export const withFeatureFlag = ( + check: Effect.Effect, + onEnabled: Effect.Effect, + onDisabled: Effect.Effect, +): Effect.Effect => + Effect.flatMap(check, (enabled) => + Effect.if(enabled, { onTrue: () => onEnabled, onFalse: () => onDisabled }), + ); + +/** + * Hard gate that fails with FeatureDisabledError if the flag is not enabled. + */ +export const guardFeature = ( + flags: IFeatureFlagService, + flag: BooleanFlag, + guildId: string, +): Effect.Effect => + Effect.flatMap(flags.isPostHogEnabled(flag, guildId), (enabled) => + enabled + ? Effect.void + : Effect.fail( + new FeatureDisabledError({ + feature: flag, + guildId, + reason: "not_in_rollout", + }), + ), + ); From 277a1f315922ffe9ee925cc9b111fe306eac2ca4 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 5 Feb 2026 17:29:39 -0500 Subject: [PATCH 11/13] Extract shared admin code into app/features/Admin/ Move duplicated helpers (requireAdmin, fetchFeatureFlags, fetchStripeDetails) and UI components (TierBadge, StatusDot, GuildIcon, ExternalLink, PaymentMethodsList, InvoiceTable, PostHogSection) into a shared feature module, slimming both admin route files significantly. Co-Authored-By: Claude Opus 4.6 --- app/features/Admin/components.tsx | 341 +++++++++++++++++++++++++++ app/features/Admin/helpers.server.ts | 33 +++ app/features/Admin/index.ts | 1 + app/routes/__auth/admin.$guildId.tsx | 302 +++--------------------- app/routes/__auth/admin.tsx | 269 ++++----------------- 5 files changed, 450 insertions(+), 496 deletions(-) create mode 100644 app/features/Admin/components.tsx create mode 100644 app/features/Admin/helpers.server.ts create mode 100644 app/features/Admin/index.ts diff --git a/app/features/Admin/components.tsx b/app/features/Admin/components.tsx new file mode 100644 index 00000000..e9c37bb3 --- /dev/null +++ b/app/features/Admin/components.tsx @@ -0,0 +1,341 @@ +// Structural interfaces for component props — compatible with Stripe SDK types +// without requiring a .server import in this client-safe module. +export interface PaymentMethodItem { + id: string; + type: string; + card?: { + brand?: string | null; + last4?: string; + exp_month?: number; + exp_year?: number; + } | null; +} + +export interface InvoiceItem { + id: string; + created?: number | null; + number?: string | null; + amount_due?: number | null; + status?: string | null; + hosted_invoice_url?: string | null; +} + +export function TierBadge({ tier }: { tier: string | null }) { + if (!tier || tier === "free") { + return ( + + Free + + ); + } + if (tier === "paid") { + return ( + + Paid + + ); + } + return ( + + Custom + + ); +} + +export function StatusDot({ status }: { status: string | null }) { + if (status === "active") { + return ( + + + Active + + ); + } + if (status && status !== "active") { + return ( + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + } + return ( + + + No subscription + + ); +} + +export function tierAmount(tier: string | null) { + if (tier === "paid") return "$100/yr"; + if (tier === "custom") return "Custom"; + return "$0"; +} + +export function GuildIcon({ + guildId, + icon, + name, + size = "sm", +}: { + guildId: string; + icon: string | null; + name: string; + size?: "sm" | "lg"; +}) { + const dimension = size === "lg" ? "h-12 w-12" : "h-8 w-8"; + const fontSize = size === "lg" ? "text-sm" : "text-xs"; + const cdnSize = size === "lg" ? 64 : 32; + const initials = name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + + if (icon) { + return ( + + ); + } + return ( +
+ {initials} +
+ ); +} + +export function ExternalLink({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) { + return ( +
+ {children} + + + + + ); +} + +export function PaymentMethodsList({ + paymentMethods, + compact, +}: { + paymentMethods: PaymentMethodItem[]; + compact?: boolean; +}) { + if (paymentMethods.length === 0) { + return

No payment methods on file

; + } + if (compact) { + return ( +
    + {paymentMethods.map((pm) => ( +
  • + {pm.type === "card" && pm.card + ? `${pm.card.brand?.toUpperCase()} ****${pm.card.last4} (exp ${pm.card.exp_month}/${pm.card.exp_year})` + : pm.type} +
  • + ))} +
+ ); + } + return ( +
    + {paymentMethods.map((pm) => ( +
  • + + {pm.type} + + {pm.type === "card" && pm.card ? ( + + {pm.card.brand?.toUpperCase()} ****{pm.card.last4} (exp{" "} + {pm.card.exp_month}/{pm.card.exp_year}) + + ) : ( + {pm.type} + )} +
  • + ))} +
+ ); +} + +export function InvoiceTable({ + invoices, + compact, +}: { + invoices: InvoiceItem[]; + compact?: boolean; +}) { + if (invoices.length === 0) { + return

No invoices

; + } + const py = compact ? "py-1" : "py-2"; + const pb = compact ? "pb-1" : "pb-2"; + return ( + + + + + {!compact && } + + + + + + + {invoices.map((inv) => ( + + + {!compact && ( + + )} + + + + + ))} + +
DateNumberAmountStatusInvoice
+ {inv.created + ? new Date(inv.created * 1000).toLocaleDateString() + : "-"} + + {inv.number ?? "-"} + + {inv.amount_due != null + ? `$${(inv.amount_due / 100).toFixed(2)}` + : "-"} + + + {inv.status ?? "-"} + + + {inv.hosted_invoice_url && ( + + View + + )} +
+ ); +} + +export function PostHogSection({ + featureFlags, + groupProperties, + compact, +}: { + featureFlags: Record | null; + groupProperties?: Record | null; + compact?: boolean; +}) { + const Heading = compact ? "h4" : "h2"; + const SubHeading = compact ? "h5" : "h3"; + const headingClass = compact + ? "mb-2 text-sm font-medium text-gray-300" + : "text-lg font-semibold text-gray-200"; + const subHeadingClass = compact + ? "mb-1 text-xs font-medium text-gray-400" + : "mb-2 text-sm font-medium text-gray-400"; + const wrapperClass = compact + ? "" + : "space-y-3 rounded-md border border-gray-600 bg-gray-800 p-4"; + + if (featureFlags === null) { + return ( +
+ PostHog +

PostHog not configured

+
+ ); + } + + const flagEntries = Object.entries(featureFlags).sort(([a], [b]) => + a.localeCompare(b), + ); + const propEntries = groupProperties ? Object.entries(groupProperties) : []; + + return ( +
+ PostHog + + {propEntries.length > 0 && ( +
+ Group Properties +
+ {propEntries.map(([key, value]) => ( +
+
{key}
+
{String(value)}
+
+ ))} +
+
+ )} + +
+ Feature Flags + {flagEntries.length === 0 ? ( +

No feature flags evaluated

+ ) : ( +
+ {flagEntries.map(([name, value]) => ( + + {name} + + ))} +
+ )} +
+
+ ); +} diff --git a/app/features/Admin/helpers.server.ts b/app/features/Admin/helpers.server.ts new file mode 100644 index 00000000..7f03ce89 --- /dev/null +++ b/app/features/Admin/helpers.server.ts @@ -0,0 +1,33 @@ +import { data } from "react-router"; + +import { posthogClient } from "#~/AppRuntime"; +import { requireUser } from "#~/models/session.server"; +import { StripeService } from "#~/models/stripe.server"; + +export async function requireAdmin(request: Request) { + const user = await requireUser(request); + if (!user.email?.endsWith("@reactiflux.com")) { + throw data({ message: "Forbidden" }, { status: 403 }); + } + return user; +} + +export async function fetchFeatureFlags(guildId: string) { + if (!posthogClient) return null; + return (await posthogClient.getAllFlags(guildId, { + groups: { guild: guildId }, + })) as Record; +} + +export async function fetchStripeDetails(stripeCustomerId: string) { + const [paymentMethods, invoices] = await Promise.all([ + StripeService.listPaymentMethods(stripeCustomerId), + StripeService.listInvoices(stripeCustomerId), + ]); + return { paymentMethods, invoices }; +} + +export type PaymentMethods = Awaited< + ReturnType +>; +export type Invoices = Awaited>; diff --git a/app/features/Admin/index.ts b/app/features/Admin/index.ts new file mode 100644 index 00000000..b7222e06 --- /dev/null +++ b/app/features/Admin/index.ts @@ -0,0 +1 @@ +export * from "./components.js"; diff --git a/app/routes/__auth/admin.$guildId.tsx b/app/routes/__auth/admin.$guildId.tsx index 9151b878..730a1968 100644 --- a/app/routes/__auth/admin.$guildId.tsx +++ b/app/routes/__auth/admin.$guildId.tsx @@ -1,24 +1,30 @@ import { Routes, type APIGuild } from "discord-api-types/v10"; -import { data, Link } from "react-router"; +import { Link } from "react-router"; -import { posthogClient } from "#~/AppRuntime"; import { Page } from "#~/basics/page.js"; import { ssrDiscordSdk } from "#~/discord/api.js"; +import { + ExternalLink, + GuildIcon, + InvoiceTable, + PaymentMethodsList, + PostHogSection, + StatusDot, + tierAmount, + TierBadge, +} from "#~/features/Admin/components.js"; +import { + fetchFeatureFlags, + fetchStripeDetails, + requireAdmin, + type Invoices, + type PaymentMethods, +} from "#~/features/Admin/helpers.server.js"; import { log } from "#~/helpers/observability"; -import { requireUser } from "#~/models/session.server"; -import { StripeService } from "#~/models/stripe.server"; import { SubscriptionService } from "#~/models/subscriptions.server"; import type { Route } from "./+types/admin.$guildId"; -async function requireAdmin(request: Request) { - const user = await requireUser(request); - if (!user.email?.endsWith("@reactiflux.com")) { - throw data({ message: "Forbidden" }, { status: 403 }); - } - return user; -} - export async function loader({ params, request }: Route.LoaderArgs) { await requireAdmin(request); const { guildId } = params; @@ -47,24 +53,17 @@ export async function loader({ params, request }: Route.LoaderArgs) { }); } - const featureFlags: Record | null = posthogClient - ? ((await posthogClient.getAllFlags(guildId, { - groups: { guild: guildId }, - })) as Record) - : null; + const featureFlags = await fetchFeatureFlags(guildId); - let paymentMethods: Awaited< - ReturnType - > = []; - let invoices: Awaited> = []; + let paymentMethods: PaymentMethods = []; + let invoices: Invoices = []; let stripeCustomerUrl: string | null = null; let stripeSubscriptionUrl: string | null = null; if (subscription?.stripe_customer_id) { - [paymentMethods, invoices] = await Promise.all([ - StripeService.listPaymentMethods(subscription.stripe_customer_id), - StripeService.listInvoices(subscription.stripe_customer_id), - ]); + ({ paymentMethods, invoices } = await fetchStripeDetails( + subscription.stripe_customer_id, + )); stripeCustomerUrl = `https://dashboard.stripe.com/customers/${subscription.stripe_customer_id}`; } @@ -98,118 +97,6 @@ export async function loader({ params, request }: Route.LoaderArgs) { }; } -function ExternalLink({ - href, - children, -}: { - href: string; - children: React.ReactNode; -}) { - return ( - - {children} - - - - - ); -} - -export function PostHogSection({ - featureFlags, - groupProperties, - compact, -}: { - featureFlags: Record | null; - groupProperties?: Record | null; - compact?: boolean; -}) { - const Heading = compact ? "h4" : "h2"; - const SubHeading = compact ? "h5" : "h3"; - const headingClass = compact - ? "mb-2 text-sm font-medium text-gray-300" - : "text-lg font-semibold text-gray-200"; - const subHeadingClass = compact - ? "mb-1 text-xs font-medium text-gray-400" - : "mb-2 text-sm font-medium text-gray-400"; - const wrapperClass = compact - ? "" - : "space-y-3 rounded-md border border-gray-600 bg-gray-800 p-4"; - - if (featureFlags === null) { - return ( -
- PostHog -

PostHog not configured

-
- ); - } - - const flagEntries = Object.entries(featureFlags).sort(([a], [b]) => - a.localeCompare(b), - ); - const propEntries = groupProperties ? Object.entries(groupProperties) : []; - - return ( -
- PostHog - - {propEntries.length > 0 && ( -
- Group Properties -
- {propEntries.map(([key, value]) => ( -
-
{key}
-
{String(value)}
-
- ))} -
-
- )} - -
- Feature Flags - {flagEntries.length === 0 ? ( -

No feature flags evaluated

- ) : ( -
- {flagEntries.map(([name, value]) => ( - - {name} - - ))} -
- )} -
-
- ); -} - export default function AdminGuildDetail({ loaderData: { guildId, @@ -239,22 +126,12 @@ export default function AdminGuildDetail({
- {guildInfo.icon ? ( - - ) : ( -
- {guildInfo.name - .split(" ") - .map((w) => w[0]) - .join("") - .slice(0, 2) - .toUpperCase()} -
- )} +

{guildInfo.name} @@ -270,48 +147,18 @@ export default function AdminGuildDetail({
Tier
- {tier === "paid" ? ( - - Paid - - ) : tier === "custom" ? ( - - Custom - - ) : ( - - Free - - )} +
Status
- {status === "active" ? ( - - - Active - - ) : status ? ( - - - {status.charAt(0).toUpperCase() + status.slice(1)} - - ) : ( - None - )} +
Amount
-
- {tier === "paid" - ? "$100/yr" - : tier === "custom" - ? "Custom" - : "$0"} -
+
{tierAmount(tier)}
Next Payment
@@ -372,92 +219,13 @@ export default function AdminGuildDetail({

Payment Methods

- {paymentMethods.length === 0 ? ( -

No payment methods on file

- ) : ( -
    - {paymentMethods.map((pm) => ( -
  • - - {pm.type} - - {pm.type === "card" && pm.card ? ( - - {pm.card.brand?.toUpperCase()} ****{pm.card.last4} (exp{" "} - {pm.card.exp_month}/{pm.card.exp_year}) - - ) : ( - {pm.type} - )} -
  • - ))} -
- )} + {/* Invoices */}

Invoices

- {invoices.length === 0 ? ( -

No invoices

- ) : ( - - - - - - - - - - - - {invoices.map((inv) => ( - - - - - - - - ))} - -
DateNumberAmountStatusInvoice
- {inv.created - ? new Date(inv.created * 1000).toLocaleDateString() - : "-"} - - {inv.number ?? "-"} - - {inv.amount_due != null - ? `$${(inv.amount_due / 100).toFixed(2)}` - : "-"} - - - {inv.status ?? "-"} - - - {inv.hosted_invoice_url && ( - - View - - )} -
- )} +
{/* Feature Flags */} diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx index 17938276..43be9a8d 100644 --- a/app/routes/__auth/admin.tsx +++ b/app/routes/__auth/admin.tsx @@ -1,27 +1,32 @@ import { Routes, type APIGuild } from "discord-api-types/v10"; -import { data, Link, useFetcher, useSearchParams } from "react-router"; +import { Link, useFetcher, useSearchParams } from "react-router"; -import { posthogClient } from "#~/AppRuntime"; import { Page } from "#~/basics/page.js"; import { ssrDiscordSdk } from "#~/discord/api.js"; +import { + ExternalLink, + GuildIcon, + InvoiceTable, + PaymentMethodsList, + PostHogSection, + StatusDot, + tierAmount, + TierBadge, + type InvoiceItem, + type PaymentMethodItem, +} from "#~/features/Admin/components.js"; +import { + fetchFeatureFlags, + fetchStripeDetails, + requireAdmin, + type Invoices, + type PaymentMethods, +} from "#~/features/Admin/helpers.server.js"; import { log } from "#~/helpers/observability"; -import { requireUser } from "#~/models/session.server"; -import { StripeService } from "#~/models/stripe.server"; import { SubscriptionService } from "#~/models/subscriptions.server"; import type { Route } from "./+types/admin"; -import { - PostHogSection, - type loader as guildDetailLoader, -} from "./admin.$guildId"; - -async function requireAdmin(request: Request) { - const user = await requireUser(request); - if (!user.email?.endsWith("@reactiflux.com")) { - throw data({ message: "Forbidden" }, { status: 403 }); - } - return user; -} +import type { loader as guildDetailLoader } from "./admin.$guildId"; export async function loader({ request }: Route.LoaderArgs) { await requireAdmin(request); @@ -58,10 +63,8 @@ export async function loader({ request }: Route.LoaderArgs) { const expandedDetails: Record< string, { - paymentMethods: Awaited< - ReturnType - >; - invoices: Awaited>; + paymentMethods: PaymentMethods; + invoices: Invoices; featureFlags: Record | null; groupProperties: Record; } @@ -70,11 +73,7 @@ export async function loader({ request }: Route.LoaderArgs) { for (const guildId of expandedGuildIds) { const sub = subscriptionsByGuildId.get(guildId); const guild = guilds.find((g) => g.id === guildId); - const featureFlags: Record | null = posthogClient - ? ((await posthogClient.getAllFlags(guildId, { - groups: { guild: guildId }, - })) as Record) - : null; + const featureFlags = await fetchFeatureFlags(guildId); const groupProperties = { name: guild?.name ?? guildId, subscription_tier: sub?.product_tier ?? "free", @@ -82,10 +81,9 @@ export async function loader({ request }: Route.LoaderArgs) { }; if (sub?.stripe_customer_id) { - const [paymentMethods, invoices] = await Promise.all([ - StripeService.listPaymentMethods(sub.stripe_customer_id), - StripeService.listInvoices(sub.stripe_customer_id), - ]); + const { paymentMethods, invoices } = await fetchStripeDetails( + sub.stripe_customer_id, + ); expandedDetails[guildId] = { paymentMethods, invoices, @@ -110,90 +108,7 @@ export async function loader({ request }: Route.LoaderArgs) { return { guilds, expandedGuildIds, expandedDetails }; } -function TierBadge({ tier }: { tier: string | null }) { - if (!tier || tier === "free") { - return ( - - Free - - ); - } - if (tier === "paid") { - return ( - - Paid - - ); - } - return ( - - Custom - - ); -} - -function StatusDot({ status }: { status: string | null }) { - if (status === "active") { - return ( - - - Active - - ); - } - if (status && status !== "active") { - return ( - - - {status.charAt(0).toUpperCase() + status.slice(1)} - - ); - } - return ( - - - No subscription - - ); -} - -function tierAmount(tier: string | null) { - if (tier === "paid") return "$100/yr"; - if (tier === "custom") return "Custom"; - return "$0"; -} - -function GuildIcon({ - guildId, - icon, - name, -}: { - guildId: string; - icon: string | null; - name: string; -}) { - if (icon) { - return ( - - ); - } - return ( -
- {name - .split(" ") - .map((w) => w[0]) - .join("") - .slice(0, 2) - .toUpperCase()} -
- ); -} - -function StripeDetails({ +function ExpandedGuildDetails({ guildId, subscription, serverData, @@ -205,10 +120,8 @@ function StripeDetails({ stripe_subscription_id: string | null; } | null; serverData?: { - paymentMethods: Awaited< - ReturnType - >; - invoices: Awaited>; + paymentMethods: PaymentMethodItem[]; + invoices: InvoiceItem[]; featureFlags?: Record | null; groupProperties?: Record | null; }; @@ -233,50 +146,14 @@ function StripeDetails({ {(stripeCustomerUrl ?? stripeSubscriptionUrl) && (
{stripeCustomerUrl && ( - + Stripe Customer - - - - + )} {stripeSubscriptionUrl && ( - + Stripe Subscription - - - - + )}
)} @@ -285,78 +162,14 @@ function StripeDetails({

Payment Methods

- {paymentMethods.length === 0 ? ( -

No payment methods on file

- ) : ( -
    - {paymentMethods.map((pm) => ( -
  • - {pm.type === "card" && pm.card - ? `${pm.card.brand?.toUpperCase()} ****${pm.card.last4} (exp ${pm.card.exp_month}/${pm.card.exp_year})` - : pm.type} -
  • - ))} -
- )} +

Recent Invoices

- {invoices.length === 0 ? ( -

No invoices

- ) : ( - - - - - - - - - - - {invoices.map((inv) => ( - - - - - - - ))} - -
DateAmountStatusInvoice
- {inv.created - ? new Date(inv.created * 1000).toLocaleDateString() - : "-"} - - {inv.amount_due != null - ? `$${(inv.amount_due / 100).toFixed(2)}` - : "-"} - - - {inv.status ?? "-"} - - - {inv.hosted_invoice_url && ( - - View - - )} -
- )} +
- >; - invoices: Awaited>; + paymentMethods: PaymentMethodItem[]; + invoices: InvoiceItem[]; featureFlags?: Record | null; groupProperties?: Record | null; }; @@ -461,13 +272,13 @@ function GuildRow({
{expandedDetail ? ( - ) : fetcher.data ? ( - Date: Thu, 5 Feb 2026 17:42:34 -0500 Subject: [PATCH 12/13] Faster admin page data fetch, include guild ID in group identity --- app/effects/posthog.ts | 1 + app/routes/__auth/admin.tsx | 64 +++++++++++++++---------------------- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/app/effects/posthog.ts b/app/effects/posthog.ts index 4a3fd5b0..c85dc418 100644 --- a/app/effects/posthog.ts +++ b/app/effects/posthog.ts @@ -57,6 +57,7 @@ export const initializeGroups = (guilds: Collection) => groupType: "guild", groupKey: guildId, properties: { + id: guild.id, name: guild.name, member_count: guild.memberCount, subscription_tier: sub?.product_tier ?? "free", diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx index 43be9a8d..ed7ab5c3 100644 --- a/app/routes/__auth/admin.tsx +++ b/app/routes/__auth/admin.tsx @@ -59,46 +59,34 @@ export async function loader({ request }: Route.LoaderArgs) { return a.name.localeCompare(b.name); }); - // For expanded guilds, fetch Stripe details and feature flags - const expandedDetails: Record< - string, - { - paymentMethods: PaymentMethods; - invoices: Invoices; - featureFlags: Record | null; - groupProperties: Record; - } - > = {}; + // For expanded guilds, fetch Stripe details and feature flags concurrently + const expandedEntries = await Promise.all( + expandedGuildIds.map(async (guildId) => { + const sub = subscriptionsByGuildId.get(guildId); + const guild = guilds.find((g) => g.id === guildId); + const groupProperties = { + name: guild?.name ?? guildId, + subscription_tier: sub?.product_tier ?? "free", + subscription_status: sub?.status ?? "none", + }; - for (const guildId of expandedGuildIds) { - const sub = subscriptionsByGuildId.get(guildId); - const guild = guilds.find((g) => g.id === guildId); - const featureFlags = await fetchFeatureFlags(guildId); - const groupProperties = { - name: guild?.name ?? guildId, - subscription_tier: sub?.product_tier ?? "free", - subscription_status: sub?.status ?? "none", - }; + const [featureFlags, stripeData] = await Promise.all([ + fetchFeatureFlags(guildId), + sub?.stripe_customer_id + ? fetchStripeDetails(sub.stripe_customer_id) + : Promise.resolve({ + paymentMethods: [] as PaymentMethods, + invoices: [] as Invoices, + }), + ]); - if (sub?.stripe_customer_id) { - const { paymentMethods, invoices } = await fetchStripeDetails( - sub.stripe_customer_id, - ); - expandedDetails[guildId] = { - paymentMethods, - invoices, - featureFlags, - groupProperties, - }; - } else { - expandedDetails[guildId] = { - paymentMethods: [], - invoices: [], - featureFlags, - groupProperties, - }; - } - } + return [ + guildId, + { ...stripeData, featureFlags, groupProperties }, + ] as const; + }), + ); + const expandedDetails = Object.fromEntries(expandedEntries); log("info", "admin", "Admin page accessed", { guildCount: guilds.length, From 1ef094e6b1f1d391fa3d7da5d459bd94b53c8e6b Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 5 Feb 2026 19:06:35 -0500 Subject: [PATCH 13/13] Add feature gating tests --- app/effects/featureFlags.test.ts | 98 ++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 app/effects/featureFlags.test.ts diff --git a/app/effects/featureFlags.test.ts b/app/effects/featureFlags.test.ts new file mode 100644 index 00000000..78e7cf5e --- /dev/null +++ b/app/effects/featureFlags.test.ts @@ -0,0 +1,98 @@ +import { Effect, Exit } from "effect"; +import { vi } from "vitest"; + +import { FeatureDisabledError } from "./errors"; +import { + guardFeature, + withFeatureFlag, + type IFeatureFlagService, +} from "./featureFlags"; + +// Mock heavy transitive deps that featureFlags.ts imports but we don't use +vi.mock("#~/Database", () => ({ + DatabaseService: { key: "DatabaseService" }, +})); +vi.mock("#~/effects/posthog", () => ({ + PostHogService: { key: "PostHogService" }, +})); + +describe("withFeatureFlag", () => { + test("runs onEnabled when check returns true", async () => { + const result = await Effect.runPromise( + withFeatureFlag( + Effect.succeed(true), + Effect.succeed("enabled-path"), + Effect.succeed("disabled-path"), + ), + ); + expect(result).toBe("enabled-path"); + }); + + test("runs onDisabled when check returns false", async () => { + const result = await Effect.runPromise( + withFeatureFlag( + Effect.succeed(false), + Effect.succeed("enabled-path"), + Effect.succeed("disabled-path"), + ), + ); + expect(result).toBe("disabled-path"); + }); + + test("propagates defects from the check effect", async () => { + const exit = await Effect.runPromiseExit( + withFeatureFlag( + Effect.die("check failed"), + Effect.succeed("enabled-path"), + Effect.succeed("disabled-path"), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + }); +}); + +describe("guardFeature", () => { + const makeMockFlags = (enabled: boolean): IFeatureFlagService => ({ + isPostHogEnabled: (_flag, _guildId) => Effect.succeed(enabled), + getPostHogValue: () => Effect.die("not implemented"), + isTierEnabled: () => Effect.succeed(false), + requireTierFeature: () => Effect.void, + }); + + test("succeeds when isPostHogEnabled returns true", async () => { + const flags = makeMockFlags(true); + const exit = await Effect.runPromiseExit( + guardFeature(flags, "analytics", "guild-1"), + ); + expect(Exit.isSuccess(exit)).toBe(true); + }); + + test('fails with FeatureDisabledError (reason "not_in_rollout") when returns false', async () => { + const flags = makeMockFlags(false); + const exit = await Effect.runPromiseExit( + guardFeature(flags, "analytics", "guild-1"), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = exit.cause.toString(); + expect(error).toContain("FeatureDisabledError"); + } + }); + + test("error carries correct feature and guildId", async () => { + const flags = makeMockFlags(false); + const result = await Effect.runPromise( + guardFeature(flags, "escalate", "guild-42").pipe( + Effect.catchTag("FeatureDisabledError", (e) => Effect.succeed(e)), + ), + ); + + expect(result).toBeInstanceOf(FeatureDisabledError); + if (result instanceof FeatureDisabledError) { + expect(result.feature).toBe("escalate"); + expect(result.guildId).toBe("guild-42"); + expect(result.reason).toBe("not_in_rollout"); + } + }); +});