managarten/apps-archived/news/MigrationPlan-Unified-App.md
Wuesteon 9c47119535 Fix wrong type
import, make auth and chat work
2025-12-04 23:25:25 +01:00

40 KiB

Migrationsplan: Unified News Hub

Übersicht

Migration von ainews + kokon zu einer vereinten App mit:

  • PostgreSQL (Docker lokal, später Cloud)
  • Drizzle ORM (Type-safe Database)
  • NestJS (Backend API)
  • Better Auth (Authentication)

Ziel-Architektur

news/
├── apps/
│   ├── mobile/              # React Native/Expo App (vereint)
│   └── api/                 # NestJS Backend
├── packages/
│   ├── database/            # Drizzle Schema + Migrations
│   ├── shared/              # Shared Types & Utilities
│   └── browser-extension/   # Chrome Extension
├── docker/
│   └── docker-compose.yml   # PostgreSQL + Dev Services
├── package.json             # Monorepo Root (pnpm workspaces)
└── turbo.json               # Turborepo Config (optional)

Datenfluss

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Mobile App    │────▶│   NestJS API    │────▶│   PostgreSQL    │
│  (Expo/RN)      │◀────│   + Better Auth │◀────│   (Docker)      │
└─────────────────┘     └─────────────────┘     └─────────────────┘
         │                       │
         │              ┌────────┴────────┐
         │              │                 │
┌────────▼────────┐     │    ┌───────────▼───────────┐
│ Browser Extension│─────┘    │  Content Extraction   │
│ (Chrome/Firefox) │          │  (Readability)        │
└─────────────────┘           └───────────────────────┘

Phase 1: Monorepo Setup

1.1 Projekt-Struktur erstellen

# Im news/ Ordner
pnpm init

# Workspace-Struktur
mkdir -p apps/mobile apps/api packages/database packages/shared packages/browser-extension docker

1.2 Root package.json

{
  "name": "news-hub",
  "private": true,
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "db:generate": "turbo run db:generate --filter=@news/database",
    "db:migrate": "turbo run db:migrate --filter=@news/database",
    "db:studio": "turbo run db:studio --filter=@news/database"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  },
  "packageManager": "pnpm@9.0.0",
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

1.3 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "build/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "db:generate": {},
    "db:migrate": {},
    "db:studio": {
      "cache": false,
      "persistent": true
    }
  }
}

Phase 2: Docker Setup

2.1 docker/docker-compose.yml

version: '3.9'

services:
  postgres:
    image: postgres:16-alpine
    container_name: news-hub-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: news
      POSTGRES_PASSWORD: news_dev_password
      POSTGRES_DB: news_hub
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U news -d news_hub"]
      interval: 5s
      timeout: 5s
      retries: 5

  # Optional: pgAdmin für DB-Verwaltung
  pgadmin:
    image: dpage/pgadmin4:latest
    container_name: news-hub-pgadmin
    restart: unless-stopped
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@local.dev
      PGADMIN_DEFAULT_PASSWORD: admin
    ports:
      - "5050:80"
    depends_on:
      - postgres

volumes:
  postgres_data:

2.2 docker/init.sql

-- Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";  -- Für Textsuche

-- Grants
GRANT ALL PRIVILEGES ON DATABASE news_hub TO news;

2.3 Start-Script in Root package.json

{
  "scripts": {
    "docker:up": "docker-compose -f docker/docker-compose.yml up -d",
    "docker:down": "docker-compose -f docker/docker-compose.yml down",
    "docker:logs": "docker-compose -f docker/docker-compose.yml logs -f"
  }
}

Phase 3: Database Package (Drizzle)

3.1 packages/database/package.json

{
  "name": "@news/database",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio"
  },
  "dependencies": {
    "drizzle-orm": "^0.36.0",
    "postgres": "^3.4.0"
  },
  "devDependencies": {
    "drizzle-kit": "^0.28.0",
    "tsup": "^8.0.0",
    "typescript": "^5.4.0"
  }
}

3.2 packages/database/drizzle.config.ts

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/schema/index.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL || 'postgresql://news:news_dev_password@localhost:5432/news_hub',
  },
});

3.3 packages/database/src/schema/index.ts

export * from './users';
export * from './articles';
export * from './categories';
export * from './interactions';
export * from './auth';

3.4 packages/database/src/schema/users.ts

import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';

