diff --git a/.circleci/config.yml b/.circleci/config.yml index ee8e59892..75b98b7d6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,6 +33,7 @@ jobs: POSTGRES_HOST: localhost POSTGRES_SSL: false POSTGRES_LOGGING: false + DATABASE_URL: postgresql://chefbook:admin@localhost:5432/chefbook_test SEARCH_PROVIDER: none STORAGE_TYPE: filesystem FILESYSTEM_STORAGE_PATH: ~/project/rsdata @@ -49,6 +50,9 @@ jobs: - run: name: prettier command: npx prettier --check . + - run: + name: migrate + command: npx prisma migrate dev - run: name: lint, typecheck, test, build command: npx nx run-many --targets=lint,typecheck,test,build diff --git a/docker-compose.yml b/docker-compose.yml index e67ba7375..0717bb13f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: - POSTGRES_SSL=false - POSTGRES_LOGGING=true - DATABASE_URL=postgresql://recipesage_dev:recipesage_dev@postgres:5432/recipesage_dev + - OPENAI_GPT_MODEL=gpt-3.5-turbo-1106 - GCM_KEYPAIR - SENTRY_SAMPLE_RATE=0 - GRIP_URL=http://pushpin:5561/ diff --git a/example.env b/example.env index f263bae1a..5b9df9b42 100644 --- a/example.env +++ b/example.env @@ -3,3 +3,4 @@ AWS_SECRET_ACCESS_KEY=KEY STRIPE_SK=KEY STRIPE_WEBHOOK_SECRET=KEY SENTRY_DSN=VAL +OPENAI_API_KEY=VAL diff --git a/kube/configs/api-beta.yml b/kube/configs/api-beta.yml index 12fb01781..4f2d893c7 100644 --- a/kube/configs/api-beta.yml +++ b/kube/configs/api-beta.yml @@ -70,6 +70,8 @@ spec: value: "true" - name: TYPESENSE_NODES value: '[{"host": "rs-typesense", "port": 8108, "protocol": "http"}]' + - name: OPENAI_GPT_MODEL + value: gpt-3.5-turbo-1106 volumeMounts: - name: firebase-credentials mountPath: /app/packages/backend/src/config/firebase-credentials.json diff --git a/kube/configs/api.yml b/kube/configs/api.yml index 189815ed1..a370a7dbd 100644 --- a/kube/configs/api.yml +++ b/kube/configs/api.yml @@ -69,6 +69,8 @@ spec: value: "true" - name: TYPESENSE_NODES value: '[{"host": "rs-typesense", "port": 8108, "protocol": "http"}]' + - name: OPENAI_GPT_MODEL + value: gpt-3.5-turbo-1106 volumeMounts: - name: firebase-credentials mountPath: /app/packages/backend/src/config/firebase-credentials.json diff --git a/kube/configs/secrets-template.yml b/kube/configs/secrets-template.yml index 6ad698394..bbdbb0df1 100644 --- a/kube/configs/secrets-template.yml +++ b/kube/configs/secrets-template.yml @@ -21,6 +21,7 @@ stringData: POSTGRES_HOST: "" POSTGRES_SSL: "" POSTGRES_LOGGING: "" + OPENAI_API_KEY: "" GCM_KEYPAIR: "" SENTRY_DSN: "" TYPESENSE_API_KEY: "" diff --git a/package-lock.json b/package-lock.json index a59cc78b3..fcee6cc5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "ngx-ui-scroll": "^3.0.1", "node-cron": "^3.0.2", "node-fetch": "^2.6.7", + "openai": "^4.17.4", "p-limit": "^3.1.0", "pdfmake": "^0.2.7", "pg": "8.8.0", @@ -87,7 +88,7 @@ "tslib": "^2.5.0", "tsx": "^3.12.6", "typesense": "^1.5.4", - "umzug": "^3.2.1", + "umzug": "^3.4.0", "unitz-ts": "^1.0.1", "uuid": "^9.0.0", "xml-js": "^1.6.11", @@ -11375,7 +11376,6 @@ "version": "2.6.4", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", - "dev": true, "dependencies": { "@types/node": "*", "form-data": "^3.0.0" @@ -12661,7 +12661,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -12808,7 +12807,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.3.0.tgz", "integrity": "sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==", - "devOptional": true, "dependencies": { "debug": "^4.1.0", "depd": "^2.0.0", @@ -14103,6 +14101,11 @@ "node": ">=0.10.0" } }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base/node_modules/define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", @@ -14920,6 +14923,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", @@ -15935,6 +15946,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", @@ -16556,6 +16575,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/digest-fetch": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", + "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", + "dependencies": { + "base-64": "^0.1.0", + "md5": "^2.3.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -16874,7 +16902,6 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, "engines": { "node": ">=12" }, @@ -18092,7 +18119,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "optional": true, "engines": { "node": ">=6" } @@ -19050,7 +19076,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -19060,6 +19085,31 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/formidable": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", @@ -19132,26 +19182,6 @@ "node": ">=14.14" } }, - "node_modules/fs-jetpack": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/fs-jetpack/-/fs-jetpack-4.3.1.tgz", - "integrity": "sha512-dbeOK84F6BiQzk2yqqCVwCPWTxAvVGJ3fMQc6E2wuEohS28mR6yHngbrKuVCK1KHRx/ccByDylqu4H5PCP2urQ==", - "dependencies": { - "minimatch": "^3.0.2", - "rimraf": "^2.6.3" - } - }, - "node_modules/fs-jetpack/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/fs-minipass": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.2.tgz", @@ -20751,7 +20781,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "devOptional": true, "dependencies": { "ms": "^2.0.0" } @@ -25888,6 +25917,16 @@ "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==" }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mdb": { "version": "0.1.0", "resolved": "git+https://git@github.com/julianpoy/node-mdb.git#d2351d7c84965a5ae8a1260c6c3996a603525f79", @@ -26988,6 +27027,24 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", @@ -28034,6 +28091,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.17.4.tgz", + "integrity": "sha512-ThRFkl6snLbcAKS58St7N3CaKuI5WdYUvIjPvf4s+8SdymgNtOfzmZcZXVcCefx04oKFnvZJvIcTh3eAFUUhAQ==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "digest-fetch": "^1.3.0", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + }, + "bin": { + "openai": "bin/cli" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -34713,16 +34789,15 @@ } }, "node_modules/umzug": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.2.1.tgz", - "integrity": "sha512-XyWQowvP9CKZycKc/Zg9SYWrAWX/gJCE799AUTFqk8yC3tp44K1xWr3LoFF0MNEjClKOo1suCr5ASnoy+KltdA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.4.0.tgz", + "integrity": "sha512-bTen9ElCBoWU1mhcaXqVZWXxB1PojsBQBs/4vW0YV8f5CfhuhkfRjQZj6SCb6IuHWPkccDzF+T+RGZCYUiXaKg==", "dependencies": { "@rushstack/ts-command-line": "^4.12.2", - "emittery": "^0.12.1", - "fs-jetpack": "^4.3.1", + "emittery": "^0.13.0", "glob": "^8.0.3", - "pony-cause": "^2.1.2", - "type-fest": "^2.18.0" + "pony-cause": "^2.1.4", + "type-fest": "^3.0.0" }, "engines": { "node": ">=12" @@ -34736,17 +34811,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/umzug/node_modules/emittery": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.12.1.tgz", - "integrity": "sha512-pYyW59MIZo0HxPFf+Vb3+gacUu0gxVS3TZwB2ClwkEZywgF9f9OJDoVmNLojTn0vKX3tO9LC+pdQEcLP4Oz/bQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "node_modules/umzug/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -34777,11 +34841,11 @@ } }, "node_modules/umzug/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", "engines": { - "node": ">=12.20" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -37198,6 +37262,14 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 92a812b7d..ca06e02f4 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "ngx-ui-scroll": "^3.0.1", "node-cron": "^3.0.2", "node-fetch": "^2.6.7", + "openai": "^4.17.4", "p-limit": "^3.1.0", "pdfmake": "^0.2.7", "pg": "8.8.0", @@ -85,7 +86,7 @@ "tslib": "^2.5.0", "tsx": "^3.12.6", "typesense": "^1.5.4", - "umzug": "^3.2.1", + "umzug": "^3.4.0", "unitz-ts": "^1.0.1", "uuid": "^9.0.0", "xml-js": "^1.6.11", diff --git a/packages/backend/src/models/assistantMessage.js b/packages/backend/src/models/assistantMessage.js new file mode 100644 index 000000000..2009cc195 --- /dev/null +++ b/packages/backend/src/models/assistantMessage.js @@ -0,0 +1,37 @@ +export const AssistantMessageInit = (sequelize, DataTypes) => { + const AssistantMessage = sequelize.define( + "AssistantMessage", + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + }, + data: { + type: DataTypes.JSONB, + allowNull: false, + }, + role: { + type: DataTypes.STRING, + allowNull: false, + }, + type: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + {} + ); + AssistantMessage.associate = function (models) { + AssistantMessage.belongsTo(models.User, { + foreignKey: { + name: "userId", + allowNull: false, + }, + as: "user", + onDelete: "CASCADE", + }); + }; + return AssistantMessage; +}; diff --git a/packages/backend/src/models/index.js b/packages/backend/src/models/index.js index dbe368e28..da3ca76bd 100644 --- a/packages/backend/src/models/index.js +++ b/packages/backend/src/models/index.js @@ -69,6 +69,11 @@ export const Message = MessageInit(sequelize, DataTypes); db.modelNames.push(Message.name); db[Message.name] = Message; +import { AssistantMessageInit } from "./assistantMessage.js"; +export const AssistantMessage = AssistantMessageInit(sequelize, DataTypes); +db.modelNames.push(AssistantMessage.name); +db[AssistantMessage.name] = AssistantMessage; + import { ProfileItemInit } from "./profileitem.js"; export const ProfileItem = ProfileItemInit(sequelize, DataTypes); db.modelNames.push(ProfileItem.name); diff --git a/packages/backend/src/models/recipe.spec.js b/packages/backend/src/models/recipe.spec.js index 135f89e2c..91dbed809 100644 --- a/packages/backend/src/models/recipe.spec.js +++ b/packages/backend/src/models/recipe.spec.js @@ -4,7 +4,6 @@ import * as sinon from "sinon"; import { setup, cleanup, - syncDB, randomString, createUser, createRecipe, @@ -56,10 +55,6 @@ describe("recipe", () => { }); describe("_findTitle", () => { - beforeEach(async () => { - await syncDB(); - }); - it("returns initial name when no conflicts arise", async () => { let user = await createUser(); @@ -121,7 +116,6 @@ describe("recipe", () => { let _shareStub; beforeAll(async () => { - await syncDB(); _shareStub = sinon .stub(Recipe.prototype, "share") .returns(Promise.resolve()); @@ -152,7 +146,6 @@ describe("recipe", () => { describe("instance.share", () => { let findTitleStub; beforeAll(async () => { - await syncDB(); findTitleStub = sinon .stub(Recipe, "findTitle") .callsFake((a, b, title) => Promise.resolve(title)); @@ -163,13 +156,15 @@ describe("recipe", () => { }); describe("shares recipe to recipient", () => { - let user1, user2, recipe, sharedRecipe; + let user1, user2, recipe, sharedRecipe, initialCount; beforeAll(async () => { user1 = await createUser(); user2 = await createUser(); recipe = await createRecipe(user1.id); + initialCount = await Recipe.count(); + await sequelize.transaction(async (t) => { sharedRecipe = await recipe.share(user2.id, t); }); @@ -179,7 +174,9 @@ describe("recipe", () => { expect(recipe.id).not.to.equal(sharedRecipe.id); return Promise.all([ - Recipe.count().then((count) => expect(count).to.equal(2)), + Recipe.count().then((count) => + expect(count).to.equal(initialCount + 1) + ), Recipe.findByPk(recipe.id).then((r) => expect(r).to.not.be.null), Recipe.findByPk(sharedRecipe.id).then( (r) => expect(r).to.not.be.null diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 97049228e..d96d5207e 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -84,6 +84,11 @@ export const UserInit = (sequelize, DataTypes) => { as: "ownedShoppingLists", }); + User.hasMany(models.AssistantMessage, { + foreignKey: "userId", + as: "assistantMessages", + }); + User.belongsToMany(models.ShoppingList, { foreignKey: "userId", otherKey: "shoppingListId", diff --git a/packages/backend/src/routes/labels.spec.js b/packages/backend/src/routes/labels.spec.js index f1d726cb9..c7d4f7457 100644 --- a/packages/backend/src/routes/labels.spec.js +++ b/packages/backend/src/routes/labels.spec.js @@ -4,7 +4,6 @@ import { expect } from "chai"; import { setup, cleanup, - syncDB, randomString, createUser, createSession, @@ -24,10 +23,6 @@ describe("labels", () => { server = await setup(); }); - beforeEach(async () => { - await syncDB(); - }); - afterAll(async () => { await cleanup(); }); @@ -130,13 +125,16 @@ describe("labels", () => { recipeId: "invalid", }; + const intialCount = await Label.count(); + return request(server) .post("/labels") .query({ token: session.token }) .send(payload) .expect(500) - .then(() => { - Label.count().then((count) => expect(count).to.equal(0)); + .then(async () => { + const count = await Label.count(); + expect(count).to.equal(intialCount); }); }); diff --git a/packages/backend/src/routes/messages.spec.js b/packages/backend/src/routes/messages.spec.js index 0a76f50ae..90451ee25 100644 --- a/packages/backend/src/routes/messages.spec.js +++ b/packages/backend/src/routes/messages.spec.js @@ -5,7 +5,6 @@ import * as sinon from "sinon"; import { setup, cleanup, - syncDB, randomString, createUser, createSession, @@ -27,10 +26,6 @@ describe("messages", () => { server = await setup(); }); - beforeEach(async () => { - await syncDB(); - }); - afterAll(async () => { await cleanup(); }); diff --git a/packages/backend/src/routes/recipes.js b/packages/backend/src/routes/recipes.js index e3a51b453..2355a5cfa 100644 --- a/packages/backend/src/routes/recipes.js +++ b/packages/backend/src/routes/recipes.js @@ -75,16 +75,16 @@ router.post( joiValidator( Joi.object({ body: Joi.object({ - title: Joi.string().allow("").optional(), // TODO: change to required once frontend no longer needs PreconditionFailed - description: Joi.string().allow("").optional(), - yield: Joi.string().allow("").optional(), - activeTime: Joi.string().allow("").optional(), - totalTime: Joi.string().allow("").optional(), - source: Joi.string().allow("").optional(), - url: Joi.string().allow("").optional(), - notes: Joi.string().allow("").optional(), - ingredients: Joi.string().allow("").optional(), - instructions: Joi.string().allow("").optional(), + title: Joi.string().allow("").optional().allow(null), // TODO: change to required once frontend no longer needs PreconditionFailed + description: Joi.string().allow("", null).optional(), + yield: Joi.string().allow("", null).optional(), + activeTime: Joi.string().allow("", null).optional(), + totalTime: Joi.string().allow("", null).optional(), + source: Joi.string().allow("", null).optional(), + url: Joi.string().allow("", null).optional(), + notes: Joi.string().allow("", null).optional(), + ingredients: Joi.string().allow("", null).optional(), + instructions: Joi.string().allow("", null).optional(), rating: Joi.number().min(1).max(5).allow(null).optional(), labels: Joi.array().items(Joi.string()).optional(), imageIds: Joi.array().items(Joi.string().uuid()).optional(), diff --git a/packages/backend/src/routes/recipes.spec.js b/packages/backend/src/routes/recipes.spec.js index f16540d39..5ee5b51ed 100644 --- a/packages/backend/src/routes/recipes.spec.js +++ b/packages/backend/src/routes/recipes.spec.js @@ -6,7 +6,6 @@ const Op = Sequelize.Op; import { setup, cleanup, - syncDB, randomString, createUser, createSession, @@ -26,10 +25,6 @@ describe("recipes", () => { server = await setup(); }); - beforeEach(async () => { - await syncDB(); - }); - afterAll(async () => { await cleanup(); }); @@ -40,6 +35,8 @@ describe("recipes", () => { const session = await createSession(user.id); + const initialCount = await Recipe.count(); + const payload = { title: randomString(20), description: randomString(20), @@ -68,10 +65,9 @@ describe("recipes", () => { .then((recipe) => { expect(recipe).not.to.be.null; }) - .then(() => { - Recipe.count().then((count) => { - expect(count).to.equal(1); - }); + .then(async () => { + const count = await Recipe.count(); + expect(count).to.equal(initialCount + 1); }) ); }); @@ -81,6 +77,8 @@ describe("recipes", () => { const session = await createSession(user.id); + const initialCount = await Recipe.count(); + const payload = { title: randomString(20), }; @@ -99,10 +97,9 @@ describe("recipes", () => { .then((recipe) => { expect(recipe).not.to.be.null; }) - .then(() => { - Recipe.count().then((count) => { - expect(count).to.equal(1); - }); + .then(async () => { + const count = await Recipe.count(); + expect(count).to.equal(initialCount + 1); }) ); }); @@ -112,6 +109,8 @@ describe("recipes", () => { const session = await createSession(user.id); + const initialCount = await Recipe.count(); + const payload = { description: randomString(20), yield: randomString(20), @@ -130,11 +129,10 @@ describe("recipes", () => { .query({ token: session.token }) .send(payload) .expect(412) - .then(() => - Recipe.count().then((count) => { - expect(count).to.equal(0); - }) - ); + .then(async () => { + const count = await Recipe.count(); + expect(count).to.equal(initialCount); + }); }); it("rejects if title is an empty string", async () => { @@ -142,6 +140,8 @@ describe("recipes", () => { const session = await createSession(user.id); + const initialCount = await Recipe.count(); + const payload = { title: "", description: randomString(20), @@ -161,14 +161,15 @@ describe("recipes", () => { .query({ token: session.token }) .send(payload) .expect(412) - .then(() => - Recipe.count().then((count) => { - expect(count).to.equal(0); - }) - ); + .then(async () => { + const count = await Recipe.count(); + expect(count).to.equal(initialCount); + }); }); it("rejects invalid token", async () => { + const initialCount = await Recipe.count(); + const payload = { title: randomString(20), description: randomString(20), @@ -188,11 +189,10 @@ describe("recipes", () => { .send(payload) .query({ token: "invalid" }) .expect(401) - .then(() => - Recipe.count().then((count) => { - expect(count).to.equal(0); - }) - ); + .then(async () => { + const count = await Recipe.count(); + expect(count).to.equal(initialCount); + }); }); }); diff --git a/packages/backend/src/routes/users.js b/packages/backend/src/routes/users.js index 0ec056735..3942be05c 100644 --- a/packages/backend/src/routes/users.js +++ b/packages/backend/src/routes/users.js @@ -168,7 +168,7 @@ router.put( const canUploadMultipleImages = await SubscriptionService.userHasCapability( res.locals.session.userId, - SubscriptionService.CAPABILITIES.MULTIPLE_IMAGES + SubscriptionService.Capabilities.MultipleImages ); if (!canUploadMultipleImages && req.body.profileImageIds.length > 1) { @@ -486,7 +486,7 @@ router.get( res.locals.session.userId ); - const capabilityTypes = Object.values(SubscriptionService.CAPABILITIES); + const capabilityTypes = Object.values(SubscriptionService.Capabilities); const capabilityMap = capabilityTypes.reduce((acc, capabilityType) => { acc[capabilityType] = userCapabilities.indexOf(capabilityType) > -1; diff --git a/packages/backend/src/routes/users.spec.js b/packages/backend/src/routes/users.spec.js index fa86d6aa5..57c4b7378 100644 --- a/packages/backend/src/routes/users.spec.js +++ b/packages/backend/src/routes/users.spec.js @@ -4,7 +4,6 @@ import { expect } from "chai"; import { setup, cleanup, - syncDB, randomString, randomEmail, createUser, @@ -22,10 +21,6 @@ describe("users", () => { server = await setup(); }); - beforeEach(async () => { - await syncDB(); - }); - afterAll(async () => { await cleanup(); }); diff --git a/packages/backend/src/services/subscriptions.js b/packages/backend/src/services/subscriptions.js index 1c00a840d..29d152c57 100644 --- a/packages/backend/src/services/subscriptions.js +++ b/packages/backend/src/services/subscriptions.js @@ -1,90 +1,25 @@ import * as moment from "moment"; +import { + subscriptionsForUser as _subscriptionsForUser, + capabilitiesForUser as _capabilitiesForUser, + capabilitiesForSubscription as _capabilitiesForSubscription, + userHasCapability as _userHasCapability, + Capabilities as _Capabilities, + SubscriptionModels as _SubscriptionModels, +} from "@recipesage/trpc"; // DB -import { Op } from "sequelize"; import { UserSubscription } from "../models/index.js"; -const CAPABILITY_GRACE_PERIOD = 7; - -export const CAPABILITIES = { - HIGH_RES_IMAGES: "highResImages", - MULTIPLE_IMAGES: "multipleImages", - EXPANDABLE_PREVIEWS: "expandablePreviews", -}; - -const SUBSCRIPTION_MODELS = { - "pyo-monthly": { - title: "Choose your own price", - expiresIn: 31, - capabilities: [ - CAPABILITIES.HIGH_RES_IMAGES, - CAPABILITIES.MULTIPLE_IMAGES, - CAPABILITIES.EXPANDABLE_PREVIEWS, - ], - }, - "pyo-single": { - title: "Choose your own price - One time", - expiresIn: 365, - capabilities: [ - CAPABILITIES.HIGH_RES_IMAGES, - CAPABILITIES.MULTIPLE_IMAGES, - CAPABILITIES.EXPANDABLE_PREVIEWS, - ], - }, - forever: { - title: "The Forever Subscription...", - expiresIn: 3650, // 10 years - okay, not quite forever - capabilities: [ - CAPABILITIES.HIGH_RES_IMAGES, - CAPABILITIES.MULTIPLE_IMAGES, - CAPABILITIES.EXPANDABLE_PREVIEWS, - ], - }, -}; - -export const modelsForCapability = (capability) => { - return Object.keys(SUBSCRIPTION_MODELS) - .map((modelName) => SUBSCRIPTION_MODELS[modelName]) - .filter((model) => model.capabilities.indexOf(capability) > -1); -}; - -export const subscriptionsForUser = async (userId, includeExpired) => { - // Allow users to continue to access expired features for grace period - const mustBeValidUntil = includeExpired - ? moment(new Date("1980")) - : moment().subtract(CAPABILITY_GRACE_PERIOD, "days"); - - return UserSubscription.findAll({ - where: { - userId, - name: { [Op.ne]: null }, - expires: { - [Op.or]: [{ [Op.gte]: mustBeValidUntil }, null], - }, - }, - }); -}; - -export const capabilitiesForSubscription = (subscriptionName) => { - return SUBSCRIPTION_MODELS[subscriptionName].capabilities; -}; - -export const capabilitiesForUser = async (userId) => { - const activeSubscriptions = await subscriptionsForUser(userId); - - return activeSubscriptions.reduce((acc, activeSubscription) => { - const capabilities = capabilitiesForSubscription(activeSubscription.name); - return [...acc, ...capabilities]; - }, []); -}; - -export const userHasCapability = async (userId, capability) => { - const capabilities = await capabilitiesForUser(userId); - return capabilities.indexOf(capability) > -1; -}; +export const subscriptionsForUser = _subscriptionsForUser; +export const capabilitiesForUser = _capabilitiesForUser; +export const capabilitiesForSubscription = _capabilitiesForSubscription; +export const userHasCapability = _userHasCapability; +export const SubscriptionModels = _SubscriptionModels; +export const Capabilities = _Capabilities; export const extend = async (userId, subscriptionName, transaction) => { - const renewalLength = SUBSCRIPTION_MODELS[subscriptionName].expiresIn; + const renewalLength = SubscriptionModels[subscriptionName].expiresIn; const existingSubscription = await UserSubscription.findOne({ where: { diff --git a/packages/backend/src/testutils.js b/packages/backend/src/testutils.js index f44fc4d3f..759a34e75 100644 --- a/packages/backend/src/testutils.js +++ b/packages/backend/src/testutils.js @@ -1,67 +1,16 @@ import { expect } from "chai"; import { v4 as uuid } from "uuid"; -import * as path from "path"; -import { Umzug, SequelizeStorage } from "umzug"; - -import { - sequelize, - Sequelize, - modelNames, - User, - Session, - Recipe, - Label, - Message, -} from "./models/index.js"; - -const umzug = new Umzug({ - migrations: { - glob: path.join(__dirname, "migrations/*.js"), - resolve: ({ name, path }) => { - return { - name, - up: async () => - (await import(path)).up(sequelize.getQueryInterface(), Sequelize), - down: async () => - (await import(path)).down(sequelize.getQueryInterface(), Sequelize), - }; - }, - }, - storage: new SequelizeStorage({ sequelize }), - logger: undefined, -}); - -let migrate = async (down) => { - if (down) { - await umzug.down(); - } else { - await umzug.up(); - } -}; -export const syncDB = async () => { - await Promise.all( - modelNames.map(async (modelName) => { - await ( - await import("./models") - )[modelName].destroy({ - truncate: true, - cascade: true, - hooks: false, - }); - }) - ); -}; +import { User, Session, Recipe, Label, Message } from "./models/index.js"; export const setup = async () => { - await migrate(); const mainExecutable = await import("./app"); return mainExecutable.app; }; export const cleanup = async () => { - await migrate(true); + // Stub }; export function randomString(len) { diff --git a/packages/frontend/src/app/app-routing.module.ts b/packages/frontend/src/app/app-routing.module.ts index 11d31d375..a501653fd 100644 --- a/packages/frontend/src/app/app-routing.module.ts +++ b/packages/frontend/src/app/app-routing.module.ts @@ -127,6 +127,14 @@ const routes: Routes = [ ), canDeactivate: [UnsavedChangesGuardService], }, + { + path: RouteMap.AssistantPage.path, + loadChildren: () => + import("~/pages/messaging-components/assistant/assistant.module").then( + (module) => module.AssistantPageModule + ), + canDeactivate: [UnsavedChangesGuardService], + }, { path: RouteMap.MessagesPage.path, loadChildren: () => diff --git a/packages/frontend/src/app/app.component.ts b/packages/frontend/src/app/app.component.ts index a2e120d70..d874d4bdf 100644 --- a/packages/frontend/src/app/app.component.ts +++ b/packages/frontend/src/app/app.component.ts @@ -258,6 +258,9 @@ export class AppComponent { const home = await this.translate.get("pages.app.nav.home").toPromise(); const labels = await this.translate.get("pages.app.nav.labels").toPromise(); const people = await this.translate.get("pages.app.nav.people").toPromise(); + const assistant = await this.translate + .get("pages.app.nav.assistant") + .toPromise(); const messages = await this.translate .get("pages.app.nav.messages") .toPromise(); @@ -362,12 +365,21 @@ export class AppComponent { url: RouteMap.PeoplePage.getPath(), }, ], + [ + true, + { + id: "assistant", + title: assistant, + icon: "chatbox-ellipses", + url: RouteMap.AssistantPage.getPath(), + }, + ], [ true, { id: "messages", title: messages, - icon: "chatbox", + icon: "chatbubbles", url: RouteMap.MessagesPage.getPath(), }, ], diff --git a/packages/frontend/src/app/pages/home/home.page.ts b/packages/frontend/src/app/pages/home/home.page.ts index 7d3474b46..05aa5cb13 100644 --- a/packages/frontend/src/app/pages/home/home.page.ts +++ b/packages/frontend/src/app/pages/home/home.page.ts @@ -17,7 +17,7 @@ import { UserProfile, UserService } from "~/services/user.service"; import { LoadingService } from "~/services/loading.service"; import { WebsocketService } from "~/services/websocket.service"; import { EventService } from "~/services/event.service"; -import { RouteMap } from "~/services/util.service"; +import { RouteMap, UtilService } from "~/services/util.service"; import { LabelService, Label } from "~/services/label.service"; import { @@ -127,7 +127,8 @@ export class HomePage { private userService: UserService, private preferencesService: PreferencesService, private websocketService: WebsocketService, - private trpcService: TRPCService + private trpcService: TRPCService, + private utilService: UtilService ) { this.showBack = !!this.router.getCurrentNavigation()?.extras.state?.showBack; @@ -365,11 +366,7 @@ export class HomePage { } openRecipe(recipe: Recipe, event?: MouseEvent | KeyboardEvent) { - if (event && (event.metaKey || event.ctrlKey)) { - window.open(`#/recipe/${recipe.id}`); - return; - } - this.navCtrl.navigateForward(RouteMap.RecipePage.getPath(recipe.id)); + this.utilService.openRecipe(this.navCtrl, recipe.id, event); } async presentPopover(event: Event) { diff --git a/packages/frontend/src/app/pages/messaging-components/assistant/assistant.module.ts b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.module.ts new file mode 100644 index 000000000..5083af0d7 --- /dev/null +++ b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { IonicModule } from "@ionic/angular"; +import { RouterModule } from "@angular/router"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; + +import { AssistantPage } from "./assistant.page"; + +import { GlobalModule } from "~/global.module"; +import { LogoIconModule } from "../../../components/logo-icon/logo-icon.module"; +import { NullStateModule } from "../../../components/null-state/null-state.module"; + +@NgModule({ + declarations: [AssistantPage], + imports: [ + GlobalModule, + CommonModule, + IonicModule, + RouterModule.forChild([ + { + path: "", + component: AssistantPage, + }, + ]), + LogoIconModule, + NullStateModule, + FormsModule, + ReactiveFormsModule, + ], +}) +export class AssistantPageModule {} diff --git a/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.html b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.html new file mode 100644 index 000000000..4501fed63 --- /dev/null +++ b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.html @@ -0,0 +1,167 @@ + + + + + + + {{ 'pages.assistant.title' | translate }} + + + + + + + + + + + + + +
+ + + +

