mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 03:26:41 +02:00
feat(infra): add mana-sync and mana-notify-go to docker-compose
- mana-sync on port 3051 (Go sync server for local-first apps) - mana-notify-go on port 3040 (Go notification service) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
313779f439
commit
ef19018e71
60 changed files with 908 additions and 2876 deletions
18
services/mana-user/src/config.ts
Normal file
18
services/mana-user/src/config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: 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', '3062'), 10),
|
||||
databaseUrl: env('DATABASE_URL', 'postgresql://manacore:devpassword@localhost:5432/mana_user'),
|
||||
manaAuthUrl: env('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
|
||||
serviceKey: env('MANA_CORE_SERVICE_KEY', 'dev-service-key'),
|
||||
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
|
||||
};
|
||||
}
|
||||
15
services/mana-user/src/db/connection.ts
Normal file
15
services/mana-user/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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>;
|
||||
4
services/mana-user/src/db/schema/index.ts
Normal file
4
services/mana-user/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './tag-groups';
|
||||
export * from './tags';
|
||||
export * from './tag-links';
|
||||
export * from './settings';
|
||||
18
services/mana-user/src/db/schema/settings.ts
Normal file
18
services/mana-user/src/db/schema/settings.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const userSettings = pgTable('user_settings', {
|
||||
userId: text('user_id').primaryKey(),
|
||||
globalSettings: jsonb('global_settings')
|
||||
.default({
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean' },
|
||||
locale: 'de',
|
||||
})
|
||||
.notNull(),
|
||||
appOverrides: jsonb('app_overrides').default({}).notNull(),
|
||||
deviceSettings: jsonb('device_settings').default({}).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type UserSettings = typeof userSettings.$inferSelect;
|
||||
31
services/mana-user/src/db/schema/tag-groups.ts
Normal file
31
services/mana-user/src/db/schema/tag-groups.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
pgTable,
|
||||
varchar,
|
||||
text,
|
||||
uuid,
|
||||
timestamp,
|
||||
index,
|
||||
unique,
|
||||
integer,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export const tagGroups = pgTable(
|
||||
'tag_groups',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
icon: varchar('icon', { length: 50 }),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tag_groups_user_idx').on(table.userId),
|
||||
unique('tag_groups_user_name_unique').on(table.userId, table.name),
|
||||
]
|
||||
);
|
||||
|
||||
export type TagGroup = typeof tagGroups.$inferSelect;
|
||||
export type NewTagGroup = typeof tagGroups.$inferInsert;
|
||||
26
services/mana-user/src/db/schema/tag-links.ts
Normal file
26
services/mana-user/src/db/schema/tag-links.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core';
|
||||
import { tags } from './tags';
|
||||
|
||||
export const tagLinks = pgTable(
|
||||
'tag_links',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: 'cascade' }),
|
||||
appId: varchar('app_id', { length: 50 }).notNull(),
|
||||
entityId: varchar('entity_id', { length: 255 }).notNull(),
|
||||
entityType: varchar('entity_type', { length: 100 }).notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tag_links_tag_idx').on(table.tagId),
|
||||
index('tag_links_entity_idx').on(table.appId, table.entityId),
|
||||
index('tag_links_user_app_idx').on(table.userId, table.appId),
|
||||
unique('tag_links_unique').on(table.tagId, table.appId, table.entityId),
|
||||
]
|
||||
);
|
||||
|
||||
export type TagLink = typeof tagLinks.$inferSelect;
|
||||
export type NewTagLink = typeof tagLinks.$inferInsert;
|
||||
34
services/mana-user/src/db/schema/tags.ts
Normal file
34
services/mana-user/src/db/schema/tags.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
pgTable,
|
||||
varchar,
|
||||
text,
|
||||
uuid,
|
||||
timestamp,
|
||||
index,
|
||||
unique,
|
||||
integer,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { tagGroups } from './tag-groups';
|
||||
|
||||
export const tags = pgTable(
|
||||
'tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
icon: varchar('icon', { length: 50 }),
|
||||
groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('tags_user_idx').on(table.userId),
|
||||
index('tags_group_idx').on(table.groupId),
|
||||
unique('tags_user_name_unique').on(table.userId, table.name),
|
||||
]
|
||||
);
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
50
services/mana-user/src/index.ts
Normal file
50
services/mana-user/src/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* mana-user — User preferences, tags, and storage service
|
||||
*
|
||||
* Hono + Bun runtime. Extracted from mana-core-auth.
|
||||
* Handles: user settings, tags, tag groups, tag links, avatar storage.
|
||||
*/
|
||||
|
||||
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 { TagsService } from './services/tags';
|
||||
import { SettingsService } from './services/settings';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createTagRoutes } from './routes/tags';
|
||||
import { createTagGroupRoutes } from './routes/tag-groups';
|
||||
import { createTagLinkRoutes } from './routes/tag-links';
|
||||
import { createSettingsRoutes } from './routes/settings';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
const tagsService = new TagsService(db);
|
||||
const settingsService = new SettingsService(db);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
|
||||
|
||||
// Health (no auth)
|
||||
app.route('/health', healthRoutes);
|
||||
|
||||
// All API routes require JWT auth
|
||||
app.use('/api/v1/*', jwtAuth(config.manaAuthUrl));
|
||||
|
||||
// Routes
|
||||
app.route('/api/v1/tags', createTagRoutes(tagsService));
|
||||
app.route('/api/v1/tag-groups', createTagGroupRoutes(tagsService));
|
||||
app.route('/api/v1/tag-links', createTagLinkRoutes(tagsService));
|
||||
app.route('/api/v1/settings', createSettingsRoutes(settingsService));
|
||||
|
||||
console.log(`mana-user starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
43
services/mana-user/src/lib/errors.ts
Normal file
43
services/mana-user/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-user/src/middleware/error-handler.ts
Normal file
29
services/mana-user/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-user/src/middleware/jwt-auth.ts
Normal file
57
services/mana-user/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-user/src/middleware/service-auth.ts
Normal file
26
services/mana-user/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();
|
||||
};
|
||||
}
|
||||
5
services/mana-user/src/routes/health.ts
Normal file
5
services/mana-user/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-user', timestamp: new Date().toISOString() })
|
||||
);
|
||||
30
services/mana-user/src/routes/settings.ts
Normal file
30
services/mana-user/src/routes/settings.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { SettingsService } from '../services/settings';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createSettingsRoutes(settingsService: SettingsService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await settingsService.getSettings(user.userId));
|
||||
})
|
||||
.put('/global', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(await settingsService.updateGlobalSettings(user.userId, body));
|
||||
})
|
||||
.put('/app/:appId', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await settingsService.updateAppOverride(user.userId, c.req.param('appId'), body)
|
||||
);
|
||||
})
|
||||
.put('/device/:deviceId', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await settingsService.updateDeviceSettings(user.userId, c.req.param('deviceId'), body)
|
||||
);
|
||||
});
|
||||
}
|
||||
26
services/mana-user/src/routes/tag-groups.ts
Normal file
26
services/mana-user/src/routes/tag-groups.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { TagsService } from '../services/tags';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createTagGroupRoutes(tagsService: TagsService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json(await tagsService.getUserGroups(user.userId));
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(await tagsService.createGroup(user.userId, body), 201);
|
||||
})
|
||||
.put('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
return c.json(await tagsService.updateGroup(user.userId, c.req.param('id'), body));
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
await tagsService.deleteGroup(user.userId, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
}
|
||||
40
services/mana-user/src/routes/tag-links.ts
Normal file
40
services/mana-user/src/routes/tag-links.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { TagsService } from '../services/tags';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createTagLinkRoutes(tagsService: TagsService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/entity', async (c) => {
|
||||
const user = c.get('user');
|
||||
const appId = c.req.query('appId') || '';
|
||||
const entityId = c.req.query('entityId') || '';
|
||||
const resolved = await tagsService.getLinksForEntity(user.userId, appId, entityId);
|
||||
return c.json(resolved);
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const link = await tagsService.createLink(user.userId, body);
|
||||
return c.json(link, 201);
|
||||
})
|
||||
.post('/sync', async (c) => {
|
||||
const user = c.get('user');
|
||||
const { appId, entityId, entityType, tagIds } = await c.req.json();
|
||||
const result = await tagsService.syncLinks(user.userId, appId, entityId, entityType, tagIds);
|
||||
return c.json(result);
|
||||
})
|
||||
.get('/query', async (c) => {
|
||||
const user = c.get('user');
|
||||
const links = await tagsService.queryLinks(user.userId, {
|
||||
appId: c.req.query('appId'),
|
||||
entityId: c.req.query('entityId'),
|
||||
tagId: c.req.query('tagId'),
|
||||
});
|
||||
return c.json(links);
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
await tagsService.deleteLink(user.userId, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
}
|
||||
42
services/mana-user/src/routes/tags.ts
Normal file
42
services/mana-user/src/routes/tags.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { TagsService } from '../services/tags';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createTagRoutes(tagsService: TagsService) {
|
||||
return (
|
||||
new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const allTags = await tagsService.getUserTags(user.userId);
|
||||
return c.json(allTags);
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const tag = await tagsService.createTag(user.userId, body);
|
||||
return c.json(tag, 201);
|
||||
})
|
||||
.put('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const tag = await tagsService.updateTag(user.userId, c.req.param('id'), body);
|
||||
return c.json(tag);
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
await tagsService.deleteTag(user.userId, c.req.param('id'));
|
||||
return c.json({ success: true });
|
||||
})
|
||||
.post('/defaults', async (c) => {
|
||||
const user = c.get('user');
|
||||
const defaultTags = await tagsService.createDefaultTags(user.userId);
|
||||
return c.json(defaultTags);
|
||||
})
|
||||
// Batch resolve
|
||||
.post('/resolve', async (c) => {
|
||||
const { ids } = await c.req.json();
|
||||
const resolved = await tagsService.getTagsByIds(ids || []);
|
||||
return c.json(resolved);
|
||||
})
|
||||
);
|
||||
}
|
||||
92
services/mana-user/src/services/settings.ts
Normal file
92
services/mana-user/src/services/settings.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Settings Service — User preferences, theme, nav, device settings
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { userSettings } from '../db/schema/settings';
|
||||
import type { Database } from '../db/connection';
|
||||
|
||||
export class SettingsService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async getSettings(userId: string) {
|
||||
const [settings] = await this.db
|
||||
.select()
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!settings) return this.initializeSettings(userId);
|
||||
return settings;
|
||||
}
|
||||
|
||||
async initializeSettings(userId: string) {
|
||||
const [settings] = await this.db
|
||||
.insert(userSettings)
|
||||
.values({ userId })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (!settings) {
|
||||
// Already exists, fetch it
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.limit(1);
|
||||
return existing;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
async updateGlobalSettings(userId: string, updates: Record<string, unknown>) {
|
||||
const current = await this.getSettings(userId);
|
||||
const merged = { ...(current.globalSettings as Record<string, unknown>), ...updates };
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSettings)
|
||||
.set({ globalSettings: merged, updatedAt: new Date() })
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateAppOverride(userId: string, appId: string, overrides: Record<string, unknown>) {
|
||||
const current = await this.getSettings(userId);
|
||||
const appOverrides = { ...(current.appOverrides as Record<string, unknown>) };
|
||||
appOverrides[appId] = {
|
||||
...((appOverrides[appId] as Record<string, unknown>) || {}),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSettings)
|
||||
.set({ appOverrides, updatedAt: new Date() })
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateDeviceSettings(userId: string, deviceId: string, settings: Record<string, unknown>) {
|
||||
const current = await this.getSettings(userId);
|
||||
const deviceSettings = { ...(current.deviceSettings as Record<string, unknown>) };
|
||||
deviceSettings[deviceId] = {
|
||||
...((deviceSettings[deviceId] as Record<string, unknown>) || {}),
|
||||
...settings,
|
||||
};
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSettings)
|
||||
.set({ deviceSettings, updatedAt: new Date() })
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteSettings(userId: string) {
|
||||
await this.db.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||
}
|
||||
}
|
||||
210
services/mana-user/src/services/tags.ts
Normal file
210
services/mana-user/src/services/tags.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Tags Service — CRUD for user tags, tag groups, and tag links
|
||||
*/
|
||||
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import { tags, tagGroups, tagLinks } from '../db/schema/index';
|
||||
import type { Database } from '../db/connection';
|
||||
import { NotFoundError, BadRequestError } from '../lib/errors';
|
||||
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Arbeit', color: '#3B82F6', icon: 'briefcase' },
|
||||
{ name: 'Persönlich', color: '#10B981', icon: 'user' },
|
||||
{ name: 'Familie', color: '#F59E0B', icon: 'users' },
|
||||
{ name: 'Wichtig', color: '#EF4444', icon: 'star' },
|
||||
];
|
||||
|
||||
export class TagsService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
// ─── Tags ───────────────────────────────────────────────
|
||||
|
||||
async getUserTags(userId: string) {
|
||||
return this.db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.sortOrder);
|
||||
}
|
||||
|
||||
async getTagById(userId: string, tagId: string) {
|
||||
const [tag] = await this.db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
|
||||
.limit(1);
|
||||
return tag;
|
||||
}
|
||||
|
||||
async getTagsByIds(tagIds: string[]) {
|
||||
if (tagIds.length === 0) return [];
|
||||
return this.db.select().from(tags).where(inArray(tags.id, tagIds));
|
||||
}
|
||||
|
||||
async createTag(
|
||||
userId: string,
|
||||
data: { name: string; color?: string; icon?: string; groupId?: string; sortOrder?: number }
|
||||
) {
|
||||
const [tag] = await this.db
|
||||
.insert(tags)
|
||||
.values({ userId, ...data })
|
||||
.returning();
|
||||
return tag;
|
||||
}
|
||||
|
||||
async updateTag(
|
||||
userId: string,
|
||||
tagId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
groupId?: string | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
) {
|
||||
const [tag] = await this.db
|
||||
.update(tags)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
if (!tag) throw new NotFoundError('Tag not found');
|
||||
return tag;
|
||||
}
|
||||
|
||||
async deleteTag(userId: string, tagId: string) {
|
||||
const result = await this.db
|
||||
.delete(tags)
|
||||
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
if (result.length === 0) throw new NotFoundError('Tag not found');
|
||||
}
|
||||
|
||||
async createDefaultTags(userId: string) {
|
||||
const existing = await this.getUserTags(userId);
|
||||
if (existing.length > 0) return existing;
|
||||
|
||||
const created = [];
|
||||
for (let i = 0; i < DEFAULT_TAGS.length; i++) {
|
||||
const [tag] = await this.db
|
||||
.insert(tags)
|
||||
.values({ userId, ...DEFAULT_TAGS[i], sortOrder: i })
|
||||
.returning();
|
||||
created.push(tag);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// ─── Tag Groups ─────────────────────────────────────────
|
||||
|
||||
async getUserGroups(userId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tagGroups)
|
||||
.where(eq(tagGroups.userId, userId))
|
||||
.orderBy(tagGroups.sortOrder);
|
||||
}
|
||||
|
||||
async createGroup(
|
||||
userId: string,
|
||||
data: { name: string; color?: string; icon?: string; sortOrder?: number }
|
||||
) {
|
||||
const [group] = await this.db
|
||||
.insert(tagGroups)
|
||||
.values({ userId, ...data })
|
||||
.returning();
|
||||
return group;
|
||||
}
|
||||
|
||||
async updateGroup(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
data: { name?: string; color?: string; icon?: string; sortOrder?: number }
|
||||
) {
|
||||
const [group] = await this.db
|
||||
.update(tagGroups)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
|
||||
.returning();
|
||||
if (!group) throw new NotFoundError('Tag group not found');
|
||||
return group;
|
||||
}
|
||||
|
||||
async deleteGroup(userId: string, groupId: string) {
|
||||
// Unlink tags from this group first (set groupId to null)
|
||||
await this.db.update(tags).set({ groupId: null }).where(eq(tags.groupId, groupId));
|
||||
const result = await this.db
|
||||
.delete(tagGroups)
|
||||
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
|
||||
.returning();
|
||||
if (result.length === 0) throw new NotFoundError('Tag group not found');
|
||||
}
|
||||
|
||||
// ─── Tag Links ──────────────────────────────────────────
|
||||
|
||||
async getLinksForEntity(userId: string, appId: string, entityId: string) {
|
||||
const links = await this.db
|
||||
.select()
|
||||
.from(tagLinks)
|
||||
.where(
|
||||
and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId))
|
||||
);
|
||||
|
||||
// Resolve full tag objects
|
||||
const tagIds = links.map((l) => l.tagId);
|
||||
const resolvedTags = tagIds.length > 0 ? await this.getTagsByIds(tagIds) : [];
|
||||
return resolvedTags;
|
||||
}
|
||||
|
||||
async createLink(
|
||||
userId: string,
|
||||
data: { tagId: string; appId: string; entityId: string; entityType: string }
|
||||
) {
|
||||
const [link] = await this.db
|
||||
.insert(tagLinks)
|
||||
.values({ userId, ...data })
|
||||
.returning();
|
||||
return link;
|
||||
}
|
||||
|
||||
async syncLinks(
|
||||
userId: string,
|
||||
appId: string,
|
||||
entityId: string,
|
||||
entityType: string,
|
||||
tagIds: string[]
|
||||
) {
|
||||
return this.db.transaction(async (tx) => {
|
||||
// Delete all existing links for this entity
|
||||
await tx
|
||||
.delete(tagLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(tagLinks.userId, userId),
|
||||
eq(tagLinks.appId, appId),
|
||||
eq(tagLinks.entityId, entityId)
|
||||
)
|
||||
);
|
||||
|
||||
// Insert new links
|
||||
if (tagIds.length > 0) {
|
||||
await tx
|
||||
.insert(tagLinks)
|
||||
.values(tagIds.map((tagId) => ({ tagId, appId, entityId, entityType, userId })));
|
||||
}
|
||||
|
||||
return { synced: tagIds.length };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLink(userId: string, linkId: string) {
|
||||
await this.db.delete(tagLinks).where(and(eq(tagLinks.id, linkId), eq(tagLinks.userId, userId)));
|
||||
}
|
||||
|
||||
async queryLinks(
|
||||
userId: string,
|
||||
filters: { appId?: string; entityId?: string; entityType?: string; tagId?: string }
|
||||
) {
|
||||
let query = this.db.select().from(tagLinks).where(eq(tagLinks.userId, userId)).$dynamic();
|
||||
if (filters.appId) query = query.where(eq(tagLinks.appId, filters.appId));
|
||||
if (filters.entityId) query = query.where(eq(tagLinks.entityId, filters.entityId));
|
||||
if (filters.tagId) query = query.where(eq(tagLinks.tagId, filters.tagId));
|
||||
return query;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue