mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 19:46:42 +02:00
feat(todo/web, shared-i18n): complete i18n for Todo web app + add missing common translations
Extract ~120 hardcoded German strings from 14 Svelte components into i18n locale files using svelte-i18n $t() calls. Add new translation sections (taskForm, filters, tags, subtasks, durationPicker, kanban, toolbar) across all 5 languages (de/en/fr/es/it). Also add missing shared common translations for Spanish, French, and Italian (150+ keys each) in packages/shared-i18n. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c66492279
commit
cb85fba820
30 changed files with 2145 additions and 248 deletions
|
|
@ -40,6 +40,24 @@ Handled directly by Better Auth — includes sign-in, sign-up, session, 2FA, mag
|
|||
### OIDC (`/.well-known/*`, `/api/auth/oauth2/*`)
|
||||
OpenID Connect provider for Matrix/Synapse SSO.
|
||||
|
||||
### Me — GDPR Self-Service (`/api/v1/me/*`)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/data` | Full user data summary (auth, credits, project entities) |
|
||||
| GET | `/data/export` | Download all data as JSON file |
|
||||
| DELETE | `/data` | Delete all user data across all services (right to be forgotten) |
|
||||
|
||||
Aggregates data from 3 sources: auth DB (sessions, accounts, 2FA, passkeys), mana-credits (balance, transactions), mana-sync DB (entity counts per app).
|
||||
|
||||
### Admin (`/api/v1/admin/*`)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/users` | Paginated user list with search (`?page=1&limit=20&search=`) |
|
||||
| GET | `/users/:id/data` | Aggregated user data summary (same as /me/data) |
|
||||
| DELETE | `/users/:id/data` | Delete all user data (admin) |
|
||||
| GET | `/users/:id/tier` | Get user's access tier |
|
||||
| PUT | `/users/:id/tier` | Update user's access tier |
|
||||
|
||||
### Internal (`/api/v1/internal/*`)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
|
|
@ -54,11 +72,13 @@ Session cookies shared across `*.mana.how` via `COOKIE_DOMAIN=.mana.how`.
|
|||
```env
|
||||
PORT=3001
|
||||
DATABASE_URL=postgresql://...
|
||||
SYNC_DATABASE_URL=postgresql://.../mana_sync # mana-sync DB for entity counts (GDPR data view)
|
||||
BASE_URL=https://auth.mana.how
|
||||
COOKIE_DOMAIN=.mana.how
|
||||
NODE_ENV=production
|
||||
MANA_CORE_SERVICE_KEY=...
|
||||
MANA_CREDITS_URL=http://mana-credits:3061
|
||||
MANA_SUBSCRIPTIONS_URL=http://mana-subscriptions:3063
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=...
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
syncDatabaseUrl: string;
|
||||
baseUrl: string;
|
||||
cookieDomain: string;
|
||||
nodeEnv: string;
|
||||
|
|
@ -22,6 +23,10 @@ export function loadConfig(): Config {
|
|||
return {
|
||||
port: parseInt(env('PORT', '3001'), 10),
|
||||
databaseUrl: env('DATABASE_URL', 'postgresql://manacore:devpassword@localhost:5432/mana_auth'),
|
||||
syncDatabaseUrl: env(
|
||||
'SYNC_DATABASE_URL',
|
||||
'postgresql://manacore:devpassword@localhost:5432/mana_sync'
|
||||
),
|
||||
baseUrl: env('BASE_URL', 'http://localhost:3001'),
|
||||
cookieDomain: env('COOKIE_DOMAIN'),
|
||||
nodeEnv: env('NODE_ENV', 'development'),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { initializeEmail } from './email/send';
|
|||
import { SecurityEventsService, AccountLockoutService } from './services/security';
|
||||
import { SignupLimitService } from './services/signup-limit';
|
||||
import { ApiKeysService } from './services/api-keys';
|
||||
import { UserDataService } from './services/user-data';
|
||||
import { createAuthRoutes } from './routes/auth';
|
||||
import { createGuildRoutes } from './routes/guilds';
|
||||
import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys';
|
||||
|
|
@ -35,6 +36,7 @@ const security = new SecurityEventsService(db);
|
|||
const lockout = new AccountLockoutService(db);
|
||||
const signupLimit = new SignupLimitService(db);
|
||||
const apiKeysService = new ApiKeysService(db);
|
||||
const userDataService = new UserDataService(db, config);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -80,12 +82,12 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService));
|
|||
// ─── Me (GDPR) ──────────────────────────────────────────────
|
||||
|
||||
app.use('/api/v1/me/*', jwtAuth(config.baseUrl));
|
||||
app.route('/api/v1/me', createMeRoutes(db));
|
||||
app.route('/api/v1/me', createMeRoutes(userDataService));
|
||||
|
||||
// ─── Admin ──────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/v1/admin/*', jwtAuth(config.baseUrl));
|
||||
app.route('/api/v1/admin', createAdminRoutes(db));
|
||||
app.route('/api/v1/admin', createAdminRoutes(db, userDataService));
|
||||
|
||||
// ─── Internal API ───────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
/**
|
||||
* Admin routes — User tier management
|
||||
* Admin routes — User management, tier management, user data access
|
||||
*
|
||||
* Protected by JWT auth + admin role check.
|
||||
* Only users with role 'admin' can manage tiers.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
|
@ -10,11 +9,12 @@ import { eq } from 'drizzle-orm';
|
|||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import { users } from '../db/schema/auth';
|
||||
import type { UserDataService } from '../services/user-data';
|
||||
|
||||
const VALID_TIERS = ['guest', 'public', 'beta', 'alpha', 'founder'] as const;
|
||||
type AccessTier = (typeof VALID_TIERS)[number];
|
||||
|
||||
export function createAdminRoutes(db: PostgresJsDatabase<any>) {
|
||||
export function createAdminRoutes(db: PostgresJsDatabase<any>, userDataService: UserDataService) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// Admin role check middleware
|
||||
|
|
@ -26,7 +26,74 @@ export function createAdminRoutes(db: PostgresJsDatabase<any>) {
|
|||
await next();
|
||||
});
|
||||
|
||||
// ─── Update user's access tier ─────────────────────────────
|
||||
// ─── List users with pagination and search ────────────────
|
||||
|
||||
app.get('/users', async (c) => {
|
||||
const page = parseInt(c.req.query('page') || '1', 10);
|
||||
const limit = parseInt(c.req.query('limit') || '20', 10);
|
||||
const search = c.req.query('search');
|
||||
const tier = c.req.query('tier');
|
||||
|
||||
// If tier-only query (legacy), use simple response
|
||||
if (tier && !search && !c.req.query('page')) {
|
||||
if (!VALID_TIERS.includes(tier as AccessTier)) {
|
||||
return c.json({ error: 'Invalid tier' }, 400);
|
||||
}
|
||||
const result = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
accessTier: users.accessTier,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.accessTier, tier as AccessTier))
|
||||
.limit(limit);
|
||||
|
||||
return c.json({ users: result, count: result.length });
|
||||
}
|
||||
|
||||
// Full paginated list with search
|
||||
const result = await userDataService.listUsers(page, limit, search || undefined);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// ─── Get user data summary (aggregated) ───────────────────
|
||||
|
||||
app.get('/users/:userId/data', async (c) => {
|
||||
const { userId } = c.req.param();
|
||||
const summary = await userDataService.getUserDataSummary(userId);
|
||||
|
||||
if (!summary) {
|
||||
return c.json({ error: 'Not found', message: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json(summary);
|
||||
});
|
||||
|
||||
// ─── Delete user data ─────────────────────────────────────
|
||||
|
||||
app.delete('/users/:userId/data', async (c) => {
|
||||
const { userId } = c.req.param();
|
||||
|
||||
// Get user email first for confirmation
|
||||
const [user] = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Not found', message: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
const result = await userDataService.deleteUserData(userId, user.email);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// ─── Update user's access tier ────────────────────────────
|
||||
|
||||
app.put('/users/:userId/tier', async (c) => {
|
||||
const { userId } = c.req.param();
|
||||
|
|
@ -53,13 +120,10 @@ export function createAdminRoutes(db: PostgresJsDatabase<any>) {
|
|||
return c.json({ error: 'Not found', message: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
user: updated,
|
||||
});
|
||||
return c.json({ success: true, user: updated });
|
||||
});
|
||||
|
||||
// ─── Get user's current tier ───────────────────────────────
|
||||
// ─── Get user's current tier ──────────────────────────────
|
||||
|
||||
app.get('/users/:userId/tier', async (c) => {
|
||||
const { userId } = c.req.param();
|
||||
|
|
@ -77,32 +141,5 @@ export function createAdminRoutes(db: PostgresJsDatabase<any>) {
|
|||
return c.json(user);
|
||||
});
|
||||
|
||||
// ─── List all users with their tiers ───────────────────────
|
||||
|
||||
app.get('/users', async (c) => {
|
||||
const tier = c.req.query('tier');
|
||||
const limit = parseInt(c.req.query('limit') || '50', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
|
||||
let query = db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
accessTier: users.accessTier,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
if (tier && VALID_TIERS.includes(tier as AccessTier)) {
|
||||
query = query.where(eq(users.accessTier, tier as AccessTier)) as typeof query;
|
||||
}
|
||||
|
||||
const result = await query.limit(limit).offset(offset);
|
||||
|
||||
return c.json({ users: result, count: result.length });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,61 @@
|
|||
/**
|
||||
* Me routes — GDPR self-service data management
|
||||
*
|
||||
* GET /data — Full user data summary (auth, credits, projects)
|
||||
* GET /data/export — Download all data as JSON
|
||||
* DELETE /data — Delete all user data (right to be forgotten)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { Database } from '../db/connection';
|
||||
import type { UserDataService } from '../services/user-data';
|
||||
import { sendAccountDeletionEmail } from '../email/send';
|
||||
|
||||
export function createMeRoutes(db: Database) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/data', async (c) => {
|
||||
const user = c.get('user');
|
||||
// Return basic user data summary
|
||||
const result = await db.execute(
|
||||
sql`SELECT id, email, name, role, created_at FROM auth.users WHERE id = ${user.userId}`
|
||||
);
|
||||
return c.json({ user: (result as any)[0] || null });
|
||||
})
|
||||
.get('/data/export', async (c) => {
|
||||
const user = c.get('user');
|
||||
const [userData] = (await db.execute(
|
||||
sql`SELECT * FROM auth.users WHERE id = ${user.userId}`
|
||||
)) as any[];
|
||||
const sessions = await db.execute(
|
||||
sql`SELECT id, created_at, expires_at, ip_address FROM auth.sessions WHERE user_id = ${user.userId}`
|
||||
);
|
||||
const securityEvents = await db.execute(
|
||||
sql`SELECT event_type, ip_address, created_at FROM auth.security_events WHERE user_id = ${user.userId} ORDER BY created_at DESC LIMIT 100`
|
||||
);
|
||||
export function createMeRoutes(userDataService: UserDataService) {
|
||||
return (
|
||||
new Hono<{ Variables: { user: AuthUser } }>()
|
||||
|
||||
return c.json({
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportVersion: '1.0',
|
||||
user: userData,
|
||||
sessions,
|
||||
securityEvents,
|
||||
});
|
||||
})
|
||||
.delete('/data', async (c) => {
|
||||
const user = c.get('user');
|
||||
// Delete user (cascades via FK)
|
||||
await db.execute(sql`DELETE FROM auth.users WHERE id = ${user.userId}`);
|
||||
// Send confirmation email
|
||||
sendAccountDeletionEmail(user.email).catch(() => {});
|
||||
return c.json({ success: true, message: 'Account and all data deleted' });
|
||||
});
|
||||
// ─── Get full user data summary ─────────────────────────
|
||||
.get('/data', async (c) => {
|
||||
const user = c.get('user');
|
||||
const summary = await userDataService.getUserDataSummary(user.userId);
|
||||
|
||||
if (!summary) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
return c.json(summary);
|
||||
})
|
||||
|
||||
// ─── Export user data as JSON download ──────────────────
|
||||
.get('/data/export', async (c) => {
|
||||
const user = c.get('user');
|
||||
const exportData = await userDataService.exportUserData(user.userId);
|
||||
|
||||
if (!exportData) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
const filename = `meine-daten-${new Date().toISOString().split('T')[0]}.json`;
|
||||
const json = JSON.stringify(exportData, null, 2);
|
||||
|
||||
return new Response(json, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// ─── Delete all user data ───────────────────────────────
|
||||
.delete('/data', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await userDataService.deleteUserData(user.userId, user.email);
|
||||
|
||||
// Send confirmation email (fire-and-forget)
|
||||
sendAccountDeletionEmail(user.email).catch(() => {});
|
||||
|
||||
return c.json(result);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
579
services/mana-auth/src/services/user-data.ts
Normal file
579
services/mana-auth/src/services/user-data.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
/**
|
||||
* User Data Aggregation Service
|
||||
*
|
||||
* Aggregates user data from auth DB, mana-credits, and mana-sync
|
||||
* for GDPR self-service (/me) and admin endpoints.
|
||||
*/
|
||||
|
||||
import { eq, sql, and, count, isNull, desc, ilike, or } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import type { Config } from '../config';
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
accounts,
|
||||
twoFactorAuth,
|
||||
passkeys,
|
||||
securityEvents,
|
||||
} from '../db/schema/auth';
|
||||
import { apiKeys } from '../db/schema/api-keys';
|
||||
import postgres from 'postgres';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export interface AuthDataSummary {
|
||||
sessionsCount: number;
|
||||
accountsCount: number;
|
||||
has2FA: boolean;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreditsDataSummary {
|
||||
balance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
transactionsCount: number;
|
||||
}
|
||||
|
||||
export interface EntityCount {
|
||||
entity: string;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProjectDataSummary {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
icon: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
entities: EntityCount[];
|
||||
totalCount: number;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
export interface UserDataSummary {
|
||||
user: UserInfo;
|
||||
auth: AuthDataSummary;
|
||||
credits: CreditsDataSummary;
|
||||
projects: ProjectDataSummary[];
|
||||
totals: {
|
||||
totalEntities: number;
|
||||
projectsWithData: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectDeleteResult {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeleteUserDataResponse {
|
||||
success: boolean;
|
||||
deletedFromProjects: ProjectDeleteResult[];
|
||||
deletedFromAuth: {
|
||||
sessions: number;
|
||||
accounts: number;
|
||||
credits: number;
|
||||
user: boolean;
|
||||
};
|
||||
totalDeleted: number;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
users: UserListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// ─── Project Metadata ──────────────────────────────────────
|
||||
|
||||
const PROJECT_META: Record<string, { name: string; icon: string }> = {
|
||||
todo: { name: 'Todo', icon: '✅' },
|
||||
chat: { name: 'ManaChat', icon: '💬' },
|
||||
calendar: { name: 'Kalender', icon: '📅' },
|
||||
clock: { name: 'Clock', icon: '⏰' },
|
||||
contacts: { name: 'Kontakte', icon: '👤' },
|
||||
cards: { name: 'Cards', icon: '🃏' },
|
||||
picture: { name: 'ManaPicture', icon: '🎨' },
|
||||
zitare: { name: 'Zitare', icon: '✨' },
|
||||
presi: { name: 'Presi', icon: '📊' },
|
||||
inventar: { name: 'Inventar', icon: '📦' },
|
||||
nutriphi: { name: 'Nutriphi', icon: '🥗' },
|
||||
planta: { name: 'Planta', icon: '🌱' },
|
||||
storage: { name: 'Storage', icon: '☁️' },
|
||||
questions: { name: 'Questions', icon: '❓' },
|
||||
mukke: { name: 'Mukke', icon: '🎵' },
|
||||
context: { name: 'Context', icon: '📄' },
|
||||
photos: { name: 'Photos', icon: '📷' },
|
||||
skilltree: { name: 'SkillTree', icon: '🌳' },
|
||||
citycorners: { name: 'CityCorners', icon: '🏙️' },
|
||||
times: { name: 'Taktik', icon: '⏱️' },
|
||||
uload: { name: 'uLoad', icon: '🔗' },
|
||||
calc: { name: 'Calc', icon: '🧮' },
|
||||
manacore: { name: 'ManaCore', icon: '💎' },
|
||||
};
|
||||
|
||||
/** Convert camelCase/snake_case table name to readable label */
|
||||
function tableNameToLabel(name: string): string {
|
||||
return name
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/^\w/, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
// ─── Service ───────────────────────────────────────────────
|
||||
|
||||
export class UserDataService {
|
||||
private syncSql: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
constructor(
|
||||
private db: Database,
|
||||
private config: Config
|
||||
) {}
|
||||
|
||||
private getSyncSql() {
|
||||
if (!this.syncSql) {
|
||||
this.syncSql = postgres(this.config.syncDatabaseUrl, { max: 5 });
|
||||
}
|
||||
return this.syncSql;
|
||||
}
|
||||
|
||||
// ─── User Info ───────────────────────────────────────────
|
||||
|
||||
async getUserInfo(userId: string): Promise<UserInfo | null> {
|
||||
const [user] = await this.db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
emailVerified: users.emailVerified,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
...user,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Auth Data ───────────────────────────────────────────
|
||||
|
||||
async getAuthData(userId: string): Promise<AuthDataSummary> {
|
||||
const [sessionsResult, accountsResult, twoFaResult, lastSession] = await Promise.all([
|
||||
this.db
|
||||
.select({ count: count() })
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt))),
|
||||
this.db.select({ count: count() }).from(accounts).where(eq(accounts.userId, userId)),
|
||||
this.db
|
||||
.select({ enabled: twoFactorAuth.enabled })
|
||||
.from(twoFactorAuth)
|
||||
.where(eq(twoFactorAuth.userId, userId))
|
||||
.limit(1),
|
||||
this.db
|
||||
.select({ lastActivity: sessions.lastActivityAt })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId))
|
||||
.orderBy(desc(sessions.lastActivityAt))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
return {
|
||||
sessionsCount: sessionsResult[0]?.count ?? 0,
|
||||
accountsCount: accountsResult[0]?.count ?? 0,
|
||||
has2FA: twoFaResult[0]?.enabled ?? false,
|
||||
lastLoginAt: lastSession[0]?.lastActivity?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Credits Data ────────────────────────────────────────
|
||||
|
||||
async getCreditsData(userId: string): Promise<CreditsDataSummary> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${this.config.manaCreditsUrl}/api/v1/internal/credits/balance/${userId}`,
|
||||
{ headers: { 'X-Service-Key': this.config.serviceKey } }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return { balance: 0, totalEarned: 0, totalSpent: 0, transactionsCount: 0 };
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
balance?: number;
|
||||
totalEarned?: number;
|
||||
totalSpent?: number;
|
||||
transactionsCount?: number;
|
||||
};
|
||||
|
||||
return {
|
||||
balance: data.balance ?? 0,
|
||||
totalEarned: data.totalEarned ?? 0,
|
||||
totalSpent: data.totalSpent ?? 0,
|
||||
transactionsCount: data.transactionsCount ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return { balance: 0, totalEarned: 0, totalSpent: 0, transactionsCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Project Data (from mana-sync) ───────────────────────
|
||||
|
||||
async getProjectData(userId: string): Promise<ProjectDataSummary[]> {
|
||||
try {
|
||||
const syncSql = this.getSyncSql();
|
||||
|
||||
// Get entity counts per app/table (latest state, excluding deleted)
|
||||
const entityCounts = await syncSql`
|
||||
SELECT app_id, table_name, COUNT(*) as count
|
||||
FROM (
|
||||
SELECT DISTINCT ON (app_id, table_name, record_id)
|
||||
app_id, table_name, record_id, op
|
||||
FROM sync_changes
|
||||
WHERE user_id = ${userId}
|
||||
ORDER BY app_id, table_name, record_id, created_at DESC
|
||||
) latest
|
||||
WHERE op != 'delete'
|
||||
GROUP BY app_id, table_name
|
||||
ORDER BY app_id, table_name
|
||||
`;
|
||||
|
||||
// Get last activity per app
|
||||
const lastActivity = await syncSql`
|
||||
SELECT app_id, MAX(created_at) as last_activity
|
||||
FROM sync_changes
|
||||
WHERE user_id = ${userId}
|
||||
GROUP BY app_id
|
||||
`;
|
||||
|
||||
const lastActivityMap = new Map<string, string>();
|
||||
for (const row of lastActivity) {
|
||||
lastActivityMap.set(row.app_id, new Date(row.last_activity).toISOString());
|
||||
}
|
||||
|
||||
// Group by app
|
||||
const appEntities = new Map<string, EntityCount[]>();
|
||||
for (const row of entityCounts) {
|
||||
const appId = row.app_id;
|
||||
if (!appEntities.has(appId)) {
|
||||
appEntities.set(appId, []);
|
||||
}
|
||||
appEntities.get(appId)!.push({
|
||||
entity: row.table_name,
|
||||
count: Number(row.count),
|
||||
label: tableNameToLabel(row.table_name),
|
||||
});
|
||||
}
|
||||
|
||||
// Build project summaries for all known projects
|
||||
const projects: ProjectDataSummary[] = [];
|
||||
|
||||
for (const [projectId, meta] of Object.entries(PROJECT_META)) {
|
||||
const entities = appEntities.get(projectId) || [];
|
||||
const totalCount = entities.reduce((sum, e) => sum + e.count, 0);
|
||||
|
||||
projects.push({
|
||||
projectId,
|
||||
projectName: meta.name,
|
||||
icon: meta.icon,
|
||||
available: true,
|
||||
entities,
|
||||
totalCount,
|
||||
lastActivityAt: lastActivityMap.get(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
// Add any unknown apps from sync data
|
||||
for (const [appId, entities] of appEntities) {
|
||||
if (!PROJECT_META[appId]) {
|
||||
const totalCount = entities.reduce((sum, e) => sum + e.count, 0);
|
||||
projects.push({
|
||||
projectId: appId,
|
||||
projectName: appId,
|
||||
icon: '📁',
|
||||
available: true,
|
||||
entities,
|
||||
totalCount,
|
||||
lastActivityAt: lastActivityMap.get(appId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return projects;
|
||||
} catch (err) {
|
||||
// If sync DB is unavailable, return all projects as unavailable
|
||||
return Object.entries(PROJECT_META).map(([projectId, meta]) => ({
|
||||
projectId,
|
||||
projectName: meta.name,
|
||||
icon: meta.icon,
|
||||
available: false,
|
||||
error: 'Sync-Datenbank nicht erreichbar',
|
||||
entities: [],
|
||||
totalCount: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Full Summary ────────────────────────────────────────
|
||||
|
||||
async getUserDataSummary(userId: string): Promise<UserDataSummary | null> {
|
||||
const userInfo = await this.getUserInfo(userId);
|
||||
if (!userInfo) return null;
|
||||
|
||||
const [auth, credits, projects] = await Promise.all([
|
||||
this.getAuthData(userId),
|
||||
this.getCreditsData(userId),
|
||||
this.getProjectData(userId),
|
||||
]);
|
||||
|
||||
const totalEntities = projects.reduce((sum, p) => sum + p.totalCount, 0);
|
||||
const projectsWithData = projects.filter((p) => p.totalCount > 0).length;
|
||||
|
||||
return {
|
||||
user: userInfo,
|
||||
auth,
|
||||
credits,
|
||||
projects,
|
||||
totals: { totalEntities, projectsWithData },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Export ──────────────────────────────────────────────
|
||||
|
||||
async exportUserData(userId: string) {
|
||||
const summary = await this.getUserDataSummary(userId);
|
||||
if (!summary) return null;
|
||||
|
||||
// Also fetch detailed auth data for export
|
||||
const [userSessions, userPasskeys, userApiKeys, userSecurityEvents] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
createdAt: sessions.createdAt,
|
||||
expiresAt: sessions.expiresAt,
|
||||
ipAddress: sessions.ipAddress,
|
||||
deviceName: sessions.deviceName,
|
||||
lastActivityAt: sessions.lastActivityAt,
|
||||
revokedAt: sessions.revokedAt,
|
||||
})
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId)),
|
||||
this.db
|
||||
.select({
|
||||
id: passkeys.id,
|
||||
friendlyName: passkeys.friendlyName,
|
||||
deviceType: passkeys.deviceType,
|
||||
createdAt: passkeys.createdAt,
|
||||
lastUsedAt: passkeys.lastUsedAt,
|
||||
})
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, userId)),
|
||||
this.db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
scopes: apiKeys.scopes,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId)),
|
||||
this.db
|
||||
.select({
|
||||
eventType: securityEvents.eventType,
|
||||
ipAddress: securityEvents.ipAddress,
|
||||
createdAt: securityEvents.createdAt,
|
||||
})
|
||||
.from(securityEvents)
|
||||
.where(eq(securityEvents.userId, userId))
|
||||
.orderBy(desc(securityEvents.createdAt))
|
||||
.limit(200),
|
||||
]);
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportVersion: '2.0',
|
||||
data: summary,
|
||||
details: {
|
||||
sessions: userSessions,
|
||||
passkeys: userPasskeys,
|
||||
apiKeys: userApiKeys,
|
||||
securityEvents: userSecurityEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────
|
||||
|
||||
async deleteUserData(userId: string, userEmail: string): Promise<DeleteUserDataResponse> {
|
||||
const deletedFromProjects: ProjectDeleteResult[] = [];
|
||||
let totalDeleted = 0;
|
||||
|
||||
// 1. Delete sync data
|
||||
try {
|
||||
const syncSql = this.getSyncSql();
|
||||
const result = await syncSql`
|
||||
DELETE FROM sync_changes WHERE user_id = ${userId}
|
||||
`;
|
||||
const deletedCount = result.count;
|
||||
totalDeleted += deletedCount;
|
||||
deletedFromProjects.push({
|
||||
projectId: 'sync',
|
||||
projectName: 'Sync-Daten',
|
||||
success: true,
|
||||
deletedCount,
|
||||
});
|
||||
} catch (err) {
|
||||
deletedFromProjects.push({
|
||||
projectId: 'sync',
|
||||
projectName: 'Sync-Daten',
|
||||
success: false,
|
||||
error: 'Sync-Datenbank nicht erreichbar',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Delete credits data
|
||||
let creditsDeleted = 0;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${this.config.manaCreditsUrl}/api/v1/internal/credits/balance/${userId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Service-Key': this.config.serviceKey },
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { deletedCount?: number };
|
||||
creditsDeleted = data.deletedCount ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// Credits deletion is best-effort
|
||||
}
|
||||
|
||||
// 3. Count auth records before deletion
|
||||
const [sessionsCount, accountsCount] = await Promise.all([
|
||||
this.db.select({ count: count() }).from(sessions).where(eq(sessions.userId, userId)),
|
||||
this.db.select({ count: count() }).from(accounts).where(eq(accounts.userId, userId)),
|
||||
]);
|
||||
|
||||
const deletedSessions = sessionsCount[0]?.count ?? 0;
|
||||
const deletedAccounts = accountsCount[0]?.count ?? 0;
|
||||
totalDeleted += deletedSessions + deletedAccounts + creditsDeleted;
|
||||
|
||||
// 4. Delete user (cascades sessions, accounts, passkeys, api keys, etc.)
|
||||
await this.db.delete(users).where(eq(users.id, userId));
|
||||
totalDeleted += 1; // the user record itself
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedFromProjects,
|
||||
deletedFromAuth: {
|
||||
sessions: deletedSessions,
|
||||
accounts: deletedAccounts,
|
||||
credits: creditsDeleted,
|
||||
user: true,
|
||||
},
|
||||
totalDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── User List (Admin) ───────────────────────────────────
|
||||
|
||||
async listUsers(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
search?: string
|
||||
): Promise<UserListResponse> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Count total
|
||||
let totalQuery = this.db.select({ count: count() }).from(users);
|
||||
if (search) {
|
||||
totalQuery = totalQuery.where(
|
||||
or(ilike(users.email, `%${search}%`), ilike(users.name, `%${search}%`))
|
||||
) as typeof totalQuery;
|
||||
}
|
||||
const [{ count: total }] = await totalQuery;
|
||||
|
||||
// Fetch page with last activity
|
||||
let query = this.db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
if (search) {
|
||||
query = query.where(
|
||||
or(ilike(users.email, `%${search}%`), ilike(users.name, `%${search}%`))
|
||||
) as typeof query;
|
||||
}
|
||||
|
||||
const rows = await query.orderBy(desc(users.createdAt)).limit(limit).offset(offset);
|
||||
|
||||
// Get last activity for these users
|
||||
const userIds = rows.map((r) => r.id);
|
||||
const lastActivities =
|
||||
userIds.length > 0
|
||||
? await this.db
|
||||
.select({
|
||||
userId: sessions.userId,
|
||||
lastActivity: sql<Date>`MAX(${sessions.lastActivityAt})`.as('last_activity'),
|
||||
})
|
||||
.from(sessions)
|
||||
.where(sql`${sessions.userId} IN ${userIds}`)
|
||||
.groupBy(sessions.userId)
|
||||
: [];
|
||||
|
||||
const activityMap = new Map(lastActivities.map((a) => [a.userId, a.lastActivity]));
|
||||
|
||||
return {
|
||||
users: rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
name: r.name,
|
||||
role: r.role,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
lastActiveAt: activityMap.get(r.id)?.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue