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:
Till JS 2026-04-01 14:19:48 +02:00
parent 5c66492279
commit cb85fba820
30 changed files with 2145 additions and 248 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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