From 153be05ba24db35437985d97f1b055fc462b3e09 Mon Sep 17 00:00:00 2001 From: Emanuele Pavanello Date: Mon, 14 Jul 2025 22:16:31 +0200 Subject: [PATCH 1/3] refactor: streamline post creation and destination handling --- .../integrations/integrations-list.tsx | 166 ++++++++++ src/functions/posts.ts | 76 +---- src/lib/server/ai-service.ts | 310 ++---------------- src/lib/server/ai/context-service.ts | 37 +++ src/lib/server/ai/prompt-builder.ts | 77 +++++ src/lib/server/ai/types.ts | 35 ++ src/lib/server/ai/usage-service.ts | 128 ++++++++ src/lib/server/posts/destination-service.ts | 68 ++++ .../server/social-platforms/base-platform.ts | 18 +- .../social-platforms/reddit-platform.ts | 271 +++------------ .../reddit/reddit-api-client.ts | 154 +++++++++ .../social-platforms/reddit/reddit-utils.ts | 79 +++++ src/lib/server/social-platforms/x-platform.ts | 55 ++-- src/lib/server/social-platforms/x/x-utils.ts | 17 + src/routes/_protected/app/index.tsx | 124 +------ 15 files changed, 893 insertions(+), 722 deletions(-) create mode 100644 src/components/integrations/integrations-list.tsx create mode 100644 src/lib/server/ai/context-service.ts create mode 100644 src/lib/server/ai/prompt-builder.ts create mode 100644 src/lib/server/ai/types.ts create mode 100644 src/lib/server/ai/usage-service.ts create mode 100644 src/lib/server/posts/destination-service.ts create mode 100644 src/lib/server/social-platforms/reddit/reddit-api-client.ts create mode 100644 src/lib/server/social-platforms/reddit/reddit-utils.ts create mode 100644 src/lib/server/social-platforms/x/x-utils.ts diff --git a/src/components/integrations/integrations-list.tsx b/src/components/integrations/integrations-list.tsx new file mode 100644 index 0000000..59427e3 --- /dev/null +++ b/src/components/integrations/integrations-list.tsx @@ -0,0 +1,166 @@ +import { platformIcons } from "@/components/platform-icons" +import { Button } from "@/components/ui/button" +import type { Platform } from "@/database/schema/integrations" +import { deleteIntegration, deleteUserAppCredentials, type getIntegrations, getUserPlatformStatus } from "@/functions/integrations" +import type { getAllPlatformInfo } from "@/functions/platforms" +import { useMutation, useQuery } from "@tanstack/react-query" +import { PlusCircle, Settings, Trash2 } from "lucide-react" +import { toast } from "sonner" + +interface IntegrationsListProps { + integrations: Awaited> + platformsInfo: Awaited> + platformSetups: Record> + authorizingPlatform: Platform | null + onPlatformConnect: (platform: Platform) => void + onSetupPlatform: (platform: Platform) => void + onIntegrationRemoved: () => void +} + +// Hook for platform setup (moved from main component) +function usePlatformSetup(platform: Platform) { + const { data: platformStatus, refetch: refetchStatus } = useQuery({ + queryKey: ["platform-status", platform], + queryFn: () => getUserPlatformStatus({ data: platform }), + enabled: true + }) + + const { mutate: removeCredentials, isPending: isRemoving } = useMutation({ + mutationFn: deleteUserAppCredentials, + onSuccess: () => { + toast.success("User credentials removed successfully.") + refetchStatus() + } + }) + + return { + requiresSetup: platformStatus?.requiresSetup || false, + hasCredentials: platformStatus?.hasCredentials || false, + canConnect: platformStatus?.canConnect ?? true, + redirectUrl: platformStatus?.redirectUrl, + credentialSource: platformStatus?.source, + refetchStatus, + removeCredentials, + isRemovingCredentials: isRemoving + } +} + +export function IntegrationsList({ + integrations, + platformsInfo, + platformSetups, + authorizingPlatform, + onPlatformConnect, + onSetupPlatform, + onIntegrationRemoved +}: IntegrationsListProps) { + const { mutate: remove, isPending: isRemovePending } = useMutation({ + mutationFn: deleteIntegration, + onSuccess: () => { + toast.success("Integration removed successfully.") + onIntegrationRemoved() + } + }) + + const connectedPlatforms = integrations.map((i) => i.platform) + + return ( +
+
+

Connected

+ {integrations.length > 0 ? ( +
+ {integrations.map((integration) => { + const platformInfo = platformsInfo.find((p) => p.name === integration.platform) + return ( +
+
+ {platformIcons[integration.platform]} +
+

{platformInfo?.displayName || integration.platform}

+

{integration.platformAccountName}

+
+
+ +
+ ) + })} +
+ ) : ( +

No integrations connected yet.

+ )} +
+ +
+

Available

