diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index 97e9a519d..a22b8e4f3 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -103,6 +103,7 @@ export type { AdminOrganization, AdminOrganizationBillingSummary, AdminOrganizationDetail, + AdminReferralCampaign, AdminSeatAnalytics, AdminSingleResponse, AdminSubscription, @@ -117,6 +118,7 @@ export type { AdminWorkspaceMember, DbMember, DbOrganization, + DbReferralCampaign, DbSubscription, DbUser, DbUserStats, @@ -145,6 +147,7 @@ export { parseWorkflowVariables, toAdminFolder, toAdminOrganization, + toAdminReferralCampaign, toAdminSubscription, toAdminUser, toAdminWorkflow, diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts index cd03b9c7f..45f0a230a 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/[id]/route.ts @@ -8,20 +8,21 @@ * Update campaign fields. All fields are optional. * * Body: - * - name?: string — Campaign name (non-empty) - * - bonusCreditAmount?: number — Bonus credits in dollars (> 0) - * - isActive?: boolean — Enable/disable the campaign - * - code?: string | null — Redeemable code (min 6 chars, auto-uppercased, null to remove) - * - utmSource?: string | null — UTM source match (null = wildcard) - * - utmMedium?: string | null — UTM medium match (null = wildcard) - * - utmCampaign?: string | null — UTM campaign match (null = wildcard) - * - utmContent?: string | null — UTM content match (null = wildcard) + * - name: string (non-empty) - Campaign name + * - bonusCreditAmount: number (> 0) - Bonus credits in dollars + * - isActive: boolean - Enable/disable the campaign + * - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code + * - utmSource: string | null - UTM source match (null = wildcard) + * - utmMedium: string | null - UTM medium match (null = wildcard) + * - utmCampaign: string | null - UTM campaign match (null = wildcard) + * - utmContent: string | null - UTM content match (null = wildcard) */ import { db } from '@sim/db' import { referralCampaigns } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { getBaseUrl } from '@/lib/core/utils/urls' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -29,56 +30,59 @@ import { notFoundResponse, singleResponse, } from '@/app/api/v1/admin/responses' +import { toAdminReferralCampaign } from '@/app/api/v1/admin/types' -const logger = createLogger('AdminReferralCampaign') +const logger = createLogger('AdminReferralCampaignDetailAPI') interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (_request, context) => { +export const GET = withAdminAuthParams(async (_, context) => { try { - const { id } = await context.params + const { id: campaignId } = await context.params const [campaign] = await db .select() .from(referralCampaigns) - .where(eq(referralCampaigns.id, id)) + .where(eq(referralCampaigns.id, campaignId)) .limit(1) if (!campaign) { return notFoundResponse('Campaign') } - return singleResponse(campaign) + logger.info(`Admin API: Retrieved referral campaign ${campaignId}`) + + return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl())) } catch (error) { - logger.error('Failed to get referral campaign', { error }) + logger.error('Admin API: Failed to get referral campaign', { error }) return internalErrorResponse('Failed to get referral campaign') } }) export const PATCH = withAdminAuthParams(async (request, context) => { try { - const { id } = await context.params + const { id: campaignId } = await context.params const body = await request.json() const [existing] = await db .select() .from(referralCampaigns) - .where(eq(referralCampaigns.id, id)) + .where(eq(referralCampaigns.id, campaignId)) .limit(1) if (!existing) { return notFoundResponse('Campaign') } - const updates: Record = { updatedAt: new Date() } + const updateData: Record = { updatedAt: new Date() } if (body.name !== undefined) { - if (typeof body.name !== 'string' || !body.name) { + if (typeof body.name !== 'string' || body.name.trim().length === 0) { return badRequestResponse('name must be a non-empty string') } - updates.name = body.name + updateData.name = body.name.trim() } if (body.bonusCreditAmount !== undefined) { @@ -89,14 +93,14 @@ export const PATCH = withAdminAuthParams(async (request, context) = ) { return badRequestResponse('bonusCreditAmount must be a positive number') } - updates.bonusCreditAmount = body.bonusCreditAmount.toString() + updateData.bonusCreditAmount = body.bonusCreditAmount.toString() } if (body.isActive !== undefined) { if (typeof body.isActive !== 'boolean') { return badRequestResponse('isActive must be a boolean') } - updates.isActive = body.isActive + updateData.isActive = body.isActive } if (body.code !== undefined) { @@ -108,7 +112,7 @@ export const PATCH = withAdminAuthParams(async (request, context) = return badRequestResponse('code must be at least 6 characters') } } - updates.code = body.code ? body.code.trim().toUpperCase() : null + updateData.code = body.code ? body.code.trim().toUpperCase() : null } for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) { @@ -116,21 +120,23 @@ export const PATCH = withAdminAuthParams(async (request, context) = if (body[field] !== null && typeof body[field] !== 'string') { return badRequestResponse(`${field} must be a string or null`) } - updates[field] = body[field] + updateData[field] = body[field] || null } } const [updated] = await db .update(referralCampaigns) - .set(updates) - .where(eq(referralCampaigns.id, id)) + .set(updateData) + .where(eq(referralCampaigns.id, campaignId)) .returning() - logger.info('Updated referral campaign', { id, updates }) + logger.info(`Admin API: Updated referral campaign ${campaignId}`, { + fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }) - return singleResponse(updated) + return singleResponse(toAdminReferralCampaign(updated, getBaseUrl())) } catch (error) { - logger.error('Failed to update referral campaign', { error }) + logger.error('Admin API: Failed to update referral campaign', { error }) return internalErrorResponse('Failed to update referral campaign') } }) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index 54d4eb926..64b711eeb 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -3,30 +3,31 @@ * * List referral campaigns with optional filtering and pagination. * - * Query params: - * - active?: 'true' | 'false' — Filter by active status - * - limit?: number — Page size (default 50) - * - offset?: number — Offset for pagination + * Query Parameters: + * - active: string (optional) - Filter by active status ('true' or 'false') + * - limit: number (default: 50, max: 250) + * - offset: number (default: 0) * * POST /api/v1/admin/referral-campaigns * * Create a new referral campaign. * * Body: - * - name: string — Campaign name (required) - * - bonusCreditAmount: number — Bonus credits in dollars (required, > 0) - * - code?: string | null — Redeemable code (min 6 chars, auto-uppercased) - * - utmSource?: string | null — UTM source match (null = wildcard) - * - utmMedium?: string | null — UTM medium match (null = wildcard) - * - utmCampaign?: string | null — UTM campaign match (null = wildcard) - * - utmContent?: string | null — UTM content match (null = wildcard) + * - name: string (required) - Campaign name + * - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars + * - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code + * - utmSource: string | null (optional) - UTM source match (null = wildcard) + * - utmMedium: string | null (optional) - UTM medium match (null = wildcard) + * - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard) + * - utmContent: string | null (optional) - UTM content match (null = wildcard) */ import { db } from '@sim/db' import { referralCampaigns } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { count, eq, type SQL } from 'drizzle-orm' import { nanoid } from 'nanoid' +import { getBaseUrl } from '@/lib/core/utils/urls' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -34,38 +35,51 @@ import { listResponse, singleResponse, } from '@/app/api/v1/admin/responses' -import { createPaginationMeta, parsePaginationParams } from '@/app/api/v1/admin/types' +import { + type AdminReferralCampaign, + createPaginationMeta, + parsePaginationParams, + toAdminReferralCampaign, +} from '@/app/api/v1/admin/types' -const logger = createLogger('AdminReferralCampaigns') +const logger = createLogger('AdminReferralCampaignsAPI') export const GET = withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) + const activeFilter = url.searchParams.get('active') + try { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const activeFilter = url.searchParams.get('active') - - let query = db.select().from(referralCampaigns).$dynamic() - + const conditions: SQL[] = [] if (activeFilter === 'true') { - query = query.where(eq(referralCampaigns.isActive, true)) + conditions.push(eq(referralCampaigns.isActive, true)) } else if (activeFilter === 'false') { - query = query.where(eq(referralCampaigns.isActive, false)) + conditions.push(eq(referralCampaigns.isActive, false)) } - const rows = await query.limit(limit).offset(offset) + const whereClause = conditions.length > 0 ? conditions[0] : undefined + const baseUrl = getBaseUrl() - let countQuery = db.select().from(referralCampaigns).$dynamic() - if (activeFilter === 'true') { - countQuery = countQuery.where(eq(referralCampaigns.isActive, true)) - } else if (activeFilter === 'false') { - countQuery = countQuery.where(eq(referralCampaigns.isActive, false)) - } - const allRows = await countQuery - const total = allRows.length + const [countResult, campaigns] = await Promise.all([ + db.select({ total: count() }).from(referralCampaigns).where(whereClause), + db + .select() + .from(referralCampaigns) + .where(whereClause) + .orderBy(referralCampaigns.createdAt) + .limit(limit) + .offset(offset), + ]) - return listResponse(rows, createPaginationMeta(total, limit, offset)) + const total = countResult[0].total + const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl)) + const pagination = createPaginationMeta(total, limit, offset) + + logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`) + + return listResponse(data, pagination) } catch (error) { - logger.error('Failed to list referral campaigns', { error }) + logger.error('Admin API: Failed to list referral campaigns', { error }) return internalErrorResponse('Failed to list referral campaigns') } }) @@ -112,20 +126,15 @@ export const POST = withAdminAuth(async (request) => { }) .returning() - logger.info('Created referral campaign', { - id, + logger.info(`Admin API: Created referral campaign ${id}`, { name, code: campaign.code, - utmSource, - utmMedium, - utmCampaign, - utmContent, bonusCreditAmount, }) - return singleResponse(campaign) + return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl())) } catch (error) { - logger.error('Failed to create referral campaign', { error }) + logger.error('Admin API: Failed to create referral campaign', { error }) return internalErrorResponse('Failed to create referral campaign') } }) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 615e02d78..d7ec4f5c3 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -8,6 +8,7 @@ import type { member, organization, + referralCampaigns, subscription, user, userStats, @@ -31,6 +32,7 @@ export type DbOrganization = InferSelectModel export type DbSubscription = InferSelectModel export type DbMember = InferSelectModel export type DbUserStats = InferSelectModel +export type DbReferralCampaign = InferSelectModel // ============================================================================= // Pagination @@ -646,3 +648,49 @@ export interface AdminDeployResult { export interface AdminUndeployResult { isDeployed: boolean } + +// ============================================================================= +// Referral Campaign Types +// ============================================================================= + +export interface AdminReferralCampaign { + id: string + name: string + code: string | null + utmSource: string | null + utmMedium: string | null + utmCampaign: string | null + utmContent: string | null + bonusCreditAmount: string + isActive: boolean + signupUrl: string | null + createdAt: string + updatedAt: string +} + +export function toAdminReferralCampaign( + dbCampaign: DbReferralCampaign, + baseUrl: string +): AdminReferralCampaign { + const utmParams = new URLSearchParams() + if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource) + if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium) + if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign) + if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent) + const query = utmParams.toString() + + return { + id: dbCampaign.id, + name: dbCampaign.name, + code: dbCampaign.code, + utmSource: dbCampaign.utmSource, + utmMedium: dbCampaign.utmMedium, + utmCampaign: dbCampaign.utmCampaign, + utmContent: dbCampaign.utmContent, + bonusCreditAmount: dbCampaign.bonusCreditAmount, + isActive: dbCampaign.isActive, + signupUrl: query ? `${baseUrl}/signup?${query}` : null, + createdAt: dbCampaign.createdAt.toISOString(), + updatedAt: dbCampaign.updatedAt.toISOString(), + } +}