mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #91 from ashim-hq/feat/analytics
feat: production-grade opt-in product analytics with PostHog and Sentry
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
- **REST API** - Every tool available via API with API key auth. Interactive docs at `/api/docs`
|
||||
- **Single container** - One `docker run`, no Redis, no Postgres, no external services
|
||||
- **Multi-arch** - Runs on AMD64 and ARM64 (Intel, Apple Silicon, Raspberry Pi)
|
||||
- **Your data stays yours** - No telemetry, no tracking, no external calls. Images never leave your machine
|
||||
- **Privacy first** - Your images never leave your machine. ashim asks once whether you'd like to share anonymous product analytics (which tools are used, errors encountered — never file data). Change anytime in Settings, or set `ANALYTICS_ENABLED=false` to disable completely
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users ADD COLUMN analytics_enabled integer;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE users ADD COLUMN analytics_consent_shown_at integer;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE users ADD COLUMN analytics_consent_remind_at integer;
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1745366600000,
|
||||
"tag": "0008_api_key_expiration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1776855591000,
|
||||
"tag": "0009_analytics_consent",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,15 +12,16 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ashim/ai": "workspace:*",
|
||||
"@ashim/image-engine": "workspace:*",
|
||||
"@ashim/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/multipart": "^9.0.0",
|
||||
"@fastify/rate-limit": "^10.2.0",
|
||||
"@fastify/static": "^8.1.0",
|
||||
"@neplex/vectorizer": "^0.0.5",
|
||||
"@scalar/fastify-api-reference": "^1.49.5",
|
||||
"@ashim/ai": "workspace:*",
|
||||
"@ashim/image-engine": "workspace:*",
|
||||
"@ashim/shared": "workspace:*",
|
||||
"@sentry/node": "^10.49.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"dotenv": "^16.4.0",
|
||||
@@ -33,6 +34,7 @@
|
||||
"p-queue": "^9.1.0",
|
||||
"pdfkit": "^0.18.0",
|
||||
"piscina": "^5.1.4",
|
||||
"posthog-node": "^5.29.5",
|
||||
"potrace": "^2.1.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.33.0",
|
||||
|
||||
@@ -13,6 +13,9 @@ export const users = sqliteTable("users", {
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
analyticsEnabled: integer("analytics_enabled", { mode: "boolean" }),
|
||||
analyticsConsentShownAt: integer("analytics_consent_shown_at", { mode: "timestamp" }),
|
||||
analyticsConsentRemindAt: integer("analytics_consent_remind_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
export const teams = sqliteTable("teams", {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isGpuAvailable } from "@ashim/ai";
|
||||
import { APP_VERSION } from "@ashim/shared";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { eq } from "drizzle-orm";
|
||||
import Fastify from "fastify";
|
||||
import { env } from "./config.js";
|
||||
import { db, schema } from "./db/index.js";
|
||||
import { runMigrations } from "./db/migrate.js";
|
||||
import { initAnalytics, shutdownAnalytics } from "./lib/analytics.js";
|
||||
import { startCleanupCron } from "./lib/cleanup.js";
|
||||
import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js";
|
||||
import { shutdownWorkerPool } from "./lib/worker-pool.js";
|
||||
@@ -13,6 +16,7 @@ import { requirePermission } from "./permissions.js";
|
||||
import { authMiddleware, authRoutes, ensureDefaultAdmin } from "./plugins/auth.js";
|
||||
import { registerStatic } from "./plugins/static.js";
|
||||
import { registerUpload } from "./plugins/upload.js";
|
||||
import { analyticsRoutes } from "./routes/analytics.js";
|
||||
import { apiKeyRoutes } from "./routes/api-keys.js";
|
||||
import { auditLogRoutes } from "./routes/audit-log.js";
|
||||
import { registerBatchRoutes } from "./routes/batch.js";
|
||||
@@ -35,6 +39,20 @@ console.log("Database initialized");
|
||||
// Create default admin user if no users exist
|
||||
await ensureDefaultAdmin();
|
||||
|
||||
function ensureInstanceId() {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "instance_id"))
|
||||
.get();
|
||||
if (!existing) {
|
||||
db.insert(schema.settings).values({ key: "instance_id", value: randomUUID() }).run();
|
||||
}
|
||||
}
|
||||
|
||||
ensureInstanceId();
|
||||
initAnalytics();
|
||||
|
||||
// Mark any jobs left in processing/queued from a previous unclean shutdown
|
||||
recoverStaleJobs();
|
||||
|
||||
@@ -55,6 +73,12 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) =>
|
||||
{ err: error, url: request.url, method: request.method },
|
||||
"Unhandled request error",
|
||||
);
|
||||
try {
|
||||
const Sentry = require("@sentry/node") as typeof import("@sentry/node");
|
||||
Sentry.captureException(error);
|
||||
} catch {
|
||||
// Sentry not available
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
reply.status(statusCode).send({
|
||||
error: statusCode >= 500 ? "Internal server error" : error.message,
|
||||
@@ -128,6 +152,9 @@ await apiKeyRoutes(app);
|
||||
// Settings routes
|
||||
await settingsRoutes(app);
|
||||
|
||||
// Analytics config and consent routes
|
||||
await analyticsRoutes(app);
|
||||
|
||||
// Feature management routes (AI feature bundle install/uninstall)
|
||||
await registerFeatureRoutes(app);
|
||||
|
||||
@@ -258,6 +285,13 @@ async function shutdown(signal: string) {
|
||||
// AI package may not be available
|
||||
}
|
||||
|
||||
try {
|
||||
await shutdownAnalytics();
|
||||
console.log("Analytics flushed");
|
||||
} catch {
|
||||
// analytics shutdown is best-effort
|
||||
}
|
||||
|
||||
try {
|
||||
const { sqlite: sqliteConn } = await import("./db/index.js");
|
||||
sqliteConn.close();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { getAuthUser } from "../plugins/auth.js";
|
||||
|
||||
const FILE_EXT_PATTERN =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|he[ic]f?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi;
|
||||
const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai)\//g;
|
||||
|
||||
let posthogClient: import("posthog-node").PostHog | null = null;
|
||||
|
||||
export function initAnalytics(): void {
|
||||
if (!env.ANALYTICS_ENABLED || !env.POSTHOG_API_KEY) return;
|
||||
|
||||
try {
|
||||
const { PostHog } = require("posthog-node") as typeof import("posthog-node");
|
||||
posthogClient = new PostHog(env.POSTHOG_API_KEY, {
|
||||
host: env.POSTHOG_HOST,
|
||||
flushAt: 20,
|
||||
flushInterval: 30000,
|
||||
});
|
||||
} catch {
|
||||
// posthog-node not available — analytics disabled
|
||||
}
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
try {
|
||||
const Sentry = require("@sentry/node") as typeof import("@sentry/node");
|
||||
Sentry.init({
|
||||
dsn: env.SENTRY_DSN,
|
||||
sendDefaultPii: false,
|
||||
beforeSend(event) {
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.username;
|
||||
}
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
if (ex.value) {
|
||||
ex.value = ex.value
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
if (ex.stacktrace?.frames) {
|
||||
for (const frame of ex.stacktrace.frames) {
|
||||
if (frame.filename) {
|
||||
frame.filename = frame.filename
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb(breadcrumb) {
|
||||
if (breadcrumb.message) {
|
||||
breadcrumb.message = breadcrumb.message
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
return breadcrumb;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// @sentry/node not available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdownAnalytics(): Promise<void> {
|
||||
if (posthogClient) {
|
||||
await posthogClient.shutdown();
|
||||
posthogClient = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const Sentry = require("@sentry/node") as typeof import("@sentry/node");
|
||||
await Sentry.close(2000);
|
||||
} catch {
|
||||
// @sentry/node not available
|
||||
}
|
||||
}
|
||||
|
||||
function getInstanceId(): string {
|
||||
const row = db.select().from(schema.settings).where(eq(schema.settings.key, "instance_id")).get();
|
||||
return row?.value ?? "unknown";
|
||||
}
|
||||
|
||||
function isUserOptedIn(userId: string): boolean {
|
||||
if (!env.ANALYTICS_ENABLED) return false;
|
||||
if (userId === "anonymous") return false;
|
||||
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||
return user?.analyticsEnabled === true;
|
||||
}
|
||||
|
||||
function isRequestOptedIn(request: FastifyRequest): boolean {
|
||||
if (!env.ANALYTICS_ENABLED) return false;
|
||||
const user = getAuthUser(request);
|
||||
if (!user) return false;
|
||||
if (user.id === "anonymous") {
|
||||
const header = request.headers["x-analytics-consent"];
|
||||
return header === "true";
|
||||
}
|
||||
return isUserOptedIn(user.id);
|
||||
}
|
||||
|
||||
function shouldSample(): boolean {
|
||||
if (env.ANALYTICS_SAMPLE_RATE >= 1.0) return true;
|
||||
if (env.ANALYTICS_SAMPLE_RATE <= 0.0) return false;
|
||||
return Math.random() < env.ANALYTICS_SAMPLE_RATE;
|
||||
}
|
||||
|
||||
export function trackEvent(
|
||||
request: FastifyRequest,
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
): void {
|
||||
if (!posthogClient || !isRequestOptedIn(request) || !shouldSample()) return;
|
||||
try {
|
||||
posthogClient.capture({
|
||||
distinctId: getInstanceId(),
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
} catch {
|
||||
// never throw from analytics
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,18 @@ const envSchema = z.object({
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
ANALYTICS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0),
|
||||
POSTHOG_API_KEY: z.string().default("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"),
|
||||
POSTHOG_HOST: z.string().default("https://us.i.posthog.com"),
|
||||
SENTRY_DSN: z
|
||||
.string()
|
||||
.default(
|
||||
"https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248",
|
||||
),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -208,6 +208,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword,
|
||||
permissions: getPermissions(user.role),
|
||||
teamName: teamRow?.name ?? user.team,
|
||||
analyticsEnabled: user.analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null,
|
||||
analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null,
|
||||
},
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
});
|
||||
@@ -255,6 +258,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
role: user.role,
|
||||
mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword,
|
||||
permissions: getPermissions(user.role),
|
||||
analyticsEnabled: user.analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null,
|
||||
analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null,
|
||||
},
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
|
||||
export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/v1/config/analytics", async () => {
|
||||
if (!env.ANALYTICS_ENABLED) {
|
||||
return {
|
||||
enabled: false,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "",
|
||||
};
|
||||
}
|
||||
|
||||
const row = db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
.where(eq(schema.settings.key, "instance_id"))
|
||||
.get();
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
posthogApiKey: env.POSTHOG_API_KEY,
|
||||
posthogHost: env.POSTHOG_HOST,
|
||||
sentryDsn: env.SENTRY_DSN,
|
||||
sampleRate: env.ANALYTICS_SAMPLE_RATE,
|
||||
instanceId: row?.value ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
app.put("/api/v1/user/analytics", async (request, reply) => {
|
||||
const user = requireAuth(request, reply);
|
||||
if (!user) return;
|
||||
|
||||
const body = request.body as {
|
||||
enabled?: boolean;
|
||||
remindLater?: boolean;
|
||||
} | null;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (body?.remindLater) {
|
||||
const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
db.update(schema.users)
|
||||
.set({
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: remindAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.users.id, user.id))
|
||||
.run();
|
||||
return reply.send({ ok: true, analyticsEnabled: null });
|
||||
}
|
||||
|
||||
const enabled = body?.enabled === true;
|
||||
db.update(schema.users)
|
||||
.set({
|
||||
analyticsEnabled: enabled,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.users.id, user.id))
|
||||
.run();
|
||||
return reply.send({ ok: true, analyticsEnabled: enabled });
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import crypto from "node:crypto";
|
||||
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { shutdownDispatcher } from "@ashim/ai";
|
||||
import { FEATURE_BUNDLES } from "@ashim/shared";
|
||||
import { ANALYTICS_EVENTS, FEATURE_BUNDLES } from "@ashim/shared";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { trackEvent } from "../lib/analytics.js";
|
||||
import {
|
||||
acquireInstallLock,
|
||||
getAiDir,
|
||||
@@ -133,6 +134,9 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
const manifestPath = getManifestPath();
|
||||
const modelsDir = getModelsDir();
|
||||
|
||||
const installStartTime = Date.now();
|
||||
const reqRef = request;
|
||||
|
||||
const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
@@ -192,6 +196,11 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
shutdownDispatcher();
|
||||
setInstallProgress(null, null, null);
|
||||
updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" });
|
||||
trackEvent(reqRef, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, {
|
||||
bundle_id: bundleId,
|
||||
action: "installed",
|
||||
duration_ms: Date.now() - installStartTime,
|
||||
});
|
||||
} else {
|
||||
const errorDetail =
|
||||
lastStderrLines.filter((l) => !l.startsWith("{")).join("\n") || stdoutBuffer.trim();
|
||||
@@ -264,6 +273,12 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
markUninstalled(bundleId);
|
||||
shutdownDispatcher();
|
||||
|
||||
trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, {
|
||||
bundle_id: bundleId,
|
||||
action: "uninstalled",
|
||||
duration_ms: 0,
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared";
|
||||
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared";
|
||||
import archiver from "archiver";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
@@ -17,6 +17,7 @@ import PQueue from "p-queue";
|
||||
import { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { trackEvent } from "../lib/analytics.js";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { resolveConcurrency } from "../lib/env.js";
|
||||
import { formatZodErrors } from "../lib/errors.js";
|
||||
@@ -204,6 +205,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
}
|
||||
|
||||
// Execute the pipeline: pass the buffer through each step sequentially
|
||||
const startTime = Date.now();
|
||||
let currentBuffer = fileBuffer;
|
||||
let currentFilename = filename;
|
||||
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
|
||||
@@ -244,6 +246,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Pipeline processing failed";
|
||||
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
|
||||
step_count: pipeline.steps.length,
|
||||
tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId),
|
||||
is_batch: false,
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: "failed",
|
||||
});
|
||||
return reply.status(422).send({
|
||||
error: message,
|
||||
completedSteps: stepResults,
|
||||
@@ -260,6 +269,14 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
const inputPath = join(workspacePath, "input", filename);
|
||||
await writeFile(inputPath, fileBuffer);
|
||||
|
||||
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
|
||||
step_count: pipeline.steps.length,
|
||||
tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId),
|
||||
is_batch: false,
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(currentFilename)}`,
|
||||
@@ -508,6 +525,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
}
|
||||
|
||||
// ── Progress tracking ────────────────────────────────────────────
|
||||
const batchStartTime = Date.now();
|
||||
const jobId = clientJobId || randomUUID();
|
||||
|
||||
const progress: JobProgress = {
|
||||
@@ -651,12 +669,29 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
||||
|
||||
// If every file failed, return an error instead of an empty ZIP
|
||||
if (progress.status === "failed") {
|
||||
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
|
||||
step_count: pipeline.steps.length,
|
||||
tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId),
|
||||
is_batch: true,
|
||||
file_count: files.length,
|
||||
duration_ms: Date.now() - batchStartTime,
|
||||
status: "failed",
|
||||
});
|
||||
return reply.status(422).send({
|
||||
error: "All files failed processing",
|
||||
errors: progress.errors,
|
||||
});
|
||||
}
|
||||
|
||||
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
|
||||
step_count: pipeline.steps.length,
|
||||
tool_ids: pipeline.steps.map((s: { toolId: string }) => s.toolId),
|
||||
is_batch: true,
|
||||
file_count: files.length,
|
||||
duration_ms: Date.now() - batchStartTime,
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// ── Stream ZIP response ──────────────────────────────────────────
|
||||
reply.hijack();
|
||||
reply.raw.writeHead(200, {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared";
|
||||
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@ashim/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import type { z } from "zod";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { trackEvent } from "../lib/analytics.js";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../lib/errors.js";
|
||||
import { isToolInstalled } from "../lib/feature-status.js";
|
||||
@@ -242,6 +243,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
}
|
||||
|
||||
// Process the image (worker thread or main thread)
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
let result: { buffer: Buffer; filename: string; contentType: string };
|
||||
|
||||
@@ -381,6 +383,14 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, {
|
||||
tool_id: config.toolId,
|
||||
status: "completed",
|
||||
duration_ms: Date.now() - startTime,
|
||||
category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown",
|
||||
is_ai_tool: getBundleForTool(config.toolId) !== null,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
||||
@@ -393,6 +403,13 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
// Catch Sharp / processing errors and return a clean API error
|
||||
const message = err instanceof Error ? err.message : "Image processing failed";
|
||||
request.log.error({ err, toolId: config.toolId }, "Tool processing failed");
|
||||
trackEvent(request, ANALYTICS_EVENTS.TOOL_USED, {
|
||||
tool_id: config.toolId,
|
||||
status: "failed",
|
||||
duration_ms: Date.now() - startTime,
|
||||
category: TOOLS.find((t) => t.id === config.toolId)?.category ?? "unknown",
|
||||
is_ai_tool: getBundleForTool(config.toolId) !== null,
|
||||
});
|
||||
return reply.status(422).send({
|
||||
error: "Processing failed",
|
||||
details: message,
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@sentry/react": "^10.49.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"jszip": "^3.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"posthog-js": "^1.370.0",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
+76
-3
@@ -1,9 +1,12 @@
|
||||
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react";
|
||||
import { APP_VERSION } from "@ashim/shared";
|
||||
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { identify, initAnalytics } from "./lib/analytics";
|
||||
import { useAnalyticsStore } from "./stores/analytics-store";
|
||||
|
||||
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
||||
// downloaded when the user navigates there, shrinking the main bundle.
|
||||
@@ -22,6 +25,9 @@ const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default
|
||||
const PrivacyPolicyPage = lazy(() =>
|
||||
import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })),
|
||||
);
|
||||
const AnalyticsConsentPage = lazy(() =>
|
||||
import("./pages/analytics-consent-page").then((m) => ({ default: m.AnalyticsConsentPage })),
|
||||
);
|
||||
const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage })));
|
||||
|
||||
class ErrorBoundary extends Component<
|
||||
@@ -69,14 +75,43 @@ class ErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { loading, authEnabled, isAuthenticated, mustChangePassword } = useAuth();
|
||||
const {
|
||||
loading,
|
||||
authEnabled,
|
||||
isAuthenticated,
|
||||
mustChangePassword,
|
||||
analyticsEnabled,
|
||||
analyticsConsentShownAt,
|
||||
} = useAuth();
|
||||
const storeConsent = useAnalyticsStore((s) => s.consent);
|
||||
const setStoreConsent = useAnalyticsStore((s) => s.setConsent);
|
||||
const location = useLocation();
|
||||
|
||||
// Hydrate the analytics store from session data on initial load.
|
||||
// Only hydrate if the store is still in its initial state (user hasn't taken
|
||||
// an explicit action like accepting/declining on the consent page).
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loading &&
|
||||
analyticsEnabled !== undefined &&
|
||||
storeConsent.analyticsConsentShownAt === null &&
|
||||
storeConsent.analyticsEnabled === null
|
||||
) {
|
||||
setStoreConsent({
|
||||
analyticsEnabled: analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt: analyticsConsentShownAt ?? null,
|
||||
analyticsConsentRemindAt: null,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line -- only hydrate on session load, not on store changes
|
||||
}, [loading, analyticsEnabled, analyticsConsentShownAt, setStoreConsent]);
|
||||
|
||||
// Don't guard the login or change-password pages
|
||||
if (
|
||||
location.pathname === "/login" ||
|
||||
location.pathname === "/change-password" ||
|
||||
location.pathname === "/privacy"
|
||||
location.pathname === "/privacy" ||
|
||||
location.pathname === "/analytics-consent"
|
||||
) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -101,6 +136,15 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
return <Navigate to="/change-password" replace />;
|
||||
}
|
||||
|
||||
// Check both session state (useAuth) and real-time store state.
|
||||
// After the consent page calls acceptAnalytics(), the store updates immediately
|
||||
// but useAuth won't re-fetch until the next session check.
|
||||
const effectiveEnabled = storeConsent.analyticsEnabled ?? analyticsEnabled;
|
||||
const effectiveShownAt = storeConsent.analyticsConsentShownAt ?? analyticsConsentShownAt;
|
||||
if (authEnabled && effectiveEnabled === null && effectiveShownAt === null) {
|
||||
return <Navigate to="/analytics-consent" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -114,6 +158,34 @@ function PageLoader() {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
||||
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
|
||||
const fetchAnalyticsConfig = useAnalyticsStore((s) => s.fetchConfig);
|
||||
const analyticsConsent = useAnalyticsStore((s) => s.consent);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalyticsConfig();
|
||||
}, [fetchAnalyticsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (analyticsConfigLoaded && analyticsConfig?.enabled) {
|
||||
initAnalytics(analyticsConfig);
|
||||
}
|
||||
}, [analyticsConfigLoaded, analyticsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!analyticsConfigLoaded ||
|
||||
!analyticsConfig?.enabled ||
|
||||
analyticsConsent.analyticsEnabled !== true
|
||||
)
|
||||
return;
|
||||
identify(analyticsConfig.instanceId, {
|
||||
$set: { version: APP_VERSION },
|
||||
$set_once: { instance_id: analyticsConfig.instanceId },
|
||||
});
|
||||
}, [analyticsConfigLoaded, analyticsConfig, analyticsConsent.analyticsEnabled]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ConnectionMonitor />
|
||||
@@ -137,6 +209,7 @@ export function App() {
|
||||
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { GemLogo } from "../common/gem-logo";
|
||||
import { AiFeaturesSection } from "./ai-features-section";
|
||||
@@ -50,6 +51,7 @@ type Section =
|
||||
| "api-keys"
|
||||
| "ai-features"
|
||||
| "tools"
|
||||
| "analytics"
|
||||
| "about";
|
||||
|
||||
interface NavItem {
|
||||
@@ -70,6 +72,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||
{ id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" },
|
||||
{ id: "tools", label: "Tools", icon: Wrench },
|
||||
{ id: "analytics", label: "Product Analytics", icon: Eye },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
];
|
||||
|
||||
@@ -147,6 +150,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
{section === "api-keys" && <ApiKeysSection />}
|
||||
{section === "ai-features" && <AiFeaturesSection />}
|
||||
{section === "tools" && <ToolsSection />}
|
||||
{section === "analytics" && <AnalyticsSection />}
|
||||
{section === "about" && <AboutSection />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2428,6 +2432,69 @@ function ToolsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────── Analytics ────────────────────── */
|
||||
|
||||
function AnalyticsSection() {
|
||||
const { consent, config, configLoaded, fetchConfig, toggleAnalytics } = useAnalyticsStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
if (!configLoaded) return null;
|
||||
|
||||
const disabled = !config?.enabled;
|
||||
const enabled = consent.analyticsEnabled === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Product Analytics</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Share anonymous usage data to help improve ashim.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Your images never leave your machine.</p>
|
||||
</div>
|
||||
|
||||
{disabled ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Product analytics has been disabled by the server administrator.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">
|
||||
{enabled ? "Analytics enabled" : "Analytics disabled"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAnalytics(!enabled)}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
enabled ? "bg-primary" : "bg-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
enabled ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────── About ────────────────────── */
|
||||
|
||||
function AboutSection() {
|
||||
|
||||
@@ -9,6 +9,9 @@ interface AuthState {
|
||||
mustChangePassword: boolean;
|
||||
role: string | null;
|
||||
permissions: string[];
|
||||
analyticsEnabled: boolean | null;
|
||||
analyticsConsentShownAt: number | null;
|
||||
analyticsConsentRemindAt: number | null;
|
||||
}
|
||||
|
||||
const USER_PERMISSIONS = [
|
||||
@@ -27,6 +30,9 @@ export function useAuth() {
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,6 +52,9 @@ export function useAuth() {
|
||||
mustChangePassword: false,
|
||||
role: "user",
|
||||
permissions: USER_PERMISSIONS,
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -60,6 +69,9 @@ export function useAuth() {
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -79,6 +91,9 @@ export function useAuth() {
|
||||
mustChangePassword: mustChange,
|
||||
role: session.user?.role ?? null,
|
||||
permissions: session.user?.permissions ?? [],
|
||||
analyticsEnabled: session.user?.analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt: session.user?.analyticsConsentShownAt ?? null,
|
||||
analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null,
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem("ashim-token");
|
||||
@@ -90,6 +105,9 @@ export function useAuth() {
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { AnalyticsConfig } from "@ashim/shared";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import posthogJs from "posthog-js";
|
||||
|
||||
let posthog: import("posthog-js").PostHog | null = null;
|
||||
let initialized = false;
|
||||
let consentGranted = false;
|
||||
|
||||
const FILE_EXT_PATTERN =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|he[ic]f?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi;
|
||||
const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai|Users|home)\//g;
|
||||
|
||||
function scrubString(str: string): string {
|
||||
return str.replace(FILE_EXT_PATTERN, ".[REDACTED]").replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
|
||||
export function initAnalytics(config: AnalyticsConfig): void {
|
||||
if (initialized || !config.enabled) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
posthog =
|
||||
posthogJs.init(config.posthogApiKey, {
|
||||
api_host: config.posthogHost,
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
disable_session_recording: true,
|
||||
session_recording: {
|
||||
captureCanvas: { recordCanvas: false },
|
||||
maskAllInputs: true,
|
||||
maskTextSelector: ".file-name, .file-path, [data-file-name]",
|
||||
blockSelector: "[data-user-content]",
|
||||
},
|
||||
ip: false,
|
||||
persistence: "localStorage",
|
||||
}) ?? null;
|
||||
} catch {
|
||||
// SDK blocked or unavailable — use null provider
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.sentryDsn) {
|
||||
Sentry.init({
|
||||
dsn: config.sentryDsn,
|
||||
sendDefaultPii: false,
|
||||
beforeSend(event) {
|
||||
if (!consentGranted) return null;
|
||||
startErrorReplay();
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.username;
|
||||
}
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
if (ex.value) ex.value = scrubString(ex.value);
|
||||
if (ex.stacktrace?.frames) {
|
||||
for (const frame of ex.stacktrace.frames) {
|
||||
if (frame.filename) frame.filename = scrubString(frame.filename);
|
||||
if (frame.abs_path) frame.abs_path = scrubString(frame.abs_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb(breadcrumb) {
|
||||
if (!consentGranted) return null;
|
||||
if (breadcrumb.category === "ui.click") return null;
|
||||
if (breadcrumb.category === "fetch" && breadcrumb.data?.url) {
|
||||
if (FILE_EXT_PATTERN.test(breadcrumb.data.url as string)) return null;
|
||||
}
|
||||
if (breadcrumb.message) {
|
||||
breadcrumb.message = scrubString(breadcrumb.message);
|
||||
}
|
||||
return breadcrumb;
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Sentry blocked or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function setAnalyticsConsent(enabled: boolean): void {
|
||||
consentGranted = enabled;
|
||||
}
|
||||
|
||||
export function identify(instanceId: string, properties: Record<string, unknown>): void {
|
||||
if (!posthog || !consentGranted) return;
|
||||
try {
|
||||
posthog.identify(instanceId, properties);
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
export function track(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!posthog || !consentGranted) return;
|
||||
try {
|
||||
posthog.capture(event, properties);
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
export function startErrorReplay(): void {
|
||||
if (!posthog || !consentGranted) return;
|
||||
try {
|
||||
posthog.startSessionRecording();
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,16 @@ export function formatHeaders(init?: HeadersInit): Headers {
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const consent = localStorage.getItem("ashim-analytics-consent");
|
||||
if (consent === "true" || consent === "false") {
|
||||
headers.set("X-Analytics-Consent", consent);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { en } from "@ashim/shared";
|
||||
import { Shield } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
|
||||
const t = en.analytics;
|
||||
|
||||
export function AnalyticsConsentPage() {
|
||||
const navigate = useNavigate();
|
||||
const { config, configLoaded, fetchConfig, acceptAnalytics, remindLater } = useAnalyticsStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoaded && !config?.enabled) {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [configLoaded, config, navigate]);
|
||||
|
||||
if (!configLoaded) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAccept = async () => {
|
||||
await acceptAnalytics();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
await remindLater();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-lg space-y-6 rounded-2xl border border-border bg-card p-8 shadow-lg">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-xl font-semibold text-foreground">{t.consentTitle}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t.consentDescription}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm font-medium text-foreground">{t.consentPrivacy}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<p className="mb-2 font-medium text-foreground">{t.whatShared}</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
{t.whatSharedItems.map((item) => (
|
||||
<li key={item} className="flex items-start gap-1.5">
|
||||
<span className="mt-1 text-xs">·</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-medium text-foreground">{t.whatNever}</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
{t.whatNeverItems.map((item) => (
|
||||
<li key={item} className="flex items-start gap-1.5">
|
||||
<span className="mt-1 text-xs">·</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t.consentProviders}
|
||||
<br />
|
||||
{t.consentChangeable}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
className="flex-1 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{t.acceptButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecline}
|
||||
className="flex-1 rounded-lg border border-border px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{t.declineButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { CategoryInfo, Tool } from "@ashim/shared";
|
||||
import { CATEGORIES, TOOLS } from "@ashim/shared";
|
||||
import { ANALYTICS_EVENTS, CATEGORIES, TOOLS } from "@ashim/shared";
|
||||
import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { GemLogo } from "@/components/common/gem-logo";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -51,6 +52,17 @@ export function FullscreenGridPage() {
|
||||
);
|
||||
}, [search, visibleTools]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) return;
|
||||
const timer = setTimeout(() => {
|
||||
track(ANALYTICS_EVENTS.SEARCH, {
|
||||
query: search,
|
||||
results_count: filteredTools.length,
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, filteredTools.length]);
|
||||
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups = new Map<string, Tool[]>();
|
||||
for (const tool of filteredTools) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export function PrivacyPolicyPage() {
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-2">Privacy Policy</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8">Last updated: March 29, 2026</p>
|
||||
<p className="text-sm text-muted-foreground mb-8">Last updated: April 22, 2026</p>
|
||||
|
||||
<div className="space-y-6 text-sm leading-relaxed text-muted-foreground">
|
||||
<section>
|
||||
@@ -37,11 +37,52 @@ export function PrivacyPolicyPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">No Tracking or Analytics</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">Product Analytics</h2>
|
||||
<p>
|
||||
ashim does not include any telemetry, analytics, or tracking. No data is collected
|
||||
about your usage patterns, and no information is sent to ashim developers or any third
|
||||
party. There are no cookies used for tracking purposes.
|
||||
ashim includes optional, anonymous product analytics. When you choose to participate,
|
||||
the following is collected:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<li>Which tools you use (e.g., "crop tool used")</li>
|
||||
<li>Error reports without file data</li>
|
||||
<li>App version and performance metrics</li>
|
||||
</ul>
|
||||
<p className="mt-2 font-medium">What is never collected:</p>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<li>Your images, PDFs, and files</li>
|
||||
<li>File names and contents</li>
|
||||
<li>Any personal information or IP addresses</li>
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Analytics data is sent to{" "}
|
||||
<a
|
||||
href="https://posthog.com"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
PostHog
|
||||
</a>{" "}
|
||||
(usage analytics) and{" "}
|
||||
<a
|
||||
href="https://sentry.io"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Sentry
|
||||
</a>{" "}
|
||||
(error tracking) — both open-source projects.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">Your Choice</h2>
|
||||
<p>
|
||||
Each user is asked individually on first login whether to participate. You can change
|
||||
your choice anytime in Settings. Server administrators can disable analytics entirely
|
||||
by setting{" "}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">ANALYTICS_ENABLED=false</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -56,11 +97,12 @@ export function PrivacyPolicyPage() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">No Third-Party Services</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">Third-Party Services</h2>
|
||||
<p>
|
||||
ashim does not integrate with or send data to any external services. AI-powered
|
||||
features (background removal, upscaling, OCR) run locally using bundled models. No
|
||||
cloud APIs are involved.
|
||||
All image processing happens locally — your images are never sent anywhere. If you opt
|
||||
in to product analytics, anonymous usage data is sent to PostHog and Sentry as
|
||||
described above. AI-powered features run locally using bundled models. No other
|
||||
external services are contacted.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { AnalyticsConfig, ConsentState } from "@ashim/shared";
|
||||
import { create } from "zustand";
|
||||
import { setAnalyticsConsent } from "@/lib/analytics";
|
||||
import { apiPut } from "@/lib/api";
|
||||
|
||||
interface AnalyticsState {
|
||||
config: AnalyticsConfig | null;
|
||||
consent: ConsentState;
|
||||
configLoaded: boolean;
|
||||
fetchConfig: () => Promise<void>;
|
||||
setConsent: (consent: ConsentState) => void;
|
||||
acceptAnalytics: () => Promise<void>;
|
||||
declineAnalytics: () => Promise<void>;
|
||||
remindLater: () => Promise<void>;
|
||||
toggleAnalytics: (enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAnalyticsStore = create<AnalyticsState>((set, get) => ({
|
||||
config: null,
|
||||
consent: {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
configLoaded: false,
|
||||
|
||||
fetchConfig: async () => {
|
||||
if (get().configLoaded) return;
|
||||
try {
|
||||
const res = await fetch("/api/v1/config/analytics");
|
||||
const config: AnalyticsConfig = await res.json();
|
||||
set({ config, configLoaded: true });
|
||||
} catch {
|
||||
set({ configLoaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
setConsent: (consent: ConsentState) => {
|
||||
set({ consent });
|
||||
setAnalyticsConsent(consent.analyticsEnabled === true);
|
||||
},
|
||||
|
||||
acceptAnalytics: async () => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { enabled: true });
|
||||
} catch {
|
||||
localStorage.setItem("ashim-analytics-consent", "true");
|
||||
}
|
||||
const now = Date.now();
|
||||
const consent: ConsentState = {
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
set({ consent });
|
||||
setAnalyticsConsent(true);
|
||||
},
|
||||
|
||||
declineAnalytics: async () => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { enabled: false });
|
||||
} catch {
|
||||
localStorage.setItem("ashim-analytics-consent", "false");
|
||||
}
|
||||
const now = Date.now();
|
||||
const consent: ConsentState = {
|
||||
analyticsEnabled: false,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
set({ consent });
|
||||
setAnalyticsConsent(false);
|
||||
},
|
||||
|
||||
remindLater: async () => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { remindLater: true });
|
||||
} catch {
|
||||
localStorage.setItem("ashim-analytics-consent", "remind");
|
||||
}
|
||||
const now = Date.now();
|
||||
const consent: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: now + 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
set({ consent });
|
||||
setAnalyticsConsent(false);
|
||||
},
|
||||
|
||||
toggleAnalytics: async (enabled: boolean) => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { enabled });
|
||||
} catch {
|
||||
localStorage.setItem("ashim-analytics-consent", enabled ? "true" : "false");
|
||||
}
|
||||
set((state) => ({
|
||||
consent: { ...state.consent, analyticsEnabled: enabled },
|
||||
}));
|
||||
setAnalyticsConsent(enabled);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ConsentState } from "./types.js";
|
||||
|
||||
export function shouldShowConsent(consent: ConsentState, serverEnabled: boolean): boolean {
|
||||
if (!serverEnabled) return false;
|
||||
if (consent.analyticsEnabled !== null) return false;
|
||||
if (consent.analyticsConsentShownAt === null) return true;
|
||||
if (consent.analyticsConsentRemindAt === null) return false;
|
||||
return Date.now() >= consent.analyticsConsentRemindAt;
|
||||
}
|
||||
|
||||
export function isConsentEnabled(consent: ConsentState, serverEnabled: boolean): boolean {
|
||||
if (!serverEnabled) return false;
|
||||
return consent.analyticsEnabled === true;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const ANALYTICS_EVENTS = {
|
||||
TOOL_USED: "tool_used",
|
||||
SEARCH: "search",
|
||||
PIPELINE_EXECUTED: "pipeline_executed",
|
||||
AI_BUNDLE_ACTION: "ai_bundle_action",
|
||||
} as const;
|
||||
|
||||
export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
|
||||
|
||||
export interface ToolUsedProperties {
|
||||
tool_id: string;
|
||||
status: "completed" | "failed";
|
||||
duration_ms: number;
|
||||
category: string;
|
||||
is_ai_tool: boolean;
|
||||
params?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface SearchProperties {
|
||||
query: string;
|
||||
results_count: number;
|
||||
clicked_tool_id?: string;
|
||||
}
|
||||
|
||||
export interface PipelineExecutedProperties {
|
||||
step_count: number;
|
||||
tool_ids: string[];
|
||||
is_batch: boolean;
|
||||
file_count?: number;
|
||||
duration_ms: number;
|
||||
status: "completed" | "failed";
|
||||
}
|
||||
|
||||
export interface AiBundleActionProperties {
|
||||
bundle_id: string;
|
||||
action: "installed" | "uninstalled";
|
||||
duration_ms: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface AnalyticsConfig {
|
||||
enabled: boolean;
|
||||
posthogApiKey: string;
|
||||
posthogHost: string;
|
||||
sentryDsn: string;
|
||||
sampleRate: number;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
export interface ConsentState {
|
||||
analyticsEnabled: boolean | null;
|
||||
analyticsConsentShownAt: number | null;
|
||||
analyticsConsentRemindAt: number | null;
|
||||
}
|
||||
@@ -301,6 +301,33 @@ export const en = {
|
||||
help: "Help",
|
||||
settings: "Settings",
|
||||
},
|
||||
analytics: {
|
||||
consentTitle: "Make ashim better for you",
|
||||
consentDescription:
|
||||
'Anonymous product analytics — like "crop tool used" or "an error occurred" — help us fix bugs faster and build the tools you actually want.',
|
||||
consentPrivacy: "Your images never leave your machine. Ever.",
|
||||
whatShared: "What's shared:",
|
||||
whatSharedItems: [
|
||||
"Which tools you use",
|
||||
"Error reports (no file data)",
|
||||
"App version and performance",
|
||||
],
|
||||
whatNever: "What NEVER leaves your machine:",
|
||||
whatNeverItems: [
|
||||
"Your images, PDFs, and files",
|
||||
"File names and contents",
|
||||
"Any personal information",
|
||||
],
|
||||
consentProviders: "Data is sent to PostHog (analytics) and Sentry (errors) — both open-source.",
|
||||
consentChangeable: "You can change this anytime in Settings.",
|
||||
acceptButton: "Sure, sounds good",
|
||||
declineButton: "Maybe later",
|
||||
settingsTitle: "Product Analytics",
|
||||
settingsDescription: "Share anonymous usage data to help improve ashim.",
|
||||
settingsPrivacy: "Your images never leave your machine.",
|
||||
settingsDisabledByAdmin: "Product analytics has been disabled by the server administrator.",
|
||||
learnMore: "Learn more",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TranslationKeys = typeof en;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from "./analytics/consent.js";
|
||||
export * from "./analytics/events.js";
|
||||
export * from "./analytics/types.js";
|
||||
export * from "./constants.js";
|
||||
export * from "./features.js";
|
||||
export * from "./i18n/index.js";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const authFile = path.join(__dirname, "test-results", ".auth", "analytics-user.json");
|
||||
|
||||
const baseURL = process.env.BASE_URL ?? "http://localhost:1349";
|
||||
process.env.API_URL ??= baseURL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e-docker",
|
||||
timeout: 120_000,
|
||||
expect: {
|
||||
timeout: 30_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: authFile,
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export { authFile };
|
||||
Generated
+1069
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// ─── Analytics API Endpoints ────────────────────────────────────────
|
||||
// Tests for the analytics config and user consent API endpoints.
|
||||
// These run against the Docker container at localhost:1349.
|
||||
|
||||
const BASE_URL = "http://localhost:1349";
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Login and return a Bearer token for authenticated requests. */
|
||||
async function getAuthToken(): Promise<string> {
|
||||
const res = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "admin" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
}
|
||||
|
||||
test.describe("GET /api/v1/config/analytics (public)", () => {
|
||||
test("returns 200 without auth token", async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/config/analytics`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test("response has correct analytics config shape", async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/config/analytics`);
|
||||
const config = await res.json();
|
||||
|
||||
expect(config).toHaveProperty("enabled");
|
||||
expect(config).toHaveProperty("posthogApiKey");
|
||||
expect(config).toHaveProperty("posthogHost");
|
||||
expect(config).toHaveProperty("sentryDsn");
|
||||
expect(config).toHaveProperty("sampleRate");
|
||||
expect(config).toHaveProperty("instanceId");
|
||||
|
||||
expect(typeof config.enabled).toBe("boolean");
|
||||
expect(typeof config.posthogApiKey).toBe("string");
|
||||
expect(typeof config.posthogHost).toBe("string");
|
||||
expect(typeof config.sentryDsn).toBe("string");
|
||||
expect(typeof config.sampleRate).toBe("number");
|
||||
expect(typeof config.instanceId).toBe("string");
|
||||
});
|
||||
|
||||
test("instanceId is a valid UUID when analytics enabled", async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/config/analytics`);
|
||||
const config = await res.json();
|
||||
|
||||
if (config.enabled) {
|
||||
expect(config.instanceId).toMatch(UUID_REGEX);
|
||||
} else {
|
||||
// When disabled, instanceId is empty string
|
||||
expect(config.instanceId).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
test("instanceId is consistent across multiple fetches", async () => {
|
||||
const res1 = await fetch(`${BASE_URL}/api/v1/config/analytics`);
|
||||
const config1 = await res1.json();
|
||||
|
||||
const res2 = await fetch(`${BASE_URL}/api/v1/config/analytics`);
|
||||
const config2 = await res2.json();
|
||||
|
||||
expect(config1.instanceId).toBe(config2.instanceId);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("PUT /api/v1/user/analytics (auth required)", () => {
|
||||
test("returns 401 without auth token", async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("accepts consent with enabled: true", async () => {
|
||||
const token = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ ok: true, analyticsEnabled: true });
|
||||
});
|
||||
|
||||
test("declines consent with enabled: false", async () => {
|
||||
const token = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ ok: true, analyticsEnabled: false });
|
||||
});
|
||||
|
||||
test("remind later sets analyticsEnabled to null", async () => {
|
||||
const token = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/v1/user/analytics`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ remindLater: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ ok: true, analyticsEnabled: null });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("GET /api/auth/session includes analytics fields", () => {
|
||||
test("session response contains analytics consent fields", async () => {
|
||||
const token = await getAuthToken();
|
||||
|
||||
// First set a consent preference so the fields are populated
|
||||
await fetch(`${BASE_URL}/api/v1/user/analytics`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
|
||||
// Fetch session
|
||||
const sessionRes = await fetch(`${BASE_URL}/api/auth/session`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(sessionRes.status).toBe(200);
|
||||
|
||||
const session = await sessionRes.json();
|
||||
|
||||
// The user object should include analytics fields
|
||||
expect(session.user).toHaveProperty("analyticsEnabled");
|
||||
expect(session.user).toHaveProperty("analyticsConsentShownAt");
|
||||
expect(session.user).toHaveProperty("analyticsConsentRemindAt");
|
||||
|
||||
// After accepting, analyticsEnabled should be true
|
||||
expect(session.user.analyticsEnabled).toBe(true);
|
||||
// analyticsConsentShownAt should be a timestamp (number)
|
||||
expect(typeof session.user.analyticsConsentShownAt).toBe("number");
|
||||
// analyticsConsentRemindAt should be null after explicit accept
|
||||
expect(session.user.analyticsConsentRemindAt).toBeNull();
|
||||
});
|
||||
|
||||
test("session reflects remind-later state", async () => {
|
||||
const token = await getAuthToken();
|
||||
|
||||
// Set remind later
|
||||
await fetch(`${BASE_URL}/api/v1/user/analytics`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ remindLater: true }),
|
||||
});
|
||||
|
||||
// Fetch session
|
||||
const sessionRes = await fetch(`${BASE_URL}/api/auth/session`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const session = await sessionRes.json();
|
||||
|
||||
expect(session.user.analyticsEnabled).toBeNull();
|
||||
expect(typeof session.user.analyticsConsentShownAt).toBe("number");
|
||||
// analyticsConsentRemindAt should be a future timestamp
|
||||
expect(typeof session.user.analyticsConsentRemindAt).toBe("number");
|
||||
expect(session.user.analyticsConsentRemindAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// ─── Analytics Consent Page Flow ────────────────────────────────────
|
||||
// These tests run against a Docker container at localhost:1349.
|
||||
// The container must be started with SKIP_MUST_CHANGE_PASSWORD=true.
|
||||
//
|
||||
// IMPORTANT: There is only one admin user in the DB. Once consent is
|
||||
// given/declined, the user's state changes. Tests run serially and
|
||||
// each builds on the state left by the previous test.
|
||||
|
||||
async function loginAndGetToHome(page: import("@playwright/test").Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("admin");
|
||||
await page.getByLabel("Password").fill("admin");
|
||||
await page.getByRole("button", { name: /login/i }).click();
|
||||
// May hit consent page or go straight to home
|
||||
try {
|
||||
const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i });
|
||||
await acceptBtn.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await acceptBtn.click();
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
} catch {
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Analytics consent page", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("consent already accepted by auth setup — home loads without consent redirect", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The auth setup project already accepted analytics consent for the admin
|
||||
// user, so a fresh browser session logging in as admin should go straight
|
||||
// to the home page without being redirected to /analytics-consent.
|
||||
await loginAndGetToHome(page);
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Navigate away and back — consent page should NOT reappear
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(1_000);
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect(page).not.toHaveURL(/analytics-consent/);
|
||||
|
||||
// Verify session has analyticsEnabled=true via API (using the in-browser token)
|
||||
const sessionData = await page.evaluate(async () => {
|
||||
const token = localStorage.getItem("ashim-token") ?? "";
|
||||
const res = await fetch("/api/auth/session", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
expect(sessionData.user.analyticsEnabled).toBe(true);
|
||||
expect(sessionData.user.analyticsConsentShownAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("settings toggle works after accepting analytics", async ({ page }) => {
|
||||
// User already accepted in previous test — login should go straight to home
|
||||
await loginAndGetToHome(page);
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Open Settings dialog — look for the gear icon or settings button
|
||||
const settingsButton = page
|
||||
.locator("[data-testid='settings-button']")
|
||||
.or(page.locator("button").filter({ has: page.locator("svg.lucide-settings") }));
|
||||
await expect(settingsButton.first()).toBeVisible({ timeout: 10_000 });
|
||||
await settingsButton.first().click();
|
||||
|
||||
// Navigate to Product Analytics section in the settings nav
|
||||
const analyticsNav = page.getByText("Product Analytics");
|
||||
await expect(analyticsNav).toBeVisible({ timeout: 5_000 });
|
||||
await analyticsNav.click();
|
||||
|
||||
// Verify toggle shows enabled state
|
||||
await expect(page.getByText("Analytics enabled")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click the toggle button to disable
|
||||
const toggleButton = page.locator("button.rounded-full");
|
||||
await toggleButton.click();
|
||||
await expect(page.getByText("Analytics disabled")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Toggle back on
|
||||
await toggleButton.click();
|
||||
await expect(page.getByText("Analytics enabled")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// ─── Analytics Disabled (ANALYTICS_ENABLED=false) ───────────────────
|
||||
// These tests verify behavior when the server has ANALYTICS_ENABLED=false.
|
||||
// They need a Docker container started with ANALYTICS_ENABLED=false.
|
||||
//
|
||||
// If the container has analytics enabled, these tests will be skipped
|
||||
// automatically by checking the config endpoint first.
|
||||
|
||||
const BASE_URL = process.env.API_URL ?? "http://localhost:1349";
|
||||
|
||||
async function loginFresh(page: import("@playwright/test").Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("admin");
|
||||
await page.getByLabel("Password").fill("admin");
|
||||
await page.getByRole("button", { name: /login/i }).click();
|
||||
}
|
||||
|
||||
test.describe("Analytics disabled by server", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeEach(async ({ request: _request }, testInfo) => {
|
||||
// Skip this suite if the container has analytics enabled
|
||||
const res = await fetch(`${BASE_URL}/api/v1/config/analytics`);
|
||||
const config = await res.json();
|
||||
if (config.enabled) {
|
||||
testInfo.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("config endpoint returns disabled with empty fields", async ({ request }) => {
|
||||
const res = await request.get("/api/v1/config/analytics");
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const config = await res.json();
|
||||
expect(config).toEqual({
|
||||
enabled: false,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("no consent screen when analytics disabled — goes directly to home", async ({ page }) => {
|
||||
await loginFresh(page);
|
||||
|
||||
// Should go directly to home, NOT to /analytics-consent
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("no outbound network requests to PostHog or Sentry", async ({ page }) => {
|
||||
const analyticsRequests: string[] = [];
|
||||
|
||||
// Intercept ALL network requests and log any that hit analytics domains
|
||||
await page.route("**/*", (route) => {
|
||||
const url = route.request().url();
|
||||
if (
|
||||
url.includes("posthog.com") ||
|
||||
url.includes("posthog") ||
|
||||
url.includes("sentry.io") ||
|
||||
url.includes("sentry") ||
|
||||
url.includes("us.i.posthog.com") ||
|
||||
url.includes("ingest.sentry.io")
|
||||
) {
|
||||
analyticsRequests.push(url);
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
// Login
|
||||
await loginFresh(page);
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
|
||||
// Navigate around
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/compress");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Assert ZERO analytics requests
|
||||
expect(analyticsRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// ─── Analytics Privacy / No Data Leak ───────────────────────────────
|
||||
// CRITICAL privacy tests: verify that disabling analytics means
|
||||
// absolutely zero data is sent to PostHog, Sentry, or any external
|
||||
// analytics domain. Also verifies that tool functionality is not
|
||||
// degraded when analytics are disabled.
|
||||
//
|
||||
// NOTE: The auth setup project already accepted analytics consent for
|
||||
// the admin user, so the consent page won't appear on login. Instead,
|
||||
// these tests login, then explicitly disable analytics via the API,
|
||||
// then reload the page so the store picks up the new state.
|
||||
|
||||
const SAMPLES_DIR = path.join(process.env.HOME ?? "/Users/sidd", "Downloads", "sample");
|
||||
const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures");
|
||||
|
||||
/** Analytics-related domains to watch for in network traffic. */
|
||||
const ANALYTICS_DOMAINS = [
|
||||
"posthog.com",
|
||||
"us.i.posthog.com",
|
||||
"eu.i.posthog.com",
|
||||
"sentry.io",
|
||||
"ingest.sentry.io",
|
||||
"o4508.ingest.us.sentry.io",
|
||||
];
|
||||
|
||||
function isAnalyticsRequest(url: string): boolean {
|
||||
return ANALYTICS_DOMAINS.some((domain) => url.includes(domain));
|
||||
}
|
||||
|
||||
async function loginFresh(page: import("@playwright/test").Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("admin");
|
||||
await page.getByLabel("Password").fill("admin");
|
||||
await page.getByRole("button", { name: /login/i }).click();
|
||||
// May land on "/" or "/analytics-consent" depending on user state
|
||||
try {
|
||||
const acceptBtn = page.getByRole("button", { name: /sure, sounds good/i });
|
||||
await acceptBtn.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await acceptBtn.click();
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
} catch {
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable analytics for the admin user via the browser's existing auth token,
|
||||
* then reload so the frontend store picks up analyticsEnabled=false.
|
||||
* This avoids an extra /api/auth/login call (which counts toward rate limits).
|
||||
*/
|
||||
async function disableAnalytics(page: import("@playwright/test").Page): Promise<void> {
|
||||
const ok = await page.evaluate(async () => {
|
||||
const token = localStorage.getItem("ashim-token") ?? "";
|
||||
const res = await fetch("/api/v1/user/analytics", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
return res.ok;
|
||||
});
|
||||
expect(ok, "Failed to disable analytics via in-browser API call").toBe(true);
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enable analytics for the admin user via the browser's existing auth token.
|
||||
* Called in afterEach so subsequent tests/runs start with analytics enabled.
|
||||
*/
|
||||
async function enableAnalytics(page: import("@playwright/test").Page): Promise<void> {
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
const token = localStorage.getItem("ashim-token") ?? "";
|
||||
await fetch("/api/v1/user/analytics", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup — page may already be closed
|
||||
}
|
||||
}
|
||||
|
||||
function getFixture(name: string): string {
|
||||
return path.join(FIXTURES_DIR, name);
|
||||
}
|
||||
|
||||
async function uploadFiles(
|
||||
page: import("@playwright/test").Page,
|
||||
filePaths: string[],
|
||||
): Promise<void> {
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
const dropzone = page.locator("[class*='border-dashed']").first();
|
||||
await dropzone.click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(filePaths);
|
||||
await page.waitForTimeout(3_000);
|
||||
}
|
||||
|
||||
async function waitForProcessingDone(
|
||||
page: import("@playwright/test").Page,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const spinner = page.locator("[class*='animate-spin']");
|
||||
if (await spinner.isVisible({ timeout: 3_000 })) {
|
||||
await spinner.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
}
|
||||
} catch {
|
||||
// No spinner — processing may have been instant
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe("No data leak after disabling analytics", () => {
|
||||
// Use fresh browser context — no saved auth state
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// Re-enable analytics after each test so subsequent tests/runs start clean
|
||||
test.afterEach(async ({ page }) => {
|
||||
await enableAnalytics(page);
|
||||
});
|
||||
|
||||
test("zero PostHog/Sentry traffic after explicitly disabling analytics", async ({ page }) => {
|
||||
const analyticsRequests: string[] = [];
|
||||
|
||||
// Login first (consent was already accepted by auth setup)
|
||||
await loginFresh(page);
|
||||
|
||||
// Disable analytics via API and reload
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Set up network interception AFTER disabling analytics
|
||||
await page.route("**/*", (route) => {
|
||||
const url = route.request().url();
|
||||
if (isAnalyticsRequest(url)) {
|
||||
analyticsRequests.push(url);
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
// Navigate to several pages
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/compress");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/fullscreen");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Assert ZERO analytics requests were made
|
||||
expect(
|
||||
analyticsRequests,
|
||||
`Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("zero PostHog/Sentry traffic after toggling analytics off", async ({ page }) => {
|
||||
const analyticsRequests: string[] = [];
|
||||
|
||||
// Login (consent was already accepted by auth setup)
|
||||
await loginFresh(page);
|
||||
|
||||
// Disable analytics via API and reload
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Set up network interception
|
||||
await page.route("**/*", (route) => {
|
||||
const url = route.request().url();
|
||||
if (isAnalyticsRequest(url)) {
|
||||
analyticsRequests.push(url);
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
// Browse around
|
||||
await page.goto("/crop");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/convert");
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
expect(
|
||||
analyticsRequests,
|
||||
`Expected zero analytics requests, but found: ${analyticsRequests.join(", ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("tool processing works normally after disabling analytics", async ({ page }) => {
|
||||
// Login and disable analytics
|
||||
await loginFresh(page);
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Navigate to resize tool
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Upload a test image
|
||||
const testImage = getFixture("test-200x150.png");
|
||||
await uploadFiles(page, [testImage]);
|
||||
|
||||
// Set resize parameters
|
||||
const widthInput = page.getByLabel("Width (px)");
|
||||
await widthInput.fill("100");
|
||||
|
||||
// Process the image
|
||||
const processBtn = page.getByTestId("resize-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
// Verify no errors
|
||||
const error = page.locator(".text-red-500");
|
||||
expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false);
|
||||
|
||||
// Verify a download link or result appeared
|
||||
const downloadLink = page.locator(
|
||||
"a[download], a[href*='download'], button:has-text('Download')",
|
||||
);
|
||||
await expect(downloadLink.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("tool processing works with sample portrait after disabling analytics", async ({ page }) => {
|
||||
const portraitPath = path.join(
|
||||
SAMPLES_DIR,
|
||||
"portrait-of-a-smiling-man-with-glasses-and-a-beard-isolated.png",
|
||||
);
|
||||
if (!fs.existsSync(portraitPath)) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Login and disable analytics
|
||||
await loginFresh(page);
|
||||
await disableAnalytics(page);
|
||||
|
||||
// Navigate to resize tool
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2_000);
|
||||
|
||||
// Upload sample portrait
|
||||
await uploadFiles(page, [portraitPath]);
|
||||
|
||||
// Set resize width
|
||||
const widthInput = page.getByLabel("Width (px)");
|
||||
await widthInput.fill("200");
|
||||
|
||||
// Process
|
||||
const processBtn = page.getByTestId("resize-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
// Verify no errors
|
||||
const error = page.locator(".text-red-500");
|
||||
expect(await error.isVisible({ timeout: 2_000 }).catch(() => false)).toBe(false);
|
||||
|
||||
// Verify download appeared — proves functionality is not degraded
|
||||
const downloadLink = page.locator(
|
||||
"a[download], a[href*='download'], button:has-text('Download')",
|
||||
);
|
||||
await expect(downloadLink.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// ─── Privacy Policy Page ────────────────────────────────────────────
|
||||
// Tests for the /privacy page content, verifying that the updated
|
||||
// privacy policy reflects the analytics feature accurately.
|
||||
|
||||
test.describe("Privacy policy page", () => {
|
||||
test("privacy policy page is accessible and loads", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
|
||||
// Verify the page loads with the correct title
|
||||
await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Product Analytics section exists", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByRole("heading", { name: "Product Analytics" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Your Choice section exists", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByRole("heading", { name: "Your Choice" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Third-Party Services section exists", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByRole("heading", { name: "Third-Party Services" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("mentions PostHog and Sentry by name", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByRole("link", { name: "PostHog" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("link", { name: "Sentry" })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("shows correct last-updated date", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByText("April 22, 2026")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("does NOT contain old 'No Tracking or Analytics' text", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
|
||||
// The old privacy policy had this text — it should be gone now
|
||||
const oldText = page.getByText("No Tracking or Analytics");
|
||||
await expect(oldText).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("back-to-app link is present", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByText("Back to app")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,27 @@
|
||||
import path from "node:path";
|
||||
import { expect, test as setup } from "@playwright/test";
|
||||
import { authFile } from "../../playwright.docker.config";
|
||||
|
||||
const authFile = path.join(__dirname, "..", "..", "test-results", ".auth", "analytics-user.json");
|
||||
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("admin");
|
||||
await page.getByLabel("Password").fill("admin");
|
||||
await page.getByRole("button", { name: /login/i }).click();
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
|
||||
// Wait for login to complete — page leaves "/login"
|
||||
await page.waitForURL((url) => !url.pathname.startsWith("/login"), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// If we landed on the consent page, accept it
|
||||
if (page.url().includes("/analytics-consent")) {
|
||||
await page.getByRole("button", { name: /sure, sounds good/i }).click();
|
||||
// Consent page does window.location.href = "/" (full reload)
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// At this point we should be on the home page
|
||||
await expect(page).toHaveURL("/");
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { ConsentState } from "@ashim/shared";
|
||||
import { isConsentEnabled, shouldShowConsent } from "@ashim/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("shouldShowConsent edge cases", () => {
|
||||
it("returns true when remindAt is exactly equal to Date.now()", () => {
|
||||
const now = Date.now();
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: now - SEVEN_DAYS_MS,
|
||||
analyticsConsentRemindAt: now,
|
||||
};
|
||||
// Date.now() >= remindAt should be true when they are equal
|
||||
expect(shouldShowConsent(state, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when remindAt is set but consentShownAt is null (defensive)", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: Date.now() - 1000,
|
||||
};
|
||||
// consentShownAt is null -> returns true (fresh user path)
|
||||
expect(shouldShowConsent(state, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when remindAt is 1ms in the future", () => {
|
||||
const now = Date.now();
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: now - SEVEN_DAYS_MS,
|
||||
analyticsConsentRemindAt: now + 100000, // safely in the future
|
||||
};
|
||||
expect(shouldShowConsent(state, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after 'Maybe later' and 7 days have passed", () => {
|
||||
const shownAt = Date.now() - SEVEN_DAYS_MS - 1000;
|
||||
const remindAt = Date.now() - 1000; // remind time has passed
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: shownAt,
|
||||
analyticsConsentRemindAt: remindAt,
|
||||
};
|
||||
expect(shouldShowConsent(state, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when server is disabled regardless of remindAt", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: Date.now() - SEVEN_DAYS_MS,
|
||||
analyticsConsentRemindAt: Date.now() - 1000,
|
||||
};
|
||||
expect(shouldShowConsent(state, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConsentEnabled edge cases", () => {
|
||||
it("returns false when analyticsEnabled is false (explicitly declined)", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: false,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(isConsentEnabled(state, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when analyticsEnabled is null (never decided)", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(isConsentEnabled(state, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when server disabled even if user opted in", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(isConsentEnabled(state, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consent lifecycle simulations", () => {
|
||||
it("fresh -> maybe later -> remind time passes -> show again -> accept", () => {
|
||||
// Step 1: Fresh user -- never been asked
|
||||
const fresh: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(shouldShowConsent(fresh, true)).toBe(true);
|
||||
expect(isConsentEnabled(fresh, true)).toBe(false);
|
||||
|
||||
// Step 2: User clicks "Maybe later" -- shown timestamp set, remind in 7 days
|
||||
const shownAt = Date.now() - SEVEN_DAYS_MS - 1000;
|
||||
const maybeLater: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: shownAt,
|
||||
analyticsConsentRemindAt: shownAt + SEVEN_DAYS_MS,
|
||||
};
|
||||
// Remind time has now passed (shownAt + 7days < now)
|
||||
expect(shouldShowConsent(maybeLater, true)).toBe(true);
|
||||
expect(isConsentEnabled(maybeLater, true)).toBe(false);
|
||||
|
||||
// Step 3: User accepts on second prompt
|
||||
const accepted: ConsentState = {
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(shouldShowConsent(accepted, true)).toBe(false);
|
||||
expect(isConsentEnabled(accepted, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("fresh -> accept immediately -> never show again", () => {
|
||||
// Step 1: Fresh user
|
||||
const fresh: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(shouldShowConsent(fresh, true)).toBe(true);
|
||||
|
||||
// Step 2: User accepts immediately
|
||||
const accepted: ConsentState = {
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(shouldShowConsent(accepted, true)).toBe(false);
|
||||
expect(isConsentEnabled(accepted, true)).toBe(true);
|
||||
|
||||
// Verify it stays hidden even far in the future
|
||||
expect(shouldShowConsent(accepted, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("fresh -> decline immediately -> never show again", () => {
|
||||
// Step 1: Fresh user
|
||||
const fresh: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(shouldShowConsent(fresh, true)).toBe(true);
|
||||
|
||||
// Step 2: User declines immediately
|
||||
const declined: ConsentState = {
|
||||
analyticsEnabled: false,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(shouldShowConsent(declined, true)).toBe(false);
|
||||
expect(isConsentEnabled(declined, true)).toBe(false);
|
||||
|
||||
// Verify it stays hidden and analytics stays disabled
|
||||
expect(shouldShowConsent(declined, true)).toBe(false);
|
||||
expect(isConsentEnabled(declined, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("fresh -> maybe later -> remind time NOT yet passed -> stay hidden", () => {
|
||||
const fresh: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(shouldShowConsent(fresh, true)).toBe(true);
|
||||
|
||||
// User clicks maybe later, only 1 day ago
|
||||
const maybeLater: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: Date.now() - 86400000,
|
||||
analyticsConsentRemindAt: Date.now() + SEVEN_DAYS_MS - 86400000,
|
||||
};
|
||||
expect(shouldShowConsent(maybeLater, true)).toBe(false);
|
||||
expect(isConsentEnabled(maybeLater, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { isConsentEnabled, shouldShowConsent } from "@ashim/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("shouldShowConsent", () => {
|
||||
it("returns false when server has analytics disabled", () => {
|
||||
expect(
|
||||
shouldShowConsent(
|
||||
{
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a fresh user who has never been asked", () => {
|
||||
expect(
|
||||
shouldShowConsent(
|
||||
{
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when user already opted in", () => {
|
||||
expect(
|
||||
shouldShowConsent(
|
||||
{
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user explicitly declined", () => {
|
||||
expect(
|
||||
shouldShowConsent(
|
||||
{
|
||||
analyticsEnabled: false,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when remind-at is in the future", () => {
|
||||
expect(
|
||||
shouldShowConsent(
|
||||
{
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: Date.now() - 86400000,
|
||||
analyticsConsentRemindAt: Date.now() + 86400000,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when remind-at has passed", () => {
|
||||
expect(
|
||||
shouldShowConsent(
|
||||
{
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: Date.now() - 86400000 * 8,
|
||||
analyticsConsentRemindAt: Date.now() - 86400000,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConsentEnabled", () => {
|
||||
it("returns false when server disabled", () => {
|
||||
expect(
|
||||
isConsentEnabled(
|
||||
{
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user has not consented", () => {
|
||||
expect(
|
||||
isConsentEnabled(
|
||||
{
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when user opted in and server enabled", () => {
|
||||
expect(
|
||||
isConsentEnabled(
|
||||
{
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: Date.now(),
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ANALYTICS_EVENTS } from "@ashim/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ANALYTICS_EVENTS", () => {
|
||||
it("has exactly 4 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("contains the expected keys", () => {
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("TOOL_USED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("SEARCH");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_EXECUTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_ACTION");
|
||||
});
|
||||
|
||||
it("all event values are strings", () => {
|
||||
for (const value of Object.values(ANALYTICS_EVENTS)) {
|
||||
expect(typeof value).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("TOOL_USED has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.TOOL_USED).toBe("tool_used");
|
||||
});
|
||||
|
||||
it("SEARCH has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.SEARCH).toBe("search");
|
||||
});
|
||||
|
||||
it("PIPELINE_EXECUTED has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.PIPELINE_EXECUTED).toBe("pipeline_executed");
|
||||
});
|
||||
|
||||
it("AI_BUNDLE_ACTION has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe("ai_bundle_action");
|
||||
});
|
||||
|
||||
it("all values follow snake_case convention", () => {
|
||||
for (const value of Object.values(ANALYTICS_EVENTS)) {
|
||||
expect(value).toMatch(/^[a-z][a-z0-9_]*$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen (as const prevents mutation)", () => {
|
||||
// as const produces a readonly object; Object.isFrozen checks runtime freezing.
|
||||
// TypeScript enforces readonly at compile time, but at runtime the object
|
||||
// defined with "as const" is a plain object unless explicitly frozen.
|
||||
// We verify the values are stable by checking they haven't changed.
|
||||
const snapshot = { ...ANALYTICS_EVENTS };
|
||||
expect(ANALYTICS_EVENTS.TOOL_USED).toBe(snapshot.TOOL_USED);
|
||||
expect(ANALYTICS_EVENTS.SEARCH).toBe(snapshot.SEARCH);
|
||||
expect(ANALYTICS_EVENTS.PIPELINE_EXECUTED).toBe(snapshot.PIPELINE_EXECUTED);
|
||||
expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe(snapshot.AI_BUNDLE_ACTION);
|
||||
});
|
||||
|
||||
it("all values are unique (no duplicate event names)", () => {
|
||||
const values = Object.values(ANALYTICS_EVENTS);
|
||||
const unique = new Set(values);
|
||||
expect(unique.size).toBe(values.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AnalyticsConfig, ConsentState } from "@ashim/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("AnalyticsConfig type", () => {
|
||||
it("accepts a fully populated config object", () => {
|
||||
const config: AnalyticsConfig = {
|
||||
enabled: true,
|
||||
posthogApiKey: "phc_test123",
|
||||
posthogHost: "https://us.i.posthog.com",
|
||||
sentryDsn: "https://abc@sentry.io/123",
|
||||
sampleRate: 1.0,
|
||||
instanceId: "inst-abc-123",
|
||||
};
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.posthogApiKey).toBe("phc_test123");
|
||||
expect(config.posthogHost).toBe("https://us.i.posthog.com");
|
||||
expect(config.sentryDsn).toBe("https://abc@sentry.io/123");
|
||||
expect(config.sampleRate).toBe(1.0);
|
||||
expect(config.instanceId).toBe("inst-abc-123");
|
||||
});
|
||||
|
||||
it("accepts a config with analytics disabled", () => {
|
||||
const config: AnalyticsConfig = {
|
||||
enabled: false,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "",
|
||||
};
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(config.sampleRate).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts fractional sample rates", () => {
|
||||
const config: AnalyticsConfig = {
|
||||
enabled: true,
|
||||
posthogApiKey: "key",
|
||||
posthogHost: "https://host.com",
|
||||
sentryDsn: "https://dsn",
|
||||
sampleRate: 0.5,
|
||||
instanceId: "id",
|
||||
};
|
||||
expect(config.sampleRate).toBe(0.5);
|
||||
});
|
||||
|
||||
it("has exactly the expected keys", () => {
|
||||
const config: AnalyticsConfig = {
|
||||
enabled: true,
|
||||
posthogApiKey: "key",
|
||||
posthogHost: "host",
|
||||
sentryDsn: "dsn",
|
||||
sampleRate: 1,
|
||||
instanceId: "id",
|
||||
};
|
||||
const keys = Object.keys(config).sort();
|
||||
expect(keys).toEqual(
|
||||
["enabled", "instanceId", "posthogApiKey", "posthogHost", "sampleRate", "sentryDsn"].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConsentState type", () => {
|
||||
it("accepts all-null state (fresh user)", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(state.analyticsEnabled).toBeNull();
|
||||
expect(state.analyticsConsentShownAt).toBeNull();
|
||||
expect(state.analyticsConsentRemindAt).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts opted-in state (analyticsEnabled = true)", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: 1713800000000,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(state.analyticsEnabled).toBe(true);
|
||||
expect(state.analyticsConsentShownAt).toBe(1713800000000);
|
||||
expect(state.analyticsConsentRemindAt).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts declined state (analyticsEnabled = false)", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: false,
|
||||
analyticsConsentShownAt: 1713800000000,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
expect(state.analyticsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts deferred state (maybe later with remindAt set)", () => {
|
||||
const shownAt = Date.now() - 86400000;
|
||||
const remindAt = Date.now() + 86400000 * 6;
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: shownAt,
|
||||
analyticsConsentRemindAt: remindAt,
|
||||
};
|
||||
expect(state.analyticsEnabled).toBeNull();
|
||||
expect(state.analyticsConsentShownAt).toBe(shownAt);
|
||||
expect(state.analyticsConsentRemindAt).toBe(remindAt);
|
||||
});
|
||||
|
||||
it("accepts mixed state with analyticsEnabled true and remindAt set", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: 1713800000000,
|
||||
analyticsConsentRemindAt: 1714400000000,
|
||||
};
|
||||
expect(state.analyticsEnabled).toBe(true);
|
||||
expect(state.analyticsConsentRemindAt).toBe(1714400000000);
|
||||
});
|
||||
|
||||
it("has exactly the expected keys", () => {
|
||||
const state: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
const keys = Object.keys(state).sort();
|
||||
expect(keys).toEqual(
|
||||
["analyticsConsentRemindAt", "analyticsConsentShownAt", "analyticsEnabled"].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("analytics env var validation", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
// Keys that the Zod schema cares about -- we clear them before each test
|
||||
// so defaults kick in unless explicitly set.
|
||||
const analyticsKeys = [
|
||||
"ANALYTICS_ENABLED",
|
||||
"ANALYTICS_SAMPLE_RATE",
|
||||
"POSTHOG_API_KEY",
|
||||
"POSTHOG_HOST",
|
||||
"SENTRY_DSN",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of analyticsKeys) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
// ── ANALYTICS_ENABLED ───────────────────────────────────────────────────
|
||||
|
||||
it("ANALYTICS_ENABLED defaults to true", async () => {
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.ANALYTICS_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("ANALYTICS_ENABLED='false' transforms to boolean false", async () => {
|
||||
process.env.ANALYTICS_ENABLED = "false";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().ANALYTICS_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("ANALYTICS_ENABLED='true' transforms to boolean true", async () => {
|
||||
process.env.ANALYTICS_ENABLED = "true";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().ANALYTICS_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("ANALYTICS_ENABLED rejects non-enum values", async () => {
|
||||
process.env.ANALYTICS_ENABLED = "yes";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(() => loadEnv()).toThrow();
|
||||
});
|
||||
|
||||
// ── ANALYTICS_SAMPLE_RATE ──────────────────────────────────────────────
|
||||
|
||||
it("ANALYTICS_SAMPLE_RATE defaults to 1.0", async () => {
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.ANALYTICS_SAMPLE_RATE).toBe(1.0);
|
||||
});
|
||||
|
||||
it("ANALYTICS_SAMPLE_RATE=0.5 parses correctly", async () => {
|
||||
process.env.ANALYTICS_SAMPLE_RATE = "0.5";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(0.5);
|
||||
});
|
||||
|
||||
it("ANALYTICS_SAMPLE_RATE=0 is valid (no sampling)", async () => {
|
||||
process.env.ANALYTICS_SAMPLE_RATE = "0";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(0);
|
||||
});
|
||||
|
||||
it("ANALYTICS_SAMPLE_RATE=1 is valid (full sampling)", async () => {
|
||||
process.env.ANALYTICS_SAMPLE_RATE = "1";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().ANALYTICS_SAMPLE_RATE).toBe(1);
|
||||
});
|
||||
|
||||
it("ANALYTICS_SAMPLE_RATE > 1 fails validation", async () => {
|
||||
process.env.ANALYTICS_SAMPLE_RATE = "1.5";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(() => loadEnv()).toThrow();
|
||||
});
|
||||
|
||||
it("ANALYTICS_SAMPLE_RATE < 0 fails validation", async () => {
|
||||
process.env.ANALYTICS_SAMPLE_RATE = "-0.1";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(() => loadEnv()).toThrow();
|
||||
});
|
||||
|
||||
it("ANALYTICS_SAMPLE_RATE=2 fails validation", async () => {
|
||||
process.env.ANALYTICS_SAMPLE_RATE = "2";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(() => loadEnv()).toThrow();
|
||||
});
|
||||
|
||||
// ── POSTHOG_API_KEY ────────────────────────────────────────────────────
|
||||
|
||||
it("POSTHOG_API_KEY defaults to the baked-in key", async () => {
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.POSTHOG_API_KEY).toBe("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy");
|
||||
});
|
||||
|
||||
it("POSTHOG_API_KEY can be overridden with a custom value", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_custom_key_123";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().POSTHOG_API_KEY).toBe("phc_custom_key_123");
|
||||
});
|
||||
|
||||
it("POSTHOG_API_KEY can be set to empty string", async () => {
|
||||
process.env.POSTHOG_API_KEY = "";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().POSTHOG_API_KEY).toBe("");
|
||||
});
|
||||
|
||||
// ── POSTHOG_HOST ───────────────────────────────────────────────────────
|
||||
|
||||
it("POSTHOG_HOST defaults to the PostHog US endpoint", async () => {
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.POSTHOG_HOST).toBe("https://us.i.posthog.com");
|
||||
});
|
||||
|
||||
it("POSTHOG_HOST can be overridden", async () => {
|
||||
process.env.POSTHOG_HOST = "https://eu.posthog.com";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().POSTHOG_HOST).toBe("https://eu.posthog.com");
|
||||
});
|
||||
|
||||
// ── SENTRY_DSN ─────────────────────────────────────────────────────────
|
||||
|
||||
it("SENTRY_DSN defaults to the baked-in DSN", async () => {
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.SENTRY_DSN).toBe(
|
||||
"https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248",
|
||||
);
|
||||
});
|
||||
|
||||
it("SENTRY_DSN can be overridden with a custom value", async () => {
|
||||
process.env.SENTRY_DSN = "https://custom@sentry.io/999";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().SENTRY_DSN).toBe("https://custom@sentry.io/999");
|
||||
});
|
||||
|
||||
it("SENTRY_DSN can be set to empty string to disable", async () => {
|
||||
process.env.SENTRY_DSN = "";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
expect(loadEnv().SENTRY_DSN).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user