+
+ {platformsInfo.map((platformInfo) => { + const setupInfo = platformSetups[platformInfo.name] + const isCurrentlyAuthenticating = authorizingPlatform === platformInfo.name + + return ( +
+
+
+ {platformIcons[platformInfo.name]} +

{platformInfo.displayName}

+
+ + {platformInfo.isImplemented ? ( + setupInfo && platformInfo.requiresSetup && !setupInfo.hasCredentials ? ( + + ) : ( + + ) + ) : ( +

Coming soon!

+ )} +
+ + {/* Setup information */} + {platformInfo.requiresSetup && setupInfo && ( +
+ {setupInfo.hasCredentials ? ( +
+ + {setupInfo.credentialSource === "system" ? "System configured ✓" : "App configured ✓"} + + {setupInfo.credentialSource === "user" && ( + + )} +
+ ) : ( +
+ Requires app configuration +
+ )} +
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/functions/posts.ts b/src/functions/posts.ts index f9f230a..3be3d9f 100644 --- a/src/functions/posts.ts +++ b/src/functions/posts.ts @@ -1,10 +1,11 @@ import { db } from "@/database/db" -import { type InsertPost, type InsertPostDestination, postDestinations, posts } from "@/database/schema" +import { type InsertPost, posts } from "@/database/schema" import { PLATFORM_VALUES, type Platform, integrations } from "@/database/schema/integrations" import { getSessionOrThrow } from "@/lib/auth" import { getEffectiveCredentials } from "@/lib/server/integrations" import { postToSocialMedia } from "@/lib/server/post-service" -import type { PostDestination } from "@/lib/server/social-platforms/base-platform" +import { PostDestinationService } from "@/lib/server/posts/destination-service" +import { DestinationSchema } from "@/lib/server/social-platforms/base-platform" import { PlatformFactory } from "@/lib/server/social-platforms/platform-factory" import { createServerFn } from "@tanstack/react-start" import { and, desc, eq, sql } from "drizzle-orm" @@ -14,15 +15,7 @@ const createPostSchema = z.object({ integrationId: z.string(), content: z.string(), scheduledAt: z.date().optional(), - destination: z - .object({ - type: z.string(), - id: z.string(), - name: z.string(), - metadata: z.record(z.any()).optional(), - description: z.string().optional() - }) - .optional(), + destination: DestinationSchema.optional(), additionalFields: z.record(z.string()).optional() }) @@ -30,6 +23,7 @@ export const createPost = createServerFn({ method: "POST" }) .validator(createPostSchema) .handler(async ({ data }) => { const session = await getSessionOrThrow() + const destinationService = new PostDestinationService() const integration = await db.query.integrations.findFirst({ where: (integrations, { eq, and }) => and(eq(integrations.id, data.integrationId), eq(integrations.userId, session.user.id)) @@ -69,7 +63,7 @@ export const createPost = createServerFn({ method: "POST" }) // Save destination to recent destinations if provided if (data.destination) { - await saveRecentDestination(session.user.id, integration.platform, data.destination) + await destinationService.saveRecentDestination(session.user.id, integration.platform, data.destination) } // If it's a draft, try to post immediately @@ -105,69 +99,15 @@ export const createPost = createServerFn({ method: "POST" }) return post }) -async function saveRecentDestination(userId: string, platform: Platform, destination: PostDestination): Promise { - const existingDestination = await db.query.postDestinations.findFirst({ - where: (postDestinations, { eq, and }) => - and(eq(postDestinations.userId, userId), eq(postDestinations.platform, platform), eq(postDestinations.destinationId, destination.id)) - }) - - if (existingDestination) { - // Update existing destination - await db - .update(postDestinations) - .set({ - useCount: sql`${postDestinations.useCount} + 1`, - lastUsedAt: new Date(), - destinationName: destination.name, - destinationMetadata: destination.metadata ? JSON.stringify(destination.metadata) : undefined, - updatedAt: new Date() - }) - .where(eq(postDestinations.id, existingDestination.id)) - } else { - // Create new destination - const newDestination: InsertPostDestination = { - userId, - platform, - destinationType: destination.type, - destinationId: destination.id, - destinationName: destination.name, - destinationMetadata: destination.metadata ? JSON.stringify(destination.metadata) : undefined, - lastUsedAt: new Date(), - useCount: 1 - } - await db.insert(postDestinations).values(newDestination) - } -} - export const getRecentDestinations = createServerFn({ method: "POST" }) .validator((payload: { platform: Platform; limit?: number }) => z.object({ platform: z.enum(PLATFORM_VALUES), limit: z.number().optional() }).parse(payload) ) .handler(async ({ data }) => { const session = await getSessionOrThrow() + const destinationService = new PostDestinationService() - const recentDestinations = await db.query.postDestinations.findMany({ - where: (postDestinations, { eq, and }) => - and(eq(postDestinations.userId, session.user.id), eq(postDestinations.platform, data.platform)), - orderBy: (postDestinations, { desc }) => desc(postDestinations.lastUsedAt), - limit: data.limit || 10 - }) - - return recentDestinations.map((dest) => { - let metadata: Record | undefined - try { - metadata = dest.destinationMetadata ? JSON.parse(dest.destinationMetadata) : undefined - } catch { - metadata = undefined - } - - return { - type: dest.destinationType, - id: dest.destinationId, - name: dest.destinationName, - description: metadata?.description as string | undefined - } - }) + return await destinationService.getRecentDestinations(session.user.id, data.platform, data.limit || 10) }) export const createDestinationFromInput = createServerFn({ method: "POST" }) diff --git a/src/lib/server/ai-service.ts b/src/lib/server/ai-service.ts index c1920b9..7d280b3 100644 --- a/src/lib/server/ai-service.ts +++ b/src/lib/server/ai-service.ts @@ -1,39 +1,16 @@ -import { db } from "@/database/db" -import { type Post, type UserContext, aiUsage, posts, subscriptions, userContext } from "@/database/schema" -import { getAiConfig, isAiEnabled, isSaasDeployment } from "@/lib/env" +import { getAiConfig } from "@/lib/env" import { openai } from "@ai-sdk/openai" import { generateText } from "ai" -import { and, desc, eq, gte, lte } from "drizzle-orm" -import { z } from "zod" - -export type AiGenerationOptions = { - prompt: string - userId: string - integrationId: string - maxTokens?: number - temperature?: number - // New tuning parameters - styleOverride?: "casual" | "formal" | "humorous" | "professional" | "conversational" - toneOverride?: "friendly" | "professional" | "authoritative" | "inspirational" | "educational" - lengthOverride?: "short" | "medium" | "long" - useEmojisOverride?: boolean - useHashtagsOverride?: boolean - customInstructionsOverride?: string - // For iterations - previousContent?: string - iterationInstruction?: string -} - -export type AiGenerationResult = { - success: boolean - content?: string - error?: string - tokensUsed?: number - model?: string -} +import { AiContextService } from "./ai/context-service" +import { AiPromptBuilder } from "./ai/prompt-builder" +import type { AiAccessCheck, AiGenerationOptions, AiGenerationResult } from "./ai/types" +import { AiUsageService } from "./ai/usage-service" export class AiService { private config = getAiConfig() + private contextService = new AiContextService() + private usageService = new AiUsageService() + private promptBuilder = new AiPromptBuilder() async generateContent(options: AiGenerationOptions): Promise { try { @@ -44,20 +21,20 @@ export class AiService { // Check usage limits if SaaS deployment if (this.config.isSaas) { - const canUse = await this.checkUsageLimits(options.userId) + const canUse = await this.usageService.checkUsageLimits(options.userId) if (!canUse.allowed) { return { success: false, error: canUse.reason } } } // Get user context for personalization - const context = await this.getUserContext(options.userId) + const context = await this.contextService.getUserContext(options.userId) // Get recent posts for style consistency - const recentPosts = await this.getRecentPosts(options.userId, options.integrationId) + const recentPosts = await this.contextService.getRecentPosts(options.userId, options.integrationId) // Build the system prompt - const systemPrompt = this.buildSystemPrompt(context, recentPosts, options) + const systemPrompt = this.promptBuilder.buildSystemPrompt(context, recentPosts, options) // Generate content using OpenAI const result = await generateText({ @@ -70,7 +47,7 @@ export class AiService { // Track usage if SaaS if (this.config.isSaas) { - await this.trackUsage(options.userId, result.usage?.totalTokens || 0) + await this.usageService.trackUsage(options.userId, result.usage?.totalTokens || 0) } return { @@ -88,261 +65,30 @@ export class AiService { } } - private async checkUsageLimits(userId: string): Promise<{ allowed: boolean; reason?: string }> { + async canUserAccessAi(userId: string): Promise { try { - // Get user's subscription - const subscription = await db - .select() - .from(subscriptions) - .where(eq(subscriptions.userId, userId)) - .orderBy(desc(subscriptions.createdAt)) - .limit(1) - .then((rows) => rows[0]) - - if (!subscription) { - return { allowed: false, reason: "Subscribe to Pro plan to access AI features" } - } - - if (subscription.status !== "active") { - return { - allowed: false, - reason: "Your subscription has expired. Renew to continue using AI" - } - } - - // Check if current period is still valid - const now = new Date() - if (subscription.currentPeriodEnd && subscription.currentPeriodEnd < now) { - return { allowed: false, reason: "Your subscription period has expired. Please renew" } + // Check if AI is enabled + if (!this.config.isEnabled) { + return { canAccess: false, reason: "AI service is not enabled" } } - // Get current usage for this period - const currentUsage = await db - .select() - .from(aiUsage) - .where( - and( - eq(aiUsage.userId, userId), - eq(aiUsage.subscriptionId, subscription.id), - gte(aiUsage.periodStart, subscription.currentPeriodStart || new Date()), - lte(aiUsage.periodEnd, subscription.currentPeriodEnd || new Date()) - ) - ) - .limit(1) - .then((rows) => rows[0]) - - if (currentUsage) { - // Check generation limit - if (currentUsage.generationsUsed >= (subscription.aiGenerationsLimit ?? 0)) { - return { - allowed: false, - reason: "Monthly AI generation limit reached. Upgrade your plan" - } - } - - // Check context window limit - if (currentUsage.contextWindowUsed >= (subscription.aiContextWindowLimit ?? 0)) { - return { allowed: false, reason: "Monthly AI usage limit reached. Upgrade your plan" } - } + // For self-hosted, always allow access + if (!this.config.isSaas) { + return { canAccess: true } } - return { allowed: true } - } catch (error) { - console.error("Error checking usage limits:", error) - return { allowed: false, reason: "Unable to verify subscription status" } - } - } - - private async getUserContext(userId: string) { - try { - const context = await db - .select() - .from(userContext) - .where(eq(userContext.userId, userId)) - .limit(1) - .then((rows) => rows[0]) - - return context - } catch (error) { - console.error("Error getting user context:", error) - return null - } - } - - private async getRecentPosts(userId: string, integrationId: string, limit = 10) { - try { - const recentPosts = await db - .select() - .from(posts) - .where(and(eq(posts.userId, userId), eq(posts.integrationId, integrationId), eq(posts.status, "posted"))) - .orderBy(desc(posts.postedAt)) - .limit(limit) - - return recentPosts - } catch (error) { - console.error("Error getting recent posts:", error) - return [] - } - } - - private buildSystemPrompt(context: UserContext | null, recentPosts: Post[], overrides: Partial = {}): string { - let systemPrompt = "You are a social media content creator assistant. Your task is to generate engaging, authentic social media posts." - - // Add user context if available, with overrides taking priority - if (context || Object.keys(overrides).length > 0) { - systemPrompt += "\n\nUser Profile:" - if (context?.bio) systemPrompt += `\n- Bio: ${context.bio}` - if (context?.profession) systemPrompt += `\n- Profession: ${context.profession}` - if (context?.industry) systemPrompt += `\n- Industry: ${context.industry ?? ""}` - if (context?.targetAudience) systemPrompt += `\n- Target Audience: ${context.targetAudience}` - - // Use overrides or fall back to context - const writingStyle = overrides.styleOverride || context?.writingStyle - const toneOfVoice = overrides.toneOverride || context?.toneOfVoice - const customInstructions = overrides.customInstructionsOverride || context?.customInstructions - const postLength = overrides.lengthOverride || context?.defaultPostLength - - if (writingStyle) systemPrompt += `\n- Writing Style: ${writingStyle}` - if (toneOfVoice) systemPrompt += `\n- Tone of Voice: ${toneOfVoice}` - if (postLength) { - const lengthMap = { - short: "1-2 sentences", - medium: "3-5 sentences", - long: "6+ sentences" - } - systemPrompt += `\n- Preferred Length: ${lengthMap[postLength as keyof typeof lengthMap] || postLength}` - } - if (customInstructions) systemPrompt += `\n- Custom Instructions: ${customInstructions}` - } - - // Add recent posts for style consistency - if (recentPosts.length > 0) { - systemPrompt += "\n\nRecent Posts (for style reference):" - recentPosts.forEach((post, index) => { - systemPrompt += `\n${index + 1}. ${post.content}` - }) - systemPrompt += "\n\nPlease maintain consistency with the writing style and tone shown in these recent posts." - } - - // Handle iteration requests - if (overrides.previousContent && overrides.iterationInstruction) { - systemPrompt += "\n\nIteration Request:" - systemPrompt += `\nPrevious content: "${overrides.previousContent}"` - systemPrompt += `\nImprovement instruction: ${overrides.iterationInstruction}` - systemPrompt += "\nPlease generate an improved version based on the instruction while maintaining the core message and style." - } - - // Add generation guidelines - systemPrompt += "\n\nGeneration Guidelines:" - systemPrompt += "\n- Keep the content authentic and engaging" - systemPrompt += "\n- Match the user's established voice and style" - systemPrompt += "\n- Make it appropriate for the target audience" - systemPrompt += "\n- Focus on value and engagement" - - // Use override values or fall back to context - const finalUseEmojis = overrides.useEmojisOverride !== undefined ? overrides.useEmojisOverride : context?.useEmojis - const finalUseHashtags = overrides.useHashtagsOverride !== undefined ? overrides.useHashtagsOverride : context?.useHashtags - - if (finalUseEmojis) { - systemPrompt += "\n- Use emojis appropriately" - } else { - systemPrompt += "\n- Do not use emojis" - } - - if (finalUseHashtags) { - systemPrompt += "\n- Include relevant hashtags when appropriate" - } else { - systemPrompt += "\n- Do not include hashtags" - } - - return systemPrompt - } - - private async trackUsage(userId: string, tokensUsed: number) { - try { - // Get user's subscription - const subscription = await db - .select() - .from(subscriptions) - .where(eq(subscriptions.userId, userId)) - .orderBy(desc(subscriptions.createdAt)) - .limit(1) - .then((rows) => rows[0]) - - if (!subscription) return - - // Get or create usage record for current period - const now = new Date() - const periodStart = subscription.currentPeriodStart || now - const periodEnd = subscription.currentPeriodEnd || new Date(now.getFullYear(), now.getMonth() + 1, 0) - - const existingUsage = await db - .select() - .from(aiUsage) - .where( - and( - eq(aiUsage.userId, userId), - eq(aiUsage.subscriptionId, subscription.id), - gte(aiUsage.periodStart, periodStart), - lte(aiUsage.periodEnd, periodEnd) - ) - ) - .limit(1) - .then((rows) => rows[0]) - - if (existingUsage) { - // Update existing usage - await db - .update(aiUsage) - .set({ - generationsUsed: existingUsage.generationsUsed + 1, - contextWindowUsed: existingUsage.contextWindowUsed + tokensUsed, - updatedAt: now - }) - .where(eq(aiUsage.id, existingUsage.id)) - } else { - // Create new usage record - await db.insert(aiUsage).values({ - userId, - subscriptionId: subscription.id, - generationsUsed: 1, - contextWindowUsed: tokensUsed, - periodStart, - periodEnd - }) + // For SaaS, check usage limits + const canUse = await this.usageService.checkUsageLimits(userId) + return { + canAccess: canUse.allowed, + reason: canUse.reason } } catch (error) { - console.error("Error tracking usage:", error) - } - } - - async canUserAccessAi(userId: string): Promise<{ canAccess: boolean; reason?: string }> { - // Check if AI is enabled globally - if (!isAiEnabled()) { - if (isSaasDeployment()) { - return { canAccess: false, reason: "AI features require a Pro subscription" } - } - - return { canAccess: false, reason: "OpenAI API key not configured" } + console.error("Error checking AI access:", error) + return { canAccess: false, reason: "Unable to verify AI access" } } - - // For self-hosted, if AI is enabled, user has access - if (!isSaasDeployment()) { - return { canAccess: true } - } - - // For SaaS deployment, check subscription and limits - const limits = await this.checkUsageLimits(userId) - return { canAccess: limits.allowed, reason: limits.reason } } } +// Export singleton instance export const aiService = new AiService() - -// Schema for API validation -export const generateContentSchema = z.object({ - prompt: z.string().min(1, "Prompt is required"), - integrationId: z.string().min(1, "Integration ID is required"), - maxTokens: z.number().optional(), - temperature: z.number().min(0).max(1).optional() -}) diff --git a/src/lib/server/ai/context-service.ts b/src/lib/server/ai/context-service.ts new file mode 100644 index 0000000..e1c4619 --- /dev/null +++ b/src/lib/server/ai/context-service.ts @@ -0,0 +1,37 @@ +import { db } from "@/database/db" +import { type Post, type UserContext, posts, userContext } from "@/database/schema" +import { and, desc, eq } from "drizzle-orm" + +export class AiContextService { + async getUserContext(userId: string): Promise { + try { + const context = await db + .select() + .from(userContext) + .where(eq(userContext.userId, userId)) + .limit(1) + .then((rows) => rows[0]) + + return context + } catch (error) { + console.error("Error getting user context:", error) + return null + } + } + + async getRecentPosts(userId: string, integrationId: string, limit = 10): Promise { + try { + const recentPosts = await db + .select() + .from(posts) + .where(and(eq(posts.userId, userId), eq(posts.integrationId, integrationId), eq(posts.status, "posted"))) + .orderBy(desc(posts.postedAt)) + .limit(limit) + + return recentPosts + } catch (error) { + console.error("Error getting recent posts:", error) + return [] + } + } +} diff --git a/src/lib/server/ai/prompt-builder.ts b/src/lib/server/ai/prompt-builder.ts new file mode 100644 index 0000000..fcc5f73 --- /dev/null +++ b/src/lib/server/ai/prompt-builder.ts @@ -0,0 +1,77 @@ +import type { Post, UserContext } from "@/database/schema" +import type { AiGenerationOptions } from "./types" + +export class AiPromptBuilder { + buildSystemPrompt(context: UserContext | null, recentPosts: Post[], overrides: Partial = {}): string { + let systemPrompt = "You are a social media content creator assistant. Your task is to generate engaging, authentic social media posts." + + // Add user context if available, with overrides taking priority + if (context || Object.keys(overrides).length > 0) { + systemPrompt += "\n\nUser Profile:" + if (context?.bio) systemPrompt += `\n- Bio: ${context.bio}` + if (context?.profession) systemPrompt += `\n- Profession: ${context.profession}` + if (context?.industry) systemPrompt += `\n- Industry: ${context.industry ?? ""}` + if (context?.targetAudience) systemPrompt += `\n- Target Audience: ${context.targetAudience}` + + // Use overrides or fall back to context + const writingStyle = overrides.styleOverride || context?.writingStyle + const toneOfVoice = overrides.toneOverride || context?.toneOfVoice + const customInstructions = overrides.customInstructionsOverride || context?.customInstructions + const postLength = overrides.lengthOverride || context?.defaultPostLength + + if (writingStyle) systemPrompt += `\n- Writing Style: ${writingStyle}` + if (toneOfVoice) systemPrompt += `\n- Tone of Voice: ${toneOfVoice}` + if (postLength) { + const lengthMap = { + short: "1-2 sentences", + medium: "3-5 sentences", + long: "6+ sentences" + } + systemPrompt += `\n- Preferred Length: ${lengthMap[postLength as keyof typeof lengthMap] || postLength}` + } + if (customInstructions) systemPrompt += `\n- Custom Instructions: ${customInstructions}` + } + + // Add recent posts for style consistency + if (recentPosts.length > 0) { + systemPrompt += "\n\nRecent Posts (for style reference):" + recentPosts.forEach((post, index) => { + systemPrompt += `\n${index + 1}. ${post.content}` + }) + systemPrompt += "\n\nPlease maintain consistency with the writing style and tone shown in these recent posts." + } + + // Handle iteration requests + if (overrides.previousContent && overrides.iterationInstruction) { + systemPrompt += "\n\nIteration Request:" + systemPrompt += `\nPrevious content: "${overrides.previousContent}"` + systemPrompt += `\nImprovement instruction: ${overrides.iterationInstruction}` + systemPrompt += "\nPlease generate an improved version based on the instruction while maintaining the core message and style." + } + + // Add generation guidelines + systemPrompt += "\n\nGeneration Guidelines:" + systemPrompt += "\n- Keep the content authentic and engaging" + systemPrompt += "\n- Match the user's established voice and style" + systemPrompt += "\n- Make it appropriate for the target audience" + systemPrompt += "\n- Focus on value and engagement" + + // Use override values or fall back to context + const finalUseEmojis = overrides.useEmojisOverride !== undefined ? overrides.useEmojisOverride : context?.useEmojis + const finalUseHashtags = overrides.useHashtagsOverride !== undefined ? overrides.useHashtagsOverride : context?.useHashtags + + if (finalUseEmojis) { + systemPrompt += "\n- Use emojis appropriately" + } else { + systemPrompt += "\n- Do not use emojis" + } + + if (finalUseHashtags) { + systemPrompt += "\n- Include relevant hashtags when appropriate" + } else { + systemPrompt += "\n- Do not include hashtags" + } + + return systemPrompt + } +} diff --git a/src/lib/server/ai/types.ts b/src/lib/server/ai/types.ts new file mode 100644 index 0000000..0662a8b --- /dev/null +++ b/src/lib/server/ai/types.ts @@ -0,0 +1,35 @@ +export type AiGenerationOptions = { + prompt: string + userId: string + integrationId: string + maxTokens?: number + temperature?: number + // New tuning parameters + styleOverride?: "casual" | "formal" | "humorous" | "professional" | "conversational" + toneOverride?: "friendly" | "professional" | "authoritative" | "inspirational" | "educational" + lengthOverride?: "short" | "medium" | "long" + useEmojisOverride?: boolean + useHashtagsOverride?: boolean + customInstructionsOverride?: string + // For iterations + previousContent?: string + iterationInstruction?: string +} + +export type AiGenerationResult = { + success: boolean + content?: string + error?: string + tokensUsed?: number + model?: string +} + +export type UsageLimitCheck = { + allowed: boolean + reason?: string +} + +export type AiAccessCheck = { + canAccess: boolean + reason?: string +} diff --git a/src/lib/server/ai/usage-service.ts b/src/lib/server/ai/usage-service.ts new file mode 100644 index 0000000..2b64f5e --- /dev/null +++ b/src/lib/server/ai/usage-service.ts @@ -0,0 +1,128 @@ +import { db } from "@/database/db" +import { aiUsage, subscriptions } from "@/database/schema" +import { and, desc, eq, gte, lte } from "drizzle-orm" +import type { UsageLimitCheck } from "./types" + +export class AiUsageService { + async checkUsageLimits(userId: string): Promise { + try { + // Get user's subscription + const subscription = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .orderBy(desc(subscriptions.createdAt)) + .limit(1) + .then((rows) => rows[0]) + + if (!subscription) { + return { allowed: false, reason: "Subscribe to Pro plan to access AI features" } + } + + if (subscription.status !== "active") { + return { + allowed: false, + reason: "Your subscription has expired. Renew to continue using AI" + } + } + + // Check if current period is still valid + const now = new Date() + if (subscription.currentPeriodEnd && subscription.currentPeriodEnd < now) { + return { allowed: false, reason: "Your subscription period has expired. Please renew" } + } + + // Get current usage for this period + const currentUsage = await db + .select() + .from(aiUsage) + .where( + and( + eq(aiUsage.userId, userId), + eq(aiUsage.subscriptionId, subscription.id), + gte(aiUsage.periodStart, subscription.currentPeriodStart || new Date()), + lte(aiUsage.periodEnd, subscription.currentPeriodEnd || new Date()) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (currentUsage) { + // Check generation limit + if (currentUsage.generationsUsed >= (subscription.aiGenerationsLimit ?? 0)) { + return { + allowed: false, + reason: "Monthly AI generation limit reached. Upgrade your plan" + } + } + + // Check context window limit + if (currentUsage.contextWindowUsed >= (subscription.aiContextWindowLimit ?? 0)) { + return { allowed: false, reason: "Monthly AI usage limit reached. Upgrade your plan" } + } + } + + return { allowed: true } + } catch (error) { + console.error("Error checking usage limits:", error) + return { allowed: false, reason: "Unable to verify subscription status" } + } + } + + async trackUsage(userId: string, tokensUsed: number): Promise { + try { + // Get current subscription + const subscription = await db + .select() + .from(subscriptions) + .where(eq(subscriptions.userId, userId)) + .orderBy(desc(subscriptions.createdAt)) + .limit(1) + .then((rows) => rows[0]) + + if (!subscription) { + console.warn("No subscription found for user:", userId) + return + } + + // Get or create usage record for current period + const currentUsage = await db + .select() + .from(aiUsage) + .where( + and( + eq(aiUsage.userId, userId), + eq(aiUsage.subscriptionId, subscription.id), + gte(aiUsage.periodStart, subscription.currentPeriodStart || new Date()), + lte(aiUsage.periodEnd, subscription.currentPeriodEnd || new Date()) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (currentUsage) { + // Update existing usage record + await db + .update(aiUsage) + .set({ + generationsUsed: currentUsage.generationsUsed + 1, + contextWindowUsed: currentUsage.contextWindowUsed + tokensUsed, + updatedAt: new Date() + }) + .where(eq(aiUsage.id, currentUsage.id)) + } else { + // Create new usage record + await db.insert(aiUsage).values({ + userId, + subscriptionId: subscription.id, + periodStart: subscription.currentPeriodStart || new Date(), + periodEnd: subscription.currentPeriodEnd || new Date(), + generationsUsed: 1, + contextWindowUsed: tokensUsed + }) + } + } catch (error) { + console.error("Error tracking AI usage:", error) + } + } +} diff --git a/src/lib/server/posts/destination-service.ts b/src/lib/server/posts/destination-service.ts new file mode 100644 index 0000000..e2dea0b --- /dev/null +++ b/src/lib/server/posts/destination-service.ts @@ -0,0 +1,68 @@ +import { db } from "@/database/db" +import { postDestinations } from "@/database/schema" +import type { Platform } from "@/database/schema/integrations" +import { eq, sql } from "drizzle-orm" +import type { PostDestination } from "../social-platforms/base-platform" + +export class PostDestinationService { + async saveRecentDestination(userId: string, platform: Platform, destination: PostDestination): Promise { + const existingDestination = await db.query.postDestinations.findFirst({ + where: (postDestinations, { eq, and }) => + and( + eq(postDestinations.userId, userId), + eq(postDestinations.platform, platform), + eq(postDestinations.destinationId, destination.id) + ) + }) + + if (existingDestination) { + // Update existing destination + await db + .update(postDestinations) + .set({ + useCount: sql`${postDestinations.useCount} + 1`, + lastUsedAt: new Date(), + destinationName: destination.name, + destinationMetadata: destination.metadata ? JSON.stringify(destination.metadata) : undefined, + updatedAt: new Date() + }) + .where(eq(postDestinations.id, existingDestination.id)) + } else { + // Create new destination + await db.insert(postDestinations).values({ + userId, + platform, + destinationType: destination.type, + destinationId: destination.id, + destinationName: destination.name, + destinationMetadata: destination.metadata ? JSON.stringify(destination.metadata) : undefined, + lastUsedAt: new Date(), + useCount: 1 + }) + } + } + + async getRecentDestinations(userId: string, platform: Platform, limit = 10) { + const recentDestinations = await db.query.postDestinations.findMany({ + where: (postDestinations, { eq, and }) => and(eq(postDestinations.userId, userId), eq(postDestinations.platform, platform)), + orderBy: (postDestinations, { desc }) => desc(postDestinations.lastUsedAt), + limit + }) + + return recentDestinations.map((dest) => { + let metadata: Record | undefined + try { + metadata = dest.destinationMetadata ? JSON.parse(dest.destinationMetadata) : undefined + } catch { + metadata = undefined + } + + return { + type: dest.destinationType, + id: dest.destinationId, + name: dest.destinationName, + description: metadata?.description as string | undefined + } + }) + } +} diff --git a/src/lib/server/social-platforms/base-platform.ts b/src/lib/server/social-platforms/base-platform.ts index 223f55f..dbb81fa 100644 --- a/src/lib/server/social-platforms/base-platform.ts +++ b/src/lib/server/social-platforms/base-platform.ts @@ -1,5 +1,6 @@ import type { InsertPost } from "@/database/schema" import type { Platform } from "@/database/schema" +import { z } from "zod" export interface PostResult { success: boolean @@ -7,14 +8,15 @@ export interface PostResult { error?: string } -export interface PostDestination { - type: string // "public", "community", "subreddit", etc. - id: string // URL, subreddit name, etc. - name: string // Display name - // biome-ignore lint/suspicious/noExplicitAny: - metadata?: Record // Additional platform-specific data - description?: string // Optional description or help text -} +export const DestinationSchema = z.object({ + type: z.string(), // "public", "community", "subreddit", etc. + id: z.string(), // URL, subreddit name, etc. + name: z.string(), // Display name + metadata: z.record(z.any()).optional(), // Additional platform-specific data + description: z.string().optional() // Optional description or help text +}) + +export type PostDestination = z.infer export interface PostDestinationSearchResult { destinations: PostDestination[] diff --git a/src/lib/server/social-platforms/reddit-platform.ts b/src/lib/server/social-platforms/reddit-platform.ts index 828f690..20eb453 100644 --- a/src/lib/server/social-platforms/reddit-platform.ts +++ b/src/lib/server/social-platforms/reddit-platform.ts @@ -12,68 +12,11 @@ import { type PostResult, type RequiredField } from "./base-platform" - -interface RedditTokenResponse { - access_token: string - refresh_token?: string - token_type: string - expires_in: number - scope: string -} - -interface RedditUser { - id: string - name: string - icon_img?: string - total_karma: number - link_karma: number - comment_karma: number -} - -interface RedditSubreddit { - display_name: string - display_name_prefixed: string - title: string - public_description?: string - description?: string - subscribers: number - over18: boolean - subreddit_type: "public" | "private" | "restricted" - icon_img?: string -} - -interface RedditSubmission { - id: string - title: string - selftext?: string - url?: string - permalink: string - created_utc: number - is_self: boolean - subreddit_name_prefixed: string - score: number - num_comments: number -} - -interface RedditSubmissionResponse { - json: { - errors: string[][] - data?: { - things: Array<{ - data: { - id: string - url: string - name: string - } - }> - } - } -} +import { RedditApiClient } from "./reddit/reddit-api-client" +import { buildRedditAuthUrl, normalizeSubredditName } from "./reddit/reddit-utils" export class RedditPlatform extends BaseSocialPlatform { - private readonly baseUrl = "https://oauth.reddit.com" - private readonly authUrl = "https://www.reddit.com/api/v1/authorize" - private readonly tokenUrl = "https://www.reddit.com/api/v1/access_token" + private readonly apiClient = new RedditApiClient() constructor() { super("reddit") @@ -116,13 +59,14 @@ export class RedditPlatform extends BaseSocialPlatform { }, { key: "postType", - label: "Post Type", + label: "Post type", type: "select", required: true, helpText: "Choose whether to create a text post or link post", + placeholder: "Select post type", options: [ - { value: "text", label: "Text Post" }, - { value: "link", label: "Link Post" } + { value: "text", label: "Text post" }, + { value: "link", label: "Link post" } ] } ] @@ -155,26 +99,6 @@ export class RedditPlatform extends BaseSocialPlatform { return true } - private async makeRedditRequest(endpoint: string, accessToken: string, options: RequestInit = {}): Promise { - const url = `${this.baseUrl}${endpoint}` - - const response = await fetch(url, { - ...options, - headers: { - Authorization: `Bearer ${accessToken}`, - "User-Agent": "BetterPlan/1.0", - ...options.headers - } - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Reddit API error: ${response.status} - ${error}`) - } - - return response - } - async startAuthorization(userId: string): Promise<{ url: string }> { const session = await getSessionOrThrow() const credentials = await getEffectiveCredentials("reddit", session.user.id) @@ -187,13 +111,7 @@ export class RedditPlatform extends BaseSocialPlatform { const redirectUri = `${envConfig.APP_URL}/api/integrations/reddit/callback` const scope = ["submit", "read", "identity", "mysubreddits"] - const authUrl = new URL(this.authUrl) - authUrl.searchParams.set("client_id", credentials.clientId) - authUrl.searchParams.set("response_type", "code") - authUrl.searchParams.set("state", state) - authUrl.searchParams.set("redirect_uri", redirectUri) - authUrl.searchParams.set("duration", "permanent") - authUrl.searchParams.set("scope", scope.join(" ")) + const authUrl = buildRedditAuthUrl(credentials.clientId, state, redirectUri, scope) // Store state in cookie for CSRF protection setCookie("reddit_oauth_state", state, { @@ -208,9 +126,8 @@ export class RedditPlatform extends BaseSocialPlatform { async validateCredentials(accessToken: string, effectiveCredentials: { clientId: string; clientSecret: string }): Promise { try { - const response = await this.makeRedditRequest("/api/v1/me", accessToken) - const user: RedditUser = await response.json() - return !!user.name + await this.apiClient.getUserInfo(accessToken) + return true } catch (error) { console.error("Reddit credentials validation failed:", error) return false @@ -235,47 +152,22 @@ export class RedditPlatform extends BaseSocialPlatform { throw new Error("Post title is required for Reddit posts") } - const subredditName = postData.destination.id.replace(/^r\//, "") - - // Prepare form data - const formData = new FormData() - formData.append("sr", subredditName) - formData.append("title", title) - formData.append("kind", postType === "link" ? "link" : "self") - formData.append("api_type", "json") + const subredditName = normalizeSubredditName(postData.destination.id) + let url: string | undefined if (postType === "link") { // For link posts, extract URL from content or use entire content as URL const urlMatch = postData.content.match(/(https?:\/\/[^\s]+)/i) - const url = urlMatch ? urlMatch[1] : postData.content.trim() + url = urlMatch ? urlMatch[1] : postData.content.trim() if (!url.startsWith("http://") && !url.startsWith("https://")) { throw new Error("Invalid URL for link post") } - - formData.append("url", url) - } else { - // For text posts, use the content as text body - formData.append("text", postData.content) } - const response = await this.makeRedditRequest("/api/submit", accessToken, { - method: "POST", - body: formData - }) - - const result: RedditSubmissionResponse = await response.json() + const result = await this.apiClient.submitPost(subredditName, title, postData.content, accessToken, postType === "link", url) - if (result.json.errors.length > 0) { - throw new Error(`Reddit API error: ${result.json.errors[0][1]}`) - } - - if (!result.json.data?.things?.[0]?.data?.id) { - throw new Error("Failed to create Reddit post: No post ID returned") - } - - const postId = result.json.data.things[0].data.id - const postUrl = `https://www.reddit.com/r/${subredditName}/comments/${postId}/` + const postUrl = `https://www.reddit.com/r/${subredditName}/comments/${result.id}/` return { success: true, @@ -297,31 +189,22 @@ export class RedditPlatform extends BaseSocialPlatform { cursor?: string ): Promise { try { - const response = await this.makeRedditRequest(`/subreddits/search?q=${encodeURIComponent(query)}&limit=25&type=sr`, accessToken) - - const data = await response.json() - const destinations: PostDestination[] = [] - - if (data.data?.children) { - for (const child of data.data.children) { - const subreddit: RedditSubreddit = child.data - destinations.push({ - type: "subreddit", - id: subreddit.display_name, - name: subreddit.display_name_prefixed, - description: `${subreddit.public_description || subreddit.title} • ${subreddit.subscribers?.toLocaleString() || "Unknown"} members`, - metadata: { - subscribers: subreddit.subscribers || 0, - over18: subreddit.over18 || false, - subredditType: subreddit.subreddit_type || "public" - } - }) + const result = await this.apiClient.searchSubreddits(query, accessToken, cursor) + const destinations: PostDestination[] = result.subreddits.map((subreddit) => ({ + type: "subreddit", + id: subreddit.display_name, + name: subreddit.display_name_prefixed, + description: `${subreddit.public_description || subreddit.title} • ${subreddit.subscribers?.toLocaleString() || "Unknown"} members`, + metadata: { + subscribers: subreddit.subscribers || 0, + over18: subreddit.over18 || false, + subredditType: subreddit.subreddit_type || "public" } - } + })) return { destinations, - hasMore: false + hasMore: result.hasMore } } catch (error) { console.error("Failed to search Reddit destinations:", error) @@ -338,8 +221,8 @@ export class RedditPlatform extends BaseSocialPlatform { effectiveCredentials: { clientId: string; clientSecret: string } ): Promise { try { - const subredditName = destination.id.replace(/^r\//, "") - const response = await this.makeRedditRequest(`/r/${subredditName}/about`, accessToken) + const subredditName = normalizeSubredditName(destination.id) + const response = await this.apiClient.makeRequest(`/r/${subredditName}/about`, accessToken) const data = await response.json() return !!data.data?.display_name } catch (error) { @@ -359,9 +242,9 @@ export class RedditPlatform extends BaseSocialPlatform { // If we have access token, try to get real subreddit info if (accessToken) { try { - const response = await this.makeRedditRequest(`/r/${cleanInput}/about`, accessToken) + const response = await this.apiClient.makeRequest(`/r/${cleanInput}/about`, accessToken) const data = await response.json() - const subreddit: RedditSubreddit = data.data + const subreddit = data.data return { type: "subreddit", @@ -393,34 +276,27 @@ export class RedditPlatform extends BaseSocialPlatform { effectiveCredentials: { clientId: string; clientSecret: string } ): Promise[]> { try { - const response = await this.makeRedditRequest("/user/me/submitted?limit=25&sort=new", accessToken) - const data = await response.json() - - const postsToUpsert: Omit[] = [] - - if (data.data?.children) { - for (const child of data.data.children) { - const submission: RedditSubmission = child.data - - // Create content from title and text - let content = submission.title - if (submission.is_self && submission.selftext) { - content += `\n${submission.selftext}` - } else if (!submission.is_self && submission.url) { - content += `\n${submission.url}` - } + const submissions = await this.apiClient.getRecentPosts(accessToken, 25) + + const postsToUpsert: Omit[] = submissions.map((submission) => { + // Create content from title and text + let content = submission.title + if (submission.is_self && submission.selftext) { + content += `\n${submission.selftext}` + } else if (!submission.is_self && submission.url) { + content += `\n${submission.url}` + } - postsToUpsert.push({ - id: submission.id, - content, - status: "posted", - source: "imported", - postedAt: new Date(submission.created_utc * 1000), - createdAt: new Date(submission.created_utc * 1000), - postUrl: `https://www.reddit.com${submission.permalink}` - }) + return { + id: submission.id, + content, + status: "posted", + source: "imported", + postedAt: new Date(submission.created_utc * 1000), + createdAt: new Date(submission.created_utc * 1000), + postUrl: `https://www.reddit.com${submission.permalink}` } - } + }) return postsToUpsert } catch (error) { @@ -435,54 +311,11 @@ export class RedditPlatform extends BaseSocialPlatform { redirectUri: string, credentials: { clientId: string; clientSecret: string } ): Promise<{ accessToken: string; refreshToken?: string }> { - try { - const auth = Buffer.from(`${credentials.clientId}:${credentials.clientSecret}`).toString("base64") - - const formData = new URLSearchParams() - formData.append("grant_type", "authorization_code") - formData.append("code", code) - formData.append("redirect_uri", redirectUri) - - const response = await fetch(this.tokenUrl, { - method: "POST", - headers: { - Authorization: `Basic ${auth}`, - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "BetterPlan/1.0" - }, - body: formData - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Token exchange failed: ${response.status} - ${error}`) - } - - const tokens: RedditTokenResponse = await response.json() - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token - } - } catch (error) { - console.error("Failed to exchange code for tokens:", error) - throw new Error("Failed to exchange authorization code for tokens") - } + return this.apiClient.exchangeCodeForTokens(code, redirectUri, credentials) } // Get user info async getUserInfo(accessToken: string): Promise<{ id: string; name: string }> { - try { - const response = await this.makeRedditRequest("/api/v1/me", accessToken) - const user: RedditUser = await response.json() - - return { - id: user.id, - name: user.name - } - } catch (error) { - console.error("Failed to get user info:", error) - throw new Error("Failed to get user information") - } + return this.apiClient.getUserInfo(accessToken) } } diff --git a/src/lib/server/social-platforms/reddit/reddit-api-client.ts b/src/lib/server/social-platforms/reddit/reddit-api-client.ts new file mode 100644 index 0000000..935d433 --- /dev/null +++ b/src/lib/server/social-platforms/reddit/reddit-api-client.ts @@ -0,0 +1,154 @@ +import type { RedditSubmission, RedditSubmissionResponse, RedditSubreddit, RedditTokenResponse, RedditUser } from "./reddit-utils" + +export class RedditApiClient { + private readonly baseUrl = "https://oauth.reddit.com" + private readonly tokenUrl = "https://www.reddit.com/api/v1/access_token" + + async makeRequest(endpoint: string, accessToken: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${endpoint}` + + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${accessToken}`, + "User-Agent": "BetterPlan/1.0", + ...options.headers + } + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Reddit API error: ${response.status} - ${error}`) + } + + return response + } + + async exchangeCodeForTokens( + code: string, + redirectUri: string, + credentials: { clientId: string; clientSecret: string } + ): Promise<{ accessToken: string; refreshToken?: string }> { + const basicAuth = btoa(`${credentials.clientId}:${credentials.clientSecret}`) + + const response = await fetch(this.tokenUrl, { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "BetterPlan/1.0" + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri + }) + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Token exchange failed: ${response.status} - ${error}`) + } + + const tokenData: RedditTokenResponse = await response.json() + return { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token + } + } + + async getUserInfo(accessToken: string): Promise<{ id: string; name: string }> { + const response = await this.makeRequest("/api/v1/me", accessToken) + const userData: RedditUser = await response.json() + + return { + id: userData.id, + name: userData.name + } + } + + async searchSubreddits( + query: string, + accessToken: string, + cursor?: string + ): Promise<{ + subreddits: RedditSubreddit[] + hasMore: boolean + nextCursor?: string + }> { + const params = new URLSearchParams({ + q: query, + type: "sr", + limit: "25" + }) + + if (cursor) { + params.set("after", cursor) + } + + const response = await this.makeRequest(`/subreddits/search?${params}`, accessToken) + const data = await response.json() + + const subreddits: RedditSubreddit[] = data.data.children.map((child: { data: RedditSubreddit }) => child.data) + const hasMore = data.data.after !== null + const nextCursor = data.data.after || undefined + + return { + subreddits, + hasMore, + nextCursor + } + } + + async submitPost( + subreddit: string, + title: string, + content: string, + accessToken: string, + isLinkPost = false, + url?: string + ): Promise<{ id: string; permalink: string }> { + const formData = new URLSearchParams({ + sr: subreddit, + title, + api_type: "json" + }) + + if (isLinkPost && url) { + formData.set("url", url) + } else { + formData.set("text", content) + } + + const response = await this.makeRequest("/api/submit", accessToken, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: formData + }) + + const result: RedditSubmissionResponse = await response.json() + + if (result.json.errors && result.json.errors.length > 0) { + throw new Error(`Reddit submission failed: ${result.json.errors.join(", ")}`) + } + + if (!result.json.data) { + throw new Error("Reddit submission failed: No data returned") + } + + const submission = result.json.data.things[0] + return { + id: submission.data.id, + permalink: submission.data.url + } + } + + async getRecentPosts(accessToken: string, limit = 10): Promise { + const response = await this.makeRequest(`/user/me/submitted?limit=${limit}`, accessToken) + const data = await response.json() + + return data.data.children.map((child: { data: RedditSubmission }) => child.data) + } +} diff --git a/src/lib/server/social-platforms/reddit/reddit-utils.ts b/src/lib/server/social-platforms/reddit/reddit-utils.ts new file mode 100644 index 0000000..4d114e7 --- /dev/null +++ b/src/lib/server/social-platforms/reddit/reddit-utils.ts @@ -0,0 +1,79 @@ +export interface RedditTokenResponse { + access_token: string + refresh_token?: string + token_type: string + expires_in: number + scope: string +} + +export interface RedditUser { + id: string + name: string + icon_img?: string + total_karma: number + link_karma: number + comment_karma: number +} + +export interface RedditSubreddit { + display_name: string + display_name_prefixed: string + title: string + public_description?: string + description?: string + subscribers: number + over18: boolean + subreddit_type: "public" | "private" | "restricted" + icon_img?: string +} + +export interface RedditSubmission { + id: string + title: string + selftext?: string + url?: string + permalink: string + created_utc: number + is_self: boolean + subreddit_name_prefixed: string + score: number + num_comments: number +} + +export interface RedditSubmissionResponse { + json: { + errors: string[][] + data?: { + things: Array<{ + data: { + id: string + url: string + name: string + } + }> + } + } +} + +export function normalizeSubredditName(input: string): string { + // Remove r/ prefix if present and normalize + let subredditName = input.trim() + if (subredditName.startsWith("r/")) { + subredditName = subredditName.substring(2) + } + if (subredditName.startsWith("/r/")) { + subredditName = subredditName.substring(3) + } + return subredditName.toLowerCase() +} + +export function buildRedditAuthUrl(clientId: string, state: string, redirectUri: string, scope: string[]): string { + const authUrl = new URL("https://www.reddit.com/api/v1/authorize") + authUrl.searchParams.set("client_id", clientId) + authUrl.searchParams.set("response_type", "code") + authUrl.searchParams.set("state", state) + authUrl.searchParams.set("redirect_uri", redirectUri) + authUrl.searchParams.set("duration", "permanent") + authUrl.searchParams.set("scope", scope.join(" ")) + return authUrl.toString() +} diff --git a/src/lib/server/social-platforms/x-platform.ts b/src/lib/server/social-platforms/x-platform.ts index d59a21a..907d53f 100644 --- a/src/lib/server/social-platforms/x-platform.ts +++ b/src/lib/server/social-platforms/x-platform.ts @@ -5,6 +5,7 @@ import { setCookie } from "@tanstack/react-start/server" import { type SendTweetV2Params, TwitterApi } from "twitter-api-v2" import { getEffectiveCredentials } from "../integrations" import { BaseSocialPlatform, type PostData, type PostDestination, type PostResult } from "./base-platform" +import { extractCommunityId, parseAccessToken, validateCommunityUrl } from "./x/x-utils" export class XPlatform extends BaseSocialPlatform { constructor() { @@ -83,17 +84,9 @@ export class XPlatform extends BaseSocialPlatform { } } - private parseAccessToken(accessToken: string): { token: string; secret: string } { - const [token, secret] = accessToken.split(":") - if (!token || !secret) { - throw new Error("Invalid X access token format. Expected format: 'token:secret'") - } - return { token, secret } - } - async validateCredentials(accessToken: string, effectiveCredentials: { clientId: string; clientSecret: string }): Promise { try { - const { token, secret } = this.parseAccessToken(accessToken) + const { token, secret } = parseAccessToken(accessToken) const client = new TwitterApi({ appKey: effectiveCredentials.clientId, @@ -117,7 +110,7 @@ export class XPlatform extends BaseSocialPlatform { effectiveCredentials: { clientId: string; clientSecret: string } ): Promise { try { - const { token, secret } = this.parseAccessToken(accessToken) + const { token, secret } = parseAccessToken(accessToken) const twitterClient = new TwitterApi({ appKey: effectiveCredentials.clientId, @@ -131,7 +124,7 @@ export class XPlatform extends BaseSocialPlatform { // Add community_id if posting to a community if (postData.destination && postData.destination.type === "community") { - const communityId = this.extractCommunityId(postData.destination.id) + const communityId = extractCommunityId(postData.destination.id) if (!communityId) { throw new Error("Invalid community URL format") } @@ -156,12 +149,6 @@ export class XPlatform extends BaseSocialPlatform { } } - private extractCommunityId(communityUrl: string): string | null { - // Extract community ID from URL like https://x.com/i/communities/1493446837214187523 - const match = communityUrl.match(/\/communities\/(\d+)/) - return match ? match[1] : null - } - async validateDestination( destination: PostDestination, accessToken: string, @@ -173,7 +160,7 @@ export class XPlatform extends BaseSocialPlatform { if (destination.type === "community") { // Validate community URL format - const communityId = this.extractCommunityId(destination.id) + const communityId = extractCommunityId(destination.id) if (!communityId) { return false } @@ -192,7 +179,7 @@ export class XPlatform extends BaseSocialPlatform { effectiveCredentials: { clientId: string; clientSecret: string } ): Promise { try { - const { token, secret } = this.parseAccessToken(accessToken) + const { token, secret } = parseAccessToken(accessToken) const client = new TwitterApi({ appKey: effectiveCredentials.clientId, @@ -228,18 +215,19 @@ export class XPlatform extends BaseSocialPlatform { // Create a destination from user input async createDestinationFromInput(input: string, accessToken: string | null, userId: string): Promise { - if (input.includes("communities")) { - // Extract community ID from URL - const communityMatch = input.match(/\/communities\/(\d+)/) - const communityId = communityMatch?.[1] + // Check if input is a community URL + if (validateCommunityUrl(input)) { + const communityId = extractCommunityId(input) + if (!communityId) { + throw new Error("Invalid community URL format") + } - if (communityId && accessToken) { + // If we have access token, try to get community info + if (accessToken) { try { - // Get credentials and try to lookup community name const credentials = await getEffectiveCredentials("x", userId) if (credentials) { const communityName = await this.lookupCommunityName(communityId, accessToken, credentials) - if (communityName) { return { type: "community", @@ -254,8 +242,8 @@ export class XPlatform extends BaseSocialPlatform { } } - // Fallback to generated name - const shortId = communityId ? communityId.slice(-6) : "Unknown" + // Fallback to basic community destination + const shortId = communityId.slice(-6) return { type: "community", id: input, @@ -264,11 +252,12 @@ export class XPlatform extends BaseSocialPlatform { } } + // Default to public timeline return { - type: "custom", - id: input, - name: input, - description: "Custom destination" + type: "public", + id: "public", + name: "Public Timeline", + description: "Post to your public timeline" } } @@ -277,7 +266,7 @@ export class XPlatform extends BaseSocialPlatform { effectiveCredentials: { clientId: string; clientSecret: string } ): Promise[]> { try { - const { token, secret } = this.parseAccessToken(accessToken) + const { token, secret } = parseAccessToken(accessToken) const client = new TwitterApi({ appKey: effectiveCredentials.clientId, diff --git a/src/lib/server/social-platforms/x/x-utils.ts b/src/lib/server/social-platforms/x/x-utils.ts new file mode 100644 index 0000000..be207eb --- /dev/null +++ b/src/lib/server/social-platforms/x/x-utils.ts @@ -0,0 +1,17 @@ +export function parseAccessToken(accessToken: string): { token: string; secret: string } { + const [token, secret] = accessToken.split(":") + if (!token || !secret) { + throw new Error("Invalid X access token format. Expected format: 'token:secret'") + } + return { token, secret } +} + +export function extractCommunityId(communityUrl: string): string | null { + // Extract community ID from URL like https://x.com/i/communities/1493446837214187523 + const match = communityUrl.match(/\/communities\/(\d+)/) + return match ? match[1] : null +} + +export function validateCommunityUrl(url: string): boolean { + return /^https:\/\/x\.com\/i\/communities\/\d+/.test(url) +} diff --git a/src/routes/_protected/app/index.tsx b/src/routes/_protected/app/index.tsx index 35ff3ff..4f971e2 100644 --- a/src/routes/_protected/app/index.tsx +++ b/src/routes/_protected/app/index.tsx @@ -1,12 +1,10 @@ -import { platformIcons } from "@/components/platform-icons" +import { IntegrationsList } from "@/components/integrations/integrations-list" import { PlatformSetup } from "@/components/platform-setup" -import { Button } from "@/components/ui/button" import type { Platform } from "@/database/schema/integrations" -import { deleteIntegration, deleteUserAppCredentials, getIntegrations, getUserPlatformStatus } from "@/functions/integrations" +import { deleteUserAppCredentials, getIntegrations, getUserPlatformStatus } from "@/functions/integrations" import { getAllPlatformInfo, startPlatformAuthorization } from "@/functions/platforms" import { useMutation, useQuery } from "@tanstack/react-query" import { createFileRoute, useRouter } from "@tanstack/react-router" -import { PlusCircle, Settings, Trash2 } from "lucide-react" import { useState } from "react" import { toast } from "sonner" @@ -69,14 +67,6 @@ function IntegrationsComponent() { } }) - const { mutate: remove, isPending: isRemovePending } = useMutation({ - mutationFn: deleteIntegration, - onSuccess: () => { - toast.success("Integration removed successfully.") - router.invalidate() - } - }) - // Handler generico per connettere qualsiasi piattaforma const handlePlatformConnect = (platform: Platform) => { const platformInfo = platformsInfo.find((p) => p.name === platform) @@ -104,9 +94,7 @@ function IntegrationsComponent() { setSetupPlatform(null) } - const connectedPlatforms = integrations.map((i) => i.platform) - - // Renderizza la schermata di setup se necessario (generico per tutte le piattaforme) + // Render setup screen if necessary if (setupPlatform) { const platformInfo = platformsInfo.find((p) => p.name === setupPlatform) const setupInfo = platformSetups[setupPlatform] @@ -131,103 +119,15 @@ function IntegrationsComponent() {

Connect and manage your accounts to post across different platforms.

-
-
-

Connected

- {integrations.length > 0 ? ( -
- {integrations.map((integration) => { - const platformInfo = platformsInfo.find((p) => p.name === integration.platform) - return ( -
-
- {platformIcons[integration.platform]} -
-

{platformInfo?.displayName || integration.platform}

-

{integration.platformAccountName}

-
-
- -
- ) - })} -
- ) : ( -

No integrations connected yet.

- )} -
- -
-

Available

-
- {platformsInfo.map((platformInfo) => { - const setupInfo = platformSetups[platformInfo.name] - const isCurrentlyAuthenticating = authorizingPlatform === platformInfo.name - - return ( -
-
-
- {platformIcons[platformInfo.name]} -

{platformInfo.displayName}

-
- - {platformInfo.isImplemented ? ( - setupInfo && platformInfo.requiresSetup && !setupInfo.hasCredentials ? ( - - ) : ( - - ) - ) : ( -

Coming soon!

- )} -
- - {/* Informazioni di setup */} - {platformInfo.requiresSetup && setupInfo && ( -
- {setupInfo.hasCredentials ? ( -
- - {setupInfo.credentialSource === "system" ? "System configured ✓" : "App configured ✓"} - - {setupInfo.credentialSource === "user" && ( - - )} -
- ) : ( -
- Requires app configuration -
- )} -
- )} -
- ) - })} -
-
-
+ router.invalidate()} + /> ) } From dc43286f38da632aa9825fe3e9131a772c85edc4 Mon Sep 17 00:00:00 2001 From: Emanuele Pavanello Date: Tue, 15 Jul 2025 00:07:14 +0200 Subject: [PATCH 2/3] refactor: enhance create post form layout and improve accessibility --- src/components/create-post-form.tsx | 509 +++++++++++++++------------- src/components/header.tsx | 3 +- src/routes/_protected/app/write.tsx | 183 ++++++---- 3 files changed, 394 insertions(+), 301 deletions(-) diff --git a/src/components/create-post-form.tsx b/src/components/create-post-form.tsx index d73312b..70af648 100644 --- a/src/components/create-post-form.tsx +++ b/src/components/create-post-form.tsx @@ -11,7 +11,7 @@ import { checkAiAccess, generateAiContent } from "@/functions/ai" import { createDestinationFromInput, getRecentDestinations } from "@/functions/posts" import type { PlatformInfo, PostDestination } from "@/lib/server/social-platforms/base-platform" import { useMutation, useQuery } from "@tanstack/react-query" -import { CalendarClock, ChevronDown, ChevronUp, HelpCircle, Loader2, Lock, MapPin, Rocket, RotateCcw, Sparkles, X } from "lucide-react" +import { CalendarClock, ChevronDown, ChevronUp, HelpCircle, History, Loader2, Lock, MapPin, Rocket, Sparkles, X, Zap } from "lucide-react" import { useState } from "react" import { toast } from "sonner" @@ -326,9 +326,9 @@ export function CreatePostForm({ } return ( -
+
{platformInfo.requiredFields.map((field) => ( -
+
-
+
-
+
-
+
@@ -566,25 +564,25 @@ export function CreatePostForm({ ) return ( -
-

Create a new post

-
- {/* AI Generation Section - Always visible */} -
- {!showAiInput || !isAiAvailable ? ( - renderAiButton() - ) : ( -
-
- +
+ {/* AI Generation Section */} +
+ {!showAiInput || !isAiAvailable ? ( + renderAiButton() + ) : ( +
+
+ +
+ setAiPrompt(e.target.value)} + disabled={isGenerating} + className="flex-1" + />
- setAiPrompt(e.target.value)} - disabled={isGenerating} - />
- - {/* Advanced AI Settings */} - {renderAdvancedSettings()} - - {/* Quick Adjustment Buttons - only show if content exists */} - { -
- -
- - - - - - -
-
- } - - {/* Generation History */} - {generationHistory.length > 0 && ( -
-
- - -
- - {showGenerationHistory && ( -
- {generationHistory.map((item) => ( -
-
- {item.timestamp.toLocaleTimeString()} - -
-

{item.content}

-

Prompt: {item.prompt}

-
- ))} -
- )} -
- )}
- )} -
- {/* Main Content Textarea */} -