diff --git a/backend/package-lock.json b/backend/package-lock.json index e75b674..e6759ff 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index daaa3b7..2cef981 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ec55e17..fcf8757 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], }) diff --git a/backend/src/cache/cache.module.ts b/backend/src/cache/cache.module.ts new file mode 100644 index 0000000..d74fbed --- /dev/null +++ b/backend/src/cache/cache.module.ts @@ -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 {} + diff --git a/backend/src/collections/collections.controller.ts b/backend/src/collections/collections.controller.ts index 7489c07..05148da 100644 --- a/backend/src/collections/collections.controller.ts +++ b/backend/src/collections/collections.controller.ts @@ -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 { - 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 { - 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
{ + @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 { - 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; } } diff --git a/backend/src/collections/collections.module.ts b/backend/src/collections/collections.module.ts index 4893854..96ac12c 100644 --- a/backend/src/collections/collections.module.ts +++ b/backend/src/collections/collections.module.ts @@ -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 {} diff --git a/backend/src/collections/dto.ts b/backend/src/collections/dto.ts new file mode 100644 index 0000000..eb978a5 --- /dev/null +++ b/backend/src/collections/dto.ts @@ -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; +} diff --git a/backend/src/filters/http-exception.filter.ts b/backend/src/filters/http-exception.filter.ts new file mode 100644 index 0000000..0973fb3 --- /dev/null +++ b/backend/src/filters/http-exception.filter.ts @@ -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(); + + 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); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 0e2fcda..4b4a5c0 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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); } diff --git a/backend/src/rss/rss.module.ts b/backend/src/rss/rss.module.ts index cb327ea..32f2b15 100644 --- a/backend/src/rss/rss.module.ts +++ b/backend/src/rss/rss.module.ts @@ -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], diff --git a/backend/src/rss/rss.service.ts b/backend/src/rss/rss.service.ts index 4080baf..54caa5f 100644 --- a/backend/src/rss/rss.service.ts +++ b/backend/src/rss/rss.service.ts @@ -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({ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6092cd1..75fe1d2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,10 +9,11 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-accordion": "^1.2.3", - "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-menubar": "^1.1.6", "@radix-ui/react-navigation-menu": "^1.2.5", @@ -29,6 +30,7 @@ "lucide-react": "^0.487.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-resizable-panels": "^2.1.7", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5" @@ -1193,17 +1195,17 @@ } }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", - "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.7.tgz", + "integrity": "sha512-7Gx1gcoltd0VxKoR8mc+TAVbzvChJyZryZsTam0UhoL92z0L+W8ovxvcgvd+nkz24y7Qc51JQKBAGe4+825tYw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dialog": "1.1.6", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2" + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.7", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", @@ -1220,6 +1222,83 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz", + "integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", @@ -1330,23 +1409,23 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", - "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.7.tgz", + "integrity": "sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.1", - "@radix-ui/react-compose-refs": "1.1.1", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.5", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.2", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.4", - "@radix-ui/react-presence": "1.1.2", - "@radix-ui/react-primitive": "2.0.2", - "@radix-ui/react-slot": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.3", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.5", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -1365,6 +1444,282 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz", + "integrity": "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.3.tgz", + "integrity": "sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.5.tgz", + "integrity": "sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz", + "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz", + "integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz", + "integrity": "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1507,6 +1862,15 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -5081,6 +5445,16 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz", + "integrity": "sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index b5ff676..b5685fc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,10 +11,11 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.3", - "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-menubar": "^1.1.6", "@radix-ui/react-navigation-menu": "^1.2.5", @@ -31,6 +32,7 @@ "lucide-react": "^0.487.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-resizable-panels": "^2.1.7", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5" diff --git a/frontend/src/api/collections.ts b/frontend/src/api/collections.ts index 1b91345..8b9eaf0 100644 --- a/frontend/src/api/collections.ts +++ b/frontend/src/api/collections.ts @@ -9,13 +9,24 @@ const getAuthHeaders = () => { }; }; -export async function fetchCollections(): Promise { - const response = await fetch('/api/collections', { +export interface PaginatedCollections { + collections: Collection[]; + nextCursor: string | null; +} + +export async function fetchCollections(cursor?: string, limit: number = 20): Promise { + const params = new URLSearchParams(); + if (cursor) params.append('cursor', cursor); + if (limit) params.append('limit', limit.toString()); + + const response = await fetch(`/api/collections?${params.toString()}`, { headers: getAuthHeaders(), }); + if (!response.ok) { throw new Error('Failed to fetch collections'); } + return response.json(); } @@ -25,19 +36,35 @@ export async function createCollection(data: { name: string; description?: strin headers: getAuthHeaders(), body: JSON.stringify(data), }); + if (!response.ok) { - throw new Error('Failed to create collection'); + const error = await response.json(); + throw new Error(error.message || 'Failed to create collection'); } + return response.json(); } -export async function addArticleToCollection(collectionId: string, articleData: { title: string; content: string; source: string; url: string; publishedAt: Date }): Promise { +export async function addArticleToCollection( + collectionId: string, + articleData: { + title: string; + content: string; + source: string; + url: string; + publishedAt: Date; + categories?: string[]; + authors?: string[]; + } +): Promise { const response = await fetch(`/api/collections/${collectionId}/add-article`, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(articleData), }); + if (!response.ok) { - throw new Error('Failed to add article to collection'); + const error = await response.json(); + throw new Error(error.message || 'Failed to add article to collection'); } } \ No newline at end of file diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..4b6d546 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} \ No newline at end of file diff --git a/frontend/src/components/ui/resizable.tsx b/frontend/src/components/ui/resizable.tsx new file mode 100644 index 0000000..33b583f --- /dev/null +++ b/frontend/src/components/ui/resizable.tsx @@ -0,0 +1,43 @@ +import { DragHandleDots2Icon } from "@radix-ui/react-icons" +import * as ResizablePrimitive from "react-resizable-panels" + +import { cn } from "@/lib/utils" + +const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps) => ( + +) + +const ResizablePanel = ResizablePrimitive.Panel + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean +}) => ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+) + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } \ No newline at end of file diff --git a/frontend/src/routes/collections.tsx b/frontend/src/routes/collections.tsx index 00e983d..b35b87f 100644 --- a/frontend/src/routes/collections.tsx +++ b/frontend/src/routes/collections.tsx @@ -1,24 +1,40 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { fetchCollections, createCollection } from '@/api/collections'; import { Button } from '@/components/ui/button'; -import { useState } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { createFileRoute } from '@tanstack/react-router'; -import { useUser, useIsAuthenticated } from '@/store/user'; +import { useUser } from '@/store/user'; import { useNavigate } from '@tanstack/react-router'; import { ProtectedRoute } from '@/components/ProtectedRoute'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; function CollectionsPage() { const queryClient = useQueryClient(); const user = useUser(); const navigate = useNavigate(); - const { data: collections, isLoading, isError } = useQuery({ + const loadMoreRef = useRef(null); + + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useInfiniteQuery({ queryKey: ['collections'], - queryFn: fetchCollections, + queryFn: ({ pageParam }) => fetchCollections(pageParam as string | undefined), + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialPageParam: undefined as string | undefined, }); const mutation = useMutation({ mutationFn: (data: { name: string; description: string; userId: string }) => createCollection(data), - onSuccess: () => queryClient.invalidateQueries(['collections']), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['collections'] }), + onError: (error) => { + alert(error instanceof Error ? error.message : 'Failed to create collection'); + }, }); const [newCollection, setNewCollection] = useState({ name: '', description: '' }); @@ -30,47 +46,171 @@ function CollectionsPage() { return; } + if (!newCollection.name.trim()) { + alert('Please enter a collection name'); + return; + } + mutation.mutate({ ...newCollection, userId: user.id }); setNewCollection({ name: '', description: '' }); }; - if (isLoading) return
Loading collections...
; - if (isError) return
Failed to load collections. Please try again later.
; + // Intersection Observer for infinite scroll + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { + const [target] = entries; + if (target.isIntersecting && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + useEffect(() => { + const element = loadMoreRef.current; + const observer = new IntersectionObserver(handleObserver, { + root: null, + rootMargin: '20px', + threshold: 0.1, + }); + + if (element) { + observer.observe(element); + } + + return () => { + if (element) { + observer.unobserve(element); + } + }; + }, [handleObserver]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError) { + return ( +
+

Error

+

{error instanceof Error ? error.message : 'Failed to load collections'}

+ +
+ ); + } + + const allCollections = data?.pages.flatMap(page => page.collections) ?? []; return (

Collections

-
- setNewCollection({ ...newCollection, name: e.target.value })} - className="border p-2 rounded w-full" - /> -