export const userTierEnum = pgEnum('user_tier', ['free', 'premium', 'enterprise']);
export const readingSpeedEnum = pgEnum('reading_speed', ['slow', 'normal', 'fast']);

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name'),
  avatarUrl: text('avatar_url'),

  // Preferences
  tier: userTierEnum('tier').default('free').notNull(),
  readingSpeed: readingSpeedEnum('reading_speed').default('normal').notNull(),
  preferredCategories: text('preferred_categories').array(),
  blockedSources: text('blocked_sources').array(),

  // Settings
  onboardingCompleted: boolean('onboarding_completed').default(false).notNull(),
  notificationSettings: text('notification_settings'), // JSON string

  // Timestamps
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

3.5 packages/database/src/schema/articles.ts

import { pgTable, uuid, text, timestamp, boolean, integer, real, pgEnum, index } from 'drizzle-orm/pg-core';
import { users } from './users';
import { categories } from './categories';

export const articleTypeEnum = pgEnum('article_type', ['feed', 'summary', 'in_depth', 'saved']);
export const articleSourceEnum = pgEnum('article_source', ['ai', 'user_saved']);
export const summaryPeriodEnum = pgEnum('summary_period', ['morning', 'noon', 'evening', 'night']);

export const articles = pgTable('articles', {
  id: uuid('id').primaryKey().defaultRandom(),

  // Core fields
  type: articleTypeEnum('type').notNull(),
  sourceOrigin: articleSourceEnum('source_origin').default('ai').notNull(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  summary: text('summary'),

  // For user-saved articles
  userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
  originalUrl: text('original_url'),
  parsedContent: text('parsed_content'),
  isArchived: boolean('is_archived').default(false),

  // Metadata
  categoryId: uuid('category_id').references(() => categories.id),
  sourceUrl: text('source_url'),
  sourceName: text('source_name'),
  sourceDomain: text('source_domain'),
  author: text('author'),
  imageUrl: text('image_url'),

  // AI-generated metadata
  aiTags: text('ai_tags').array(),
  sentimentScore: real('sentiment_score'),

  // Reading metrics
  readingTimeMinutes: integer('reading_time_minutes'),
  wordCount: integer('word_count'),

  // Summary-specific fields
  summaryDate: timestamp('summary_date'),
  summaryPeriod: summaryPeriodEnum('summary_period'),
  includedArticleIds: uuid('included_article_ids').array(),

  // In-depth specific fields
  keyInsights: text('key_insights'), // JSON string
  dataVisualizations: text('data_visualizations'), // JSON string
  relatedArticleIds: uuid('related_article_ids').array(),

  // Timestamps
  publishedAt: timestamp('published_at').defaultNow().notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  // Indexes für schnelle Abfragen
  typeIdx: index('articles_type_idx').on(table.type),
  userIdx: index('articles_user_idx').on(table.userId),
  sourceOriginIdx: index('articles_source_origin_idx').on(table.sourceOrigin),
  publishedAtIdx: index('articles_published_at_idx').on(table.publishedAt),
  categoryIdx: index('articles_category_idx').on(table.categoryId),
}));

export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;

3.6 packages/database/src/schema/categories.ts

import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';

export const categories = pgTable('categories', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull().unique(),
  displayName: text('display_name').notNull(),
  description: text('description'),
  icon: text('icon'),
  color: text('color'),
  priority: integer('priority').default(0).notNull(),

  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export type Category = typeof categories.$inferSelect;
export type NewCategory = typeof categories.$inferInsert;

3.7 packages/database/src/schema/interactions.ts

import { pgTable, uuid, timestamp, boolean, real, integer, index, unique } from 'drizzle-orm/pg-core';
import { users } from './users';
import { articles } from './articles';

export const userArticleInteractions = pgTable('user_article_interactions', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  articleId: uuid('article_id').references(() => articles.id, { onDelete: 'cascade' }).notNull(),

  // Interaction states
  isRead: boolean('is_read').default(false).notNull(),
  isSaved: boolean('is_saved').default(false).notNull(),
  readProgress: real('read_progress').default(0), // 0.0 to 1.0
  rating: integer('rating'), // 1-5
  shareCount: integer('share_count').default(0).notNull(),

  // Timestamps
  openedAt: timestamp('opened_at'),
  readAt: timestamp('read_at'),
  savedAt: timestamp('saved_at'),
  ratedAt: timestamp('rated_at'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  // Unique constraint: ein User kann nur eine Interaction pro Artikel haben
  userArticleUnique: unique('user_article_unique').on(table.userId, table.articleId),
  // Indexes
  userIdx: index('interactions_user_idx').on(table.userId),
  articleIdx: index('interactions_article_idx').on(table.articleId),
}));

