mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add backend analytics wrapper, config/consent API routes
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -15,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";
|
||||
@@ -49,6 +51,7 @@ function ensureInstanceId() {
|
||||
}
|
||||
|
||||
ensureInstanceId();
|
||||
initAnalytics();
|
||||
|
||||
// Mark any jobs left in processing/queued from a previous unclean shutdown
|
||||
recoverStaleJobs();
|
||||
@@ -143,6 +146,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);
|
||||
|
||||
@@ -273,6 +279,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,75 @@
|
||||
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";
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdownAnalytics(): Promise<void> {
|
||||
if (posthogClient) {
|
||||
await posthogClient.shutdown();
|
||||
posthogClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user