A social knowledge tool for researchers built on ATProto

Merge pull request #167 from cosmik-network/development

Development

authored by

Wesley Finck and committed by
GitHub
34461fd1 f74a2fcc

+2696 -898
-3
.github/ISSUE_TEMPLATE/bug-report.md
··· 4 4 title: '' 5 5 labels: bug 6 6 assignees: '' 7 - 8 7 --- 9 - 10 -
-3
.github/ISSUE_TEMPLATE/feature.md
··· 4 4 title: '' 5 5 labels: feature 6 6 assignees: '' 7 - 8 7 --- 9 - 10 -
-3
.github/ISSUE_TEMPLATE/task.md
··· 4 4 title: '' 5 5 labels: task 6 6 assignees: '' 7 - 8 7 --- 9 - 10 -
+1
eslint.config.mjs
··· 76 76 NodeJS: 'readonly', 77 77 clearTimeout: 'readonly', 78 78 setImmediate: 'readonly', 79 + setInterval: 'readonly', 79 80 }, 80 81 }, 81 82 rules: {
+2 -1
fly.development.toml
··· 38 38 BASE_URL="https://api.dev.semble.so" 39 39 HOST="0.0.0.0" 40 40 APP_URL="https://dev.semble.so" 41 - USE_IN_MEMORY_EVENTS="false" 41 + ACCESS_TOKEN_EXPIRES_IN=300 42 + REFRESH_TOKEN_EXPIRES_IN=2592000
+2 -1
fly.production.toml
··· 39 39 BASE_URL="https://api.semble.so" 40 40 HOST="0.0.0.0" 41 41 APP_URL="https://semble.so" 42 - USE_IN_MEMORY_EVENTS="false" 42 + ACCESS_TOKEN_EXPIRES_IN=2592000 43 + REFRESH_TOKEN_EXPIRES_IN=5184000
+183
package-lock.json
··· 31 31 "ioredis": "^5.6.1", 32 32 "jsonwebtoken": "^9.0.2", 33 33 "postgres": "^3.4.5", 34 + "redlock": "^5.0.0-beta.2", 34 35 "uuid": "^11.1.0", 35 36 "zod": "^3.22.4" 36 37 }, ··· 60 61 "eslint-plugin-storybook": "^9.1.2", 61 62 "jest": "^29.7.0", 62 63 "jsdom": "^26.1.0", 64 + "nodemon": "^3.1.10", 63 65 "pg": "^8.14.1", 64 66 "playwright": "^1.40.0", 65 67 "prettier": "^3.6.2", ··· 17444 17446 "node": ">= 4" 17445 17447 } 17446 17448 }, 17449 + "node_modules/ignore-by-default": { 17450 + "version": "1.0.1", 17451 + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", 17452 + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", 17453 + "dev": true 17454 + }, 17447 17455 "node_modules/image-size": { 17448 17456 "version": "0.5.5", 17449 17457 "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", ··· 21212 21220 "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", 21213 21221 "dev": true 21214 21222 }, 21223 + "node_modules/nodemon": { 21224 + "version": "3.1.10", 21225 + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", 21226 + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", 21227 + "dev": true, 21228 + "dependencies": { 21229 + "chokidar": "^3.5.2", 21230 + "debug": "^4", 21231 + "ignore-by-default": "^1.0.1", 21232 + "minimatch": "^3.1.2", 21233 + "pstree.remy": "^1.1.8", 21234 + "semver": "^7.5.3", 21235 + "simple-update-notifier": "^2.0.0", 21236 + "supports-color": "^5.5.0", 21237 + "touch": "^3.1.0", 21238 + "undefsafe": "^2.0.5" 21239 + }, 21240 + "bin": { 21241 + "nodemon": "bin/nodemon.js" 21242 + }, 21243 + "engines": { 21244 + "node": ">=10" 21245 + }, 21246 + "funding": { 21247 + "type": "opencollective", 21248 + "url": "https://opencollective.com/nodemon" 21249 + } 21250 + }, 21251 + "node_modules/nodemon/node_modules/brace-expansion": { 21252 + "version": "1.1.12", 21253 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", 21254 + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", 21255 + "dev": true, 21256 + "dependencies": { 21257 + "balanced-match": "^1.0.0", 21258 + "concat-map": "0.0.1" 21259 + } 21260 + }, 21261 + "node_modules/nodemon/node_modules/chokidar": { 21262 + "version": "3.6.0", 21263 + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", 21264 + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", 21265 + "dev": true, 21266 + "dependencies": { 21267 + "anymatch": "~3.1.2", 21268 + "braces": "~3.0.2", 21269 + "glob-parent": "~5.1.2", 21270 + "is-binary-path": "~2.1.0", 21271 + "is-glob": "~4.0.1", 21272 + "normalize-path": "~3.0.0", 21273 + "readdirp": "~3.6.0" 21274 + }, 21275 + "engines": { 21276 + "node": ">= 8.10.0" 21277 + }, 21278 + "funding": { 21279 + "url": "https://paulmillr.com/funding/" 21280 + }, 21281 + "optionalDependencies": { 21282 + "fsevents": "~2.3.2" 21283 + } 21284 + }, 21285 + "node_modules/nodemon/node_modules/glob-parent": { 21286 + "version": "5.1.2", 21287 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", 21288 + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 21289 + "dev": true, 21290 + "dependencies": { 21291 + "is-glob": "^4.0.1" 21292 + }, 21293 + "engines": { 21294 + "node": ">= 6" 21295 + } 21296 + }, 21297 + "node_modules/nodemon/node_modules/has-flag": { 21298 + "version": "3.0.0", 21299 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", 21300 + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", 21301 + "dev": true, 21302 + "engines": { 21303 + "node": ">=4" 21304 + } 21305 + }, 21306 + "node_modules/nodemon/node_modules/minimatch": { 21307 + "version": "3.1.2", 21308 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 21309 + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 21310 + "dev": true, 21311 + "dependencies": { 21312 + "brace-expansion": "^1.1.7" 21313 + }, 21314 + "engines": { 21315 + "node": "*" 21316 + } 21317 + }, 21318 + "node_modules/nodemon/node_modules/picomatch": { 21319 + "version": "2.3.1", 21320 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 21321 + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 21322 + "dev": true, 21323 + "engines": { 21324 + "node": ">=8.6" 21325 + }, 21326 + "funding": { 21327 + "url": "https://github.com/sponsors/jonschlinkert" 21328 + } 21329 + }, 21330 + "node_modules/nodemon/node_modules/readdirp": { 21331 + "version": "3.6.0", 21332 + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", 21333 + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", 21334 + "dev": true, 21335 + "dependencies": { 21336 + "picomatch": "^2.2.1" 21337 + }, 21338 + "engines": { 21339 + "node": ">=8.10.0" 21340 + } 21341 + }, 21342 + "node_modules/nodemon/node_modules/supports-color": { 21343 + "version": "5.5.0", 21344 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", 21345 + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", 21346 + "dev": true, 21347 + "dependencies": { 21348 + "has-flag": "^3.0.0" 21349 + }, 21350 + "engines": { 21351 + "node": ">=4" 21352 + } 21353 + }, 21215 21354 "node_modules/normalize-path": { 21216 21355 "version": "3.0.0", 21217 21356 "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", ··· 23149 23288 "optional": true, 23150 23289 "peer": true 23151 23290 }, 23291 + "node_modules/pstree.remy": { 23292 + "version": "1.1.8", 23293 + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", 23294 + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", 23295 + "dev": true 23296 + }, 23152 23297 "node_modules/pump": { 23153 23298 "version": "3.0.3", 23154 23299 "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", ··· 23700 23845 }, 23701 23846 "engines": { 23702 23847 "node": ">=4" 23848 + } 23849 + }, 23850 + "node_modules/redlock": { 23851 + "version": "5.0.0-beta.2", 23852 + "resolved": "https://registry.npmjs.org/redlock/-/redlock-5.0.0-beta.2.tgz", 23853 + "integrity": "sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==", 23854 + "dependencies": { 23855 + "node-abort-controller": "^3.0.1" 23856 + }, 23857 + "engines": { 23858 + "node": ">=12" 23703 23859 } 23704 23860 }, 23705 23861 "node_modules/reflect.getprototypeof": { ··· 24539 24695 "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", 24540 24696 "dev": true 24541 24697 }, 24698 + "node_modules/simple-update-notifier": { 24699 + "version": "2.0.0", 24700 + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", 24701 + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", 24702 + "dev": true, 24703 + "dependencies": { 24704 + "semver": "^7.5.3" 24705 + }, 24706 + "engines": { 24707 + "node": ">=10" 24708 + } 24709 + }, 24542 24710 "node_modules/sisteransi": { 24543 24711 "version": "1.0.5", 24544 24712 "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", ··· 25770 25938 "node": ">=0.6" 25771 25939 } 25772 25940 }, 25941 + "node_modules/touch": { 25942 + "version": "3.1.1", 25943 + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", 25944 + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", 25945 + "dev": true, 25946 + "bin": { 25947 + "nodetouch": "bin/nodetouch.js" 25948 + } 25949 + }, 25773 25950 "node_modules/tough-cookie": { 25774 25951 "version": "5.1.2", 25775 25952 "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", ··· 26307 26484 "funding": { 26308 26485 "url": "https://github.com/sponsors/ljharb" 26309 26486 } 26487 + }, 26488 + "node_modules/undefsafe": { 26489 + "version": "2.0.5", 26490 + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", 26491 + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", 26492 + "dev": true 26310 26493 }, 26311 26494 "node_modules/undici": { 26312 26495 "version": "6.22.0",
+3 -1
package.json
··· 34 34 "dev:app:inner": "dotenv -e .env.local -- concurrently -k -n TYPE,APP -c red,blue \"tsc --noEmit --watch\" \"tsup --watch --onSuccess='node dist/index.js'\"", 35 35 "dev:worker:feeds:inner": "dotenv -e .env.local -- concurrently -k -n WORKER -c green \"tsup --watch --onSuccess='node dist/workers/feed-worker.js'\"", 36 36 "dev:worker:search:inner": "dotenv -e .env.local -- concurrently -k -n WORKER -c yellow \"tsup --watch --onSuccess='node dist/workers/search-worker.js'\"", 37 - "dev:mock": "USE_MOCK_REPOS=true USE_FAKE_PUBLISHERS=true USE_IN_MEMORY_EVENTS=true USE_MOCK_AUTH=true npm run dev:app:inner", 37 + "dev:mock": "USE_MOCK_PERSISTENCE=true USE_FAKE_PUBLISHERS=true USE_MOCK_AUTH=true USE_MOCK_VECTOR_DB=true npm run dev:app:inner", 38 38 "dev:mock:pub:auth": "USE_FAKE_PUBLISHERS=true USE_MOCK_AUTH=true npm run dev", 39 39 "dev": "bash ./scripts/dev-combined.sh", 40 40 "migrate": "node dist/scripts/migrate.js", ··· 78 78 "ioredis": "^5.6.1", 79 79 "jsonwebtoken": "^9.0.2", 80 80 "postgres": "^3.4.5", 81 + "redlock": "^5.0.0-beta.2", 81 82 "uuid": "^11.1.0", 82 83 "zod": "^3.22.4" 83 84 }, ··· 107 108 "eslint-plugin-storybook": "^9.1.2", 108 109 "jest": "^29.7.0", 109 110 "jsdom": "^26.1.0", 111 + "nodemon": "^3.1.10", 110 112 "pg": "^8.14.1", 111 113 "playwright": "^1.40.0", 112 114 "prettier": "^3.6.2",
+6 -5
scripts/dev-combined.sh
··· 17 17 18 18 echo "Starting development with separate processes (BullMQ + Redis)..." 19 19 20 - # Run both services with concurrently 21 - concurrently -k -n APP,FEED,SEARCH -c blue,green,yellow \ 22 - "dotenv -e .env.local -- concurrently -k -n TYPE,APP -c red,blue \"tsc --noEmit --watch\" \"tsup --watch --onSuccess='node dist/index.js'\"" \ 23 - "dotenv -e .env.local -- concurrently -k -n WORKER -c green \"tsup --watch --onSuccess='node dist/workers/feed-worker.js'\"" \ 24 - "dotenv -e .env.local -- concurrently -k -n WORKER -c yellow \"tsup --watch --onSuccess='node dist/workers/search-worker.js'\"" 20 + # Use nodemon instead of tsup --onSuccess for better process management 21 + concurrently -k -n APP,FEED,SEARCH,BUILD -c blue,green,yellow,red \ 22 + "dotenv -e .env.local -- nodemon --exec 'node dist/index.js' --watch dist/index.js --delay 1000ms" \ 23 + "dotenv -e .env.local -- nodemon --exec 'node dist/workers/feed-worker.js' --watch dist/workers/feed-worker.js --delay 1000ms" \ 24 + "dotenv -e .env.local -- nodemon --exec 'node dist/workers/search-worker.js' --watch dist/workers/search-worker.js --delay 1000ms" \ 25 + "tsup --watch" 25 26 26 27 # Cleanup after concurrently exits 27 28 cleanup_postgres
+1 -1
src/index.ts
··· 10 10 await appProcess.start(); 11 11 12 12 // Only start event worker in same process when using in-memory events 13 - const useInMemoryEvents = process.env.USE_IN_MEMORY_EVENTS === 'true'; 13 + const useInMemoryEvents = configService.shouldUseInMemoryEvents(); 14 14 if (useInMemoryEvents) { 15 15 console.log('Starting in-memory event worker in the same process...'); 16 16 const inMemoryWorkerProcess = new InMemoryEventWorkerProcess(configService);
+2 -2
src/modules/atproto/infrastructure/__tests__/ATProtoCardPublisher.integration.test.ts
··· 12 12 dotenv.config({ path: '.env.test' }); 13 13 14 14 // Set to false to skip unpublishing (useful for debugging published records) 15 - const UNPUBLISH = false; 15 + const UNPUBLISH = true; 16 16 17 - describe('ATProtoCardPublisher', () => { 17 + describe.skip('ATProtoCardPublisher', () => { 18 18 let publisher: ATProtoCardPublisher; 19 19 let curatorId: CuratorId; 20 20 let publishedCardIds: PublishedRecordId[] = [];
+2 -2
src/modules/atproto/infrastructure/__tests__/ATProtoCollectionPublisher.integration.test.ts
··· 15 15 dotenv.config({ path: '.env.test' }); 16 16 17 17 // Set to false to skip unpublishing (useful for debugging published records) 18 - const UNPUBLISH = false; 18 + const UNPUBLISH = true; 19 19 20 - describe('ATProtoCollectionPublisher', () => { 20 + describe.skip('ATProtoCollectionPublisher', () => { 21 21 let collectionPublisher: ATProtoCollectionPublisher; 22 22 let cardPublisher: FakeCardPublisher; 23 23 let curatorId: CuratorId;
+15 -2
src/modules/atproto/infrastructure/publishers/ATProtoCardPublisher.ts
··· 8 8 import { IAgentService } from '../../application/IAgentService'; 9 9 import { DID } from '../../domain/DID'; 10 10 import { PublishedRecordId } from 'src/modules/cards/domain/value-objects/PublishedRecordId'; 11 + import { AuthenticationError } from 'src/shared/core/AuthenticationError'; 11 12 export class ATProtoCardPublisher implements ICardPublisher { 12 13 constructor( 13 14 private readonly agentService: IAgentService, ··· 45 46 await this.agentService.getAuthenticatedAgent(curatorDid); 46 47 47 48 if (agentResult.isErr()) { 49 + // Propagate authentication errors as-is 50 + if (agentResult.error instanceof AuthenticationError) { 51 + return err(agentResult.error); 52 + } 48 53 return err( 49 - new Error(`Authentication error: ${agentResult.error.message}`), 54 + new Error( 55 + `Authentication error for ATProtoCardPublisher: ${agentResult.error.message}`, 56 + ), 50 57 ); 51 58 } 52 59 ··· 129 136 await this.agentService.getAuthenticatedAgent(curatorDid); 130 137 131 138 if (agentResult.isErr()) { 139 + // Propagate authentication errors as-is 140 + if (agentResult.error instanceof AuthenticationError) { 141 + return err(agentResult.error); 142 + } 132 143 return err( 133 - new Error(`Authentication error: ${agentResult.error.message}`), 144 + new Error( 145 + `Authentication error for ATProtoCardPublisher: ${agentResult.error.message}`, 146 + ), 134 147 ); 135 148 } 136 149
+29 -4
src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts
··· 13 13 import { StrongRef } from '../../domain'; 14 14 import { IAgentService } from '../../application/IAgentService'; 15 15 import { DID } from '../../domain/DID'; 16 + import { AuthenticationError } from 'src/shared/core/AuthenticationError'; 16 17 17 18 export class ATProtoCollectionPublisher implements ICollectionPublisher { 18 19 constructor( ··· 43 44 await this.agentService.getAuthenticatedAgent(curatorDid); 44 45 45 46 if (agentResult.isErr()) { 47 + // Propagate authentication errors as-is 48 + if (agentResult.error instanceof AuthenticationError) { 49 + return err(agentResult.error); 50 + } 46 51 return err( 47 - new Error(`Authentication error: ${agentResult.error.message}`), 52 + new Error( 53 + `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`, 54 + ), 48 55 ); 49 56 } 50 57 ··· 123 130 await this.agentService.getAuthenticatedAgent(curatorDid); 124 131 125 132 if (agentResult.isErr()) { 133 + // Propagate authentication errors as-is 134 + if (agentResult.error instanceof AuthenticationError) { 135 + return err(agentResult.error); 136 + } 126 137 return err( 127 - new Error(`Authentication error: ${agentResult.error.message}`), 138 + new Error( 139 + `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`, 140 + ), 128 141 ); 129 142 } 130 143 ··· 221 234 await this.agentService.getAuthenticatedAgent(curatorDid); 222 235 223 236 if (agentResult.isErr()) { 237 + // Propagate authentication errors as-is 238 + if (agentResult.error instanceof AuthenticationError) { 239 + return err(agentResult.error); 240 + } 224 241 return err( 225 - new Error(`Authentication error: ${agentResult.error.message}`), 242 + new Error( 243 + `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`, 244 + ), 226 245 ); 227 246 } 228 247 ··· 265 284 await this.agentService.getAuthenticatedAgent(curatorDid); 266 285 267 286 if (agentResult.isErr()) { 287 + // Propagate authentication errors as-is 288 + if (agentResult.error instanceof AuthenticationError) { 289 + return err(agentResult.error); 290 + } 268 291 return err( 269 - new Error(`Authentication error: ${agentResult.error.message}`), 292 + new Error( 293 + `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`, 294 + ), 270 295 ); 271 296 } 272 297
+16 -10
src/modules/atproto/infrastructure/services/ATProtoAgentService.ts
··· 5 5 import { DID } from '../../domain/DID'; 6 6 import { IAppPasswordSessionService } from '../../application/IAppPasswordSessionService'; 7 7 import { ATPROTO_SERVICE_ENDPOINTS } from './ServiceEndpoints'; 8 + import { AuthenticationError } from 'src/shared/core/AuthenticationError'; 8 9 9 10 export class ATProtoAgentService implements IAgentService { 10 11 constructor( ··· 27 28 await this.getAuthenticatedAgentByAppPasswordSession(did); 28 29 if (appPasswordAgentResult.isErr()) { 29 30 return err( 30 - new Error( 31 - `Failed to get authenticated agent: ${oauthAgentResult.error.message} | ${appPasswordAgentResult.error.message}`, 31 + new AuthenticationError( 32 + `Failed to authenticate: No valid OAuth or App Password session found. OAuth error: ${oauthAgentResult.error.message}. App Password error: ${appPasswordAgentResult.error.message}`, 32 33 ), 33 34 ); 34 35 } ··· 49 50 } 50 51 51 52 // No session found 52 - throw new Error('No session found for the provided DID'); 53 + throw new AuthenticationError( 54 + 'No OAuth session found for the provided DID', 55 + ); 53 56 } catch (error) { 54 57 return err( 55 - new Error( 56 - `Failed to get authenticated agent by OAuth session: ${error instanceof Error ? error.message : String(error)}`, 58 + new AuthenticationError( 59 + `OAuth authentication failed: ${error instanceof Error ? error.message : String(error)}`, 57 60 ), 58 61 ); 59 62 } ··· 68 71 69 72 if (appPasswordSessionResult.isErr()) { 70 73 return err( 71 - new Error( 72 - `Failed to get App Password session: ${appPasswordSessionResult.error.message}`, 74 + new AuthenticationError( 75 + `App Password session failed: ${appPasswordSessionResult.error.message}`, 73 76 ), 74 77 ); 75 78 } 79 + 76 80 const session = appPasswordSessionResult.value; 77 81 if (session) { 78 82 // Create an Agent with the session ··· 88 92 } 89 93 90 94 // No session found 91 - throw new Error('No session found for the provided DID'); 95 + throw new AuthenticationError( 96 + 'No App Password session found for the provided DID', 97 + ); 92 98 } catch (error) { 93 99 return err( 94 - new Error( 95 - `Failed to get authenticated agent by App Password session: ${error instanceof Error ? error.message : String(error)}`, 100 + new AuthenticationError( 101 + `App Password authentication failed: ${error instanceof Error ? error.message : String(error)}`, 96 102 ), 97 103 ); 98 104 }
+3 -2
src/modules/atproto/infrastructure/services/BlueskyProfileService.ts
··· 5 5 import { Result, ok, err } from 'src/shared/core/Result'; 6 6 import { IAgentService } from '../../application/IAgentService'; 7 7 import { DID } from '../../domain/DID'; 8 + import { AuthenticationError } from 'src/shared/core/AuthenticationError'; 8 9 9 10 export class BlueskyProfileService implements IProfileService { 10 11 constructor(private readonly agentService: IAgentService) {} ··· 30 31 ); 31 32 if (agentResult.isErr()) { 32 33 return err( 33 - new Error( 34 - `Failed to get authenticated agent: ${agentResult.error.message}`, 34 + new AuthenticationError( 35 + `Failed to get authenticated agent for BlueskyProfileService: ${agentResult.error.message}`, 35 36 ), 36 37 ); 37 38 }
+88
src/modules/atproto/infrastructure/services/CachedBlueskyProfileService.ts
··· 1 + import Redis from 'ioredis'; 2 + import { 3 + IProfileService, 4 + UserProfile, 5 + } from 'src/modules/cards/domain/services/IProfileService'; 6 + import { Result, ok } from 'src/shared/core/Result'; 7 + 8 + export class CachedBlueskyProfileService implements IProfileService { 9 + private readonly CACHE_TTL_SECONDS = 3600 * 12; // 12 hours 10 + private readonly CACHE_KEY_PREFIX = 'profile:'; 11 + 12 + constructor( 13 + private readonly profileService: IProfileService, 14 + private readonly redis: Redis, 15 + ) {} 16 + 17 + async getProfile( 18 + userId: string, 19 + callerId?: string, 20 + ): Promise<Result<UserProfile>> { 21 + const cacheKey = this.getCacheKey(userId); 22 + 23 + try { 24 + // Try cache first 25 + const cached = await this.redis.get(cacheKey); 26 + if (cached) { 27 + try { 28 + const profile = JSON.parse(cached) as UserProfile; 29 + return ok(profile); 30 + } catch (parseError) { 31 + // If JSON parsing fails, continue to fetch fresh data 32 + console.warn( 33 + `Failed to parse cached profile for ${userId}:`, 34 + parseError, 35 + ); 36 + } 37 + } 38 + 39 + // Cache miss or parse error - fetch from underlying service 40 + const result = await this.profileService.getProfile(userId, callerId); 41 + 42 + if (result.isOk()) { 43 + // Cache the successful result 44 + try { 45 + await this.redis.setex( 46 + cacheKey, 47 + this.CACHE_TTL_SECONDS, 48 + JSON.stringify(result.value), 49 + ); 50 + } catch (cacheError) { 51 + // Log cache error but don't fail the request 52 + console.warn(`Failed to cache profile for ${userId}:`, cacheError); 53 + } 54 + } 55 + 56 + return result; 57 + } catch (redisError) { 58 + // If Redis is down, fall back to direct service call 59 + console.warn( 60 + `Redis error when fetching profile for ${userId}:`, 61 + redisError, 62 + ); 63 + return this.profileService.getProfile(userId, callerId); 64 + } 65 + } 66 + 67 + private getCacheKey(userId: string): string { 68 + return `${this.CACHE_KEY_PREFIX}${userId}`; 69 + } 70 + 71 + /** 72 + * Invalidate cached profile for a specific user 73 + */ 74 + async invalidateProfile(userId: string): Promise<void> { 75 + try { 76 + await this.redis.del(this.getCacheKey(userId)); 77 + } catch (error) { 78 + console.warn(`Failed to invalidate profile cache for ${userId}:`, error); 79 + } 80 + } 81 + 82 + /** 83 + * Warm the cache by pre-fetching a profile 84 + */ 85 + async warmCache(userId: string, callerId?: string): Promise<void> { 86 + await this.getProfile(userId, callerId); 87 + } 88 + }
+5 -1
src/modules/atproto/infrastructure/services/FakeAgentService.ts
··· 17 17 handle: mockHandle, 18 18 displayName: `Mock User`, 19 19 description: 'This is a mock profile for testing purposes', 20 - avatar: 'https://via.placeholder.com/150', 20 + avatar: 21 + 'https://cdn.bsky.app/img/avatar/plain/did:plc:rlknsba2qldjkicxsmni3vyn/bafkreid4nmxspygkftep5b3m2wlcm3xvnwefkswzej7dhipojjxylkzfby@jpeg', 21 22 }, 22 23 }; 23 24 }, ··· 41 42 async getAuthenticatedAgent(did: DID): Promise<Result<Agent, Error>> { 42 43 try { 43 44 // Return the same mock agent for authenticated requests 45 + 46 + // uncomment the line below to test error handling 47 + // throw new Error('Not implemented in FakeAgentService'); 44 48 return this.getUnauthenticatedAgent(); 45 49 } catch (error: any) { 46 50 return err(error);
+7 -2
src/modules/cards/application/useCases/commands/AddCardToCollectionUseCase.ts
··· 9 9 import { CollectionId } from '../../../domain/value-objects/CollectionId'; 10 10 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 11 11 import { CardCollectionService } from '../../../domain/services/CardCollectionService'; 12 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 12 13 13 14 export interface AddCardToCollectionDTO { 14 15 cardId: string; ··· 30 31 AddCardToCollectionDTO, 31 32 Result< 32 33 AddCardToCollectionResponseDTO, 33 - ValidationError | AppError.UnexpectedError 34 + ValidationError | AuthenticationError | AppError.UnexpectedError 34 35 > 35 36 > { 36 37 constructor( ··· 46 47 ): Promise< 47 48 Result< 48 49 AddCardToCollectionResponseDTO, 49 - ValidationError | AppError.UnexpectedError 50 + ValidationError | AuthenticationError | AppError.UnexpectedError 50 51 > 51 52 > { 52 53 try { ··· 104 105 curatorId, 105 106 ); 106 107 if (addToCollectionsResult.isErr()) { 108 + // Propagate authentication errors 109 + if (addToCollectionsResult.error instanceof AuthenticationError) { 110 + return err(addToCollectionsResult.error); 111 + } 107 112 if (addToCollectionsResult.error instanceof AppError.UnexpectedError) { 108 113 return err(addToCollectionsResult.error); 109 114 }
+11 -2
src/modules/cards/application/useCases/commands/AddCardToLibraryUseCase.ts
··· 8 8 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 9 9 import { CardLibraryService } from '../../../domain/services/CardLibraryService'; 10 10 import { CardCollectionService } from '../../../domain/services/CardCollectionService'; 11 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 11 12 12 13 export interface AddCardToLibraryDTO { 13 14 cardId: string; ··· 31 32 AddCardToLibraryDTO, 32 33 Result< 33 34 AddCardToLibraryResponseDTO, 34 - ValidationError | AppError.UnexpectedError 35 + ValidationError | AuthenticationError | AppError.UnexpectedError 35 36 > 36 37 > 37 38 { ··· 46 47 ): Promise< 47 48 Result< 48 49 AddCardToLibraryResponseDTO, 49 - ValidationError | AppError.UnexpectedError 50 + ValidationError | AuthenticationError | AppError.UnexpectedError 50 51 > 51 52 > { 52 53 try { ··· 87 88 curatorId, 88 89 ); 89 90 if (addToLibraryResult.isErr()) { 91 + // Propagate authentication errors 92 + if (addToLibraryResult.error instanceof AuthenticationError) { 93 + return err(addToLibraryResult.error); 94 + } 90 95 if (addToLibraryResult.error instanceof AppError.UnexpectedError) { 91 96 return err(addToLibraryResult.error); 92 97 } ··· 118 123 curatorId, 119 124 ); 120 125 if (addToCollectionsResult.isErr()) { 126 + // Propagate authentication errors 127 + if (addToCollectionsResult.error instanceof AuthenticationError) { 128 + return err(addToCollectionsResult.error); 129 + } 121 130 if ( 122 131 addToCollectionsResult.error instanceof AppError.UnexpectedError 123 132 ) {
+20 -2
src/modules/cards/application/useCases/commands/AddUrlToLibraryUseCase.ts
··· 17 17 import { CardLibraryService } from '../../../domain/services/CardLibraryService'; 18 18 import { CardCollectionService } from '../../../domain/services/CardCollectionService'; 19 19 import { CardContent } from '../../../domain/value-objects/CardContent'; 20 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 20 21 21 22 export interface AddUrlToLibraryDTO { 22 23 url: string; ··· 38 39 39 40 export class AddUrlToLibraryUseCase extends BaseUseCase< 40 41 AddUrlToLibraryDTO, 41 - Result<AddUrlToLibraryResponseDTO, ValidationError | AppError.UnexpectedError> 42 + Result< 43 + AddUrlToLibraryResponseDTO, 44 + ValidationError | AuthenticationError | AppError.UnexpectedError 45 + > 42 46 > { 43 47 constructor( 44 48 private cardRepository: ICardRepository, ··· 55 59 ): Promise< 56 60 Result< 57 61 AddUrlToLibraryResponseDTO, 58 - ValidationError | AppError.UnexpectedError 62 + ValidationError | AuthenticationError | AppError.UnexpectedError 59 63 > 60 64 > { 61 65 try { ··· 129 133 const addUrlCardToLibraryResult = 130 134 await this.cardLibraryService.addCardToLibrary(urlCard, curatorId); 131 135 if (addUrlCardToLibraryResult.isErr()) { 136 + // Propagate authentication errors 137 + if (addUrlCardToLibraryResult.error instanceof AuthenticationError) { 138 + return err(addUrlCardToLibraryResult.error); 139 + } 132 140 if ( 133 141 addUrlCardToLibraryResult.error instanceof AppError.UnexpectedError 134 142 ) { ··· 210 218 const addNoteCardToLibraryResult = 211 219 await this.cardLibraryService.addCardToLibrary(noteCard, curatorId); 212 220 if (addNoteCardToLibraryResult.isErr()) { 221 + // Propagate authentication errors 222 + if ( 223 + addNoteCardToLibraryResult.error instanceof AuthenticationError 224 + ) { 225 + return err(addNoteCardToLibraryResult.error); 226 + } 213 227 if ( 214 228 addNoteCardToLibraryResult.error instanceof 215 229 AppError.UnexpectedError ··· 254 268 curatorId, 255 269 ); 256 270 if (addToCollectionsResult.isErr()) { 271 + // Propagate authentication errors 272 + if (addToCollectionsResult.error instanceof AuthenticationError) { 273 + return err(addToCollectionsResult.error); 274 + } 257 275 if ( 258 276 addToCollectionsResult.error instanceof AppError.UnexpectedError 259 277 ) {
+7 -2
src/modules/cards/application/useCases/commands/CreateCollectionUseCase.ts
··· 6 6 import { Collection, CollectionAccessType } from '../../../domain/Collection'; 7 7 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 8 8 import { ICollectionPublisher } from '../../ports/ICollectionPublisher'; 9 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 9 10 10 11 export interface CreateCollectionDTO { 11 12 name: string; ··· 29 30 CreateCollectionDTO, 30 31 Result< 31 32 CreateCollectionResponseDTO, 32 - ValidationError | AppError.UnexpectedError 33 + ValidationError | AuthenticationError | AppError.UnexpectedError 33 34 > 34 35 > 35 36 { ··· 43 44 ): Promise< 44 45 Result< 45 46 CreateCollectionResponseDTO, 46 - ValidationError | AppError.UnexpectedError 47 + ValidationError | AuthenticationError | AppError.UnexpectedError 47 48 > 48 49 > { 49 50 try { ··· 84 85 // Publish collection 85 86 const publishResult = await this.collectionPublisher.publish(collection); 86 87 if (publishResult.isErr()) { 88 + // Propagate authentication errors 89 + if (publishResult.error instanceof AuthenticationError) { 90 + return err(publishResult.error); 91 + } 87 92 return err( 88 93 new ValidationError( 89 94 `Failed to publish collection: ${publishResult.error.message}`,
+7 -2
src/modules/cards/application/useCases/commands/DeleteCollectionUseCase.ts
··· 6 6 import { CollectionId } from '../../../domain/value-objects/CollectionId'; 7 7 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 8 8 import { ICollectionPublisher } from '../../ports/ICollectionPublisher'; 9 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 9 10 10 11 export interface DeleteCollectionDTO { 11 12 collectionId: string; ··· 28 29 DeleteCollectionDTO, 29 30 Result< 30 31 DeleteCollectionResponseDTO, 31 - ValidationError | AppError.UnexpectedError 32 + ValidationError | AuthenticationError | AppError.UnexpectedError 32 33 > 33 34 > 34 35 { ··· 42 43 ): Promise< 43 44 Result< 44 45 DeleteCollectionResponseDTO, 45 - ValidationError | AppError.UnexpectedError 46 + ValidationError | AuthenticationError | AppError.UnexpectedError 46 47 > 47 48 > { 48 49 try { ··· 99 100 collection.publishedRecordId, 100 101 ); 101 102 if (unpublishResult.isErr()) { 103 + // Propagate authentication errors 104 + if (unpublishResult.error instanceof AuthenticationError) { 105 + return err(unpublishResult.error); 106 + } 102 107 return err( 103 108 new ValidationError( 104 109 `Failed to unpublish collection: ${unpublishResult.error.message}`,
+7 -2
src/modules/cards/application/useCases/commands/RemoveCardFromCollectionUseCase.ts
··· 7 7 import { CollectionId } from '../../../domain/value-objects/CollectionId'; 8 8 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 9 9 import { CardCollectionService } from '../../../domain/services/CardCollectionService'; 10 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 10 11 11 12 export interface RemoveCardFromCollectionDTO { 12 13 cardId: string; ··· 30 31 RemoveCardFromCollectionDTO, 31 32 Result< 32 33 RemoveCardFromCollectionResponseDTO, 33 - ValidationError | AppError.UnexpectedError 34 + ValidationError | AuthenticationError | AppError.UnexpectedError 34 35 > 35 36 > 36 37 { ··· 44 45 ): Promise< 45 46 Result< 46 47 RemoveCardFromCollectionResponseDTO, 47 - ValidationError | AppError.UnexpectedError 48 + ValidationError | AuthenticationError | AppError.UnexpectedError 48 49 > 49 50 > { 50 51 try { ··· 102 103 curatorId, 103 104 ); 104 105 if (removeFromCollectionsResult.isErr()) { 106 + // Propagate authentication errors 107 + if (removeFromCollectionsResult.error instanceof AuthenticationError) { 108 + return err(removeFromCollectionsResult.error); 109 + } 105 110 if ( 106 111 removeFromCollectionsResult.error instanceof AppError.UnexpectedError 107 112 ) {
+7 -2
src/modules/cards/application/useCases/commands/RemoveCardFromLibraryUseCase.ts
··· 6 6 import { CardId } from '../../../domain/value-objects/CardId'; 7 7 import { CuratorId } from '../../../domain/value-objects/CuratorId'; 8 8 import { CardLibraryService } from '../../../domain/services/CardLibraryService'; 9 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 9 10 10 11 export interface RemoveCardFromLibraryDTO { 11 12 cardId: string; ··· 28 29 RemoveCardFromLibraryDTO, 29 30 Result< 30 31 RemoveCardFromLibraryResponseDTO, 31 - ValidationError | AppError.UnexpectedError 32 + ValidationError | AuthenticationError | AppError.UnexpectedError 32 33 > 33 34 > 34 35 { ··· 42 43 ): Promise< 43 44 Result< 44 45 RemoveCardFromLibraryResponseDTO, 45 - ValidationError | AppError.UnexpectedError 46 + ValidationError | AuthenticationError | AppError.UnexpectedError 46 47 > 47 48 > { 48 49 try { ··· 81 82 const removeFromLibraryResult = 82 83 await this.cardLibraryService.removeCardFromLibrary(card, curatorId); 83 84 if (removeFromLibraryResult.isErr()) { 85 + // Propagate authentication errors 86 + if (removeFromLibraryResult.error instanceof AuthenticationError) { 87 + return err(removeFromLibraryResult.error); 88 + } 84 89 if (removeFromLibraryResult.error instanceof AppError.UnexpectedError) { 85 90 return err(removeFromLibraryResult.error); 86 91 }
+7 -2
src/modules/cards/application/useCases/commands/UpdateCollectionUseCase.ts
··· 8 8 import { CollectionName } from '../../../domain/value-objects/CollectionName'; 9 9 import { CollectionDescription } from '../../../domain/value-objects/CollectionDescription'; 10 10 import { ICollectionPublisher } from '../../ports/ICollectionPublisher'; 11 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 11 12 12 13 export interface UpdateCollectionDTO { 13 14 collectionId: string; ··· 32 33 UpdateCollectionDTO, 33 34 Result< 34 35 UpdateCollectionResponseDTO, 35 - ValidationError | AppError.UnexpectedError 36 + ValidationError | AuthenticationError | AppError.UnexpectedError 36 37 > 37 38 > 38 39 { ··· 46 47 ): Promise< 47 48 Result< 48 49 UpdateCollectionResponseDTO, 49 - ValidationError | AppError.UnexpectedError 50 + ValidationError | AuthenticationError | AppError.UnexpectedError 50 51 > 51 52 > { 52 53 try { ··· 117 118 const republishResult = 118 119 await this.collectionPublisher.publish(collection); 119 120 if (republishResult.isErr()) { 121 + // Propagate authentication errors 122 + if (republishResult.error instanceof AuthenticationError) { 123 + return err(republishResult.error); 124 + } 120 125 return err( 121 126 new ValidationError( 122 127 `Failed to republish collection: ${republishResult.error.message}`,
+7 -2
src/modules/cards/application/useCases/commands/UpdateNoteCardUseCase.ts
··· 8 8 import { CardTypeEnum } from '../../../domain/value-objects/CardType'; 9 9 import { CardContent } from '../../../domain/value-objects/CardContent'; 10 10 import { ICardPublisher } from '../../ports/ICardPublisher'; 11 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 11 12 12 13 export interface UpdateNoteCardDTO { 13 14 cardId: string; ··· 31 32 UpdateNoteCardDTO, 32 33 Result< 33 34 UpdateNoteCardResponseDTO, 34 - ValidationError | AppError.UnexpectedError 35 + ValidationError | AuthenticationError | AppError.UnexpectedError 35 36 > 36 37 > 37 38 { ··· 45 46 ): Promise< 46 47 Result< 47 48 UpdateNoteCardResponseDTO, 48 - ValidationError | AppError.UnexpectedError 49 + ValidationError | AuthenticationError | AppError.UnexpectedError 49 50 > 50 51 > { 51 52 try { ··· 128 129 card.publishedRecordId, 129 130 ); 130 131 if (publishResult.isErr()) { 132 + // Propagate authentication errors 133 + if (publishResult.error instanceof AuthenticationError) { 134 + return err(publishResult.error); 135 + } 131 136 return err(AppError.UnexpectedError.create(publishResult.error)); 132 137 } 133 138
+19 -2
src/modules/cards/application/useCases/commands/UpdateUrlCardAssociationsUseCase.ts
··· 14 14 import { CardContent } from '../../../domain/value-objects/CardContent'; 15 15 import { CardFactory } from '../../../domain/CardFactory'; 16 16 import { CardLibraryService } from '../../../domain/services/CardLibraryService'; 17 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 17 18 18 19 export interface UpdateUrlCardAssociationsDTO { 19 20 cardId: string; ··· 40 41 UpdateUrlCardAssociationsDTO, 41 42 Result< 42 43 UpdateUrlCardAssociationsResponseDTO, 43 - ValidationError | AppError.UnexpectedError 44 + ValidationError | AuthenticationError | AppError.UnexpectedError 44 45 > 45 46 > { 46 47 constructor( ··· 57 58 ): Promise< 58 59 Result< 59 60 UpdateUrlCardAssociationsResponseDTO, 60 - ValidationError | AppError.UnexpectedError 61 + ValidationError | AuthenticationError | AppError.UnexpectedError 61 62 > 62 63 > { 63 64 try { ··· 190 191 const addNoteCardToLibraryResult = 191 192 await this.cardLibraryService.addCardToLibrary(noteCard, curatorId); 192 193 if (addNoteCardToLibraryResult.isErr()) { 194 + // Propagate authentication errors 195 + if ( 196 + addNoteCardToLibraryResult.error instanceof AuthenticationError 197 + ) { 198 + return err(addNoteCardToLibraryResult.error); 199 + } 193 200 if ( 194 201 addNoteCardToLibraryResult.error instanceof 195 202 AppError.UnexpectedError ··· 232 239 curatorId, 233 240 ); 234 241 if (addToCollectionsResult.isErr()) { 242 + // Propagate authentication errors 243 + if (addToCollectionsResult.error instanceof AuthenticationError) { 244 + return err(addToCollectionsResult.error); 245 + } 235 246 if ( 236 247 addToCollectionsResult.error instanceof AppError.UnexpectedError 237 248 ) { ··· 282 293 curatorId, 283 294 ); 284 295 if (removeFromCollectionsResult.isErr()) { 296 + // Propagate authentication errors 297 + if ( 298 + removeFromCollectionsResult.error instanceof AuthenticationError 299 + ) { 300 + return err(removeFromCollectionsResult.error); 301 + } 285 302 if ( 286 303 removeFromCollectionsResult.error instanceof 287 304 AppError.UnexpectedError
+14
src/modules/cards/application/useCases/queries/GetProfileUseCase.ts
··· 6 6 7 7 export interface GetMyProfileQuery { 8 8 userId: string; 9 + callerDid?: string; 9 10 } 10 11 11 12 export interface GetMyProfileResult { ··· 54 55 ), 55 56 ); 56 57 } 58 + let callerDid = undefined; 59 + if (query.callerDid) { 60 + const callerDidResult = DIDOrHandle.create(query.callerDid); 61 + if (callerDidResult.isErr()) { 62 + return err( 63 + new ValidationError( 64 + `Invalid caller DID: ${callerDidResult.error.message}`, 65 + ), 66 + ); 67 + } 68 + callerDid = callerDidResult.value.value; 69 + } 57 70 58 71 try { 59 72 // Fetch user profile using the resolved DID 60 73 const profileResult = await this.profileService.getProfile( 61 74 didResult.value.value, 75 + callerDid, 62 76 ); 63 77 64 78 if (profileResult.isErr()) {
+17 -2
src/modules/cards/application/useCases/queries/GetUrlStatusForMyLibraryUseCase.ts
··· 12 12 import { URL } from '../../../domain/value-objects/URL'; 13 13 import { CollectionId } from '../../../domain/value-objects/CollectionId'; 14 14 import { CollectionDTO, UrlCard } from '@semble/types'; 15 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 15 16 16 17 export interface GetUrlStatusForMyLibraryQuery { 17 18 url: string; ··· 33 34 GetUrlStatusForMyLibraryQuery, 34 35 Result< 35 36 GetUrlStatusForMyLibraryResult, 36 - ValidationError | AppError.UnexpectedError 37 + ValidationError | AuthenticationError | AppError.UnexpectedError 37 38 > 38 39 > { 39 40 constructor( ··· 52 53 ): Promise< 53 54 Result< 54 55 GetUrlStatusForMyLibraryResult, 55 - ValidationError | AppError.UnexpectedError 56 + ValidationError | AuthenticationError | AppError.UnexpectedError 56 57 > 57 58 > { 58 59 try { ··· 101 102 ); 102 103 103 104 if (authorProfileResult.isErr()) { 105 + // Propagate authentication errors 106 + if (authorProfileResult.error instanceof AuthenticationError) { 107 + return err(authorProfileResult.error); 108 + } 104 109 return err( 105 110 AppError.UnexpectedError.create(authorProfileResult.error), 106 111 ); ··· 161 166 fullCollection.authorId.value, 162 167 ); 163 168 if (authorProfileResult.isErr()) { 169 + // Propagate authentication errors 170 + if ( 171 + authorProfileResult.error instanceof AuthenticationError 172 + ) { 173 + throw authorProfileResult.error; 174 + } 164 175 throw new Error( 165 176 `Failed to fetch author profile: ${authorProfileResult.error.message}`, 166 177 ); ··· 186 197 }), 187 198 ); 188 199 } catch (error) { 200 + // Propagate authentication errors 201 + if (error instanceof AuthenticationError) { 202 + return err(error); 203 + } 189 204 return err(AppError.UnexpectedError.create(error)); 190 205 } 191 206 }
+24 -4
src/modules/cards/domain/services/CardCollectionService.ts
··· 7 7 import { ICollectionPublisher } from '../../application/ports/ICollectionPublisher'; 8 8 import { AppError } from '../../../../shared/core/AppError'; 9 9 import { DomainService } from '../../../../shared/domain/DomainService'; 10 + import { AuthenticationError } from '../../../../shared/core/AuthenticationError'; 10 11 11 12 export class CardCollectionValidationError extends Error { 12 13 constructor(message: string) { ··· 26 27 collectionId: CollectionId, 27 28 curatorId: CuratorId, 28 29 ): Promise< 29 - Result<Collection, CardCollectionValidationError | AppError.UnexpectedError> 30 + Result< 31 + Collection, 32 + | CardCollectionValidationError 33 + | AuthenticationError 34 + | AppError.UnexpectedError 35 + > 30 36 > { 31 37 try { 32 38 // Find the collection ··· 63 69 curatorId, 64 70 ); 65 71 if (publishLinkResult.isErr()) { 72 + // Propagate authentication errors 73 + if (publishLinkResult.error instanceof AuthenticationError) { 74 + return err(publishLinkResult.error); 75 + } 66 76 return err( 67 77 new CardCollectionValidationError( 68 78 `Failed to publish collection link: ${publishLinkResult.error.message}`, ··· 93 103 ): Promise< 94 104 Result< 95 105 Collection[], 96 - CardCollectionValidationError | AppError.UnexpectedError 106 + | CardCollectionValidationError 107 + | AuthenticationError 108 + | AppError.UnexpectedError 97 109 > 98 110 > { 99 111 const updatedCollections: Collection[] = []; ··· 119 131 ): Promise< 120 132 Result< 121 133 Collection | null, 122 - CardCollectionValidationError | AppError.UnexpectedError 134 + | CardCollectionValidationError 135 + | AuthenticationError 136 + | AppError.UnexpectedError 123 137 > 124 138 > { 125 139 try { ··· 155 169 cardLink.publishedRecordId, 156 170 ); 157 171 if (unpublishLinkResult.isErr()) { 172 + // Propagate authentication errors 173 + if (unpublishLinkResult.error instanceof AuthenticationError) { 174 + return err(unpublishLinkResult.error); 175 + } 158 176 return err( 159 177 new CardCollectionValidationError( 160 178 `Failed to unpublish collection link: ${unpublishLinkResult.error.message}`, ··· 193 211 ): Promise< 194 212 Result< 195 213 Collection[], 196 - CardCollectionValidationError | AppError.UnexpectedError 214 + | CardCollectionValidationError 215 + | AuthenticationError 216 + | AppError.UnexpectedError 197 217 > 198 218 > { 199 219 const updatedCollections: Collection[] = [];
+21 -2
src/modules/cards/domain/services/CardLibraryService.ts
··· 8 8 import { DomainService } from '../../../../shared/domain/DomainService'; 9 9 import { CardCollectionService } from './CardCollectionService'; 10 10 import { PublishedRecordId } from '../value-objects/PublishedRecordId'; 11 + import { AuthenticationError } from '../../../../shared/core/AuthenticationError'; 11 12 12 13 export class CardLibraryValidationError extends Error { 13 14 constructor(message: string) { ··· 28 29 card: Card, 29 30 curatorId: CuratorId, 30 31 ): Promise< 31 - Result<Card, CardLibraryValidationError | AppError.UnexpectedError> 32 + Result< 33 + Card, 34 + | CardLibraryValidationError 35 + | AuthenticationError 36 + | AppError.UnexpectedError 37 + > 32 38 > { 33 39 try { 34 40 // Check if card is already in curator's library ··· 76 82 parentCardPublishedRecordId, 77 83 ); 78 84 if (publishResult.isErr()) { 85 + // Propagate authentication errors 86 + if (publishResult.error instanceof AuthenticationError) { 87 + return err(publishResult.error); 88 + } 79 89 return err( 80 90 new CardLibraryValidationError( 81 91 `Failed to publish card to library: ${publishResult.error.message}`, ··· 112 122 card: Card, 113 123 curatorId: CuratorId, 114 124 ): Promise< 115 - Result<Card, CardLibraryValidationError | AppError.UnexpectedError> 125 + Result< 126 + Card, 127 + | CardLibraryValidationError 128 + | AuthenticationError 129 + | AppError.UnexpectedError 130 + > 116 131 > { 117 132 try { 118 133 // Check if card is in curator's library ··· 190 205 libraryInfo.curatorId, 191 206 ); 192 207 if (unpublishResult.isErr()) { 208 + // Propagate authentication errors 209 + if (unpublishResult.error instanceof AuthenticationError) { 210 + return err(unpublishResult.error); 211 + } 193 212 return err( 194 213 new CardLibraryValidationError( 195 214 `Failed to unpublish card from library: ${unpublishResult.error.message}`,
+6 -1
src/modules/cards/infrastructure/http/controllers/AddCardToCollectionController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { AddCardToCollectionUseCase } from '../../../application/useCases/commands/AddCardToCollectionUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class AddCardToCollectionController extends Controller { 7 8 constructor(private addCardToCollectionUseCase: AddCardToCollectionUseCase) { ··· 32 33 }); 33 34 34 35 if (result.isErr()) { 36 + // Check if the error is an authentication error 37 + if (result.error instanceof AuthenticationError) { 38 + return this.unauthorized(res, result.error.message); 39 + } 35 40 return this.fail(res, result.error); 36 41 } 37 42 38 43 return this.ok(res, result.value); 39 44 } catch (error: any) { 40 - return this.fail(res, error); 45 + return this.handleError(res, error); 41 46 } 42 47 } 43 48 }
+6 -1
src/modules/cards/infrastructure/http/controllers/AddCardToLibraryController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { AddCardToLibraryUseCase } from '../../../application/useCases/commands/AddCardToLibraryUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class AddCardToLibraryController extends Controller { 7 8 constructor(private addCardToLibraryUseCase: AddCardToLibraryUseCase) { ··· 33 34 }); 34 35 35 36 if (result.isErr()) { 37 + // Check if the error is an authentication error 38 + if (result.error instanceof AuthenticationError) { 39 + return this.unauthorized(res, result.error.message); 40 + } 36 41 return this.fail(res, result.error); 37 42 } 38 43 39 44 return this.ok(res, result.value); 40 45 } catch (error: any) { 41 - return this.fail(res, error); 46 + return this.handleError(res, error); 42 47 } 43 48 } 44 49 }
+6 -1
src/modules/cards/infrastructure/http/controllers/AddUrlToLibraryController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { AddUrlToLibraryUseCase } from '../../../application/useCases/commands/AddUrlToLibraryUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class AddUrlToLibraryController extends Controller { 7 8 constructor(private addUrlToLibraryUseCase: AddUrlToLibraryUseCase) { ··· 29 30 }); 30 31 31 32 if (result.isErr()) { 33 + // Check if the error is an authentication error 34 + if (result.error instanceof AuthenticationError) { 35 + return this.unauthorized(res, result.error.message); 36 + } 32 37 return this.fail(res, result.error); 33 38 } 34 39 35 40 return this.ok(res, result.value); 36 41 } catch (error: any) { 37 - return this.fail(res, error); 42 + return this.handleError(res, error); 38 43 } 39 44 } 40 45 }
+6 -1
src/modules/cards/infrastructure/http/controllers/CreateCollectionController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { CreateCollectionUseCase } from '../../../application/useCases/commands/CreateCollectionUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class CreateCollectionController extends Controller { 7 8 constructor(private createCollectionUseCase: CreateCollectionUseCase) { ··· 28 29 }); 29 30 30 31 if (result.isErr()) { 32 + // Check if the error is an authentication error 33 + if (result.error instanceof AuthenticationError) { 34 + return this.unauthorized(res, result.error.message); 35 + } 31 36 return this.fail(res, result.error); 32 37 } 33 38 34 39 return this.ok(res, result.value); 35 40 } catch (error: any) { 36 - return this.fail(res, error); 41 + return this.handleError(res, error); 37 42 } 38 43 } 39 44 }
+6 -1
src/modules/cards/infrastructure/http/controllers/DeleteCollectionController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { DeleteCollectionUseCase } from '../../../application/useCases/commands/DeleteCollectionUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class DeleteCollectionController extends Controller { 7 8 constructor(private deleteCollectionUseCase: DeleteCollectionUseCase) { ··· 27 28 }); 28 29 29 30 if (result.isErr()) { 31 + // Check if the error is an authentication error 32 + if (result.error instanceof AuthenticationError) { 33 + return this.unauthorized(res, result.error.message); 34 + } 30 35 return this.fail(res, result.error); 31 36 } 32 37 33 38 return this.ok(res, result.value); 34 39 } catch (error: any) { 35 - return this.fail(res, error); 40 + return this.handleError(res, error); 36 41 } 37 42 } 38 43 }
+4 -1
src/modules/cards/infrastructure/http/controllers/GetMyProfileController.ts
··· 16 16 return this.unauthorized(res); 17 17 } 18 18 19 - const result = await this.getProfileUseCase.execute({ userId }); 19 + const result = await this.getProfileUseCase.execute({ 20 + userId, 21 + callerDid: req.did, 22 + }); 20 23 21 24 if (result.isErr()) { 22 25 return this.fail(res, result.error);
+6 -1
src/modules/cards/infrastructure/http/controllers/GetUrlStatusForMyLibraryController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { GetUrlStatusForMyLibraryUseCase } from '../../../application/useCases/queries/GetUrlStatusForMyLibraryUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class GetUrlStatusForMyLibraryController extends Controller { 7 8 constructor( ··· 29 30 }); 30 31 31 32 if (result.isErr()) { 33 + // Check if the error is an authentication error 34 + if (result.error instanceof AuthenticationError) { 35 + return this.unauthorized(res, result.error.message); 36 + } 32 37 return this.fail(res, result.error); 33 38 } 34 39 35 40 return this.ok(res, result.value); 36 41 } catch (error: any) { 37 - return this.fail(res, error); 42 + return this.handleError(res, error); 38 43 } 39 44 } 40 45 }
+6 -1
src/modules/cards/infrastructure/http/controllers/RemoveCardFromCollectionController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { RemoveCardFromCollectionUseCase } from '../../../application/useCases/commands/RemoveCardFromCollectionUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class RemoveCardFromCollectionController extends Controller { 7 8 constructor( ··· 46 47 }); 47 48 48 49 if (result.isErr()) { 50 + // Check if the error is an authentication error 51 + if (result.error instanceof AuthenticationError) { 52 + return this.unauthorized(res, result.error.message); 53 + } 49 54 return this.fail(res, result.error); 50 55 } 51 56 52 57 return this.ok(res, result.value); 53 58 } catch (error: any) { 54 - return this.fail(res, error); 59 + return this.handleError(res, error); 55 60 } 56 61 } 57 62 }
+6 -1
src/modules/cards/infrastructure/http/controllers/RemoveCardFromLibraryController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { RemoveCardFromLibraryUseCase } from '../../../application/useCases/commands/RemoveCardFromLibraryUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class RemoveCardFromLibraryController extends Controller { 7 8 constructor( ··· 29 30 }); 30 31 31 32 if (result.isErr()) { 33 + // Check if the error is an authentication error 34 + if (result.error instanceof AuthenticationError) { 35 + return this.unauthorized(res, result.error.message); 36 + } 32 37 return this.fail(res, result.error); 33 38 } 34 39 35 40 return this.ok(res, result.value); 36 41 } catch (error: any) { 37 - return this.fail(res, error); 42 + return this.handleError(res, error); 38 43 } 39 44 } 40 45 }
+6 -1
src/modules/cards/infrastructure/http/controllers/UpdateCollectionController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { UpdateCollectionUseCase } from '../../../application/useCases/commands/UpdateCollectionUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class UpdateCollectionController extends Controller { 7 8 constructor(private updateCollectionUseCase: UpdateCollectionUseCase) { ··· 34 35 }); 35 36 36 37 if (result.isErr()) { 38 + // Check if the error is an authentication error 39 + if (result.error instanceof AuthenticationError) { 40 + return this.unauthorized(res, result.error.message); 41 + } 37 42 return this.fail(res, result.error); 38 43 } 39 44 40 45 return this.ok(res, result.value); 41 46 } catch (error: any) { 42 - return this.fail(res, error); 47 + return this.handleError(res, error); 43 48 } 44 49 } 45 50 }
+6 -1
src/modules/cards/infrastructure/http/controllers/UpdateNoteCardController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { UpdateNoteCardUseCase } from '../../../application/useCases/commands/UpdateNoteCardUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class UpdateNoteCardController extends Controller { 7 8 constructor(private updateNoteCardUseCase: UpdateNoteCardUseCase) { ··· 33 34 }); 34 35 35 36 if (result.isErr()) { 37 + // Check if the error is an authentication error 38 + if (result.error instanceof AuthenticationError) { 39 + return this.unauthorized(res, result.error.message); 40 + } 36 41 return this.fail(res, result.error); 37 42 } 38 43 39 44 return this.ok(res, result.value); 40 45 } catch (error: any) { 41 - return this.fail(res, error); 46 + return this.handleError(res, error); 42 47 } 43 48 } 44 49 }
+6 -1
src/modules/cards/infrastructure/http/controllers/UpdateUrlCardAssociationsController.ts
··· 2 2 import { Response } from 'express'; 3 3 import { UpdateUrlCardAssociationsUseCase } from '../../../application/useCases/commands/UpdateUrlCardAssociationsUseCase'; 4 4 import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 5 + import { AuthenticationError } from '../../../../../shared/core/AuthenticationError'; 5 6 6 7 export class UpdateUrlCardAssociationsController extends Controller { 7 8 constructor( ··· 33 34 }); 34 35 35 36 if (result.isErr()) { 37 + // Check if the error is an authentication error 38 + if (result.error instanceof AuthenticationError) { 39 + return this.unauthorized(res, result.error.message); 40 + } 36 41 return this.fail(res, result.error); 37 42 } 38 43 39 44 return this.ok(res, result.value); 40 45 } catch (error: any) { 41 - return this.fail(res, error); 46 + return this.handleError(res, error); 42 47 } 43 48 } 44 49 }
+28
src/modules/user/application/use-cases/RefreshAccessTokenUseCase.ts
··· 5 5 import { TokenPair } from '@semble/types'; 6 6 import { RefreshAccessTokenErrors } from './errors/RefreshAccessTokenErrors'; 7 7 8 + const ENABLE_REFRESH_LOGGING = true; 9 + 8 10 export interface RefreshAccessTokenDTO { 9 11 refreshToken: string; 10 12 } ··· 23 25 request: RefreshAccessTokenDTO, 24 26 ): Promise<RefreshAccessTokenResponse> { 25 27 try { 28 + if (ENABLE_REFRESH_LOGGING) { 29 + const tokenPreview = '...' + request.refreshToken.slice(-8); 30 + console.log( 31 + `[RefreshAccessTokenUseCase] Attempting token refresh with token: ${tokenPreview}`, 32 + ); 33 + } 34 + 26 35 const tokenResult = await this.tokenService.refreshToken( 27 36 request.refreshToken, 28 37 ); 29 38 30 39 if (tokenResult.isErr()) { 40 + if (ENABLE_REFRESH_LOGGING) { 41 + console.log( 42 + `[RefreshAccessTokenUseCase] Token refresh failed: ${tokenResult.error.message}`, 43 + ); 44 + } 31 45 return err(new AppError.UnexpectedError(tokenResult.error)); 32 46 } 33 47 34 48 if (!tokenResult.value) { 49 + if (ENABLE_REFRESH_LOGGING) { 50 + console.log( 51 + `[RefreshAccessTokenUseCase] Token refresh returned null - invalid refresh token`, 52 + ); 53 + } 35 54 return err(new RefreshAccessTokenErrors.InvalidRefreshTokenError()); 36 55 } 37 56 57 + if (ENABLE_REFRESH_LOGGING) { 58 + console.log(`[RefreshAccessTokenUseCase] Token refresh successful`); 59 + } 60 + 38 61 return ok(tokenResult.value); 39 62 } catch (error: any) { 63 + if (ENABLE_REFRESH_LOGGING) { 64 + console.log( 65 + `[RefreshAccessTokenUseCase] Token refresh error: ${error.message}`, 66 + ); 67 + } 40 68 return err(new AppError.UnexpectedError(error)); 41 69 } 42 70 }
+4
src/modules/user/infrastructure/http/controllers/RefreshAccessTokenController.ts
··· 26 26 }); 27 27 28 28 if (result.isErr()) { 29 + // Clear cookies when refresh fails 30 + this.cookieService.clearTokens(res); 29 31 return this.fail(res, result.error); 30 32 } 31 33 ··· 38 40 // Also return tokens in response body for backward compatibility 39 41 return this.ok(res, result.value); 40 42 } catch (error: any) { 43 + // Clear cookies on unexpected errors too 44 + this.cookieService.clearTokens(res); 41 45 return this.fail(res, error); 42 46 } 43 47 }
+56 -2
src/modules/user/infrastructure/repositories/DrizzleTokenRepository.ts
··· 12 12 13 13 async saveRefreshToken(token: RefreshToken): Promise<Result<void>> { 14 14 try { 15 + const tokenPreview = '...' + token.refreshToken.slice(-8); 16 + console.log( 17 + `[DrizzleTokenRepository] Saving new refresh token: ${tokenPreview} for user: ${token.userDid}, expiresAt: ${token.expiresAt.toISOString()}`, 18 + ); 19 + 15 20 await this.db.insert(authRefreshTokens).values({ 16 21 tokenId: token.tokenId, 17 22 userDid: token.userDid, ··· 21 26 revoked: token.revoked, 22 27 }); 23 28 29 + console.log( 30 + `[DrizzleTokenRepository] Successfully saved refresh token: ${tokenPreview}`, 31 + ); 24 32 return ok(undefined); 25 33 } catch (error: any) { 34 + console.log( 35 + `[DrizzleTokenRepository] Failed to save refresh token: ${error.message}`, 36 + ); 26 37 return err(error); 27 38 } 28 39 } ··· 31 42 refreshToken: string, 32 43 ): Promise<Result<RefreshToken | null>> { 33 44 try { 45 + const tokenPreview = '...' + refreshToken.slice(-8); 46 + console.log( 47 + `[DrizzleTokenRepository] Searching for token: ${tokenPreview}`, 48 + ); 49 + 34 50 const result = await this.db 35 51 .select() 36 52 .from(authRefreshTokens) ··· 42 58 ) 43 59 .limit(1); 44 60 61 + console.log( 62 + `[DrizzleTokenRepository] Query returned ${result.length} results`, 63 + ); 64 + 45 65 if (result.length === 0) { 66 + // Check if token exists but is revoked 67 + const revokedResult = await this.db 68 + .select() 69 + .from(authRefreshTokens) 70 + .where(eq(authRefreshTokens.refreshToken, refreshToken)) 71 + .limit(1); 72 + 73 + if (revokedResult.length > 0) { 74 + console.log( 75 + `[DrizzleTokenRepository] Token exists but is revoked: ${tokenPreview}`, 76 + ); 77 + } else { 78 + console.log( 79 + `[DrizzleTokenRepository] Token does not exist in database: ${tokenPreview}`, 80 + ); 81 + } 82 + 46 83 return ok(null); 47 84 } 48 85 49 - return ok({ ...result[0]!, revoked: result[0]!.revoked === true }); 86 + const token = result[0]!; 87 + console.log( 88 + `[DrizzleTokenRepository] Token found - userDid: ${token.userDid}, revoked: ${token.revoked}, expiresAt: ${token.expiresAt.toISOString()}`, 89 + ); 90 + 91 + return ok({ ...token, revoked: token.revoked === true }); 50 92 } catch (error: any) { 93 + console.log(`[DrizzleTokenRepository] Database error: ${error.message}`); 51 94 return err(error); 52 95 } 53 96 } 54 97 55 98 async revokeRefreshToken(refreshToken: string): Promise<Result<void>> { 56 99 try { 57 - await this.db 100 + const tokenPreview = '...' + refreshToken.slice(-8); 101 + console.log( 102 + `[DrizzleTokenRepository] Revoking refresh token: ${tokenPreview}`, 103 + ); 104 + 105 + const result = await this.db 58 106 .update(authRefreshTokens) 59 107 .set({ revoked: true }) 60 108 .where(eq(authRefreshTokens.refreshToken, refreshToken)); 61 109 110 + console.log( 111 + `[DrizzleTokenRepository] Successfully revoked refresh token: ${tokenPreview}`, 112 + ); 62 113 return ok(undefined); 63 114 } catch (error: any) { 115 + console.log( 116 + `[DrizzleTokenRepository] Failed to revoke refresh token: ${error.message}`, 117 + ); 64 118 return err(error); 65 119 } 66 120 }
+44 -2
src/modules/user/infrastructure/services/JwtTokenService.ts
··· 65 65 66 66 async refreshToken(refreshToken: string): Promise<Result<TokenPair | null>> { 67 67 try { 68 + const tokenPreview = '...' + refreshToken.slice(-8); 69 + console.log( 70 + `[JwtTokenService] Starting refresh for token: ${tokenPreview}`, 71 + ); 72 + 68 73 // Find the refresh token 69 74 const findResult = 70 75 await this.tokenRepository.findRefreshToken(refreshToken); 71 76 72 77 if (findResult.isErr()) { 78 + console.log( 79 + `[JwtTokenService] Database error finding token: ${findResult.error.message}`, 80 + ); 73 81 return err(findResult.error); 74 82 } 75 83 76 84 const tokenData = findResult.unwrap(); 77 85 if (!tokenData) { 86 + console.log( 87 + `[JwtTokenService] Token not found in database: ${tokenPreview}`, 88 + ); 78 89 return ok(null); 79 90 } 80 91 92 + console.log( 93 + `[JwtTokenService] Token found - userDid: ${tokenData.userDid}, issuedAt: ${tokenData.issuedAt.toISOString()}, expiresAt: ${tokenData.expiresAt.toISOString()}, revoked: ${tokenData.revoked}`, 94 + ); 95 + 81 96 // Check if token is expired 82 - if (new Date() > tokenData.expiresAt) { 97 + const now = new Date(); 98 + if (now > tokenData.expiresAt) { 99 + console.log( 100 + `[JwtTokenService] Token expired - now: ${now.toISOString()}, expiresAt: ${tokenData.expiresAt.toISOString()}`, 101 + ); 83 102 await this.revokeToken(refreshToken); 84 103 return ok(null); 85 104 } 86 105 106 + console.log( 107 + `[JwtTokenService] Token is valid, generating new tokens for user: ${tokenData.userDid}`, 108 + ); 109 + 87 110 // Generate new tokens 88 111 const newTokens = await this.generateToken(tokenData.userDid); 89 112 113 + if (newTokens.isErr()) { 114 + console.log( 115 + `[JwtTokenService] Failed to generate new tokens: ${newTokens.error.message}`, 116 + ); 117 + return newTokens; 118 + } 119 + 120 + console.log(`[JwtTokenService] New tokens generated successfully`); 121 + 90 122 // Revoke old token 91 - await this.revokeToken(refreshToken); 123 + const revokeResult = await this.revokeToken(refreshToken); 124 + if (revokeResult.isErr()) { 125 + console.log( 126 + `[JwtTokenService] Warning: Failed to revoke old token: ${revokeResult.error.message}`, 127 + ); 128 + } else { 129 + console.log(`[JwtTokenService] Old token revoked successfully`); 130 + } 92 131 93 132 return newTokens; 94 133 } catch (error: any) { 134 + console.log( 135 + `[JwtTokenService] Unexpected error during refresh: ${error.message}`, 136 + ); 95 137 return err(error); 96 138 } 97 139 }
+5
src/modules/user/infrastructure/services/OAuthClientFactory.ts
··· 7 7 import { InMemoryStateStore } from '../../tests/infrastructure/InMemoryStateStore'; 8 8 import { InMemorySessionStore } from '../../tests/infrastructure/InMemorySessionStore'; 9 9 import { configService } from 'src/shared/infrastructure/config'; 10 + import { LockServiceFactory } from 'src/shared/infrastructure/locking'; 10 11 11 12 export class OAuthClientFactory { 12 13 static getClientMetadata( ··· 42 43 appName: string = 'Semble', 43 44 ): NodeOAuthClient { 44 45 const { clientMetadata } = this.getClientMetadata(baseUrl, appName); 46 + const lockService = LockServiceFactory.create(); 45 47 46 48 return new NodeOAuthClient({ 47 49 clientMetadata, 48 50 stateStore, 49 51 sessionStore, 52 + requestLock: lockService.createRequestLock(), 50 53 }); 51 54 } 52 55 ··· 57 60 const { clientMetadata } = this.getClientMetadata(baseUrl, appName); 58 61 const stateStore = InMemoryStateStore.getInstance(); 59 62 const sessionStore = InMemorySessionStore.getInstance(); 63 + const lockService = LockServiceFactory.create(); 60 64 61 65 return new NodeOAuthClient({ 62 66 clientMetadata, 63 67 stateStore, 64 68 sessionStore, 69 + requestLock: lockService.createRequestLock(), 65 70 }); 66 71 } 67 72 }
+6
src/shared/core/AuthenticationError.ts
··· 1 + export class AuthenticationError extends Error { 2 + constructor(message: string) { 3 + super(message); 4 + this.name = 'AuthenticationError'; 5 + } 6 + }
+72
src/shared/infrastructure/config/EnvironmentConfigService.ts
··· 6 6 7 7 export interface EnvironmentConfig { 8 8 environment: Environment; 9 + runtime: { 10 + useMockPersistence: boolean; 11 + useMockAuth: boolean; 12 + useFakePublishers: boolean; 13 + useMockVectorDb: boolean; 14 + }; 9 15 database: { 10 16 url: string; 11 17 }; ··· 57 63 58 64 this.config = { 59 65 environment, 66 + runtime: { 67 + useMockPersistence: this.determineMockPersistenceFlag(), 68 + useMockAuth: process.env.USE_MOCK_AUTH === 'true', 69 + useFakePublishers: process.env.USE_FAKE_PUBLISHERS === 'true', 70 + useMockVectorDb: process.env.USE_MOCK_VECTOR_DB === 'true', 71 + }, 60 72 database: { 61 73 url: 62 74 process.env.DATABASE_URL || ··· 176 188 177 189 public getWorkersConfig() { 178 190 return this.config.workers; 191 + } 192 + 193 + public getRedisConfig() { 194 + return this.config.workers.redisConfig; 195 + } 196 + 197 + public getRuntimeConfig() { 198 + return this.config.runtime; 199 + } 200 + 201 + public shouldUseMockPersistence(): boolean { 202 + return this.config.runtime.useMockPersistence; 203 + } 204 + 205 + public shouldUseMockRepos(): boolean { 206 + return this.config.runtime.useMockPersistence; 207 + } 208 + 209 + public shouldUseInMemoryEvents(): boolean { 210 + return this.config.runtime.useMockPersistence; 211 + } 212 + 213 + public shouldUseMockAuth(): boolean { 214 + return this.config.runtime.useMockAuth; 215 + } 216 + 217 + public shouldUseFakePublishers(): boolean { 218 + return this.config.runtime.useFakePublishers; 219 + } 220 + 221 + public shouldUseMockVectorDb(): boolean { 222 + return this.config.runtime.useMockVectorDb; 223 + } 224 + 225 + // Convenience methods for common combinations 226 + public isFullyMocked(): boolean { 227 + const r = this.config.runtime; 228 + return r.useMockPersistence && r.useMockAuth && r.useFakePublishers; 229 + } 230 + 231 + public isMockPersistenceEnabled(): boolean { 232 + return this.config.runtime.useMockPersistence; 233 + } 234 + 235 + private determineMockPersistenceFlag(): boolean { 236 + // New unified flag takes precedence 237 + if (process.env.USE_MOCK_PERSISTENCE !== undefined) { 238 + return process.env.USE_MOCK_PERSISTENCE === 'true'; 239 + } 240 + 241 + // Legacy support - if either old flag is false, persistence is disabled 242 + if ( 243 + process.env.USE_MOCK_REPOS === 'true' || 244 + process.env.USE_IN_MEMORY_EVENTS === 'true' 245 + ) { 246 + return true; 247 + } 248 + 249 + // Default to false (use mock persistence) unless explicitly enabled 250 + return false; 179 251 } 180 252 }
+8
src/shared/infrastructure/http/Controller.ts
··· 1 1 import { Response } from 'express'; 2 + import { AuthenticationError } from '../../core/AuthenticationError'; 2 3 3 4 export abstract class Controller { 4 5 protected abstract executeImpl(req: any, res: Response): Promise<any>; ··· 62 63 return res.status(500).json({ 63 64 message: error.toString(), 64 65 }); 66 + } 67 + 68 + protected handleError(res: Response, error: Error): Response { 69 + if (error instanceof AuthenticationError) { 70 + return this.unauthorized(res, error.message); 71 + } 72 + return this.fail(res, error); 65 73 } 66 74 }
+5
src/shared/infrastructure/http/app.ts
··· 7 7 import { createCardsModuleRoutes } from '../../../modules/cards/infrastructure/http/routes'; 8 8 import { createFeedRoutes } from '../../../modules/feeds/infrastructure/http/routes/feedRoutes'; 9 9 import { createSearchRoutes } from '../../../modules/search/infrastructure/http/routes/searchRoutes'; 10 + import { createTestRoutes } from './routes/testRoutes'; 10 11 import { 11 12 EnvironmentConfigService, 12 13 Environment, ··· 124 125 controllers.getSimilarUrlsForUrlController, 125 126 ); 126 127 128 + const testRouter = Router(); 129 + createTestRoutes(testRouter); 130 + 127 131 // Register routes 128 132 app.use('/api/users', userRouter); 129 133 app.use('/atproto', atprotoRouter); 130 134 app.use('/api', cardsRouter); 131 135 app.use('/api/feeds', feedRouter); 132 136 app.use('/api/search', searchRouter); 137 + app.use('/api/test', testRouter); 133 138 134 139 return app; 135 140 };
+2 -2
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 94 94 cookieService, 95 95 ), 96 96 getMyProfileController: new GetMyProfileController( 97 - useCases.getMyProfileUseCase, 97 + useCases.getProfileUseCase, 98 98 ), 99 99 getUserProfileController: new GetUserProfileController( 100 - useCases.getMyProfileUseCase, 100 + useCases.getProfileUseCase, 101 101 ), 102 102 refreshAccessTokenController: new RefreshAccessTokenController( 103 103 useCases.refreshAccessTokenUseCase,
+2 -1
src/shared/infrastructure/http/factories/RepositoryFactory.ts
··· 1 1 import { DatabaseFactory } from '../../database/DatabaseFactory'; 2 2 import { EnvironmentConfigService } from '../../config/EnvironmentConfigService'; 3 + import { RedisFactory } from '../../redis/RedisFactory'; 3 4 import { DrizzleUserRepository } from '../../../../modules/user/infrastructure/repositories/DrizzleUserRepository'; 4 5 import { DrizzleTokenRepository } from '../../../../modules/user/infrastructure/repositories/DrizzleTokenRepository'; 5 6 import { DrizzleCardRepository } from '../../../../modules/cards/infrastructure/repositories/DrizzleCardRepository'; ··· 52 53 53 54 export class RepositoryFactory { 54 55 static create(configService: EnvironmentConfigService): Repositories { 55 - const useMockRepos = process.env.USE_MOCK_REPOS === 'true'; 56 + const useMockRepos = configService.shouldUseMockRepos(); 56 57 57 58 if (useMockRepos) { 58 59 // Use singleton instances to ensure same data across processes
+25 -13
src/shared/infrastructure/http/factories/ServiceFactory.ts
··· 9 9 import { ATProtoAgentService } from '../../../../modules/atproto/infrastructure/services/ATProtoAgentService'; 10 10 import { IFramelyMetadataService } from '../../../../modules/cards/infrastructure/IFramelyMetadataService'; 11 11 import { BlueskyProfileService } from '../../../../modules/atproto/infrastructure/services/BlueskyProfileService'; 12 + import { CachedBlueskyProfileService } from '../../../../modules/atproto/infrastructure/services/CachedBlueskyProfileService'; 12 13 import { ATProtoCollectionPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher'; 13 14 import { ATProtoCardPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoCardPublisher'; 14 15 import { FakeCollectionPublisher } from '../../../../modules/cards/tests/utils/FakeCollectionPublisher'; ··· 47 48 import { RedisFactory } from '../../redis/RedisFactory'; 48 49 import { IEventSubscriber } from 'src/shared/application/events/IEventSubscriber'; 49 50 import { FeedService } from '../../../../modules/feeds/domain/services/FeedService'; 50 - import { CardCollectionSaga } from '../../../../modules/feeds/application/sagas/CardCollectionSaga'; 51 51 import { ATProtoIdentityResolutionService } from '../../../../modules/atproto/infrastructure/services/ATProtoIdentityResolutionService'; 52 52 import { IIdentityResolutionService } from '../../../../modules/atproto/domain/services/IIdentityResolutionService'; 53 53 import { CookieService } from '../services/CookieService'; ··· 116 116 repositories, 117 117 ); 118 118 119 - const useMockAuth = process.env.USE_MOCK_AUTH === 'true'; 119 + const useMockAuth = configService.shouldUseMockAuth(); 120 120 121 121 // App Password Session Service 122 122 const appPasswordSessionService = useMockAuth ··· 135 135 ? new FakeAtProtoOAuthProcessor(sharedServices.tokenService) 136 136 : new AtProtoOAuthProcessor(sharedServices.nodeOauthClient); 137 137 138 - const useFakePublishers = process.env.USE_FAKE_PUBLISHERS === 'true'; 138 + const useFakePublishers = configService.shouldUseFakePublishers(); 139 139 const collections = configService.getAtProtoCollections(); 140 140 141 141 const collectionPublisher = useFakePublishers ··· 169 169 sharedServices.cookieService, 170 170 ); 171 171 172 - const useInMemoryEvents = process.env.USE_IN_MEMORY_EVENTS === 'true'; 172 + const useInMemoryEvents = configService.shouldUseInMemoryEvents(); 173 173 174 174 let eventPublisher: IEventPublisher; 175 175 if (useInMemoryEvents) { ··· 203 203 repositories, 204 204 ); 205 205 206 - const useInMemoryEvents = process.env.USE_IN_MEMORY_EVENTS === 'true'; 206 + const useInMemoryEvents = configService.shouldUseInMemoryEvents(); 207 207 208 208 let eventPublisher: IEventPublisher; 209 209 let redisConnection: Redis | null = null; ··· 249 249 configService: EnvironmentConfigService, 250 250 repositories: Repositories, 251 251 ): SharedServices { 252 - const useMockAuth = process.env.USE_MOCK_AUTH === 'true'; 252 + const useMockAuth = configService.shouldUseMockAuth(); 253 253 254 254 const nodeOauthClient = OAuthClientFactory.createClient( 255 255 repositories.oauthStateStore, ··· 288 288 configService.getIFramelyApiKey(), 289 289 ); 290 290 291 - // Profile Service 292 - const profileService = useMockAuth 293 - ? new FakeBlueskyProfileService() 294 - : new BlueskyProfileService(atProtoAgentService); 291 + // Profile Service with Redis caching 292 + const baseProfileService = new BlueskyProfileService(atProtoAgentService); 293 + 294 + let profileService: IProfileService; 295 + const useMockPersistence = configService.shouldUseMockPersistence(); 296 + 297 + // caching requires persistence 298 + if (useMockPersistence) { 299 + profileService = baseProfileService; 300 + } else { 301 + // Create Redis connection for caching 302 + const redisConfig = configService.getRedisConfig(); 303 + const redis = RedisFactory.createConnection(redisConfig); 304 + profileService = new CachedBlueskyProfileService( 305 + baseProfileService, 306 + redis, 307 + ); 308 + } 295 309 296 310 // Feed Service 297 311 const feedService = new FeedService(repositories.feedRepository); ··· 305 319 const cookieService = new CookieService(configService); 306 320 307 321 // Create vector database and search service (shared by both web app and workers) 308 - const useInMemoryEvents = process.env.USE_IN_MEMORY_EVENTS === 'true'; 309 - const useMockVectorDb = 310 - process.env.USE_MOCK_VECTOR_DB === 'true' || useInMemoryEvents; 322 + const useMockVectorDb = configService.shouldUseMockVectorDb(); 311 323 312 324 const vectorDatabase: IVectorDatabase = useMockVectorDb 313 325 ? InMemoryVectorDatabase.getInstance()
+2 -2
src/shared/infrastructure/http/factories/UseCaseFactory.ts
··· 44 44 logoutUseCase: LogoutUseCase; 45 45 initiateOAuthSignInUseCase: InitiateOAuthSignInUseCase; 46 46 completeOAuthSignInUseCase: CompleteOAuthSignInUseCase; 47 - getMyProfileUseCase: GetProfileUseCase; 47 + getProfileUseCase: GetProfileUseCase; 48 48 refreshAccessTokenUseCase: RefreshAccessTokenUseCase; 49 49 generateExtensionTokensUseCase: GenerateExtensionTokensUseCase; 50 50 // Card use cases ··· 109 109 repositories.userRepository, 110 110 services.userAuthService, 111 111 ), 112 - getMyProfileUseCase: new GetProfileUseCase( 112 + getProfileUseCase: new GetProfileUseCase( 113 113 services.profileService, 114 114 services.identityResolutionService, 115 115 ),
+10
src/shared/infrastructure/http/routes/testRoutes.ts
··· 1 + import { Router, Request, Response } from 'express'; 2 + 3 + export const createTestRoutes = (router: Router) => { 4 + // Simple ping/pong endpoint for performance testing 5 + router.get('/ping', (req: Request, res: Response) => { 6 + res.json({ message: 'pong' }); 7 + }); 8 + 9 + return router; 10 + };
+5
src/shared/infrastructure/locking/ILockService.ts
··· 1 + import { RuntimeLock } from '@atproto/oauth-client-node'; 2 + 3 + export interface ILockService { 4 + createRequestLock(): RuntimeLock; 5 + }
+65
src/shared/infrastructure/locking/InMemoryLockService.ts
··· 1 + import { RuntimeLock } from '@atproto/oauth-client-node'; 2 + import { ILockService } from './ILockService'; 3 + 4 + interface LockInfo { 5 + expiresAt: number; 6 + promise: Promise<any>; 7 + } 8 + 9 + export class InMemoryLockService implements ILockService { 10 + private locks = new Map<string, LockInfo>(); 11 + 12 + constructor() { 13 + // Clean up expired locks every 30 seconds 14 + setInterval(() => { 15 + const now = Date.now(); 16 + for (const [key, lock] of this.locks.entries()) { 17 + if (now > lock.expiresAt) { 18 + this.locks.delete(key); 19 + } 20 + } 21 + }, 30000); 22 + } 23 + 24 + createRequestLock(): RuntimeLock { 25 + return async (key: string, fn: () => any) => { 26 + const lockKey = `oauth:lock:${key}`; 27 + const now = Date.now(); 28 + const expiresAt = now + 45000; // 45 seconds 29 + 30 + // Check if lock exists and is still valid 31 + const existingLock = this.locks.get(lockKey); 32 + if (existingLock && now < existingLock.expiresAt) { 33 + // Wait for existing lock to complete, then retry 34 + try { 35 + await existingLock.promise; 36 + } catch { 37 + // Ignore errors from other processes 38 + } 39 + await new Promise((resolve) => setTimeout(resolve, 100)); 40 + return this.createRequestLock()(key, fn); 41 + } 42 + 43 + // Create new lock 44 + const lockPromise = this.executeLocked(fn); 45 + this.locks.set(lockKey, { 46 + expiresAt, 47 + promise: lockPromise, 48 + }); 49 + 50 + try { 51 + return await lockPromise; 52 + } finally { 53 + // Clean up lock if it's still ours 54 + const currentLock = this.locks.get(lockKey); 55 + if (currentLock?.promise === lockPromise) { 56 + this.locks.delete(lockKey); 57 + } 58 + } 59 + }; 60 + } 61 + 62 + private async executeLocked<T>(fn: () => Promise<T>): Promise<T> { 63 + return await fn(); 64 + } 65 + }
+31
src/shared/infrastructure/locking/LockServiceFactory.ts
··· 1 + import { ILockService } from './ILockService'; 2 + import { RedisLockService } from './RedisLockService'; 3 + import { InMemoryLockService } from './InMemoryLockService'; 4 + import { RedisFactory } from '../redis/RedisFactory'; 5 + import { configService } from '../config'; 6 + 7 + export class LockServiceFactory { 8 + static create(): ILockService { 9 + const useMockPersistence = configService.shouldUseMockPersistence(); 10 + if (!useMockPersistence) { 11 + try { 12 + const redis = RedisFactory.createConnection({ 13 + host: process.env.REDIS_HOST || 'localhost', 14 + port: parseInt(process.env.REDIS_PORT || '6379'), 15 + password: process.env.REDIS_PASSWORD, 16 + maxRetriesPerRequest: null, 17 + }); 18 + 19 + return new RedisLockService(redis); 20 + } catch (error) { 21 + console.warn( 22 + 'Failed to connect to Redis, falling back to in-memory locks:', 23 + error, 24 + ); 25 + return new InMemoryLockService(); 26 + } 27 + } 28 + 29 + return new InMemoryLockService(); 30 + } 31 + }
+41
src/shared/infrastructure/locking/RedisLockService.ts
··· 1 + import { RuntimeLock } from '@atproto/oauth-client-node'; 2 + import Redis from 'ioredis'; 3 + import Redlock from 'redlock'; 4 + import { ILockService } from './ILockService'; 5 + 6 + export class RedisLockService implements ILockService { 7 + private redlock: Redlock; 8 + 9 + constructor(private redis: Redis) { 10 + this.redlock = new Redlock([redis], { 11 + // Retry settings 12 + retryCount: 3, 13 + retryDelay: 200, // ms 14 + retryJitter: 200, // ms 15 + }); 16 + 17 + // Handle Fly.io container shutdown gracefully 18 + process.on('SIGTERM', () => { 19 + console.log('Received SIGTERM, shutting down gracefully...'); 20 + // Redlock will automatically release locks when the process exits 21 + // No manual cleanup needed due to TTL 22 + }); 23 + } 24 + 25 + createRequestLock(): RuntimeLock { 26 + return async (key: string, fn: () => any) => { 27 + // Include Fly.io instance info in lock key 28 + const instanceId = process.env.FLY_ALLOC_ID || 'local'; 29 + const lockKey = `oauth:lock:${instanceId}:${key}`; 30 + 31 + // 30 seconds for Fly.io (containers restart more frequently) 32 + const lock = await this.redlock.acquire([lockKey], 30000); 33 + 34 + try { 35 + return await fn(); 36 + } finally { 37 + await this.redlock.release(lock); 38 + } 39 + }; 40 + } 41 + }
+4
src/shared/infrastructure/locking/index.ts
··· 1 + export type { ILockService } from './ILockService'; 2 + export { RedisLockService } from './RedisLockService'; 3 + export { InMemoryLockService } from './InMemoryLockService'; 4 + export { LockServiceFactory } from './LockServiceFactory';
+345
src/shared/infrastructure/locking/tests/RedisLockService.integration.test.ts
··· 1 + import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis'; 2 + import Redis from 'ioredis'; 3 + import { RedisLockService } from '../RedisLockService'; 4 + 5 + describe('RedisLockService Integration', () => { 6 + let redisContainer: StartedRedisContainer; 7 + let redis: Redis; 8 + let lockService: RedisLockService; 9 + 10 + beforeAll(async () => { 11 + // Start Redis container 12 + redisContainer = await new RedisContainer('redis:7-alpine') 13 + .withExposedPorts(6379) 14 + .start(); 15 + 16 + // Create Redis connection 17 + const connectionUrl = redisContainer.getConnectionUrl(); 18 + redis = new Redis(connectionUrl, { maxRetriesPerRequest: null }); 19 + 20 + // Create lock service 21 + lockService = new RedisLockService(redis); 22 + }, 60000); // Increase timeout for container startup 23 + 24 + afterAll(async () => { 25 + // Clean up 26 + if (redis) { 27 + await redis.quit(); 28 + } 29 + if (redisContainer) { 30 + await redisContainer.stop(); 31 + } 32 + }); 33 + 34 + beforeEach(async () => { 35 + // Clear Redis data between tests 36 + await redis.flushall(); 37 + }); 38 + 39 + describe('Basic Lock Operations', () => { 40 + it('should acquire and release a lock successfully', async () => { 41 + // Arrange 42 + const lockKey = 'test-lock-key'; 43 + let executionCount = 0; 44 + const testFunction = async () => { 45 + executionCount++; 46 + return 'success'; 47 + }; 48 + 49 + // Act 50 + const requestLock = lockService.createRequestLock(); 51 + const result = await requestLock(lockKey, testFunction); 52 + 53 + // Assert 54 + expect(result).toBe('success'); 55 + expect(executionCount).toBe(1); 56 + 57 + // Verify lock was released by checking Redis directly 58 + const lockPattern = `oauth:lock:*:${lockKey}`; 59 + const keys = await redis.keys(lockPattern); 60 + expect(keys).toHaveLength(0); 61 + }); 62 + 63 + it('should prevent concurrent execution of the same lock key', async () => { 64 + // Arrange 65 + const lockKey = 'concurrent-test-lock'; 66 + let executionOrder: number[] = []; 67 + let currentExecution = 0; 68 + 69 + const createTestFunction = (id: number) => async () => { 70 + const executionId = ++currentExecution; 71 + executionOrder.push(id); 72 + 73 + // Simulate some work 74 + await new Promise((resolve) => setTimeout(resolve, 100)); 75 + 76 + return `result-${id}-${executionId}`; 77 + }; 78 + 79 + // Act - Start two concurrent operations with same lock key 80 + const requestLock = lockService.createRequestLock(); 81 + const [result1, result2] = await Promise.all([ 82 + requestLock(lockKey, createTestFunction(1)), 83 + requestLock(lockKey, createTestFunction(2)), 84 + ]); 85 + 86 + // Assert - Both should succeed but execute sequentially 87 + expect(result1).toMatch(/^result-1-\d+$/); 88 + expect(result2).toMatch(/^result-2-\d+$/); 89 + expect(executionOrder).toHaveLength(2); 90 + 91 + // Verify they executed sequentially (not concurrently) 92 + expect(currentExecution).toBe(2); 93 + }); 94 + 95 + it('should allow concurrent execution with different lock keys', async () => { 96 + // Arrange 97 + const lockKey1 = 'lock-key-1'; 98 + const lockKey2 = 'lock-key-2'; 99 + let startTimes: number[] = []; 100 + 101 + const createTestFunction = (id: number) => async () => { 102 + startTimes.push(Date.now()); 103 + await new Promise((resolve) => setTimeout(resolve, 200)); 104 + return `result-${id}`; 105 + }; 106 + 107 + // Act - Start concurrent operations with different lock keys 108 + const requestLock = lockService.createRequestLock(); 109 + const startTime = Date.now(); 110 + const [result1, result2] = await Promise.all([ 111 + requestLock(lockKey1, createTestFunction(1)), 112 + requestLock(lockKey2, createTestFunction(2)), 113 + ]); 114 + const totalTime = Date.now() - startTime; 115 + 116 + // Assert - Both should succeed and execute concurrently 117 + expect(result1).toBe('result-1'); 118 + expect(result2).toBe('result-2'); 119 + expect(startTimes).toHaveLength(2); 120 + 121 + // Should complete in roughly 200ms (concurrent) rather than 400ms (sequential) 122 + expect(totalTime).toBeLessThan(350); 123 + 124 + // Start times should be close together (concurrent execution) 125 + const timeDiff = Math.abs(startTimes[1]! - startTimes[0]!); 126 + expect(timeDiff).toBeLessThan(50); 127 + }); 128 + 129 + it('should handle function that throws an error', async () => { 130 + // Arrange 131 + const lockKey = 'error-test-lock'; 132 + const errorMessage = 'Test error'; 133 + const errorFunction = async () => { 134 + throw new Error(errorMessage); 135 + }; 136 + 137 + // Act & Assert 138 + const requestLock = lockService.createRequestLock(); 139 + await expect(requestLock(lockKey, errorFunction)).rejects.toThrow( 140 + errorMessage, 141 + ); 142 + 143 + // Verify lock was released even after error 144 + const lockPattern = `oauth:lock:*:${lockKey}`; 145 + const keys = await redis.keys(lockPattern); 146 + expect(keys).toHaveLength(0); 147 + }); 148 + 149 + it('should handle async function that returns a promise', async () => { 150 + // Arrange 151 + const lockKey = 'async-test-lock'; 152 + const asyncFunction = async () => { 153 + await new Promise((resolve) => setTimeout(resolve, 50)); 154 + return { data: 'async-result', timestamp: Date.now() }; 155 + }; 156 + 157 + // Act 158 + const requestLock = lockService.createRequestLock(); 159 + const result = await requestLock(lockKey, asyncFunction); 160 + 161 + // Assert 162 + expect(result).toHaveProperty('data', 'async-result'); 163 + expect(result).toHaveProperty('timestamp'); 164 + expect(typeof result.timestamp).toBe('number'); 165 + }); 166 + }); 167 + 168 + describe('Lock Key Isolation', () => { 169 + it('should include Fly.io instance ID in lock key when available', async () => { 170 + // Arrange 171 + const originalAllocId = process.env.FLY_ALLOC_ID; 172 + process.env.FLY_ALLOC_ID = 'test-instance-123'; 173 + 174 + const lockKey = 'instance-test-lock'; 175 + let lockKeyUsed = ''; 176 + 177 + // Mock redlock to capture the actual lock key used 178 + const originalAcquire = lockService['redlock'].acquire; 179 + lockService['redlock'].acquire = jest 180 + .fn() 181 + .mockImplementation(async (keys: string[]) => { 182 + lockKeyUsed = keys[0]!; 183 + return originalAcquire.call(lockService['redlock'], keys, 30000); 184 + }); 185 + 186 + try { 187 + // Act 188 + const requestLock = lockService.createRequestLock(); 189 + await requestLock(lockKey, async () => 'test'); 190 + 191 + // Assert 192 + expect(lockKeyUsed).toBe(`oauth:lock:test-instance-123:${lockKey}`); 193 + } finally { 194 + // Cleanup 195 + process.env.FLY_ALLOC_ID = originalAllocId; 196 + lockService['redlock'].acquire = originalAcquire; 197 + } 198 + }); 199 + 200 + it('should use "local" as default instance ID when FLY_ALLOC_ID is not set', async () => { 201 + // Arrange 202 + const originalAllocId = process.env.FLY_ALLOC_ID; 203 + delete process.env.FLY_ALLOC_ID; 204 + 205 + const lockKey = 'local-test-lock'; 206 + let lockKeyUsed = ''; 207 + 208 + // Mock redlock to capture the actual lock key used 209 + const originalAcquire = lockService['redlock'].acquire; 210 + lockService['redlock'].acquire = jest 211 + .fn() 212 + .mockImplementation(async (keys: string[]) => { 213 + lockKeyUsed = keys[0]!; 214 + return originalAcquire.call(lockService['redlock'], keys, 30000); 215 + }); 216 + 217 + try { 218 + // Act 219 + const requestLock = lockService.createRequestLock(); 220 + await requestLock(lockKey, async () => 'test'); 221 + 222 + // Assert 223 + expect(lockKeyUsed).toBe(`oauth:lock:local:${lockKey}`); 224 + } finally { 225 + // Cleanup 226 + process.env.FLY_ALLOC_ID = originalAllocId; 227 + lockService['redlock'].acquire = originalAcquire; 228 + } 229 + }); 230 + }); 231 + 232 + describe('Lock Timeout and TTL', () => { 233 + it('should automatically release lock after TTL expires', async () => { 234 + // Arrange 235 + const lockKey = 'ttl-test-lock'; 236 + 237 + // Manually acquire a lock with short TTL to simulate timeout 238 + const instanceId = process.env.FLY_ALLOC_ID || 'local'; 239 + const fullLockKey = `oauth:lock:${instanceId}:${lockKey}`; 240 + 241 + // Use redlock directly to set a very short TTL (100ms) 242 + const shortLock = await lockService['redlock'].acquire( 243 + [fullLockKey], 244 + 100, 245 + ); 246 + 247 + // Act - Wait for lock to expire 248 + await new Promise((resolve) => setTimeout(resolve, 200)); 249 + 250 + // Try to acquire the same lock - should succeed if previous lock expired 251 + const requestLock = lockService.createRequestLock(); 252 + const result = await requestLock( 253 + lockKey, 254 + async () => 'success-after-timeout', 255 + ); 256 + 257 + // Assert 258 + expect(result).toBe('success-after-timeout'); 259 + 260 + // Cleanup - release the short lock (may already be expired) 261 + try { 262 + await lockService['redlock'].release(shortLock); 263 + } catch { 264 + // Ignore errors if lock already expired 265 + } 266 + }); 267 + 268 + it('should handle high concurrency with retry mechanism', async () => { 269 + // Arrange 270 + const lockKey = 'high-concurrency-lock'; 271 + const concurrentOperations = 5; 272 + let completedOperations = 0; 273 + 274 + const testFunction = async () => { 275 + await new Promise((resolve) => setTimeout(resolve, 50)); 276 + return ++completedOperations; 277 + }; 278 + 279 + // Act - Start multiple concurrent operations 280 + const requestLock = lockService.createRequestLock(); 281 + const promises = Array.from({ length: concurrentOperations }, () => 282 + requestLock(lockKey, testFunction), 283 + ); 284 + 285 + const results = await Promise.all(promises); 286 + 287 + // Assert - All operations should complete successfully 288 + expect(results).toHaveLength(concurrentOperations); 289 + expect(completedOperations).toBe(concurrentOperations); 290 + 291 + // Results should be sequential numbers (1, 2, 3, 4, 5) 292 + const sortedResults = results.sort((a, b) => a - b); 293 + expect(sortedResults).toEqual([1, 2, 3, 4, 5]); 294 + }); 295 + }); 296 + 297 + describe('Error Handling', () => { 298 + it('should handle Redis connection issues gracefully', async () => { 299 + // Arrange - Create a new Redis connection that we can close 300 + const testRedis = new Redis(redisContainer.getConnectionUrl(), { 301 + maxRetriesPerRequest: null, 302 + }); 303 + const testLockService = new RedisLockService(testRedis); 304 + 305 + // Close the connection to simulate network issues 306 + await testRedis.quit(); 307 + 308 + // Act & Assert - Should throw an error when trying to acquire lock 309 + const requestLock = testLockService.createRequestLock(); 310 + await expect( 311 + requestLock('test-key', async () => 'should-not-execute'), 312 + ).rejects.toThrow(); 313 + }); 314 + 315 + it('should release lock even when function execution is interrupted', async () => { 316 + // Arrange 317 + const lockKey = 'interrupt-test-lock'; 318 + let lockAcquired = false; 319 + 320 + const interruptedFunction = async () => { 321 + lockAcquired = true; 322 + // Simulate an interruption/error after lock is acquired 323 + throw new Error('Simulated interruption'); 324 + }; 325 + 326 + // Act & Assert 327 + const requestLock = lockService.createRequestLock(); 328 + await expect(requestLock(lockKey, interruptedFunction)).rejects.toThrow( 329 + 'Simulated interruption', 330 + ); 331 + 332 + // Verify lock was acquired initially 333 + expect(lockAcquired).toBe(true); 334 + 335 + // Verify lock was released after error 336 + const lockPattern = `oauth:lock:*:${lockKey}`; 337 + const keys = await redis.keys(lockPattern); 338 + expect(keys).toHaveLength(0); 339 + 340 + // Verify we can acquire the same lock again 341 + const result = await requestLock(lockKey, async () => 'recovered'); 342 + expect(result).toBe('recovered'); 343 + }); 344 + }); 345 + });
+2 -2
src/shared/infrastructure/processes/AppProcess.ts
··· 10 10 // Get configuration 11 11 const config = this.configService.get(); 12 12 13 - const useMockRepos = process.env.USE_MOCK_REPOS === 'true'; 14 - if (!useMockRepos) { 13 + const useMockPersistence = this.configService.shouldUseMockPersistence(); 14 + if (!useMockPersistence) { 15 15 // Create database connection with config 16 16 const db = DatabaseFactory.createConnection( 17 17 this.configService.getDatabaseConfig(),
+8 -12
src/shared/infrastructure/redis/RedisFactory.ts
··· 1 1 import Redis from 'ioredis'; 2 2 3 3 export class RedisFactory { 4 - private static instance: Redis | null = null; 5 4 static createConnection(redisConfig: { 6 5 host: string; 7 6 port: number; 8 7 password?: string; 9 8 maxRetriesPerRequest: number | null; 10 9 }): Redis { 11 - if (!this.instance) { 12 - this.instance = new Redis({ 13 - host: redisConfig.host, 14 - port: redisConfig.port, 15 - password: redisConfig.password, 16 - maxRetriesPerRequest: redisConfig.maxRetriesPerRequest, 17 - username: 'default', 18 - family: 6, 19 - }); 20 - } 21 - return this.instance; 10 + return new Redis({ 11 + host: redisConfig.host, 12 + port: redisConfig.port, 13 + password: redisConfig.password, 14 + maxRetriesPerRequest: redisConfig.maxRetriesPerRequest, 15 + username: 'default', 16 + family: 6, 17 + }); 22 18 } 23 19 }
+6 -31
src/webapp/app/(auth)/login/page.tsx
··· 14 14 Loader, 15 15 Badge, 16 16 } from '@mantine/core'; 17 - import { Suspense, useEffect, useState } from 'react'; 17 + import { Suspense, useEffect } from 'react'; 18 18 import { IoMdHelpCircleOutline } from 'react-icons/io'; 19 19 import SembleLogo from '@/assets/semble-logo.svg'; 20 20 import { useAuth } from '@/hooks/useAuth'; ··· 22 22 import Link from 'next/link'; 23 23 24 24 function InnerPage() { 25 - const { isAuthenticated, isLoading } = useAuth(); 26 - const [isRedirecting, setIsRedirecting] = useState(false); 25 + const { isAuthenticated, isLoading, refreshAuth } = useAuth(); 27 26 const router = useRouter(); 28 27 const searchParams = useSearchParams(); 29 28 const isExtensionLogin = searchParams.get('extension-login') === 'true'; 30 29 31 30 useEffect(() => { 32 - let timeoutId: NodeJS.Timeout; 33 - 34 31 if (isAuthenticated && !isExtensionLogin) { 35 - setIsRedirecting(true); 36 - 37 - // redirect after 1 second 38 - timeoutId = setTimeout(() => { 39 - router.push('/home'); 40 - }, 1000); 32 + refreshAuth(); 33 + router.push('/home'); 41 34 } 35 + }, [isAuthenticated, router, isExtensionLogin, refreshAuth]); 42 36 43 - // clean up 44 - return () => { 45 - if (timeoutId) { 46 - clearTimeout(timeoutId); 47 - } 48 - }; 49 - }, [isAuthenticated, router, isExtensionLogin]); 50 - 51 - if (isLoading) { 37 + if (isAuthenticated) { 52 38 return ( 53 39 <Stack align="center"> 54 - <Loader type="dots" /> 55 - </Stack> 56 - ); 57 - } 58 - 59 - if (isRedirecting) { 60 - return ( 61 - <Stack align="center"> 62 - <Text fw={500} fz={'xl'}> 63 - Already logged in, redirecting you to library 64 - </Text> 65 40 <Loader type="dots" /> 66 41 </Stack> 67 42 );
+73
src/webapp/app/(dashboard)/error.tsx
··· 1 + 'use client'; 2 + 3 + import { 4 + BackgroundImage, 5 + Center, 6 + Stack, 7 + Image, 8 + Badge, 9 + Text, 10 + Group, 11 + Button, 12 + Container, 13 + } from '@mantine/core'; 14 + import SembleLogo from '@/assets/semble-logo.svg'; 15 + import BG from '@/assets/semble-bg.webp'; 16 + import Link from 'next/link'; 17 + import { BiRightArrowAlt } from 'react-icons/bi'; 18 + 19 + export default function Error() { 20 + return ( 21 + <BackgroundImage 22 + src={BG.src} 23 + h={'100svh'} 24 + pos={'fixed'} 25 + top={0} 26 + left={0} 27 + style={{ zIndex: 102 }} 28 + > 29 + <Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}> 30 + <Container size={'xl'} p={'md'} my={'auto'}> 31 + <Stack> 32 + <Stack align="center" gap={'xs'}> 33 + <Image 34 + src={SembleLogo.src} 35 + alt="Semble logo" 36 + w={48} 37 + h={64.5} 38 + mx={'auto'} 39 + /> 40 + <Badge size="sm">Alpha</Badge> 41 + </Stack> 42 + 43 + <Stack> 44 + <Text fz={'h1'} fw={600} ta={'center'}> 45 + A social knowledge network for researchers 46 + </Text> 47 + <Text fz={'h3'} fw={600} c={'#1F6144'} ta={'center'}> 48 + Follow your peers’ research trails. Surface and discover new 49 + connections. Built on ATProto so you own your data. 50 + </Text> 51 + </Stack> 52 + 53 + <Group justify="center" gap="md" mt={'lg'}> 54 + <Button component={Link} href="/signup" size="lg"> 55 + Sign up 56 + </Button> 57 + 58 + <Button 59 + component={Link} 60 + href="/login" 61 + size="lg" 62 + color="dark" 63 + rightSection={<BiRightArrowAlt size={22} />} 64 + > 65 + Log in 66 + </Button> 67 + </Group> 68 + </Stack> 69 + </Container> 70 + </Center> 71 + </BackgroundImage> 72 + ); 73 + }
+6 -1
src/webapp/app/(dashboard)/home/page.tsx
··· 1 1 import HomeContainer from '@/features/home/containers/homeContainer/HomeContainer'; 2 + import { verifySessionOnServer } from '@/lib/auth/dal.server'; 3 + import { redirect } from 'next/navigation'; 2 4 3 - export default function Page() { 5 + export default async function Page() { 6 + const session = await verifySessionOnServer(); 7 + if (!session) redirect('/login'); 8 + 4 9 return <HomeContainer />; 5 10 }
+2 -20
src/webapp/app/(dashboard)/layout.tsx
··· 1 - 'use client'; 2 - 3 - import { useEffect } from 'react'; 4 - import { useRouter } from 'next/navigation'; 5 - import { useAuth } from '@/hooks/useAuth'; 6 - import AppLayout from '@/components/navigation/appLayout/AppLayout'; 1 + import Dashboard from '@/components/navigation/dashboard/Dashboard'; 7 2 8 3 interface Props { 9 4 children: React.ReactNode; 10 5 } 11 6 export default function Layout(props: Props) { 12 - const router = useRouter(); 13 - const { isAuthenticated, isLoading } = useAuth(); 14 - 15 - useEffect(() => { 16 - if (!isLoading && !isAuthenticated) { 17 - router.push('/login'); 18 - } 19 - }, [isAuthenticated, isLoading, router]); 20 - 21 - if (!isAuthenticated) { 22 - return null; // Redirecting 23 - } 24 - 25 - return <AppLayout>{props.children}</AppLayout>; 7 + return <Dashboard>{props.children}</Dashboard>; 26 8 }
+2 -6
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/layout.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import BackButton from '@/components/navigation/backButton/BackButton'; 3 2 import Header from '@/components/navigation/header/Header'; 3 + import { getCollectionPageByAtUri } from '@/features/collections/lib/dal'; 4 4 import { truncateText } from '@/lib/utils/text'; 5 5 import type { Metadata } from 'next'; 6 6 import { Fragment } from 'react'; ··· 13 13 export async function generateMetadata({ params }: Props): Promise<Metadata> { 14 14 const { rkey, handle } = await params; 15 15 16 - const apiClient = new ApiClient( 17 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 18 - ); 19 - 20 - const collection = await apiClient.getCollectionPageByAtUri({ 16 + const collection = await getCollectionPageByAtUri({ 21 17 recordKey: rkey, 22 18 handle: handle, 23 19 });
+2 -6
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/opengraph-image.tsx
··· 1 - import { ApiClient } from '@/api-client'; 1 + import { getCollectionPageByAtUri } from '@/features/collections/lib/dal'; 2 2 import OpenGraphCard from '@/features/openGraph/components/openGraphCard/OpenGraphCard'; 3 3 import { truncateText } from '@/lib/utils/text'; 4 4 ··· 15 15 export default async function Image(props: Props) { 16 16 const { rkey, handle } = await props.params; 17 17 18 - const apiClient = new ApiClient( 19 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 20 - ); 21 - 22 - const collection = await apiClient.getCollectionPageByAtUri({ 18 + const collection = await getCollectionPageByAtUri({ 23 19 recordKey: rkey, 24 20 handle: handle, 25 21 });
+131 -65
src/webapp/app/api/auth/me/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server'; 2 + import type { GetProfileResponse } from '@/api-client/ApiClient'; 2 3 import { cookies } from 'next/headers'; 3 4 import { isTokenExpiringSoon } from '@/lib/auth/token'; 4 5 6 + const ENABLE_REFRESH_LOGGING = true; 7 + 8 + const backendUrl = 9 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; 10 + 11 + type AuthResult = { 12 + isAuth: boolean; 13 + user?: GetProfileResponse; 14 + }; 15 + 16 + // Prevent concurrent refresh attempts 17 + let refreshPromise: Promise<Response> | null = null; 18 + 5 19 export async function GET(request: NextRequest) { 6 20 try { 7 21 const cookieStore = await cookies(); ··· 10 24 11 25 // No tokens at all - not authenticated 12 26 if (!accessToken && !refreshToken) { 13 - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); 27 + return NextResponse.json<AuthResult>({ isAuth: false }, { status: 401 }); 14 28 } 15 29 16 - // Check if accessToken is expired/missing or expiring soon (< 5 min) 17 - if ((!accessToken || isTokenExpiringSoon(accessToken, 5)) && refreshToken) { 18 - try { 19 - // Proxy the refresh request completely to backend 20 - const backendUrl = 21 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; 22 - const refreshResponse = await fetch( 23 - `${backendUrl}/api/users/oauth/refresh`, 24 - { 25 - method: 'POST', 26 - headers: { 27 - 'Content-Type': 'application/json', 28 - Cookie: request.headers.get('cookie') || '', // Forward all cookies 29 - }, 30 - body: JSON.stringify({ refreshToken }), 31 - }, 30 + // Check if accessToken is expired/missing or expiring soon 31 + if ((!accessToken || isTokenExpiringSoon(accessToken)) && refreshToken) { 32 + if (ENABLE_REFRESH_LOGGING) { 33 + const tokenPreview = '...' + refreshToken.slice(-8); 34 + console.log( 35 + `[auth/me] Access token missing/expiring, attempting refresh with token: ${tokenPreview}`, 32 36 ); 37 + } 33 38 34 - if (!refreshResponse.ok) { 35 - // Refresh failed - clear invalid tokens 36 - const response = new NextResponse( 37 - JSON.stringify({ error: 'Authentication failed' }), 38 - { status: 401 }, 39 - ); 40 - response.cookies.delete('accessToken'); 41 - response.cookies.delete('refreshToken'); 42 - return response; 39 + // Use mutex to prevent concurrent refresh attempts 40 + if (!refreshPromise) { 41 + refreshPromise = performTokenRefresh(refreshToken, request); 42 + } 43 + 44 + try { 45 + const result = await refreshPromise; 46 + if (ENABLE_REFRESH_LOGGING) { 47 + console.log(`[auth/me] Token refresh completed successfully`); 48 + } 49 + return result; 50 + } catch (error: any) { 51 + if (ENABLE_REFRESH_LOGGING) { 52 + console.log(`[auth/me] Token refresh error: ${error}`); 43 53 } 54 + console.error('Token refresh error:', error); 44 55 45 - // Get new tokens from response 46 - const newTokens = await refreshResponse.json(); 47 - accessToken = newTokens.accessToken; 56 + // If this is a refresh failure with backend response, forward the cookie-clearing headers 57 + if (error.backendResponse) { 58 + const response = NextResponse.json<AuthResult>( 59 + { isAuth: false }, 60 + { status: 500 }, 61 + ); 48 62 49 - // Fetch profile with new token 50 - const profileResponse = await fetch(`${backendUrl}/api/users/me`, { 51 - method: 'GET', 52 - headers: { 53 - 'Content-Type': 'application/json', 54 - Cookie: `accessToken=${accessToken}`, 55 - }, 56 - }); 63 + // Forward the Set-Cookie headers from backend to clear cookies 64 + const setCookieHeader = 65 + error.backendResponse.headers.get('set-cookie'); 66 + if (setCookieHeader) { 67 + response.headers.set('Set-Cookie', setCookieHeader); 68 + } 57 69 58 - if (!profileResponse.ok) { 59 - return NextResponse.json( 60 - { error: 'Failed to fetch profile' }, 61 - { status: profileResponse.status }, 62 - ); 70 + return response; 63 71 } 64 72 65 - const user = await profileResponse.json(); 66 - 67 - // Return user profile with backend's Set-Cookie headers 68 - return new Response(JSON.stringify({ user }), { 69 - status: 200, 70 - headers: { 71 - 'Content-Type': 'application/json', 72 - 'Set-Cookie': refreshResponse.headers.get('set-cookie') || '', 73 - }, 74 - }); 75 - } catch (error) { 76 - console.error('Token refresh error:', error); 77 - return NextResponse.json( 78 - { error: 'Authentication failed' }, 73 + // For other errors, clear cookies manually 74 + const response = NextResponse.json<AuthResult>( 75 + { isAuth: false }, 79 76 { status: 500 }, 80 77 ); 78 + response.cookies.delete('accessToken'); 79 + response.cookies.delete('refreshToken'); 80 + return response; 81 + } finally { 82 + refreshPromise = null; 81 83 } 82 84 } 83 85 ··· 94 96 }); 95 97 96 98 if (!profileResponse.ok) { 97 - return NextResponse.json( 98 - { error: 'Failed to fetch profile' }, 99 + // Clear cookies on auth failure 100 + const response = NextResponse.json<AuthResult>( 101 + { isAuth: false }, 99 102 { status: profileResponse.status }, 100 103 ); 104 + response.cookies.delete('accessToken'); 105 + response.cookies.delete('refreshToken'); 106 + return response; 101 107 } 102 108 103 109 const user = await profileResponse.json(); 104 - return NextResponse.json({ user }); 110 + return NextResponse.json<AuthResult>({ isAuth: true, user }); 105 111 } catch (error) { 106 112 console.error('Profile fetch error:', error); 107 - return NextResponse.json( 108 - { error: 'Failed to fetch profile' }, 113 + // Clear cookies on fetch error too 114 + const response = NextResponse.json<AuthResult>( 115 + { isAuth: false }, 109 116 { status: 500 }, 110 117 ); 118 + response.cookies.delete('accessToken'); 119 + response.cookies.delete('refreshToken'); 120 + return response; 111 121 } 112 122 } catch (error) { 113 123 console.error('Auth me error:', error); 114 - return NextResponse.json( 115 - { error: 'Internal server error' }, 116 - { status: 500 }, 117 - ); 124 + refreshPromise = null; // Reset on error 125 + return NextResponse.json<AuthResult>({ isAuth: false }, { status: 500 }); 126 + } 127 + } 128 + 129 + async function performTokenRefresh( 130 + refreshToken: string, 131 + request: NextRequest, 132 + ): Promise<Response> { 133 + if (ENABLE_REFRESH_LOGGING) { 134 + console.log(`[auth/me] Sending refresh request to backend`); 118 135 } 136 + 137 + // Proxy the refresh request completely to backend 138 + const refreshResponse = await fetch(`${backendUrl}/api/users/oauth/refresh`, { 139 + method: 'POST', 140 + headers: { 141 + 'Content-Type': 'application/json', 142 + Cookie: request.headers.get('cookie') || '', 143 + }, 144 + body: JSON.stringify({ refreshToken }), 145 + }); 146 + 147 + if (!refreshResponse.ok) { 148 + if (ENABLE_REFRESH_LOGGING) { 149 + console.log( 150 + `[auth/me] Backend refresh failed with status: ${refreshResponse.status}. Message: ${await refreshResponse.text()}`, 151 + ); 152 + } 153 + // Create error with backend response to preserve cookie-clearing headers 154 + const error = new Error(`Refresh failed: ${refreshResponse.status}`) as any; 155 + error.backendResponse = refreshResponse; 156 + throw error; 157 + } 158 + 159 + // Get new tokens from response 160 + const newTokens = await refreshResponse.json(); 161 + const accessToken = newTokens.accessToken; 162 + 163 + // Fetch profile with new token 164 + const profileResponse = await fetch(`${backendUrl}/api/users/me`, { 165 + method: 'GET', 166 + headers: { 167 + 'Content-Type': 'application/json', 168 + Cookie: `accessToken=${accessToken}`, 169 + }, 170 + }); 171 + 172 + if (!profileResponse.ok) { 173 + return NextResponse.json<AuthResult>({ isAuth: false }, { status: 401 }); 174 + } 175 + 176 + const user = await profileResponse.json(); 177 + // Return user profile with backend's Set-Cookie headers 178 + return new Response(JSON.stringify({ isAuth: true, user }), { 179 + status: 200, 180 + headers: { 181 + 'Content-Type': 'application/json', 182 + 'Set-Cookie': refreshResponse.headers.get('set-cookie') || '', 183 + }, 184 + }); 119 185 }
-39
src/webapp/app/api/auth/sync/route.ts
··· 1 - import { NextRequest, NextResponse } from 'next/server'; 2 - 3 - export async function POST(request: NextRequest) { 4 - try { 5 - const { accessToken, refreshToken } = await request.json(); 6 - 7 - if (!accessToken || !refreshToken) { 8 - return NextResponse.json({ error: 'Missing tokens' }, { status: 400 }); 9 - } 10 - 11 - // Create response with cookies 12 - const response = NextResponse.json({ synced: true }); 13 - 14 - // Set httpOnly cookies 15 - response.cookies.set('accessToken', accessToken, { 16 - httpOnly: true, 17 - secure: process.env.NODE_ENV === 'production', 18 - sameSite: 'strict', 19 - maxAge: 900, // 15 minutes 20 - path: '/', 21 - }); 22 - 23 - response.cookies.set('refreshToken', refreshToken, { 24 - httpOnly: true, 25 - secure: process.env.NODE_ENV === 'production', 26 - sameSite: 'strict', 27 - maxAge: 604800, // 7 days 28 - path: '/', 29 - }); 30 - 31 - return response; 32 - } catch (error) { 33 - console.error('Token sync error:', error); 34 - return NextResponse.json( 35 - { error: 'Failed to sync tokens' }, 36 - { status: 500 }, 37 - ); 38 - } 39 - }
+8 -3
src/webapp/app/manifest.ts
··· 12 12 theme_color: theme.colors?.orange?.[6], 13 13 icons: [ 14 14 { 15 - src: '/favicon.ico', 16 - sizes: 'any', 17 - type: 'image/x-icon', 15 + src: '/semble-icon-192x192.png', 16 + sizes: '192x192', 17 + type: 'image/png', 18 + }, 19 + { 20 + src: '/semble-icon-512x512.png', 21 + sizes: '512x512', 22 + type: 'image/png', 18 23 }, 19 24 ], 20 25 };
+24 -23
src/webapp/app/page.tsx
··· 25 25 import BigPictureIcon from '@/assets/icons/big-picture-icon.svg'; 26 26 import TangledIcon from '@/assets/icons/tangled-icon.svg'; 27 27 import SembleLogo from '@/assets/semble-logo.svg'; 28 + import Link from 'next/link'; 28 29 29 30 export default function Home() { 30 31 return ( 31 32 <BackgroundImage src={BG.src} h={'100svh'}> 33 + <script async src="https://tally.so/widgets/embed.js" /> 34 + <Container size={'xl'} p={'md'} my={'auto'}> 35 + <Group justify="space-between"> 36 + <Stack gap={6} align="center"> 37 + <Image src={SembleLogo.src} alt="Semble logo" w={30} h={'auto'} /> 38 + <Badge size="sm">Alpha</Badge> 39 + </Stack> 40 + <Button 41 + data-tally-open="31a9Ng" 42 + data-tally-hide-title="1" 43 + data-tally-layout="modal" 44 + data-tally-emoji-animation="none" 45 + variant="default" 46 + size="sm" 47 + > 48 + Stay in the loop 49 + </Button> 50 + </Group> 51 + </Container> 32 52 <Center h={'100svh'} py={{ base: '2rem', xs: '5rem' }}> 33 53 <Container size={'xl'} p={'md'} my={'auto'}> 34 54 <Stack align="center" gap={'5rem'}> 35 55 <Stack gap={'xs'} align="center" maw={550} mx={'auto'}> 36 - <Stack gap={'xs'}> 37 - <Image 38 - src={SembleLogo.src} 39 - alt="Semble logo" 40 - w={'auto'} 41 - h={60} 42 - /> 43 - </Stack> 44 - <Badge size="sm">Alpha</Badge> 45 56 <Title order={1} fw={600} fz={'3rem'} ta={'center'}> 46 57 A social knowledge network for researchers 47 58 </Title> ··· 49 60 Follow your peers’ research trails. Surface and discover new 50 61 connections. Built on ATProto so you own your data. 51 62 </Title> 52 - {process.env.VERCEL_ENV === 'production' ? ( 53 - <Button 54 - component="a" 55 - href="https://forms.cosmik.network/waitlist" 56 - target="_blank" 57 - size="lg" 58 - color="dark" 59 - mt={'lg'} 60 - > 61 - Join waitlist 62 - </Button> 63 - ) : ( 63 + 64 + {process.env.VERCEL_ENV !== 'production' && ( 64 65 <Group gap="md" mt={'lg'}> 65 - <Button component="a" href="/signup" size="lg"> 66 + <Button component={Link} href="/signup" size="lg"> 66 67 Sign up 67 68 </Button> 68 69 69 70 <Button 70 - component="a" 71 + component={Link} 71 72 href="/login" 72 73 size="lg" 73 74 color="dark"
+2 -1
src/webapp/components/AddToCollectionModal.tsx
··· 4 4 import { ApiClient } from '@/api-client/ApiClient'; 5 5 import { Button, Group, Modal, Stack, Text } from '@mantine/core'; 6 6 import { CollectionSelector } from './CollectionSelector'; 7 + import { getUrlCardView } from '@/features/cards/lib/dal'; 7 8 8 9 interface Collection { 9 10 id: string; ··· 54 55 try { 55 56 setLoading(true); 56 57 setError(''); 57 - const response = await apiClient.getUrlCardView(cardId); 58 + const response = await getUrlCardView(cardId); 58 59 setCard(response); 59 60 } catch (error: any) { 60 61 console.error('Error fetching card:', error);
+7
src/webapp/components/navigation/appLayout/AppLayout.tsx
··· 5 5 import ComposerDrawer from '@/features/composer/components/composerDrawer/ComposerDrawer'; 6 6 import { useNavbarContext } from '@/providers/navbar'; 7 7 import { usePathname } from 'next/navigation'; 8 + import BottomBar from '../bottomBar/BottomBar'; 9 + import { useMediaQuery } from '@mantine/hooks'; 8 10 9 11 interface Props { 10 12 children: React.ReactNode; ··· 12 14 13 15 export default function AppLayout(props: Props) { 14 16 const { mobileOpened, desktopOpened } = useNavbarContext(); 17 + const isMobile = useMediaQuery('(max-width: 48em)'); // "sm" breakpoint 15 18 const pathname = usePathname(); 16 19 17 20 const ROUTES_WITH_ASIDE = ['/url']; ··· 33 36 breakpoint: 'xl', 34 37 collapsed: { mobile: true }, 35 38 }} 39 + footer={{ 40 + height: isMobile ? 80 : 0, 41 + }} 36 42 > 37 43 <Navbar /> 38 44 ··· 40 46 {props.children} 41 47 <ComposerDrawer /> 42 48 </AppShell.Main> 49 + <BottomBar /> 43 50 </AppShell> 44 51 ); 45 52 }
+28
src/webapp/components/navigation/bottomBar/BottomBar.tsx
··· 1 + import { AppShellFooter, Avatar, Group } from '@mantine/core'; 2 + import { FaRegNoteSticky } from 'react-icons/fa6'; 3 + import { LuLibrary } from 'react-icons/lu'; 4 + import { MdOutlineEmojiNature } from 'react-icons/md'; 5 + import NavbarToggle from '../NavbarToggle'; 6 + import BottomBarItem from '../bottomBarItem/BottomBarItem'; 7 + import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 8 + 9 + export default function BottomBar() { 10 + const { data: profile } = useMyProfile(); 11 + 12 + return ( 13 + <AppShellFooter px={'sm'} pb={'lg'} py={'xs'} hiddenFrom="sm"> 14 + <Group align="start" justify="space-around" gap={'lg'} h={'100%'}> 15 + <BottomBarItem href="/home" icon={LuLibrary} /> 16 + <BottomBarItem href="/explore" icon={MdOutlineEmojiNature} /> 17 + <BottomBarItem 18 + href={`/profile/${profile.handle}/cards`} 19 + icon={FaRegNoteSticky} 20 + /> 21 + <BottomBarItem 22 + href={`/profile/${profile.handle}`} 23 + icon={<Avatar src={profile.avatarUrl} />} 24 + /> 25 + </Group> 26 + </AppShellFooter> 27 + ); 28 + }
+37
src/webapp/components/navigation/bottomBarItem/BottomBarItem.tsx
··· 1 + import { IconType } from 'react-icons/lib'; 2 + import { ActionIcon } from '@mantine/core'; 3 + import { usePathname } from 'next/navigation'; 4 + import Link from 'next/link'; 5 + import { ReactElement, isValidElement } from 'react'; 6 + 7 + interface Props { 8 + href: string; 9 + icon: IconType | ReactElement; 10 + } 11 + 12 + export default function BottomBarItem(props: Props) { 13 + const pathname = usePathname(); 14 + const isActive = pathname === props.href; 15 + 16 + const renderIcon = () => { 17 + // If the icon is already a React element, just return it 18 + if (isValidElement(props.icon)) return props.icon; 19 + 20 + // Otherwise, it's an IconType component, so render it manually 21 + const IconComponent = props.icon as IconType; 22 + return <IconComponent size={22} />; 23 + }; 24 + 25 + return ( 26 + <ActionIcon 27 + component={Link} 28 + href={props.href} 29 + variant={isActive ? 'light' : 'transparent'} 30 + size={'lg'} 31 + bg={isActive ? 'gray.1' : 'transparent'} 32 + color={isActive ? 'dark' : 'gray'} 33 + > 34 + {renderIcon()} 35 + </ActionIcon> 36 + ); 37 + }
+20
src/webapp/components/navigation/dashboard/Dashboard.tsx
··· 1 + 'use client'; 2 + 3 + import AppLayout from '../appLayout/AppLayout'; 4 + import GuestAppLayout from '../guestAppLayout/GuestAppLayout'; 5 + import { useAuth } from '@/hooks/useAuth'; 6 + 7 + interface Props { 8 + children: React.ReactNode; 9 + } 10 + 11 + export default function Dashboard(props: Props) { 12 + const { isAuthenticated, isLoading } = useAuth(); 13 + 14 + if (isLoading) return null; 15 + 16 + if (!isAuthenticated) 17 + return <GuestAppLayout>{props.children}</GuestAppLayout>; 18 + 19 + return <AppLayout>{props.children}</AppLayout>; 20 + }
+41
src/webapp/components/navigation/guestAppLayout/GuestAppLayout.tsx
··· 1 + 'use client'; 2 + 3 + import { AppShell } from '@mantine/core'; 4 + import { useNavbarContext } from '@/providers/navbar'; 5 + import { usePathname } from 'next/navigation'; 6 + import GuestNavbar from '../guestNavbar/GuestNavbar'; 7 + 8 + interface Props { 9 + children: React.ReactNode; 10 + } 11 + 12 + export default function GuestAppLayout(props: Props) { 13 + const { mobileOpened, desktopOpened } = useNavbarContext(); 14 + const pathname = usePathname(); 15 + 16 + const ROUTES_WITH_ASIDE = ['/url']; 17 + const hasAside = ROUTES_WITH_ASIDE.some((prefix) => 18 + pathname.startsWith(prefix), 19 + ); 20 + const asideWidth = hasAside ? 300 : 0; 21 + 22 + return ( 23 + <AppShell 24 + header={{ height: 0 }} 25 + navbar={{ 26 + width: 300, 27 + breakpoint: 'xs', 28 + collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, 29 + }} 30 + aside={{ 31 + width: asideWidth, 32 + breakpoint: 'xl', 33 + collapsed: { mobile: true }, 34 + }} 35 + > 36 + <GuestNavbar /> 37 + 38 + <AppShell.Main>{props.children}</AppShell.Main> 39 + </AppShell> 40 + ); 41 + }
+65
src/webapp/components/navigation/guestNavbar/GuestNavbar.tsx
··· 1 + import NavItem from '../navItem/NavItem'; 2 + import { 3 + AppShellSection, 4 + AppShellNavbar, 5 + ScrollArea, 6 + Stack, 7 + Group, 8 + Anchor, 9 + Image, 10 + Box, 11 + Badge, 12 + Button, 13 + Text, 14 + } from '@mantine/core'; 15 + import { MdOutlineEmojiNature } from 'react-icons/md'; 16 + import Link from 'next/link'; 17 + import SembleLogo from '@/assets/semble-logo.svg'; 18 + import NavbarToggle from '../NavbarToggle'; 19 + import { BiRightArrowAlt } from 'react-icons/bi'; 20 + 21 + export default function GuestNavbar() { 22 + return ( 23 + <AppShellNavbar p={'xs'} style={{ zIndex: 3 }}> 24 + <Group justify="space-between"> 25 + <Anchor component={Link} href={'/home'}> 26 + <Stack align="center" gap={6}> 27 + <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} /> 28 + <Badge size="xs">Alpha</Badge> 29 + </Stack> 30 + </Anchor> 31 + <Box hiddenFrom="xs"> 32 + <NavbarToggle /> 33 + </Box> 34 + </Group> 35 + 36 + <AppShellSection grow component={ScrollArea}> 37 + <Stack mt={'xl'}> 38 + <Stack> 39 + <Text fw={600} fz={'xl'}> 40 + A social knowledge network for researchers 41 + </Text> 42 + <Group grow> 43 + <Button component={Link} href="/signup"> 44 + Sign up 45 + </Button> 46 + <Button 47 + component={Link} 48 + href="/login" 49 + color="dark" 50 + rightSection={<BiRightArrowAlt size={22} />} 51 + > 52 + Log in 53 + </Button> 54 + </Group> 55 + <NavItem 56 + href="/explore" 57 + label="Explore" 58 + icon={<MdOutlineEmojiNature size={25} />} 59 + /> 60 + </Stack> 61 + </Stack> 62 + </AppShellSection> 63 + </AppShellNavbar> 64 + ); 65 + }
+1 -1
src/webapp/components/navigation/header/Header.tsx
··· 8 8 9 9 export default function Header(props: Props) { 10 10 return ( 11 - <Box> 11 + <Box pos={'sticky'} top={0} bg={'white'} style={{ zIndex: 1 }}> 12 12 <Group gap={'xs'} p={'xs'} justify="space-between"> 13 13 {props.children} 14 14 <Box ml={'auto'}>
+5 -7
src/webapp/features/auth/components/loginForm/LoginForm.tsx
··· 1 1 'use client'; 2 2 3 3 import { ExtensionService } from '@/services/extensionService'; 4 - import { ApiClient } from '@/api-client/ApiClient'; 5 4 import { 6 5 Stack, 7 6 Text, ··· 17 16 import { useForm } from '@mantine/form'; 18 17 import { useRouter, useSearchParams } from 'next/navigation'; 19 18 import { useEffect, useState } from 'react'; 19 + import { createSembleClient } from '@/services/apiClient'; 20 20 21 21 export default function LoginForm() { 22 22 const router = useRouter(); ··· 28 28 const [error, setError] = useState(''); 29 29 30 30 const isExtensionLogin = searchParams.get('extension-login') === 'true'; 31 - const apiClient = new ApiClient( 32 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 33 - ); 31 + const client = createSembleClient(); 34 32 35 33 const handleExtensionTokenGeneration = async () => { 36 34 try { 37 35 setIsLoading(true); 38 - const tokens = await apiClient.generateExtensionTokens(); 36 + const tokens = await client.generateExtensionTokens(); 39 37 40 38 await ExtensionService.sendTokensToExtension(tokens); 41 39 ··· 82 80 ExtensionService.setExtensionTokensRequested(); 83 81 } 84 82 console.log('HANDLE', form.values.handle.trimEnd()); 85 - const { authUrl } = await apiClient.initiateOAuthSignIn({ 83 + const { authUrl } = await client.initiateOAuthSignIn({ 86 84 handle: form.values.handle.trimEnd(), 87 85 }); 88 86 ··· 105 103 setIsLoading(true); 106 104 setError(''); 107 105 108 - await apiClient.loginWithAppPassword({ 106 + await client.loginWithAppPassword({ 109 107 identifier: form.values.handle.trimEnd(), 110 108 appPassword: form.values.appPassword, 111 109 });
+3 -5
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 38 38 centered 39 39 onClick={(e) => e.stopPropagation()} 40 40 > 41 - {props.isOpen && ( 42 - <Suspense fallback={<CollectionSelectorSkeleton />}> 43 - <AddCardToModalContent {...props} /> 44 - </Suspense> 45 - )} 41 + <Suspense fallback={<CollectionSelectorSkeleton />}> 42 + <AddCardToModalContent {...props} /> 43 + </Suspense> 46 44 </Modal> 47 45 ); 48 46 }
+4 -1
src/webapp/features/cards/components/addCardToModal/AddCardToModalContent.tsx
··· 50 50 const [selectedCollections, setSelectedCollections] = 51 51 useState<SelectableCollectionItem[]>(collectionsWithCard); 52 52 53 + const isSaving = addCard.isPending || updateCardAssociations.isPending; 54 + 53 55 const handleUpdateCard = (e: React.FormEvent) => { 54 56 e.preventDefault(); 55 57 ··· 123 125 <Stack justify="space-between"> 124 126 <CardToBeAddedPreview 125 127 cardContent={props.cardContent} 126 - note={isMyCard ? note : undefined} 128 + note={isMyCard ? note : cardStatus.data.card?.note?.text} 127 129 onUpdateNote={setNote} 128 130 /> 129 131 ··· 135 137 setSelectedCollections(collectionsWithCard); 136 138 }} 137 139 onSave={handleUpdateCard} 140 + isSaving={isSaving} 138 141 selectedCollections={selectedCollections} 139 142 onSelectedCollectionsChange={setSelectedCollections} 140 143 />
+2 -2
src/webapp/features/cards/components/cardToBeAddedPreview/CardToBeAddedPreview.tsx
··· 38 38 <Stack gap={'xs'}> 39 39 <Textarea 40 40 id="note" 41 - label="Note" 41 + label="Your note" 42 42 placeholder="Add a note about this card" 43 43 variant="filled" 44 44 size="md" ··· 115 115 setNoteMode(true); 116 116 }} 117 117 > 118 - {note ? 'Update note' : 'Add note'} 118 + {note ? 'Edit note' : 'Add note'} 119 119 </Button> 120 120 </Group> 121 121 </Stack>
+2 -7
src/webapp/features/cards/components/urlCard/UrlCard.tsx
··· 61 61 target="_blank" 62 62 c={'gray'} 63 63 lineClamp={1} 64 + w={'fit-content'} 64 65 > 65 66 {domain} 66 67 </Anchor> 67 68 </Tooltip> 68 69 {props.cardContent.title && ( 69 - <Text 70 - fw={500} 71 - lineClamp={2} 72 - style={{ 73 - wordBreak: 'break-word', 74 - }} 75 - > 70 + <Text fw={500} lineClamp={2}> 76 71 {props.cardContent.title} 77 72 </Text> 78 73 )}
+8 -24
src/webapp/features/cards/components/urlCardActions/UrlCardActions.tsx
··· 1 1 'use client'; 2 2 3 3 import type { UrlCard, Collection, User } from '@/api-client'; 4 - import EditNoteDrawer from '@/features/notes/components/editNoteDrawer/EditNoteDrawer'; 5 4 import { ActionIcon, Button, Group, Menu } from '@mantine/core'; 6 5 import { Fragment, useState } from 'react'; 7 - import { AiOutlineSignature } from 'react-icons/ai'; 8 - import { BiPlus } from 'react-icons/bi'; 6 + import { FiPlus } from 'react-icons/fi'; 9 7 import { BsThreeDots, BsTrash2Fill } from 'react-icons/bs'; 10 8 import { LuUnplug } from 'react-icons/lu'; 11 9 import RemoveCardFromCollectionModal from '../removeCardFromCollectionModal/RemoveCardFromCollectionModal'; ··· 29 27 } 30 28 31 29 export default function UrlCardActions(props: Props) { 32 - const { user } = useAuth(); 30 + const { isAuthenticated, user } = useAuth(); 33 31 // assume the current user is the card owner if authorHandle isn't passed 34 32 const isAuthor = props.authorHandle 35 33 ? user?.handle === props.authorHandle ··· 42 40 useState(false); 43 41 const [showAddToModal, setShowAddToModal] = useState(false); 44 42 43 + if (!isAuthenticated) { 44 + return null; 45 + } 46 + 45 47 return ( 46 48 <Fragment> 47 49 <Group ··· 60 62 props.urlIsInLibrary ? ( 61 63 <IoMdCheckmark size={18} /> 62 64 ) : ( 63 - <BiPlus size={18} /> 65 + <FiPlus size={18} /> 64 66 ) 65 67 } 66 68 onClick={() => { ··· 90 92 </ActionIcon> 91 93 </Menu.Target> 92 94 <Menu.Dropdown> 93 - {props.note && ( 94 - <Menu.Item 95 - leftSection={<AiOutlineSignature />} 96 - onClick={() => { 97 - setShowEditNoteModal(true); 98 - }} 99 - > 100 - Edit note 101 - </Menu.Item> 102 - )} 103 95 {props.currentCollection && ( 104 96 <Menu.Item 105 97 leftSection={<LuUnplug />} ··· 138 130 isOpen={showNoteModal} 139 131 onClose={() => setShowNoteModal(false)} 140 132 note={props.note} 141 - urlCardContent={props.cardContent} 133 + cardContent={props.cardContent} 142 134 cardAuthor={props.cardAuthor} 143 135 /> 144 136 145 - {props.note && ( 146 - <EditNoteDrawer 147 - isOpen={showEditNoteModal} 148 - onClose={() => setShowEditNoteModal(false)} 149 - noteCardId={props.note.id} 150 - note={props.note.text} 151 - /> 152 - )} 153 137 {props.currentCollection && ( 154 138 <RemoveCardFromCollectionModal 155 139 isOpen={showRemoveFromCollectionModal}
+14
src/webapp/features/cards/lib/cardKeys.ts
··· 1 + export const cardKeys = { 2 + all: () => ['cards'] as const, 3 + card: (id: string) => [...cardKeys.all(), id] as const, 4 + byUrl: (url: string) => [...cardKeys.all(), url] as const, 5 + mine: () => [...cardKeys.all(), 'mine'] as const, 6 + search: (query: string) => [...cardKeys.all(), 'search', query], 7 + bySembleUrl: (url: string) => [...cardKeys.all(), url], 8 + libraries: (id: string) => [...cardKeys.all(), 'libraries', id], 9 + infinite: (didOrHandle?: string) => [ 10 + ...cardKeys.all(), 11 + 'infinite', 12 + didOrHandle, 13 + ], 14 + };
+96
src/webapp/features/cards/lib/dal.ts
··· 1 + import { verifySessionOnClient } from '@/lib/auth/dal'; 1 2 import { createSembleClient } from '@/services/apiClient'; 2 3 import { cache } from 'react'; 4 + 5 + interface PageParams { 6 + page?: number; 7 + limit?: number; 8 + } 3 9 4 10 export const getUrlMetadata = cache(async (url: string) => { 5 11 const client = createSembleClient(); ··· 9 15 }); 10 16 11 17 export const getCardFromMyLibrary = cache(async (url: string) => { 18 + const session = await verifySessionOnClient(); 19 + if (!session) throw new Error('No session found'); 12 20 const client = createSembleClient(); 13 21 const response = await client.getUrlStatusForMyLibrary({ url: url }); 14 22 15 23 return response; 16 24 }); 25 + 26 + export const getMyUrlCards = cache(async (params?: PageParams) => { 27 + const session = await verifySessionOnClient(); 28 + if (!session) throw new Error('No session found'); 29 + const client = createSembleClient(); 30 + const response = await client.getMyUrlCards({ 31 + page: params?.page, 32 + limit: params?.limit, 33 + }); 34 + 35 + return response; 36 + }); 37 + 38 + export const addUrlToLibrary = cache( 39 + async ( 40 + url: string, 41 + { note, collectionIds }: { note?: string; collectionIds?: string[] }, 42 + ) => { 43 + const session = await verifySessionOnClient(); 44 + if (!session) throw new Error('No session found'); 45 + const client = createSembleClient(); 46 + const response = await client.addUrlToLibrary({ 47 + url: url, 48 + note: note, 49 + collectionIds: collectionIds, 50 + }); 51 + 52 + return response; 53 + }, 54 + ); 55 + 56 + export const getUrlCardView = cache(async (id: string) => { 57 + const client = createSembleClient(); 58 + const response = await client.getUrlCardView(id); 59 + 60 + return response; 61 + }); 62 + 63 + export const getUrlCards = cache( 64 + async (didOrHandle: string, params?: PageParams) => { 65 + const client = createSembleClient(); 66 + const response = await client.getUrlCards({ 67 + identifier: didOrHandle, 68 + page: params?.page, 69 + limit: params?.limit, 70 + }); 71 + 72 + return response; 73 + }, 74 + ); 75 + 76 + export const removeCardFromCollection = cache( 77 + async ({ 78 + cardId, 79 + collectionIds, 80 + }: { 81 + cardId: string; 82 + collectionIds: string[]; 83 + }) => { 84 + const session = await verifySessionOnClient(); 85 + if (!session) throw new Error('No session found'); 86 + const client = createSembleClient(); 87 + const response = await client.removeCardFromCollection({ 88 + cardId, 89 + collectionIds, 90 + }); 91 + 92 + return response; 93 + }, 94 + ); 95 + 96 + export const removeCardFromLibrary = cache(async (cardId: string) => { 97 + const session = await verifySessionOnClient(); 98 + if (!session) throw new Error('No session found'); 99 + const client = createSembleClient(); 100 + const response = await client.removeCardFromLibrary({ cardId }); 101 + 102 + return response; 103 + }); 104 + 105 + export const getLibrariesForCard = cache(async (cardId: string) => { 106 + const session = await verifySessionOnClient(); 107 + if (!session) throw new Error('No session found'); 108 + const client = createSembleClient(); 109 + const response = await client.getLibrariesForCard(cardId); 110 + 111 + return response; 112 + });
+14 -12
src/webapp/features/cards/lib/mutations/useAddCard.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { addUrlToLibrary } from '../dal'; 3 + import { cardKeys } from '../cardKeys'; 4 + import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 3 5 4 6 export default function useAddCard() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 7 const queryClient = useQueryClient(); 10 8 11 9 const mutation = useMutation({ ··· 14 12 note?: string; 15 13 collectionIds?: string[]; 16 14 }) => { 17 - return apiClient.addUrlToLibrary({ 18 - url: newCard.url, 15 + return addUrlToLibrary(newCard.url, { 19 16 note: newCard.note, 20 17 collectionIds: newCard.collectionIds, 21 18 }); ··· 25 22 // Do UI related things like redirects or showing toast notifications in mutate callbacks. If the user navigated away from the current screen before the mutation finished, those will purposefully not fire 26 23 // https://tkdodo.eu/blog/mastering-mutations-in-react-query#some-callbacks-might-not-fire 27 24 onSuccess: (_data, variables) => { 28 - queryClient.invalidateQueries({ queryKey: ['my cards'] }); 29 - queryClient.invalidateQueries({ queryKey: ['home'] }); 30 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 31 - queryClient.invalidateQueries({ queryKey: ['collection'] }); 25 + queryClient.invalidateQueries({ queryKey: cardKeys.mine() }); 26 + queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 27 + queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 28 + queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 29 + queryClient.invalidateQueries({ 30 + queryKey: collectionKeys.bySembleUrl(variables.url), 31 + }); 32 32 33 33 // invalidate each collection query individually 34 34 variables.collectionIds?.forEach((id) => { 35 - queryClient.invalidateQueries({ queryKey: ['collection', id] }); 35 + queryClient.invalidateQueries({ 36 + queryKey: collectionKeys.collection(id), 37 + }); 36 38 }); 37 39 }, 38 40 });
-39
src/webapp/features/cards/lib/mutations/useAddCardToLibrary.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 - import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 - 4 - export default function useAddCardToLibrary() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 - const queryClient = useQueryClient(); 10 - 11 - const mutation = useMutation({ 12 - mutationFn: ({ 13 - url, 14 - note, 15 - collectionIds, 16 - }: { 17 - url: string; 18 - note?: string; 19 - collectionIds: string[]; 20 - }) => { 21 - return apiClient.addUrlToLibrary({ url, note, collectionIds }); 22 - }, 23 - 24 - onSuccess: (data, variables) => { 25 - queryClient.invalidateQueries({ queryKey: ['card', data.urlCardId] }); 26 - queryClient.invalidateQueries({ queryKey: ['card', data.noteCardId] }); 27 - queryClient.invalidateQueries({ queryKey: ['card', variables.url] }); 28 - queryClient.invalidateQueries({ queryKey: ['my cards'] }); 29 - queryClient.invalidateQueries({ queryKey: ['home'] }); 30 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 31 - 32 - variables.collectionIds.forEach((id) => { 33 - queryClient.invalidateQueries({ queryKey: ['collection', id] }); 34 - }); 35 - }, 36 - }); 37 - 38 - return mutation; 39 - }
+8 -11
src/webapp/features/cards/lib/mutations/useRemoveCardFromCollections.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { removeCardFromCollection } from '../dal'; 3 + import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 3 4 4 5 export default function useRemoveCardFromCollections() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 6 const queryClient = useQueryClient(); 10 7 11 8 const mutation = useMutation({ ··· 16 13 cardId: string; 17 14 collectionIds: string[]; 18 15 }) => { 19 - return apiClient.removeCardFromCollection({ cardId, collectionIds }); 16 + return removeCardFromCollection({ cardId, collectionIds }); 20 17 }, 21 18 22 19 onSuccess: (_data, variables) => { 23 - queryClient.invalidateQueries({ queryKey: ['card', variables.cardId] }); 24 - queryClient.invalidateQueries({ queryKey: ['my cards'] }); 25 - queryClient.invalidateQueries({ queryKey: ['home'] }); 26 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 20 + queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 21 + queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 27 22 28 23 variables.collectionIds.forEach((id) => { 29 - queryClient.invalidateQueries({ queryKey: ['collection', id] }); 24 + queryClient.invalidateQueries({ 25 + queryKey: collectionKeys.collection(id), 26 + }); 30 27 }); 31 28 }, 32 29 });
+6 -9
src/webapp/features/cards/lib/mutations/useRemoveCardFromLibrary.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { removeCardFromLibrary } from '../dal'; 3 + import { cardKeys } from '../cardKeys'; 4 + import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 3 5 4 6 export default function useRemoveCardFromLibrary() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 7 const queryClient = useQueryClient(); 10 8 11 9 const mutation = useMutation({ 12 10 mutationFn: (cardId: string) => { 13 - return apiClient.removeCardFromLibrary({ cardId }); 11 + return removeCardFromLibrary(cardId); 14 12 }, 15 13 16 14 onSuccess: () => { 17 - queryClient.invalidateQueries({ queryKey: ['my cards'] }); 18 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 19 - queryClient.invalidateQueries({ queryKey: ['collection'] }); 15 + queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 16 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 20 17 }, 21 18 }); 22 19
+15 -8
src/webapp/features/cards/lib/mutations/useUpdateCardAssociations.tsx
··· 1 1 import { createSembleClient } from '@/services/apiClient'; 2 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 + import { cardKeys } from '../cardKeys'; 4 + import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 + import { noteKeys } from '@/features/notes/lib/noteKeys'; 6 + import { sembleKeys } from '@/features/semble/lib/sembleKeys'; 3 7 4 8 export default function useUpdateCardAssociations() { 5 9 const client = createSembleClient(); ··· 22 26 }, 23 27 24 28 onSuccess: (_data, variables) => { 25 - queryClient.invalidateQueries({ queryKey: ['my cards'] }); 26 - queryClient.invalidateQueries({ queryKey: ['home'] }); 27 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 28 - queryClient.invalidateQueries({ 29 - queryKey: ['card from my library'], 30 - }); 29 + queryClient.invalidateQueries({ queryKey: cardKeys.all() }); 30 + queryClient.invalidateQueries({ queryKey: noteKeys.all() }); 31 + queryClient.invalidateQueries({ queryKey: sembleKeys.all() }); 32 + queryClient.invalidateQueries({ queryKey: collectionKeys.mine() }); 33 + queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 31 34 32 35 // invalidate each collection query individually 33 36 variables.addToCollectionIds?.forEach((id) => { 34 - queryClient.invalidateQueries({ queryKey: ['collection', id] }); 37 + queryClient.invalidateQueries({ 38 + queryKey: collectionKeys.collection(id), 39 + }); 35 40 }); 36 41 37 42 variables.removeFromCollectionIds?.forEach((id) => { 38 - queryClient.invalidateQueries({ queryKey: ['collection', id] }); 43 + queryClient.invalidateQueries({ 44 + queryKey: collectionKeys.collection(id), 45 + }); 39 46 }); 40 47 }, 41 48 });
+4 -8
src/webapp/features/cards/lib/queries/useCards.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 + import { getUrlCards } from '../dal'; 3 + import { cardKeys } from '../cardKeys'; 3 4 4 5 interface Props { 5 6 didOrHandle: string; ··· 7 8 } 8 9 9 10 export default function useCards(props: Props) { 10 - const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 12 - ); 13 - 14 11 const limit = props?.limit ?? 16; 15 12 16 13 const cards = useSuspenseInfiniteQuery({ 17 - queryKey: ['cards', props.didOrHandle, limit], 14 + queryKey: cardKeys.infinite(props.didOrHandle), 18 15 initialPageParam: 1, 19 16 queryFn: ({ pageParam = 1 }) => { 20 - return apiClient.getUrlCards({ 17 + return getUrlCards(props.didOrHandle, { 21 18 limit, 22 19 page: pageParam, 23 - identifier: props.didOrHandle, 24 20 }); 25 21 }, 26 22 getNextPageParam: (lastPage) => {
+4 -7
src/webapp/features/cards/lib/queries/useGetCard.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseQuery } from '@tanstack/react-query'; 2 + import { getUrlCardView } from '../dal'; 3 + import { cardKeys } from '../cardKeys'; 3 4 4 5 interface Props { 5 6 id: string; 6 7 } 7 8 8 9 export default function useGetCard(props: Props) { 9 - const apiClient = new ApiClient( 10 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 11 - ); 12 - 13 10 const card = useSuspenseQuery({ 14 - queryKey: ['card', props.id], 15 - queryFn: () => apiClient.getUrlCardView(props.id), 11 + queryKey: cardKeys.card(props.id), 12 + queryFn: () => getUrlCardView(props.id), 16 13 }); 17 14 18 15 return card;
+2 -2
src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx
··· 1 1 import { useSuspenseQuery } from '@tanstack/react-query'; 2 2 import { getCardFromMyLibrary } from '../dal'; 3 + import { cardKeys } from '../cardKeys'; 3 4 4 5 interface Props { 5 6 url: string; ··· 7 8 8 9 export default function useGetCardFromMyLibrary(props: Props) { 9 10 const cardStatus = useSuspenseQuery({ 10 - queryKey: ['card from my library', props.url], 11 + queryKey: cardKeys.byUrl(props.url), 11 12 queryFn: () => getCardFromMyLibrary(props.url), 12 13 }); 13 - 14 14 return cardStatus; 15 15 }
+4 -7
src/webapp/features/cards/lib/queries/useGetLibrariesForcard.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseQuery } from '@tanstack/react-query'; 2 + import { getLibrariesForCard } from '../dal'; 3 + import { cardKeys } from '../cardKeys'; 3 4 4 5 interface Props { 5 6 id: string; 6 7 } 7 8 8 9 export default function useGetLibrariesForCard(props: Props) { 9 - const apiClient = new ApiClient( 10 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 11 - ); 12 - 13 10 const libraries = useSuspenseQuery({ 14 - queryKey: ['libraries for card', props.id], 15 - queryFn: () => apiClient.getLibrariesForCard(props.id), 11 + queryKey: cardKeys.libraries(props.id), 12 + queryFn: () => getLibrariesForCard(props.id), 16 13 }); 17 14 18 15 return libraries;
+4 -7
src/webapp/features/cards/lib/queries/useMyCards.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 + import { getMyUrlCards } from '../dal'; 3 + import { cardKeys } from '../cardKeys'; 3 4 4 5 interface Props { 5 6 limit?: number; 6 7 } 7 8 8 9 export default function useMyCards(props?: Props) { 9 - const apiClient = new ApiClient( 10 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 11 - ); 12 - 13 10 const limit = props?.limit ?? 16; 14 11 15 12 const myCards = useSuspenseInfiniteQuery({ 16 - queryKey: ['my cards', limit], 13 + queryKey: cardKeys.mine(), 17 14 initialPageParam: 1, 18 15 queryFn: ({ pageParam = 1 }) => { 19 - return apiClient.getMyUrlCards({ limit, page: pageParam }); 16 + return getMyUrlCards({ page: pageParam, limit }); 20 17 }, 21 18 getNextPageParam: (lastPage) => { 22 19 if (lastPage.pagination.hasMore) {
+9 -2
src/webapp/features/collections/components/collectionActions/CollectionActions.tsx
··· 15 15 } 16 16 17 17 export default function CollectionActions(props: Props) { 18 - const { user } = useAuth(); 18 + const { isAuthenticated, user } = useAuth(); 19 19 const [showEditDrawer, setShowEditDrawer] = useState(false); 20 20 const [showDeleteModal, setShowDeleteModal] = useState(false); 21 21 22 22 const isAuthor = user?.handle === props.authorHandle; 23 - const shareLink = `${window.location.origin}/profile/${props.authorHandle}/collections/${props.rkey}`; 23 + const shareLink = 24 + typeof window !== 'undefined' 25 + ? `${window.location.origin}/profile/${props.authorHandle}/collections/${props.rkey}` 26 + : ''; 27 + 28 + if (!isAuthenticated) { 29 + return null; 30 + } 24 31 25 32 return ( 26 33 <Group>
+2 -1
src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx
··· 27 27 onClose: () => void; 28 28 onCancel: () => void; 29 29 onSave: (e: React.FormEvent) => void; 30 + isSaving?: boolean; 30 31 selectedCollections: SelectableCollectionItem[]; 31 32 onSelectedCollectionsChange: ( 32 33 collectionIds: SelectableCollectionItem[], ··· 203 204 204 205 <Button 205 206 size="md" 207 + loading={props.isSaving} 206 208 onClick={(e) => { 207 209 props.onSave(e); 208 - props.onClose(); 209 210 }} 210 211 > 211 212 Save
+1 -8
src/webapp/features/collections/components/collectionSelectorItem/CollectionSelectorItem.tsx
··· 41 41 > 42 42 <Group justify="space-between" wrap="nowrap"> 43 43 <Group gap={'xs'} wrap="nowrap"> 44 - <Text 45 - fw={500} 46 - lineClamp={1} 47 - flex={1} 48 - style={{ 49 - wordBreak: 'break-all', 50 - }} 51 - > 44 + <Text fw={500} lineClamp={1} flex={1}> 52 45 {props.name} 53 46 </Text> 54 47 <Text c={'gray'}>·</Text>
+1 -3
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
··· 59 59 <Text fw={700} c="grape"> 60 60 Collection 61 61 </Text> 62 - <Title order={1} style={{ wordBreak: 'break-all' }}> 63 - {firstPage.name} 64 - </Title> 62 + <Title order={1}>{firstPage.name}</Title> 65 63 {firstPage.description && ( 66 64 <Text c="gray" mt="lg"> 67 65 {firstPage.description}
+8
src/webapp/features/collections/lib/collectionKeys.ts
··· 1 + export const collectionKeys = { 2 + all: () => ['collections'] as const, 3 + collection: (id: string) => [...collectionKeys.all(), id] as const, 4 + mine: () => [...collectionKeys.all(), 'mine'] as const, 5 + search: (query: string) => [...collectionKeys.all(), 'search', query], 6 + bySembleUrl: (url: string) => [...collectionKeys.all(), url], 7 + infinite: (id?: string) => [...collectionKeys.all(), 'infinite', id], 8 + };
+90 -5
src/webapp/features/collections/lib/dal.ts
··· 1 + import { verifySessionOnClient } from '@/lib/auth/dal'; 1 2 import { createSembleClient } from '@/services/apiClient'; 2 3 import { cache } from 'react'; 3 4 ··· 6 7 limit?: number; 7 8 } 8 9 10 + interface SearchParams { 11 + sortBy?: string; 12 + sortOrder?: 'asc' | 'desc'; 13 + searchText?: string; 14 + } 15 + 9 16 export const getCollectionsForUrl = cache( 10 17 async (url: string, params?: PageParams) => { 11 18 const client = createSembleClient(); ··· 19 26 }, 20 27 ); 21 28 22 - export const getMyCollections = cache(async (params?: PageParams) => { 29 + export const getCollections = cache( 30 + async (didOrHandle: string, params?: PageParams) => { 31 + const client = createSembleClient(); 32 + const response = await client.getCollections({ 33 + identifier: didOrHandle, 34 + limit: params?.limit, 35 + page: params?.page, 36 + }); 37 + 38 + return response; 39 + }, 40 + ); 41 + 42 + export const getMyCollections = cache( 43 + async (params?: PageParams & SearchParams) => { 44 + const session = await verifySessionOnClient(); 45 + if (!session) throw new Error('No session found'); 46 + const client = createSembleClient(); 47 + const response = await client.getMyCollections({ 48 + page: params?.page, 49 + limit: params?.limit, 50 + sortBy: params?.sortBy, 51 + sortOrder: params?.sortOrder, 52 + searchText: params?.searchText, 53 + }); 54 + 55 + return response; 56 + }, 57 + ); 58 + 59 + export const createCollection = cache( 60 + async (newCollection: { name: string; description: string }) => { 61 + const session = await verifySessionOnClient(); 62 + if (!session) throw new Error('No session found'); 63 + const client = createSembleClient(); 64 + const response = await client.createCollection(newCollection); 65 + 66 + return response; 67 + }, 68 + ); 69 + 70 + export const deleteCollection = cache(async (id: string) => { 71 + const session = await verifySessionOnClient(); 72 + if (!session) throw new Error('No session found'); 23 73 const client = createSembleClient(); 24 - const response = await client.getMyCollections({ 25 - page: params?.page, 26 - limit: params?.limit, 27 - }); 74 + const response = await client.deleteCollection({ collectionId: id }); 28 75 29 76 return response; 30 77 }); 78 + 79 + export const updateCollection = cache( 80 + async (collection: { 81 + collectionId: string; 82 + rkey: string; 83 + name: string; 84 + description?: string; 85 + }) => { 86 + const session = await verifySessionOnClient(); 87 + if (!session) throw new Error('No session found'); 88 + const client = createSembleClient(); 89 + const response = await client.updateCollection(collection); 90 + 91 + return response; 92 + }, 93 + ); 94 + 95 + export const getCollectionPageByAtUri = cache( 96 + async ({ 97 + recordKey, 98 + handle, 99 + params, 100 + }: { 101 + recordKey: string; 102 + handle: string; 103 + params?: PageParams; 104 + }) => { 105 + const client = createSembleClient(); 106 + const response = await client.getCollectionPageByAtUri({ 107 + recordKey, 108 + handle, 109 + page: params?.page, 110 + limit: params?.limit, 111 + }); 112 + 113 + return response; 114 + }, 115 + );
-35
src/webapp/features/collections/lib/mutations/useAddCardToCollection.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 - import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 - 4 - export default function useAddCardToCollection() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 - const queryClient = useQueryClient(); 10 - 11 - const mutation = useMutation({ 12 - mutationFn: ({ 13 - cardId, 14 - collectionIds, 15 - }: { 16 - cardId: string; 17 - collectionIds: string[]; 18 - }) => { 19 - return apiClient.addCardToCollection({ cardId, collectionIds }); 20 - }, 21 - 22 - onSuccess: (_data, variables) => { 23 - queryClient.invalidateQueries({ queryKey: ['card', variables.cardId] }); 24 - queryClient.invalidateQueries({ queryKey: ['my cards'] }); 25 - queryClient.invalidateQueries({ queryKey: ['home'] }); 26 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 27 - 28 - variables.collectionIds.forEach((id) => { 29 - queryClient.invalidateQueries({ queryKey: ['collection', id] }); 30 - }); 31 - }, 32 - }); 33 - 34 - return mutation; 35 - }
+5 -7
src/webapp/features/collections/lib/mutations/useCreateCollection.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { createCollection } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 export default function useCreateCollection() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 6 const queryClient = useQueryClient(); 10 7 11 8 const mutation = useMutation({ 12 9 mutationFn: (newCollection: { name: string; description: string }) => { 13 - return apiClient.createCollection(newCollection); 10 + return createCollection(newCollection); 14 11 }, 15 12 16 13 // Do things that are absolutely necessary and logic related (like query invalidation) in the useMutation callbacks 17 14 // Do UI related things like redirects or showing toast notifications in mutate callbacks. If the user navigated away from the current screen before the mutation finished, those will purposefully not fire 18 15 // https://tkdodo.eu/blog/mastering-mutations-in-react-query#some-callbacks-might-not-fire 19 16 onSuccess: () => { 20 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 17 + queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() }); 18 + queryClient.refetchQueries({ queryKey: collectionKeys.mine() }); 21 19 }, 22 20 }); 23 21
+5 -11
src/webapp/features/collections/lib/mutations/useDeleteCollection.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { deleteCollection } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 export default function useDeleteCollection() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 6 const queryClient = useQueryClient(); 10 7 11 8 const mutation = useMutation({ 12 9 mutationFn: (collectionId: string) => { 13 - return apiClient.deleteCollection({ collectionId }); 10 + return deleteCollection(collectionId); 14 11 }, 15 12 16 - onSuccess: (data) => { 17 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 18 - queryClient.invalidateQueries({ 19 - queryKey: ['collection', data.collectionId], 20 - }); 13 + onSuccess: () => { 14 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 21 15 }, 22 16 }); 23 17
+6 -10
src/webapp/features/collections/lib/mutations/useUpdateCollection.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { updateCollection } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 export default function useUpdateCollection() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 6 const queryClient = useQueryClient(); 10 7 11 8 const mutation = useMutation({ ··· 15 12 name: string; 16 13 description?: string; 17 14 }) => { 18 - return apiClient.updateCollection(collection); 15 + return updateCollection(collection); 19 16 }, 20 17 21 - onSuccess: (data, variables) => { 22 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 18 + onSuccess: (variables) => { 23 19 queryClient.invalidateQueries({ 24 - queryKey: ['collection', data.collectionId], 20 + queryKey: collectionKeys.collection(variables.collectionId), 25 21 }); 26 22 queryClient.invalidateQueries({ 27 - queryKey: ['collection', variables.rkey], 23 + queryKey: collectionKeys.all(), 28 24 }); 29 25 }, 30 26 });
+5 -7
src/webapp/features/collections/lib/queries/useCollection.tsx
··· 1 - import { createSembleClient } from '@/services/apiClient'; 2 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 + import { getCollectionPageByAtUri } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 interface Props { 5 6 rkey: string; ··· 8 9 } 9 10 10 11 export default function useCollection(props: Props) { 11 - const apiClient = createSembleClient(); 12 - 13 12 const limit = props.limit ?? 20; 14 13 15 14 return useSuspenseInfiniteQuery({ 16 - queryKey: ['collection', props.rkey, props.handle, limit], 15 + queryKey: collectionKeys.infinite(props.rkey), 17 16 initialPageParam: 1, 18 17 queryFn: ({ pageParam }) => 19 - apiClient.getCollectionPageByAtUri({ 18 + getCollectionPageByAtUri({ 20 19 recordKey: props.rkey, 21 20 handle: props.handle, 22 - limit, 23 - page: pageParam, 21 + params: { limit, page: pageParam }, 24 22 }), 25 23 getNextPageParam: (lastPage) => { 26 24 return lastPage.pagination.hasMore
+4 -7
src/webapp/features/collections/lib/queries/useCollectionSearch.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useQuery } from '@tanstack/react-query'; 2 + import { getMyCollections } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 interface Props { 5 6 query: string; ··· 9 10 } 10 11 11 12 export default function useCollectionSearch(props: Props) { 12 - const apiClient = new ApiClient( 13 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 14 - ); 15 - 16 13 // TODO: replace with infinite suspense query 17 14 const collections = useQuery({ 18 - queryKey: ['collection search', props.query, props.params?.limit], 15 + queryKey: collectionKeys.search(props.query), 19 16 queryFn: () => 20 - apiClient.getMyCollections({ 17 + getMyCollections({ 21 18 limit: props.params?.limit ?? 10, 22 19 sortBy: 'updatedAt', 23 20 sortOrder: 'desc',
+4 -8
src/webapp/features/collections/lib/queries/useCollections.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 + import { getCollections } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 interface Props { 5 6 didOrHandle: string; ··· 7 8 } 8 9 9 10 export default function useCollections(props: Props) { 10 - const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 12 - ); 13 - 14 11 const limit = props?.limit ?? 15; 15 12 16 13 return useSuspenseInfiniteQuery({ 17 - queryKey: ['collections', props.didOrHandle, limit], 14 + queryKey: collectionKeys.infinite(props.didOrHandle), 18 15 initialPageParam: 1, 19 16 queryFn: ({ pageParam }) => 20 - apiClient.getCollections({ 17 + getCollections(props.didOrHandle, { 21 18 limit, 22 19 page: pageParam, 23 - identifier: props.didOrHandle, 24 20 }), 25 21 getNextPageParam: (lastPage) => { 26 22 return lastPage.pagination.hasMore
+2 -1
src/webapp/features/collections/lib/queries/useMyCollections.tsx
··· 1 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 2 import { getMyCollections } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 interface Props { 5 6 limit?: number; ··· 9 10 const limit = props?.limit ?? 15; 10 11 11 12 return useSuspenseInfiniteQuery({ 12 - queryKey: ['collections', limit], 13 + queryKey: collectionKeys.mine(), 13 14 initialPageParam: 1, 14 15 queryFn: ({ pageParam }) => getMyCollections({ limit, page: pageParam }), 15 16 getNextPageParam: (lastPage) => {
+2 -1
src/webapp/features/collections/lib/queries/useSembleCollectionts.tsx
··· 1 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 2 import { getCollectionsForUrl } from '../dal'; 3 + import { collectionKeys } from '../collectionKeys'; 3 4 4 5 interface Props { 5 6 url: string; ··· 10 11 const limit = props?.limit ?? 16; 11 12 12 13 const collections = useSuspenseInfiniteQuery({ 13 - queryKey: ['semble collections', props.url, limit], 14 + queryKey: collectionKeys.bySembleUrl(props.url), 14 15 initialPageParam: 1, 15 16 queryFn: ({ pageParam = 1 }) => { 16 17 return getCollectionsForUrl(props.url, { page: pageParam, limit });
+6 -1
src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
··· 15 15 return ( 16 16 <Fragment key={shouldShowFab.toString()}> 17 17 {shouldShowFab && ( 18 - <Affix m={{ base: 'lg', sm: 'xs' }} style={{ zIndex: 102 }}> 18 + <Affix 19 + mt={'md'} 20 + mx={{ base: 20, sm: 'xs' }} 21 + mb={{ base: 100, sm: 'md' }} 22 + style={{ zIndex: 102 }} 23 + > 19 24 <ActionIcon 20 25 size="input-xl" 21 26 radius="xl"
+2 -2
src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx
··· 1 1 'use client'; 2 2 3 - import useMyFeed from '@/features/feeds/lib/queries/useMyFeed'; 3 + import useGlobalFeed from '@/features/feeds/lib/queries/useGlobalFeed'; 4 4 import FeedItem from '@/features/feeds/components/feedItem/FeedItem'; 5 5 import { Stack, Title, Text, Center, Container } from '@mantine/core'; 6 6 import MyFeedContainerSkeleton from './Skeleton.MyFeedContainer'; ··· 15 15 fetchNextPage, 16 16 hasNextPage, 17 17 isFetchingNextPage, 18 - } = useMyFeed(); 18 + } = useGlobalFeed(); 19 19 20 20 const allActivities = 21 21 data?.pages.flatMap((page) => page.activities ?? []) ?? [];
+17
src/webapp/features/feeds/lib/dal.ts
··· 1 + import { createSembleClient } from '@/services/apiClient'; 2 + import { cache } from 'react'; 3 + 4 + interface PageParams { 5 + page?: number; 6 + limit?: number; 7 + } 8 + 9 + export const getGlobalFeed = cache(async (params?: PageParams) => { 10 + const client = createSembleClient(); 11 + const response = await client.getGlobalFeed({ 12 + page: params?.page, 13 + limit: params?.limit, 14 + }); 15 + 16 + return response; 17 + });
+4
src/webapp/features/feeds/lib/feedKeys.ts
··· 1 + export const feedKeys = { 2 + all: () => ['feeds'] as const, 3 + infinite: () => [...feedKeys.all(), 'infinite'], 4 + };
+27
src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx
··· 1 + import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 + import { getGlobalFeed } from '../dal'; 3 + import { feedKeys } from '../feedKeys'; 4 + 5 + interface Props { 6 + limit?: number; 7 + } 8 + 9 + export default function useGlobalFeed(props?: Props) { 10 + const limit = props?.limit ?? 15; 11 + 12 + const query = useSuspenseInfiniteQuery({ 13 + queryKey: feedKeys.infinite(), 14 + initialPageParam: 1, 15 + queryFn: ({ pageParam = 1 }) => { 16 + return getGlobalFeed({ limit, page: pageParam }); 17 + }, 18 + getNextPageParam: (lastPage) => { 19 + if (lastPage.pagination.hasMore) { 20 + return lastPage.pagination.currentPage + 1; 21 + } 22 + return undefined; 23 + }, 24 + }); 25 + 26 + return query; 27 + }
-30
src/webapp/features/feeds/lib/queries/useMyFeed.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 - import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 3 - 4 - interface Props { 5 - limit?: number; 6 - } 7 - 8 - export default function useMyFeed(props?: Props) { 9 - const apiClient = new ApiClient( 10 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 11 - ); 12 - 13 - const limit = props?.limit ?? 15; 14 - 15 - const query = useSuspenseInfiniteQuery({ 16 - queryKey: ['my feed', limit], 17 - initialPageParam: 1, 18 - queryFn: ({ pageParam = 1 }) => { 19 - return apiClient.getGlobalFeed({ limit, page: pageParam }); 20 - }, 21 - getNextPageParam: (lastPage) => { 22 - if (lastPage.pagination.hasMore) { 23 - return lastPage.pagination.currentPage + 1; 24 - } 25 - return undefined; 26 - }, 27 - }); 28 - 29 - return query; 30 - }
+2 -2
src/webapp/features/home/containers/homeContainer/HomeContainer.tsx
··· 26 26 import { useNavbarContext } from '@/providers/navbar'; 27 27 28 28 export default function HomeContainer() { 29 - const { data: collectionsData } = useMyCollections({ limit: 4 }); 30 - const { data: myCardsData } = useMyCards({ limit: 4 }); 29 + const { data: collectionsData } = useMyCollections({ limit: 8 }); 30 + const { data: myCardsData } = useMyCards({ limit: 8 }); 31 31 const { data: profile } = useMyProfile(); 32 32 33 33 const { desktopOpened } = useNavbarContext();
+7 -1
src/webapp/features/notes/components/noteCard/NoteCard.stories.tsx
··· 11 11 export const Primary: Story = { 12 12 args: { 13 13 note: `This is the note's content`, 14 - authorId: 'author id', 14 + author: { 15 + id: 'id', 16 + name: 'name', 17 + handle: 'handle', 18 + avatarUrl: 'avatar url', 19 + description: 'description', 20 + }, 15 21 id: 'note id', 16 22 createdAt: '2025', 17 23 },
+26 -21
src/webapp/features/notes/components/noteCard/NoteCard.tsx
··· 1 - import useProfile from '@/features/profile/lib/queries/useProfile'; 2 - import { getRelativeTime } from '@/lib/utils/time'; 1 + import { User } from '@/api-client/ApiClient'; 3 2 import { Avatar, Card, Group, Spoiler, Stack, Text } from '@mantine/core'; 4 - import { Suspense } from 'react'; 3 + import { getRelativeTime } from '@/lib/utils/time'; 4 + import Link from 'next/link'; 5 5 6 6 interface Props { 7 7 id: string; 8 8 note: string; 9 9 createdAt: string; 10 - authorId: string; 10 + author: User; 11 11 } 12 12 13 13 export default function NoteCard(props: Props) { 14 - const { data: author } = useProfile({ didOrHandle: props.authorId }); 15 14 const time = getRelativeTime(props.createdAt); 16 15 const relativeCreateDate = time === 'just now' ? `${time}` : `${time} ago`; 17 16 ··· 19 18 <Card p={'sm'} radius={'lg'} withBorder> 20 19 <Stack> 21 20 <Spoiler showLabel={'Read more'} hideLabel={'See less'} maxHeight={200}> 22 - <Text>{props.note}</Text> 21 + <Text fs={'italic'}>{props.note}</Text> 23 22 </Spoiler> 24 23 25 - <Suspense fallback={<Text>LOADINGLOADINGLOADING</Text>}> 26 - <Group gap={'xs'}> 27 - <Avatar 28 - src={author.avatarUrl} 29 - alt={`${author.handle}'s avatar`} 30 - size={'sm'} 31 - /> 24 + <Group gap={'xs'}> 25 + <Avatar 26 + component={Link} 27 + href={`/profile/${props.author.handle}`} 28 + src={props.author.avatarUrl} 29 + alt={`${props.author.handle}'s avatar`} 30 + size={'sm'} 31 + /> 32 32 33 - <Text c={'gray'}> 34 - <Text c={'dark'} fw={500} span> 35 - {author.name} 36 - </Text> 37 - <Text span>{' · '}</Text> 38 - <Text span>{relativeCreateDate} </Text> 33 + <Text c={'gray'}> 34 + <Text 35 + component={Link} 36 + href={`/profile/${props.author.handle}`} 37 + c={'dark'} 38 + fw={500} 39 + span 40 + > 41 + {props.author.name} 39 42 </Text> 40 - </Group> 41 - </Suspense> 43 + <Text span>{' · '}</Text> 44 + <Text span>{relativeCreateDate} </Text> 45 + </Text> 46 + </Group> 42 47 </Stack> 43 48 </Card> 44 49 );
+10 -76
src/webapp/features/notes/components/noteCardModal/NoteCardModal.tsx
··· 1 1 import type { UrlCard, User } from '@/api-client'; 2 2 import { getDomain } from '@/lib/utils/link'; 3 3 import { UPDATE_OVERLAY_PROPS } from '@/styles/overlays'; 4 - import { 5 - Anchor, 6 - AspectRatio, 7 - Card, 8 - Group, 9 - Modal, 10 - Stack, 11 - Text, 12 - Image, 13 - Tooltip, 14 - Avatar, 15 - } from '@mantine/core'; 16 - import Link from 'next/link'; 4 + import { Modal, Text } from '@mantine/core'; 5 + import NoteCardModalContent from './NoteCardModalContent'; 6 + import { Suspense } from 'react'; 7 + import NoteCardModalContentSkeleton from './Skeleton.NoteCardModalContent'; 17 8 18 9 interface Props { 19 10 isOpen: boolean; 20 11 onClose: () => void; 21 12 note: UrlCard['note']; 22 - urlCardContent: UrlCard['cardContent']; 13 + cardContent: UrlCard['cardContent']; 23 14 cardAuthor?: User; 24 15 } 25 16 26 17 export default function NoteCardModal(props: Props) { 27 - const domain = getDomain(props.urlCardContent.url); 18 + const domain = getDomain(props.cardContent.url); 28 19 29 20 return ( 30 21 <Modal 31 22 opened={props.isOpen} 32 23 onClose={props.onClose} 33 - title="Note" 24 + title={<Text fw={600}>Note</Text>} 34 25 overlayProps={UPDATE_OVERLAY_PROPS} 35 26 centered 36 27 onClick={(e) => e.stopPropagation()} 37 28 > 38 - <Stack gap={'xs'}> 39 - {props.cardAuthor && ( 40 - <Group gap={5}> 41 - <Avatar 42 - size={'sm'} 43 - component={Link} 44 - href={`/profile/${props.cardAuthor.handle}`} 45 - target="_blank" 46 - src={props.cardAuthor.avatarUrl} 47 - alt={`${props.cardAuthor.name}'s' avatar`} 48 - /> 49 - <Anchor 50 - component={Link} 51 - href={`/profile/${props.cardAuthor.handle}`} 52 - target="_blank" 53 - fw={700} 54 - c="blue" 55 - lineClamp={1} 56 - > 57 - {props.cardAuthor.name} 58 - </Anchor> 59 - </Group> 60 - )} 61 - {props.note && <Text fs={'italic'}>{props.note.text}</Text>} 62 - <Card withBorder p={'xs'} radius={'lg'}> 63 - <Stack> 64 - <Group gap={'sm'}> 65 - {props.urlCardContent.thumbnailUrl && ( 66 - <AspectRatio ratio={1 / 1} flex={0.1}> 67 - <Image 68 - src={props.urlCardContent.thumbnailUrl} 69 - alt={`${props.urlCardContent.url} social preview image`} 70 - radius={'md'} 71 - w={50} 72 - h={50} 73 - /> 74 - </AspectRatio> 75 - )} 76 - <Stack gap={0} flex={0.9}> 77 - <Tooltip label={props.urlCardContent.url}> 78 - <Anchor 79 - component={Link} 80 - href={props.urlCardContent.url} 81 - target="_blank" 82 - c={'gray'} 83 - lineClamp={1} 84 - > 85 - {domain} 86 - </Anchor> 87 - </Tooltip> 88 - {props.urlCardContent.title && ( 89 - <Text fw={500} lineClamp={1}> 90 - {props.urlCardContent.title} 91 - </Text> 92 - )} 93 - </Stack> 94 - </Group> 95 - </Stack> 96 - </Card> 97 - </Stack> 29 + <Suspense fallback={<NoteCardModalContentSkeleton />}> 30 + <NoteCardModalContent {...props} domain={domain} /> 31 + </Suspense> 98 32 </Modal> 99 33 ); 100 34 }
+171
src/webapp/features/notes/components/noteCardModal/NoteCardModalContent.tsx
··· 1 + import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; 2 + import { 3 + Anchor, 4 + AspectRatio, 5 + Avatar, 6 + Card, 7 + Group, 8 + Stack, 9 + Tooltip, 10 + Text, 11 + Image, 12 + Textarea, 13 + Button, 14 + } from '@mantine/core'; 15 + import { UrlCard, User } from '@semble/types'; 16 + import Link from 'next/link'; 17 + import { useState } from 'react'; 18 + import useUpdateNote from '../../lib/mutations/useUpdateNote'; 19 + import { notifications } from '@mantine/notifications'; 20 + 21 + interface Props { 22 + note: UrlCard['note']; 23 + cardContent: UrlCard['cardContent']; 24 + cardAuthor?: User; 25 + domain: string; 26 + } 27 + 28 + export default function NoteCardModalContent(props: Props) { 29 + const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 30 + const isMyCard = props.cardAuthor?.id === cardStatus.data.card?.author.id; 31 + const [note, setNote] = useState(isMyCard ? props.note?.text : ''); 32 + const [editMode, setEditMode] = useState(false); 33 + 34 + const updateNote = useUpdateNote(); 35 + 36 + const handleUpdateNote = () => { 37 + if (!props.note || !note) return; 38 + 39 + updateNote.mutate( 40 + { 41 + cardId: props.note?.id, 42 + note: note, 43 + }, 44 + { 45 + onError: () => { 46 + notifications.show({ 47 + message: 'Could not update note.', 48 + position: 'top-center', 49 + }); 50 + }, 51 + onSettled: () => { 52 + setEditMode(false); 53 + }, 54 + }, 55 + ); 56 + }; 57 + 58 + if (editMode) { 59 + return ( 60 + <Stack gap={'xs'}> 61 + <Textarea 62 + id="note" 63 + label="Your note" 64 + placeholder="Add a note about this card" 65 + variant="filled" 66 + size="md" 67 + autosize 68 + minRows={3} 69 + maxRows={8} 70 + maxLength={500} 71 + value={note} 72 + onChange={(e) => setNote(e.currentTarget.value)} 73 + /> 74 + <Group gap={'xs'} grow> 75 + <Button 76 + variant="light" 77 + color="gray" 78 + onClick={() => { 79 + setEditMode(false); 80 + setNote(props.note?.text); 81 + }} 82 + > 83 + Cancel 84 + </Button> 85 + <Button 86 + onClick={handleUpdateNote} 87 + loading={updateNote.isPending} 88 + disabled={note?.trimEnd() === ''} 89 + > 90 + Save 91 + </Button> 92 + </Group> 93 + </Stack> 94 + ); 95 + } 96 + return ( 97 + <Stack gap={'xs'}> 98 + {props.cardAuthor && ( 99 + <Group gap={5}> 100 + <Avatar 101 + size={'sm'} 102 + component={Link} 103 + href={`/profile/${props.cardAuthor.handle}`} 104 + target="_blank" 105 + src={props.cardAuthor.avatarUrl} 106 + alt={`${props.cardAuthor.name}'s' avatar`} 107 + /> 108 + <Anchor 109 + component={Link} 110 + href={`/profile/${props.cardAuthor.handle}`} 111 + target="_blank" 112 + fw={700} 113 + c="blue" 114 + lineClamp={1} 115 + > 116 + {props.cardAuthor.name} 117 + </Anchor> 118 + </Group> 119 + )} 120 + {props.note && <Text fs={'italic'}>{props.note.text}</Text>} 121 + <Card withBorder component="article" p={'xs'} radius={'lg'}> 122 + <Stack> 123 + <Group gap={'sm'} justify="space-between"> 124 + {props.cardContent.thumbnailUrl && ( 125 + <AspectRatio ratio={1 / 1} flex={0.1}> 126 + <Image 127 + src={props.cardContent.thumbnailUrl} 128 + alt={`${props.cardContent.url} social preview image`} 129 + radius={'md'} 130 + w={50} 131 + h={50} 132 + /> 133 + </AspectRatio> 134 + )} 135 + <Stack gap={0} flex={0.9}> 136 + <Tooltip label={props.cardContent.url}> 137 + <Anchor 138 + component={Link} 139 + href={props.cardContent.url} 140 + target="_blank" 141 + c={'gray'} 142 + lineClamp={1} 143 + onClick={(e) => e.stopPropagation()} 144 + > 145 + {props.domain} 146 + </Anchor> 147 + </Tooltip> 148 + {props.cardContent.title && ( 149 + <Text fw={500} lineClamp={1}> 150 + {props.cardContent.title} 151 + </Text> 152 + )} 153 + </Stack> 154 + {isMyCard && ( 155 + <Button 156 + variant="light" 157 + color="gray" 158 + onClick={(e) => { 159 + e.stopPropagation(); 160 + setEditMode(true); 161 + }} 162 + > 163 + Edit note 164 + </Button> 165 + )} 166 + </Group> 167 + </Stack> 168 + </Card> 169 + </Stack> 170 + ); 171 + }
+16
src/webapp/features/notes/components/noteCardModal/Skeleton.NoteCardModalContent.tsx
··· 1 + import { Avatar, Group, Skeleton, Stack } from '@mantine/core'; 2 + 3 + export default function NoteCardModalContentSkeleton() { 4 + return ( 5 + <Stack gap={'xs'}> 6 + <Group gap={5}> 7 + <Avatar size={'md'} /> 8 + <Skeleton w={100} h={14} /> 9 + </Group> 10 + {/* Note */} 11 + <Skeleton w={'100%'} h={14} /> 12 + <Skeleton w={'100%'} h={14} /> 13 + <Skeleton w={'100%'} h={14} /> 14 + </Stack> 15 + ); 16 + }
+12
src/webapp/features/notes/lib/dal.ts
··· 1 + import { verifySessionOnClient } from '@/lib/auth/dal'; 1 2 import { createSembleClient } from '@/services/apiClient'; 2 3 import { cache } from 'react'; 3 4 ··· 18 19 return response; 19 20 }, 20 21 ); 22 + 23 + export const updateNoteCard = cache( 24 + async (note: { cardId: string; note: string }) => { 25 + const session = await verifySessionOnClient(); 26 + if (!session) throw new Error('No session found'); 27 + const client = createSembleClient(); 28 + const response = await client.updateNoteCard(note); 29 + 30 + return response; 31 + }, 32 + );
+10 -9
src/webapp/features/notes/lib/mutations/useUpdateNote.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { updateNoteCard } from '../dal'; 3 + import { cardKeys } from '@/features/cards/lib/cardKeys'; 4 + import { collectionKeys } from '@/features/collections/lib/collectionKeys'; 5 + import { feedKeys } from '@/features/feeds/lib/feedKeys'; 3 6 4 7 export default function useUpdateNote() { 5 - const apiClient = new ApiClient( 6 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 7 - ); 8 - 9 8 const queryClient = useQueryClient(); 10 9 11 10 const mutation = useMutation({ 12 11 mutationFn: (note: { cardId: string; note: string }) => { 13 - return apiClient.updateNoteCard(note); 12 + return updateNoteCard(note); 14 13 }, 15 14 16 15 onSuccess: (data) => { 17 - queryClient.invalidateQueries({ queryKey: ['card', data.cardId] }); 18 - queryClient.invalidateQueries({ queryKey: ['collection'] }); 19 - queryClient.invalidateQueries({ queryKey: ['collections'] }); 16 + queryClient.invalidateQueries({ queryKey: cardKeys.card(data.cardId) }); 17 + queryClient.invalidateQueries({ queryKey: cardKeys.infinite() }); 18 + queryClient.invalidateQueries({ queryKey: cardKeys.infinite() }); 19 + queryClient.invalidateQueries({ queryKey: feedKeys.all() }); 20 + queryClient.invalidateQueries({ queryKey: collectionKeys.all() }); 20 21 }, 21 22 }); 22 23
+6
src/webapp/features/notes/lib/noteKeys.ts
··· 1 + export const noteKeys = { 2 + all: () => ['notes'] as const, 3 + note: (id: string) => [...noteKeys.all(), id] as const, 4 + bySembleUrl: (url: string) => [...noteKeys.all(), url], 5 + infinite: () => [...noteKeys.all(), 'infinite'], 6 + };
+3 -2
src/webapp/features/notes/lib/queries/useSembleNotes.tsx src/webapp/features/semble/lib/queries/useSembleNotes.ts
··· 1 + import { getNoteCardsForUrl } from '@/features/notes/lib/dal'; 1 2 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 - import { getNoteCardsForUrl } from '../dal'; 3 + import { sembleKeys } from '../sembleKeys'; 3 4 4 5 interface Props { 5 6 url: string; ··· 10 11 const limit = props?.limit ?? 16; 11 12 12 13 const notes = useSuspenseInfiniteQuery({ 13 - queryKey: ['semble notes', props.url, limit], 14 + queryKey: sembleKeys.notesInfinite(props.url), 14 15 initialPageParam: 1, 15 16 queryFn: ({ pageParam = 1 }) => { 16 17 return getNoteCardsForUrl(props.url, { page: pageParam, limit });
+1 -1
src/webapp/features/profile/components/profileHeader/MinimalProfileHeader.tsx
··· 8 8 9 9 export default function MinimalProfileHeader(props: Props) { 10 10 return ( 11 - <Container px={0} py={'xs'} size={'xl'} mx={0}> 11 + <Container p={'xs'} size={'xl'} mx={0}> 12 12 <Group gap={'sm'}> 13 13 <Avatar 14 14 src={props.avatarUrl}
+5 -14
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
··· 23 23 const profile = await getProfile(props.handle); 24 24 25 25 return ( 26 - <Container bg={'white'} p={'xs'} size={'xl'}> 26 + <Container bg={'white'} p={0} size={'xl'}> 27 27 <MinimalProfileHeaderContainer 28 28 avatarUrl={profile.avatarUrl} 29 29 name={profile.name} 30 30 handle={profile.handle} 31 31 /> 32 - <Stack gap={'sm'}> 32 + <Stack gap={'sm'} p={'xs'}> 33 33 <Stack gap={'xl'}> 34 34 <Grid gutter={'md'} align={'center'} grow> 35 35 <GridCol span={'auto'}> ··· 44 44 <GridCol span={{ base: 12, xs: 9 }}> 45 45 <Stack gap={'sm'}> 46 46 <Stack gap={0}> 47 - <Title 48 - order={1} 49 - fz={{ base: 'h2', md: 'h1' }} 50 - style={{ wordBreak: 'break-all' }} 51 - > 47 + <Title order={1} fz={{ base: 'h2', md: 'h1' }}> 52 48 {profile.name} 53 49 </Title> 54 - <Text 55 - c="blue" 56 - fw={600} 57 - fz={{ base: 'lg', md: 'xl' }} 58 - style={{ wordBreak: 'break-all' }} 59 - > 50 + <Text c="blue" fw={600} fz={{ base: 'lg', md: 'xl' }}> 60 51 @{profile.handle} 61 52 </Text> 62 53 </Stack> ··· 64 55 <Spoiler 65 56 showLabel={'Read more'} 66 57 hideLabel={'See less'} 67 - maxHeight={45} 58 + maxHeight={30} 68 59 > 69 60 <Text>{profile.description}</Text> 70 61 </Spoiler>
-2
src/webapp/features/profile/components/profileMenu/ProfileMenu.tsx
··· 4 4 Group, 5 5 Alert, 6 6 Menu, 7 - Stack, 8 7 Image, 9 - Text, 10 8 Button, 11 9 } from '@mantine/core'; 12 10 import useMyProfile from '../../lib/queries/useMyProfile';
+3
src/webapp/features/profile/lib/dal.ts
··· 1 + import { verifySessionOnClient } from '@/lib/auth/dal'; 1 2 import { createSembleClient } from '@/services/apiClient'; 2 3 import { cache } from 'react'; 3 4 ··· 11 12 }); 12 13 13 14 export const getMyProfile = cache(async () => { 15 + const session = await verifySessionOnClient(); 16 + if (!session) throw new Error('No session found'); 14 17 const client = createSembleClient(); 15 18 const response = await client.getMyProfile(); 16 19
+6
src/webapp/features/profile/lib/profileKeys.ts
··· 1 + export const profileKeys = { 2 + all: () => ['profiles'] as const, 3 + profile: (didOrHandle: string) => 4 + [...profileKeys.all(), didOrHandle] as const, 5 + mine: () => [...profileKeys.all(), 'mine'] as const, 6 + };
+3 -2
src/webapp/features/profile/lib/queries/useMyProfile.tsx
··· 1 - import { useSuspenseQuery, useQuery } from '@tanstack/react-query'; 1 + import { useSuspenseQuery } from '@tanstack/react-query'; 2 2 import { getMyProfile } from '../dal'; 3 + import { profileKeys } from '../profileKeys'; 3 4 4 5 export default function useMyProfile() { 5 6 return useSuspenseQuery({ 6 - queryKey: ['my profile'], 7 + queryKey: profileKeys.mine(), 7 8 queryFn: () => getMyProfile(), 8 9 }); 9 10 }
+4 -7
src/webapp/features/profile/lib/queries/useProfile.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 1 import { useSuspenseQuery } from '@tanstack/react-query'; 2 + import { getProfile } from '../dal'; 3 + import { profileKeys } from '../profileKeys'; 3 4 4 5 interface Props { 5 6 didOrHandle: string; 6 7 } 7 8 8 9 export default function useProfile(props: Props) { 9 - const apiClient = new ApiClient( 10 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 11 - ); 12 - 13 10 const profile = useSuspenseQuery({ 14 - queryKey: ['profile', props.didOrHandle], 15 - queryFn: () => apiClient.getProfile({ identifier: props.didOrHandle }), 11 + queryKey: profileKeys.profile(props.didOrHandle), 12 + queryFn: () => getProfile(props.didOrHandle), 16 13 }); 17 14 18 15 return profile;
+6 -9
src/webapp/features/semble/components/SembleHeader/SembleHeader.tsx
··· 14 14 import { getUrlMetadata } from '@/features/cards/lib/dal'; 15 15 import { getDomain } from '@/lib/utils/link'; 16 16 import UrlAddedBySummary from '../urlAddedBySummary/UrlAddedBySummary'; 17 + // import SembleActions from '../sembleActions/SembleActions'; 17 18 18 19 interface Props { 19 20 url: string; ··· 48 49 </Tooltip> 49 50 </Text> 50 51 51 - {metadata.title && ( 52 - <Title order={1} style={{ wordBreak: 'break-all' }}> 53 - {metadata.title} 54 - </Title> 55 - )} 52 + {metadata.title && <Title order={1}>{metadata.title}</Title>} 56 53 </Stack> 57 54 {metadata.description && ( 58 55 <Spoiler showLabel={'Read more'} hideLabel={'See less'}> 59 - <Text c="gray" fw={500}> 56 + <Text c="gray" fw={500} maw={650}> 60 57 {metadata.description} 61 58 </Text> 62 59 </Spoiler> ··· 64 61 </Stack> 65 62 </GridCol> 66 63 <GridCol span={{ base: 12, sm: 'content' }}> 67 - <Stack gap={'sm'} align="start"> 64 + <Stack gap={'sm'} align="center"> 68 65 {metadata.imageUrl && ( 69 - <Card p={0} radius={'lg'} withBorder> 66 + <Card p={0} radius={'md'} withBorder> 70 67 <Image 71 68 src={metadata.imageUrl} 72 69 alt={`${props.url} social preview image`} ··· 77 74 /> 78 75 </Card> 79 76 )} 80 - {/*<SembleActions />*/} 77 + {/*<SembleActions url={props.url} />*/} 81 78 </Stack> 82 79 </GridCol> 83 80 </Grid>
+1 -1
src/webapp/features/semble/components/SembleHeader/Skeleton.SembleHeader.tsx
··· 26 26 </GridCol> 27 27 <GridCol span={{ base: 12, sm: 'content' }}> 28 28 <Stack gap={'sm'} align="start" flex={1}> 29 - <Skeleton radius={'lg'} h={150} w={300} maw={'100%'} /> 29 + <Skeleton h={150} w={300} maw={'100%'} /> 30 30 31 31 {/*<SembleActions />*/} 32 32 </Stack>
+28 -6
src/webapp/features/semble/components/sembleActions/SembleActions.tsx
··· 1 1 'use client'; 2 2 3 + import useGetCardFromMyLibrary from '@/features/cards/lib/queries/useGetCardFromMyLibrary'; 3 4 import { Button, Group } from '@mantine/core'; 5 + import { Fragment } from 'react'; 4 6 import { FiPlus } from 'react-icons/fi'; 7 + import { IoMdCheckmark } from 'react-icons/io'; 5 8 6 - export default function SembleActions() { 9 + interface Props { 10 + url: string; 11 + } 12 + 13 + export default function SembleActions(props: Props) { 14 + const cardStatus = useGetCardFromMyLibrary({ url: props.url }); 15 + const isInYourLibrary = cardStatus.data.card?.urlInLibrary; 16 + 17 + if (cardStatus.error) { 18 + return null; 19 + } 20 + 7 21 return ( 8 - <Group> 9 - <Button size="md" radius={'md'} leftSection={<FiPlus size={22} />}> 10 - Add 11 - </Button> 12 - </Group> 22 + <Fragment> 23 + <Group> 24 + <Button 25 + variant={isInYourLibrary ? 'default' : 'filled'} 26 + size="md" 27 + leftSection={ 28 + isInYourLibrary ? <IoMdCheckmark size={18} /> : <FiPlus size={18} /> 29 + } 30 + > 31 + {isInYourLibrary ? 'In library' : 'Add to library'} 32 + </Button> 33 + </Group> 34 + </Fragment> 13 35 ); 14 36 }
+5 -1
src/webapp/features/semble/containers/sembleAside/SembleAside.tsx
··· 66 66 ) : ( 67 67 <Stack gap={'xs'}> 68 68 {collectionsData.collections.slice(0, 3).map((col) => ( 69 - <CollectionCard key={col.uri} collection={col} /> 69 + <CollectionCard 70 + key={col.uri} 71 + collection={col} 72 + showAuthor={true} 73 + /> 70 74 ))} 71 75 </Stack> 72 76 )}
+31 -11
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
··· 1 1 import SembleHeader from '../../components/SembleHeader/SembleHeader'; 2 - import { BackgroundImage, Container, Stack } from '@mantine/core'; 2 + import { Image, Container, Stack, Box } from '@mantine/core'; 3 3 import BG from '@/assets/semble-header-bg.webp'; 4 4 import { Suspense } from 'react'; 5 5 import SembleTabs from '../../components/sembleTabs/SembleTabs'; ··· 12 12 export default async function SembleContainer(props: Props) { 13 13 return ( 14 14 <Container p={0} fluid> 15 - <BackgroundImage src={BG.src} h={240}> 16 - <Container p={'xs'} size={'xl'}> 17 - <Stack gap={'xl'}> 18 - <Suspense fallback={<SembleHeaderSkeleton />}> 19 - <SembleHeader url={props.url} /> 20 - </Suspense> 21 - <SembleTabs url={props.url} /> 22 - </Stack> 23 - </Container> 24 - </BackgroundImage> 15 + <Box style={{ position: 'relative', width: '100%' }}> 16 + <Image 17 + src={BG.src} 18 + alt="bg" 19 + fit="cover" 20 + w="100%" 21 + h={{ base: 100, md: 120 }} 22 + /> 23 + 24 + {/* White gradient overlay */} 25 + <Box 26 + style={{ 27 + position: 'absolute', 28 + bottom: 0, 29 + left: 0, 30 + width: '100%', 31 + height: '60%', // fade height 32 + background: 'linear-gradient(to top, white, transparent)', 33 + pointerEvents: 'none', 34 + }} 35 + /> 36 + </Box> 37 + <Container px={'xs'} pb={'xs'} size={'xl'}> 38 + <Stack gap={'xl'}> 39 + <Suspense fallback={<SembleHeaderSkeleton />}> 40 + <SembleHeader url={props.url} /> 41 + </Suspense> 42 + <SembleTabs url={props.url} /> 43 + </Stack> 44 + </Container> 25 45 </Container> 26 46 ); 27 47 }
+2 -2
src/webapp/features/semble/containers/sembleNotesContainer/SembleNotesContainer.tsx
··· 1 1 'use client'; 2 2 3 - import useSembleNotes from '@/features/notes/lib/queries/useSembleNotes'; 3 + import useSembleNotes from '@/features/semble/lib/queries/useSembleNotes'; 4 4 import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 5 5 import { Grid } from '@mantine/core'; 6 6 import SembleNotesContainerError from './Error.SembleNotesContainer'; ··· 57 57 > 58 58 <NoteCard 59 59 id={note.id} 60 - authorId={note.author.id} 60 + author={note.author} 61 61 createdAt={note.createdAt} 62 62 note={note.note} 63 63 />
+2 -1
src/webapp/features/semble/lib/queries/useSembleLibraries.tsx
··· 1 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 2 import { getLibrariesForUrl } from '../dal'; 3 + import { sembleKeys } from '../sembleKeys'; 3 4 4 5 interface Props { 5 6 url: string; ··· 10 11 const limit = props?.limit ?? 16; 11 12 12 13 const libraries = useSuspenseInfiniteQuery({ 13 - queryKey: ['semble libraries', props.url, limit], 14 + queryKey: sembleKeys.librariesInfinite(props.url), 14 15 initialPageParam: 1, 15 16 queryFn: ({ pageParam = 1 }) => { 16 17 return getLibrariesForUrl(props.url, { page: pageParam, limit });
+2 -1
src/webapp/features/semble/lib/queries/useSembleSimilarCards.tsx
··· 1 1 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 2 import { getSimilarUrlsForUrl } from '../dal'; 3 + import { sembleKeys } from '../sembleKeys'; 3 4 4 5 interface Props { 5 6 url: string; ··· 11 12 const limit = props?.limit ?? 16; 12 13 13 14 const similarCards = useSuspenseInfiniteQuery({ 14 - queryKey: ['semble similar cards', props.url, limit, props.threshold], 15 + queryKey: sembleKeys.similarCardsInfinite(props.url), 15 16 initialPageParam: 1, 16 17 queryFn: ({ pageParam = 1 }) => { 17 18 return getSimilarUrlsForUrl(props.url, {
+14
src/webapp/features/semble/lib/sembleKeys.ts
··· 1 + export const sembleKeys = { 2 + all: () => ['semble'] as const, 3 + byUrl: (url: string) => [...sembleKeys.all(), url] as const, 4 + notes: (url: string) => [...sembleKeys.byUrl(url), 'notes'] as const, 5 + notesInfinite: (url: string) => 6 + [...sembleKeys.notes(url), 'infinite'] as const, 7 + similarCards: (url: string) => 8 + [...sembleKeys.byUrl(url), 'similar cards'] as const, 9 + similarCardsInfinite: (url: string) => 10 + [...sembleKeys.similarCards(url), 'infinite'] as const, 11 + libraries: (url: string) => [...sembleKeys.byUrl(url), 'libraries'] as const, 12 + librariesInfinite: (url: string) => 13 + [...sembleKeys.libraries(url), 'infinite'] as const, 14 + };
+9 -18
src/webapp/hooks/useAuth.tsx
··· 5 5 import { useRouter } from 'next/navigation'; 6 6 import type { GetProfileResponse } from '@/api-client/ApiClient'; 7 7 import { ClientCookieAuthService } from '@/services/auth/CookieAuthService.client'; 8 - 9 - type UserProfile = GetProfileResponse; 8 + import { verifySessionOnClient } from '@/lib/auth/dal'; 9 + import { usePathname } from 'next/navigation'; 10 10 11 11 interface AuthContextType { 12 - user: UserProfile | null; 12 + user: GetProfileResponse | null; 13 13 isAuthenticated: boolean; 14 14 isLoading: boolean; 15 15 refreshAuth: () => Promise<void>; ··· 21 21 export const AuthProvider = ({ children }: { children: ReactNode }) => { 22 22 const router = useRouter(); 23 23 const queryClient = useQueryClient(); 24 + const pathname = usePathname(); // to prevent redirecting to login on landing page 24 25 25 26 const refreshAuth = async () => { 26 27 await query.refetch(); ··· 28 29 29 30 const logout = async () => { 30 31 await ClientCookieAuthService.clearTokens(); 31 - queryClient.removeQueries({ queryKey: ['authenticated user'] }); 32 + queryClient.clear(); 32 33 router.push('/login'); 33 34 }; 34 35 35 - const query = useQuery<UserProfile | null>({ 36 + const query = useQuery<GetProfileResponse | null>({ 36 37 queryKey: ['authenticated user'], 37 38 queryFn: async () => { 38 - const response = await fetch('/api/auth/me', { 39 - method: 'GET', 40 - credentials: 'include', // HttpOnly cookies sent automatically 41 - }); 42 - 43 - // unauthenticated 44 - if (!response.ok) { 45 - throw new Error('Not authenticated'); 46 - } 47 - 48 - const data = await response.json(); 49 - return data.user as UserProfile; 39 + const session = await verifySessionOnClient(); 40 + return session; 50 41 }, 51 42 staleTime: 5 * 60 * 1000, // cache for 5 minutes 52 43 refetchOnWindowFocus: false, ··· 54 45 }); 55 46 56 47 useEffect(() => { 57 - if (query.isError) logout(); 48 + if (query.isError && !query.isLoading && pathname !== '/') logout(); 58 49 }, [query.isError, logout]); 59 50 60 51 const contextValue: AuthContextType = {
+20
src/webapp/lib/auth/dal.server.ts
··· 1 + import { GetProfileResponse } from '@/api-client/ApiClient'; 2 + import { cookies } from 'next/headers'; 3 + import { cache } from 'react'; 4 + 5 + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000'; 6 + 7 + export const verifySessionOnServer = cache(async () => { 8 + const cookieStore = await cookies(); 9 + const res = await fetch(`${appUrl}/api/auth/me`, { 10 + headers: { 11 + Cookie: cookieStore.toString(), // forward user's cookies 12 + }, 13 + }); 14 + 15 + if (!res.ok) return null; 16 + 17 + const { user }: { user: GetProfileResponse } = await res.json(); 18 + 19 + return user; 20 + });
+30 -80
src/webapp/lib/auth/dal.ts
··· 1 - import 'server-only'; 2 - 1 + import type { GetProfileResponse } from '@/api-client/ApiClient'; 3 2 import { cache } from 'react'; 4 - import { cookies } from 'next/headers'; 5 - import { redirect } from 'next/navigation'; 6 - import { isTokenExpiringSoon } from './token'; 7 3 8 - export const verifySession = cache(async () => { 9 - const cookieStore = await cookies(); 10 - const accessToken = cookieStore.get('accessToken')?.value; 11 - const refreshToken = cookieStore.get('refreshToken')?.value; 12 - 13 - // no session tokens — redirect to login 14 - if (!accessToken && !refreshToken) { 15 - redirect('/login'); 16 - } 4 + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000'; 17 5 18 - const backendUrl = 19 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; 6 + // Flag-based approach to prevent concurrent auth requests 7 + let isRefreshing = false; 8 + let refreshPromise: Promise<GetProfileResponse | null> | null = null; 20 9 21 - // token expired or about to expire 22 - if ((!accessToken || isTokenExpiringSoon(accessToken)) && refreshToken) { 23 - const refreshResponse = await fetch( 24 - `${backendUrl}/api/users/oauth/refresh`, 25 - { 26 - method: 'POST', 27 - headers: { 'Content-Type': 'application/json' }, 28 - body: JSON.stringify({ refreshToken }), 29 - cache: 'no-store', 30 - }, 31 - ); 32 - 33 - if (!refreshResponse.ok) { 34 - // clear invalid tokens and redirect to login 35 - cookieStore.delete('accessToken'); 36 - cookieStore.delete('refreshToken'); 37 - redirect('/login'); 10 + export const verifySessionOnClient = cache( 11 + async (): Promise<GetProfileResponse | null> => { 12 + if (isRefreshing && refreshPromise) { 13 + console.log('Auth refresh already in progress, waiting...'); 14 + return refreshPromise; 38 15 } 39 16 40 - const newTokens = await refreshResponse.json(); 41 - const newAccessToken = newTokens.accessToken; 42 - 43 - // update cookie 44 - cookieStore.set('accessToken', newAccessToken, { 45 - httpOnly: true, 46 - secure: true, 47 - sameSite: 'lax', 48 - path: '/', 49 - }); 50 - 51 - return { isAuthenticated: true }; 52 - } 17 + isRefreshing = true; 18 + refreshPromise = (async () => { 19 + try { 20 + const response = await fetch(`${appUrl}/api/auth/me`, { 21 + method: 'GET', 22 + credentials: 'include', // HttpOnly cookies sent automatically 23 + }); 53 24 54 - // if access token is valid 55 - return { isAuthenticated: true }; 56 - }); 25 + if (!response.ok) { 26 + return null; 27 + } 57 28 58 - export const getUser = cache(async () => { 59 - const cookieStore = await cookies(); 60 - const accessToken = cookieStore.get('accessToken')?.value; 61 - const refreshToken = cookieStore.get('refreshToken')?.value; 29 + const { user }: { user: GetProfileResponse } = await response.json(); 62 30 63 - if (!accessToken && !refreshToken) redirect('/login'); 31 + return user; 32 + } finally { 33 + isRefreshing = false; 34 + refreshPromise = null; 35 + } 36 + })(); 64 37 65 - const backendUrl = 66 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'; 67 - 68 - // Forward cookies manually 69 - const cookieHeader = [ 70 - accessToken ? `accessToken=${accessToken}` : null, 71 - refreshToken ? `refreshToken=${refreshToken}` : null, 72 - ] 73 - .filter(Boolean) 74 - .join('; '); 75 - 76 - const res = await fetch(`${backendUrl}/api/users/me`, { 77 - headers: { 78 - 'Content-Type': 'application/json', 79 - Cookie: cookieHeader, // forward the cookies 80 - }, 81 - cache: 'no-store', 82 - }); 83 - 84 - if (!res.ok) { 85 - redirect('/login'); 86 - } 87 - 88 - const user = await res.json(); 89 - return user; 90 - }); 38 + return refreshPromise; 39 + }, 40 + );
+15 -5
src/webapp/lib/auth/token.ts
··· 1 1 export const isTokenExpiringSoon = ( 2 2 token: string | null | undefined, 3 - bufferMinutes: number = 5, 4 3 ): boolean => { 5 4 if (!token) return true; 6 5 6 + const bufferSeconds = parseInt( 7 + process.env.NEXT_PUBLIC_ACCESS_TOKEN_EXPIRY_BUFFER_SECONDS || '300', 8 + 10, 9 + ); 10 + 7 11 try { 8 - const payload = JSON.parse( 9 - Buffer.from(token.split('.')[1], 'base64').toString(), 10 - ); 12 + // Validate JWT structure first 13 + const parts = token.split('.'); 14 + if (parts.length !== 3) return true; 15 + 16 + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); 17 + 18 + // Ensure exp claim exists and is a number 19 + if (!payload.exp || typeof payload.exp !== 'number') return true; 20 + 11 21 const expiry = payload.exp * 1000; 12 - const bufferTime = bufferMinutes * 60 * 1000; 22 + const bufferTime = bufferSeconds * 1000; 13 23 return Date.now() >= expiry - bufferTime; 14 24 } catch { 15 25 return true;
src/webapp/public/semble-icon-192x192.png

This is a binary file and will not be displayed.

src/webapp/public/semble-icon-512x512.png

This is a binary file and will not be displayed.

+3 -14
src/webapp/services/auth/CookieAuthService.client.ts
··· 1 + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://127.0.0.1:4000'; 2 + 1 3 export class ClientCookieAuthService { 2 4 // Note: With HttpOnly cookies, we cannot read tokens from document.cookie 3 5 // The browser automatically sends cookies with requests using credentials: 'include' 4 6 // All auth logic (checking status, refreshing tokens) is handled by /api/auth/me endpoint 5 7 6 - // Set cookies via API (used after OAuth login) 7 - static async setTokens( 8 - accessToken: string, 9 - refreshToken: string, 10 - ): Promise<void> { 11 - await fetch('/api/auth/sync', { 12 - method: 'POST', 13 - headers: { 'Content-Type': 'application/json' }, 14 - body: JSON.stringify({ accessToken, refreshToken }), 15 - credentials: 'include', 16 - }); 17 - } 18 - 19 8 // Clear cookies via API (logout) 20 9 static async clearTokens(): Promise<void> { 21 10 try { 22 - const response = await fetch('/api/auth/logout', { 11 + const response = await fetch(`${appUrl}/api/auth/logout`, { 23 12 method: 'POST', 24 13 credentials: 'include', 25 14 });
+16
src/webapp/styles/theme.tsx
··· 11 11 Spoiler, 12 12 TabsTab, 13 13 Tooltip, 14 + Title, 15 + Text, 14 16 } from '@mantine/core'; 15 17 16 18 export const theme = createTheme({ ··· 122 124 Tooltip: Tooltip.extend({ 123 125 defaultProps: { 124 126 position: 'top-start', 127 + }, 128 + }), 129 + Title: Text.extend({ 130 + styles: { 131 + root: { 132 + wordBreak: 'break-word', 133 + }, 134 + }, 135 + }), 136 + Text: Text.extend({ 137 + styles: { 138 + root: { 139 + wordBreak: 'break-word', 140 + }, 125 141 }, 126 142 }), 127 143 },
+2 -2
src/workers/feed-worker.ts
··· 2 2 import { FeedWorkerProcess } from '../shared/infrastructure/processes/FeedWorkerProcess'; 3 3 4 4 async function main() { 5 - const useInMemoryEvents = process.env.USE_IN_MEMORY_EVENTS === 'true'; 5 + const configService = new EnvironmentConfigService(); 6 + const useInMemoryEvents = configService.shouldUseInMemoryEvents(); 6 7 7 8 if (useInMemoryEvents) { 8 9 console.log( ··· 12 13 } 13 14 14 15 console.log('Starting dedicated feed worker process...'); 15 - const configService = new EnvironmentConfigService(); 16 16 const feedWorkerProcess = new FeedWorkerProcess(configService); 17 17 18 18 await feedWorkerProcess.start();
+2 -2
src/workers/search-worker.ts
··· 2 2 import { SearchWorkerProcess } from '../shared/infrastructure/processes/SearchWorkerProcess'; 3 3 4 4 async function main() { 5 - const useInMemoryEvents = process.env.USE_IN_MEMORY_EVENTS === 'true'; 5 + const configService = new EnvironmentConfigService(); 6 + const useInMemoryEvents = configService.shouldUseInMemoryEvents(); 6 7 7 8 if (useInMemoryEvents) { 8 9 console.log( ··· 12 13 } 13 14 14 15 console.log('Starting dedicated search worker process...'); 15 - const configService = new EnvironmentConfigService(); 16 16 const searchWorkerProcess = new SearchWorkerProcess(configService); 17 17 18 18 await searchWorkerProcess.start();