export type UserArticleInteraction = typeof userArticleInteractions.$inferSelect;
export type NewUserArticleInteraction = typeof userArticleInteractions.$inferInsert;

3.8 packages/database/src/schema/auth.ts (Better Auth)

import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
import { users } from './users';

// Better Auth Sessions
export const sessions = pgTable('sessions', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  token: text('token').notNull().unique(),
  expiresAt: timestamp('expires_at').notNull(),
  ipAddress: text('ip_address'),
  userAgent: text('user_agent'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

// Better Auth Accounts (OAuth providers)
export const accounts = pgTable('accounts', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  providerId: text('provider_id').notNull(), // 'email', 'google', 'apple', etc.
  providerAccountId: text('provider_account_id').notNull(),
  accessToken: text('access_token'),
  refreshToken: text('refresh_token'),
  accessTokenExpiresAt: timestamp('access_token_expires_at'),
  refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
  scope: text('scope'),
  password: text('password'), // Hashed, only for email provider
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

// Better Auth Verification Tokens (Email verification, password reset)
export const verificationTokens = pgTable('verification_tokens', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  token: text('token').notNull().unique(),
  type: text('type').notNull(), // 'email_verification', 'password_reset'
  expiresAt: timestamp('expires_at').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

3.9 packages/database/src/index.ts

import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

export * from './schema';

const connectionString = process.env.DATABASE_URL || 'postgresql://news:news_dev_password@localhost:5432/news_hub';

// For query purposes
const queryClient = postgres(connectionString);
export const db = drizzle(queryClient, { schema });

// For migrations (uses different client settings)
export const createMigrationClient = () => {
  const migrationClient = postgres(connectionString, { max: 1 });
  return drizzle(migrationClient, { schema });
};

Phase 4: NestJS Backend

4.1 apps/api/package.json

{
  "name": "@news/api",
  "version": "1.0.0",
  "scripts": {
    "build": "nest build",
    "dev": "nest start --watch",
    "start": "nest start",
    "start:prod": "node dist/main"
  },
  "dependencies": {
    "@nestjs/common": "^10.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/platform-fastify": "^10.0.0",
    "@news/database": "workspace:*",
    "better-auth": "^1.0.0",
    "@mozilla/readability": "^0.5.0",
    "jsdom": "^24.0.0",
    "class-validator": "^0.14.0",
    "class-transformer": "^0.5.1"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.0.0",
    "@types/node": "^20.0.0",
    "typescript": "^5.4.0"
  }
}

4.2 apps/api/src/main.ts

import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter()
  );

  app.enableCors({
    origin: [
      'http://localhost:8081',  // Expo web
      'http://localhost:19006', // Expo web alt
      'exp://*',                // Expo Go
    ],
    credentials: true,
  });

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    transform: true,
  }));

  await app.listen(3000, '0.0.0.0');
  console.log('API running on http://localhost:3000');
}

bootstrap();

4.3 apps/api/src/app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './auth/auth.module';
import { ArticlesModule } from './articles/articles.module';
import { CategoriesModule } from './categories/categories.module';
import { UsersModule } from './users/users.module';
import { ContentExtractionModule } from './content-extraction/content-extraction.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    DatabaseModule,
    AuthModule,
    ArticlesModule,
    CategoriesModule,
    UsersModule,
    ContentExtractionModule,
  ],
})
export class AppModule {}

4.4 apps/api/src/database/database.module.ts

import { Module, Global } from '@nestjs/common';
import { db } from '@news/database';

export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';

