diff --git a/backend/.env.example b/backend/.env.example index 7938570..2330c3e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,7 @@ DATABASE_URL=postgresql://postgres@localhost:5433/readr +BETTER_AUTH_URL=http://localhost:3001 +FRONTEND_URL=http://localhost:5173 + +# Add a random string here +# In order to generate one, you may use this command: openssl rand -base64 32 +BETTER_AUTH_SECRET= diff --git a/backend/codegen.ts b/backend/codegen.ts index cd59d61..1ffe5c6 100644 --- a/backend/codegen.ts +++ b/backend/codegen.ts @@ -6,7 +6,10 @@ const config: CodegenConfig = { schema: "**/schema.graphql", generates: { "src/schema": defineConfig({ - typesPluginsConfig: { useTypeImports: true }, + typesPluginsConfig: { + useTypeImports: true, + contextType: "~/index#ApolloContext", + }, }), }, }; diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts index bb9afba..2faf7be 100644 --- a/backend/drizzle.config.ts +++ b/backend/drizzle.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ out: "./drizzle", - schema: "./src/db/schema.ts", + schema: "./src/db/schema/index.ts", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL!, diff --git a/backend/package.json b/backend/package.json index 6cf159e..c5f6055 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,7 @@ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --packages=external --outfile=dist/index.js", "start": "pnpm build && node dist/index.js", "db:init": "initdb -D db-data -U postgres -E UTF8 --auth=trust", - "db:start": "pg_ctl -D db-data -l ./db-data/db-data.log -w -o \"-p 5433 -c listen_addresses=127.0.0.1\" start", + "db:start": "node -e \"require('child_process').spawnSync('pg_ctl',['-D','db-data','-l','db-data/db-data.log','-w','-o','-p 5433 -c listen_addresses=127.0.0.1','start'],{stdio:'ignore',windowsHide:true})\"", "db:create": "createdb -h 127.0.0.1 -p 5433 -U postgres readr", "db:stop": "pg_ctl -D db-data stop", "db:status": "pg_ctl -D db-data status", @@ -43,7 +43,9 @@ "dependencies": { "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", + "@better-auth/drizzle-adapter": "^1.7.7", "@graphql-tools/merge": "^9.2.6", + "better-auth": "^1.7.7", "cors": "^2.8.6", "csv-parse": "^7.0.3", "dotenv": "^18.0.5", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 58c4a04..4affcff 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -172,9 +172,15 @@ importers: '@as-integrations/express5': specifier: ^1.1.2 version: 1.1.2(@apollo/server@5.5.1(graphql@17.0.2))(express@5.2.1) + '@better-auth/drizzle-adapter': + specifier: ^1.7.7 + version: 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6)) '@graphql-tools/merge': specifier: ^9.2.6 version: 9.2.6(graphql@17.0.2) + better-auth: + specifier: ^1.7.7 + version: 1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6))(pg@8.23.1) cors: specifier: ^2.8.6 version: 2.8.6 @@ -186,7 +192,7 @@ importers: version: 18.0.5 drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1) + version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6) express: specifier: ^5.2.1 version: 5.2.1 @@ -402,6 +408,85 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.7.7': + resolution: {integrity: sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.7': + resolution: {integrity: sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.7': + resolution: {integrity: sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.7': + resolution: {integrity: sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.7': + resolution: {integrity: sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.7': + resolution: {integrity: sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.7': + resolution: {integrity: sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==} + peerDependencies: + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + '@drizzle-team/brocli@0.12.1': resolution: {integrity: sha512-yHwomvolVafvUXhy5TdiJVkuNCxXn+bKOs2aWiWEQh/5YaV97Oa/abJz3dmx7lxgIUsUlS1Jl4mEfvJPo3YsVw==} @@ -1142,6 +1227,14 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1154,6 +1247,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1187,6 +1284,9 @@ packages: '@repeaterjs/repeater@3.1.0': resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@ts-morph/common@0.23.0': resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==} @@ -1430,6 +1530,73 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-auth@1.7.7: + resolution: {integrity: sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -1585,6 +1752,9 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2062,6 +2232,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2089,6 +2262,10 @@ packages: engines: {node: '>=6'} hasBin: true + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2190,6 +2367,10 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} + nanostores@1.5.5: + resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==} + engines: {node: ^20.0.0 || >=22.0.0} + negotiator@1.1.0: resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} engines: {node: '>=18'} @@ -2388,6 +2569,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2413,6 +2597,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.1.3: + resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2640,6 +2827,9 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} + zod@4.6.6: + resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} + snapshots: '@apollo/cache-control-types@1.0.3(graphql@17.0.2)': @@ -2864,6 +3054,63 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.6.6) + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.6 + + '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6))': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6) + + '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.6 + + '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-fetch/fetch@1.3.2': {} + '@drizzle-team/brocli@0.12.1': {} '@eddeee888/gcg-server-config@0.6.0(graphql@17.0.2)': @@ -3616,6 +3863,10 @@ snapshots: dependencies: jsbi: 4.3.2 + '@noble/ciphers@2.4.0': {} + + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3628,6 +3879,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 + '@opentelemetry/semantic-conventions@1.43.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -3652,6 +3905,8 @@ snapshots: '@repeaterjs/repeater@3.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@ts-morph/common@0.23.0': dependencies: fast-glob: 3.3.3 @@ -3851,6 +4106,41 @@ snapshots: baseline-browser-mapping@2.11.27: {} + better-auth@1.7.7(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6))(pg@8.23.1): + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6)) + '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.6))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.0(zod@4.6.6) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.6 + optionalDependencies: + drizzle-kit: 1.0.0-rc.4 + drizzle-orm: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6) + pg: 8.23.1 + transitivePeerDependencies: + - '@opentelemetry/api' + + better-call@1.4.0(zod@4.6.6): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.3 + optionalDependencies: + zod: 4.6.6 + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -3999,6 +4289,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + defu@6.1.7: {} + depd@2.0.0: {} dependency-graph@1.0.0: {} @@ -4019,10 +4311,11 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1): + drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.1)(zod@4.6.6): optionalDependencies: '@types/pg': 8.23.1 pg: 8.23.1 + zod: 4.6.6 dunder-proto@1.0.1: dependencies: @@ -4386,6 +4679,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.12: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -4405,6 +4700,8 @@ snapshots: json5@2.2.3: {} + kysely@0.29.6: {} + lines-and-columns@1.2.4: {} listr2@10.2.2: @@ -4485,6 +4782,8 @@ snapshots: mute-stream@3.0.0: {} + nanostores@1.5.5: {} + negotiator@1.1.0: dependencies: content-type: 2.1.0 @@ -4651,6 +4950,8 @@ snapshots: rfdc@1.4.1: {} + rou3@0.9.2: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -4696,6 +4997,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.3: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -4929,3 +5232,5 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors@2.2.0: {} + + zod@4.6.6: {} diff --git a/backend/src/auth/index.ts b/backend/src/auth/index.ts new file mode 100644 index 0000000..fc131a8 --- /dev/null +++ b/backend/src/auth/index.ts @@ -0,0 +1,24 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; +import { db } from "~/db"; +import * as schema from "~/db/schema"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + emailAndPassword: { + enabled: true, + minPasswordLength: 8, + }, + user: { + additionalFields: { + bio: { + type: "string", + required: false, + }, + }, + }, + trustedOrigins: [process.env.FRONTEND_URL ?? "http://localhost:5173"], +}); diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index eaf3a39..f019bb8 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,6 +1,80 @@ import "dotenv/config"; +import { defineRelations } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; -import { relations } from "~/db/schema"; +import { + account, + bookGenres, + books, + bookToGenres, + favoriteBooks, + session, + user, + verification, +} from "~/db/schema"; + +// We define all the relations in one place so the individual schema files don't have to import each other, which makes it easy to create circular dependencies. +const relations = defineRelations( + { + user, + session, + account, + verification, + favoriteBooks, + books, + bookGenres, + bookToGenres, + }, + (r) => ({ + // Auth + user: { + sessions: r.many.session({ + from: r.user.id, + to: r.session.userId, + }), + accounts: r.many.account({ + from: r.user.id, + to: r.account.userId, + }), + favoriteBooks: r.many.favoriteBooks({ + from: r.user.id, + to: r.favoriteBooks.userId, + }), + }, + session: { + user: r.one.user({ + from: r.session.userId, + to: r.user.id, + }), + }, + account: { + user: r.one.user({ + from: r.account.userId, + to: r.user.id, + }), + }, + // Books + books: { + genres: r.many.bookGenres({ + from: r.books.id.through(r.bookToGenres.bookId), + to: r.bookGenres.id.through(r.bookToGenres.genreId), + }), + }, + bookGenres: { + books: r.many.books(), + }, + // Favorites + favoriteBooks: { + user: r.one.user({ + from: r.favoriteBooks.userId, + to: r.user.id, + }), + book: r.one.books({ + from: r.favoriteBooks.bookId, + to: r.books.id, + }), + }, + }), +); const db = drizzle(process.env.DATABASE_URL!, { relations }); diff --git a/backend/src/db/schema/auth.ts b/backend/src/db/schema/auth.ts new file mode 100644 index 0000000..26577af --- /dev/null +++ b/backend/src/db/schema/auth.ts @@ -0,0 +1,101 @@ +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; + +const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + bio: text("bio"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +type SelectUser = typeof user.$inferSelect; +type InsertUser = typeof user.$inferInsert; + +type SelectSession = typeof session.$inferSelect; +type InsertSession = typeof session.$inferInsert; + +type SelectAccount = typeof account.$inferSelect; +type InsertAccount = typeof account.$inferInsert; + +type SelectVerification = typeof verification.$inferSelect; +type InsertVerification = typeof verification.$inferInsert; + +export { + user, + session, + account, + verification, + type SelectUser, + type InsertUser, + type SelectSession, + type InsertSession, + type SelectAccount, + type InsertAccount, + type SelectVerification, + type InsertVerification, +}; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema/books.ts similarity index 81% rename from backend/src/db/schema.ts rename to backend/src/db/schema/books.ts index 614aa82..99b66d7 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema/books.ts @@ -1,4 +1,3 @@ -import { defineRelations } from "drizzle-orm"; import { integer, pgTable, @@ -41,18 +40,6 @@ const bookToGenres = pgTable( (t) => [primaryKey({ columns: [t.bookId, t.genreId] })], ); -const relations = defineRelations({ books, bookGenres, bookToGenres }, (r) => ({ - books: { - genres: r.many.bookGenres({ - from: r.books.id.through(r.bookToGenres.bookId), - to: r.bookGenres.id.through(r.bookToGenres.genreId), - }), - }, - bookGenres: { - books: r.many.books(), - }, -})); - type InsertBook = typeof books.$inferInsert; type SelectBook = typeof books.$inferSelect; @@ -66,7 +53,6 @@ export { books, bookGenres, bookToGenres, - relations, type InsertBook, type SelectBook, type InsertBookGenre, diff --git a/backend/src/db/schema/favorites.ts b/backend/src/db/schema/favorites.ts new file mode 100644 index 0000000..4e5d87b --- /dev/null +++ b/backend/src/db/schema/favorites.ts @@ -0,0 +1,28 @@ +import { + integer, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { user } from "./auth"; +import { books } from "./books"; + +const favoriteBooks = pgTable( + "favorite_books", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [primaryKey({ columns: [t.userId, t.bookId] })], +); + +type InsertFavoriteBook = typeof favoriteBooks.$inferInsert; +type SelectFavoriteBook = typeof favoriteBooks.$inferSelect; + +export { favoriteBooks, type InsertFavoriteBook, type SelectFavoriteBook }; diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts new file mode 100644 index 0000000..341a815 --- /dev/null +++ b/backend/src/db/schema/index.ts @@ -0,0 +1,3 @@ +export * from "./auth"; +export * from "./books"; +export * from "./favorites"; diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 3459d6e..a095323 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -3,7 +3,7 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "csv-parse"; import { db } from "~/db"; -import { books, type InsertBook } from "~/db/schema"; +import { books, type InsertBook } from "~/db/schema/books"; type BookRow = { bookId: string; diff --git a/backend/src/index.ts b/backend/src/index.ts index 3d6e6ea..5621a39 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,9 +6,11 @@ import cors from "cors"; import { expressMiddleware } from "@as-integrations/express5"; import { typeDefs } from "~/schema/typeDefs.generated"; import { resolvers } from "~/schema/resolvers.generated"; +import { fromNodeHeaders, toNodeHandler } from "better-auth/node"; +import { auth } from "~/auth"; -type ApolloContext = { - token?: string; +export type ApolloContext = { + session: Awaited>; }; const PORT = 3001; @@ -17,7 +19,7 @@ const PORT = 3001; // https://www.apollographql.com/docs/apollo-server/getting-started // https://www.apollographql.com/docs/apollo-server/api/standalone#swapping-to-expressmiddleware // https://www.apollographql.com/docs/apollo-server/api/express-middleware -const app: Express = express(); +const app = express(); const httpServer = http.createServer(app); @@ -29,12 +31,29 @@ const server = new ApolloServer({ await server.start(); +app.all( + "/api/auth/{*any}", + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:5173", + methods: ["GET", "POST", "PUT", "DELETE"], + credentials: true, + }), + toNodeHandler(auth), +); + app.use( "/", - cors(), + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:5173", + credentials: true, + }), express.json(), expressMiddleware(server, { - context: async ({ req }) => ({ token: req.headers.token }), + context: async ({ req }) => ({ + session: await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }), + }), }), ); diff --git a/backend/src/schema/base/schema.graphql b/backend/src/schema/base/schema.graphql index a4dda08..515ca91 100644 --- a/backend/src/schema/base/schema.graphql +++ b/backend/src/schema/base/schema.graphql @@ -1 +1,2 @@ type Query +type Mutation diff --git a/backend/src/schema/books/resolvers/Book.ts b/backend/src/schema/books/resolvers/Book.ts index 93bd77e..8501feb 100644 --- a/backend/src/schema/books/resolvers/Book.ts +++ b/backend/src/schema/books/resolvers/Book.ts @@ -1,3 +1,19 @@ import type { BookResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; -export const Book: BookResolvers = {}; +export const Book: BookResolvers = { + // Fetch the books genres for a given book if requested + async genres(parent) { + const book = await db.query.books.findFirst({ + // Do not fetch any columns from the books table, we don't need them + columns: {}, + where: { + id: parent.id, + }, + with: { + genres: true, + }, + }); + return book?.genres.map((g) => g.genre) ?? []; + }, +}; diff --git a/backend/src/schema/books/resolvers/Query/book.ts b/backend/src/schema/books/resolvers/Query/book.ts index efd2be1..e2f3fa2 100644 --- a/backend/src/schema/books/resolvers/Query/book.ts +++ b/backend/src/schema/books/resolvers/Query/book.ts @@ -1,5 +1,6 @@ import type { QueryResolvers } from "./../../../types.generated"; import { db } from "~/db"; + export const book: NonNullable = async ( _parent, args, diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 03ed08c..afc7435 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -1,6 +1,6 @@ import { and, asc, ilike, type SQL } from "drizzle-orm"; import { db } from "~/db"; -import { books } from "~/db/schema"; +import { books } from "~/db/schema/books"; import type { QueryResolvers } from "~/schema/types.generated"; export const searchBooks: NonNullable = async ( diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index cd16580..af3f5df 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -12,6 +12,7 @@ type Book { publisher: String publishDate: String coverImgUrl: String + genres: [String!]! } extend type Query { diff --git a/backend/src/schema/favorites/resolvers/Mutation/addFavoriteBook.ts b/backend/src/schema/favorites/resolvers/Mutation/addFavoriteBook.ts new file mode 100644 index 0000000..08a058d --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Mutation/addFavoriteBook.ts @@ -0,0 +1,36 @@ +import type { MutationResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { GraphQLError } from "graphql/error"; +import { favoriteBooks } from "~/db/schema"; + +export const addFavoriteBook: NonNullable< + MutationResolvers["addFavoriteBook"] +> = async (_parent, arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to add a book to your favorites.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + const book = await db.query.books.findFirst({ + where: { + id: arg.bookId, + }, + }); + + if (!book) { + throw new GraphQLError("Book not found.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + await db + .insert(favoriteBooks) + .values({ userId: ctx.session.user.id, bookId: arg.bookId }) + .onConflictDoNothing(); + + return book; +}; diff --git a/backend/src/schema/favorites/resolvers/Mutation/removeFavoriteBook.ts b/backend/src/schema/favorites/resolvers/Mutation/removeFavoriteBook.ts new file mode 100644 index 0000000..3a2d8a1 --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Mutation/removeFavoriteBook.ts @@ -0,0 +1,29 @@ +import { GraphQLError } from "graphql/error"; +import type { MutationResolvers } from "./../../../types.generated"; +import { db } from "~/db"; +import { favoriteBooks } from "~/db/schema"; +import { and, eq } from "drizzle-orm"; + +export const removeFavoriteBook: NonNullable< + MutationResolvers["removeFavoriteBook"] +> = async (_parent, arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to remove a book from your favorites.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + await db + .delete(favoriteBooks) + .where( + and( + eq(favoriteBooks.userId, ctx.session.user.id), + eq(favoriteBooks.bookId, arg.bookId), + ), + ); + + return true; +}; diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteBookIds.ts b/backend/src/schema/favorites/resolvers/Query/favoriteBookIds.ts new file mode 100644 index 0000000..d037bba --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Query/favoriteBookIds.ts @@ -0,0 +1,25 @@ +import { GraphQLError } from "graphql/error"; +import type { QueryResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; + +export const favoriteBookIds: NonNullable< + QueryResolvers["favoriteBookIds"] +> = async (_parent, _arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to view your favorite books.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + const favorites = await db.query.favoriteBooks.findMany({ + columns: { bookId: true }, + where: { + userId: ctx.session.user.id, + }, + }); + + return favorites.map((favorite) => favorite.bookId); +}; diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts new file mode 100644 index 0000000..af54cc9 --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts @@ -0,0 +1,45 @@ +import { GraphQLError } from "graphql/error"; +import { eq } from "drizzle-orm"; +import type { QueryResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { favoriteBooks as favoriteBooksTable } from "~/db/schema"; + +export const favoriteBooks: NonNullable< + QueryResolvers["favoriteBooks"] +> = async (_parent, arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to view your favorite books.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + const [foundBooks, totalCount] = await Promise.all([ + db.query.favoriteBooks.findMany({ + where: { + userId: ctx.session.user.id, + }, + with: { + book: true, + }, + orderBy: { + createdAt: "desc", + }, + limit: arg.limit, + offset: arg.offset, + }), + db.$count( + favoriteBooksTable, + eq(favoriteBooksTable.userId, ctx.session.user.id), + ), + ]); + + const books = foundBooks.flatMap(({ book }) => (book ? [book] : [])); + + return { + books, + totalCount, + }; +}; diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts new file mode 100644 index 0000000..2a7d162 --- /dev/null +++ b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts @@ -0,0 +1,30 @@ +import { GraphQLError } from "graphql/error"; +import { asc, count, desc, eq } from "drizzle-orm"; +import type { QueryResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { bookGenres, bookToGenres, favoriteBooks } from "~/db/schema"; + +export const favoriteGenres: NonNullable< + QueryResolvers["favoriteGenres"] +> = async (_parent, arg, ctx) => { + if (!ctx.session) { + throw new GraphQLError( + "You must be logged in to view your favorite genres.", + { + extensions: { code: "UNAUTHENTICATED" }, + }, + ); + } + + const genres = await db + .select({ genre: bookGenres.genre }) + .from(favoriteBooks) + .innerJoin(bookToGenres, eq(favoriteBooks.bookId, bookToGenres.bookId)) + .innerJoin(bookGenres, eq(bookToGenres.genreId, bookGenres.id)) + .where(eq(favoriteBooks.userId, ctx.session.user.id)) + .groupBy(bookGenres.id, bookGenres.genre) + .orderBy(desc(count()), asc(bookGenres.genre)) + .limit(arg.limit); + + return genres.map((g) => g.genre); +}; diff --git a/backend/src/schema/favorites/schema.graphql b/backend/src/schema/favorites/schema.graphql new file mode 100644 index 0000000..87732cd --- /dev/null +++ b/backend/src/schema/favorites/schema.graphql @@ -0,0 +1,13 @@ +extend type Query { + "Favorite books of the logged-in user, newest first." + favoriteBooks(limit: Int = 12, offset: Int = 0): BookSearchResult! + "ID of every book the logged-in user that has been added to their favorites." + favoriteBookIds: [Int!]! + "The logged-in user's most common genres among their favorite books." + favoriteGenres(limit: Int = 3): [String!]! +} + +extend type Mutation { + addFavoriteBook(bookId: Int!): Book! + removeFavoriteBook(bookId: Int!): Boolean! +} diff --git a/backend/src/schema/resolvers.generated.ts b/backend/src/schema/resolvers.generated.ts index 022f932..30e35fc 100644 --- a/backend/src/schema/resolvers.generated.ts +++ b/backend/src/schema/resolvers.generated.ts @@ -1,12 +1,17 @@ /* This file was automatically generated. DO NOT UPDATE MANUALLY. */ import type { Resolvers } from './types.generated'; import { book as Query_book } from './books/resolvers/Query/book'; +import { favoriteBookIds as Query_favoriteBookIds } from './favorites/resolvers/Query/favoriteBookIds'; +import { favoriteBooks as Query_favoriteBooks } from './favorites/resolvers/Query/favoriteBooks'; +import { favoriteGenres as Query_favoriteGenres } from './favorites/resolvers/Query/favoriteGenres'; import { searchBooks as Query_searchBooks } from './books/resolvers/Query/searchBooks'; +import { addFavoriteBook as Mutation_addFavoriteBook } from './favorites/resolvers/Mutation/addFavoriteBook'; +import { removeFavoriteBook as Mutation_removeFavoriteBook } from './favorites/resolvers/Mutation/removeFavoriteBook'; import { Book } from './books/resolvers/Book'; import { BookSearchResult } from './books/resolvers/BookSearchResult'; export const resolvers: Resolvers = { - Query: { book: Query_book,searchBooks: Query_searchBooks }, - + Query: { book: Query_book,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,searchBooks: Query_searchBooks }, + Mutation: { addFavoriteBook: Mutation_addFavoriteBook,removeFavoriteBook: Mutation_removeFavoriteBook }, Book: Book, BookSearchResult: BookSearchResult diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 5ac6a75..d63ba0c 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -4,6 +4,7 @@ type Book { coverImgUrl: String description: String edition: String + genres: [String!]! id: Int! isbn: String! language: String @@ -19,7 +20,23 @@ type BookSearchResult { totalCount: Int! } +type Mutation { + addFavoriteBook(bookId: Int!): Book! + removeFavoriteBook(bookId: Int!): Boolean! +} + type Query { book(id: Int!): Book + + """ + ID of every book the logged-in user that has been added to their favorites. + """ + favoriteBookIds: [Int!]! + + """Favorite books of the logged-in user, newest first.""" + favoriteBooks(limit: Int = 12, offset: Int = 0): BookSearchResult! + + """The logged-in user's most common genres among their favorite books.""" + favoriteGenres(limit: Int = 3): [String!]! searchBooks(author: String, bookFormat: String, language: String, limit: Int = 12, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult! } \ No newline at end of file diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 7c33094..8b6a17b 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index a4712cf..35dcb3c 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -1,4 +1,5 @@ import type { GraphQLResolveInfo } from 'graphql'; +import type { ApolloContext } from '~/index'; export type Maybe = T | null | undefined; export type InputMaybe = T | null | undefined; export type RequireFields = Omit & { [P in K]-?: NonNullable }; @@ -18,6 +19,7 @@ export type Book = { coverImgUrl?: Maybe; description?: Maybe; edition?: Maybe; + genres: Array; id: Scalars['Int']['output']; isbn: Scalars['String']['output']; language?: Maybe; @@ -34,9 +36,31 @@ export type BookSearchResult = { totalCount: Scalars['Int']['output']; }; +export type Mutation = { + __typename?: 'Mutation'; + addFavoriteBook: Book; + removeFavoriteBook: Scalars['Boolean']['output']; +}; + + +export type MutationaddFavoriteBookArgs = { + bookId: Scalars['Int']['input']; +}; + + +export type MutationremoveFavoriteBookArgs = { + bookId: Scalars['Int']['input']; +}; + export type Query = { __typename?: 'Query'; book?: Maybe; + /** ID of every book the logged-in user that has been added to their favorites. */ + favoriteBookIds: Array; + /** Favorite books of the logged-in user, newest first. */ + favoriteBooks: BookSearchResult; + /** The logged-in user's most common genres among their favorite books. */ + favoriteGenres: Array; searchBooks: BookSearchResult; }; @@ -46,6 +70,17 @@ export type QuerybookArgs = { }; +export type QueryfavoriteBooksArgs = { + limit?: InputMaybe; + offset?: InputMaybe; +}; + + +export type QueryfavoriteGenresArgs = { + limit?: InputMaybe; +}; + + export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; @@ -134,8 +169,9 @@ export type ResolversTypes = { String: ResolverTypeWrapper; Int: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper; - Query: ResolverTypeWrapper>; + Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; + Query: ResolverTypeWrapper>; }; /** Mapping between all available schema types and the resolvers parents */ @@ -144,16 +180,18 @@ export type ResolversParentTypes = { String: Scalars['String']['output']; Int: Scalars['Int']['output']; BookSearchResult: BookSearchResult; - Query: Record; + Mutation: Record; Boolean: Scalars['Boolean']['output']; + Query: Record; }; -export type BookResolvers = { +export type BookResolvers = { author?: Resolver; bookFormat?: Resolver; coverImgUrl?: Resolver, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; edition?: Resolver, ParentType, ContextType>; + genres?: Resolver, ParentType, ContextType>; id?: Resolver; isbn?: Resolver; language?: Resolver, ParentType, ContextType>; @@ -164,19 +202,28 @@ export type BookResolvers; }; -export type BookSearchResultResolvers = { +export type BookSearchResultResolvers = { books?: Resolver, ParentType, ContextType>; totalCount?: Resolver; }; -export type QueryResolvers = { +export type MutationResolvers = { + addFavoriteBook?: Resolver>; + removeFavoriteBook?: Resolver>; +}; + +export type QueryResolvers = { book?: Resolver, ParentType, ContextType, RequireFields>; + favoriteBookIds?: Resolver, ParentType, ContextType>; + favoriteBooks?: Resolver>; + favoriteGenres?: Resolver, ParentType, ContextType, RequireFields>; searchBooks?: Resolver>; }; -export type Resolvers = { +export type Resolvers = { Book?: BookResolvers; BookSearchResult?: BookSearchResultResolvers; + Mutation?: MutationResolvers; Query?: QueryResolvers; }; diff --git a/frontend/app/api/auth.ts b/frontend/app/api/auth.ts new file mode 100644 index 0000000..e535eb9 --- /dev/null +++ b/frontend/app/api/auth.ts @@ -0,0 +1,18 @@ +import { createAuthClient } from "better-auth/react"; +import { inferAdditionalFields } from "better-auth/client/plugins"; + +const authClient = createAuthClient({ + baseURL: "http://localhost:3001", + plugins: [ + inferAdditionalFields({ + user: { + bio: { + type: "string", + required: false, + }, + }, + }), + ], +}); + +export { authClient }; diff --git a/frontend/app/api/toBook.ts b/frontend/app/api/toBook.ts index 2e82e19..ce41fd4 100644 --- a/frontend/app/api/toBook.ts +++ b/frontend/app/api/toBook.ts @@ -1,6 +1,7 @@ import type { Book } from "~/api/types"; import type { BookQuery, + FavoriteBooksQuery, SearchBooksQuery, } from "~/api/types/graphql.generated"; @@ -19,7 +20,8 @@ function yearFromPublishDate(publishDate: string | null): number | undefined { export function toBook( book: | NonNullable - | NonNullable["books"][number], + | NonNullable["books"][number] + | FavoriteBooksQuery["favoriteBooks"]["books"][number], ): Book { return { id: String(book.id), diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 49a2f65..905db23 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -1,26 +1,40 @@ /** Internal type. DO NOT USE DIRECTLY. */ type Exact = { [K in keyof T]: T[K] }; /** Internal type. DO NOT USE DIRECTLY. */ -export type Incremental = - | T - | { - [P in keyof T]?: P extends " $fragmentName" | "__typename" ? T[P] : never; - }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; export type BookQueryVariables = Exact<{ id: number; }>; -export type BookQuery = { - book: { - __typename: "Book"; - id: number; - title: string; - author: string; - publishDate: string | null; - coverImgUrl: string | null; - description: string | null; - } | null; -}; + +export type BookQuery = { book: { __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, description: string | null } | null }; + +export type FavoriteBooksQueryVariables = Exact<{ + limit?: number | null | undefined; + offset?: number | null | undefined; +}>; + + +export type FavoriteBooksQuery = { favoriteGenres: Array, favoriteBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null }> } }; + +export type FavoriteBookIdsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FavoriteBookIdsQuery = { favoriteBookIds: Array }; + +export type AddFavoriteBookMutationVariables = Exact<{ + bookId: number; +}>; + + +export type AddFavoriteBookMutation = { addFavoriteBook: { __typename: 'Book', id: number } }; + +export type RemoveFavoriteBookMutationVariables = Exact<{ + bookId: number; +}>; + + +export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; @@ -28,17 +42,5 @@ export type SearchBooksQueryVariables = Exact<{ offset?: number | null | undefined; }>; -export type SearchBooksQuery = { - searchBooks: { - __typename: "BookSearchResult"; - totalCount: number; - books: Array<{ - __typename: "Book"; - id: number; - title: string; - author: string; - publishDate: string | null; - coverImgUrl: string | null; - }>; - }; -}; + +export type SearchBooksQuery = { searchBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null }> } }; diff --git a/frontend/app/api/useFavoriteBooks.ts b/frontend/app/api/useFavoriteBooks.ts new file mode 100644 index 0000000..9beacd7 --- /dev/null +++ b/frontend/app/api/useFavoriteBooks.ts @@ -0,0 +1,113 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { toBook } from "~/api/toBook"; +import type { + AddFavoriteBookMutation, + AddFavoriteBookMutationVariables, + FavoriteBookIdsQuery, + FavoriteBookIdsQueryVariables, + FavoriteBooksQuery, + FavoriteBooksQueryVariables, + RemoveFavoriteBookMutation, + RemoveFavoriteBookMutationVariables, +} from "~/api/types/graphql.generated"; + +export const FAVORITES_LIMIT = 12; + +export const FAVORITE_BOOKS: TypedDocumentNode< + FavoriteBooksQuery, + FavoriteBooksQueryVariables +> = gql` + query FavoriteBooks($limit: Int, $offset: Int) { + favoriteBooks(limit: $limit, offset: $offset) { + totalCount + books { + id + title + author + publishDate + coverImgUrl + } + } + favoriteGenres + } +`; + +export const FAVORITE_BOOK_IDS: TypedDocumentNode< + FavoriteBookIdsQuery, + FavoriteBookIdsQueryVariables +> = gql` + query FavoriteBookIds { + favoriteBookIds + } +`; + +export const ADD_FAVORITE_BOOK: TypedDocumentNode< + AddFavoriteBookMutation, + AddFavoriteBookMutationVariables +> = gql` + mutation AddFavoriteBook($bookId: Int!) { + addFavoriteBook(bookId: $bookId) { + id + } + } +`; + +export const REMOVE_FAVORITE_BOOK: TypedDocumentNode< + RemoveFavoriteBookMutation, + RemoveFavoriteBookMutationVariables +> = gql` + mutation RemoveFavoriteBook($bookId: Int!) { + removeFavoriteBook(bookId: $bookId) + } +`; + +export const useFavoriteBooks = ({ + enabled, + page, +}: { + enabled: boolean; + page: number; +}) => { + const { data, loading, error } = useQuery(FAVORITE_BOOKS, { + variables: { limit: FAVORITES_LIMIT, offset: (page - 1) * FAVORITES_LIMIT }, + skip: !enabled, + // Favorites could be added on other pages, so we want to refetch our favorites when the user visits their profile page. + fetchPolicy: "cache-and-network", + }); + + return { + books: data?.favoriteBooks.books.map(toBook) ?? [], + totalCount: data?.favoriteBooks.totalCount ?? 0, + topGenres: data?.favoriteGenres ?? [], + loading, + error, + }; +}; + +export const useFavoriteStatus = ({ enabled }: { enabled: boolean }) => { + const { data } = useQuery(FAVORITE_BOOK_IDS, { skip: !enabled }); + const favoriteBookIds = data?.favoriteBookIds ?? []; + + const options = { + refetchQueries: [FAVORITE_BOOK_IDS, FAVORITE_BOOKS], + awaitRefetchQueries: true, + }; + const [addFavoriteBook, { loading: adding }] = useMutation( + ADD_FAVORITE_BOOK, + options, + ); + const [removeFavoriteBook, { loading: removing }] = useMutation( + REMOVE_FAVORITE_BOOK, + options, + ); + + return { + isFavorite: (bookId: string) => favoriteBookIds.includes(Number(bookId)), + addFavoriteBook: (bookId: string) => + addFavoriteBook({ variables: { bookId: Number(bookId) } }), + removeFavoriteBook: (bookId: string) => + removeFavoriteBook({ variables: { bookId: Number(bookId) } }), + saving: adding || removing, + }; +}; diff --git a/frontend/app/components/FavoriteButton/FavoriteButton.tsx b/frontend/app/components/FavoriteButton/FavoriteButton.tsx index 26c74e3..26e9aee 100644 --- a/frontend/app/components/FavoriteButton/FavoriteButton.tsx +++ b/frontend/app/components/FavoriteButton/FavoriteButton.tsx @@ -1,5 +1,19 @@ import { useState } from "react"; +import { Link } from "react-router"; +import { authClient } from "~/api/auth"; +import { useFavoriteStatus } from "~/api/useFavoriteBooks"; import { CheckIcon } from "~/components/icons/CheckIcon"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/AlertDialog"; +import { buttonVariants } from "~/components/ui/Button"; +import { toast } from "~/components/ui/Toast"; import type { Book } from "~/api/types"; interface FavoriteButtonProps { @@ -7,41 +21,68 @@ interface FavoriteButtonProps { } function FavoriteButton({ book }: FavoriteButtonProps) { - const [favorites, setFavorites] = useState(() => { - const rawFavorites = localStorage.getItem("favorites"); - return JSON.parse(rawFavorites ?? "[]") as string[]; - }); - - const saveFavorite = () => { - const rawFavorites = localStorage.getItem("favorites"); - let savedFavorites = JSON.parse(rawFavorites ?? "[]") as string[]; - - if (savedFavorites.includes(book.id)) { - // Remove from favorites if already present - savedFavorites = savedFavorites.filter((id) => id !== book.id); - } else { - savedFavorites.push(book.id); + const { data: session } = authClient.useSession(); + const { isFavorite, addFavoriteBook, removeFavoriteBook, saving } = + useFavoriteStatus({ enabled: !!session }); + + const [loginDialogOpen, setLoginDialogOpen] = useState(false); + + const favorite = isFavorite(book.id); + + const toggleFavorite = async () => { + if (!session) { + setLoginDialogOpen(true); + return; } - localStorage.setItem("favorites", JSON.stringify(savedFavorites)); - setFavorites(savedFavorites); + try { + if (favorite) { + await removeFavoriteBook(book.id); + } else { + await addFavoriteBook(book.id); + } + } catch { + toast.add({ + description: "Could not update your favorites. Please try again.", + type: "error", + }); + } }; return ( - + <> + + + + + Log in to save favorites + + You need an account to save {book.title} to your favorites. + + + + Cancel + + Log in + + + + + ); } diff --git a/frontend/app/components/Header/Header.tsx b/frontend/app/components/Header/Header.tsx index e1d6d66..52622f1 100644 --- a/frontend/app/components/Header/Header.tsx +++ b/frontend/app/components/Header/Header.tsx @@ -1,7 +1,29 @@ +import { Link } from "react-router"; +import { authClient } from "~/api/auth"; +import { Button, buttonVariants } from "~/components/ui/Button"; + function Header() { + const { data: session, isPending } = authClient.useSession(); + return (
-

