diff --git a/src/config/generate-query-docs.ts b/src/config/generate-query-docs.ts index fddf72d..b16e989 100644 --- a/src/config/generate-query-docs.ts +++ b/src/config/generate-query-docs.ts @@ -1,6 +1,7 @@ import fs from 'fs' import path from 'path' import { getTwoDaysAgo } from './swagger' +import { getQueryDataSchema, getQueryResponseExample } from './query-examples' /** * Generates OpenAPI documentation for SQL query endpoints @@ -9,6 +10,13 @@ import { getTwoDaysAgo } from './swagger' const QUERIES_DIR = path.join(__dirname, '../../db_schema/queries/v1') +type QueryDoc = { + name: string + category: string + description: string + path: string +} + // Category mappings based on query name patterns const categorizeQuery = (queryName: string): string => { if (queryName.includes('Spotify') || queryName.includes('spotify')) @@ -45,8 +53,8 @@ const extractDescription = (sqlContent: string): string => { return commentLines.join('
') || 'Analytics query endpoint' } -export const generateQueryDocs = () => { - const queries: any[] = [] +export const generateQueryDocs = (): QueryDoc[] => { + const queries: QueryDoc[] = [] if (!fs.existsSync(QUERIES_DIR)) { console.warn(`Queries directory not found: ${QUERIES_DIR}`) @@ -85,16 +93,23 @@ export const generateQueryDocs = () => { // Generate OpenAPI paths object for all queries export const generateQueryPaths = () => { const queries = generateQueryDocs() - const paths: any = {} + const paths: Record = {} // Group by category for better organization const byCategory = queries.reduce((acc, q) => { if (!acc[q.category]) acc[q.category] = [] acc[q.category].push(q) return acc - }, {} as Record) + }, {} as Record) for (const query of queries) { + const exampleDate = getTwoDaysAgo() + const responseExample = getQueryResponseExample( + query.name, + exampleDate, + exampleDate + ) + paths[query.path] = { get: { summary: query.name, @@ -115,7 +130,7 @@ export const generateQueryPaths = () => { schema: { type: 'string', format: 'date', - example: getTwoDaysAgo(), + example: exampleDate, }, description: 'Start date (YYYY-MM-DD)', }, @@ -126,7 +141,7 @@ export const generateQueryPaths = () => { schema: { type: 'string', format: 'date', - example: getTwoDaysAgo(), + example: exampleDate, }, description: 'End date (YYYY-MM-DD)', }, @@ -144,7 +159,9 @@ export const generateQueryPaths = () => { type: 'object', properties: { query: { type: 'string' }, + accountId: { type: 'string' }, podcastId: { type: 'string' }, + date: { type: 'string' }, startDate: { type: 'string', format: 'date', @@ -153,14 +170,15 @@ export const generateQueryPaths = () => { type: 'string', format: 'date', }, + result: { type: 'string' }, }, }, - data: { - type: 'array', - items: { type: 'object' }, - }, + data: getQueryDataSchema(query.name), }, }, + ...(responseExample + ? { example: responseExample } + : {}), }, }, }, diff --git a/src/config/query-examples.ts b/src/config/query-examples.ts new file mode 100644 index 0000000..0918c93 --- /dev/null +++ b/src/config/query-examples.ts @@ -0,0 +1,529 @@ +type JsonPrimitive = string | number | boolean | null + +type JsonValue = JsonPrimitive | JsonObject | JsonValue[] + +type JsonObject = { + [key: string]: JsonValue +} + +type QueryExampleMap = Record + +type OpenApiSchema = { + type?: string + nullable?: boolean + properties?: Record + items?: OpenApiSchema + additionalProperties?: boolean | OpenApiSchema + oneOf?: OpenApiSchema[] + example?: JsonValue +} + +export const queryDataExamples: QueryExampleMap = { + appleEpisodesLTR: [ + { + name: 'How Small Teams Measure Growth', + guid: 'episode-2026-07-growth-metrics', + date: '2026-07-07', + apple_quarter1: '92.4000', + apple_quarter2: '81.7000', + apple_quarter3: '68.9000', + apple_quarter4: '54.2000', + apple_listeners: 1840, + }, + ], + appleEpisodesPlays: [ + { + date: '2026-07-07', + name: 'How Small Teams Measure Growth', + guid: 'episode-2026-07-growth-metrics', + plays: 2380, + total_time_listened: 184500, + unique_engaged_listeners: 1420, + unique_listeners: 1765, + }, + ], + applePodcastFollowers: [ + { + date: '2026-07-07', + total_followers: 2875, + total_unfollowers: 318, + gained_followers: 42, + lost_followers: 9, + }, + ], + chartsRankings: [ + { + platform: 'spotify', + item_type: 'podcast', + show_id: '5xSignalRoomPodcast', + episode_id: null, + episode_name: null, + market: 'US', + chart_name: 'technology', + position: 18, + chart_date: '2026-07-07', + }, + { + platform: 'apple', + item_type: 'episode', + show_id: '1543210987', + episode_id: '1000667123456', + episode_name: 'How Small Teams Measure Growth', + market: 'DE', + chart_name: 'Technology', + position: 12, + chart_date: '2026-07-07', + }, + ], + episodesAge: [ + { + date: '2026-07-07', + guid: 'episode-2026-07-growth-metrics', + age_group: '28-34', + listeners: 640, + percent: '34.60', + }, + { + date: '2026-07-07', + guid: 'episode-2026-07-growth-metrics', + age_group: '0-17', + listeners: 0, + percent: null, + }, + ], + episodesDailyMetrics: [ + { + account_id: 42, + spotify_episode_id: '3n7SignalGrowthMetrics', + apple_episode_id: 1000667123456, + guid: 'episode-2026-07-growth-metrics', + date: '2026-07-07', + spotify_starts: 3200, + spotify_streams: 2440, + spotify_listeners: 2180, + apple_plays: 2380, + apple_listeners: 1765, + apple_engaged_listeners: 1420, + apple_time_listened: 184500, + }, + { + account_id: 42, + spotify_episode_id: '5m2SignalTrailer', + apple_episode_id: 1000667000123, + guid: 'episode-2026-07-signal-trailer', + date: '2026-07-07', + spotify_starts: 980, + spotify_streams: 720, + spotify_listeners: 650, + apple_plays: null, + apple_listeners: null, + apple_engaged_listeners: null, + apple_time_listened: null, + }, + ], + episodesGender: [ + { + date: '2026-07-07', + guid: 'episode-2026-07-growth-metrics', + gender: 'female', + listeners: 345, + age_group: '28-34', + }, + { + date: '2026-07-07', + guid: 'episode-2026-07-growth-metrics', + gender: 'male', + listeners: 265, + age_group: '28-34', + }, + ], + episodesLTR: [ + { + name: 'How Small Teams Measure Growth', + guid: 'episode-2026-07-growth-metrics', + date: '2026-07-07', + quarter1_combined: '91.80000000', + quarter2_combined: '80.40000000', + quarter3_combined: '66.70000000', + quarter4_combined: '52.90000000', + spotify_quarter1: 91.3, + spotify_quarter2: 79.5, + spotify_quarter3: 65.1, + spotify_quarter4: 51.8, + apple_quarter1: '92.4000', + apple_quarter2: '81.7000', + apple_quarter3: '68.9000', + apple_quarter4: '54.2000', + listeners_combined: 4020, + apple_listeners: 1840, + spotify_listeners: 2180, + }, + ], + episodesLTRHistogram: [ + { + guid: 'episode-2026-07-growth-metrics', + date: '2026-07-07', + spotify_listeners_max: 2180, + apple_listeners_max: 1840, + spotify_histogram: [ + { '0': 2180 }, + { '15': 2095 }, + { '30': 2040 }, + { '45': 1988 }, + { '60': 1935 }, + ], + apple_histogram: [ + { '0': 1840 }, + { '15': 1794 }, + { '30': 1728 }, + { '45': 1660 }, + { '60': 1595 }, + ], + }, + ], + episodesMetadata: [ + { + account_id: 42, + spotify_episode_id: '3n7SignalGrowthMetrics', + apple_episode_id: 1000667123456, + episode_name: 'How Small Teams Measure Growth', + guid: 'episode-2026-07-growth-metrics', + spotify_url: + 'https://open.spotify.com/episode/3n7SignalGrowthMetrics', + spotify_release_date: '2026-07-03', + spotify_description: + 'A practical conversation about choosing podcast metrics that help small teams make better publishing decisions.', + spotify_duration_seconds: '2148.0000', + spotify_language: 'en', + spotify_explicit_content: 0, + spotify_has_video: 0, + spotify_artwork_url: + 'https://cdn.example.com/openpodcast/signal-room-growth.jpg', + apple_collection_name: 'The Signal Room', + apple_release_datetime: '2026-07-03T06:00:00Z', + apple_release_date: '2026-07-03', + apple_episode_number: 84, + apple_episode_type: 'full', + }, + { + account_id: 42, + spotify_episode_id: '5m2SignalTrailer', + apple_episode_id: 1000667000123, + episode_name: 'The Signal Room Trailer', + guid: 'episode-2026-07-signal-trailer', + spotify_url: 'https://open.spotify.com/episode/5m2SignalTrailer', + spotify_release_date: '2026-06-20', + spotify_description: + 'A short preview of the conversations coming up on The Signal Room.', + spotify_duration_seconds: '180.0000', + spotify_language: 'en', + spotify_explicit_content: 0, + spotify_has_video: 0, + spotify_artwork_url: + 'https://cdn.example.com/openpodcast/signal-room-trailer.jpg', + apple_collection_name: 'The Signal Room', + apple_release_datetime: '2026-06-20 06:00:00', + apple_release_date: '2026-06-20', + apple_episode_number: null, + apple_episode_type: 'trailer', + }, + ], + ping: [ + { + start: '2026-07-07', + end: '2026-07-07', + result: 'pong', + }, + ], + podcastAge: [ + { + date: '2026-07-07', + age_group: '28-34', + listeners: 4920, + percent: '36.40', + }, + { + date: '2026-07-07', + age_group: '35-44', + listeners: 3180, + percent: '23.50', + }, + ], + podcastFollowers: [ + { + account_id: 42, + date: '2026-07-07', + spotify_total_followers: 4120, + spotify_change_to_day_before: 36, + apple_total_followers: 2557, + apple_change_to_day_before: 33, + }, + ], + podcastGender: [ + { + date: '2026-07-07', + gender: 'female', + listeners: 2550, + age_group: '28-34', + }, + { + date: '2026-07-07', + gender: 'male', + listeners: 2210, + age_group: '28-34', + }, + ], + podcastMetadata: [ + { + account_id: 42, + podcast_name: 'The Signal Room', + artwork_url: 'https://cdn.example.com/openpodcast/signal-room.jpg', + podcast_release_date: '2024-01-12', + podcast_url: 'https://example.com/podcasts/the-signal-room', + publisher: 'OpenPodcast Studios', + metadata_last_updated: '2026-07-07 09:30:00', + spotify_last_update_date: '2026-07-07', + spotify_total_episodes: 128, + spotify_latest_starts: 18420, + spotify_latest_streams: 12680, + spotify_latest_listeners: 9340, + spotify_latest_followers: 4120, + apple_followers_last_update: '2026-07-07', + apple_total_followers: 2875, + apple_total_unfollowers: 318, + }, + ], + spotifyCountries: [ + { + spotify_listeners: '4360', + spotify_country_shortcode: 'US', + spotify_listeners_percent: '46.70', + }, + { + spotify_listeners: '1280', + spotify_country_shortcode: 'DE', + spotify_listeners_percent: '13.70', + }, + ], + spotifyImpressions: [ + { + account_id: 42, + date: '2026-07-07', + impressions: 48250, + }, + ], + spotifyImpressionsFunnel: [ + { + account_id: 42, + date: '2026-07-07', + step_id: 'impressions', + step_count: 48250, + conversion_percent: null, + }, + { + account_id: 42, + date: '2026-07-07', + step_id: 'streams', + step_count: 12680, + conversion_percent: '0.26280', + }, + ], + spotifyImpressionsSources: [ + { + account_id: 42, + date_start: '2026-07-01', + date_end: '2026-07-07', + source_id: 'HOME', + impression_count: 28400, + }, + { + account_id: 42, + date_start: '2026-07-01', + date_end: '2026-07-07', + source_id: 'SEARCH', + impression_count: 9200, + }, + ], + spotifyPlaysSum: [ + { + podcast_id: 42, + spotify_total_episodes: '128', + spotify_starts: '129400', + spotify_streams: '91420', + spotify_listeners: '58300', + spotify_followers: '4120', + }, + ], +} + +const inferPrimitiveSchema = (value: JsonPrimitive): OpenApiSchema => { + if (value === null) { + return { + nullable: true, + example: null, + } + } + + return { + type: typeof value, + example: value, + } +} + +const mergeSchemas = (schemas: OpenApiSchema[]): OpenApiSchema => { + const hasNull = schemas.some((schema) => schema.nullable && !schema.type) + const nonNullSchemas = schemas.filter((schema) => schema.type) + + if (nonNullSchemas.length === 0) { + return { nullable: true } + } + + const schemaTypes = new Set(nonNullSchemas.map((schema) => schema.type)) + + if (schemaTypes.size > 1) { + return { + oneOf: nonNullSchemas, + nullable: hasNull || undefined, + } + } + + const type = nonNullSchemas[0].type + + if (type === 'object') { + const propertyNames = new Set( + nonNullSchemas.flatMap((schema) => + Object.keys(schema.properties ?? {}) + ) + ) + + const properties = Array.from(propertyNames).reduce( + (acc, propertyName) => { + const propertySchemas = nonNullSchemas + .map((schema) => schema.properties?.[propertyName]) + .filter((schema): schema is OpenApiSchema => !!schema) + + acc[propertyName] = mergeSchemas(propertySchemas) + return acc + }, + {} as Record + ) + + const additionalPropertySchemas = nonNullSchemas + .map((schema) => schema.additionalProperties) + .filter( + (schema): schema is OpenApiSchema => typeof schema === 'object' + ) + + return { + type: 'object', + ...(Object.keys(properties).length > 0 ? { properties } : {}), + additionalProperties: + additionalPropertySchemas.length > 0 + ? mergeSchemas(additionalPropertySchemas) + : false, + nullable: hasNull || undefined, + } + } + + if (type === 'array') { + return { + type: 'array', + items: mergeSchemas( + nonNullSchemas + .map((schema) => schema.items) + .filter((schema): schema is OpenApiSchema => !!schema) + ), + nullable: hasNull || undefined, + } + } + + return { + type, + nullable: hasNull || undefined, + } +} + +const inferSchemaFromValue = (value: JsonValue): OpenApiSchema => { + if (Array.isArray(value)) { + return { + type: 'array', + items: + value.length > 0 + ? mergeSchemas( + value.map((item) => inferSchemaFromValue(item)) + ) + : { type: 'object' }, + example: value, + } + } + + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value) + const hasOnlyDynamicNumericKeys = + entries.length > 0 && entries.every(([key]) => /^\d+$/.test(key)) + + if (hasOnlyDynamicNumericKeys) { + return { + type: 'object', + additionalProperties: mergeSchemas( + entries.map(([, propertyValue]) => + inferSchemaFromValue(propertyValue) + ) + ), + example: value, + } + } + + const properties = entries.reduce((acc, [key, propertyValue]) => { + acc[key] = inferSchemaFromValue(propertyValue) + return acc + }, {} as Record) + + return { + type: 'object', + properties, + additionalProperties: false, + example: value, + } + } + + return inferPrimitiveSchema(value) +} + +export const getQueryDataSchema = (queryName: string): OpenApiSchema => { + const example = queryDataExamples[queryName] + + if (!example) { + return { + type: 'array', + items: { type: 'object' }, + } + } + + return inferSchemaFromValue(example) +} + +export const getQueryResponseExample = ( + queryName: string, + startDate: string, + endDate: string +): JsonObject | undefined => { + const data = queryDataExamples[queryName] + + if (!data) { + return undefined + } + + return { + meta: { + query: queryName, + accountId: '42', + podcastId: '42', + date: '2026-07-09 12:00:00', + startDate, + endDate, + result: 'success', + }, + data, + } +}