@Global()
@Module({
  providers: [
    {
      provide: DATABASE_CONNECTION,
      useValue: db,
    },
  ],
  exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

4.5 apps/api/src/auth/auth.module.ts (Better Auth)

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { BetterAuthService } from './better-auth.service';

@Module({
  controllers: [AuthController],
  providers: [AuthService, BetterAuthService],
  exports: [AuthService, BetterAuthService],
})
export class AuthModule {}

4.6 apps/api/src/auth/better-auth.service.ts

import { Injectable } from '@nestjs/common';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db, users, sessions, accounts, verificationTokens } from '@news/database';

@Injectable()
export class BetterAuthService {
  public auth = betterAuth({
    database: drizzleAdapter(db, {
      provider: 'pg',
      schema: {
        user: users,
        session: sessions,
        account: accounts,
        verification: verificationTokens,
      },
    }),
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: false, // Für MVP erstmal aus
    },
    session: {
      expiresIn: 60 * 60 * 24 * 7, // 7 days
      updateAge: 60 * 60 * 24, // 1 day
    },
    // Optional: OAuth providers
    // socialProviders: {
    //   google: {
    //     clientId: process.env.GOOGLE_CLIENT_ID!,
    //     clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    //   },
    //   apple: {
    //     clientId: process.env.APPLE_CLIENT_ID!,
    //     clientSecret: process.env.APPLE_CLIENT_SECRET!,
    //   },
    // },
  });
}

4.7 apps/api/src/auth/auth.controller.ts

import { Controller, Post, Get, Body, Req, Res, UseGuards } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { BetterAuthService } from './better-auth.service';

@Controller('auth')
export class AuthController {
  constructor(private betterAuth: BetterAuthService) {}

  @Post('signup')
  async signUp(
    @Body() body: { email: string; password: string; name?: string },
    @Req() req: FastifyRequest,
    @Res() res: FastifyReply,
  ) {
    return this.betterAuth.auth.api.signUpEmail({
      body,
      headers: req.headers as any,
    });
  }

  @Post('signin')
  async signIn(
    @Body() body: { email: string; password: string },
    @Req() req: FastifyRequest,
  ) {
    return this.betterAuth.auth.api.signInEmail({
      body,
      headers: req.headers as any,
    });
  }

  @Post('signout')
  async signOut(@Req() req: FastifyRequest) {
    return this.betterAuth.auth.api.signOut({
      headers: req.headers as any,
    });
  }

  @Get('session')
  async getSession(@Req() req: FastifyRequest) {
    return this.betterAuth.auth.api.getSession({
      headers: req.headers as any,
    });
  }
}

4.8 apps/api/src/articles/articles.module.ts

import { Module } from '@nestjs/common';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';

@Module({
  controllers: [ArticlesController],
  providers: [ArticlesService],
  exports: [ArticlesService],
})
export class ArticlesModule {}

4.9 apps/api/src/articles/articles.service.ts

import { Injectable, Inject } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../database/database.module';
import { db, articles, Article, NewArticle } from '@news/database';

@Injectable()
export class ArticlesService {
  constructor(@Inject(DATABASE_CONNECTION) private db: typeof db) {}

  // AI-generierte Artikel (feed, summary, in_depth)
  async getAIArticles(options: {
    type?: 'feed' | 'summary' | 'in_depth';
    categoryId?: string;
    limit?: number;
    offset?: number;
  }): Promise<Article[]> {
    const { type, categoryId, limit = 20, offset = 0 } = options;

    let query = this.db
      .select()
      .from(articles)
      .where(eq(articles.sourceOrigin, 'ai'))
      .orderBy(desc(articles.publishedAt))
      .limit(limit)
      .offset(offset);

    if (type) {
      query = query.where(and(
        eq(articles.sourceOrigin, 'ai'),
        eq(articles.type, type)
      ));
    }

    if (categoryId) {
      query = query.where(and(
        eq(articles.sourceOrigin, 'ai'),
        eq(articles.categoryId, categoryId)
      ));
    }

    return query;
  }

  // User-gespeicherte Artikel
  async getSavedArticles(userId: string, includeArchived = false): Promise<Article[]> {
    const conditions = [
      eq(articles.sourceOrigin, 'user_saved'),
      eq(articles.userId, userId),
    ];

    if (!includeArchived) {
      conditions.push(eq(articles.isArchived, false));
    }

    return this.db
      .select()
      .from(articles)
      .where(and(...conditions))
      .orderBy(desc(articles.createdAt));
  }

