mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
30e124e609
commit
753c685ef7
21 changed files with 562 additions and 83 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
services/mana-analytics/CLAUDE.md
Normal file
29
services/mana-analytics/CLAUDE.md
Normal 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
|
||||
```
|
||||
12
services/mana-analytics/drizzle.config.ts
Normal file
12
services/mana-analytics/drizzle.config.ts
Normal 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'],
|
||||
});
|
||||
23
services/mana-analytics/package.json
Normal file
23
services/mana-analytics/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
services/mana-analytics/src/config.ts
Normal file
23
services/mana-analytics/src/config.ts
Normal 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(',') },
|
||||
};
|
||||
}
|
||||
19
services/mana-analytics/src/db/connection.ts
Normal file
19
services/mana-analytics/src/db/connection.ts
Normal 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>;
|
||||
74
services/mana-analytics/src/db/schema/feedback.ts
Normal file
74
services/mana-analytics/src/db/schema/feedback.ts
Normal 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;
|
||||
1
services/mana-analytics/src/db/schema/index.ts
Normal file
1
services/mana-analytics/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './feedback';
|
||||
38
services/mana-analytics/src/index.ts
Normal file
38
services/mana-analytics/src/index.ts
Normal 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,
|
||||
};
|
||||
43
services/mana-analytics/src/lib/errors.ts
Normal file
43
services/mana-analytics/src/lib/errors.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
29
services/mana-analytics/src/middleware/error-handler.ts
Normal file
29
services/mana-analytics/src/middleware/error-handler.ts
Normal 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
|
||||
);
|
||||
};
|
||||
57
services/mana-analytics/src/middleware/jwt-auth.ts
Normal file
57
services/mana-analytics/src/middleware/jwt-auth.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
26
services/mana-analytics/src/middleware/service-auth.ts
Normal file
26
services/mana-analytics/src/middleware/service-auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
34
services/mana-analytics/src/routes/feedback.ts
Normal file
34
services/mana-analytics/src/routes/feedback.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
5
services/mana-analytics/src/routes/health.ts
Normal file
5
services/mana-analytics/src/routes/health.ts
Normal 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() })
|
||||
);
|
||||
126
services/mana-analytics/src/services/feedback.ts
Normal file
126
services/mana-analytics/src/services/feedback.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
services/mana-analytics/tsconfig.json
Normal file
13
services/mana-analytics/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue