This commit is contained in:
echo committed 2025-06-24 21:50:09 +02:00
1 parent 5a01897a44
commit 62b6a26a4f
18 files changed
+1391 -160

No files matched your search

+242 -8
View File
@@ -9,15 +9,24 @@
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^5.0.1",
"@prisma/client": "^6.6.0",
"@types/bcrypt": "^5.0.2",
"@types/passport-jwt": "^4.0.1",
"bcrypt": "^5.1.1",
"bullmq": "^5.47.2",
"cache-manager": "^6.4.2",
"cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"passport-jwt": "^4.0.1",
"prisma": "^6.6.0",
"redis": "^4.7.0",
@@ -2331,6 +2340,39 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@keyv/serialize": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz",
"integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3"
}
},
"node_modules/@keyv/serialize/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/@lukeed/csprng": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
@@ -2767,6 +2809,19 @@
"node": ">= 10"
}
},
"node_modules/@nestjs/cache-manager": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.0.1.tgz",
"integrity": "sha512-4UxTnR0fsmKL5YDalU2eLFVnL+OBebWUpX+hEduKGncrVKH4PPNoiRn1kXyOCjmzb0UvWgqubpssNouc8e0MCw==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
"cache-manager": ">=6",
"keyv": ">=5",
"rxjs": "^7.8.1"
}
},
"node_modules/@nestjs/cli": {
"version": "11.0.6",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.6.tgz",
@@ -3025,6 +3080,21 @@
}
}
},
"node_modules/@nestjs/config": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz",
"integrity": "sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==",
"license": "MIT",
"dependencies": {
"dotenv": "16.4.7",
"dotenv-expand": "12.0.1",
"lodash": "4.17.21"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/core": {
"version": "11.0.13",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.0.13.tgz",
@@ -3100,6 +3170,19 @@
"@nestjs/core": "^11.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-5.0.1.tgz",
"integrity": "sha512-kFoel84I4RyS2LNPH6yHYTKxB16tb3auAEciFuc788C3ph6nABkUfzX5IE+unjVaRX+3GuruJwurNepMlHXpQg==",
"license": "MIT",
"dependencies": {
"cron": "3.5.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.4.tgz",
@@ -3955,6 +4038,12 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -4070,6 +4159,12 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/validator": {
"version": "13.12.3",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.3.tgz",
"integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==",
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -5108,7 +5203,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -5382,6 +5476,27 @@
"node": ">= 0.8"
}
},
"node_modules/cache-manager": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-6.4.2.tgz",
"integrity": "sha512-oT0d1cGWZAlqEGDPjOfhmldTS767jT6kBT3KIdn7MX5OevlRVYqJT+LxRv5WY4xW9heJtYxeRRXaoKlEW+Biew==",
"license": "MIT",
"dependencies": {
"keyv": "^5.3.2"
}
},
"node_modules/cache-manager-redis-store": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/cache-manager-redis-store/-/cache-manager-redis-store-3.0.1.tgz",
"integrity": "sha512-o560kw+dFqusC9lQJhcm6L2F2fMKobJ5af+FoR2PdnMVdpQ3f3Bz6qzvObTGyvoazQJxjQNWgMQeChP4vRTuXQ==",
"license": "MIT",
"dependencies": {
"redis": "^4.3.1"
},
"engines": {
"node": ">= 16.18.0"
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -5411,6 +5526,16 @@
"node": ">=14.16"
}
},
"node_modules/cacheable-request/node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -5573,6 +5698,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz",
"integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==",
"license": "MIT",
"dependencies": {
"@types/validator": "^13.11.8",
"libphonenumber-js": "^1.10.53",
"validator": "^13.9.0"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -5962,6 +6104,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.5.0.tgz",
"integrity": "sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.4.0",
"luxon": "~3.5.0"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
@@ -5974,6 +6126,15 @@
"node": ">=12.0.0"
}
},
"node_modules/cron/node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6174,6 +6335,33 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz",
"integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6759,6 +6947,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
"integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": "^4.11 || 5 || ^5.0.0-beta.1"
}
},
"node_modules/express/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@@ -7135,6 +7338,16 @@
"node": ">=16"
}
},
"node_modules/flat-cache/node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
@@ -7688,6 +7901,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/hexoid": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz",
@@ -7781,7 +8003,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -8979,13 +9200,12 @@
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.2.tgz",
"integrity": "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ==",
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
"@keyv/serialize": "^1.0.3"
}
},
"node_modules/kind-of": {
@@ -9032,6 +9252,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/libphonenumber-js": {
"version": "1.12.6",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.6.tgz",
"integrity": "sha512-PJiS4ETaUfCOFLpmtKzAbqZQjCCKVu2OhTV4SVNNE7c2nu/dACvtCqj4L0i/KWNnIgRv7yrILvBj5Lonv5Ncxw==",
"license": "MIT"
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9069,7 +9295,6 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.defaults": {
@@ -12295,6 +12520,15 @@
"node": ">=10.12.0"
}
},
"node_modules/validator": {
"version": "13.15.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz",
"integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+9
View File
@@ -20,15 +20,24 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^5.0.1",
"@prisma/client": "^6.6.0",
"@types/bcrypt": "^5.0.2",
"@types/passport-jwt": "^4.0.1",
"bcrypt": "^5.1.1",
"bullmq": "^5.47.2",
"cache-manager": "^6.4.2",
"cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"passport-jwt": "^4.0.1",
"prisma": "^6.6.0",
"redis": "^4.7.0",
+10 -1
View File
@@ -1,13 +1,22 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArticlesModule } from './articles/articles.module';
import { AuthModule } from './auth/auth.module';
import { RssModule } from './rss/rss.module';
import { CollectionsModule } from './collections/collections.module';
import { RedisCacheModule } from './cache/cache.module';
@Module({
imports: [ArticlesModule, AuthModule, RssModule, CollectionsModule],
imports: [
ScheduleModule.forRoot(),
ArticlesModule,
AuthModule,
RssModule,
CollectionsModule,
RedisCacheModule,
],
controllers: [AppController],
providers: [AppService],
})
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
store: redisStore,
host: configService.get('REDIS_HOST', 'localhost'),
port: configService.get('REDIS_PORT', 6379),
ttl: 60 * 60 * 24, // 24 hours
}),
}),
],
exports: [CacheModule],
})
export class RedisCacheModule {}
@@ -6,10 +6,14 @@ import {
Param,
UseGuards,
Request,
Query,
Inject,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { PrismaService } from '../prisma/prisma.service';
import { JwtAuthGuard } from '../auth';
import { Article, Collection, QuickNote } from '@prisma/client';
import { CreateCollectionDto, AddArticleDto, CreateQuickNoteDto } from './dto';
interface RequestWithUser extends Request {
user: {
@@ -18,59 +22,63 @@ interface RequestWithUser extends Request {
};
}
type CollectionWithRelations = Collection & {
articles: Article[];
quickNotes: QuickNote[];
notes: any[];
};
interface CreateCollectionDto {
name: string;
description?: string;
}
interface CreateArticleDto {
title: string;
content: string;
source: string;
url: string;
publishedAt: Date;
}
interface CreateQuickNoteDto {
title: string;
content: string;
shared?: boolean;
sharedVia?: string;
}
@Controller('api/collections')
@UseGuards(JwtAuthGuard)
export class CollectionsController {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
@Get()
async getAllCollections(
@Request() req: RequestWithUser,
): Promise<CollectionWithRelations[]> {
return await this.prisma.collection.findMany({
@Query('limit') limit?: number,
@Query('cursor') cursor?: string,
) {
const cacheKey = `collections:${req.user.id}:${limit}:${cursor}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
return cached;
}
const take = limit ? Number(limit) : 20;
const collections = await this.prisma.collection.findMany({
where: {
userId: req.user.id,
},
take: take + 1,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
include: {
articles: true,
quickNotes: true,
notes: true,
},
});
let nextCursor: string | null = null;
if (collections.length > take) {
const nextItem = collections.pop()!;
nextCursor = nextItem.id;
}
const result = {
collections,
nextCursor,
};
await this.cacheManager.set(cacheKey, result, 300); // Cache for 5 minutes
return result;
}
@Post()
async createCollection(
@Body() data: CreateCollectionDto,
@Request() req: RequestWithUser,
): Promise<CollectionWithRelations> {
return await this.prisma.collection.create({
) {
const collection = await this.prisma.collection.create({
data: {
...data,
userId: req.user.id,
@@ -81,13 +89,20 @@ export class CollectionsController {
notes: true,
},
});
// Invalidate user's collections cache
const cachePattern = `collections:${req.user.id}:*`;
await this.cacheManager.del(cachePattern);
return collection;
}
@Post(':id/add-article')
async addArticleToCollection(
@Param('id') id: string,
@Body() articleData: CreateArticleDto,
): Promise<Article> {
@Body() articleData: AddArticleDto,
@Request() req: RequestWithUser,
) {
const existingArticle = await this.prisma.article.findUnique({
where: { url: articleData.url },
});
@@ -101,10 +116,15 @@ export class CollectionsController {
},
},
});
// Invalidate cache for this collection
const cachePattern = `collections:${req.user.id}:*`;
await this.cacheManager.del(cachePattern);
return existingArticle;
}
return await this.prisma.article.create({
const article = await this.prisma.article.create({
data: {
...articleData,
collections: {
@@ -112,6 +132,12 @@ export class CollectionsController {
},
},
});
// Invalidate cache for this collection
const cachePattern = `collections:${req.user.id}:*`;
await this.cacheManager.del(cachePattern);
return article;
}
@Post(':id/quick-notes')
@@ -119,8 +145,8 @@ export class CollectionsController {
@Param('id') id: string,
@Body() quickNoteData: CreateQuickNoteDto,
@Request() req: RequestWithUser,
): Promise<QuickNote> {
return await this.prisma.quickNote.create({
) {
const quickNote = await this.prisma.quickNote.create({
data: {
...quickNoteData,
shared: quickNoteData.shared ?? false,
@@ -132,5 +158,11 @@ export class CollectionsController {
},
},
});
// Invalidate cache for this collection
const cachePattern = `collections:${req.user.id}:*`;
await this.cacheManager.del(cachePattern);
return quickNote;
}
}
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { CollectionsController } from './collections.controller';
import { AuthModule } from '../auth/auth.module';
import { RedisCacheModule } from '../cache/cache.module';
@Module({
imports: [PrismaModule, AuthModule],
imports: [PrismaModule, AuthModule, RedisCacheModule],
controllers: [CollectionsController],
})
export class CollectionsModule {}
+63
View File
@@ -0,0 +1,63 @@
import {
IsString,
IsOptional,
IsUUID,
IsNotEmpty,
Length,
} from 'class-validator';
export class CreateCollectionDto {
@IsString()
@IsNotEmpty()
@Length(1, 100)
name: string;
@IsString()
@IsOptional()
@Length(0, 500)
description?: string;
@IsUUID()
userId: string;
}
export class AddArticleDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
content: string;
@IsString()
@IsNotEmpty()
source: string;
@IsString()
@IsNotEmpty()
url: string;
@IsString()
@IsNotEmpty()
publishedAt: string;
}
export class CreateQuickNoteDto {
@IsString()
@IsNotEmpty()
@Length(1, 100)
title: string;
@IsString()
@IsNotEmpty()
@Length(1, 5000)
content: string;
@IsOptional()
shared?: boolean;
@IsString()
@IsOptional()
sharedVia?: string;
}
@@ -0,0 +1,39 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
const errorResponse = {
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: ctx.getRequest().url,
};
// Log error for debugging
console.error('Exception:', exception);
console.error('Stack trace:', (exception as Error).stack);
response.status(status).json(errorResponse);
}
}
+29
View File
@@ -1,8 +1,37 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import helmet from 'helmet';
import { ValidationPipe } from '@nestjs/common';
import rateLimit from 'express-rate-limit';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global exception filter
app.useGlobalFilters(new HttpExceptionFilter());
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Security headers
app.use(helmet());
// Rate limiting
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later',
}),
);
await app.listen(process.env.PORT ?? 3000);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from '../prisma/prisma.module';
import { RssService } from './rss.service';
import { RssController } from './rss.controller';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, ScheduleModule],
providers: [RssService],
controllers: [RssController],
exports: [RssService],
+37 -2
View File
@@ -1,9 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Cron, CronExpression } from '@nestjs/schedule';
import * as Parser from 'rss-parser';
@Injectable()
export class RssService {
private feedUrls: string[] = [
'https://rt.com/rss',
'http://feeds.abcnews.com',
'http://rss.cnn.com/rss/cnn',
'https://www.npr.org/rss/',
'https://www.theguardian.com/rss',
'https://www.bbc.co.uk/news',
];
constructor(private prisma: PrismaService) {}
private parser: Parser;
@@ -20,6 +30,25 @@ export class RssService {
});
}
addFeedUrl(url: string) {
if (!this.feedUrls.includes(url)) {
this.feedUrls.push(url);
}
}
@Cron(CronExpression.EVERY_5_MINUTES)
async handleCron() {
console.log('Fetching RSS feeds...', Date.now().toLocaleString());
for (const url of this.feedUrls) {
try {
await this.fetchAndStoreArticles(url);
console.log(`Successfully fetched articles from ${url}`);
} catch (error) {
console.error(`Error fetching from ${url}:`, error);
}
}
}
private cleanHtml(html: string): string {
// Remove HTML tags and decode entities
return html
@@ -36,7 +65,11 @@ export class RssService {
for (const item of feed.items) {
// Extract content from either contentEncoded or description
const rawContent = item.contentEncoded || item.content || item.description || '';
const rawContent =
(item as Parser.Item & { contentEncoded?: string }).contentEncoded ||
item.content ||
item.description ||
'';
const cleanContent = this.cleanHtml(rawContent);
// Get the first image URL from content if available
@@ -44,7 +77,9 @@ export class RssService {
const imageUrl = imageMatch ? imageMatch[1] : null;
// Extract categories and authors
const categories = Array.isArray(item.categories) ? item.categories : [];
const categories = Array.isArray(item.categories)
? item.categories
: [];
const authors = item.creator ? [item.creator] : [];
await this.prisma.article.upsert({