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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:29:24 +01:00
parent 30e124e609
commit 753c685ef7
21 changed files with 562 additions and 83 deletions

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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
```

View file

@ -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'],
});

View file

@ -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"
}
}

View file

@ -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(',') },
};
}

View file

@ -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<typeof drizzle<typeof schema>> | 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<typeof getDb>;

View file

@ -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;

View file

@ -0,0 +1 @@
export * from './feedback';

View file

@ -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,
};

View file

@ -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 },
});
}
}

View file

@ -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<string, unknown> | 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
);
};

View file

@ -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<typeof createRemoteJWKSet> | 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');
}
};
}

View file

@ -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();
};
}

View file

@ -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));
});
}

View file

@ -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() })
);

View file

@ -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<string, unknown>;
}
) {
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<string> {
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);
}
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

View file

@ -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: [
{

View file

@ -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';