mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 06:06:42 +02:00
Feat: Landingpages centralized, new app news integrated
This commit is contained in:
parent
36b85fc8a0
commit
865d74ff37
91 changed files with 8242 additions and 610 deletions
24
news/apps/api/src/app.module.ts
Normal file
24
news/apps/api/src/app.module.ts
Normal 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 {}
|
||||
81
news/apps/api/src/articles/articles.controller.ts
Normal file
81
news/apps/api/src/articles/articles.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
news/apps/api/src/articles/articles.module.ts
Normal file
12
news/apps/api/src/articles/articles.module.ts
Normal 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 {}
|
||||
147
news/apps/api/src/articles/articles.service.ts
Normal file
147
news/apps/api/src/articles/articles.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
88
news/apps/api/src/auth/auth.controller.ts
Normal file
88
news/apps/api/src/auth/auth.controller.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
10
news/apps/api/src/auth/auth.module.ts
Normal file
10
news/apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
159
news/apps/api/src/auth/auth.service.ts
Normal file
159
news/apps/api/src/auth/auth.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
news/apps/api/src/categories/categories.controller.ts
Normal file
12
news/apps/api/src/categories/categories.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
10
news/apps/api/src/categories/categories.module.ts
Normal file
10
news/apps/api/src/categories/categories.module.ts
Normal 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 {}
|
||||
31
news/apps/api/src/categories/categories.service.ts
Normal file
31
news/apps/api/src/categories/categories.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
31
news/apps/api/src/common/guards/auth.guard.ts
Normal file
31
news/apps/api/src/common/guards/auth.guard.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
25
news/apps/api/src/database/database.module.ts
Normal file
25
news/apps/api/src/database/database.module.ts
Normal 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
37
news/apps/api/src/main.ts
Normal 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();
|
||||
59
news/apps/api/src/users/users.controller.ts
Normal file
59
news/apps/api/src/users/users.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
news/apps/api/src/users/users.module.ts
Normal file
12
news/apps/api/src/users/users.module.ts
Normal 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 {}
|
||||
51
news/apps/api/src/users/users.service.ts
Normal file
51
news/apps/api/src/users/users.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue