Feat: Landingpages centralized, new app news integrated

This commit is contained in:
Till-JS 2025-11-25 18:20:17 +01:00
parent 36b85fc8a0
commit 865d74ff37
91 changed files with 8242 additions and 610 deletions

View file

@ -0,0 +1,24 @@
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,
envFilePath: '../../.env',
}),
DatabaseModule,
AuthModule,
ArticlesModule,
CategoriesModule,
UsersModule,
ContentExtractionModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,81 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { AuthGuard } from '../common/guards/auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '@manacore/news-database';
@Controller('articles')
export class ArticlesController {
constructor(private articlesService: ArticlesService) {}
// Public: Get AI-generated articles
@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, 10) : 20,
offset: offset ? parseInt(offset, 10) : 0,
});
}
// Public: Get single article
@Get(':id')
async getArticle(@Param('id') id: string) {
const article = await this.articlesService.getArticleById(id);
if (!article) {
return { error: 'Article not found' };
}
return article;
}
// Protected: Get user's saved articles
@Get('saved/list')
@UseGuards(AuthGuard)
async getSavedArticles(
@CurrentUser() user: User,
@Query('includeArchived') includeArchived?: string,
) {
return this.articlesService.getSavedArticles(
user.id,
includeArchived === 'true',
);
}
// Protected: Archive article
@Post(':id/archive')
@UseGuards(AuthGuard)
async archiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
await this.articlesService.archiveArticle(id, user.id);
return { success: true };
}
// Protected: Unarchive article
@Post(':id/unarchive')
@UseGuards(AuthGuard)
async unarchiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
await this.articlesService.unarchiveArticle(id, user.id);
return { success: true };
}
// Protected: Delete article
@Delete(':id')
@UseGuards(AuthGuard)
async deleteArticle(@Param('id') id: string, @CurrentUser() user: User) {
await this.articlesService.deleteArticle(id, user.id);
return { success: true };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [ArticlesController],
providers: [ArticlesService],
exports: [ArticlesService],
})
export class ArticlesModule {}

View file

@ -0,0 +1,147 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { DATABASE_CONNECTION } from '../database/database.module';
import {
Database,
articles,
Article,
eq,
and,
desc,
} from '@manacore/news-database';
@Injectable()
export class ArticlesService {
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
// Get AI-generated articles (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;
const conditions = [eq(articles.sourceOrigin, 'ai')];
if (type) {
conditions.push(eq(articles.type, type));
}
if (categoryId) {
conditions.push(eq(articles.categoryId, categoryId));
}
return this.database
.select()
.from(articles)
.where(and(...conditions))
.orderBy(desc(articles.publishedAt))
.limit(limit)
.offset(offset);
}
// Get user-saved articles
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.database
.select()
.from(articles)
.where(and(...conditions))
.orderBy(desc(articles.createdAt));
}
// Get single article by ID
async getArticleById(articleId: string): Promise<Article | null> {
const [article] = await this.database
.select()
.from(articles)
.where(eq(articles.id, articleId))
.limit(1);
return article || null;
}
// Create a saved article
async createSavedArticle(data: {
userId: string;
title: string;
content: string;
parsedContent: string;
originalUrl: string;
author?: string;
imageUrl?: string;
}): Promise<Article> {
const wordCount = data.content.split(/\s+/).length;
const readingTimeMinutes = Math.ceil(wordCount / 200);
const [article] = await this.database
.insert(articles)
.values({
type: 'saved',
sourceOrigin: 'user_saved',
userId: data.userId,
title: data.title,
content: data.content,
parsedContent: data.parsedContent,
originalUrl: data.originalUrl,
author: data.author,
imageUrl: data.imageUrl,
wordCount,
readingTimeMinutes,
isArchived: false,
})
.returning();
return article;
}
// Archive an article
async archiveArticle(articleId: string, userId: string): Promise<void> {
const result = await this.database
.update(articles)
.set({ isArchived: true, updatedAt: new Date() })
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
.returning();
if (result.length === 0) {
throw new NotFoundException('Article not found');
}
}
// Unarchive an article
async unarchiveArticle(articleId: string, userId: string): Promise<void> {
const result = await this.database
.update(articles)
.set({ isArchived: false, updatedAt: new Date() })
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
.returning();
if (result.length === 0) {
throw new NotFoundException('Article not found');
}
}
// Delete an article
async deleteArticle(articleId: string, userId: string): Promise<void> {
const result = await this.database
.delete(articles)
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
.returning();
if (result.length === 0) {
throw new NotFoundException('Article not found');
}
}
}

View file