  // Artikel speichern (für Content Extraction)
  async createSavedArticle(data: {
    userId: string;
    title: string;
    content: string;
    parsedContent: string;
    originalUrl: string;
  }): Promise<Article> {
    const [article] = await this.db
      .insert(articles)
      .values({
        type: 'saved',
        sourceOrigin: 'user_saved',
        userId: data.userId,
        title: data.title,
        content: data.content,
        parsedContent: data.parsedContent,
        originalUrl: data.originalUrl,
        isArchived: false,
      })
      .returning();

    return article;
  }

  // Artikel archivieren
  async archiveArticle(articleId: string, userId: string): Promise<void> {
    await this.db
      .update(articles)
      .set({ isArchived: true, updatedAt: new Date() })
      .where(and(
        eq(articles.id, articleId),
        eq(articles.userId, userId)
      ));
  }

  // Artikel löschen
  async deleteArticle(articleId: string, userId: string): Promise<void> {
    await this.db
      .delete(articles)
      .where(and(
        eq(articles.id, articleId),
        eq(articles.userId, userId)
      ));
  }

  // Einzelnen Artikel laden
  async getArticleById(articleId: string): Promise<Article | null> {
    const [article] = await this.db
      .select()
      .from(articles)
      .where(eq(articles.id, articleId))
      .limit(1);

    return article || null;
  }
}

4.10 apps/api/src/articles/articles.controller.ts

import { Controller, Get, Post, Delete, Param, Query, Body, UseGuards, Req } from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { AuthGuard } from '../auth/auth.guard';

@Controller('articles')
export class ArticlesController {
  constructor(private articlesService: ArticlesService) {}

  // Öffentliche AI-Artikel
  @Get()
  async getArticles(
    @Query('type') type?: 'feed' | 'summary' | 'in_depth',
    @Query('categoryId') categoryId?: string,
    @Query('limit') limit?: string,
    @Query('offset') offset?: string,
  ) {
    return this.articlesService.getAIArticles({
      type,
      categoryId,
      limit: limit ? parseInt(limit) : 20,
      offset: offset ? parseInt(offset) : 0,
    });
  }

  // Einzelner Artikel
  @Get(':id')
  async getArticle(@Param('id') id: string) {
    return this.articlesService.getArticleById(id);
  }

  // Gespeicherte Artikel (Auth required)
  @Get('saved')
  @UseGuards(AuthGuard)
  async getSavedArticles(
    @Req() req: any,
    @Query('includeArchived') includeArchived?: string,
  ) {
    return this.articlesService.getSavedArticles(
      req.user.id,
      includeArchived === 'true'
    );
  }

  // Artikel archivieren
  @Post(':id/archive')
  @UseGuards(AuthGuard)
  async archiveArticle(@Param('id') id: string, @Req() req: any) {
    await this.articlesService.archiveArticle(id, req.user.id);
    return { success: true };
  }

  // Artikel löschen
  @Delete(':id')
  @UseGuards(AuthGuard)
  async deleteArticle(@Param('id') id: string, @Req() req: any) {
    await this.articlesService.deleteArticle(id, req.user.id);
    return { success: true };
  }
}

4.11 apps/api/src/content-extraction/content-extraction.service.ts

import { Injectable } from '@nestjs/common';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { ArticlesService } from '../articles/articles.service';

export interface ExtractedContent {
  title: string;
  content: string;      // Plain text
  htmlContent: string;  // Cleaned HTML
  excerpt?: string;
  byline?: string;
  siteName?: string;
}

@Injectable()
export class ContentExtractionService {
  constructor(private articlesService: ArticlesService) {}

