diff --git a/.fallowrc.json b/.fallowrc.json index 0dc856f..191f2c5 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -2,6 +2,7 @@ "$schema": "node_modules/fallow/schema.json", "ignorePatterns": [ "src/gen/**/*_connect.ts", - "src/gen/**/file_*.ts" + "src/gen/**/file_*.ts", + "src/gen/**/*.js" ] } \ No newline at end of file diff --git a/package.json b/package.json index 585f86c..77994fc 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,14 @@ "drizzle-kit": "^0.31.6", "fallow": "^2.64.0", "grpc-tools": "^1.13.1", - "ts-protoc-gen": "^0.15.0", - "tsx": "^4.20.6", - "vitest": "^4.0.3" + "vitest": "^4.0.3", + "tsx": "^4.20.6" }, "peerDependencies": { "typescript": "^5" }, "dependencies": { - + "@grpc/grpc-js": "^1.12.0", "bullmq": "^5.75.2", "dodopayments": "^2.30.0", "drizzle-orm": "^0.44.7", diff --git a/src/context/requestContext.ts b/src/context/requestContext.ts index d80b65c..303cfac 100644 --- a/src/context/requestContext.ts +++ b/src/context/requestContext.ts @@ -52,6 +52,7 @@ export class WideEventBuilder { /** * Set authentication context after successful auth. */ + // fallow-ignore-next-line unused-class-member setAuth(apiKeyId: string | number, cacheHit: boolean): this { this.event.apiKeyId = apiKeyId; this.event.cacheHit = cacheHit; @@ -69,6 +70,7 @@ export class WideEventBuilder { /** * Set event processing context. */ + // fallow-ignore-next-line unused-class-member setEventContext(data: { eventType?: string; eventCount?: number }): this { if (data.eventType !== undefined) this.event.eventType = data.eventType; if (data.eventCount !== undefined) this.event.eventCount = data.eventCount; @@ -97,6 +99,7 @@ export class WideEventBuilder { /** * Set API key creation context. */ + // fallow-ignore-next-line unused-class-member setApiKeyContext(data: { name?: string; expiration?: string }): this { if (data.name !== undefined) this.event.apiKeyName = data.name; if (data.expiration !== undefined) @@ -147,6 +150,7 @@ export class WideEventBuilder { /** * Build the final wide event with duration calculation. */ + // fallow-ignore-next-line unused-class-member build(): WideEvent { const durationMs = DateTime.utc().toMillis() - this.startTime; diff --git a/src/errors/apikey.ts b/src/errors/apikey.ts index ac4560e..a74b084 100644 --- a/src/errors/apikey.ts +++ b/src/errors/apikey.ts @@ -30,6 +30,7 @@ export class APIKeyError extends Error { this.code = context.code; } + // fallow-ignore-next-line unused-class-member static invalidExpiration( details?: string, originalError?: Error @@ -44,6 +45,7 @@ export class APIKeyError extends Error { }); } + // fallow-ignore-next-line unused-class-member static invalidName(details?: string, originalError?: Error): APIKeyError { return new APIKeyError({ type: APIKeyErrorType.INVALID_NAME, @@ -64,6 +66,7 @@ export class APIKeyError extends Error { }); } + // fallow-ignore-next-line unused-class-member static notFound(apiKeyId?: string, originalError?: Error): APIKeyError { return new APIKeyError({ type: APIKeyErrorType.NOT_FOUND, @@ -75,6 +78,7 @@ export class APIKeyError extends Error { }); } + // fallow-ignore-next-line unused-class-member static revocationFailed( details?: string, originalError?: Error @@ -98,6 +102,7 @@ export class APIKeyError extends Error { }); } + // fallow-ignore-next-line unused-class-member static unknown(originalError?: Error): APIKeyError { const details = originalError?.message || "No details available"; return new APIKeyError({ diff --git a/src/errors/auth.ts b/src/errors/auth.ts index cf7d407..89531dc 100644 --- a/src/errors/auth.ts +++ b/src/errors/auth.ts @@ -70,6 +70,7 @@ export class AuthError extends Error { }); } + // fallow-ignore-next-line unused-class-member static databaseError(originalError?: Error): AuthError { return new AuthError({ type: AuthErrorType.DATABASE_ERROR, @@ -79,6 +80,7 @@ export class AuthError extends Error { }); } + // fallow-ignore-next-line unused-class-member static unknown(originalError?: Error): AuthError { const details = originalError?.message || "No details available"; return new AuthError({ diff --git a/src/errors/event.ts b/src/errors/event.ts index 6bcd3d8..166a109 100644 --- a/src/errors/event.ts +++ b/src/errors/event.ts @@ -31,6 +31,7 @@ export class EventError extends Error { this.code = context.code; } + // fallow-ignore-next-line unused-class-member static invalidPayload(details?: string, originalError?: Error): EventError { return new EventError({ type: EventErrorType.INVALID_PAYLOAD, @@ -63,6 +64,7 @@ export class EventError extends Error { }); } + // fallow-ignore-next-line unused-class-member static serializationError( details?: string, originalError?: Error @@ -77,6 +79,7 @@ export class EventError extends Error { }); } + // fallow-ignore-next-line unused-class-member static invalidUserId(userId?: string, originalError?: Error): EventError { return new EventError({ type: EventErrorType.INVALID_USER_ID, @@ -86,6 +89,7 @@ export class EventError extends Error { }); } + // fallow-ignore-next-line unused-class-member static missingData(field: string, originalError?: Error): EventError { return new EventError({ type: EventErrorType.MISSING_DATA, @@ -95,6 +99,7 @@ export class EventError extends Error { }); } + // fallow-ignore-next-line unused-class-member static invalidDataFormat( field: string, expectedFormat: string, @@ -108,6 +113,7 @@ export class EventError extends Error { }); } + // fallow-ignore-next-line unused-class-member static unknown(originalError?: Error): EventError { const details = originalError?.message || "No details available"; return new EventError({ diff --git a/src/errors/payment.ts b/src/errors/payment.ts index cbcc153..b9c6205 100644 --- a/src/errors/payment.ts +++ b/src/errors/payment.ts @@ -36,6 +36,7 @@ export class PaymentError extends Error { this.code = context.code; } + // fallow-ignore-next-line unused-class-member static invalidUserId(userId?: string, originalError?: Error): PaymentError { return new PaymentError({ type: PaymentErrorType.INVALID_USER_ID, @@ -45,6 +46,7 @@ export class PaymentError extends Error { }); } + // fallow-ignore-next-line unused-class-member static checkoutCreationFailed( details?: string, originalError?: Error @@ -72,6 +74,8 @@ export class PaymentError extends Error { }); } + // fallow-ignore-next-line unused-class-members + // fallow-ignore-next-line unused-class-member static paymentProviderApiError( details?: string, originalError?: Error @@ -95,6 +99,8 @@ export class PaymentError extends Error { }); } + // fallow-ignore-next-line unused-class-members + // fallow-ignore-next-line unused-class-member static missingStoreId(originalError?: Error): PaymentError { return new PaymentError({ type: PaymentErrorType.MISSING_STORE_ID, @@ -104,6 +110,8 @@ export class PaymentError extends Error { }); } + // fallow-ignore-next-line unused-class-members + // fallow-ignore-next-line unused-class-member static missingVariantId(originalError?: Error): PaymentError { return new PaymentError({ type: PaymentErrorType.MISSING_VARIANT_ID, @@ -164,6 +172,7 @@ export class PaymentError extends Error { }); } + // fallow-ignore-next-line unused-class-member static configurationError( details?: string, originalError?: Error @@ -178,6 +187,7 @@ export class PaymentError extends Error { }); } + // fallow-ignore-next-line unused-class-member static unknown(originalError?: Error): PaymentError { const details = originalError?.message || "No details available"; return new PaymentError({ diff --git a/src/errors/storage.ts b/src/errors/storage.ts index 2ee65e7..4127002 100644 --- a/src/errors/storage.ts +++ b/src/errors/storage.ts @@ -38,6 +38,7 @@ export class StorageError extends Error { this.code = context.code; } + // fallow-ignore-next-line unused-class-member static connectionFailed( details?: string, originalError?: Error @@ -102,6 +103,7 @@ export class StorageError extends Error { }); } + // fallow-ignore-next-line unused-class-member static dataNotFound(entity?: string, originalError?: Error): StorageError { return new StorageError({ type: StorageErrorType.CONNECTION_FAILED, @@ -173,6 +175,7 @@ export class StorageError extends Error { }); } + // fallow-ignore-next-line unused-class-member static userInsertFailed( userId?: string, originalError?: Error @@ -226,6 +229,7 @@ export class StorageError extends Error { }); } + // fallow-ignore-next-line unused-class-member static unknown(originalError?: Error): StorageError { const details = originalError?.message || "No details available"; return new StorageError({ diff --git a/src/events/AIEvents/AITokenUsage.ts b/src/events/AIEvents/AITokenUsage.ts index 5acb864..71a6394 100644 --- a/src/events/AIEvents/AITokenUsage.ts +++ b/src/events/AIEvents/AITokenUsage.ts @@ -17,6 +17,7 @@ export class AITokenUsage implements AITokenUsageEvent { this.ingested_timestamp = DateTime.utc(); } + // fallow-ignore-next-line unused-class-member serialize() { return { SQL: { diff --git a/src/events/RawEvents/SDKCall.ts b/src/events/RawEvents/SDKCall.ts index cf83ecb..6c75f4c 100644 --- a/src/events/RawEvents/SDKCall.ts +++ b/src/events/RawEvents/SDKCall.ts @@ -17,6 +17,7 @@ export class SDKCall implements SDKCallEvent { this.ingested_timestamp = DateTime.utc(); } + // fallow-ignore-next-line unused-class-member serialize() { return { SQL: { diff --git a/src/interceptors/auth.ts b/src/interceptors/auth.ts index bccd59f..161d973 100644 --- a/src/interceptors/auth.ts +++ b/src/interceptors/auth.ts @@ -63,45 +63,25 @@ export function authInterceptor( handler: GrpcHandler ): GrpcHandler { return (call: GrpcCall, callback) => { - // Skip auth for whitelisted endpoints - const fullPath = methodPath.startsWith("/") ? methodPath : `/${methodPath}`; - if (no_auth.some((path) => fullPath === path || fullPath.endsWith(path))) { + if (isWhitelistedEndpoint(methodPath)) { return handler(call, callback); } - const wideEventBuilder = call[wideEventContextKey]; - - // Extract authorization from metadata - const authHeader = call.metadata.get("authorization")?.[0] as - | string - | undefined; - - if (!authHeader) { - return callback?.(AuthError.missingHeader()); - } - - if (!authHeader.startsWith("Bearer ")) { - return callback?.(AuthError.invalidHeaderFormat()); - } - - const apiKey = authHeader.slice("Bearer ".length).trim(); - - // Validate API key format - if (!apiKey.startsWith("scrn_") || apiKey.length !== 37) { - return callback?.(AuthError.invalidAPIKey("Invalid API key format")); + const authResult = extractAndValidateAuth(call); + if (authResult.error) { + return callback?.(authResult.error); } + const apiKey = authResult.apiKey!; const apiKeyHash = hashAPIKey(apiKey); - // Check cache first const cached = apiKeyCache.get(apiKeyHash); if (cached) { call[apiKeyContextKey] = cached.id; - wideEventBuilder?.setAuth(cached.id, true); + call[wideEventContextKey]?.setAuth(cached.id, true); return handler(call, callback); } - // Query database for API key lookupApiKey(apiKeyHash) .then((apiKeyRecord) => { if (!apiKeyRecord) { @@ -116,14 +96,13 @@ export function authInterceptor( return callback?.(AuthError.expiredAPIKey()); } - // Cache and set context apiKeyCache.set(apiKeyHash, { id: apiKeyRecord.id, expiresAt: apiKeyRecord.expiresAt, }); call[apiKeyContextKey] = apiKeyRecord.id; - wideEventBuilder?.setAuth(apiKeyRecord.id, false); + call[wideEventContextKey]?.setAuth(apiKeyRecord.id, false); return handler(call, callback); }) @@ -133,6 +112,31 @@ export function authInterceptor( }; } +function isWhitelistedEndpoint(methodPath: string): boolean { + const fullPath = methodPath.startsWith("/") ? methodPath : `/${methodPath}`; + return no_auth.some((path) => fullPath === path || fullPath.endsWith(path)); +} + +function extractAndValidateAuth(call: GrpcCall): { apiKey?: string; error?: Error } { + const authHeader = call.metadata.get("authorization")?.[0] as string | undefined; + + if (!authHeader) { + return { error: AuthError.missingHeader() }; + } + + if (!authHeader.startsWith("Bearer ")) { + return { error: AuthError.invalidHeaderFormat() }; + } + + const apiKey = authHeader.slice("Bearer ".length).trim(); + + if (!apiKey.startsWith("scrn_") || apiKey.length !== 37) { + return { error: AuthError.invalidAPIKey("Invalid API key format") }; + } + + return { apiKey }; +} + async function lookupApiKey(apiKeyHash: string) { const db = getPostgresDB(); const result = await db diff --git a/src/interceptors/logging.ts b/src/interceptors/logging.ts index e4c77dd..381ce94 100644 --- a/src/interceptors/logging.ts +++ b/src/interceptors/logging.ts @@ -73,43 +73,27 @@ export function loggingInterceptor( }; } +const GRPC_TO_HTTP_STATUS: Record = { + [grpcStatus.CANCELLED]: 499, + [grpcStatus.UNKNOWN]: 500, + [grpcStatus.INVALID_ARGUMENT]: 400, + [grpcStatus.DEADLINE_EXCEEDED]: 504, + [grpcStatus.NOT_FOUND]: 404, + [grpcStatus.ALREADY_EXISTS]: 409, + [grpcStatus.PERMISSION_DENIED]: 403, + [grpcStatus.RESOURCE_EXHAUSTED]: 429, + [grpcStatus.FAILED_PRECONDITION]: 400, + [grpcStatus.ABORTED]: 409, + [grpcStatus.OUT_OF_RANGE]: 400, + [grpcStatus.UNIMPLEMENTED]: 501, + [grpcStatus.INTERNAL]: 500, + [grpcStatus.UNAVAILABLE]: 503, + [grpcStatus.DATA_LOSS]: 500, + [grpcStatus.UNAUTHENTICATED]: 401, +}; + function grpcStatusToHttpStatus(code: number): number { - switch (code) { - case grpcStatus.CANCELLED: - return 499; - case grpcStatus.UNKNOWN: - return 500; - case grpcStatus.INVALID_ARGUMENT: - return 400; - case grpcStatus.DEADLINE_EXCEEDED: - return 504; - case grpcStatus.NOT_FOUND: - return 404; - case grpcStatus.ALREADY_EXISTS: - return 409; - case grpcStatus.PERMISSION_DENIED: - return 403; - case grpcStatus.RESOURCE_EXHAUSTED: - return 429; - case grpcStatus.FAILED_PRECONDITION: - return 400; - case grpcStatus.ABORTED: - return 409; - case grpcStatus.OUT_OF_RANGE: - return 400; - case grpcStatus.UNIMPLEMENTED: - return 501; - case grpcStatus.INTERNAL: - return 500; - case grpcStatus.UNAVAILABLE: - return 503; - case grpcStatus.DATA_LOSS: - return 500; - case grpcStatus.UNAUTHENTICATED: - return 401; - default: - return 500; - } + return GRPC_TO_HTTP_STATUS[code] ?? 500; } interface ErrorDetails { diff --git a/src/interface/event/Event.ts b/src/interface/event/Event.ts index b51f82d..1ad01dd 100644 --- a/src/interface/event/Event.ts +++ b/src/interface/event/Event.ts @@ -67,7 +67,7 @@ export type EventData = EventDataMap[K]; /** * Base SQL record structure for all events */ -type BaseSqlRecord = { +export type BaseSqlRecord = { type: K; reported_timestamp: DateTime; data: EventData; @@ -76,7 +76,7 @@ type BaseSqlRecord = { /** * SQL record structure for events that require userId */ -type SqlRecordWithUserId = BaseSqlRecord & { +export type SqlRecordWithUserId = BaseSqlRecord & { userId: UserId; }; diff --git a/src/queues/onboarding.ts b/src/queues/onboarding.ts index 5f7d135..33000f9 100644 --- a/src/queues/onboarding.ts +++ b/src/queues/onboarding.ts @@ -2,15 +2,14 @@ import { Queue, type RepeatOptions } from "bullmq"; import { DateTime } from "luxon"; import { getRedisConnection } from "../storage/db/redis.ts"; -export interface OnboardingJobData { +interface OnboardingJobData { cronExpression: string; createdAt: string; } let onboardingQueue: Queue | null = null; -// fallow-ignore-next-line unused-exports -export function getOnboardingQueue(): Queue { +function getOnboardingQueue(): Queue { if (!onboardingQueue) { onboardingQueue = new Queue("onboarding", { connection: getRedisConnection(), diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 0bc587b..c4f47c5 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -42,37 +42,18 @@ export async function handleDodoWebhook( builder: WideEventBuilder ): Promise { try { - const client = getDodoClient(); - - const headers: Record = { - "webhook-id": webhookId || "", - "webhook-signature": signature || "", - "webhook-timestamp": timestamp || "", - }; - - let webhookPayload: DodoWebhookPayload; - try { - webhookPayload = client.webhooks.unwrap(rawBody, { - headers, - }) as unknown as DodoWebhookPayload; - } catch { - builder.setError(401, { - type: "AuthenticationError", - message: "Invalid webhook signature", - }); - return { statusCode: 401, body: { error: "Invalid signature" } }; + const payloadResult = verifyWebhookPayload( + rawBody, + signature, + timestamp, + webhookId, + builder + ); + if (payloadResult.error) { + return payloadResult.error; } - if (!webhookPayload.type || !webhookPayload.data) { - builder.setError(400, { - type: "ParseError", - message: "Invalid webhook payload shape", - }); - return { - statusCode: 400, - body: { error: "Invalid webhook payload shape" }, - }; - } + const webhookPayload = payloadResult.payload!; builder.setWebhookContext({ webhookEvent: webhookPayload.type, @@ -85,11 +66,8 @@ export async function handleDodoWebhook( return { statusCode: 200, body: { message: "Event ignored" } }; } - const { payment_id, checkout_session_id, total_amount, status } = - webhookPayload.data; - const creditAmount = Math.round(total_amount); - - if (!checkout_session_id) { + const checkoutSessionId = webhookPayload.data.checkout_session_id; + if (!checkoutSessionId) { builder.setError(400, { type: "ValidationError", message: "Missing checkout_session_id in webhook payload", @@ -100,33 +78,11 @@ export async function handleDodoWebhook( }; } - const db = getPostgresDB(); - - const sessions = await db - .select({ - id: sessionsTable.id, - userId: sessionsTable.userId, - billed_upto: sessionsTable.billed_upto, - processed: sessionsTable.processed, - }) - .from(sessionsTable) - .where(eq(sessionsTable.sessionId, checkout_session_id)) - .limit(1); - - if (sessions.length === 0 || !sessions[0]) { - builder.setError(404, { - type: "NotFoundError", - message: `Session not found for checkout_session_id: ${checkout_session_id}`, - }); - return { - statusCode: 404, - body: { error: "Session not found" }, - }; - } - - const session = sessions[0]; + const sessionResult = await lookupSession(checkoutSessionId); + if ("error" in sessionResult) { + return sessionResult.error;} - if (session.processed) { + if (sessionResult.processed) { builder.setSuccess(200); builder.addContext({ ignored: true }); return { @@ -135,75 +91,165 @@ export async function handleDodoWebhook( }; } - const userId = session.userId; - const billedUpto = session.billed_upto; + const { userId, billedUpto } = sessionResult; + await updateUserBilling(userId, billedUpto); + await markSessionProcessed(checkoutSessionId); + + builder.setUser(userId); + builder.setPaymentContext({ + creditAmount: Math.round(webhookPayload.data.total_amount), + }); + + return await storePaymentEvent(userId, Math.round(webhookPayload.data.total_amount), builder); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + builder.setError(500, { + type: "InternalError", + message: `Unexpected webhook error: ${errorMessage}`, + cause: error instanceof Error ? error.message : undefined, + stack: isDev && error instanceof Error ? error.stack : undefined, + }); + return { statusCode: 500, body: { error: "Internal server error" } }; + } +} + +function verifyWebhookPayload( + rawBody: string, + signature: string | undefined, + timestamp: string | undefined, + webhookId: string | undefined, + builder: WideEventBuilder +): { error?: WebhookResponse; payload?: DodoWebhookPayload } { + const client = getDodoClient(); + + const headers: Record = { + "webhook-id": webhookId || "", + "webhook-signature": signature || "", + "webhook-timestamp": timestamp || "", + }; + + try { + const webhookPayload = client.webhooks.unwrap(rawBody, { + headers, + }) as unknown as DodoWebhookPayload; - if (!userId) { - builder.setError(500, { - type: "InternalServerError", - message: `User ID not found for session: ${checkout_session_id}`, + if (!webhookPayload.type || !webhookPayload.data) { + builder.setError(400, { + type: "ParseError", + message: "Invalid webhook payload shape", }); return { - statusCode: 500, - body: { error: "User ID not found for session" }, + error: { + statusCode: 400, + body: { error: "Invalid webhook payload shape" }, + }, }; } - if (!billedUpto) { - builder.setError(500, { - type: "InternalServerError", - message: `billed_upto not found for session: ${checkout_session_id}`, - }); - return { + return { payload: webhookPayload }; + } catch { + builder.setError(401, { + type: "AuthenticationError", + message: "Invalid webhook signature", + }); + return { + error: { statusCode: 401, body: { error: "Invalid signature" } }, + }; + } +} + +async function lookupSession( + checkoutSessionId: string +): Promise< + { error: WebhookResponse } | { processed: boolean; userId: string; billedUpto: string } +> { + const db = getPostgresDB(); + + const sessions = await db + .select({ + id: sessionsTable.id, + userId: sessionsTable.userId, + billed_upto: sessionsTable.billed_upto, + processed: sessionsTable.processed, + }) + .from(sessionsTable) + .where(eq(sessionsTable.sessionId, checkoutSessionId)) + .limit(1); + + if (sessions.length === 0 || !sessions[0]) { + return { + error: { + statusCode: 404, + body: { error: "Session not found" }, + }, + }; + } + + const session = sessions[0]; + const userId = session.userId; + const billedUpto = session.billed_upto; + + if (!userId) { + return { + error: { + statusCode: 500, + body: { error: "User ID not found for session" }, + }, + }; + } + + if (!billedUpto) { + return { + error: { statusCode: 500, body: { error: "billed_upto not found for session" }, - }; - } + }, + }; + } - await db - .update(usersTable) - .set({ last_billed_timestamp: billedUpto }) - .where(eq(usersTable.id, userId)); + return { processed: session.processed ?? false, userId, billedUpto }; +} - await db - .update(sessionsTable) - .set({ processed: true }) - .where(eq(sessionsTable.sessionId, checkout_session_id)); +async function updateUserBilling(userId: string, billedUpto: string): Promise { + const db = getPostgresDB(); + await db + .update(usersTable) + .set({ last_billed_timestamp: billedUpto }) + .where(eq(usersTable.id, userId)); +} - builder.setUser(userId); - builder.setPaymentContext({ creditAmount }); +async function markSessionProcessed(checkoutSessionId: string): Promise { + const db = getPostgresDB(); + await db + .update(sessionsTable) + .set({ processed: true }) + .where(eq(sessionsTable.sessionId, checkoutSessionId)); +} - try { - const paymentEvent = new Payment(userId, { creditAmount }); - const adapter = - await StorageAdapterFactory.getEventStorageAdapter("PAYMENT"); +async function storePaymentEvent( + userId: string, + creditAmount: number, + builder: WideEventBuilder +): Promise { + try { + const paymentEvent = new Payment(userId, { creditAmount }); + const adapter = await StorageAdapterFactory.getEventStorageAdapter("PAYMENT"); - await adapter.add(paymentEvent.serialize()); + await adapter.add(paymentEvent.serialize()); - builder.setSuccess(200); - return { - statusCode: 200, - body: { message: "Webhook processed successfully" }, - }; - } catch (dbError) { - const errorMessage = - dbError instanceof Error ? dbError.message : String(dbError); - builder.setError(500, { - type: "DatabaseError", - message: `Failed to store payment event: ${errorMessage}`, - cause: dbError instanceof Error ? dbError.message : undefined, - stack: isDev && dbError instanceof Error ? dbError.stack : undefined, - }); - return { statusCode: 500, body: { error: "Database error" } }; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + builder.setSuccess(200); + return { + statusCode: 200, + body: { message: "Webhook processed successfully" }, + }; + } catch (dbError) { + const errorMessage = dbError instanceof Error ? dbError.message : String(dbError); builder.setError(500, { - type: "InternalError", - message: `Unexpected webhook error: ${errorMessage}`, - cause: error instanceof Error ? error.message : undefined, - stack: isDev && error instanceof Error ? error.stack : undefined, + type: "DatabaseError", + message: `Failed to store payment event: ${errorMessage}`, + cause: dbError instanceof Error ? dbError.message : undefined, + stack: isDev && dbError instanceof Error ? dbError.stack : undefined, }); - return { statusCode: 500, body: { error: "Internal server error" } }; + return { statusCode: 500, body: { error: "Database error" } }; } } diff --git a/src/routes/http/registerWebhookRoutes.ts b/src/routes/http/registerWebhookRoutes.ts index 5c1681d..b216f77 100644 --- a/src/routes/http/registerWebhookRoutes.ts +++ b/src/routes/http/registerWebhookRoutes.ts @@ -9,68 +9,84 @@ export async function registerWebhookRoutes( server.post( "/webhooks/payment/createdCheckout", { config: { rawBody: true } }, - async ( - request: FastifyRequest, - reply: FastifyReply - ) => { - const builder = createWideEventBuilder( - generateRequestId(), - request.method, - request.url - ); + handleCheckoutWebhook + ); +} - try { - const signatureHeader = request.headers["webhook-signature"]; - const timestampHeader = request.headers["webhook-timestamp"]; - const webhookIdHeader = request.headers["webhook-id"]; - const signature = - typeof signatureHeader === "string" - ? signatureHeader - : Array.isArray(signatureHeader) - ? signatureHeader[0] - : undefined; - const timestamp = - typeof timestampHeader === "string" - ? timestampHeader - : Array.isArray(timestampHeader) - ? timestampHeader[0] - : undefined; - const webhookId = - typeof webhookIdHeader === "string" - ? webhookIdHeader - : Array.isArray(webhookIdHeader) - ? webhookIdHeader[0] - : undefined; +async function handleCheckoutWebhook( + request: FastifyRequest, + reply: FastifyReply +): Promise<{ error?: string }> { + const builder = createWideEventBuilder( + generateRequestId(), + request.method, + request.url + ); - const requestWithRawBody = request as typeof request & { - rawBody?: string; - }; - const rawBody = requestWithRawBody.rawBody; + try { + const { signature, timestamp, webhookId } = extractWebhookHeaders(request); + const rawBody = extractRawBody(request); - if (!rawBody) { - builder.setError(400, { - type: "ParseError", - message: "Missing raw webhook payload", - }); - reply.code(400); - return { error: "Missing raw webhook payload" }; - } + if (!rawBody) { + builder.setError(400, { + type: "ParseError", + message: "Missing raw webhook payload", + }); + reply.code(400); + return { error: "Missing raw webhook payload" }; + } - const result = await handleDodoWebhook(rawBody, signature, timestamp, webhookId, builder); + const result = await handleDodoWebhook(rawBody, signature, timestamp, webhookId, builder); - reply.code(result.statusCode); - return result.body; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - builder.setError(500, { - type: "InternalError", - message: err.message, - }); - reply.code(500); - return { error: "Internal server error" }; - } finally { - logger.emit(builder.build()); - } - } - ); + reply.code(result.statusCode); + return result.body; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + builder.setError(500, { + type: "InternalError", + message: err.message, + }); + reply.code(500); + return { error: "Internal server error" }; + } finally { + logger.emit(builder.build()); + } +} + +function extractWebhookHeaders(request: FastifyRequest): { + signature?: string; + timestamp?: string; + webhookId?: string; +} { + const signatureHeader = request.headers["webhook-signature"]; + const timestampHeader = request.headers["webhook-timestamp"]; + const webhookIdHeader = request.headers["webhook-id"]; + + const signature = + typeof signatureHeader === "string" + ? signatureHeader + : Array.isArray(signatureHeader) + ? signatureHeader[0] + : undefined; + const timestamp = + typeof timestampHeader === "string" + ? timestampHeader + : Array.isArray(timestampHeader) + ? timestampHeader[0] + : undefined; + const webhookId = + typeof webhookIdHeader === "string" + ? webhookIdHeader + : Array.isArray(webhookIdHeader) + ? webhookIdHeader[0] + : undefined; + + return { signature, timestamp, webhookId }; +} + +function extractRawBody(request: FastifyRequest): string | undefined { + const requestWithRawBody = request as typeof request & { + rawBody?: string; + }; + return requestWithRawBody.rawBody; } diff --git a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts index 6acd904..42d71f7 100644 --- a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts @@ -1,5 +1,8 @@ import { getPostgresDB } from "../../../db/postgres/db"; -import { eventsTable, aiTokenUsageEventsTable } from "../../../db/postgres/schema"; +import { + eventsTable, + aiTokenUsageEventsTable, +} from "../../../db/postgres/schema"; import { StorageError } from "../../../../errors/storage"; import { type SqlRecord } from "../../../../interface/event/Event"; import type { UserId } from "../../../../config/identifiers"; @@ -31,161 +34,165 @@ export async function handleAddAiTokenUsage( return; } - for (const event_data of events) { - // Validate input tokens is not negative - const inputTokens = event_data.data.inputTokens; - if (typeof inputTokens === "number" && inputTokens < 0) { - throw StorageError.insertFailed( - `Negative input tokens not allowed for AI token usage for user ${event_data.userId}`, - new Error(`inputTokens ${inputTokens} is negative`) + validateAiTokenEvents(events); + + const aggregatedEvents = await aggregateAiTokenEvents(events); + + return await executeInTransaction( + connectionObject, + `storing ${events.length} AI_TOKEN_USAGE event(s)`, + async (txn) => { + const uniqueUserIds = Array.from( + new Set(aggregatedEvents.map((event) => event.userId)) + ); + + for (const userId of uniqueUserIds) { + const userEvent = new User({ id: userId }); + await handleAddUser(userEvent.serialize().SQL); + } + + const eventValues = aggregatedEvents.map((aggEvent) => ({ + reported_timestamp: aggEvent.reported_timestamp, + ingested_timestamp: DateTime.utc().toString(), + userId: aggEvent.userId, + api_keyId: apiKeyId, + })); + + let eventIDs; + try { + eventIDs = await txn + .insert(eventsTable) + .values(eventValues) + .returning({ id: eventsTable.id }); + } catch (e) { + throw StorageError.eventInsertFailed( + `Failed to batch insert ${aggregatedEvents.length} aggregated event(s)`, + e instanceof Error ? e : new Error(String(e)) ); } - // Validate output tokens is not negative - const outputTokens = event_data.data.outputTokens; - if (typeof outputTokens === "number" && outputTokens < 0) { + if (!eventIDs || eventIDs.length === 0) { + throw StorageError.emptyResult("Event insert returned no IDs"); + } + + if (eventIDs.length !== aggregatedEvents.length) { throw StorageError.insertFailed( - `Negative output tokens not allowed for AI token usage for user ${event_data.userId}`, - new Error(`outputTokens ${outputTokens} is negative`) + `Expected ${aggregatedEvents.length} event IDs but got ${eventIDs.length}`, + new Error("Event ID count mismatch") ); } - // Validate input debit amount is not negative - const inputDebitAmount = event_data.data.inputDebitAmount; - if (typeof inputDebitAmount === "number" && inputDebitAmount < 0) { + const aiTokenUsageValues = aggregatedEvents.map((aggEvent, index) => { + const eventId = eventIDs[index]; + if (!eventId) { + throw StorageError.insertFailed( + `Missing event ID at index ${index}`, + new Error("Event ID is undefined") + ); + } + return { + id: eventId.id, + model: aggEvent.model, + inputTokens: aggEvent.inputTokens, + outputTokens: aggEvent.outputTokens, + inputDebitAmount: aggEvent.inputDebitAmount, + outputDebitAmount: aggEvent.outputDebitAmount, + }; + }); + + try { + await txn.insert(aiTokenUsageEventsTable).values(aiTokenUsageValues); + } catch (e) { throw StorageError.insertFailed( - `Negative input debit amount not allowed for AI token usage for user ${event_data.userId}`, - new Error(`inputDebitAmount ${inputDebitAmount} is negative`) + `Failed to batch insert AI token usage events`, + e instanceof Error ? e : new Error(String(e)) ); } - // Validate output debit amount is not negative - const outputDebitAmount = event_data.data.outputDebitAmount; - if (typeof outputDebitAmount === "number" && outputDebitAmount < 0) { + const firstEvent = eventIDs[0]; + if (!firstEvent || !firstEvent.id) { throw StorageError.insertFailed( - `Negative output debit amount not allowed for AI token usage for user ${event_data.userId}`, - new Error(`outputDebitAmount ${outputDebitAmount} is negative`) + "Missing or invalid ID for the first inserted event", + new Error(`Invalid first event ID: ${JSON.stringify(firstEvent)}`) ); } - } - // Aggregate events by userId and model - const aggregationMap = new Map(); + return { id: firstEvent.id }; + } + ); +} - for (const event_data of events) { - const reported_timestamp = await validateAndPrepareTimestamp( - event_data.reported_timestamp +function validateAiTokenEvents( + events: Array> +): void { + for (const event_data of events) { + const inputTokens = event_data.data.inputTokens; + if (typeof inputTokens === "number" && inputTokens < 0) { + throw StorageError.insertFailed( + `Negative input tokens not allowed for AI token usage for user ${event_data.userId}`, + new Error(`inputTokens ${inputTokens} is negative`) ); - - const key = `${event_data.userId}:${event_data.data.model}`; - const existing = aggregationMap.get(key); - - if (existing) { - // Aggregate with existing entry - existing.inputTokens += event_data.data.inputTokens; - existing.outputTokens += event_data.data.outputTokens; - existing.inputDebitAmount += event_data.data.inputDebitAmount; - existing.outputDebitAmount += event_data.data.outputDebitAmount; - // Use the latest timestamp - if (reported_timestamp > existing.reported_timestamp) { - existing.reported_timestamp = reported_timestamp; - } - } else { - // Create new aggregated entry - aggregationMap.set(key, { - userId: event_data.userId, - model: event_data.data.model, - inputTokens: event_data.data.inputTokens, - outputTokens: event_data.data.outputTokens, - inputDebitAmount: event_data.data.inputDebitAmount, - outputDebitAmount: event_data.data.outputDebitAmount, - reported_timestamp, - }); - } } - const aggregatedEvents = Array.from(aggregationMap.values()); - - return await executeInTransaction( - connectionObject, - `storing ${events.length} AI_TOKEN_USAGE event(s)`, - async (txn) => { - const uniqueUserIds = Array.from( - new Set(aggregatedEvents.map((event) => event.userId)) - ); - - for (const userId of uniqueUserIds) { - const userEvent = new User({ id: userId }); - await handleAddUser(userEvent.serialize().SQL); - } + const outputTokens = event_data.data.outputTokens; + if (typeof outputTokens === "number" && outputTokens < 0) { + throw StorageError.insertFailed( + `Negative output tokens not allowed for AI token usage for user ${event_data.userId}`, + new Error(`outputTokens ${outputTokens} is negative`) + ); + } - const eventValues = aggregatedEvents.map((aggEvent) => ({ - reported_timestamp: aggEvent.reported_timestamp, - ingested_timestamp: DateTime.utc().toString(), - userId: aggEvent.userId, - api_keyId: apiKeyId, - })); - - let eventIDs; - try { - eventIDs = await txn - .insert(eventsTable) - .values(eventValues) - .returning({ id: eventsTable.id }); - } catch (e) { - throw StorageError.eventInsertFailed( - `Failed to batch insert ${aggregatedEvents.length} aggregated event(s)`, - e instanceof Error ? e : new Error(String(e)) - ); - } + const inputDebitAmount = event_data.data.inputDebitAmount; + if (typeof inputDebitAmount === "number" && inputDebitAmount < 0) { + throw StorageError.insertFailed( + `Negative input debit amount not allowed for AI token usage for user ${event_data.userId}`, + new Error(`inputDebitAmount ${inputDebitAmount} is negative`) + ); + } - if (!eventIDs || eventIDs.length === 0) { - throw StorageError.emptyResult("Event insert returned no IDs"); - } + const outputDebitAmount = event_data.data.outputDebitAmount; + if (typeof outputDebitAmount === "number" && outputDebitAmount < 0) { + throw StorageError.insertFailed( + `Negative output debit amount not allowed for AI token usage for user ${event_data.userId}`, + new Error(`outputDebitAmount ${outputDebitAmount} is negative`) + ); + } + } +} - if (eventIDs.length !== aggregatedEvents.length) { - throw StorageError.insertFailed( - `Expected ${aggregatedEvents.length} event IDs but got ${eventIDs.length}`, - new Error("Event ID count mismatch") - ); - } +async function aggregateAiTokenEvents( + events: Array> +): Promise { + const aggregationMap = new Map(); - const aiTokenUsageValues = aggregatedEvents.map((aggEvent, index) => { - const eventId = eventIDs[index]; - if (!eventId) { - throw StorageError.insertFailed( - `Missing event ID at index ${index}`, - new Error("Event ID is undefined") - ); - } - return { - id: eventId.id, - model: aggEvent.model, - inputTokens: aggEvent.inputTokens, - outputTokens: aggEvent.outputTokens, - inputDebitAmount: aggEvent.inputDebitAmount, - outputDebitAmount: aggEvent.outputDebitAmount, - }; - }); - - try { - await txn.insert(aiTokenUsageEventsTable).values(aiTokenUsageValues); - } catch (e) { - throw StorageError.insertFailed( - `Failed to batch insert AI token usage events`, - e instanceof Error ? e : new Error(String(e)) - ); - } + for (const event_data of events) { + const reported_timestamp = await validateAndPrepareTimestamp( + event_data.reported_timestamp + ); - const firstEvent = eventIDs[0]; - if (!firstEvent || !firstEvent.id) { - throw StorageError.insertFailed( - "Missing or invalid ID for the first inserted event", - new Error(`Invalid first event ID: ${JSON.stringify(firstEvent)}`) - ); - } + const key = `${event_data.userId}:${event_data.data.model}`; + const existing = aggregationMap.get(key); - return { id: firstEvent.id }; + if (existing) { + existing.inputTokens += event_data.data.inputTokens; + existing.outputTokens += event_data.data.outputTokens; + existing.inputDebitAmount += event_data.data.inputDebitAmount; + existing.outputDebitAmount += event_data.data.outputDebitAmount; + if (reported_timestamp > existing.reported_timestamp) { + existing.reported_timestamp = reported_timestamp; } - ); + } else { + aggregationMap.set(key, { + userId: event_data.userId, + model: event_data.data.model, + inputTokens: event_data.data.inputTokens, + outputTokens: event_data.data.outputTokens, + inputDebitAmount: event_data.data.inputDebitAmount, + outputDebitAmount: event_data.data.outputDebitAmount, + reported_timestamp, + }); + } + } + + return Array.from(aggregationMap.values()); } diff --git a/src/storage/adapter/postgres/handlers/addEventUtils.ts b/src/storage/adapter/postgres/handlers/addEventUtils.ts index 934640c..910c384 100644 --- a/src/storage/adapter/postgres/handlers/addEventUtils.ts +++ b/src/storage/adapter/postgres/handlers/addEventUtils.ts @@ -11,6 +11,25 @@ export type TransactionFn = ( txn: PgTransaction ) => Promise; +export async function insertEventWithBaseData( + txn: PgTransaction, + event_data: { userId: string; reported_timestamp: DateTime }, + apiKeyId: string | undefined +): Promise<{ id: string }> { + await ensureUserExists(event_data.userId); + + const reported_timestamp = await validateAndPrepareTimestamp( + event_data.reported_timestamp + ); + + return await insertEvent(txn, { + reported_timestamp, + ingested_timestamp: DateTime.utc().toString(), + userId: event_data.userId, + api_keyId: apiKeyId, + }); +} + export async function executeInTransaction( connectionObject: PgDatabase, operationName: string, @@ -64,6 +83,7 @@ export type EventInsertValues = { api_keyId: string | undefined; }; +// fallow-ignore-next-line unused-export export async function insertEvent( txn: PgTransaction, values: EventInsertValues @@ -93,6 +113,7 @@ export async function insertEvent( return { id: eventID.id }; } +// fallow-ignore-next-line unused-export export async function ensureUserExists( userId: string ): Promise { diff --git a/src/storage/adapter/postgres/handlers/addPayment.ts b/src/storage/adapter/postgres/handlers/addPayment.ts index 724a6b8..1f18ac3 100644 --- a/src/storage/adapter/postgres/handlers/addPayment.ts +++ b/src/storage/adapter/postgres/handlers/addPayment.ts @@ -2,11 +2,9 @@ import { getPostgresDB } from "../../../db/postgres/db"; import { eventsTable, paymentEventsTable } from "../../../db/postgres/schema"; import { StorageError } from "../../../../errors/storage"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { DateTime } from "luxon"; import { validateAndPrepareTimestamp, - insertEvent, - ensureUserExists, + insertEventWithBaseData, executeInTransaction, } from "./addEventUtils"; @@ -36,18 +34,7 @@ export async function handleAddPayment( connectionObject, "storing PAYMENT event", async (txn) => { - await ensureUserExists(event_data.userId); - - const reported_timestamp = await validateAndPrepareTimestamp( - event_data.reported_timestamp - ); - - const eventID = await insertEvent(txn, { - reported_timestamp, - ingested_timestamp: DateTime.utc().toString(), - userId: event_data.userId, - api_keyId: apiKeyId, - }); + const eventID = await insertEventWithBaseData(txn, event_data, apiKeyId); try { await txn.insert(paymentEventsTable).values({ diff --git a/src/storage/adapter/postgres/handlers/addSdkCall.ts b/src/storage/adapter/postgres/handlers/addSdkCall.ts index b22f7c0..8759ea3 100644 --- a/src/storage/adapter/postgres/handlers/addSdkCall.ts +++ b/src/storage/adapter/postgres/handlers/addSdkCall.ts @@ -2,13 +2,7 @@ import { getPostgresDB } from "../../../db/postgres/db"; import { eventsTable, sdkCallEventsTable } from "../../../db/postgres/schema"; import { StorageError } from "../../../../errors/storage"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { DateTime } from "luxon"; -import { - validateAndPrepareTimestamp, - insertEvent, - ensureUserExists, - executeInTransaction, -} from "./addEventUtils"; +import { insertEventWithBaseData, executeInTransaction } from "./addEventUtils"; export async function handleAddSdkCall( event_data: SqlRecord<"SDK_CALL">, @@ -28,26 +22,13 @@ export async function handleAddSdkCall( connectionObject, "storing SDK_CALL event", async (txn) => { - await ensureUserExists(event_data.userId); - - const reported_timestamp = await validateAndPrepareTimestamp( - event_data.reported_timestamp - ); - - const eventID = await insertEvent(txn, { - reported_timestamp, - ingested_timestamp: DateTime.utc().toString(), - userId: event_data.userId, - api_keyId: apiKeyId, - }); + const eventID = await insertEventWithBaseData(txn, event_data, apiKeyId); try { - const sdkData = event_data; - await txn.insert(sdkCallEventsTable).values({ id: eventID.id, - type: sdkData.data.sdkCallType, - debitAmount: sdkData.data.debitAmount, + type: event_data.data.sdkCallType, + debitAmount: event_data.data.debitAmount, }); } catch (e) { throw StorageError.insertFailed( diff --git a/src/storage/adapter/postgres/handlers/addSession.ts b/src/storage/adapter/postgres/handlers/addSession.ts index 23ec596..1669805 100644 --- a/src/storage/adapter/postgres/handlers/addSession.ts +++ b/src/storage/adapter/postgres/handlers/addSession.ts @@ -9,57 +9,73 @@ export async function handleAddSession( sessionId: string, billedUpto: DateTime ): Promise<{ id: string }> { + const billedUptoStr = billedUpto.toISO(); + validateSessionInput(sessionId, billedUpto, billedUptoStr); + const connectionObject = getPostgresDB(); try { - if (!sessionId || sessionId.trim().length === 0) { - throw StorageError.invalidData("Missing sessionId in handleAddSession"); - } - - const billedUptoStr = billedUpto.toISO(); - if (!billedUptoStr) { - throw StorageError.invalidTimestamp("billedUpto.toISO() returned falsy"); - } - const insertResult = await connectionObject .insert(sessionsTable) .values({ - userId: userId as string, sessionId: sessionId, - billed_upto: billedUptoStr, + billed_upto: billedUptoStr!, + userId: userId as string, }) .returning({ id: sessionsTable.id }); - if (!insertResult[0]) { - throw StorageError.emptyResult("Session insert returned no record"); - } + return validateInsertResult(insertResult); + } catch (e) { + handleSessionError(e, sessionId); + } +} + +function validateSessionInput( + sessionId: string, + billedUpto: DateTime, + billedUptoStr: string | null +): void { + if (!sessionId || sessionId.trim().length === 0) { + throw StorageError.invalidData("Missing sessionId in handleAddSession"); + } + + if (!billedUptoStr) { + throw StorageError.invalidTimestamp("billedUpto.toISO() returned falsy"); + } +} - const insertedId = insertResult[0].id; - if (!insertedId) { - throw StorageError.emptyResult("Session insert returned null id"); - } +function validateInsertResult(insertResult: unknown): { id: string } { + if (!insertResult || !Array.isArray(insertResult) || !insertResult[0]) { + throw StorageError.emptyResult("Session insert returned no record"); + } - return { id: insertedId }; - } catch (e) { - if ( - e && - typeof e === "object" && - "type" in e && - (e as any).name === "StorageError" - ) { - throw e; - } + const insertedId = insertResult[0].id; + if (!insertedId) { + throw StorageError.emptyResult("Session insert returned null id"); + } + + return { id: insertedId }; +} - if (e instanceof Error && e.message.includes("unique")) { - throw StorageError.constraintViolation( - `Session with sessionId ${sessionId} already exists`, - e - ); - } +function handleSessionError(e: unknown, sessionId: string): never { + if ( + e && + typeof e === "object" && + "type" in e && + (e as any).name === "StorageError" + ) { + throw e; + } - throw StorageError.insertFailed( - "Failed to insert session", - e instanceof Error ? e : new Error(String(e)) + if (e instanceof Error && e.message.includes("unique")) { + throw StorageError.constraintViolation( + `Session with sessionId ${sessionId} already exists`, + e ); } + + throw StorageError.insertFailed( + "Failed to insert session", + e instanceof Error ? e : new Error(String(e)) + ); } \ No newline at end of file diff --git a/src/storage/adapter/postgres/handlers/priceRequest.ts b/src/storage/adapter/postgres/handlers/priceRequest.ts index 143f1d4..4ee34e9 100644 --- a/src/storage/adapter/postgres/handlers/priceRequest.ts +++ b/src/storage/adapter/postgres/handlers/priceRequest.ts @@ -23,80 +23,18 @@ export async function handlePriceRequest( const db = getPostgresDB(); try { - if (!userId) { - throw StorageError.invalidData(`Missing userId in ${eventType} event`); - } - - if (typeof userId !== "string" || userId.trim().length === 0) { - throw StorageError.invalidData(`Invalid userId format: ${typeof userId}`); - } - - let result; - try { - const baseCondition = sql`${eventsTable.reported_timestamp} > ${usersTable.last_billed_timestamp} AND ${eventsTable.userId} = ${userId}`; - const whereClause = beforeTimestamp - ? and( - baseCondition, - sql`${eventsTable.reported_timestamp} < ${beforeTimestamp.toISO()}` - ) - : baseCondition; - - result = await db - .select({ - price: sum(priceColumn), - }) - .from(priceTable) - .innerJoin(eventsTable, eq(priceTable.id, eventsTable.id)) - .innerJoin(usersTable, eq(eventsTable.userId, usersTable.id)) - .where(whereClause) - .groupBy(eventsTable.userId); - } catch (e) { - throw StorageError.queryFailed( - `Failed to query ${eventType} events for user ${userId}`, - e instanceof Error ? e : new Error(String(e)) - ); - } - - if (!result) { - throw StorageError.emptyResult( - `Price query returned null for user ${userId}` - ); - } - - if (!Array.isArray(result)) { - throw StorageError.queryFailed( - `Query result is not an array for user ${userId}` - ); - } - - if (result.length === 0 || !result[0]) { - return 0; - } - - const priceValue = result[0].price; - - if (priceValue === null || priceValue === undefined) { - return 0; - } - - let parsedPrice: number; - try { - parsedPrice = parseInt(priceValue); - } catch (e) { - throw StorageError.priceCalculationFailed( - userId, - new Error(`Failed to parse price value: ${priceValue}`) - ); - } + validateUserId(userId, eventType); - if (isNaN(parsedPrice)) { - throw StorageError.priceCalculationFailed( - userId, - new Error(`Price parsed to NaN from value: ${priceValue}`) - ); - } + const queryResult = await buildAndExecutePriceQuery( + db, + userId, + priceTable, + priceColumn, + eventType, + beforeTimestamp + ); - return parsedPrice; + return parsePriceResult(queryResult, userId); } catch (e) { if ( e && @@ -113,3 +51,92 @@ export async function handlePriceRequest( ); } } + +function validateUserId(userId: UserId, eventType: string): void { + if (!userId) { + throw StorageError.invalidData(`Missing userId in ${eventType} event`); + } + + if (typeof userId !== "string" || userId.trim().length === 0) { + throw StorageError.invalidData(`Invalid userId format: ${typeof userId}`); + } +} + +async function buildAndExecutePriceQuery( + db: ReturnType, + userId: UserId, + priceTable: PriceEventTable, + priceColumn: SQL, + eventType: string, + beforeTimestamp: DateTime +): Promise { + try { + const baseCondition = sql`${eventsTable.reported_timestamp} > ${usersTable.last_billed_timestamp} AND ${eventsTable.userId} = ${userId}`; + const whereClause = beforeTimestamp + ? and( + baseCondition, + sql`${eventsTable.reported_timestamp} < ${beforeTimestamp.toISO()}` + ) + : baseCondition; + + const result = await db + .select({ + price: sum(priceColumn), + }) + .from(priceTable) + .innerJoin(eventsTable, eq(priceTable.id, eventsTable.id)) + .innerJoin(usersTable, eq(eventsTable.userId, usersTable.id)) + .where(whereClause) + .groupBy(eventsTable.userId); + + return result; + } catch (e) { + throw StorageError.queryFailed( + `Failed to query ${eventType} events for user ${userId}`, + e instanceof Error ? e : new Error(String(e)) + ); + } +} + +function parsePriceResult(result: unknown, userId: UserId): number { + if (!result) { + throw StorageError.emptyResult( + `Price query returned null for user ${userId}` + ); + } + + if (!Array.isArray(result)) { + throw StorageError.queryFailed( + `Query result is not an array for user ${userId}` + ); + } + + if (result.length === 0 || !result[0]) { + return 0; + } + + const priceValue = (result[0] as { price?: unknown }).price; + + if (priceValue === null || priceValue === undefined) { + return 0; + } + + let parsedPrice: number; + try { + parsedPrice = parseInt(priceValue as string); + } catch (e) { + throw StorageError.priceCalculationFailed( + userId, + new Error(`Failed to parse price value: ${priceValue}`) + ); + } + + if (isNaN(parsedPrice)) { + throw StorageError.priceCalculationFailed( + userId, + new Error(`Price parsed to NaN from value: ${priceValue}`) + ); + } + + return parsedPrice; +} diff --git a/src/storage/adapter/postgres/handlers/priceRequestPayment.ts b/src/storage/adapter/postgres/handlers/priceRequestPayment.ts index b2d7099..700429f 100644 --- a/src/storage/adapter/postgres/handlers/priceRequestPayment.ts +++ b/src/storage/adapter/postgres/handlers/priceRequestPayment.ts @@ -9,47 +9,36 @@ export async function handlePriceRequestPayment( userId: UserId, beforeTimestamp: DateTime ): Promise { - try { - if (!userId) { - throw StorageError.invalidData("Missing userId in REQUEST_PAYMENT event"); - } + validateUserId(userId); - const sdkPrice = await handlePriceRequestSdkCall(userId, beforeTimestamp); + const [sdkPrice, aiPrice] = await Promise.all([ + handlePriceRequestSdkCall(userId, beforeTimestamp), + handlePriceRequestAiTokenUsage(userId, beforeTimestamp), + ]); - if (typeof sdkPrice !== "number" || isNaN(sdkPrice)) { - throw StorageError.priceCalculationFailed( - userId, - new Error(`Invalid SDK price value returned: ${sdkPrice}`) - ); - } + return combinePrices(userId, sdkPrice, aiPrice); +} - const aiPrice = await handlePriceRequestAiTokenUsage( +function validateUserId(userId: UserId): void { + if (!userId) { + throw StorageError.invalidData("Missing userId in REQUEST_PAYMENT event"); + } +} + +function combinePrices(userId: UserId, sdkPrice: number, aiPrice: number): number { + if (typeof sdkPrice !== "number" || isNaN(sdkPrice)) { + throw StorageError.priceCalculationFailed( userId, - beforeTimestamp + new Error(`Invalid SDK price value returned: ${sdkPrice}`) ); + } - if (typeof aiPrice !== "number" || isNaN(aiPrice)) { - throw StorageError.priceCalculationFailed( - userId, - new Error(`Invalid AI price value returned: ${aiPrice}`) - ); - } - - const totalPrice = sdkPrice + aiPrice; - return totalPrice; - } catch (e) { - if ( - e && - typeof e === "object" && - "type" in e && - (e as any).name === "StorageError" - ) { - throw e; - } - + if (typeof aiPrice !== "number" || isNaN(aiPrice)) { throw StorageError.priceCalculationFailed( - "Failed to calculate price for REQUEST_PAYMENT event", - e instanceof Error ? e : new Error(String(e)) + userId, + new Error(`Invalid AI price value returned: ${aiPrice}`) ); } + + return sdkPrice + aiPrice; } diff --git a/src/storage/adapter/postgres/postgres.ts b/src/storage/adapter/postgres/postgres.ts index 23f77a5..3a3a53d 100644 --- a/src/storage/adapter/postgres/postgres.ts +++ b/src/storage/adapter/postgres/postgres.ts @@ -21,9 +21,46 @@ import type { import type { UserId } from "../../../config/identifiers"; import type { DateTime } from "luxon"; +function dispatchToHandler(type: EventKind, data: SqlRecord, apiKeyId?: string) { + switch (type) { + case "SDK_CALL": + if (!apiKeyId) throw StorageError.missingApiKeyId(); + return handleAddSdkCall(data as never, apiKeyId); + case "AI_TOKEN_USAGE": + if (!apiKeyId) throw StorageError.missingApiKeyId(); + return handleAddAiTokenUsage([data as never], apiKeyId); + case "ADD_KEY": + return handleAddKey(data as never); + case "PAYMENT": + return handleAddPayment(data as never, apiKeyId); + case "METADATA": + return handleAddMetadata(data as never); + case "USER": + return handleAddUser(data as never); + default: + const _exhaustive: never = type; + throw StorageError.unknownEventType(_exhaustive as EventKind); + } +} + +function dispatchPriceHandler(type: EventKind, userId: UserId, ts: DateTime) { + switch (type) { + case "PAYMENT": + return handlePriceRequestPayment(userId, ts); + case "SDK_CALL": + return handlePriceRequestSdkCall(userId, ts); + case "AI_TOKEN_USAGE": + return handlePriceRequestAiTokenUsage(userId, ts); + default: + throw StorageError.unknownEventType(type); + } +} + export class PostgresAdapter implements StorageAdapter { + // fallow-ignore-next-line unused-class-member connectionObject = getPostgresDB(); + // fallow-ignore-next-line unused-class-member async add(serialized: SerializedEvent, apiKeyId?: string) { let event_data: SqlRecord; @@ -37,7 +74,6 @@ export class PostgresAdapter implements StorageAdapter { ); } } catch (e) { - // Use duck typing instead of instanceof to work with mocked modules if ( e && typeof e === "object" && @@ -52,64 +88,15 @@ export class PostgresAdapter implements StorageAdapter { ); } - switch (event_data.type) { - case "SDK_CALL": { - if (!apiKeyId) { - throw StorageError.missingApiKeyId(); - } - return await handleAddSdkCall(event_data, apiKeyId); - } - - case "AI_TOKEN_USAGE": { - if (!apiKeyId) { - throw StorageError.missingApiKeyId(); - } - return await handleAddAiTokenUsage([event_data], apiKeyId); - } - - case "ADD_KEY": { - return await handleAddKey(event_data); - } - - case "PAYMENT": { - return await handleAddPayment(event_data, apiKeyId); - } - - case "METADATA": { - return await handleAddMetadata(event_data); - } - - case "USER": { - return await handleAddUser(event_data); - } - - default: { - throw StorageError.unknownEventType(event_data); - } - } + return dispatchToHandler(event_data.type, event_data, apiKeyId); } + // fallow-ignore-next-line unused-class-member async price( userID: UserId, event_type: EventKind, beforeTimestamp: DateTime ): Promise { - switch (event_type) { - case "PAYMENT": { - return await handlePriceRequestPayment(userID, beforeTimestamp); - } - - case "SDK_CALL": { - return await handlePriceRequestSdkCall(userID, beforeTimestamp); - } - - case "AI_TOKEN_USAGE": { - return await handlePriceRequestAiTokenUsage(userID, beforeTimestamp); - } - - default: { - throw StorageError.unknownEventType(event_type); - } - } + return dispatchPriceHandler(event_type, userID, beforeTimestamp); } -} +} \ No newline at end of file diff --git a/src/utils/parseExpr.ts b/src/utils/parseExpr.ts index b1ba49c..2b470df 100644 --- a/src/utils/parseExpr.ts +++ b/src/utils/parseExpr.ts @@ -89,7 +89,12 @@ function validateExprSyntax(exprString: string): void { throw EventError.validationFailed("Expression cannot be empty"); } - // Check parentheses balance + validateParenthesesBalance(exprString); + validateFunctionNames(exprString); + validateTagNames(exprString); +} + +function validateParenthesesBalance(exprString: string): void { let depth = 0; for (const char of exprString) { if (char === "(") depth++; @@ -105,8 +110,9 @@ function validateExprSyntax(exprString: string): void { "Invalid expression syntax: unmatched opening parenthesis" ); } +} - // Extract and validate function names +function validateFunctionNames(exprString: string): void { const functionPattern = /([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g; let match: RegExpExecArray | null; @@ -118,9 +124,12 @@ function validateExprSyntax(exprString: string): void { ); } } +} - // Validate tag name format (must be UPPER_SNAKE_CASE) +function validateTagNames(exprString: string): void { const tagNamePattern = /tag\(([^)]*)\)/gi; + let match: RegExpExecArray | null; + while ((match = tagNamePattern.exec(exprString)) !== null) { const tagName = match[1]; if (!tagName || !/^[A-Z_][A-Z0-9_]*$/.test(tagName)) {