Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,14 @@ To avoid Sonar/ESLint code smells in `dashboard/`:

## Key Conventions

### Warnings and lint fixes (no suppressions)
### Warnings and lint fixes (suppress rarely)

**CRITICAL:** When asked to fix a compiler warning, linter finding, or static analysis issue (Detekt, ESLint, Sonar, Kotlin compiler, etc.):

- ❌ **NEVER suppress** the finding — do not use `@Suppress`, `eslint-disable`, `// NOSONAR`, `SuppressWarnings`, file- or line-level ignores, or similar to silence the tool without fixing the underlying problem.
- ❌ **Do not suppress to defer work** — do not use `@Suppress`, `eslint-disable`, `// NOSONAR`, `SuppressWarnings`, file- or line-level ignores, or similar because fixing the finding properly is too large or inconvenient right now. That is not a valid reason to silence the tool.
- ✅ **Fix the root cause** — refactor, correct types, satisfy the rule, or extract helpers so the violation is resolved properly.
- ✅ **Suppress only when it is appropriate forever** — e.g. a documented false positive, or a case where the rule genuinely does not apply on that line and a short comment explains why. **New feature code** should not ship with suppressions that are really “TODO fix later”; land the feature clean or fix the underlying issue before merge.
- ✅ **When the rule genuinely does not fit the file** — e.g. a demo data seeder may trigger hundreds of “magic number” findings: those literals *are* the seed data, not something that should become hundreds of named constants in one file. Prefer **configuration** (exclude that file or path in `detekt.yml`, ESLint `overrides`, etc.) so the tool skips that context, rather than line-level suppressions or a wall of constants that add no clarity.

If a rule is wrong for the whole project, **adjust the tool configuration** (e.g. `detekt.yml`, ESLint config) deliberately — do not mask individual violations with suppressions.

Expand Down
9 changes: 8 additions & 1 deletion backend/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ comments:
# These can be enabled incrementally as the codebase is cleaned up.
style:
MagicNumber:
active: false
active: true
excludes:
- '**/test/**'
- '**/integrationTest/**'
- '**/DemoDataReseeder.kt'
ignorePropertyDeclaration: true
ignoreEnums: true
ignoreRanges: true
WildcardImport:
active: false
ReturnCount:
Expand Down
4 changes: 3 additions & 1 deletion backend/src/main/kotlin/com/moneat/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import io.ktor.server.netty.EngineMain
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger

private const val STACK_TRACE_DEPTH = 20