{{ 'pages.assistant.welcome.1' | translate }}

+

+

+ {{ 'pages.assistant.welcome.2' | translate }}
+ {{ 'pages.assistant.welcome.3' | translate }}
+

+

+
+
+
+
+ {{ message.dateDiff }} +
+
+ +
+
+ + + + + + + + + +

{{ message.recipe.title }}

+

+ {{ 'pages.messageThread.clickToOpen' | translate }} +

+
+
+ + +

+ {{ 'pages.messageThread.recipeDeleted' | translate }} +

+

+ {{ 'pages.messageThread.yourRecipeDeleted' | translate }} +

+
+
+
+ +
+ + + + + + +

{{ message.recipe.title }}

+

+ {{ 'pages.messageThread.recipeCopySent' | translate }} +

+
+
+ + +

+ {{ 'pages.messageThread.recipeDeleted' | translate }} +

+

+ {{ 'pages.messageThread.yourRecipeDeleted' | translate }} +

+
+
+
+ +
+ {{ message.formattedDate }} +
+
+
+
+ + +
+ + + + + + + +
+
diff --git a/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.scss b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.scss new file mode 100644 index 000000000..ba44a3e60 --- /dev/null +++ b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.scss @@ -0,0 +1,112 @@ +.assistant-recipe-image { + --border-radius: 0; +} + +.submit { + margin-top: 10px; +} + +.message { + margin-top: 10px; + margin-bottom: 10px; + + .time-divider { + margin-top: 20px; + margin-bottom: 10px; + text-transform: capitalize; + text-align: center; + } + + .chat { + box-shadow: 1px 1px 7px rgba(0, 0, 0, 0.3); + background-color: rgb(0, 104, 166); + display: inline-block; + padding: 10px; + // border-radius: 20px; + border-radius: 6px; + text-align: left; + width: 90%; + max-width: 600px; + + span { + white-space: pre-wrap; + color: var(--ion-color-primary-contrast); + + a { + color: var(--ion-color-primary-contrast); + opacity: 1; + } + } + } + + .recipe { + box-shadow: 1px 1px 7px rgba(0, 0, 0, 0.3); + background-color: rgb(0, 104, 166); + width: 60%; + max-width: 400px; + // border-radius: 20px; + border-radius: 6px; + + ion-item { + --background: transparent; + + h2 { + margin-top: 0; + color: var(--ion-color-primary-contrast); + } + + .description { + color: var(--ion-color-primary-contrast); + } + } + } + + &.me { + text-align: right; + + .recipe { + margin-left: auto; + } + } + + &.blue { + .chat { + background-color: var(--ion-background-tint-color); + + span { + color: var(--ion-text-color, #000); + + a { + color: var(--ion-text-color, #000); + } + } + } + + .recipe { + background-color: var(--ion-background-tint-color); + + ion-item { + h2 { + color: var(--ion-text-color, #000); + } + + .description { + color: var(--ion-text-color, #000); + } + } + } + } +} + +.reload.reloading { + animation: spin 1s linear infinite normal none; +} + +@-webkit-keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.ts b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.ts new file mode 100644 index 000000000..bf6b17098 --- /dev/null +++ b/packages/frontend/src/app/pages/messaging-components/assistant/assistant.page.ts @@ -0,0 +1,311 @@ +import { Component, ViewChild } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { NavController, ToastController } from "@ionic/angular"; + +import { linkifyStr } from "~/utils/linkify"; +import { MessagingService } from "~/services/messaging.service"; +import { LoadingService } from "~/services/loading.service"; +import { WebsocketService } from "~/services/websocket.service"; +import { EventService } from "~/services/event.service"; +import { UtilService, RouteMap } from "~/services/util.service"; +import { TranslateService } from "@ngx-translate/core"; +import { TRPCService } from "../../../services/trpc.service"; +import { AssistantMessageSummary, RecipeSummary } from "@recipesage/trpc"; + +@Component({ + selector: "page-assistant", + templateUrl: "assistant.page.html", + styleUrls: ["assistant.page.scss"], +}) +export class AssistantPage { + @ViewChild("content", { static: true }) content: any; + + messages: (AssistantMessageSummary & { + formattedDate?: string; + deservesDateDiff?: boolean; + dateDiff?: string; + })[] = []; + messagesById: { [key: string]: AssistantMessageSummary } = {}; + + pendingMessage = ""; + processing = false; + reloading = false; + + isViewLoaded = true; + + selectedChatIdx = -1; + + constructor( + public navCtrl: NavController, + public translate: TranslateService, + public route: ActivatedRoute, + public events: EventService, + public toastCtrl: ToastController, + public loadingService: LoadingService, + public websocketService: WebsocketService, + public utilService: UtilService, + public messagingService: MessagingService, + public trpcService: TRPCService + ) {} + + ionViewWillEnter() { + this.isViewLoaded = true; + + const loading = this.loadingService.start(); + + let messageArea: any; + try { + messageArea = this.content.getNativeElement().children[1].children[0]; + } catch (e) {} + + if (messageArea) messageArea.style.opacity = 0; + this.loadMessages("bottom", false).then( + () => { + loading.dismiss(); + if (messageArea) messageArea.style.opacity = 1; + }, + () => { + loading.dismiss(); + } + ); + } + + ionViewWillLeave() { + this.isViewLoaded = false; + } + + reload() { + this.reloading = true; + + this.loadMessages("bottom", true).finally(() => { + setTimeout(() => { + this.reloading = false; // TODO: Replace with better delay for minimum animation time + }, 350); + }); + } + + refresh(refresher: any) { + this.loadMessages("bottom", true).then( + () => { + refresher.target.complete(); + }, + () => { + refresher.target.complete(); + } + ); + } + + scrollToBottom(animate?: boolean, delay?: boolean, callback?: () => any) { + const animationDuration = animate ? 300 : 0; + + if (delay) { + setTimeout(() => { + this.content.scrollToBottom(animationDuration); + callback?.(); + }); + } else { + this.content.scrollToBottom(animationDuration); + } + } + + scrollIntoView( + elRef: string | Element, + animate?: boolean, + delay?: boolean, + callback?: () => any + ) { + const go = () => { + const element = + typeof elRef === "string" ? document.querySelector(elRef) : elRef; + if (!element) return; + + element.scrollIntoView({ + block: "start", + inline: "nearest", + behavior: animate ? "smooth" : "instant", + }); + callback?.(); + }; + + if (delay) { + setTimeout(go); + } else { + go(); + } + } + + keyboardOpened() { + window.onresize = () => { + this.scrollToBottom(false, true); + window.onresize = null; + }; + } + + trackByFn(_: number, item: { id: string }) { + return item.id; + } + + async loadMessages( + scrollBehavior?: "newest" | "bottom" | "none", + animateScroll?: boolean + ) { + const response = await this.trpcService.handle( + this.trpcService.trpc.getAssistantMessages.query() + ); + if (!response) return; + + let firstNewMessage: AssistantMessageSummary | undefined = undefined; + const messages = []; + for (const message of response) { + if (!message.content && !message.recipe) { + continue; + } + + if (!message.content) { + messages.push(message); + continue; + } + + // Reuse messages that have already been parsed for performance. Otherwise, send it through linkify + if (this.messagesById[message.id]) { + message.content = this.messagesById[message.id].content; + } else { + message.content = this.parseMessage(message.content); + } + + if (!this.messagesById[message.id] && !firstNewMessage) + firstNewMessage = message; + this.messagesById[message.id] = message; + + messages.push(message); + } + this.messages = messages; + + this.processMessages(); + + if (!scrollBehavior || scrollBehavior === "bottom") { + this.scrollToBottom(animateScroll, true); + } + if (scrollBehavior === "newest" && firstNewMessage) { + this.scrollIntoView( + `#message-${firstNewMessage.id}`, + animateScroll, + true + ); + } + } + + processMessages() { + for (let i = 0; i < this.messages.length; i++) { + const message = this.messages[i]; + message.deservesDateDiff = !!this.deservesDateDiff( + this.messages[i - 1], + message + ); + if (message.deservesDateDiff) + message.dateDiff = this.formatMessageDividerDate(message.createdAt); + message.formattedDate = this.formatMessageDate(message.createdAt); + } + } + + async sendMessage() { + const pendingMessage = this.pendingMessage; + if (this.processing || !pendingMessage) return; + + this.processing = true; + + const response = await this.trpcService.handle( + this.trpcService.trpc.sendAssistantMessage.query({ + content: pendingMessage, + }), + { + 429: async () => { + const message = await this.translate + .get("pages.assistant.messageLimit") + .toPromise(); + const close = await this.translate.get("generic.close").toPromise(); + + const toast = await this.toastCtrl.create({ + message, + buttons: [ + { + text: close, + role: "cancel", + }, + ], + }); + await toast.present(); + return; + }, + } + ); + + if (!response) { + setTimeout(() => { + this.pendingMessage = pendingMessage; + }, 150); + } + + this.processing = false; + this.pendingMessage = ""; + this.loadMessages("newest", true); + + setTimeout(() => { + ( + document.querySelector( + "#assistant-message-textarea textarea" + ) as HTMLElement + )?.focus(); + }); + } + + openRecipe(recipe: RecipeSummary) { + this.navCtrl.navigateForward(RouteMap.RecipePage.getPath(recipe.id)); + } + + onMessageKeyDown(event: KeyboardEvent) { + if (event.key !== "Enter") return; + event.preventDefault(); + + if (event.ctrlKey || event.shiftKey || event.altKey) { + this.pendingMessage += "\n"; + } else { + this.sendMessage(); + } + } + + deservesDateDiff( + previous: { createdAt: Date | string | number }, + next: { createdAt: Date | string | number } + ) { + if (!previous || !next) return; + + const p = new Date(previous.createdAt); + const n = new Date(next.createdAt); + + return p.getDay() !== n.getDay(); + } + + formatMessageDividerDate(plainTextDate: Date | string | number) { + return this.utilService.formatDate(plainTextDate); + } + + formatMessageDate(plainTextDate: Date | string | number) { + return this.utilService.formatDate(plainTextDate, { + now: true, + times: true, + }); + } + + setSelectedChat(idx: number) { + if (idx === this.selectedChatIdx) { + this.selectedChatIdx = -1; + } else { + this.selectedChatIdx = idx; + } + } + + parseMessage(message: string) { + return linkifyStr(message); + } +} diff --git a/packages/frontend/src/app/pages/recipe-components/recipe/recipe.page.html b/packages/frontend/src/app/pages/recipe-components/recipe/recipe.page.html index f6bbe24fe..6469c25f1 100644 --- a/packages/frontend/src/app/pages/recipe-components/recipe/recipe.page.html +++ b/packages/frontend/src/app/pages/recipe-components/recipe/recipe.page.html @@ -340,19 +340,6 @@

{{ recipe.title }}


- - -

- - {{ 'pages.recipeDetails.created' | translate }} {{ - prettyDateTime(recipe.createdAt) }}
- {{ 'pages.recipeDetails.updated' | translate }} {{ - prettyDateTime(recipe.updatedAt) }} -
-

-
-
-
@@ -372,6 +359,24 @@

{{ recipe.title }}

+
+ + + +

+ {{ 'pages.recipeDetails.similar' | translate }} +

+
+
+ + + {{similarRecipe.title}} + + + +
+
+
{{ recipe.title }}
+ + +

+ + {{ 'pages.recipeDetails.created' | translate }} {{ + prettyDateTime(recipe.createdAt) }}
+ {{ 'pages.recipeDetails.updated' | translate }} {{ + prettyDateTime(recipe.updatedAt) }} +
+

+
+
+ label.title); this.updateRatingVisual(); + + if (this.isLoggedIn) { + this.similarRecipes = await this.trpcService.trpc.getSimilarRecipes.query( + { + recipeIds: [this.recipe.id], + } + ); + } } async loadLabels() { @@ -596,6 +608,10 @@ export class RecipePage { this.cookingToolbarService.unpinRecipe(this.recipe.id); } + openRecipe(recipeId: string, event?: MouseEvent | KeyboardEvent) { + this.utilService.openRecipe(this.navCtrl, recipeId, event); + } + setupWakeLock() { if ( !this.wakeLockRequest && diff --git a/packages/frontend/src/app/services/util.service.ts b/packages/frontend/src/app/services/util.service.ts index 429d52151..dd44dfc17 100644 --- a/packages/frontend/src/app/services/util.service.ts +++ b/packages/frontend/src/app/services/util.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@angular/core"; import { TranslateService } from "@ngx-translate/core"; import { API_BASE_URL } from "../../environments/environment"; import { SupportedFontSize, SupportedLanguages } from "./preferences.service"; +import { NavController } from "@ionic/angular"; export interface RecipeTemplateModifiers { version?: string; @@ -127,6 +128,12 @@ export const RouteMap = { }, path: "meal-planners/:mealPlanId", }, + AssistantPage: { + getPath() { + return `/assistant`; + }, + path: "assistant", + }, MessagesPage: { getPath() { return `/messages`; @@ -443,4 +450,16 @@ export class UtilService { if (str.length <= trueMaxLength) return str; return `${str.substring(0, trueMaxLength)}${ellipsis}`; } + + openRecipe( + navCtrl: NavController, + recipeId: string, + event?: MouseEvent | KeyboardEvent + ) { + if (event && (event.metaKey || event.ctrlKey)) { + window.open(`#/recipe/${recipeId}`); + return; + } + navCtrl.navigateForward(RouteMap.RecipePage.getPath(recipeId)); + } } diff --git a/packages/frontend/src/assets/i18n/de-de.json b/packages/frontend/src/assets/i18n/de-de.json index 361329c97..02ffc63e7 100644 --- a/packages/frontend/src/assets/i18n/de-de.json +++ b/packages/frontend/src/assets/i18n/de-de.json @@ -13,6 +13,7 @@ "pages.app.nav.home": "Meine Rezepte", "pages.app.nav.labels": "Label verwalten", "pages.app.nav.people": "Personen & Profil", + "pages.app.nav.assistant": "Assistentin in der Küche", "pages.app.nav.messages": "Nachrichten", "pages.app.nav.inbox": "Rezepte Postfach", "pages.app.nav.newrecipe": "Rezept anlegen", @@ -256,6 +257,7 @@ "pages.recipeDetails.deleteLabel.message": "Dadurch wird das Label \"{{title}}\" von diesem Rezept entfernt.", "pages.recipeDetails.cloned": "Das Rezept wurde in deinem Konto gespeichert", + "pages.recipeDetails.similar": "Ähnliche Rezepte in Ihrer Sammlung", "====== Pages.RecipeDetailsPopover ======": "", @@ -546,6 +548,14 @@ "pages.shareMealPlanModal.title": "Essensplan teilen", "pages.shareMealPlanModal.description": "Der unten stehende iCal/ICS-Link kann in fast jedem Kalenderprogramm verwendet werden, um deinen Essensplan außerhalb von RecipeSage zu synchronisieren", + "====== Pages.Assistant ======": "", + + "pages.assistant.title": "Assistentin in der Küche", + "pages.assistant.welcome.1": "Willkommen bei der RecipeSage Kochhilfe!", + "pages.assistant.welcome.2": "Der Kochassistent ist ein KI-Modell, das Ihre Fragen zum Thema Kochen beantworten und bei der Erstellung von Rezepten helfen kann.", + "pages.assistant.welcome.3": "Der Kochassistent hat wegen der hohen Kosten für den Betrieb der KI ein Nachrichtenlimit von 5 Nachrichten pro Tag. Dieses Limit wird für Beitragszahler auf 50 Nachrichten pro Tag erhöht.", + "pages.assistant.messageLimit": "Maximale Nachrichtenanzahl erreicht (sorry, das ist teuer!). Der Nachrichtenzähler wird um 0:00GMT zurückgesetzt.", + "====== Pages.MessageThread ======": "", "pages.messageThread.title": "Unterhaltung mit {{name}}", diff --git a/packages/frontend/src/assets/i18n/en-us.json b/packages/frontend/src/assets/i18n/en-us.json index 7bf312511..42837dc83 100644 --- a/packages/frontend/src/assets/i18n/en-us.json +++ b/packages/frontend/src/assets/i18n/en-us.json @@ -13,6 +13,7 @@ "pages.app.nav.home": "My Recipes", "pages.app.nav.labels": "Manage Labels", "pages.app.nav.people": "People & Profile", + "pages.app.nav.assistant": "Cooking Assistant", "pages.app.nav.messages": "Messages", "pages.app.nav.inbox": "Recipe Inbox", "pages.app.nav.newrecipe": "Create Recipe", @@ -257,6 +258,7 @@ "pages.recipeDetails.deleteLabel.message": "This will remove the label \"{{title}}\" from this recipe.", "pages.recipeDetails.cloned": "The recipe has been saved to your account", + "pages.recipeDetails.similar": "Similar recipes in your collection", "====== Pages.RecipeDetailsPopover ======": "", @@ -547,6 +549,14 @@ "pages.shareMealPlanModal.title": "Share Meal Plan", "pages.shareMealPlanModal.description": "The iCal/ICS link below can be used in almost any calendar program to sync your meal plan outside of RecipeSage", + "====== Pages.Assistant ======": "", + + "pages.assistant.title": "Cooking Assistant", + "pages.assistant.welcome.1": "Welcome to the RecipeSage cooking assistant!", + "pages.assistant.welcome.2": "The cooking assistant is an AI model that can respond to your cooking questions and help create recipes.", + "pages.assistant.welcome.3": "The cooking assistant has a message limit of 5 messages/day because of the high costs to run the AI. This limit is increased to 50 messages/day for contributors.", + "pages.assistant.messageLimit": "Maximum messages reached (sorry, it's costly!). Message count resets at 0:00GMT.", + "====== Pages.MessageThread ======": "", "pages.messageThread.title": "Conversation with {{name}}", diff --git a/packages/frontend/src/assets/i18n/he.json b/packages/frontend/src/assets/i18n/he.json index 5033b6910..58df0d90a 100644 --- a/packages/frontend/src/assets/i18n/he.json +++ b/packages/frontend/src/assets/i18n/he.json @@ -13,6 +13,7 @@ "pages.app.nav.home": "המתכונים שלי", "pages.app.nav.labels": "ניהול תוויות", "pages.app.nav.people": "אנשים ופרופיל", + "pages.app.nav.assistant": "עוזר בישול", "pages.app.nav.messages": "הודעות", "pages.app.nav.inbox": "דואר נכנס", "pages.app.nav.newrecipe": "הוספת מתכון", @@ -257,6 +258,7 @@ "pages.recipeDetails.deleteLabel.message": "פעולה זו תמחק את התווית \"{{title}}\" מהמתכון.", "pages.recipeDetails.cloned": "המתכון נשמר", + "pages.recipeDetails.similar": "מתכונים דומים באוסף שלך", "====== Pages.RecipeDetailsPopover ======": "", @@ -547,6 +549,14 @@ "pages.shareMealPlanModal.title": "שיתוף תוכנית ארוחה", "pages.shareMealPlanModal.description": "ניתן להשתמש בקישור iCal/ICS כמעט בכל תוכנית לוח שנה כדי לסנכרן את תכנית הארוחות שלך מחוץ ל-RecipeSage", + "====== Pages.Assistant ======": "", + + "pages.assistant.title": "עוזר בישול", + "pages.assistant.welcome.1": "ברוכים הבאים לעוזר הבישול של RecipeSage!", + "pages.assistant.welcome.2": "עוזר הבישול הוא מודל AI שיכול להגיב לשאלות הבישול שלך ולעזור ביצירת מתכונים", + "pages.assistant.welcome.3": "לעוזר הבישול יש מגבלת הודעות של 5 הודעות ביום בגלל העלויות הגבוהות להפעלת ה-AI, מגבלה זו גדלה ל-50 הודעות ביום עבור תורמים", + "pages.assistant.messageLimit": "הגעתם למקסימום ההודעות (מצטער, זה יקר!). ספירת ההודעות מתאפסת ב-0:00 GMT", + "====== Pages.MessageThread ======": "", "pages.messageThread.title": "שיחה עם {{name}}", diff --git a/packages/frontend/src/assets/i18n/it-it.json b/packages/frontend/src/assets/i18n/it-it.json index f3a9aeef9..8899fb3f8 100644 --- a/packages/frontend/src/assets/i18n/it-it.json +++ b/packages/frontend/src/assets/i18n/it-it.json @@ -13,6 +13,7 @@ "pages.app.nav.home": "Le Mie Ricette", "pages.app.nav.labels": "Gestisci Etichette", "pages.app.nav.people": "Persone & Profilo", + "pages.app.nav.assistant": "Assistente di cucina", "pages.app.nav.messages": "Messaggi", "pages.app.nav.inbox": "Ricette Ricevute", "pages.app.nav.newrecipe": "Crea una Ricetta", @@ -257,6 +258,7 @@ "pages.recipeDetails.deleteLabel.message": "Rimuovere l’etichetta \"{{title}}\" da questa ricetta.", "pages.recipeDetails.cloned": "La ricetta è stata aggiunta alla tua raccolta", + "pages.recipeDetails.similar": "Ricette simili nella vostra collezione", "====== Pages.RecipeDetailsPopover ======": "", @@ -547,6 +549,14 @@ "pages.shareMealPlanModal.title": "Condividi piano alimentare", "pages.shareMealPlanModal.description": "Il collegamento iCal/ICS di seguito può essere utilizzato in quasi tutti i programmi di calendario per sincronizzare il piano alimentare al di fuori di RecipeSage", + "====== Pages.Assistant ======": "", + + "pages.assistant.title": "Assistente di cucina", + "pages.assistant.welcome.1": "Benvenuti nell'assistente di cucina di RecipeSage!", + "pages.assistant.welcome.2": "L'assistente di cucina è un modello AI in grado di rispondere alle vostre domande di cucina e di aiutarvi a creare ricette.", + "pages.assistant.welcome.3": "L'assistente di cucina ha un limite di 5 messaggi al giorno a causa degli alti costi di gestione dell'IA. Questo limite è aumentato a 50 messaggi al giorno per i collaboratori.", + "pages.assistant.messageLimit": "Raggiunto il massimo dei messaggi (scusate, è costoso!). Il conteggio dei messaggi si azzera a 0:00GMT", + "====== Pages.MessageThread ======": "", "pages.messageThread.title": "Conversazione con {{name}}", diff --git a/packages/frontend/src/assets/i18n/uk-ua.json b/packages/frontend/src/assets/i18n/uk-ua.json index ca3dbb1a3..0998237ed 100644 --- a/packages/frontend/src/assets/i18n/uk-ua.json +++ b/packages/frontend/src/assets/i18n/uk-ua.json @@ -13,6 +13,7 @@ "pages.app.nav.home": "Мої рецепти", "pages.app.nav.labels": "Управління мітками", "pages.app.nav.people": "Люди & Профілі", + "pages.app.nav.assistant": "Помічник кухаря", "pages.app.nav.messages": "Повідомлення", "pages.app.nav.inbox": "Вхідні рецепти", "pages.app.nav.newrecipe": "Створити рецепт", @@ -257,6 +258,7 @@ "pages.recipeDetails.deleteLabel.message": "Буде видалено мітку \"{{title}}\" з цього рецепту.", "pages.recipeDetails.cloned": "Рецепт було збережено до Вашого облікового запису", + "pages.recipeDetails.similar": "Схожі рецепти у вашій колекції", "====== Pages.RecipeDetailsPopover ======": "", @@ -547,6 +549,14 @@ "pages.shareMealPlanModal.title": "Поділитись планом харчування", "pages.shareMealPlanModal.description": "Посилання iCal/ICS нижче можна використовувати майже в будь-якій програмі календаря для синхронізації вашого плану харчування поза RecipeSage", + "====== Pages.Assistant ======": "", + + "pages.assistant.title": "Помічник кухаря", + "pages.assistant.welcome.1": "Ласкаво просимо до кулінарного помічника RecipeSage!", + "pages.assistant.welcome.2": "Асистент приготування їжі - це модель штучного інтелекту, яка може відповідати на ваші кулінарні запитання та допомагати створювати рецепти.", + "pages.assistant.welcome.3": "Помічник кулінара має ліміт на 5 повідомлень на день через високі витрати на запуск ШІ. Для дописувачів цей ліміт збільшується до 50 повідомлень на день.", + "pages.assistant.messageLimit": "Досягнуто максимальної кількості повідомлень (вибачте, це дорого!). Лічильник повідомлень обнуляється о 0:00GMT", + "====== Pages.MessageThread ======": "", "pages.messageThread.title": "Спілкування з {{name}}", diff --git a/packages/prisma/project.json b/packages/prisma/project.json index ae0653079..b650fcdef 100644 --- a/packages/prisma/project.json +++ b/packages/prisma/project.json @@ -4,6 +4,13 @@ "sourceRoot": "packages/prisma/src", "projectType": "library", "targets": { + "seed": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/prisma", + "command": "npx tsx src/seeders/index.ts" + } + }, "build": { "executor": "nx:run-commands", "options": { diff --git a/packages/prisma/src/index.ts b/packages/prisma/src/index.ts index b0ad84d94..901f3a0d9 100644 --- a/packages/prisma/src/index.ts +++ b/packages/prisma/src/index.ts @@ -1,2 +1,3 @@ import { PrismaClient } from "@prisma/client"; + export const prisma = new PrismaClient(); diff --git a/packages/prisma/src/prisma/migrations/20231107015959_default_createdat/migration.sql b/packages/prisma/src/prisma/migrations/20231107015959_default_createdat/migration.sql new file mode 100644 index 000000000..c3c775eff --- /dev/null +++ b/packages/prisma/src/prisma/migrations/20231107015959_default_createdat/migration.sql @@ -0,0 +1,59 @@ +-- AlterTable +ALTER TABLE "FCMTokens" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Friendships" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Images" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Labels" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "MealPlanItems" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "MealPlan_Collaborators" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "MealPlans" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Messages" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "ProfileItems" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Recipe_Images" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Recipe_Labels" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Recipes" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Sessions" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "ShoppingListItems" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "ShoppingList_Collaborators" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "ShoppingLists" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "StripePayments" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "UserSubscriptions" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "User_Profile_Images" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Users" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP; diff --git a/packages/prisma/src/prisma/migrations/20231112183754_create_assistantmessage/migration.sql b/packages/prisma/src/prisma/migrations/20231112183754_create_assistantmessage/migration.sql new file mode 100644 index 000000000..47e2a9493 --- /dev/null +++ b/packages/prisma/src/prisma/migrations/20231112183754_create_assistantmessage/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "AssistantMessages" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "role" VARCHAR(255) NOT NULL, + "content" TEXT, + "name" TEXT, + "json" JSONB NOT NULL, + "recipeId" UUID, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "AssistantMessages_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "AssistantMessages" ADD CONSTRAINT "AssistantMessages_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AssistantMessages" ADD CONSTRAINT "AssistantMessages_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipes"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/src/prisma/migrations/migration_lock.toml b/packages/prisma/src/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..fbffa92c2 --- /dev/null +++ b/packages/prisma/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/packages/prisma/src/prisma/schema.prisma b/packages/prisma/src/prisma/schema.prisma index 84b998e34..bedc451a0 100644 --- a/packages/prisma/src/prisma/schema.prisma +++ b/packages/prisma/src/prisma/schema.prisma @@ -8,22 +8,22 @@ datasource db { } model FCMToken { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid token String? - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) users User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("FCMTokens") } model Friendship { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid friendId String @db.Uuid - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) friend User @relation("Friendships_friendIdToUsers", fields: [friendId], references: [id], onDelete: Cascade) user User @relation("Friendships_userIdToUsers", fields: [userId], references: [id], onDelete: Cascade) @@ -32,13 +32,13 @@ model Friendship { } model Image { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid location String? @db.VarChar(255) key String? @db.VarChar(255) json Json? - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) user User @relation(fields: [userId], references: [id], onDelete: SetNull) recipeImages RecipeImage[] profileImages UserProfileImage[] @@ -48,11 +48,11 @@ model Image { } model Label { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid title String? @db.VarChar(255) - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) user User @relation(fields: [userId], references: [id], onDelete: Cascade) profileItems ProfileItem[] recipeLabels RecipeLabel[] @@ -62,15 +62,15 @@ model Label { } model MealPlanItem { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid mealPlanId String @db.Uuid recipeId String? @db.Uuid title String? scheduled DateTime? @db.Timestamptz(6) meal String? @db.VarChar(255) - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) mealPlan MealPlan @relation(fields: [mealPlanId], references: [id], onDelete: Cascade) recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -82,11 +82,11 @@ model MealPlanItem { } model MealPlanCollaborator { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid mealPlanId String @db.Uuid userId String @db.Uuid - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) mealPlan MealPlan @relation(fields: [mealPlanId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -95,11 +95,11 @@ model MealPlanCollaborator { } model MealPlan { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid title String? - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) items MealPlanItem[] collaboratorUsers MealPlanCollaborator[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -108,15 +108,15 @@ model MealPlan { } model Message { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid fromUserId String @db.Uuid toUserId String @db.Uuid recipeId String? @db.Uuid originalRecipeId String? @db.Uuid body String? type String? @db.VarChar(255) - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) fromUser User @relation("Messages_fromUserIdToUsers", fields: [fromUserId], references: [id], onDelete: Cascade) originalRecipe Recipe? @relation("Messages_originalRecipeIdToRecipes", fields: [originalRecipeId], references: [id]) recipe Recipe? @relation("Messages_recipeIdToRecipes", fields: [recipeId], references: [id]) @@ -127,8 +127,24 @@ model Message { @@map("Messages") } +model AssistantMessage { + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid + role String @db.VarChar(255) + content String? + name String? + json Json + recipeId String? @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + user User @relation("AssistantMessages_userIdToUsers", fields: [userId], references: [id], onDelete: Cascade) + recipe Recipe? @relation("AssistantMessages_recipeIdToRecipes", fields: [recipeId], references: [id], onDelete: Cascade) + + @@map("AssistantMessages") +} + model ProfileItem { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid recipeId String? @db.Uuid labelId String? @db.Uuid @@ -136,8 +152,8 @@ model ProfileItem { type String @db.VarChar(255) visibility String @db.VarChar(255) order Int - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) label Label? @relation(fields: [labelId], references: [id], onDelete: Cascade) recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -146,12 +162,12 @@ model ProfileItem { } model RecipeImage { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid recipeId String @db.Uuid imageId String @db.Uuid order Int - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) image Image @relation(fields: [imageId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) @@ -161,11 +177,11 @@ model RecipeImage { } model RecipeLabel { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid recipeId String @db.Uuid labelId String @db.Uuid - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) label Label @relation(fields: [labelId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) @@ -177,7 +193,7 @@ model RecipeLabel { } model Recipe { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid fromUserId String? @db.Uuid title String? @@ -191,13 +207,14 @@ model Recipe { ingredients String? instructions String? folder String? @db.VarChar(255) - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) indexedAt DateTime? @db.Timestamptz(6) rating Int? mealPlanItems MealPlanItem[] originalRecipe Message[] @relation("Messages_originalRecipeIdToRecipes") messages Message[] @relation("Messages_recipeIdToRecipes") + assistantMessages AssistantMessage[] @relation("AssistantMessages_recipeIdToRecipes") profileItems ProfileItem[] recipeImages RecipeImage[] recipeLabels RecipeLabel[] @@ -211,28 +228,28 @@ model Recipe { } model Session { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid type String? @db.VarChar(255) token String? @db.VarChar(255) expires DateTime? @db.Timestamptz(6) - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("Sessions") } model ShoppingListItem { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid shoppingListId String @db.Uuid mealPlanItemId String? @db.Uuid recipeId String? @db.Uuid title String completed Boolean - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) mealPlanItem MealPlanItem? @relation(fields: [mealPlanItemId], references: [id], onDelete: Cascade) recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade) shoppingList ShoppingList @relation(fields: [shoppingListId], references: [id], onDelete: Cascade) @@ -244,11 +261,11 @@ model ShoppingListItem { } model ShoppingListCollaborator { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid shoppingListId String @db.Uuid userId String @db.Uuid - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) shoppingList ShoppingList @relation(fields: [shoppingListId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -257,11 +274,11 @@ model ShoppingListCollaborator { } model ShoppingList { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid title String? - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) items ShoppingListItem[] collaboratorUsers ShoppingListCollaborator[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -270,7 +287,7 @@ model ShoppingList { } model StripePayment { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String? @db.Uuid amountPaid Int customerId String @db.VarChar(255) @@ -278,32 +295,32 @@ model StripePayment { paymentIntentId String @unique @db.VarChar(255) subscriptionId String? @db.VarChar(255) invoiceBlob Json - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("StripePayments") } model UserSubscription { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid name String? @db.VarChar(255) expires DateTime? @db.Timestamptz(6) - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("UserSubscriptions") } model UserProfileImage { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid userId String @db.Uuid imageId String @db.Uuid order Int - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) image Image @relation(fields: [imageId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -311,15 +328,15 @@ model UserProfileImage { } model User { - id String @id @db.Uuid + id String @id @default(uuid()) @db.Uuid name String? email String? @unique(map: "Users_email_uk") passwordHash String? passwordSalt String? passwordVersion Int? lastLogin DateTime? @db.Timestamptz(6) - createdAt DateTime @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) stripeCustomerId String? @unique @db.VarChar(255) handle String? @unique(map: "Users_handle_uk") @db.VarChar(255) enableProfile Boolean @default(false) @@ -334,6 +351,7 @@ model User { mealPlans MealPlan[] incomingMessages Message[] @relation("Messages_fromUserIdToUsers") outgoingMessages Message[] @relation("Messages_toUserIdToUsers") + assistantMessages AssistantMessage[] @relation("AssistantMessages_userIdToUsers") profileItems ProfileItem[] outgoingRecipes Recipe[] @relation("Recipes_fromUserIdToUsers") recipes Recipe[] @relation("Recipes_userIdToUsers") diff --git a/packages/prisma/src/seeders/index.ts b/packages/prisma/src/seeders/index.ts new file mode 100644 index 000000000..90ead9ff5 --- /dev/null +++ b/packages/prisma/src/seeders/index.ts @@ -0,0 +1,5 @@ +import { initAssistantUser } from "./initAssistantUser"; + +// Run all seeders + +initAssistantUser(); diff --git a/packages/prisma/src/seeders/initAssistantUser.ts b/packages/prisma/src/seeders/initAssistantUser.ts new file mode 100644 index 000000000..c2e37ca48 --- /dev/null +++ b/packages/prisma/src/seeders/initAssistantUser.ts @@ -0,0 +1,38 @@ +import { prisma } from ".."; + +export const initAssistantUser = async () => { + const assistantUser = await prisma.user.upsert({ + create: { + name: "RecipeSage Cooking Assistant", + email: "assistant@recipesage.com", + passwordHash: "nologin", + passwordSalt: "nologin", + passwordVersion: 2, + handle: "assistant", + enableProfile: true, + }, + where: { + email: "assistant@recipesage.com", + }, + update: {}, + }); + + const existingProfileItem = await prisma.profileItem.findFirst({ + where: { + userId: assistantUser.id, + order: 0, + }, + }); + + if (!existingProfileItem) { + await prisma.profileItem.create({ + data: { + userId: assistantUser.id, + type: "all-recipes", + title: "Created by RecipeSage Cooking Assistant", + visibility: "public", + order: 0, + }, + }); + } +}; diff --git a/packages/trpc/src/dbHelpers/getSimilarRecipes.ts b/packages/trpc/src/dbHelpers/getSimilarRecipes.ts new file mode 100644 index 000000000..47b3b1ae1 --- /dev/null +++ b/packages/trpc/src/dbHelpers/getSimilarRecipes.ts @@ -0,0 +1,60 @@ +import { prisma } from "@recipesage/prisma"; +import { recipeSummary } from "../types/queryTypes"; + +/** + * Removes the duplicate-numbered recipe title + * `Chicken Soup (2)` would become `Chicken Soup` + */ +const stripNumberedRecipeTitle = (title: string) => { + return title.replace(/\s?\(\d\)$/, ""); +}; + +export const getSimilarRecipes = async ( + userId: string, + recipeIds: string[] +) => { + const recipes = await prisma.recipe.findMany({ + where: { + id: { + in: recipeIds, + }, + }, + }); + + if (recipes.length === 0) { + return []; + } + + const relatedRecipes = await prisma.recipe.findMany({ + where: { + id: { + notIn: recipeIds, + }, + userId, + OR: [ + ...recipes.map((recipe) => ({ + title: { + startsWith: stripNumberedRecipeTitle(recipe.title as string), + }, + })), + ...recipes + .filter((recipe) => recipe.ingredients) + .map((recipe) => ({ + ingredients: recipe.ingredients, + })), + ...recipes + .filter((recipe) => recipe.instructions) + .map((recipe) => ({ + instructions: recipe.instructions, + })), + ], + }, + ...recipeSummary, + take: 100, + orderBy: { + title: "asc", + }, + }); + + return relatedRecipes; +}; diff --git a/packages/trpc/src/index.ts b/packages/trpc/src/index.ts index 51aaa5ae6..992c4364f 100644 --- a/packages/trpc/src/index.ts +++ b/packages/trpc/src/index.ts @@ -4,15 +4,22 @@ import * as Sentry from "@sentry/node"; import { router } from "./trpc"; import { getRecipes } from "./procedures/recipes/getRecipes"; import { searchRecipes } from "./procedures/recipes/searchRecipes"; +import { getSimilarRecipes } from "./procedures/recipes/getSimilarRecipes"; +import { sendAssistantMessage } from "./procedures/assistant/sendAssistantMessage"; +import { getAssistantMessages } from "./procedures/assistant/getAssistantMessages"; import { createContext } from "./context"; import { TRPCError } from "@trpc/server"; export * from "./types/queryTypes"; export * from "./services/search"; // Legacy while old backend still needs it +export * from "./services/capabilities"; // Legacy while old backend still needs it const appRouter = router({ getRecipes, searchRecipes, + getSimilarRecipes, + sendAssistantMessage, + getAssistantMessages, }); export const trpcExpressMiddleware = createExpressMiddleware({ diff --git a/packages/trpc/src/procedures/assistant/getAssistantMessages.ts b/packages/trpc/src/procedures/assistant/getAssistantMessages.ts new file mode 100644 index 000000000..296711e92 --- /dev/null +++ b/packages/trpc/src/procedures/assistant/getAssistantMessages.ts @@ -0,0 +1,19 @@ +import { publicProcedure } from "../../trpc"; +import { Assistant } from "../../services/chat/assistant"; +import { TRPCError } from "@trpc/server"; + +const assistant = new Assistant(); + +export const getAssistantMessages = publicProcedure.query(async ({ ctx }) => { + const session = ctx.session; + if (!session) { + throw new TRPCError({ + message: "Must be logged in", + code: "UNAUTHORIZED", + }); + } + + const chatHistory = await assistant.getChatHistory(session.userId); + + return chatHistory; +}); diff --git a/packages/trpc/src/procedures/assistant/sendAssistantMessage.ts b/packages/trpc/src/procedures/assistant/sendAssistantMessage.ts new file mode 100644 index 000000000..26f2cb264 --- /dev/null +++ b/packages/trpc/src/procedures/assistant/sendAssistantMessage.ts @@ -0,0 +1,36 @@ +import { publicProcedure } from "../../trpc"; +import { z } from "zod"; +import { Assistant } from "../../services/chat/assistant"; +import { TRPCError } from "@trpc/server"; + +const assistant = new Assistant(); + +export const sendAssistantMessage = publicProcedure + .input( + z.object({ + content: z.string().max(500), + }) + ) + .query(async ({ ctx, input }) => { + const session = ctx.session; + if (!session) { + throw new TRPCError({ + message: "Must be logged in", + code: "UNAUTHORIZED", + }); + } + + const isOverMessageLimit = await assistant.checkMessageLimit( + session.userId + ); + if (isOverMessageLimit) { + throw new TRPCError({ + message: "Over daily message limit", + code: "TOO_MANY_REQUESTS", + }); + } + + await assistant.sendChat(input.content, session.userId); + + return "ok"; + }); diff --git a/packages/trpc/src/procedures/recipes/getSimilarRecipes.ts b/packages/trpc/src/procedures/recipes/getSimilarRecipes.ts new file mode 100644 index 000000000..f6df1289b --- /dev/null +++ b/packages/trpc/src/procedures/recipes/getSimilarRecipes.ts @@ -0,0 +1,27 @@ +import { publicProcedure } from "../../trpc"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { getSimilarRecipes as _getSimilarRecipes } from "../../dbHelpers/getSimilarRecipes"; + +export const getSimilarRecipes = publicProcedure + .input( + z.object({ + recipeIds: z.array(z.string()).min(1), + }) + ) + .query(async ({ ctx, input }) => { + const session = ctx.session; + if (!session) { + throw new TRPCError({ + message: "Must be logged in", + code: "UNAUTHORIZED", + }); + } + + const similarRecipes = await _getSimilarRecipes( + session.userId, + input.recipeIds + ); + + return similarRecipes; + }); diff --git a/packages/trpc/src/services/capabilities/index.ts b/packages/trpc/src/services/capabilities/index.ts new file mode 100644 index 000000000..48ccb9ee9 --- /dev/null +++ b/packages/trpc/src/services/capabilities/index.ts @@ -0,0 +1,112 @@ +import { prisma } from "@recipesage/prisma"; +import * as moment from "moment"; + +const CAPABILITY_GRACE_PERIOD = 7; + +export enum Capabilities { + HighResImages = "highResImages", + MultipleImages = "multipleImages", + ExpandablePreviews = "expandablePreviews", + AssistantMoreMessages = "assistantMoreMessages", +} + +export enum SubscriptionModels { + PyoMonthly = "pyo-monthly", + PyoSingle = "pyo-single", + Forever = "forever", +} + +const SUBSCRIPTION_MODELS = { + [SubscriptionModels.PyoMonthly]: { + title: "Choose your own price", + expiresIn: 31, + capabilities: [ + Capabilities.HighResImages, + Capabilities.MultipleImages, + Capabilities.ExpandablePreviews, + Capabilities.AssistantMoreMessages, + ], + }, + [SubscriptionModels.PyoSingle]: { + title: "Choose your own price - One time", + expiresIn: 365, + capabilities: [ + Capabilities.HighResImages, + Capabilities.MultipleImages, + Capabilities.ExpandablePreviews, + Capabilities.AssistantMoreMessages, + ], + }, + [SubscriptionModels.Forever]: { + title: "The Forever Subscription...", + expiresIn: 3650, // 10 years - okay, not quite forever + capabilities: [ + Capabilities.HighResImages, + Capabilities.MultipleImages, + Capabilities.ExpandablePreviews, + Capabilities.AssistantMoreMessages, + ], + }, +}; + +export const modelsForCapability = (capability: Capabilities) => { + return Object.values(SUBSCRIPTION_MODELS).filter( + (model) => model.capabilities.indexOf(capability) > -1 + ); +}; + +export const subscriptionsForUser = async ( + userId: string, + includeExpired?: boolean +) => { + // Allow users to continue to access expired features for grace period + const mustBeValidUntil = includeExpired + ? moment(new Date("1980")) + : moment().subtract(CAPABILITY_GRACE_PERIOD, "days"); + + const subscriptions = prisma.userSubscription.findMany({ + where: { + userId, + name: { + not: null, + }, + OR: [ + { + expires: { + gte: mustBeValidUntil.toDate(), + }, + }, + { + expires: null, + }, + ], + }, + }); + + return subscriptions; +}; + +export const capabilitiesForSubscription = ( + subscriptionName: SubscriptionModels +) => { + return SUBSCRIPTION_MODELS[subscriptionName].capabilities; +}; + +export const capabilitiesForUser = async (userId: string) => { + const activeSubscriptions = await subscriptionsForUser(userId); + + return activeSubscriptions.reduce((acc, activeSubscription) => { + const capabilities = capabilitiesForSubscription( + activeSubscription.name as SubscriptionModels + ); + return [...acc, ...capabilities]; + }, [] as Capabilities[]); +}; + +export const userHasCapability = async ( + userId: string, + capability: Capabilities +) => { + const capabilities = await capabilitiesForUser(userId); + return capabilities.includes(capability); +}; diff --git a/packages/trpc/src/services/chat/assistant.ts b/packages/trpc/src/services/chat/assistant.ts new file mode 100644 index 000000000..d37c41b0c --- /dev/null +++ b/packages/trpc/src/services/chat/assistant.ts @@ -0,0 +1,208 @@ +import { prisma } from "@recipesage/prisma"; +import { + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionUserMessageParam, +} from "openai/resources/chat/completions"; +import { OpenAIHelper } from "./openai"; +import { AssistantMessage, Prisma } from "@prisma/client"; +import { initBuildRecipe } from "./chatFunctions"; +import { + AssistantMessageSummary, + assistantMessageSummary, +} from "../../types/queryTypes"; +import dedent from "ts-dedent"; +import { Capabilities, userHasCapability } from "../capabilities"; + +export class Assistant { + private openAiHelper: OpenAIHelper; + /** + * Limits the number of historical messages sent to ChatGPT + */ + private contextSizeLimit = 4; + /** + * Limits the number of messages returned to the user + * to keep long conversations loading quickly. + */ + private chatHistoryLimit = 200; + /** + * Sets up the assistant with some initial instructions + */ + private systemPrompt = dedent` + You are the RecipeSage cooking assistant. + You will not deviate from the topic of recipes and cooking. + `; + + constructor() { + this.openAiHelper = new OpenAIHelper(); + } + + private _buildChatContext(_messages: AssistantMessage[]): AssistantMessage[] { + const messages = Array.from(_messages); + const context: AssistantMessage[] = []; + + // Add messages until context size reached and last message is not a tool_call + while ( + context.length < this.contextSizeLimit || + (context.at(-1) && context.at(-1)?.role === "tool") + ) { + const message = messages.shift(); + if (!message) return context; + + context.push(message); + } + + return context; + } + + private async getChatContext( + userId: string + ): Promise { + const assistantMessages = await prisma.assistantMessage.findMany({ + where: { + userId, + }, + orderBy: { + createdAt: "desc", + }, + take: 100, + }); + + const assistantMessageContext = this._buildChatContext(assistantMessages); + + const chatGPTContext = assistantMessageContext + .reverse() // Oldest first + .map( + (assistantMessage) => + assistantMessage.json as unknown as ChatCompletionMessageParam + ); + + // Insert the system prompt at the beginning of the messages sent to ChatGPT (oldest) + chatGPTContext.unshift({ + role: "system", + content: this.systemPrompt, + }); + + return chatGPTContext; + } + + async checkMessageLimit(userId: string) { + const moreMessages = await userHasCapability( + userId, + Capabilities.AssistantMoreMessages + ); + + const lastDayReset = new Date(); + lastDayReset.setUTCSeconds(0); + lastDayReset.setUTCMinutes(0); + lastDayReset.setUTCHours(0); + + const todayMessageCount = await prisma.assistantMessage.count({ + where: { + userId, + role: "user", + createdAt: { + gte: lastDayReset, + }, + }, + }); + + const messageLimit = moreMessages ? 50 : 5; + + const isOverLimit = todayMessageCount >= messageLimit; + + return isOverLimit; + } + + async sendChat(content: string, userId: string): Promise { + const assistantUser = await prisma.user.findUniqueOrThrow({ + where: { + email: "assistant@recipesage.com", + }, + }); + + const userMessage = { + role: "user", + content, + } satisfies ChatCompletionUserMessageParam; + + const context = [...(await this.getChatContext(userId)), userMessage]; + + const recipes: Prisma.RecipeUncheckedCreateInput[] = []; + + const response = await this.openAiHelper.getChatResponse(context, [ + initBuildRecipe(assistantUser.id, recipes), + ]); + + await prisma.assistantMessage.create({ + data: { + userId, + role: userMessage.role, + content: userMessage.content, + json: userMessage, + }, + }); + + const toolCallsById: Record = {}; + for (const message of response) { + if (message.role === "assistant" && message.tool_calls) { + message.tool_calls.forEach((toolCall) => { + toolCallsById[toolCall.id] = toolCall; + }); + } + } + + for (const message of response) { + let recipeId: string | undefined = undefined; + if ( + message.role === "tool" && + toolCallsById[message.tool_call_id]?.function.name === "displayRecipe" + ) { + const recipeToCreate = recipes.shift(); + if (!recipeToCreate) { + throw new Error( + "ChatGPT claims it created a recipe but no recipe was created by function call" + ); + } + + const recipe = await prisma.recipe.create({ + data: recipeToCreate, + }); + + recipeId = recipe.id; + } + + const content = Array.isArray(message.content) + ? message.content + .map((part) => (part.type === "text" ? part.text : part.image_url)) + .join("\n") + : message.content; + + await prisma.assistantMessage.create({ + data: { + userId, + role: message.role, + recipeId, + content, + name: "name" in message ? message.name : null, + json: message as object, // Prisma does not like OpenAI's typings + }, + }); + } + } + + async getChatHistory(userId: string): Promise { + const messages = await prisma.assistantMessage.findMany({ + where: { + userId, + }, + ...assistantMessageSummary, + orderBy: { + createdAt: "asc", + }, + take: this.chatHistoryLimit, + }); + + return messages; + } +} diff --git a/packages/trpc/src/services/chat/chatFunctions.ts b/packages/trpc/src/services/chat/chatFunctions.ts new file mode 100644 index 000000000..35d2f24e2 --- /dev/null +++ b/packages/trpc/src/services/chat/chatFunctions.ts @@ -0,0 +1,90 @@ +import { Prisma } from "@prisma/client"; +import { RunnableToolFunction } from "openai/lib/RunnableFunction"; + +export const initBuildRecipe = ( + userId: string, + result: Prisma.RecipeUncheckedCreateInput[] +): RunnableToolFunction<{ + title: unknown; + yield: unknown; + activeTime: unknown; + totalTime: unknown; + ingredients: unknown; + instructions: unknown; +}> => ({ + type: "function", + function: { + name: "displayRecipe", + description: + "Displays a recipe in-app to the user. This must always be used any time you use ingredients or instructions in your response.", + parse: JSON.parse, + function: (args) => { + console.log("buildRecipe called with", args); + + const recipe: Prisma.RecipeUncheckedCreateInput = { + userId, + fromUserId: null, + title: typeof args.title === "string" ? args.title : null, + description: null, + folder: "main", + source: "RecipeSage Cooking Assistant", + url: null, + rating: null, + yield: typeof args.yield === "string" ? args.yield : null, + activeTime: + typeof args.activeTime === "string" ? args.activeTime : null, + totalTime: typeof args.totalTime === "string" ? args.totalTime : null, + ingredients: Array.isArray(args.ingredients) + ? args.ingredients.join("\n") + : null, + instructions: Array.isArray(args.instructions) + ? args.instructions.join("\n") + : null, + notes: null, + }; + + result.push(recipe); + + // Return the same thing GPT sent us so that it replies to user with what it built + // If we don't do this, ChatGPT will create a new (different) recipe and reply with that + return args; + }, + parameters: { + type: "object", + properties: { + title: { + type: "string", + description: "The title of the recipe", + }, + yield: { + type: "string", + description: `The yield of the recipe. E.g. "2 servings" or "6 cupcakes"`, + }, + activeTime: { + type: "string", + description: "The amount of time spent actively preparing the recipe", + }, + totalTime: { + type: "string", + description: + "The total amount of time it will take to cook the recipe including prep", + }, + ingredients: { + type: "array", + items: { + type: "string", + description: "An ingredient required for the recipe", + }, + }, + instructions: { + type: "array", + items: { + type: "string", + description: "An instruction for the recipe", + }, + }, + }, + required: ["title", "ingredients", "instructions"], + }, + }, +}); diff --git a/packages/trpc/src/services/chat/openai.ts b/packages/trpc/src/services/chat/openai.ts new file mode 100644 index 000000000..4566d0426 --- /dev/null +++ b/packages/trpc/src/services/chat/openai.ts @@ -0,0 +1,59 @@ +import { OpenAI } from "openai"; +import { RunnableTools } from "openai/lib/RunnableFunction"; +import { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import { initBuildRecipe } from "./chatFunctions"; + +export class OpenAIHelper { + private openAi: OpenAI; + private gptModel = process.env.OPENAI_GPT_MODEL || "gpt-3.5-turbo-1106"; + + constructor() { + const OPENAI_API_KEY = process.env.OPENAI_API_KEY; + if (!OPENAI_API_KEY && process.env.NODE_ENV !== "selfhost") { + throw new Error("OPENAI_API_KEY must be provided"); + } + + this.openAi = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY || "selfhost-invalid-placeholder", + }); + } + + async getChatResponse( + context: ChatCompletionMessageParam[], + tools: RunnableTools<[ReturnType]> + ): Promise { + const runner = this.openAi.beta.chat.completions.runTools({ + messages: context, + model: this.gptModel, + tools, + }); + + await runner.done(); + + // Messages includes the context passed in for some reason. We only want to return new messages + const chats = runner.messages.slice(context.length); + + console.log("messages", chats); + + console.log("cost", await runner.totalUsage()); + + return chats; + } + + async generateImage(prompt: string, userId: string) { + const image = await this.openAi.images.generate({ + prompt, + n: 1, + response_format: "url", + size: "512x512", + user: userId, + }); + + const url = image.data[0].url; + if (!url) { + throw new Error("Dall-E did not create image as requested"); + } + + return url; + } +} diff --git a/packages/trpc/src/types/queryTypes.ts b/packages/trpc/src/types/queryTypes.ts index 9677d5a6e..1478fa231 100644 --- a/packages/trpc/src/types/queryTypes.ts +++ b/packages/trpc/src/types/queryTypes.ts @@ -62,3 +62,28 @@ export const recipeSummary = Prisma.validator()({ * not including ingredients, instructions, notes, etc. **/ export type RecipeSummary = Prisma.RecipeGetPayload; + +/** + * Provides assistant chat history with recipe summary included + **/ +export const assistantMessageSummary = + Prisma.validator()({ + select: { + id: true, + userId: true, + role: true, + content: true, + name: true, + recipeId: true, + createdAt: true, + updatedAt: true, + recipe: recipeSummary, + }, + }); + +/** + * Provides assistant chat history with recipe summary included + **/ +export type AssistantMessageSummary = Prisma.AssistantMessageGetPayload< + typeof assistantMessageSummary +>;