  async extractFromUrl(url: string): Promise<ExtractedContent> {
    // Fetch the page
    const response = await fetch(url, {
      headers: {
        'User-Agent': 'Mozilla/5.0 (compatible; NewsHub/1.0)',
      },
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch URL: ${response.status}`);
    }

    const html = await response.text();

    // Parse with JSDOM
    const dom = new JSDOM(html, { url });
    const reader = new Readability(dom.window.document);
    const article = reader.parse();

    if (!article) {
      throw new Error('Could not extract article content');
    }

    return {
      title: article.title,
      content: article.textContent,
      htmlContent: article.content,
      excerpt: article.excerpt,
      byline: article.byline,
      siteName: article.siteName,
    };
  }

  async saveArticleFromUrl(userId: string, url: string) {
    const extracted = await this.extractFromUrl(url);

    return this.articlesService.createSavedArticle({
      userId,
      title: extracted.title,
      content: extracted.content,
      parsedContent: extracted.htmlContent,
      originalUrl: url,
    });
  }
}

4.12 apps/api/src/content-extraction/content-extraction.controller.ts

import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';
import { ContentExtractionService } from './content-extraction.service';
import { AuthGuard } from '../auth/auth.guard';

@Controller('extract')
export class ContentExtractionController {
  constructor(private contentExtractionService: ContentExtractionService) {}

  @Post('save')
  @UseGuards(AuthGuard)
  async saveFromUrl(@Body('url') url: string, @Req() req: any) {
    const article = await this.contentExtractionService.saveArticleFromUrl(
      req.user.id,
      url
    );

    return { success: true, article };
  }

  @Post('preview')
  async previewUrl(@Body('url') url: string) {
    const extracted = await this.contentExtractionService.extractFromUrl(url);
    return extracted;
  }
}

Phase 5: Mobile App migrieren

5.1 Apps zusammenführen

# ainews nach apps/mobile verschieben
mv ainews apps/mobile

# kokon-spezifische Dateien übernehmen
cp kokon/hooks/useArticles.ts apps/mobile/hooks/
cp -r kokon/browser-extension packages/browser-extension

5.2 apps/mobile/package.json anpassen

{
  "name": "@news/mobile",
  "dependencies": {
    "@news/shared": "workspace:*",
    // Supabase entfernen:
    // "@supabase/supabase-js": "REMOVE",
    // Stattdessen:
    "better-auth/client": "^1.0.0"
  }
}

5.3 Neuer API Client: apps/mobile/services/api.ts

const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3000';

class ApiClient {
  private token: string | null = null;

  setToken(token: string | null) {
    this.token = token;
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const headers: HeadersInit = {
      'Content-Type': 'application/json',
      ...options.headers,
    };

    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`;
    }

    const response = await fetch(`${API_URL}${endpoint}`, {
      ...options,
      headers,
    });

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new Error(error.message || `API Error: ${response.status}`);
    }

    return response.json();
  }

  // Articles
  async getArticles(params?: {
    type?: string;
    categoryId?: string;
    limit?: number;
    offset?: number;
  }) {
    const query = new URLSearchParams(params as any).toString();
    return this.request(`/articles?${query}`);
  }

  async getArticle(id: string) {
    return this.request(`/articles/${id}`);
  }

  async getSavedArticles(includeArchived = false) {
    return this.request(`/articles/saved?includeArchived=${includeArchived}`);
  }

  async archiveArticle(id: string) {
    return this.request(`/articles/${id}/archive`, { method: 'POST' });
  }

  async deleteArticle(id: string) {
    return this.request(`/articles/${id}`, { method: 'DELETE' });
  }

  // Content Extraction
  async saveArticleFromUrl(url: string) {
    return this.request('/extract/save', {
      method: 'POST',
      body: JSON.stringify({ url }),
    });
  }

  // Auth
  async signUp(email: string, password: string, name?: string) {
    return this.request('/auth/signup', {
      method: 'POST',
      body: JSON.stringify({ email, password, name }),
    });
  }