fun main(args: Array<String>) {
// Add JVM shutdown hook to log when shutdown is triggered
Runtime.getRuntime().addShutdownHook(
Expand Down Expand Up @@ -70,7 +72,7 @@ fun Application.module() {
// Log stack trace when shutdown is triggered to identify the cause
monitor.subscribe(ApplicationStopping) {
log.warn("APPLICATION STOPPING - Stack trace to identify trigger:")
Thread.currentThread().stackTrace.take(20).forEach { frame ->
Thread.currentThread().stackTrace.take(STACK_TRACE_DEPTH).forEach { frame ->
log.warn(" at $frame")
}
}
Expand Down
12 changes: 8 additions & 4 deletions backend/src/main/kotlin/com/moneat/ai/AiChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,22 @@ private val aiChatResponseJson = Json { ignoreUnknownKeys = true }
*/
class AiChatService {

companion object {
private const val MAX_USER_INPUT_LENGTH = 4000
private const val MAX_HISTORY_MESSAGES = 20
}

suspend fun chat(userId: Int, orgId: Int, request: ChatRequest): ChatApiResponse {
return unavailable(
operation = "chat",
userId = userId,
orgId = orgId,
context = "message=${request.message.take(50)}",
context = "messageLength=${request.message.length}",
)
}

/** Shared helpers for AI message shaping; `internal` for unit tests and enterprise module reuse. */
internal fun sanitizeUserInput(input: String): String {
val maxLength = 4000
val injectionPatterns = listOf(
Regex("ignore previous instructions", RegexOption.IGNORE_CASE),
Regex("system:", RegexOption.IGNORE_CASE),
Expand All @@ -49,7 +53,7 @@ class AiChatService {
for (pattern in injectionPatterns) {
sanitized = pattern.replace(sanitized, "")
}
return sanitized.take(maxLength)
return sanitized.take(MAX_USER_INPUT_LENGTH)
}

internal fun buildOpenAiMessages(
Expand All @@ -59,7 +63,7 @@ class AiChatService {
): List<OpenAiMessage> {
val systemContent = "$systemPrompt\n\n== API DOCUMENTATION ==\n$docBlock"
val systemMessage = OpenAiMessage(role = "system", content = systemContent)
val recentHistory = history.takeLast(20)
val recentHistory = history.takeLast(MAX_HISTORY_MESSAGES)
return listOf(systemMessage) + recentHistory
}

Expand Down
12 changes: 9 additions & 3 deletions backend/src/main/kotlin/com/moneat/ai/OpenAiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ sealed class OpenAiError(override val message: String) : Exception(message) {
}

object OpenAiClient {
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_TOO_MANY_REQUESTS = 429
private const val HTTP_CLIENT_ERROR_MIN = 400
private const val HTTP_CLIENT_ERROR_MAX = 499

private val json = Json { ignoreUnknownKeys = true }

private val client =
Expand Down Expand Up @@ -92,9 +97,10 @@ object OpenAiClient {
if (response.status != HttpStatusCode.OK) {
logger.error { "OpenAI API error (${response.status}): $body" }
throw when (response.status.value) {
401 -> OpenAiError.AuthenticationError("Invalid or expired API key")
429 -> OpenAiError.RateLimitError("Rate limit exceeded — please try again later")
in 400..499 -> OpenAiError.ModelError("OpenAI request error (${response.status.value}): $body")
HTTP_UNAUTHORIZED -> OpenAiError.AuthenticationError("Invalid or expired API key")
HTTP_TOO_MANY_REQUESTS -> OpenAiError.RateLimitError("Rate limit exceeded — please try again later")
in HTTP_CLIENT_ERROR_MIN..HTTP_CLIENT_ERROR_MAX ->
OpenAiError.ModelError("OpenAI request error (${response.status.value}): $body")
else -> OpenAiError.ServerError("OpenAI server error (${response.status.value})")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ import java.time.format.DateTimeParseException

private val logger = KotlinLogging.logger {}

private const val PERIOD_7D_OFFSET_DAYS = 6L
private const val PERIOD_30D_OFFSET_DAYS = 29L
private const val PERIOD_6MO_MONTHS = 6L
private const val PERIOD_12MO_MONTHS = 12L
private const val FILTER_PARTS_COUNT = 3

/**
* Authenticated dashboard API routes for product analytics.
* All endpoints are under /v1/analytics/{projectId}/...
Expand Down Expand Up @@ -253,11 +259,11 @@ private fun parseDateRange(call: io.ktor.server.application.ApplicationCall): Pa

return when (period) {
"today" -> now to now
"7d" -> now.minusDays(6) to now
"30d" -> now.minusDays(29) to now
"7d" -> now.minusDays(PERIOD_7D_OFFSET_DAYS) to now
"30d" -> now.minusDays(PERIOD_30D_OFFSET_DAYS) to now
"month" -> now.withDayOfMonth(1) to now
"6mo" -> now.minusMonths(6) to now
"12mo" -> now.minusMonths(12) to now
"6mo" -> now.minusMonths(PERIOD_6MO_MONTHS) to now
"12mo" -> now.minusMonths(PERIOD_12MO_MONTHS) to now
"custom" -> {
val from = call.request.queryParameters["from"] ?: call.request.queryParameters["date_from"]
val to = call.request.queryParameters["to"] ?: call.request.queryParameters["date_to"]
Expand All @@ -268,7 +274,7 @@ private fun parseDateRange(call: io.ktor.server.application.ApplicationCall): Pa
null
}
}
else -> now.minusDays(29) to now
else -> now.minusDays(PERIOD_30D_OFFSET_DAYS) to now
}
}

Expand Down Expand Up @@ -300,7 +306,7 @@ private fun parseFilters(call: io.ktor.server.application.ApplicationCall): List
?: emptyList()
return rawFilters.mapNotNull { filterStr ->
val parts = filterStr.split(":", limit = 3)
if (parts.size == 3) {
if (parts.size == FILTER_PARTS_COUNT) {
AnalyticsFilter(parts[0], parts[1], parts[2])
} else {
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ class AnalyticsIngestionWorker(
val response = ClickHouseClient.execute(sql)
val body = response.bodyAsText()
if (response.isClickHouseError(body)) {
throw IOException("ClickHouse insert failed: ${body.take(500)}")
throw IOException("ClickHouse insert failed: ${body.take(ERROR_BODY_MAX_LENGTH)}")
}
}

Expand All @@ -168,6 +168,7 @@ class AnalyticsIngestionWorker(
private const val DEFAULT_WORKER_COUNT = 2
private const val BRPOP_TIMEOUT = 5L
private const val ERROR_BACKOFF_MS = 1000L
private const val ERROR_BODY_MAX_LENGTH = 500

fun escapeCH(s: String): String = ClickHouseSqlUtils.escapeSql(s)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import java.time.format.DateTimeFormatter
private val logger = KotlinLogging.logger {}
private val jsonParser = Json { ignoreUnknownKeys = true }

private const val ERROR_TRUNCATE_LENGTH = 500
private const val PERCENTAGE_MULTIPLIER = 100
private const val DEFAULT_LIMIT = 100

/**
* Query builder for analytics dashboard endpoints.
* Builds ClickHouse SQL queries with filters, date ranges, and comparison periods.
Expand Down Expand Up @@ -159,7 +163,7 @@ class AnalyticsService {
dateTo: LocalDate,
filters: List<AnalyticsFilter>,
dimension: String,
limit: Int = 100,
limit: Int = DEFAULT_LIMIT,
): BreakdownResponse {
val (column, table, alias) = resolveDimension(dimension)
val timeColumn = if (alias == "s") "hour" else "timestamp"
Expand Down Expand Up @@ -195,23 +199,23 @@ class AnalyticsService {
dateFrom: LocalDate,
dateTo: LocalDate,
filters: List<AnalyticsFilter>,
limit: Int = 100,
limit: Int = DEFAULT_LIMIT,
): BreakdownResponse = getBreakdown(projectId, dateFrom, dateTo, filters, "pathname", limit)

suspend fun getEntryPages(
projectId: Long,
dateFrom: LocalDate,
dateTo: LocalDate,
filters: List<AnalyticsFilter>,
limit: Int = 100,
limit: Int = DEFAULT_LIMIT,
): BreakdownResponse = getBreakdown(projectId, dateFrom, dateTo, filters, "entry_page", limit)

suspend fun getExitPages(
projectId: Long,
dateFrom: LocalDate,
dateTo: LocalDate,
filters: List<AnalyticsFilter>,
limit: Int = 100,
limit: Int = DEFAULT_LIMIT,
): BreakdownResponse = getBreakdown(projectId, dateFrom, dateTo, filters, "exit_page", limit)

// --- Realtime ---
Expand All @@ -221,7 +225,7 @@ class AnalyticsService {
val count = suspendRunCatching {
RedisConfig.sync().pfcount(key)
}.getOrElse { e ->
val errorMsg = e.toString().take(500)
val errorMsg = e.toString().take(ERROR_TRUNCATE_LENGTH)
logger.debug { "Failed to read realtime counter: $errorMsg" }
0L
}
Expand Down Expand Up @@ -284,7 +288,7 @@ class AnalyticsService {
step
} else {
val prev = funnelSteps[i - 1].visitors
val dropoff = if (prev > 0) ((prev - step.visitors).toDouble() / prev * 100) else 0.0
val dropoff = if (prev > 0) ((prev - step.visitors).toDouble() / prev * PERCENTAGE_MULTIPLIER) else 0.0
step.copy(dropoff = dropoff)
}
}
Expand All @@ -298,7 +302,7 @@ class AnalyticsService {
dateFrom: LocalDate,
dateTo: LocalDate,
filters: List<AnalyticsFilter>,
limit: Int = 100,
limit: Int = DEFAULT_LIMIT,
): BreakdownResponse {
val where = buildWhere(projectId, dateFrom, dateTo, filters, "e", "timestamp")
val sql = """
Expand Down
3 changes: 2 additions & 1 deletion backend/src/main/kotlin/com/moneat/auth/routes/AuthRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import com.moneat.utils.suspendRunCatching
private val logger = KotlinLogging.logger {}
private const val GITHUB_OAUTH_CALLBACK_ERROR = "GitHub OAuth callback error"
private const val APPLE_OAUTH_CALLBACK_ERROR = "Apple OAuth callback error"
private const val USER_AGENT_MAX_LENGTH = 2048

fun Route.authRoutes(
authService: AuthService = GlobalContext.get().get(),
Expand Down Expand Up @@ -112,7 +113,7 @@ private suspend fun io.ktor.server.routing.RoutingContext.handleSignup(authServi
call.request.headers["User-Agent"]
?.trim()
?.takeIf { it.isNotBlank() }
?.take(2048)
?.take(USER_AGENT_MAX_LENGTH)
val context =
SignupRequestContext(
ipAddress = cfConnectingIp ?: forwardedFor ?: remoteHost,
Expand Down
Loading
Loading