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 }}
+
+
+
+
+
+
+
+ 0"
+ slot="start"
+ >
+
+
+
+
+
+
+ {{ message.recipe.title }}
+
+ {{ 'pages.messageThread.clickToOpen' | translate }}
+
+
+
+
+
+
+ {{ 'pages.messageThread.recipeDeleted' | translate }}
+
+
+ {{ 'pages.messageThread.yourRecipeDeleted' | translate }}
+
+
+
+
+
+
+
+
+ 0"
+ slot="start"
+ >
+
+
+
+ {{ 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
+>;