  async signIn(email: string, password: string) {
    const result = await this.request<{ token: string; user: any }>('/auth/signin', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    this.setToken(result.token);
    return result;
  }

  async signOut() {
    await this.request('/auth/signout', { method: 'POST' });
    this.setToken(null);
  }

  async getSession() {
    return this.request('/auth/session');
  }
}

export const api = new ApiClient();

5.4 Auth Context aktualisieren: apps/mobile/contexts/AuthContext.tsx

import React, { createContext, useContext, useEffect, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { api } from '~/services/api';

interface User {
  id: string;
  email: string;
  name?: string;
}

interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  signIn: (email: string, password: string) => Promise<void>;
  signUp: (email: string, password: string, name?: string) => Promise<void>;
  signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | null>(null);

const TOKEN_KEY = 'auth_token';

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    loadStoredAuth();
  }, []);

  async function loadStoredAuth() {
    try {
      const token = await AsyncStorage.getItem(TOKEN_KEY);
      if (token) {
        api.setToken(token);
        const session = await api.getSession();
        setUser(session.user);
      }
    } catch (error) {
      console.error('Failed to load auth:', error);
    } finally {
      setIsLoading(false);
    }
  }

  async function signIn(email: string, password: string) {
    const result = await api.signIn(email, password);
    await AsyncStorage.setItem(TOKEN_KEY, result.token);
    setUser(result.user);
  }

  async function signUp(email: string, password: string, name?: string) {
    const result = await api.signUp(email, password, name);
    await AsyncStorage.setItem(TOKEN_KEY, result.token);
    setUser(result.user);
  }

  async function signOut() {
    await api.signOut();
    await AsyncStorage.removeItem(TOKEN_KEY);
    setUser(null);
  }

  return (
    <AuthContext.Provider value={{ user, isLoading, signIn, signUp, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

5.5 Article Store aktualisieren: apps/mobile/store/articleStore.ts

import { create } from 'zustand';
import { api } from '~/services/api';

interface Article {
  id: string;
  type: 'feed' | 'summary' | 'in_depth' | 'saved';
  title: string;
  content: string;
  // ... weitere Felder
}

interface ArticleState {
  articles: Article[];
  savedArticles: Article[];
  isLoading: boolean;
  isLoadingSaved: boolean;

  loadArticles: (type?: string) => Promise<void>;
  loadSavedArticles: () => Promise<void>;
  saveArticleFromUrl: (url: string) => Promise<boolean>;
  archiveArticle: (id: string) => Promise<void>;
}

export const useArticleStore = create<ArticleState>((set, get) => ({
  articles: [],
  savedArticles: [],
  isLoading: false,
  isLoadingSaved: false,

  loadArticles: async (type) => {
    set({ isLoading: true });
    try {
      const articles = await api.getArticles({});
      set({ articles, isLoading: false });
    } catch (error) {
      console.error('Failed to load articles:', error);
      set({ isLoading: false });
    }
  },

  loadSavedArticles: async () => {
    set({ isLoadingSaved: true });
    try {
      const savedArticles = await api.getSavedArticles();
      set({ savedArticles, isLoadingSaved: false });
    } catch (error) {
      console.error('Failed to load saved articles:', error);
      set({ isLoadingSaved: false });
    }
  },

  saveArticleFromUrl: async (url: string) => {
    try {
      await api.saveArticleFromUrl(url);
      get().loadSavedArticles();
      return true;
    } catch (error) {
      console.error('Failed to save article:', error);
      return false;
    }
  },

  archiveArticle: async (id: string) => {
    // Optimistic update
    set(state => ({
      savedArticles: state.savedArticles.filter(a => a.id !== id)
    }));

    try {
      await api.archiveArticle(id);
    } catch (error) {
      // Rollback
      get().loadSavedArticles();
    }
  },
}));

Phase 6: Browser Extension anpassen

6.1 packages/browser-extension/manifest.json

{
  "manifest_version": 3,
  "name": "News Hub - Save Article",
  "version": "1.0.0",
  "description": "Speichere Artikel in deiner News Hub Bibliothek",
  "permissions": ["activeTab", "storage"],
  "host_permissions": ["http://localhost:3000/*"],
  "action": {
    "default_popup": "popup.html",
    "default_title": "In News Hub speichern"
  },
  "background": {
    "service_worker": "background.js"
  }
}

6.2 packages/browser-extension/popup.js

const API_URL = 'http://localhost:3000';

document.addEventListener('DOMContentLoaded', async () => {
  const pageTitle = document.getElementById('pageTitle');
  const pageUrl = document.getElementById('pageUrl');
  const saveButton = document.getElementById('saveButton');
  const status = document.getElementById('status');
  const loginNotice = document.getElementById('loginNotice');

  let currentTab = null;
  let authToken = null;

  // Get current tab
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  currentTab = tab;
  pageTitle.textContent = tab.title || 'Untitled';
  pageUrl.textContent = tab.url;

  // Check auth from storage
  const stored = await chrome.storage.local.get(['news_hub_token']);
  authToken = stored.news_hub_token;

  if (authToken) {
    saveButton.disabled = false;
    loginNotice.style.display = 'none';
    saveArticle();
  } else {
    loginNotice.style.display = 'block';
    saveButton.disabled = true;
  }

  async function saveArticle() {
    if (!currentTab || !authToken) return;

    saveButton.disabled = true;
    status.textContent = 'Speichert...';

    try {
      const response = await fetch(`${API_URL}/extract/save`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${authToken}`,
        },
        body: JSON.stringify({ url: currentTab.url }),
      });

      if (response.ok) {
        status.textContent = 'Gespeichert!';
        setTimeout(() => window.close(), 1500);
      } else {
        throw new Error('Failed to save');
      }
    } catch (error) {
      status.textContent = 'Fehler beim Speichern';
      saveButton.disabled = false;
    }
  }

  saveButton.addEventListener('click', saveArticle);
});

Phase 7: Environment & Scripts

7.1 .env.example (Root)

# Database
DATABASE_URL=postgresql://news:news_dev_password@localhost:5432/news_hub

# API
API_PORT=3000
API_URL=http://localhost:3000

# Better Auth
BETTER_AUTH_SECRET=your-secret-key-here

# Mobile App
EXPO_PUBLIC_API_URL=http://localhost:3000

7.2 Root package.json Scripts

{
  "scripts": {
    "dev": "turbo run dev",
    "dev:api": "turbo run dev --filter=@news/api",
    "dev:mobile": "turbo run dev --filter=@news/mobile",
    "build": "turbo run build",
    "docker:up": "docker-compose -f docker/docker-compose.yml up -d",
    "docker:down": "docker-compose -f docker/docker-compose.yml down",
    "db:generate": "pnpm --filter @news/database db:generate",
    "db:migrate": "pnpm --filter @news/database db:migrate",
    "db:push": "pnpm --filter @news/database db:push",
    "db:studio": "pnpm --filter @news/database db:studio"
  }
}

Migrations-Checkliste

Vorbereitung

  • pnpm installieren (falls nicht vorhanden)
  • Docker Desktop installiert
  • Node.js 20+ installiert

Phase 1: Monorepo

  • Root package.json erstellt
  • turbo.json erstellt
  • Ordnerstruktur angelegt
  • pnpm install erfolgreich

Phase 2: Docker

  • docker-compose.yml erstellt
  • pnpm docker:up startet PostgreSQL
  • pgAdmin erreichbar unter localhost:5050

Phase 3: Database Package

  • Drizzle Schema definiert
  • pnpm db:generate erfolgreich
  • pnpm db:push erstellt Tabellen
  • pnpm db:studio zeigt Tabellen

Phase 4: NestJS Backend

  • NestJS Projekt erstellt
  • Better Auth konfiguriert
  • Auth Endpoints funktionieren
  • Articles Endpoints funktionieren
  • Content Extraction funktioniert

Phase 5: Mobile App

  • ainews nach apps/mobile verschoben
  • Supabase-Abhängigkeiten entfernt
  • API Client erstellt
  • Auth Context aktualisiert
  • Store aktualisiert
  • App startet und verbindet mit API

Phase 6: Browser Extension

  • Extension nach packages/browser-extension
  • API URL angepasst
  • Funktioniert mit neuem Backend

Phase 7: Testing

  • User Registration
  • User Login
  • Artikel laden (Feed)
  • Artikel speichern via URL
  • Artikel speichern via Extension
  • Artikel archivieren

Empfohlene Reihenfolge zum Starten

# 1. Monorepo initialisieren
cd news
pnpm init
# package.json anpassen (workspaces)

# 2. Docker starten
pnpm docker:up

# 3. Database Package erstellen & migrieren
cd packages/database
pnpm install
pnpm db:push

# 4. API entwickeln & testen
cd apps/api
pnpm install
pnpm dev
# Test: curl http://localhost:3000/articles

# 5. Mobile App migrieren
mv ainews apps/mobile
cd apps/mobile
# Supabase entfernen, API Client einbauen
pnpm dev

# 6. Extension anpassen
cd packages/browser-extension
# URLs anpassen, testen

Vorteile der neuen Architektur

Aspekt Vorher (Supabase) Nachher (Eigenes Backend)
Kontrolle Abhängig von Supabase Volle Kontrolle
Kosten Pay-per-use Fixkosten (oder gratis lokal)
Flexibilität Supabase-Limits Unbegrenzt skalierbar
Type Safety Manuell generiert Drizzle: Schema = Types
Migrations Supabase Dashboard Drizzle-Kit: Versioniert
Testing Schwierig lokal Docker: Identisch zu Prod
Auth Supabase Auth Better Auth: Flexibel

Nächste Schritte

  1. Entscheidung: Plan OK? Anpassungen nötig?
  2. Phase 1 starten: Monorepo Setup
  3. Parallel: Docker & Database Package

Soll ich mit der Implementierung beginnen?