@ -0,0 +1,88 @@
import { Controller, Post, Get, Body, Headers, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
class SignUpDto {
@IsEmail()
email: string;
@IsString()
@MinLength(6)
password: string;
@IsOptional()
@IsString()
name?: string;
}
class SignInDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('signup')
async signUp(@Body() body: SignUpDto) {
const result = await this.authService.signUp(body.email, body.password, body.name);
return {
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
token: result.token,
};
}
@Post('signin')
async signIn(@Body() body: SignInDto) {
const result = await this.authService.signIn(body.email, body.password);
return {
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
token: result.token,
};
}
@Post('signout')
async signOut(@Headers('authorization') authHeader: string) {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
}
const token = authHeader.substring(7);
await this.authService.signOut(token);
return { success: true };
}
@Get('session')
async getSession(@Headers('authorization') authHeader: string) {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
}
const token = authHeader.substring(7);
const session = await this.authService.getSession(token);
if (!session) {
throw new UnauthorizedException('Invalid or expired session');
}
return {
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
},
};
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View file

@ -0,0 +1,159 @@
import { Injectable, Inject, UnauthorizedException, ConflictException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DATABASE_CONNECTION } from '../database/database.module';
import {
Database,
users,
sessions,
accounts,
eq,
and,
User,
} from '@manacore/news-database';
import * as crypto from 'crypto';
@Injectable()
export class AuthService {
constructor(
@Inject(DATABASE_CONNECTION) private database: Database,
private configService: ConfigService,
) {}
private hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
return `${salt}:${hash}`;
}
private verifyPassword(password: string, storedHash: string): boolean {
const [salt, hash] = storedHash.split(':');
const verifyHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
return hash === verifyHash;
}
private generateToken(): string {
return crypto.randomBytes(32).toString('hex');
}
async signUp(email: string, password: string, name?: string): Promise<{ user: User; token: string }> {
// Check if user exists
const existingUser = await this.database
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (existingUser.length > 0) {
throw new ConflictException('User already exists');
}
// Create user
const [user] = await this.database
.insert(users)
.values({
email: email.toLowerCase(),
name: name || null,
})
.returning();
// Create account with password
await this.database.insert(accounts).values({
userId: user.id,
providerId: 'credential',
accountId: email.toLowerCase(),
password: this.hashPassword(password),
});
// Create session
const token = this.generateToken();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await this.database.insert(sessions).values({
userId: user.id,
token,
expiresAt,
});
return { user, token };
}
async signIn(email: string, password: string): Promise<{ user: User; token: string }> {
// Find user
const [user] = await this.database
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Find account
const [account] = await this.database
.select()
.from(accounts)
.where(
and(
eq(accounts.userId, user.id),
eq(accounts.providerId, 'credential'),
),
)
.limit(1);
if (!account || !account.password) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
if (!this.verifyPassword(password, account.password)) {
throw new UnauthorizedException('Invalid credentials');
}
// Create session
const token = this.generateToken();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await this.database.insert(sessions).values({
userId: user.id,
token,
expiresAt,
});
return { user, token };
}
async signOut(token: string): Promise<void> {
await this.database.delete(sessions).where(eq(sessions.token, token));
}
async validateSession(token: string): Promise<{ user: User; session: any } | null> {
const [session] = await this.database
.select()
.from(sessions)
.where(eq(sessions.token, token))
.limit(1);
if (!session || new Date(session.expiresAt) < new Date()) {
return null;
}
const [user] = await this.database
.select()
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
if (!user) {
return null;
}
return { user, session };
}
async getSession(token: string): Promise<{ user: User } | null> {
const result = await this.validateSession(token);
if (!result) return null;
return { user: result.user };
}
}

View file

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { CategoriesService } from './categories.service';
@Controller('categories')
export class CategoriesController {
constructor(private categoriesService: CategoriesService) {}
@Get()
async getAllCategories() {
return this.categoriesService.getAllCategories();
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View file

@ -0,0 +1,31 @@
import { Injectable, Inject } from '@nestjs/common';
import { DATABASE_CONNECTION } from '../database/database.module';
import { Database, categories, Category, asc } from '@manacore/news-database';
@Injectable()
export class CategoriesService {
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
async getAllCategories(): Promise<Category[]> {
return this.database
.select()
.from(categories)
.orderBy(asc(categories.priority));
}
async createCategory(data: {
name: string;
displayName: string;
description?: string;
icon?: string;
color?: string;
priority?: number;
}): Promise<Category> {
const [category] = await this.database
.insert(categories)
.values(data)
.returning();
return category;
}
}

View file

@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View file

@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../../auth/auth.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('No token provided');
}
const token = authHeader.substring(7);
try {
const session = await this.authService.validateSession(token);
if (!session) {
throw new UnauthorizedException('Invalid or expired session');
}
request.user = session.user;
request.session = session;
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

View file

@ -0,0 +1,51 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ContentExtractionService } from './content-extraction.service';
import { AuthGuard } from '../common/guards/auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '@manacore/news-database';
import { IsUrl } from 'class-validator';
class ExtractUrlDto {
@IsUrl()
url: string;
}
@Controller('extract')
export class ContentExtractionController {
constructor(private contentExtractionService: ContentExtractionService) {}
// Protected: Save article from URL
@Post('save')
@UseGuards(AuthGuard)
async saveFromUrl(@Body() body: ExtractUrlDto, @CurrentUser() user: User) {
const article = await this.contentExtractionService.saveArticleFromUrl(
user.id,
body.url,
);
return {
success: true,
article: {
id: article.id,
title: article.title,
createdAt: article.createdAt,
},
};
}
// Public: Preview URL extraction (without saving)
@Post('preview')
async previewUrl(@Body() body: ExtractUrlDto) {
const extracted = await this.contentExtractionService.extractFromUrl(
body.url,
);
return {
title: extracted.title,
excerpt: extracted.excerpt,
byline: extracted.byline,
siteName: extracted.siteName,
contentLength: extracted.content.length,
};
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ContentExtractionController } from './content-extraction.controller';
import { ContentExtractionService } from './content-extraction.service';
import { ArticlesModule } from '../articles/articles.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [ArticlesModule, AuthModule],
controllers: [ContentExtractionController],
providers: [ContentExtractionService],
exports: [ContentExtractionService],
})
export class ContentExtractionModule {}