Readr

+ +

Readr

+ + {!isPending && session ? ( +
+ + {session.user.name} + + +
+ ) : ( + + Log in + + )}
); } diff --git a/frontend/app/components/settings/PasswordForm.tsx b/frontend/app/components/settings/PasswordForm.tsx new file mode 100644 index 0000000..636985a --- /dev/null +++ b/frontend/app/components/settings/PasswordForm.tsx @@ -0,0 +1,209 @@ +import { useForm } from "@tanstack/react-form"; +import { useState } from "react"; +import z from "zod"; +import { authClient } from "~/api/auth"; +import { Button } from "~/components/ui/Button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/Card"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "~/components/ui/Field"; +import { Input } from "~/components/ui/Input"; +import { Spinner } from "~/components/ui/Spinner"; +import { toast } from "~/components/ui/Toast"; + +// Make sure this matches the minimum password length set in backend/src/auth/index.ts +const MIN_PASSWORD_LENGTH = 8; + +const passwordFormSchema = z.object({ + currentPassword: z.string().min(1, "Enter your current password."), + newPassword: z + .string() + .min( + MIN_PASSWORD_LENGTH, + `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`, + ), + confirmPassword: z.string().min(1, "Confirm your new password."), +}); + +function PasswordForm() { + const [serverError, setServerError] = useState(null); + + const form = useForm({ + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + validators: { + onSubmit: passwordFormSchema, + }, + onSubmit: async ({ value }) => { + setServerError(null); + const { error } = await authClient.changePassword({ + currentPassword: value.currentPassword, + newPassword: value.newPassword, + revokeOtherSessions: true, + }); + + if (error) { + setServerError( + error.message ?? "Could not change your password. Please try again.", + ); + return; + } + + toast.add({ + description: "Your password has been changed.", + type: "success", + }); + form.reset(); + }, + }); + + return ( + + + +

