diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 692bc5cf..26eef106 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -27,6 +27,17 @@ model User { scopes String[] @default([]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + apiKeys ApiKey[] + refreshTokens RefreshToken[] + goals Goal[] + sentTips Tip[] @relation("SentTips") + receivedTips Tip[] @relation("ReceivedTips") + leaderboardSnapshots LeaderboardSnapshot[] + streak Streak? + notifications Notification[] + + tipperSubscriptions Subscription[] @relation("SubscriptionTipper") + creatorSubscriptions Subscription[] @relation("SubscriptionCreator") /// Soft-delete marker: non-null means the record is logically deleted. deletedAt DateTime? apiKeys ApiKey[] @@ -189,6 +200,13 @@ model XAccount { updatedAt DateTime @updatedAt } +/// Status of a tip transaction. +enum TipStatus { + CONFIRMED + PENDING + REFUNDED +} + /// Leaderboard period tracked by snapshots. enum Period { WEEKLY diff --git a/backend/src/app.ts b/backend/src/app.ts index 06923ac5..53b81c1f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,14 @@ import { env } from './config/env.js'; import { errorHandler, notFoundHandler, +} from "./common/middleware/errorHandler.js"; +import { logger } from "./common/utils/logger.js"; +import { openApiDocument } from "./docs/openapi.js"; +import { authRouter } from "./modules/auth/auth.routes.js"; +import { profilesRouter } from "./modules/profiles/profiles.routes.js"; +import { creditRouter } from "./modules/credit/credit.routes.js"; +import { leaderboardRouter } from "./modules/leaderboard/leaderboard.routes.js"; +import { xRouter } from "./modules/x/x.routes.js"; } from './common/middleware/errorHandler.js'; import { logger } from './common/utils/logger.js'; import { openApiDocument } from './docs/openapi.js'; @@ -59,6 +67,10 @@ export function createApp(): Express { app.use(`${env.API_BASE_PATH}/profiles`, profilesRouter); app.use(`${env.API_BASE_PATH}/credit`, creditRouter); app.use(`${env.API_BASE_PATH}/leaderboard`, leaderboardRouter); + app.use(`${env.API_BASE_PATH}/x`, xRouter); + // app.use(`${env.API_BASE_PATH}/tips`, tipsRouter); + // ... (one issue per module) + // ───────────────────────────────────────────────────────────── app.use(`${env.API_BASE_PATH}/ipfs`, ipfsRouter); app.use(`${env.API_BASE_PATH}/tips`, tipsRouter); app.use(`${env.API_BASE_PATH}/withdrawals`, withdrawalsRouter); diff --git a/backend/src/common/errors/AppError.ts b/backend/src/common/errors/AppError.ts index c1fd0150..fb874b60 100644 --- a/backend/src/common/errors/AppError.ts +++ b/backend/src/common/errors/AppError.ts @@ -6,7 +6,7 @@ export class AppError extends Error { constructor( public readonly statusCode: number, message: string, - public readonly code: string = 'INTERNAL_ERROR', + public readonly code: string = "INTERNAL_ERROR", public readonly details?: unknown, ) { super(message); @@ -16,28 +16,33 @@ export class AppError extends Error { } export class BadRequestError extends AppError { - constructor(message = 'Bad request', details?: unknown) { - super(400, message, 'BAD_REQUEST', details); + constructor(message = "Bad request", details?: unknown) { + super(400, message, "BAD_REQUEST", details); } } export class UnauthorizedError extends AppError { - constructor(message = 'Unauthorized') { - super(401, message, 'UNAUTHORIZED'); + constructor(message = "Unauthorized") { + super(401, message, "UNAUTHORIZED"); } } export class ForbiddenError extends AppError { - constructor(message = 'Forbidden') { - super(403, message, 'FORBIDDEN'); + constructor(message = "Forbidden") { + super(403, message, "FORBIDDEN"); } } export class NotFoundError extends AppError { - constructor(message = 'Not found') { - super(404, message, 'NOT_FOUND'); + constructor(message = "Not found") { + super(404, message, "NOT_FOUND"); } } export class ConflictError extends AppError { - constructor(message = 'Conflict') { - super(409, message, 'CONFLICT'); + constructor(message = "Conflict") { + super(409, message, "CONFLICT"); + } +} +export class ServiceUnavailableError extends AppError { + constructor(message = "Service unavailable") { + super(503, message, "SERVICE_UNAVAILABLE"); } } diff --git a/backend/src/modules/x/README.md b/backend/src/modules/x/README.md new file mode 100644 index 00000000..8faa2fce --- /dev/null +++ b/backend/src/modules/x/README.md @@ -0,0 +1,229 @@ +# X (Twitter) Integration Module + +This module provides integration with the X (formerly Twitter) API to fetch and cache user metrics such as follower counts and engagement scores. + +## Features + +- Fetch X account metrics (followers, engagement) from X API v2 +- Cache metrics in PostgreSQL for performance and fallback +- Graceful degradation when X API is unavailable +- Configurable cache expiration +- Rate limit handling +- Comprehensive error handling + +## Setup + +### Required Environment Variables + +Add these to your `.env` file: + +```env +X_API_BEARER_TOKEN=your_twitter_bearer_token_here +X_API_BASE_URL=https://api.twitter.com/2 +``` + +### Getting X API Credentials + +1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard) +2. Create a new App or use an existing one +3. Navigate to "Keys and tokens" +4. Generate a "Bearer Token" +5. Copy the token to your `.env` file as `X_API_BEARER_TOKEN` + +### Required Scopes + +The X API Bearer Token needs the following scopes: + +- `users.read` - Read user profile information +- `tweet.read` - Read tweet metrics (for engagement calculation) + +## API Endpoints + +### GET `/api/v1/x/metrics/:handle` + +Fetches fresh X metrics with optional fallback to cached data. + +**Parameters:** + +- `handle` (path) - X handle without @ symbol (e.g., "elonmusk") +- `useFallback` (query, optional) - Whether to use cached data if API fails (default: true) +- `maxCacheAge` (query, optional) - Max age of cached data in milliseconds (default: 86400000 = 24h) + +**Response:** + +```json +{ + "handle": "elonmusk", + "followers": 150000000, + "engagement": 0.45, + "fetchedAt": "2024-01-15T10:30:00.000Z" +} +``` + +**Errors:** + +- `404 Not Found` - X user not found +- `429 Too Many Requests` - Rate limit exceeded +- `503 Service Unavailable` - X API is down (and no fallback available) + +### GET `/api/v1/x/cached/:handle` + +Retrieves cached metrics without calling the X API. + +**Parameters:** + +- `handle` (path) - X handle without @ symbol + +**Response:** + +```json +{ + "handle": "elonmusk", + "followers": 149500000, + "engagement": 0.44, + "fetchedAt": "2024-01-14T15:20:00.000Z" +} +``` + +**Errors:** + +- `404 Not Found` - No cached data available + +## Service Functions + +### `fetchXMetrics(handle, options)` + +Fetches X metrics with graceful degradation. + +```typescript +import { fetchXMetrics } from "./modules/x/x.service.js"; + +const metrics = await fetchXMetrics("elonmusk", { + useFallback: true, + maxCacheAge: 24 * 60 * 60 * 1000, // 24 hours +}); +``` + +**Options:** + +- `useFallback` (boolean) - Use cached data when API fails (default: true) +- `maxCacheAge` (number) - Max cache age in milliseconds (default: 24h) + +### `getCachedXMetrics(handle)` + +Gets cached metrics without calling the API. + +```typescript +import { getCachedXMetrics } from "./modules/x/x.service.js"; + +const cached = await getCachedXMetrics("elonmusk"); +if (cached) { + console.log(`Cached followers: ${cached.followers}`); +} +``` + +### `clearCachedXMetrics(handle)` + +Clears cached metrics for a handle. + +```typescript +import { clearCachedXMetrics } from "./modules/x/x.service.js"; + +await clearCachedXMetrics("elonmusk"); +``` + +## Engagement Calculation + +The engagement score is calculated as: + +``` +engagement = tweet_count / followers_count +``` + +This provides a simple ratio indicating how active the account is relative to their follower base. A higher score indicates more frequent posting. + +**Examples:** + +- 10,000 tweets / 10,000 followers = 1.0 (very active) +- 5,000 tweets / 10,000 followers = 0.5 (moderately active) +- 1,000 tweets / 100,000 followers = 0.01 (low activity relative to reach) + +## Graceful Degradation + +When the X API is unavailable (503 errors, network issues, rate limits), the module automatically falls back to cached data: + +1. **Fresh data attempt**: Try to fetch from X API +2. **Cache check**: If API fails, check database for cached data +3. **Age validation**: Ensure cached data is within `maxCacheAge` +4. **Fallback response**: Return cached data with original `fetchedAt` timestamp +5. **Error propagation**: If no valid cache, throw ServiceUnavailableError + +This ensures the application remains functional even when X API is down. + +## Error Handling + +The module uses AppError subclasses: + +- `NotFoundError` - X user doesn't exist +- `BadRequestError` - Invalid handle format or API error +- `ServiceUnavailableError` - X API down, rate limited, or token missing + +## Database Schema + +The module uses the `XAccount` model: + +```prisma +model XAccount { + id String @id @default(cuid()) + handle String @unique + followers Int @default(0) + engagement Float? + fetchedAt DateTime @default(now()) +} +``` + +## Testing + +All tests use mocked API responses (no real network calls): + +```bash +npm test -- x.test.ts +``` + +Test coverage includes: + +- Fresh metrics fetching and caching +- Engagement calculation +- Fallback to cached data +- Rate limit handling +- API error scenarios +- Cache expiration +- Stale data rejection + +## Rate Limiting + +X API v2 has rate limits: + +- Bearer Token: 300 requests per 15 minutes per app + +The module handles rate limits by: + +1. Throwing `ServiceUnavailableError` when rate limited +2. Falling back to cached data (if enabled) +3. Logging rate limit events + +## Best Practices + +1. **Enable fallback in production**: Set `useFallback: true` for better reliability +2. **Adjust cache age**: Use shorter `maxCacheAge` for real-time needs, longer for cost savings +3. **Monitor API health**: Track ServiceUnavailableError frequency +4. **Batch updates**: Pre-fetch metrics for multiple users during off-peak hours +5. **Graceful UI**: Show cache age to users when displaying fallback data + +## Future Enhancements + +- More sophisticated engagement metrics (likes, retweets, replies) +- Batch fetching for multiple handles +- Background refresh jobs for popular accounts +- Circuit breaker pattern for API failures +- Metrics history and trend analysis diff --git a/backend/src/modules/x/x.controller.ts b/backend/src/modules/x/x.controller.ts index 9085c9e8..ddd1e557 100644 --- a/backend/src/modules/x/x.controller.ts +++ b/backend/src/modules/x/x.controller.ts @@ -1,17 +1,84 @@ -import type { Request, Response, NextFunction } from 'express'; -import { handleParamSchema } from './x.schema.js'; -import * as xService from './x.service.js'; +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { BadRequestError } from "../../common/errors/AppError.js"; +import { fetchXMetrics, getCachedXMetrics } from "./x.service.js"; +import { fetchMetricsSchema } from "./x.schema.js"; -export async function getMetrics( +/** + * GET /x/metrics/:handle + * Fetches X account metrics with optional fallback to cached data. + */ +export async function getXMetricsController( req: Request, res: Response, next: NextFunction, ): Promise { try { - const { handle } = handleParamSchema.parse(req.params); - const result = await xService.getCachedXMetrics(handle); - res.status(200).json({ data: result }); - } catch (err) { - next(err); + const { handle } = req.params; + const useFallback = + req.query.useFallback === "true" || req.query.useFallback === undefined; + const maxCacheAge = req.query.maxCacheAge + ? parseInt(req.query.maxCacheAge as string, 10) + : undefined; + + const input = fetchMetricsSchema.parse({ + handle, + useFallback, + maxCacheAge, + }); + + const metrics = await fetchXMetrics(input.handle, { + useFallback: input.useFallback, + maxCacheAge: input.maxCacheAge, + }); + + res.json({ + handle: metrics.handle, + followers: metrics.followers, + engagement: metrics.engagement ?? null, + fetchedAt: metrics.fetchedAt.toISOString(), + }); + } catch (error) { + if (error instanceof z.ZodError) { + next(new BadRequestError("Invalid request parameters", error.issues)); + } else { + next(error); + } + } +} + +/** + * GET /x/cached/:handle + * Gets cached X metrics without calling the API. + */ +export async function getCachedXMetricsController( + req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const { handle } = req.params; + + if (!handle || handle.length === 0) { + throw new BadRequestError("X handle is required"); + } + + const metrics = await getCachedXMetrics(handle); + + if (!metrics) { + res.status(404).json({ + error: "No cached data available for this handle", + }); + return; + } + + res.json({ + handle: metrics.handle, + followers: metrics.followers, + engagement: metrics.engagement ?? null, + fetchedAt: metrics.fetchedAt.toISOString(), + }); + } catch (error) { + next(error); } } diff --git a/backend/src/modules/x/x.routes.ts b/backend/src/modules/x/x.routes.ts index 55241191..bbcdcc74 100644 --- a/backend/src/modules/x/x.routes.ts +++ b/backend/src/modules/x/x.routes.ts @@ -1,6 +1,17 @@ -import { Router } from 'express'; -import * as xController from './x.controller.js'; +import { Router } from "express"; +import { + getXMetricsController, + getCachedXMetricsController, +} from "./x.controller.js"; +/** + * X integration module router. + * Mounted at /api/v1/x in app.ts + */ export const xRouter = Router(); -xRouter.get('/:handle/metrics', xController.getMetrics); +/** + * Public routes for fetching X account metrics + */ +xRouter.get("/metrics/:handle", getXMetricsController); +xRouter.get("/cached/:handle", getCachedXMetricsController); diff --git a/backend/src/modules/x/x.schema.ts b/backend/src/modules/x/x.schema.ts index 4c340c51..38a2a0c7 100644 --- a/backend/src/modules/x/x.schema.ts +++ b/backend/src/modules/x/x.schema.ts @@ -1,7 +1,35 @@ -import { z } from 'zod'; +import { z } from "zod"; -export const handleParamSchema = z.object({ - handle: z.string().min(1).max(50), +/** + * Zod validation schemas for X integration endpoints. + */ + +export const xHandleSchema = z.object({ + handle: z + .string() + .min(1) + .max(15) + .regex( + /^[a-zA-Z0-9_]+$/, + "X handle must contain only letters, numbers, and underscores", + ), +}); + +export const fetchMetricsSchema = z.object({ + handle: z + .string() + .min(1) + .max(15) + .regex( + /^[a-zA-Z0-9_]+$/, + "X handle must contain only letters, numbers, and underscores", + ), + useFallback: z.boolean().optional().default(true), + maxCacheAge: z + .number() + .optional() + .default(24 * 60 * 60 * 1000), // 24 hours }); -export type HandleParam = z.infer; +export type XHandleInput = z.infer; +export type FetchMetricsInput = z.infer; diff --git a/backend/src/modules/x/x.service.ts b/backend/src/modules/x/x.service.ts index ce431436..db71fced 100644 --- a/backend/src/modules/x/x.service.ts +++ b/backend/src/modules/x/x.service.ts @@ -1,195 +1,215 @@ -import { prisma } from '../../db/prisma.js'; -import { redis } from '../../db/redis.js'; -import { NotFoundError, BadGatewayError } from '../../common/errors/AppError.js'; -import { logger } from '../../common/utils/logger.js'; -import { xApiClient } from './x.client.js'; -import type { XMetricsResponse } from './x.types.js'; +import { prisma } from "../../db/prisma.js"; +import { env } from "../../config/env.js"; +import { logger } from "../../common/utils/logger.js"; +import { + BadRequestError, + NotFoundError, + ServiceUnavailableError, +} from "../../common/errors/AppError.js"; +import type { + XAccountMetrics, + XApiUserResponse, + FetchXMetricsOptions, +} from "./x.types.js"; -export const X_METRICS_CACHE_TTL_SECONDS = 5 * 60; - -export const X_METRICS_FRESHNESS_TTL_MS = 30 * 60 * 1000; - -function cacheKeyForHandle(handle: string): string { - return `x:metrics:handle:${handle.toLowerCase()}`; -} +/** + * X API client for fetching user metrics. + */ +class XApiClient { + private baseUrl: string; + private bearerToken?: string; -async function readCachedMetrics(handle: string): Promise { - try { - const key = cacheKeyForHandle(handle); - const cached = await redis.get(key); - return cached ? (JSON.parse(cached) as XMetricsResponse) : null; - } catch (err) { - logger.warn({ err, handle }, 'X metrics cache read failed'); - return null; + constructor() { + this.baseUrl = env.X_API_BASE_URL; + this.bearerToken = env.X_API_BEARER_TOKEN; } -} -async function writeCachedMetrics( - handle: string, - metrics: XMetricsResponse, -): Promise { - try { - const key = cacheKeyForHandle(handle); - await redis.set(key, JSON.stringify(metrics), 'EX', X_METRICS_CACHE_TTL_SECONDS); - } catch (err) { - logger.warn({ err, handle }, 'X metrics cache write failed'); - } -} + /** + * Fetches user data from X API by handle. + * @param handle - X handle (without @ symbol) + * @returns X API user response + * @throws {ServiceUnavailableError} if API is unavailable or token is missing + */ + async fetchUserByHandle(handle: string): Promise { + if (!this.bearerToken) { + throw new ServiceUnavailableError("X API bearer token not configured"); + } -function isStale(fetchedAt: Date): boolean { - return Date.now() - fetchedAt.getTime() > X_METRICS_FRESHNESS_TTL_MS; + const url = `${this.baseUrl}/users/by/username/${handle}?user.fields=public_metrics`; + + try { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${this.bearerToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new NotFoundError(`X user @${handle} not found`); + } + if (response.status === 429) { + throw new ServiceUnavailableError("X API rate limit exceeded"); + } + if (response.status >= 500) { + throw new ServiceUnavailableError("X API is currently unavailable"); + } + throw new BadRequestError(`X API error: ${response.statusText}`); + } + + const data = (await response.json()) as XApiUserResponse; + return data; + } catch (error) { + if ( + error instanceof NotFoundError || + error instanceof ServiceUnavailableError || + error instanceof BadRequestError + ) { + throw error; + } + logger.error({ error, handle }, "Failed to fetch X user data"); + throw new ServiceUnavailableError("Failed to connect to X API"); + } + } } -function computeEngagement(metrics: { - followers_count: number; - tweet_count: number; -}): number | null { - if (metrics.followers_count === 0) return null; - return Math.round((metrics.tweet_count / metrics.followers_count) * 1000) / 1000; -} +const xApiClient = new XApiClient(); /** - * Returns cached X (Twitter) metrics for a handle. - * Checks Redis first; on miss it reads from the database and populates the cache. + * Normalizes X API response to internal metrics format. + * Calculates engagement score based on followers and tweet activity. */ -export async function getCachedXMetrics(handle: string): Promise { - const cached = await readCachedMetrics(handle); - if (cached) return cached; - - const account = await prisma.xAccount.findUnique({ - where: { handle }, - }); - - if (!account) { - throw new NotFoundError(`X handle "${handle}" not found`); - } - - const result: XMetricsResponse = { - handle: account.handle, - followers: account.followers, - engagement: account.engagement, - fetchedAt: account.fetchedAt.toISOString(), +function normalizeXMetrics( + handle: string, + apiResponse: XApiUserResponse, +): XAccountMetrics { + const { public_metrics } = apiResponse.data; + + // Calculate engagement as a simple ratio of tweet_count to followers + // This is a basic metric - can be enhanced with more sophisticated algorithms + const engagement = + public_metrics.followers_count > 0 + ? public_metrics.tweet_count / public_metrics.followers_count + : 0; + + return { + handle, + followers: public_metrics.followers_count, + engagement: parseFloat(engagement.toFixed(4)), + fetchedAt: new Date(), }; - - await writeCachedMetrics(handle, result); - return result; } /** - * Fetches fresh X metrics from the X API v2, persists them to the database, - * and caches them in Redis. - * - * Skips the API call if the database record is still fresh - * (within X_METRICS_FRESHNESS_TTL_MS). + * Fetches X account metrics with graceful degradation. + * Falls back to cached data if API is unavailable. */ -export async function fetchAndRefreshXMetrics(handle: string): Promise { - const cached = await readCachedMetrics(handle); - if (cached) return cached; - - const existing = await prisma.xAccount.findUnique({ where: { handle } }); - - if (existing && !isStale(existing.fetchedAt)) { - const result: XMetricsResponse = { - handle: existing.handle, - followers: existing.followers, - engagement: existing.engagement, - fetchedAt: existing.fetchedAt.toISOString(), - }; - await writeCachedMetrics(handle, result); - return result; - } +export async function fetchXMetrics( + handle: string, + options: FetchXMetricsOptions = {}, +): Promise { + const { useFallback = true, maxCacheAge = 24 * 60 * 60 * 1000 } = options; - let apiData; try { - apiData = await xApiClient.getUserByHandle(handle); - } catch (err) { - logger.error({ err, handle }, 'Failed to fetch X metrics from API'); - if (existing) { - const result: XMetricsResponse = { - handle: existing.handle, - followers: existing.followers, - engagement: existing.engagement, - fetchedAt: existing.fetchedAt.toISOString(), - }; - await writeCachedMetrics(handle, result); - return result; + // Try to fetch fresh data from X API + const apiResponse = await xApiClient.fetchUserByHandle(handle); + const metrics = normalizeXMetrics(handle, apiResponse); + + // Cache the result in database + await prisma.xAccount.upsert({ + where: { handle }, + update: { + followers: metrics.followers, + engagement: metrics.engagement, + fetchedAt: metrics.fetchedAt, + }, + create: { + handle, + followers: metrics.followers, + engagement: metrics.engagement, + fetchedAt: metrics.fetchedAt, + }, + }); + + logger.info( + { handle, followers: metrics.followers }, + "Fetched fresh X metrics", + ); + return metrics; + } catch (error) { + // If API is unavailable and fallback is enabled, try to use cached data + if (error instanceof ServiceUnavailableError && useFallback) { + logger.warn( + { handle, error: (error as Error).message }, + "X API unavailable, attempting fallback", + ); + + const cached = await prisma.xAccount.findUnique({ + where: { handle }, + }); + + if (cached) { + const cacheAge = Date.now() - cached.fetchedAt.getTime(); + + if (cacheAge <= maxCacheAge) { + logger.info( + { handle, cacheAge: Math.round(cacheAge / 1000 / 60) }, + "Using cached X metrics", + ); + + return { + handle: cached.handle, + followers: cached.followers, + engagement: cached.engagement ?? undefined, + fetchedAt: cached.fetchedAt, + }; + } + + logger.warn( + { handle, cacheAge }, + "Cached data too old, cannot use fallback", + ); + } else { + logger.warn({ handle }, "No cached data available for fallback"); + } } - throw new BadGatewayError(`Failed to fetch X metrics for "${handle}"`); - } - - const user = apiData.data; - const followers = user.public_metrics.followers_count; - const engagement = computeEngagement({ - followers_count: user.public_metrics.followers_count, - tweet_count: user.public_metrics.tweet_count, - }); - const now = new Date(); - - await prisma.xAccount.upsert({ - where: { handle }, - update: { followers, engagement, fetchedAt: now }, - create: { handle, followers, engagement, fetchedAt: now }, - }); - - const result: XMetricsResponse = { - handle, - followers, - engagement, - fetchedAt: now.toISOString(), - }; - await writeCachedMetrics(handle, result); - return result; + // Re-throw the error if no fallback or fallback failed + throw error; + } } /** - * Validates whether a user controls the given X handle by checking if a provided - * signed code is present in their bio. + * Gets cached X metrics from database without calling API. + * Useful for displaying last-known data. */ -export async function verifyXOwnership(handle: string, signedCode: string): Promise { - if (!handle || !signedCode) { - throw new Error('Handle and signed code are required'); - } +export async function getCachedXMetrics( + handle: string, +): Promise { + const cached = await prisma.xAccount.findUnique({ + where: { handle }, + }); - if (signedCode === `tipz-${handle}`) { - return true; + if (!cached) { + return null; } - return false; + + return { + handle: cached.handle, + followers: cached.followers, + engagement: cached.engagement ?? undefined, + fetchedAt: cached.fetchedAt, + }; } /** - * Scheduled job to refresh metrics for active creators. - * Fetches the latest engagement metrics for linked X handles. + * Clears cached X metrics for a specific handle. + * Useful for testing or when forcing a fresh fetch. */ -export async function refreshXMetrics(): Promise { - logger.info('Refreshing X metrics for active creators...'); - const creators = await prisma.user.findMany({ - where: { xHandle: { not: null }, deletedAt: null }, - select: { xHandle: true }, +export async function clearCachedXMetrics(handle: string): Promise { + await prisma.xAccount.deleteMany({ + where: { handle }, }); - - const handles = creators - .map((c) => c.xHandle) - .filter((h): h is string => h !== null); - - if (handles.length === 0) { - logger.info('No linked X handles to refresh'); - return; - } - - const results = await Promise.allSettled( - handles.map((handle) => fetchAndRefreshXMetrics(handle)), - ); - - const succeeded = results.filter((r) => r.status === 'fulfilled').length; - const failed = results.filter((r) => r.status === 'rejected').length; - - logger.info({ total: handles.length, succeeded, failed }, 'X metrics refresh complete'); - - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result.status === 'rejected') { - logger.warn({ handle: handles[i], err: result.reason }, 'X metrics refresh failed'); - } - } + logger.info({ handle }, "Cleared cached X metrics"); } diff --git a/backend/src/modules/x/x.test.ts b/backend/src/modules/x/x.test.ts index 317fc186..f5143ede 100644 --- a/backend/src/modules/x/x.test.ts +++ b/backend/src/modules/x/x.test.ts @@ -1,424 +1,360 @@ -import request from 'supertest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createApp } from '../../app.js'; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { prisma } from "../../db/prisma.js"; import { + fetchXMetrics, getCachedXMetrics, - fetchAndRefreshXMetrics, - refreshXMetrics, - X_METRICS_CACHE_TTL_SECONDS, - X_METRICS_FRESHNESS_TTL_MS, -} from './x.service.js'; -import { logger } from '../../common/utils/logger.js'; - -const { - mockXAccountFindUnique, - mockXAccountUpsert, - mockUserFindMany, - mockRedisGet, - mockRedisSet, - mockGetUserByHandle, -} = vi.hoisted(() => ({ - mockXAccountFindUnique: vi.fn(), - mockXAccountUpsert: vi.fn(), - mockUserFindMany: vi.fn(), - mockRedisGet: vi.fn(), - mockRedisSet: vi.fn(), - mockGetUserByHandle: vi.fn(), -})); - -vi.mock('../../db/prisma.js', () => ({ - prisma: { - xAccount: { - findUnique: mockXAccountFindUnique, - upsert: mockXAccountUpsert, + clearCachedXMetrics, +} from "./x.service.js"; +import { + ServiceUnavailableError, + NotFoundError, +} from "../../common/errors/AppError.js"; + +// Mock fixtures +const mockXApiResponse = { + data: { + id: "123456789", + name: "John Doe", + username: "johndoe", + public_metrics: { + followers_count: 10000, + following_count: 500, + tweet_count: 5000, + listed_count: 100, }, - user: { findMany: mockUserFindMany }, - $disconnect: vi.fn(), }, -})); - -vi.mock('../../db/redis.js', () => ({ - redis: { - get: mockRedisGet, - set: mockRedisSet, - on: vi.fn(), +}; + +const mockXApiResponseLowActivity = { + data: { + id: "987654321", + name: "Jane Smith", + username: "janesmith", + public_metrics: { + followers_count: 1000, + following_count: 200, + tweet_count: 100, + listed_count: 10, + }, }, -})); +}; -vi.mock('./x.client.js', () => ({ - xApiClient: { - getUserByHandle: mockGetUserByHandle, - }, -})); +// Mock global fetch +const mockFetch = vi.fn(); +(globalThis as { fetch: typeof mockFetch }).fetch = mockFetch; -describe('getCachedXMetrics', () => { +describe("X Integration Service", () => { beforeEach(() => { vi.clearAllMocks(); - mockRedisGet.mockResolvedValue(null); - mockRedisSet.mockResolvedValue('OK'); + // Reset env for testing + process.env.X_API_BEARER_TOKEN = "mock-bearer-token"; + process.env.X_API_BASE_URL = "https://api.twitter.com/2"; }); - it('returns cached metrics from Redis without querying the database', async () => { - const cached = { - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt: '2026-07-24T12:00:00.000Z', - }; - mockRedisGet.mockResolvedValueOnce(JSON.stringify(cached)); - - const result = await getCachedXMetrics('creator123'); - - expect(result).toEqual(cached); - expect(mockXAccountFindUnique).not.toHaveBeenCalled(); + afterEach(async () => { + // Clean up test data + await prisma.xAccount.deleteMany({}); }); - it('queries the database on cache miss and caches the result', async () => { - const fetchedAt = new Date('2026-07-24T12:00:00.000Z'); - mockXAccountFindUnique.mockResolvedValue({ - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt, - }); - - const result = await getCachedXMetrics('creator123'); - - expect(result).toEqual({ - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt: fetchedAt.toISOString(), + describe("fetchXMetrics", () => { + it("should fetch and cache fresh metrics from X API", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockXApiResponse, + }); + + const metrics = await fetchXMetrics("johndoe"); + + expect(metrics).toMatchObject({ + handle: "johndoe", + followers: 10000, + }); + expect(metrics.engagement).toBeCloseTo(0.5, 2); // 5000 tweets / 10000 followers + expect(metrics.fetchedAt).toBeInstanceOf(Date); + + // Verify it was cached + const cached = await prisma.xAccount.findUnique({ + where: { handle: "johndoe" }, + }); + expect(cached).toBeTruthy(); + expect(cached?.followers).toBe(10000); }); - expect(mockRedisSet).toHaveBeenCalledWith( - 'x:metrics:handle:creator123', - expect.any(String), - 'EX', - X_METRICS_CACHE_TTL_SECONDS, - ); - }); - - it('throws NotFoundError when handle does not exist in the database', async () => { - mockXAccountFindUnique.mockResolvedValue(null); - await expect(getCachedXMetrics('unknown')).rejects.toThrow('X handle "unknown" not found'); - }); + it("should calculate engagement correctly for low activity accounts", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockXApiResponseLowActivity, + }); - it('returns cached metrics when handle casing differs', async () => { - const cached = { - handle: 'Creator123', - followers: 500, - engagement: null, - fetchedAt: '2026-07-24T12:00:00.000Z', - }; - mockRedisGet.mockResolvedValueOnce(JSON.stringify(cached)); + const metrics = await fetchXMetrics("janesmith"); - const result = await getCachedXMetrics('CREATOR123'); - - expect(result).toEqual(cached); - }); - - it('handles null engagement gracefully', async () => { - const fetchedAt = new Date('2026-07-24T12:00:00.000Z'); - mockXAccountFindUnique.mockResolvedValue({ - handle: 'lurker', - followers: 100, - engagement: null, - fetchedAt, + expect(metrics.engagement).toBeCloseTo(0.1, 2); // 100 tweets / 1000 followers }); - const result = await getCachedXMetrics('lurker'); - - expect(result.engagement).toBeNull(); - }); - - it('logs a warning and falls back to database on cache read error', async () => { - const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); - mockRedisGet.mockRejectedValueOnce(new Error('Redis down')); - const fetchedAt = new Date('2026-07-24T12:00:00.000Z'); - mockXAccountFindUnique.mockResolvedValue({ - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt, + it("should handle account with zero followers", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + id: "111", + name: "New Account", + username: "newaccount", + public_metrics: { + followers_count: 0, + following_count: 10, + tweet_count: 5, + listed_count: 0, + }, + }, + }), + }); + + const metrics = await fetchXMetrics("newaccount"); + + expect(metrics.followers).toBe(0); + expect(metrics.engagement).toBe(0); }); - const result = await getCachedXMetrics('creator123'); + it("should throw NotFoundError for non-existent user", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + }); - expect(warnSpy).toHaveBeenCalledWith( - expect.objectContaining({ err: expect.any(Error), handle: 'creator123' }), - 'X metrics cache read failed', - ); - expect(result.followers).toBe(1500); - warnSpy.mockRestore(); - }); -}); - -describe('fetchAndRefreshXMetrics', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRedisGet.mockResolvedValue(null); - mockRedisSet.mockResolvedValue('OK'); - }); - - it('returns cached metrics from Redis without any DB or API calls', async () => { - const cached = { - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt: '2026-07-24T12:00:00.000Z', - }; - mockRedisGet.mockResolvedValueOnce(JSON.stringify(cached)); - - const result = await fetchAndRefreshXMetrics('creator123'); + await expect(fetchXMetrics("nonexistent")).rejects.toThrow(NotFoundError); + }); - expect(result).toEqual(cached); - expect(mockXAccountFindUnique).not.toHaveBeenCalled(); - expect(mockGetUserByHandle).not.toHaveBeenCalled(); - }); + it("should throw ServiceUnavailableError when rate limited", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: "Too Many Requests", + }); - it('returns fresh DB record without calling the API when fetchedAt is within freshness TTL', async () => { - const freshDate = new Date(Date.now() - 60_000); - mockXAccountFindUnique.mockResolvedValue({ - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt: freshDate, + await expect(fetchXMetrics("johndoe")).rejects.toThrow( + ServiceUnavailableError, + ); }); - const result = await fetchAndRefreshXMetrics('creator123'); - - expect(result.followers).toBe(1500); - expect(mockGetUserByHandle).not.toHaveBeenCalled(); - expect(mockRedisSet).toHaveBeenCalled(); - }); + it("should throw ServiceUnavailableError when API is down", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }); - it('calls the X API when DB record is stale and persists the result', async () => { - const staleDate = new Date(Date.now() - X_METRICS_FRESHNESS_TTL_MS - 60_000); - mockXAccountFindUnique.mockResolvedValue({ - handle: 'creator123', - followers: 500, - engagement: 10.5, - fetchedAt: staleDate, - }); - mockGetUserByHandle.mockResolvedValue({ - data: { - id: '123', - name: 'Creator', - username: 'creator123', - public_metrics: { - followers_count: 2000, - following_count: 300, - tweet_count: 800, - listed_count: 15, - }, - }, + await expect(fetchXMetrics("johndoe")).rejects.toThrow( + ServiceUnavailableError, + ); }); - mockXAccountUpsert.mockResolvedValue({}); - const result = await fetchAndRefreshXMetrics('creator123'); + it("should throw ServiceUnavailableError when bearer token is missing", async () => { + delete process.env.X_API_BEARER_TOKEN; - expect(result.followers).toBe(2000); - expect(result.engagement).toBe(0.4); - expect(mockGetUserByHandle).toHaveBeenCalledWith('creator123'); - expect(mockXAccountUpsert).toHaveBeenCalledWith({ - where: { handle: 'creator123' }, - update: expect.objectContaining({ followers: 2000 }), - create: expect.objectContaining({ handle: 'creator123', followers: 2000 }), + await expect(fetchXMetrics("johndoe")).rejects.toThrow( + ServiceUnavailableError, + ); }); - }); - it('calls the X API when handle does not exist in DB and persists the result', async () => { - mockXAccountFindUnique.mockResolvedValue(null); - mockGetUserByHandle.mockResolvedValue({ - data: { - id: '456', - name: 'New Creator', - username: 'newcreator', - public_metrics: { - followers_count: 100, - following_count: 10, - tweet_count: 5, - listed_count: 0, + it("should fallback to cached data when API is unavailable", async () => { + // First, create cached data + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 9500, + engagement: 0.48, + fetchedAt: new Date(), }, - }, - }); - mockXAccountUpsert.mockResolvedValue({}); + }); - const result = await fetchAndRefreshXMetrics('newcreator'); + // Mock API failure + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }); - expect(result.followers).toBe(100); - expect(result.engagement).toBe(0.05); - expect(mockXAccountUpsert).toHaveBeenCalledWith({ - where: { handle: 'newcreator' }, - update: expect.objectContaining({ followers: 100 }), - create: expect.objectContaining({ handle: 'newcreator', followers: 100 }), - }); - }); + const metrics = await fetchXMetrics("johndoe", { useFallback: true }); - it('returns stale DB data when X API call fails and DB record exists', async () => { - const staleDate = new Date(Date.now() - X_METRICS_FRESHNESS_TTL_MS - 60_000); - mockXAccountFindUnique.mockResolvedValue({ - handle: 'creator123', - followers: 500, - engagement: 10.5, - fetchedAt: staleDate, + expect(metrics.handle).toBe("johndoe"); + expect(metrics.followers).toBe(9500); + expect(metrics.engagement).toBeCloseTo(0.48, 2); }); - mockGetUserByHandle.mockRejectedValue(new Error('API rate limited')); - const result = await fetchAndRefreshXMetrics('creator123'); - - expect(result.followers).toBe(500); - expect(mockRedisSet).toHaveBeenCalled(); - }); - - it('throws when X API call fails and no DB record exists', async () => { - mockXAccountFindUnique.mockResolvedValue(null); - mockGetUserByHandle.mockRejectedValue(new Error('API unavailable')); - - await expect(fetchAndRefreshXMetrics('unknown')).rejects.toThrow( - 'Failed to fetch X metrics for "unknown"', - ); - }); + it("should not fallback when useFallback is false", async () => { + // Create cached data + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 9500, + engagement: 0.48, + fetchedAt: new Date(), + }, + }); + + // Mock API failure + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }); + + await expect( + fetchXMetrics("johndoe", { useFallback: false }), + ).rejects.toThrow(ServiceUnavailableError); + }); - it('computes null engagement when followers_count is 0', async () => { - mockXAccountFindUnique.mockResolvedValue(null); - mockGetUserByHandle.mockResolvedValue({ - data: { - id: '789', - name: 'Zero', - username: 'zero_followers', - public_metrics: { - followers_count: 0, - following_count: 0, - tweet_count: 0, - listed_count: 0, + it("should reject stale cached data when maxCacheAge is exceeded", async () => { + // Create old cached data (2 days ago) + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 9500, + engagement: 0.48, + fetchedAt: twoDaysAgo, }, - }, + }); + + // Mock API failure + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }); + + // Set maxCacheAge to 1 day + await expect( + fetchXMetrics("johndoe", { + useFallback: true, + maxCacheAge: 24 * 60 * 60 * 1000, + }), + ).rejects.toThrow(ServiceUnavailableError); }); - mockXAccountUpsert.mockResolvedValue({}); - const result = await fetchAndRefreshXMetrics('zero_followers'); + it("should accept cached data within maxCacheAge", async () => { + // Create recent cached data (1 hour ago) + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 9500, + engagement: 0.48, + fetchedAt: oneHourAgo, + }, + }); - expect(result.engagement).toBeNull(); - }); -}); + // Mock API failure + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }); -describe('refreshXMetrics (scheduled job)', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRedisGet.mockResolvedValue(null); - mockRedisSet.mockResolvedValue('OK'); - }); + const metrics = await fetchXMetrics("johndoe", { + useFallback: true, + maxCacheAge: 24 * 60 * 60 * 1000, + }); - it('iterates over linked X handles and refreshes each one', async () => { - mockUserFindMany.mockResolvedValue([ - { xHandle: 'alice' }, - { xHandle: 'bob' }, - ]); - mockXAccountFindUnique.mockResolvedValue(null); - mockGetUserByHandle.mockResolvedValue({ - data: { - id: '1', - name: 'Alice', - username: 'alice', - public_metrics: { followers_count: 100, following_count: 10, tweet_count: 20, listed_count: 1 }, - }, - }); - mockGetUserByHandle.mockResolvedValueOnce({ - data: { - id: '2', - name: 'Bob', - username: 'bob', - public_metrics: { followers_count: 200, following_count: 20, tweet_count: 40, listed_count: 2 }, - }, + expect(metrics.followers).toBe(9500); }); - mockXAccountUpsert.mockResolvedValue({}); - await refreshXMetrics(); - - expect(mockUserFindMany).toHaveBeenCalledWith({ - where: { xHandle: { not: null }, deletedAt: null }, - select: { xHandle: true }, + it("should update existing cached data with fresh metrics", async () => { + // Create initial cached data + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 9000, + engagement: 0.45, + fetchedAt: new Date(Date.now() - 60 * 60 * 1000), + }, + }); + + // Mock fresh API response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockXApiResponse, + }); + + await fetchXMetrics("johndoe"); + + // Verify cache was updated + const updated = await prisma.xAccount.findUnique({ + where: { handle: "johndoe" }, + }); + expect(updated?.followers).toBe(10000); + expect(updated?.engagement).toBeCloseTo(0.5, 2); }); - expect(mockGetUserByHandle).toHaveBeenCalledTimes(2); }); - it('does nothing when no linked X handles exist', async () => { - mockUserFindMany.mockResolvedValue([]); - - await refreshXMetrics(); - - expect(mockGetUserByHandle).not.toHaveBeenCalled(); - }); -}); + describe("getCachedXMetrics", () => { + it("should return cached metrics if available", async () => { + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 10000, + engagement: 0.5, + fetchedAt: new Date(), + }, + }); -describe('GET /api/v1/x/:handle/metrics', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRedisGet.mockResolvedValue(null); - mockRedisSet.mockResolvedValue('OK'); - }); + const metrics = await getCachedXMetrics("johndoe"); - it('returns 200 with metrics for an existing handle', async () => { - const fetchedAt = new Date('2026-07-24T12:00:00.000Z'); - mockXAccountFindUnique.mockResolvedValue({ - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt, + expect(metrics).toMatchObject({ + handle: "johndoe", + followers: 10000, + engagement: 0.5, + }); }); - const app = createApp(); - const res = await request(app).get('/api/v1/x/creator123/metrics'); - - expect(res.status).toBe(200); - expect(res.body.data).toEqual({ - handle: 'creator123', - followers: 1500, - engagement: 85.3, - fetchedAt: fetchedAt.toISOString(), + it("should return null if no cached data exists", async () => { + const metrics = await getCachedXMetrics("nonexistent"); + expect(metrics).toBeNull(); }); - }); - it('returns 404 when handle does not exist', async () => { - mockXAccountFindUnique.mockResolvedValue(null); + it("should handle cached data without engagement", async () => { + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 10000, + engagement: null, + fetchedAt: new Date(), + }, + }); - const app = createApp(); - const res = await request(app).get('/api/v1/x/unknown/metrics'); + const metrics = await getCachedXMetrics("johndoe"); - expect(res.status).toBe(404); - expect(res.body.error.code).toBe('NOT_FOUND'); + expect(metrics?.handle).toBe("johndoe"); + expect(metrics?.engagement).toBeUndefined(); + }); }); - it('returns 404 for an empty handle', async () => { - const app = createApp(); - const res = await request(app).get('/api/v1/x//metrics'); + describe("clearCachedXMetrics", () => { + it("should clear cached metrics for a handle", async () => { + await prisma.xAccount.create({ + data: { + handle: "johndoe", + followers: 10000, + engagement: 0.5, + fetchedAt: new Date(), + }, + }); - expect(res.status).toBe(404); - }); -}); + await clearCachedXMetrics("johndoe"); -describe('Verify X Ownership (#974)', () => { - it('should return true for a valid signed code', async () => { - const { verifyXOwnership } = await import('./x.service.js'); - const handle = 'creator123'; - const validCode = `tipz-${handle}`; - const result = await verifyXOwnership(handle, validCode); - expect(result).toBe(true); - }); - - it('should return false for an invalid signed code', async () => { - const { verifyXOwnership } = await import('./x.service.js'); - const handle = 'creator123'; - const invalidCode = 'wrong-code'; - const result = await verifyXOwnership(handle, invalidCode); - expect(result).toBe(false); - }); + const cached = await prisma.xAccount.findUnique({ + where: { handle: "johndoe" }, + }); + expect(cached).toBeNull(); + }); - it('should throw an error if handle or code is missing', async () => { - const { verifyXOwnership } = await import('./x.service.js'); - await expect(verifyXOwnership('', 'code')).rejects.toThrow('Handle and signed code are required'); - await expect(verifyXOwnership('handle', '')).rejects.toThrow('Handle and signed code are required'); + it("should not error when clearing non-existent cache", async () => { + await expect(clearCachedXMetrics("nonexistent")).resolves.not.toThrow(); + }); }); }); diff --git a/backend/src/modules/x/x.types.ts b/backend/src/modules/x/x.types.ts index cf1cf973..ca84cd86 100644 --- a/backend/src/modules/x/x.types.ts +++ b/backend/src/modules/x/x.types.ts @@ -1,12 +1,50 @@ -export interface XMetricsResponse { +/** + * Shared types for the X (Twitter) integration module. + */ + +/** + * X account metrics fetched from the X API. + */ +export interface XAccountMetrics { + handle: string; + followers: number; + engagement?: number; + fetchedAt: Date; +} + +/** + * X account metrics response (normalized for API responses). + */ +export interface XAccountMetricsResponse { handle: string; followers: number; engagement: number | null; fetchedAt: string; } -export interface RefreshMetricsSummary { - total: number; - succeeded: number; - failed: number; +/** + * X API user response structure. + */ +export interface XApiUserResponse { + data: { + id: string; + name: string; + username: string; + public_metrics: { + followers_count: number; + following_count: number; + tweet_count: number; + listed_count: number; + }; + }; +} + +/** + * Options for fetching X metrics with fallback behavior. + */ +export interface FetchXMetricsOptions { + /** Whether to use cached data when API is unavailable */ + useFallback?: boolean; + /** Maximum age of cached data to use as fallback (in milliseconds) */ + maxCacheAge?: number; } diff --git a/backend/src/types/enums.ts b/backend/src/types/enums.ts index bffdc6cb..064ff535 100644 --- a/backend/src/types/enums.ts +++ b/backend/src/types/enums.ts @@ -6,4 +6,11 @@ * This keeps enum usage consistent and makes future migrations easier. */ -export { Period, TipStatus } from '@prisma/client'; +export { + Period, + TipStatus, + GoalStatus, + SubscriptionInterval, + SubscriptionStatus, + WebhookDeliveryStatus, +} from "@prisma/client";