diff --git a/app/AppRuntime.ts b/app/AppRuntime.ts index 3eaa5df6..824499ca 100644 --- a/app/AppRuntime.ts +++ b/app/AppRuntime.ts @@ -1,11 +1,36 @@ -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 { + 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"; -// App layer: database + PostHog + feature flags -// FeatureFlagServiceLive depends on both DatabaseService and PostHogService -const AppLayer = Layer.mergeAll(DatabaseLayer); +// 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, + Layer.provide( + FeatureFlagServiceLive, + Layer.mergeAll(DatabaseLayer, PostHogServiceLive), + ), + InfraLayer, +); // ManagedRuntime keeps the AppLayer scope alive for the process lifetime. // Unlike Effect.runSync which closes the scope (and thus the SQLite connection) @@ -19,20 +44,35 @@ 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 --- -// 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 => @@ -43,3 +83,38 @@ 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); + +/** + * 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/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/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/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/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.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"); + } + }); +}); diff --git a/app/effects/featureFlags.ts b/app/effects/featureFlags.ts new file mode 100644 index 00000000..adb1355c --- /dev/null +++ b/app/effects/featureFlags.ts @@ -0,0 +1,214 @@ +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; + +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: BooleanFlag, + 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, { + groups: { guild: 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, { + groups: { guild: 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 }, + }), + ), + }; + }), +); + +/** + * 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", + }), + ), + ); diff --git a/app/effects/posthog.ts b/app/effects/posthog.ts new file mode 100644 index 00000000..c85dc418 --- /dev/null +++ b/app/effects/posthog.ts @@ -0,0 +1,74 @@ +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, + 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"); + } + }), + ), +); + +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: { + id: guild.id, + 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/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); 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 ( + + ); + } + return ( + + ); +} + +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/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/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, 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/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..730a1968 --- /dev/null +++ b/app/routes/__auth/admin.$guildId.tsx @@ -0,0 +1,239 @@ +import { Routes, type APIGuild } from "discord-api-types/v10"; +import { Link } from "react-router"; + +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 { SubscriptionService } from "#~/models/subscriptions.server"; + +import type { Route } from "./+types/admin.$guildId"; + +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; memberCount: number } = { + name: guildId, + icon: null, + memberCount: 0, + }; + try { + 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, + error: e, + }); + } + + const featureFlags = await fetchFeatureFlags(guildId); + + let paymentMethods: PaymentMethods = []; + let invoices: Invoices = []; + let stripeCustomerUrl: string | null = null; + let stripeSubscriptionUrl: string | null = null; + + if (subscription?.stripe_customer_id) { + ({ paymentMethods, invoices } = await fetchStripeDetails( + 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, + }); + + const groupProperties = { + name: guildInfo.name, + member_count: guildInfo.memberCount, + subscription_tier: subscription?.product_tier ?? "free", + subscription_status: subscription?.status ?? "none", + }; + + return { + guildId, + guildInfo, + subscription, + paymentMethods, + invoices, + stripeCustomerUrl, + stripeSubscriptionUrl, + featureFlags, + groupProperties, + }; +} + +export default function AdminGuildDetail({ + loaderData: { + guildId, + guildInfo, + subscription, + paymentMethods, + invoices, + stripeCustomerUrl, + stripeSubscriptionUrl, + featureFlags, + groupProperties, + }, +}: Route.ComponentProps) { + const tier = subscription?.product_tier ?? "free"; + const status = subscription?.status ?? null; + + return ( + +
+
+ + ← All Guilds + +
+ +
+ +
+

+ {guildInfo.name} +

+

ID: {guildId}

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

Subscription

+
+
+
Tier
+
+ +
+
+
+
Status
+
+ +
+
+
+
Amount
+
{tierAmount(tier)}
+
+
+
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 +

+ +
+ + {/* Invoices */} +
+

Invoices

+ +
+ + {/* Feature Flags */} + +
+
+ ); +} diff --git a/app/routes/__auth/admin.tsx b/app/routes/__auth/admin.tsx new file mode 100644 index 00000000..ed7ab5c3 --- /dev/null +++ b/app/routes/__auth/admin.tsx @@ -0,0 +1,321 @@ +import { Routes, type APIGuild } from "discord-api-types/v10"; +import { Link, useFetcher, useSearchParams } from "react-router"; + +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 { SubscriptionService } from "#~/models/subscriptions.server"; + +import type { Route } from "./+types/admin"; +import type { loader as guildDetailLoader } from "./admin.$guildId"; + +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 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", + }; + + 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, + }), + ]); + + return [ + guildId, + { ...stripeData, featureFlags, groupProperties }, + ] as const; + }), + ); + const expandedDetails = Object.fromEntries(expandedEntries); + + log("info", "admin", "Admin page accessed", { + guildCount: guilds.length, + expandedCount: expandedGuildIds.length, + }); + + return { guilds, expandedGuildIds, expandedDetails }; +} + +function ExpandedGuildDetails({ + guildId, + subscription, + serverData, + fetcherData, +}: { + guildId: string; + subscription: { + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + } | null; + serverData?: { + paymentMethods: PaymentMethodItem[]; + invoices: InvoiceItem[]; + featureFlags?: Record | null; + groupProperties?: 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 groupProperties = + serverData?.groupProperties ?? fetcherData?.groupProperties ?? 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 ( +
+ {(stripeCustomerUrl ?? stripeSubscriptionUrl) && ( +
+ {stripeCustomerUrl && ( + + Stripe Customer + + )} + {stripeSubscriptionUrl && ( + + Stripe Subscription + + )} +
+ )} + +
+

+ Payment Methods +

+ +
+ +
+

+ Recent Invoices +

+ +
+ + + +
+ + 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: PaymentMethodItem[]; + invoices: InvoiceItem[]; + featureFlags?: Record | null; + groupProperties?: Record | null; + }; +}) { + 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. +

+ )} +
+
+
+ ); +} diff --git a/app/server.ts b/app/server.ts index c0051a9f..fbadd331 100644 --- a/app/server.ts +++ b/app/server.ts @@ -34,7 +34,8 @@ 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 { initializeGroups } from "./effects/posthog"; +import { botStats } from "./helpers/metrics"; export const app = express(); @@ -119,31 +120,25 @@ 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); // 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}`); + yield* checkpointWal(); + yield* logEffect("info", "Server", "Database WAL checkpointed"); + process.exit(0); + }), + ) + .then(() => runtime.dispose().then(() => console.log("ok"))); yield* logEffect("debug", "Server", "setting signal handlers"); process.on("SIGTERM", () => void handleShutdown("SIGTERM"));