Password

+
+ + Changing your password logs you out on your other devices. + +
+ +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Current password + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="current-password" + /> + {isInvalid && ( + + )} + + ); + }} + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + New password + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + aria-describedby="new-password-description" + autoComplete="new-password" + /> + + At least {MIN_PASSWORD_LENGTH} characters. + + {isInvalid && ( + + )} + + ); + }} + + { + if (value !== fieldApi.form.getFieldValue("newPassword")) { + return { message: "Passwords do not match." }; + } + return undefined; + }, + }} + > + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Confirm new password + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="new-password" + /> + {isInvalid && ( + + )} + + ); + }} + + {serverError} + +
+
+ + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + +
+ ); +} + +export { PasswordForm }; diff --git a/frontend/app/components/settings/ProfileForm.tsx b/frontend/app/components/settings/ProfileForm.tsx new file mode 100644 index 0000000..1ca673b --- /dev/null +++ b/frontend/app/components/settings/ProfileForm.tsx @@ -0,0 +1,160 @@ +import { useForm } from "@tanstack/react-form"; +import { useState } from "react"; +import z from "zod"; +import { authClient } from "~/api/auth"; +import { Button } from "~/components/ui/Button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/Card"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "~/components/ui/Field"; +import { Input } from "~/components/ui/Input"; +import { Spinner } from "~/components/ui/Spinner"; +import { Textarea } from "~/components/ui/Textarea"; +import { toast } from "~/components/ui/Toast"; + +const MAX_BIO_LENGTH = 300; + +const profileFormSchema = z.object({ + name: z.string().trim().min(1, "Enter your name."), + bio: z + .string() + .max(MAX_BIO_LENGTH, `Bio must be at most ${MAX_BIO_LENGTH} characters.`), +}); + +function ProfileForm({ name, bio }: { name: string; bio: string }) { + const [serverError, setServerError] = useState(null); + + const form = useForm({ + defaultValues: { + name, + bio, + }, + validators: { + onSubmit: profileFormSchema, + }, + onSubmit: async ({ value }) => { + setServerError(null); + const { error } = await authClient.updateUser({ + name: value.name.trim(), + bio: value.bio, + }); + + if (error) { + setServerError( + error.message ?? "Could not update your profile. Please try again.", + ); + return; + } + + toast.add({ + description: "Your profile has been updated.", + type: "success", + }); + }, + }); + + return ( + + + +

Profile

+
+ + Change how your name and bio appear on your profile. + +
+ +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Name + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + autoComplete="name" + /> + {isInvalid && ( + + )} + + ); + }} + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Bio +