View file

@ -0,0 +1,79 @@
import { Injectable, BadRequestException } 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;
htmlContent: string;
excerpt?: string;
byline?: string;
siteName?: string;
}
@Injectable()
export class ContentExtractionService {
constructor(private articlesService: ArticlesService) {}
async extractFromUrl(url: string): Promise<ExtractedContent> {
// Validate URL
try {
new URL(url);
} catch {
throw new BadRequestException('Invalid URL');
}
// Fetch the page
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
},
});
if (!response.ok) {
throw new BadRequestException(
`Failed to fetch URL: ${response.status} ${response.statusText}`,
);
}
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 BadRequestException(
'Could not extract article content from this page',
);
}
return {
title: article.title || 'Untitled',
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,
author: extracted.byline,
});
}
}

View file

@ -0,0 +1,25 @@
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createDb } from '@manacore/news-database';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService) => {
const databaseUrl = configService.get<string>('DATABASE_URL') ||
'postgresql://news:news_dev_password@localhost:5434/news_hub';
console.log('Connecting to database:', databaseUrl.replace(/:[^:@]+@/, ':****@'));
return createDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

37
news/apps/api/src/main.ts Normal file
View file

@ -0,0 +1,37 @@
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({ logger: true }),
);
app.enableCors({
origin: [
'http://localhost:8081', // Expo web
'http://localhost:19006', // Expo web alt
'http://localhost:3000', // API itself (for testing)
/^exp:\/\/.*/, // Expo Go
],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
const port = process.env.API_PORT || 3000;
await app.listen(port, '0.0.0.0');
console.log(`API running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,59 @@
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { AuthGuard } from '../common/guards/auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '@manacore/news-database';
import { IsOptional, IsString, IsArray, IsEnum, IsBoolean } from 'class-validator';
class UpdateUserDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsArray()
preferredCategories?: string[];
@IsOptional()
@IsArray()
blockedSources?: string[];
@IsOptional()
@IsEnum(['slow', 'normal', 'fast'])
readingSpeed?: 'slow' | 'normal' | 'fast';
@IsOptional()
@IsString()
notificationSettings?: string;
@IsOptional()
@IsBoolean()
onboardingCompleted?: boolean;
}
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Get('me')
@UseGuards(AuthGuard)
async getCurrentUser(@CurrentUser() user: User) {
return this.usersService.getUserById(user.id);
}
@Patch('me')
@UseGuards(AuthGuard)
async updateCurrentUser(
@CurrentUser() user: User,
@Body() body: UpdateUserDto,
) {
return this.usersService.updateUser(user.id, body);
}
@Patch('me/onboarding')
@UseGuards(AuthGuard)
async completeOnboarding(@CurrentUser() user: User) {
await this.usersService.completeOnboarding(user.id);
return { success: true };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -0,0 +1,51 @@
import { Injectable, Inject } from '@nestjs/common';
import { DATABASE_CONNECTION } from '../database/database.module';
import { Database, users, User, eq } from '@manacore/news-database';
@Injectable()
export class UsersService {
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
async getUserById(userId: string): Promise<User | null> {
const [user] = await this.database
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user || null;
}
async updateUser(
userId: string,
data: {
name?: string;
preferredCategories?: string[];
blockedSources?: string[];
readingSpeed?: 'slow' | 'normal' | 'fast';
notificationSettings?: string;
onboardingCompleted?: boolean;
},
): Promise<User> {
const [user] = await this.database
.update(users)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(users.id, userId))
.returning();
return user;
}
async completeOnboarding(userId: string): Promise<void> {
await this.database
.update(users)
.set({
onboardingCompleted: true,
updatedAt: new Date(),
})
.where(eq(users.id, userId));
}
}