From 753c685ef7e7041441e3ece77121645a1a76923b Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 02:29:24 +0100 Subject: [PATCH] feat(services): create mana-analytics, remove feedback/analytics/ai from auth Extract feedback, analytics, and AI modules from mana-core-auth into standalone mana-analytics service (Hono + Bun, Port 3064). New service (services/mana-analytics/): - User feedback CRUD with voting - AI-powered feedback title generation via mana-llm - Simplified from DuckDB analytics to pure PostgreSQL - ~550 LOC Removed from mana-core-auth: - feedback/ module (6 files) - analytics/ module (4 files) - ai/ module (3 files) - db/schema/feedback.schema.ts mana-core-auth now contains ONLY pure auth: - Better Auth (JWT, Sessions, 2FA, Passkeys, OIDC, Magic Links) - Organizations/Guilds (membership management) - API Keys, Security, Me (GDPR), Health, Metrics - Ready for Phase 5: Hono rewrite Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + docker-compose.macmini.yml | 71 +--------- docker/prometheus/prometheus.yml | 9 +- services/mana-analytics/CLAUDE.md | 29 ++++ services/mana-analytics/drizzle.config.ts | 12 ++ services/mana-analytics/package.json | 23 ++++ services/mana-analytics/src/config.ts | 23 ++++ services/mana-analytics/src/db/connection.ts | 19 +++ .../mana-analytics/src/db/schema/feedback.ts | 74 ++++++++++ .../mana-analytics/src/db/schema/index.ts | 1 + services/mana-analytics/src/index.ts | 38 ++++++ services/mana-analytics/src/lib/errors.ts | 43 ++++++ .../src/middleware/error-handler.ts | 29 ++++ .../mana-analytics/src/middleware/jwt-auth.ts | 57 ++++++++ .../src/middleware/service-auth.ts | 26 ++++ .../mana-analytics/src/routes/feedback.ts | 34 +++++ services/mana-analytics/src/routes/health.ts | 5 + .../mana-analytics/src/services/feedback.ts | 126 ++++++++++++++++++ services/mana-analytics/tsconfig.json | 13 ++ services/mana-core-auth/src/app.module.ts | 10 -- .../mana-core-auth/src/db/schema/index.ts | 2 - 21 files changed, 562 insertions(+), 83 deletions(-) create mode 100644 services/mana-analytics/CLAUDE.md create mode 100644 services/mana-analytics/drizzle.config.ts create mode 100644 services/mana-analytics/package.json create mode 100644 services/mana-analytics/src/config.ts create mode 100644 services/mana-analytics/src/db/connection.ts create mode 100644 services/mana-analytics/src/db/schema/feedback.ts create mode 100644 services/mana-analytics/src/db/schema/index.ts create mode 100644 services/mana-analytics/src/index.ts create mode 100644 services/mana-analytics/src/lib/errors.ts create mode 100644 services/mana-analytics/src/middleware/error-handler.ts create mode 100644 services/mana-analytics/src/middleware/jwt-auth.ts create mode 100644 services/mana-analytics/src/middleware/service-auth.ts create mode 100644 services/mana-analytics/src/routes/feedback.ts create mode 100644 services/mana-analytics/src/routes/health.ts create mode 100644 services/mana-analytics/src/services/feedback.ts create mode 100644 services/mana-analytics/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index 4a949f391..d34905c20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,7 @@ manacore-monorepo/ │ ├── mana-credits/ # Credit system (Hono + Bun, extracted from auth) │ ├── mana-user/ # User settings, tags, storage (Hono + Bun, extracted from auth) │ ├── mana-subscriptions/ # Subscription billing (Hono + Bun, extracted from auth) +│ ├── mana-analytics/ # Feedback & analytics (Hono + Bun, extracted from auth) │ ├── mana-search-go/ # Central search & content extraction (Go) │ ├── mana-crawler-go/ # Web crawler service (Go) │ ├── mana-llm/ # Central LLM abstraction service diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 5b589285b..061524532 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -752,34 +752,7 @@ services: retries: 3 start_period: 60s - skilltree-backend: - build: - context: . - dockerfile: apps/skilltree/apps/backend/Dockerfile - image: skilltree-backend:local - container_name: mana-app-skilltree-backend - restart: always - depends_on: - mana-auth: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 3038 - DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/skilltree - DB_HOST: postgres - DB_PORT: 5432 - DB_USER: postgres - MANA_CORE_AUTH_URL: http://mana-auth:3001 - CORS_ORIGINS: https://skilltree.mana.how,https://mana.how - GLITCHTIP_DSN: http://93548ec4e2a14586bfef9f4f98e72fe1@glitchtip:8020/16 - ports: - - "3038:3038" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3038/health"] - interval: 120s - timeout: 10s - retries: 3 - start_period: 60s + # skilltree-backend: REMOVED — migrated to local-first (mana-sync handles CRUD) # photos-backend: REMOVED — migrated to local-first (talks to mana-media directly) @@ -856,35 +829,7 @@ services: retries: 3 start_period: 60s - citycorners-backend: - build: - context: . - dockerfile: apps/citycorners/apps/backend/Dockerfile - image: citycorners-backend:local - container_name: mana-app-citycorners-backend - restart: always - depends_on: - mana-auth: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 3042 - DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/citycorners - DB_HOST: postgres - DB_PORT: 5432 - DB_USER: postgres - MANA_CORE_AUTH_URL: http://mana-auth:3001 - MANA_SEARCH_URL: http://mana-search:3020 - CORS_ORIGINS: https://citycorners.mana.how,https://mana.how - ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - ports: - - "3042:3042" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3042/health"] - interval: 120s - timeout: 10s - retries: 3 - start_period: 60s + # citycorners-backend: REMOVED — migrated to local-first (mana-sync handles CRUD) # ============================================ # Tier 4: Matrix Stack (Ports 4000-4099) @@ -1345,21 +1290,19 @@ services: context: . dockerfile: apps/skilltree/apps/web/Dockerfile args: - PUBLIC_BACKEND_URL: http://skilltree-backend:3038 PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 image: skilltree-web:local container_name: mana-app-skilltree-web restart: always depends_on: - skilltree-backend: + mana-auth: condition: service_healthy environment: NODE_ENV: production PORT: 5020 - PUBLIC_BACKEND_URL: http://skilltree-backend:3038 PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 - PUBLIC_BACKEND_URL_CLIENT: https://skilltree-api.mana.how PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how + PUBLIC_SYNC_SERVER_URL: ws://mana-core-sync:3051 ports: - "5020:5020" healthcheck: @@ -1433,21 +1376,19 @@ services: context: . dockerfile: apps/citycorners/apps/web/Dockerfile args: - PUBLIC_BACKEND_URL: http://citycorners-backend:3042 PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 image: citycorners-web:local container_name: mana-app-citycorners-web restart: always depends_on: - citycorners-backend: + mana-auth: condition: service_healthy environment: NODE_ENV: production PORT: 5022 - PUBLIC_BACKEND_URL: http://citycorners-backend:3042 PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 - PUBLIC_CITYCORNERS_API_URL_CLIENT: https://citycorners-api.mana.how PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how + PUBLIC_SYNC_SERVER_URL: ws://mana-core-sync:3051 ports: - "5022:5022" healthcheck: diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml index 9b884fc34..753ce3288 100644 --- a/docker/prometheus/prometheus.yml +++ b/docker/prometheus/prometheus.yml @@ -102,15 +102,12 @@ scrape_configs: metrics_path: '/metrics' scrape_interval: 30s - # SkillTree Backend - - job_name: 'skilltree-backend' - static_configs: - - targets: ['skilltree-backend:3038'] - metrics_path: '/metrics' - scrape_interval: 30s + # SkillTree Backend: REMOVED — migrated to local-first # Photos Backend: REMOVED — migrated to local-first + direct mana-media + # CityCorners Backend: REMOVED — migrated to local-first + # Zitare Backend: REMOVED — migrated to local-first # Mukke Backend diff --git a/services/mana-analytics/CLAUDE.md b/services/mana-analytics/CLAUDE.md new file mode 100644 index 000000000..869517736 --- /dev/null +++ b/services/mana-analytics/CLAUDE.md @@ -0,0 +1,29 @@ +# mana-analytics + +Feedback and analytics service. Extracted from mana-core-auth. + +## Port: 3064 + +## API Endpoints (JWT auth) + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/v1/feedback` | Submit feedback | +| GET | `/api/v1/feedback/public` | List public feedback | +| GET | `/api/v1/feedback/me` | My feedback | +| POST | `/api/v1/feedback/:id/vote` | Upvote | +| DELETE | `/api/v1/feedback/:id/vote` | Remove vote | +| DELETE | `/api/v1/feedback/:id` | Delete my feedback | + +## Database: `mana_analytics` + +Tables: user_feedback, feedback_votes + +## Environment Variables + +```env +PORT=3064 +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_analytics +MANA_CORE_AUTH_URL=http://localhost:3001 +MANA_LLM_URL=http://localhost:3025 +``` diff --git a/services/mana-analytics/drizzle.config.ts b/services/mana-analytics/drizzle.config.ts new file mode 100644 index 000000000..5bbf66d2c --- /dev/null +++ b/services/mana-analytics/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema/*.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: + process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_analytics', + }, + schemaFilter: ['feedback'], +}); diff --git a/services/mana-analytics/package.json b/services/mana-analytics/package.json new file mode 100644 index 000000000..d66562e98 --- /dev/null +++ b/services/mana-analytics/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mana/analytics", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "hono": "^4.7.0", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "jose": "^6.1.2", + "zod": "^3.24.0" + }, + "devDependencies": { + "drizzle-kit": "^0.30.4", + "typescript": "^5.9.3" + } +} diff --git a/services/mana-analytics/src/config.ts b/services/mana-analytics/src/config.ts new file mode 100644 index 000000000..f9d5bef53 --- /dev/null +++ b/services/mana-analytics/src/config.ts @@ -0,0 +1,23 @@ +export interface Config { + port: number; + databaseUrl: string; + manaAuthUrl: string; + manaLlmUrl: string; + serviceKey: string; + cors: { origins: string[] }; +} + +export function loadConfig(): Config { + const env = (key: string, fallback?: string) => process.env[key] || fallback || ''; + return { + port: parseInt(env('PORT', '3064'), 10), + databaseUrl: env( + 'DATABASE_URL', + 'postgresql://manacore:devpassword@localhost:5432/mana_analytics' + ), + manaAuthUrl: env('MANA_CORE_AUTH_URL', 'http://localhost:3001'), + manaLlmUrl: env('MANA_LLM_URL', 'http://localhost:3025'), + serviceKey: env('MANA_CORE_SERVICE_KEY', 'dev-service-key'), + cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') }, + }; +} diff --git a/services/mana-analytics/src/db/connection.ts b/services/mana-analytics/src/db/connection.ts new file mode 100644 index 000000000..aa63e328e --- /dev/null +++ b/services/mana-analytics/src/db/connection.ts @@ -0,0 +1,19 @@ +/** + * Database connection using Drizzle ORM + postgres.js + */ + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema/index'; + +let db: ReturnType> | null = null; + +export function getDb(databaseUrl: string) { + if (!db) { + const client = postgres(databaseUrl, { max: 10 }); + db = drizzle(client, { schema }); + } + return db; +} + +export type Database = ReturnType; diff --git a/services/mana-analytics/src/db/schema/feedback.ts b/services/mana-analytics/src/db/schema/feedback.ts new file mode 100644 index 000000000..770d4895d --- /dev/null +++ b/services/mana-analytics/src/db/schema/feedback.ts @@ -0,0 +1,74 @@ +import { + pgSchema, + uuid, + text, + timestamp, + integer, + boolean, + jsonb, + index, + unique, + pgEnum, +} from 'drizzle-orm/pg-core'; + +export const feedbackSchema = pgSchema('feedback'); + +export const feedbackCategoryEnum = pgEnum('feedback_category', [ + 'bug', + 'feature', + 'improvement', + 'question', + 'praise', + 'other', +]); + +export const feedbackStatusEnum = pgEnum('feedback_status', [ + 'new', + 'reviewed', + 'planned', + 'in_progress', + 'done', + 'rejected', +]); + +export const userFeedback = feedbackSchema.table( + 'user_feedback', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + appId: text('app_id').notNull(), + title: text('title'), + feedbackText: text('feedback_text').notNull(), + category: feedbackCategoryEnum('category').default('other').notNull(), + status: feedbackStatusEnum('status').default('new').notNull(), + isPublic: boolean('is_public').default(true).notNull(), + adminResponse: text('admin_response'), + voteCount: integer('vote_count').default(0).notNull(), + deviceInfo: jsonb('device_info'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdIdx: index('feedback_user_id_idx').on(table.userId), + appIdIdx: index('feedback_app_id_idx').on(table.appId), + statusIdx: index('feedback_status_idx').on(table.status), + }) +); + +export const feedbackVotes = feedbackSchema.table( + 'feedback_votes', + { + id: uuid('id').primaryKey().defaultRandom(), + feedbackId: uuid('feedback_id') + .notNull() + .references(() => userFeedback.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + feedbackUserUnique: unique('feedback_votes_unique').on(table.feedbackId, table.userId), + }) +); + +export type Feedback = typeof userFeedback.$inferSelect; +export type FeedbackVote = typeof feedbackVotes.$inferSelect; diff --git a/services/mana-analytics/src/db/schema/index.ts b/services/mana-analytics/src/db/schema/index.ts new file mode 100644 index 000000000..3c6cb93bb --- /dev/null +++ b/services/mana-analytics/src/db/schema/index.ts @@ -0,0 +1 @@ +export * from './feedback'; diff --git a/services/mana-analytics/src/index.ts b/services/mana-analytics/src/index.ts new file mode 100644 index 000000000..a31850ef9 --- /dev/null +++ b/services/mana-analytics/src/index.ts @@ -0,0 +1,38 @@ +/** + * mana-analytics — Feedback and analytics service + * + * Hono + Bun runtime. Extracted from mana-core-auth. + * Handles: user feedback, voting, AI-powered title generation. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { loadConfig } from './config'; +import { getDb } from './db/connection'; +import { errorHandler } from './middleware/error-handler'; +import { jwtAuth } from './middleware/jwt-auth'; +import { FeedbackService } from './services/feedback'; +import { healthRoutes } from './routes/health'; +import { createFeedbackRoutes } from './routes/feedback'; + +const config = loadConfig(); +const db = getDb(config.databaseUrl); + +const feedbackService = new FeedbackService(db, config.manaLlmUrl); + +const app = new Hono(); + +app.onError(errorHandler); +app.use('*', cors({ origin: config.cors.origins, credentials: true })); + +app.route('/health', healthRoutes); + +app.use('/api/v1/feedback/*', jwtAuth(config.manaAuthUrl)); +app.route('/api/v1/feedback', createFeedbackRoutes(feedbackService)); + +console.log(`mana-analytics starting on port ${config.port}...`); + +export default { + port: config.port, + fetch: app.fetch, +}; diff --git a/services/mana-analytics/src/lib/errors.ts b/services/mana-analytics/src/lib/errors.ts new file mode 100644 index 000000000..d3b2c3392 --- /dev/null +++ b/services/mana-analytics/src/lib/errors.ts @@ -0,0 +1,43 @@ +import { HTTPException } from 'hono/http-exception'; + +export class BadRequestError extends HTTPException { + constructor(message: string) { + super(400, { message }); + } +} + +export class UnauthorizedError extends HTTPException { + constructor(message = 'Unauthorized') { + super(401, { message }); + } +} + +export class ForbiddenError extends HTTPException { + constructor(message = 'Forbidden') { + super(403, { message }); + } +} + +export class NotFoundError extends HTTPException { + constructor(message = 'Not found') { + super(404, { message }); + } +} + +export class ConflictError extends HTTPException { + constructor(message = 'Conflict') { + super(409, { message }); + } +} + +export class InsufficientCreditsError extends HTTPException { + constructor( + public readonly required: number, + public readonly available: number + ) { + super(402, { + message: 'Insufficient credits', + cause: { required, available }, + }); + } +} diff --git a/services/mana-analytics/src/middleware/error-handler.ts b/services/mana-analytics/src/middleware/error-handler.ts new file mode 100644 index 000000000..cec6640e8 --- /dev/null +++ b/services/mana-analytics/src/middleware/error-handler.ts @@ -0,0 +1,29 @@ +/** + * Global error handler middleware for Hono. + */ + +import type { ErrorHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +export const errorHandler: ErrorHandler = (err, c) => { + if (err instanceof HTTPException) { + const cause = err.cause as Record | undefined; + return c.json( + { + statusCode: err.status, + message: err.message, + ...(cause ? { details: cause } : {}), + }, + err.status + ); + } + + console.error('Unhandled error:', err); + return c.json( + { + statusCode: 500, + message: 'Internal server error', + }, + 500 + ); +}; diff --git a/services/mana-analytics/src/middleware/jwt-auth.ts b/services/mana-analytics/src/middleware/jwt-auth.ts new file mode 100644 index 000000000..390319288 --- /dev/null +++ b/services/mana-analytics/src/middleware/jwt-auth.ts @@ -0,0 +1,57 @@ +/** + * JWT Authentication Middleware + * + * Validates Bearer tokens via JWKS from mana-core-auth. + * Uses jose library with EdDSA algorithm. + */ + +import type { MiddlewareHandler } from 'hono'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { UnauthorizedError } from '../lib/errors'; + +let jwks: ReturnType | null = null; + +function getJwks(authUrl: string) { + if (!jwks) { + jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl)); + } + return jwks; +} + +export interface AuthUser { + userId: string; + email: string; + role: string; +} + +/** + * Middleware that validates JWT tokens from Authorization: Bearer header. + * Sets c.set('user', { userId, email, role }) on success. + */ +export function jwtAuth(authUrl: string): MiddlewareHandler { + return async (c, next) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedError('Missing or invalid Authorization header'); + } + + const token = authHeader.slice(7); + try { + const { payload } = await jwtVerify(token, getJwks(authUrl), { + issuer: authUrl, + audience: 'manacore', + }); + + const user: AuthUser = { + userId: payload.sub || '', + email: (payload.email as string) || '', + role: (payload.role as string) || 'user', + }; + + c.set('user', user); + await next(); + } catch { + throw new UnauthorizedError('Invalid or expired token'); + } + }; +} diff --git a/services/mana-analytics/src/middleware/service-auth.ts b/services/mana-analytics/src/middleware/service-auth.ts new file mode 100644 index 000000000..a1012a11d --- /dev/null +++ b/services/mana-analytics/src/middleware/service-auth.ts @@ -0,0 +1,26 @@ +/** + * Service-to-Service Authentication Middleware + * + * Validates X-Service-Key header for backend-to-backend calls. + * Used by /internal/* routes. + */ + +import type { MiddlewareHandler } from 'hono'; +import { UnauthorizedError } from '../lib/errors'; + +/** + * Middleware that validates X-Service-Key header. + * Sets c.set('appId', ...) from X-App-Id header. + */ +export function serviceAuth(serviceKey: string): MiddlewareHandler { + return async (c, next) => { + const key = c.req.header('X-Service-Key'); + if (!key || key !== serviceKey) { + throw new UnauthorizedError('Invalid or missing service key'); + } + + const appId = c.req.header('X-App-Id') || 'unknown'; + c.set('appId', appId); + await next(); + }; +} diff --git a/services/mana-analytics/src/routes/feedback.ts b/services/mana-analytics/src/routes/feedback.ts new file mode 100644 index 000000000..84d015444 --- /dev/null +++ b/services/mana-analytics/src/routes/feedback.ts @@ -0,0 +1,34 @@ +import { Hono } from 'hono'; +import type { FeedbackService } from '../services/feedback'; +import type { AuthUser } from '../middleware/jwt-auth'; + +export function createFeedbackRoutes(feedbackService: FeedbackService) { + return new Hono<{ Variables: { user: AuthUser } }>() + .post('/', async (c) => { + const user = c.get('user'); + const body = await c.req.json(); + return c.json(await feedbackService.createFeedback(user.userId, body), 201); + }) + .get('/public', async (c) => { + const appId = c.req.query('appId'); + const limit = parseInt(c.req.query('limit') || '50', 10); + const offset = parseInt(c.req.query('offset') || '0', 10); + return c.json(await feedbackService.getPublicFeedback(appId, limit, offset)); + }) + .get('/me', async (c) => { + const user = c.get('user'); + return c.json(await feedbackService.getMyFeedback(user.userId)); + }) + .post('/:id/vote', async (c) => { + const user = c.get('user'); + return c.json(await feedbackService.vote(c.req.param('id'), user.userId)); + }) + .delete('/:id/vote', async (c) => { + const user = c.get('user'); + return c.json(await feedbackService.unvote(c.req.param('id'), user.userId)); + }) + .delete('/:id', async (c) => { + const user = c.get('user'); + return c.json(await feedbackService.deleteFeedback(c.req.param('id'), user.userId)); + }); +} diff --git a/services/mana-analytics/src/routes/health.ts b/services/mana-analytics/src/routes/health.ts new file mode 100644 index 000000000..9ece52084 --- /dev/null +++ b/services/mana-analytics/src/routes/health.ts @@ -0,0 +1,5 @@ +import { Hono } from 'hono'; + +export const healthRoutes = new Hono().get('/', (c) => + c.json({ status: 'ok', service: 'mana-analytics', timestamp: new Date().toISOString() }) +); diff --git a/services/mana-analytics/src/services/feedback.ts b/services/mana-analytics/src/services/feedback.ts new file mode 100644 index 000000000..1ec1acaec --- /dev/null +++ b/services/mana-analytics/src/services/feedback.ts @@ -0,0 +1,126 @@ +/** + * Feedback Service — User feedback CRUD with voting + */ + +import { eq, and, desc, sql } from 'drizzle-orm'; +import { userFeedback, feedbackVotes } from '../db/schema/feedback'; +import type { Database } from '../db/connection'; +import { NotFoundError } from '../lib/errors'; + +export class FeedbackService { + constructor( + private db: Database, + private llmUrl: string + ) {} + + async createFeedback( + userId: string, + data: { + appId: string; + feedbackText: string; + category?: string; + title?: string; + deviceInfo?: Record; + } + ) { + let title = data.title; + + // Auto-generate title via LLM if not provided + if (!title && this.llmUrl) { + try { + title = await this.generateTitle(data.feedbackText); + } catch { + title = data.feedbackText.slice(0, 80); + } + } + + const [feedback] = await this.db + .insert(userFeedback) + .values({ + userId, + appId: data.appId, + title: title || data.feedbackText.slice(0, 80), + feedbackText: data.feedbackText, + category: (data.category as any) || 'other', + deviceInfo: data.deviceInfo, + }) + .returning(); + + return feedback; + } + + async getPublicFeedback(appId?: string, limit = 50, offset = 0) { + let query = this.db + .select() + .from(userFeedback) + .where(eq(userFeedback.isPublic, true)) + .orderBy(desc(userFeedback.voteCount)) + .limit(limit) + .offset(offset); + + return query; + } + + async getMyFeedback(userId: string) { + return this.db + .select() + .from(userFeedback) + .where(eq(userFeedback.userId, userId)) + .orderBy(desc(userFeedback.createdAt)); + } + + async vote(feedbackId: string, userId: string) { + await this.db.insert(feedbackVotes).values({ feedbackId, userId }).onConflictDoNothing(); + await this.db + .update(userFeedback) + .set({ voteCount: sql`${userFeedback.voteCount} + 1` }) + .where(eq(userFeedback.id, feedbackId)); + return { success: true }; + } + + async unvote(feedbackId: string, userId: string) { + const result = await this.db + .delete(feedbackVotes) + .where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId))) + .returning(); + + if (result.length > 0) { + await this.db + .update(userFeedback) + .set({ voteCount: sql`GREATEST(${userFeedback.voteCount} - 1, 0)` }) + .where(eq(userFeedback.id, feedbackId)); + } + return { success: true }; + } + + async deleteFeedback(feedbackId: string, userId: string) { + const result = await this.db + .delete(userFeedback) + .where(and(eq(userFeedback.id, feedbackId), eq(userFeedback.userId, userId))) + .returning(); + if (result.length === 0) throw new NotFoundError('Feedback not found'); + return { success: true }; + } + + private async generateTitle(text: string): Promise { + const res = await fetch(`${this.llmUrl}/api/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [ + { + role: 'system', + content: + 'Generate a short title (max 80 chars) for this feedback. Reply with only the title.', + }, + { role: 'user', content: text }, + ], + model: 'gemma3:4b', + max_tokens: 50, + }), + }); + if (!res.ok) throw new Error('LLM failed'); + const data = await res.json(); + return data.choices?.[0]?.message?.content?.trim() || text.slice(0, 80); + } +} diff --git a/services/mana-analytics/tsconfig.json b/services/mana-analytics/tsconfig.json new file mode 100644 index 000000000..8c513d34d --- /dev/null +++ b/services/mana-analytics/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 42966fc27..739137f71 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -5,16 +5,11 @@ import { APP_FILTER } from '@nestjs/core'; import { LlmModule } from '@manacore/shared-llm'; import configuration from './config/configuration'; import { AdminModule } from './admin/admin.module'; -import { AiModule } from './ai/ai.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { AuthModule } from './auth/auth.module'; -import { FeedbackModule } from './feedback/feedback.module'; import { GuildsModule } from './guilds/guilds.module'; import { HealthModule } from './health/health.module'; import { MeModule } from './me/me.module'; -import { SubscriptionsModule } from './subscriptions/subscriptions.module'; -import { StripeModule } from './stripe/stripe.module'; -import { AnalyticsModule } from './analytics'; import { MetricsModule } from './metrics'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { LoggerModule } from './common/logger'; @@ -43,17 +38,12 @@ import { SecurityModule } from './security'; LoggerModule, SecurityModule, MetricsModule, - AnalyticsModule, AdminModule, - AiModule, ApiKeysModule, AuthModule, - FeedbackModule, GuildsModule, HealthModule, MeModule, - StripeModule, - SubscriptionsModule, ], providers: [ { diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index cb7461096..fa9a6cf3c 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -1,6 +1,4 @@ export * from './api-keys.schema'; export * from './auth.schema'; -export * from './feedback.schema'; export * from './login-attempts.schema'; export * from './organizations.schema'; -export * from './subscriptions.schema';