新增构建OpenSSL镜像相关文件
This commit is contained in:
16
examples/omnivore/api/.eslintrc
Normal file
16
examples/omnivore/api/.eslintrc
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"semi": [2, "never"]
|
||||
}
|
||||
}
|
||||
4
examples/omnivore/api/.prettierrc
Normal file
4
examples/omnivore/api/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
59
examples/omnivore/api/Dockerfile
Normal file
59
examples/omnivore/api/Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
||||
FROM node:18.19.0 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
|
||||
RUN apt-get update && apt-get install -y g++ make python3
|
||||
|
||||
COPY ./package.json .
|
||||
COPY ./yarn.lock .
|
||||
COPY ./tsconfig.json .
|
||||
COPY ./.prettierrc .
|
||||
COPY ./.eslintrc .
|
||||
|
||||
COPY ./readabilityjs/package.json ./packages/readabilityjs/package.json
|
||||
COPY ./api/package.json ./packages/api/package.json
|
||||
COPY ./text-to-speech/package.json ./packages/text-to-speech/package.json
|
||||
COPY ./content-handler/package.json ./packages/content-handler/package.json
|
||||
COPY ./liqe/package.json ./packages/liqe/package.json
|
||||
|
||||
RUN yarn config set registry https://registry.npm.taobao.org && yarn install --pure-lockfile
|
||||
|
||||
ADD ./readabilityjs ./packages/readabilityjs
|
||||
ADD ./api ./packages/api
|
||||
ADD ./text-to-speech ./packages/text-to-speech
|
||||
ADD ./content-handler ./packages/content-handler
|
||||
ADD ./liqe ./packages/liqe
|
||||
|
||||
RUN yarn workspace @omnivore/text-to-speech-handler build
|
||||
RUN yarn workspace @omnivore/content-handler build
|
||||
RUN yarn workspace @omnivore/liqe build
|
||||
RUN yarn workspace @omnivore/api build
|
||||
|
||||
# After building, fetch the production dependencies
|
||||
RUN rm -rf /app/packages/api/node_modules
|
||||
RUN rm -rf /app/node_modules
|
||||
RUN yarn install --pure-lockfile --production
|
||||
|
||||
FROM node:18.19.0 as runner
|
||||
|
||||
RUN apt-get update && apt-get install -y netcat-openbsd
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
ENV PORT=8080
|
||||
|
||||
COPY --from=builder /app/packages/api/dist /app/packages/api/dist
|
||||
COPY --from=builder /app/packages/readabilityjs/ /app/packages/readabilityjs/
|
||||
COPY --from=builder /app/packages/api/package.json /app/packages/api/package.json
|
||||
COPY --from=builder /app/packages/api/node_modules /app/packages/api/node_modules
|
||||
COPY --from=builder /app/node_modules /app/node_modules
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/packages/text-to-speech/ /app/packages/text-to-speech/
|
||||
COPY --from=builder /app/packages/content-handler/ /app/packages/content-handler/
|
||||
COPY --from=builder /app/packages/liqe/ /app/packages/liqe/
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["yarn", "workspace", "@omnivore/api", "start"]
|
||||
8
examples/omnivore/api/api/.dockerignore
Normal file
8
examples/omnivore/api/api/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.env*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
*.yaml
|
||||
.secrets*.yaml
|
||||
30
examples/omnivore/api/api/.env.example
Normal file
30
examples/omnivore/api/api/.env.example
Normal file
@@ -0,0 +1,30 @@
|
||||
API_ENV=local
|
||||
PG_HOST=localhost
|
||||
PG_PORT=5432
|
||||
PG_USER=app_user
|
||||
PG_PASSWORD=app_pass
|
||||
PG_POOL_MAX=20
|
||||
PG_DB=omnivore
|
||||
JWT_SECRET=some_secret
|
||||
SSO_JWT_SECRET=some_sso_secret
|
||||
CLIENT_URL=http://localhost:3000
|
||||
GATEWAY_URL=http://localhost:4000/api
|
||||
IMAGE_PROXY_URL=http://localhost:8080
|
||||
IMAGE_PROXY_SECRET_KEY=some-secret
|
||||
GAUTH_IOS_CLIENT_ID=
|
||||
GAUTH_ANDROID_CLIENT_ID=
|
||||
GAUTH_CLIENT_ID='notset'
|
||||
GAUTH_SECRET='notset'
|
||||
GCP_PROJECT_ID=omnivore-local
|
||||
INTERCOM_TOKEN=
|
||||
SENTRY_DSN=
|
||||
JAEGER_HOST=
|
||||
SAMPLE_METRICS_LOCALLY=FALSE
|
||||
GCS_UPLOAD_BUCKET=
|
||||
GCS_UPLOAD_SA_KEY_FILE_PATH=
|
||||
TWITTER_BEARER_TOKEN=
|
||||
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
|
||||
SENDER_MESSAGE=msgs@sender.domain
|
||||
SENDER_FEEDBACK=feedback@sender.domain
|
||||
SENDER_GENERAL=no-reply@sender.domain
|
||||
CONTENT_FETCH_URL=http://localhost:9090/
|
||||
30
examples/omnivore/api/api/.env.test
Normal file
30
examples/omnivore/api/api/.env.test
Normal file
@@ -0,0 +1,30 @@
|
||||
API_ENV=local
|
||||
PG_HOST=localhost
|
||||
PG_PORT=5432
|
||||
PG_USER=app_user
|
||||
PG_PASSWORD=app_pass
|
||||
PG_POOL_MAX=20
|
||||
PG_DB=omnivore
|
||||
JWT_SECRET=some_secret
|
||||
SSO_JWT_SECRET=some_sso_secret
|
||||
CLIENT_URL=http://localhost:3000
|
||||
GATEWAY_URL=http://localhost:4000/api
|
||||
IMAGE_PROXY_URL=http://localhost:8080
|
||||
IMAGE_PROXY_SECRET_KEY=some-secret
|
||||
GAUTH_IOS_CLIENT_ID=
|
||||
GAUTH_ANDROID_CLIENT_ID=
|
||||
GAUTH_CLIENT_ID='notset'
|
||||
GAUTH_SECRET='notset'
|
||||
GCP_PROJECT_ID=omnivore-local
|
||||
INTERCOM_TOKEN=
|
||||
SENTRY_DSN=
|
||||
JAEGER_HOST=
|
||||
SAMPLE_METRICS_LOCALLY=FALSE
|
||||
GCS_UPLOAD_BUCKET=
|
||||
GCS_UPLOAD_SA_KEY_FILE_PATH=
|
||||
GCS_UPLOAD_PRIVATE_BUCKET=
|
||||
TWITTER_BEARER_TOKEN=
|
||||
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
|
||||
SEGMENT_WRITE_KEY='test'
|
||||
PUBSUB_VERIFICATION_TOKEN='123456'
|
||||
CONTENT_FETCH_URL=http://localhost:9090/
|
||||
5
examples/omnivore/api/api/.eslintignore
Normal file
5
examples/omnivore/api/api/.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
readabilityjs/
|
||||
src/generated/
|
||||
test/resolvers/
|
||||
9
examples/omnivore/api/api/.eslintrc
Normal file
9
examples/omnivore/api/api/.eslintrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unsafe-argument": 0
|
||||
}
|
||||
}
|
||||
15
examples/omnivore/api/api/.nycrc
Normal file
15
examples/omnivore/api/api/.nycrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@istanbuljs/nyc-config-typescript",
|
||||
"check-coverage": true,
|
||||
"all": true,
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"reporter": [
|
||||
"text-summary"
|
||||
],
|
||||
"branches": 40,
|
||||
"lines": 0,
|
||||
"functions": 0,
|
||||
"statements": 60
|
||||
}
|
||||
59
examples/omnivore/api/api/Dockerfile
Normal file
59
examples/omnivore/api/api/Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
||||
FROM node:18.16 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
|
||||
RUN apt-get update && apt-get install -y g++ make python3
|
||||
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
COPY tsconfig.json .
|
||||
COPY .prettierrc .
|
||||
COPY .eslintrc .
|
||||
|
||||
COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json
|
||||
COPY /packages/api/package.json ./packages/api/package.json
|
||||
COPY /packages/text-to-speech/package.json ./packages/text-to-speech/package.json
|
||||
COPY /packages/content-handler/package.json ./packages/content-handler/package.json
|
||||
COPY /packages/liqe/package.json ./packages/liqe/package.json
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
|
||||
ADD /packages/readabilityjs ./packages/readabilityjs
|
||||
ADD /packages/api ./packages/api
|
||||
ADD /packages/text-to-speech ./packages/text-to-speech
|
||||
ADD /packages/content-handler ./packages/content-handler
|
||||
ADD /packages/liqe ./packages/liqe
|
||||
|
||||
RUN yarn workspace @omnivore/text-to-speech-handler build
|
||||
RUN yarn workspace @omnivore/content-handler build
|
||||
RUN yarn workspace @omnivore/liqe build
|
||||
RUN yarn workspace @omnivore/api build
|
||||
|
||||
# After building, fetch the production dependencies
|
||||
RUN rm -rf /app/packages/api/node_modules
|
||||
RUN rm -rf /app/node_modules
|
||||
RUN yarn install --pure-lockfile --production
|
||||
|
||||
FROM node:18.16 as runner
|
||||
|
||||
RUN apt-get update && apt-get install -y netcat-openbsd
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
ENV PORT=8080
|
||||
|
||||
COPY --from=builder /app/packages/api/dist /app/packages/api/dist
|
||||
COPY --from=builder /app/packages/readabilityjs/ /app/packages/readabilityjs/
|
||||
COPY --from=builder /app/packages/api/package.json /app/packages/api/package.json
|
||||
COPY --from=builder /app/packages/api/node_modules /app/packages/api/node_modules
|
||||
COPY --from=builder /app/node_modules /app/node_modules
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/packages/text-to-speech/ /app/packages/text-to-speech/
|
||||
COPY --from=builder /app/packages/content-handler/ /app/packages/content-handler/
|
||||
COPY --from=builder /app/packages/liqe/ /app/packages/liqe/
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["yarn", "workspace", "@omnivore/api", "start"]
|
||||
30
examples/omnivore/api/api/Dockerfile-test
Normal file
30
examples/omnivore/api/api/Dockerfile-test
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM node:18.16-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
COPY tsconfig.json .
|
||||
|
||||
COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json
|
||||
COPY /packages/api/package.json ./packages/api/package.json
|
||||
COPY /packages/text-to-speech/package.json ./packages/text-to-speech/package.json
|
||||
COPY /packages/content-handler/package.json ./packages/content-handler/package.json
|
||||
COPY /packages/liqe/package.json ./packages/liqe/package.json
|
||||
|
||||
RUN apk --no-cache --virtual build-dependencies add \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
|
||||
|
||||
RUN yarn install
|
||||
|
||||
COPY /packages/readabilityjs ./packages/readabilityjs
|
||||
COPY /packages/api ./packages/api
|
||||
COPY /packages/text-to-speech ./packages/text-to-speech
|
||||
COPY /packages/content-handler ./packages/content-handler
|
||||
COPY /packages/liqe ./packages/liqe
|
||||
|
||||
CMD ["yarn", "workspace", "@omnivore/api", "test"]
|
||||
53
examples/omnivore/api/api/README.md
Normal file
53
examples/omnivore/api/api/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Backend
|
||||
|
||||
This workspace is dedicated to API server that uses Apollo GraphQL server and Knex query builder to provide the app with data operations.
|
||||
|
||||
## GraphQL schema
|
||||
|
||||
GraphQL schema is located in `schema.ts`. In order to use new types, queries or mutations you need to declare them there and then run `yarn gql-typegen` from the application root to create the necessary typings in order to write GQL queries from the app.
|
||||
|
||||
## Apollo resolvers and data sources
|
||||
|
||||
We make use of Apollo resolvers and data sources. Resolvers typically contain business logic and handling of user domain errors, while data sources are ideally simple atomic database operations.
|
||||
|
||||
## Interacting with the database
|
||||
|
||||
All operations on the database must be wrapped in Knex transaction on a resolver layer. This ensures data integrity and safety with no side effects on a failed operations.
|
||||
|
||||
Because we make use of Row Level Security in the database, - all operations typically begin with assuming the role for which policies exist via `omnivore.set_claims` database function.
|
||||
|
||||
## ElasticSearch
|
||||
|
||||
We use ElasticSearch to store page data in a distributed manner. This is a great way to store data that is not easily searchable.
|
||||
All the page data is stored in a single index `pages`. This index is then queried by the app to display the data.
|
||||
You need to make sure you have an elasticsearch instance running locally (or just use docker compose).
|
||||
ES url is specified by `ES_URL` environment variable (username `ES_USERNAME` and password `ES_PASSWORD` can be random strings in local environment).
|
||||
|
||||
When you're running elastic for the very first time, you need to create indices and ingest existing data. This can be done by running `python elastic_migrate.py`.
|
||||
This operation is idempotent, so you can always run `python elastic_migrate.py` again to re-ingest all the data.
|
||||
|
||||
You can run ElasticSearch separately by using `docker compose -f docker-compose.yml up -d elastic`.
|
||||
|
||||
## Image Proxy (optional for local dev)
|
||||
|
||||
Backend API server returns article image links using image proxy
|
||||
(/imageproxy). You will need to set the env with var IMAGE_PROXY_URL to point
|
||||
to a running instance of image proxy along with env var IMAGE_PROXY_SECRET. The
|
||||
same secret env var ought to be passed as config to the running image proxy
|
||||
service. You can also use the docker-compose-dev.yml file to bring up just the
|
||||
image proxy service alone (w/ env var for secret specified in the compose file)
|
||||
by running: `docker compose -f docker-compose-dev.yml up -d imageproxy`.
|
||||
|
||||
When running locally, use the .env.local file to set up the env variables in your environment.
|
||||
|
||||
### Set up the database
|
||||
|
||||
Refer the [using locally](../db/README.md#using-locally) section from db README.
|
||||
|
||||
### Copy .env.example file to .env file:
|
||||
|
||||
cp .env.example .env
|
||||
|
||||
### Run the app
|
||||
|
||||
yarn dev
|
||||
5
examples/omnivore/api/api/apollo.config.js
Normal file
5
examples/omnivore/api/api/apollo.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
service: {
|
||||
localSchemaFile: './src/generated/schema.graphql',
|
||||
},
|
||||
}
|
||||
6
examples/omnivore/api/api/mocha-config.json
Normal file
6
examples/omnivore/api/api/mocha-config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"reporter": "mocha-unfunk-reporter",
|
||||
"require": ["test/babel-register.js", "test/global-setup.ts", "test/global-teardown.ts"]
|
||||
}
|
||||
154
examples/omnivore/api/api/package.json
Normal file
154
examples/omnivore/api/api/package.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"name": "@omnivore/api",
|
||||
"version": "1.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "tsc && yarn copy-files",
|
||||
"dev": "ts-node-dev --files src/server.ts",
|
||||
"start": "node dist/server.js",
|
||||
"lint": "eslint src --ext ts,js,tsx,jsx",
|
||||
"lint:fix": "eslint src --fix --ext ts,js,tsx,jsx",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test": "nyc mocha -r ts-node/register --config mocha-config.json --timeout 10000",
|
||||
"copy-files": "copyfiles -u 1 src/**/*.html dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/logging-winston": "^6.0.0",
|
||||
"@google-cloud/monitoring": "^4.0.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0",
|
||||
"@google-cloud/pubsub": "^4.0.0",
|
||||
"@google-cloud/storage": "^7.0.1",
|
||||
"@google-cloud/tasks": "^4.0.0",
|
||||
"@graphql-tools/utils": "^9.1.1",
|
||||
"@omnivore/content-handler": "1.0.0",
|
||||
"@omnivore/liqe": "1.0.0",
|
||||
"@omnivore/readability": "1.0.0",
|
||||
"@omnivore/text-to-speech-handler": "1.0.0",
|
||||
"@opentelemetry/api": "^1.0.1",
|
||||
"@opentelemetry/core": "^1.3.1",
|
||||
"@opentelemetry/exporter-jaeger": "^1.0.1",
|
||||
"@opentelemetry/instrumentation-dns": "^0.31.2",
|
||||
"@opentelemetry/instrumentation-express": "^0.28.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.29.0",
|
||||
"@opentelemetry/instrumentation-grpc": "^0.37.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.27.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.35.1",
|
||||
"@opentelemetry/node": "^0.24.0",
|
||||
"@opentelemetry/resources": "^1.17.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.0.1",
|
||||
"@opentelemetry/tracing": "^0.24.0",
|
||||
"@sendgrid/mail": "^7.6.0",
|
||||
"@sentry/integrations": "^7.10.0",
|
||||
"@sentry/node": "^5.26.0",
|
||||
"@sentry/tracing": "^7.9.0",
|
||||
"addressparser": "^1.0.1",
|
||||
"analytics-node": "^6.0.0",
|
||||
"apollo-datasource": "^3.3.1",
|
||||
"apollo-server-express": "^3.6.3",
|
||||
"axios": "^0.27.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie": "^0.5.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"csv-stringify": "^6.4.0",
|
||||
"dataloader": "^2.0.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"dompurify": "^2.0.17",
|
||||
"dot-case": "^3.0.4",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-http-context2": "^1.0.0",
|
||||
"express-rate-limit": "^6.3.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"firebase-admin": "^11.5.0",
|
||||
"googleapis": "^125.0.0",
|
||||
"graphql": "^15.3.0",
|
||||
"graphql-fields": "^2.0.3",
|
||||
"graphql-middleware": "^6.0.10",
|
||||
"graphql-shield": "^7.5.0",
|
||||
"highlightjs": "^9.16.2",
|
||||
"html-entities": "^2.3.2",
|
||||
"intercom-client": "^3.1.4",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwks-rsa": "^2.0.3",
|
||||
"linkedom": "^0.14.9",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.2.1",
|
||||
"nanoid": "^3.1.25",
|
||||
"node-html-markdown": "^1.3.0",
|
||||
"nodemailer": "^6.7.3",
|
||||
"normalize-url": "^6.1.0",
|
||||
"oauth": "^0.10.0",
|
||||
"pg": "^8.3.3",
|
||||
"postgrator": "^4.2.0",
|
||||
"private-ip": "^2.3.3",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sanitize-html": "^2.3.2",
|
||||
"sax": "^1.3.0",
|
||||
"search-query-parser": "^1.6.0",
|
||||
"snake-case": "^3.0.3",
|
||||
"supertest": "^6.2.2",
|
||||
"ts-loader": "^9.3.0",
|
||||
"typeorm": "^0.3.4",
|
||||
"typeorm-naming-strategies": "^4.1.0",
|
||||
"underscore": "^1.13.6",
|
||||
"urlsafe-base64": "^1.0.0",
|
||||
"uuid": "^8.3.1",
|
||||
"voca": "^1.4.0",
|
||||
"winston": "^3.3.3",
|
||||
"word-counting": "^1.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/register": "^7.14.5",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@types/addressparser": "^1.0.1",
|
||||
"@types/analytics-node": "^3.1.7",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/chai": "^4.2.18",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/chai-string": "^1.4.2",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/csv-stringify": "^3.1.0",
|
||||
"@types/diff-match-patch": "^1.0.32",
|
||||
"@types/dompurify": "^2.0.4",
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/graphql-fields": "^1.3.4",
|
||||
"@types/highlightjs": "^9.12.2",
|
||||
"@types/intercom-client": "^2.11.8",
|
||||
"@types/jsonwebtoken": "^8.5.0",
|
||||
"@types/luxon": "^1.25.0",
|
||||
"@types/mocha": "^8.2.2",
|
||||
"@types/nanoid": "^3.0.0",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/oauth": "^0.9.1",
|
||||
"@types/private-ip": "^1.0.0",
|
||||
"@types/sanitize-html": "^1.27.1",
|
||||
"@types/sax": "^1.2.7",
|
||||
"@types/sinon": "^10.0.13",
|
||||
"@types/sinon-chai": "^3.2.8",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/urlsafe-base64": "^1.0.28",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/voca": "^1.4.0",
|
||||
"chai": "^4.3.4",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chai-string": "^1.5.0",
|
||||
"circular-dependency-plugin": "^5.2.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
"mocha": "^9.0.1",
|
||||
"mocha-unfunk-reporter": "^0.4.0",
|
||||
"nock": "^13.2.4",
|
||||
"nyc": "^15.1.0",
|
||||
"postgrator": "^4.2.0",
|
||||
"sinon": "^14.0.0",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"ts-node-dev": "^1.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.16.1"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
114
examples/omnivore/api/api/src/apollo.ts
Normal file
114
examples/omnivore/api/api/src/apollo.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { ContextFunction } from 'apollo-server-core'
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
import { ExpressContext } from 'apollo-server-express/dist/ApolloServer'
|
||||
import * as httpContext from 'express-http-context2'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { EntityManager } from 'typeorm'
|
||||
import { promisify } from 'util'
|
||||
import { appDataSource } from './data_source'
|
||||
import { sanitizeDirectiveTransformer } from './directives'
|
||||
import { env } from './env'
|
||||
import { createPubSubClient } from './pubsub'
|
||||
import { functionResolvers } from './resolvers/function_resolvers'
|
||||
import { ClaimsToSet, ResolverContext } from './resolvers/types'
|
||||
import ScalarResolvers from './scalars'
|
||||
import typeDefs from './schema'
|
||||
import { tracer } from './tracing'
|
||||
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
|
||||
import { SetClaimsRole } from './utils/dictionary'
|
||||
import { logger } from './utils/logger'
|
||||
|
||||
const signToken = promisify(jwt.sign)
|
||||
const pubsub = createPubSubClient()
|
||||
|
||||
const resolvers = {
|
||||
...functionResolvers,
|
||||
...ScalarResolvers,
|
||||
}
|
||||
|
||||
const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
||||
req,
|
||||
res,
|
||||
}) => {
|
||||
logger.info(`handling gql request`, {
|
||||
query: req.body.query,
|
||||
variables: req.body.variables,
|
||||
})
|
||||
|
||||
const token = req?.cookies?.auth || req?.headers?.authorization
|
||||
const claims = await getClaimsByToken(token)
|
||||
|
||||
httpContext.set('claims', claims)
|
||||
|
||||
async function setClaims(
|
||||
em: EntityManager,
|
||||
uuid?: string,
|
||||
userRole?: string
|
||||
): Promise<void> {
|
||||
const uid =
|
||||
(claims && claims.uid) || uuid || '00000000-0000-0000-0000-000000000000'
|
||||
const dbRole =
|
||||
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
|
||||
return em.query('SELECT * from omnivore.set_claims($1, $2)', [uid, dbRole])
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
log: logger,
|
||||
claims,
|
||||
pubsub,
|
||||
// no caching for subscriptions
|
||||
clearAuth: () => {
|
||||
res.clearCookie('auth')
|
||||
res.clearCookie('pendingUserAuth')
|
||||
},
|
||||
signToken,
|
||||
setAuth: async (
|
||||
claims: ClaimsToSet,
|
||||
secret: string = env.server.jwtSecret
|
||||
) => await setAuthInCookie(claims, res, secret),
|
||||
setClaims,
|
||||
authTrx: <TResult>(
|
||||
cb: (em: EntityManager) => TResult,
|
||||
userRole?: string
|
||||
): Promise<TResult> =>
|
||||
appDataSource.transaction(async (tx) => {
|
||||
await setClaims(tx, undefined, userRole)
|
||||
return cb(tx)
|
||||
}),
|
||||
tracingSpan: tracer.startSpan('apollo.request'),
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function makeApolloServer(): ApolloServer {
|
||||
let schema = makeExecutableSchema({
|
||||
resolvers,
|
||||
typeDefs,
|
||||
})
|
||||
|
||||
schema = sanitizeDirectiveTransformer(schema)
|
||||
|
||||
const apollo = new ApolloServer({
|
||||
schema: schema,
|
||||
context: contextFunc,
|
||||
formatError: (err) => {
|
||||
logger.info('server error', err)
|
||||
Sentry.captureException(err)
|
||||
// hide error messages from frontend on prod
|
||||
return new Error('Unexpected server error')
|
||||
},
|
||||
introspection: env.dev.isLocal,
|
||||
persistedQueries: false,
|
||||
})
|
||||
|
||||
return apollo
|
||||
}
|
||||
21
examples/omnivore/api/api/src/data_source.ts
Normal file
21
examples/omnivore/api/api/src/data_source.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { DataSource } from 'typeorm'
|
||||
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
|
||||
import { env } from './env'
|
||||
import { CustomTypeOrmLogger } from './utils/logger'
|
||||
|
||||
export const appDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: env.pg.host,
|
||||
port: env.pg.port,
|
||||
schema: 'omnivore',
|
||||
username: env.pg.userName,
|
||||
password: env.pg.password,
|
||||
database: env.pg.dbName,
|
||||
logging: ['query', 'info'],
|
||||
entities: [__dirname + '/entity/**/*{.js,.ts}'],
|
||||
subscribers: [__dirname + '/events/**/*{.js,.ts}'],
|
||||
namingStrategy: new SnakeNamingStrategy(),
|
||||
logger: new CustomTypeOrmLogger(['query', 'info']),
|
||||
connectTimeoutMS: 40000, // 40 seconds
|
||||
maxQueryExecutionTime: 10000, // 10 seconds
|
||||
})
|
||||
50
examples/omnivore/api/api/src/directives.ts
Normal file
50
examples/omnivore/api/api/src/directives.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'
|
||||
import { GraphQLNonNull, GraphQLScalarType, GraphQLSchema } from 'graphql'
|
||||
import { SanitizedString } from './scalars'
|
||||
|
||||
export const sanitizeDirectiveTransformer = (schema: GraphQLSchema) => {
|
||||
return mapSchema(schema, {
|
||||
[MapperKind.FIELD]: (fieldConfig) => {
|
||||
const sanitizeDirective = getDirective(
|
||||
schema,
|
||||
fieldConfig,
|
||||
'sanitize'
|
||||
)?.[0]
|
||||
if (!sanitizeDirective) {
|
||||
return fieldConfig
|
||||
}
|
||||
|
||||
const maxLength = sanitizeDirective.maxLength as number | undefined
|
||||
const minLength = sanitizeDirective.minLength as number | undefined
|
||||
const allowedTags = sanitizeDirective.allowedTags as string[] | undefined
|
||||
const pattern = sanitizeDirective.pattern as string | undefined
|
||||
|
||||
if (
|
||||
fieldConfig.type instanceof GraphQLNonNull &&
|
||||
fieldConfig.type.ofType instanceof GraphQLScalarType
|
||||
) {
|
||||
fieldConfig.type = new GraphQLNonNull(
|
||||
new SanitizedString(
|
||||
fieldConfig.type.ofType,
|
||||
allowedTags,
|
||||
maxLength,
|
||||
minLength,
|
||||
pattern
|
||||
)
|
||||
)
|
||||
} else if (fieldConfig.type instanceof GraphQLScalarType) {
|
||||
fieldConfig.type = new SanitizedString(
|
||||
fieldConfig.type,
|
||||
allowedTags,
|
||||
maxLength,
|
||||
minLength,
|
||||
pattern
|
||||
)
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new Error(`Not a scalar type: ${fieldConfig.type}`)
|
||||
}
|
||||
return fieldConfig
|
||||
},
|
||||
})
|
||||
}
|
||||
39
examples/omnivore/api/api/src/entity/api_key.ts
Normal file
39
examples/omnivore/api/api/src/entity/api_key.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity()
|
||||
@Unique('user_id_name', ['user', 'name'])
|
||||
export class ApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column('text')
|
||||
key!: string
|
||||
|
||||
@Column('text', { array: true })
|
||||
scopes?: string[]
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@Column('timestamp')
|
||||
expiresAt!: Date
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
usedAt!: Date | null
|
||||
}
|
||||
40
examples/omnivore/api/api/src/entity/entity_label.ts
Normal file
40
examples/omnivore/api/api/src/entity/entity_label.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { Label } from './label'
|
||||
|
||||
// for labels created by rules, we use the rule name as the source, for example: 'rule:my-rule'
|
||||
// for labels created by users, we use 'user'
|
||||
// for labels created by system, we use 'system'
|
||||
type RuleSourceType = `rule:${string}`
|
||||
export type LabelSource = 'user' | 'system' | RuleSourceType
|
||||
|
||||
export const isLabelSource = (source: string): source is LabelSource => {
|
||||
return ['user', 'system'].indexOf(source) !== -1 || source.startsWith('rule:')
|
||||
}
|
||||
|
||||
@Entity({ name: 'entity_labels' })
|
||||
export class EntityLabel {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('uuid')
|
||||
labelId!: string
|
||||
|
||||
@ManyToOne(() => Label)
|
||||
@JoinColumn({ name: 'label_id' })
|
||||
label!: Label
|
||||
|
||||
@Column('uuid')
|
||||
libraryItemId?: string | null
|
||||
|
||||
@Column('uuid')
|
||||
highlightId?: string | null
|
||||
|
||||
@Column('text', { default: 'user' })
|
||||
source!: LabelSource
|
||||
}
|
||||
37
examples/omnivore/api/api/src/entity/feature.ts
Normal file
37
examples/omnivore/api/api/src/entity/feature.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'features' })
|
||||
@Unique(['user', 'name'])
|
||||
export class Feature {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
grantedAt?: Date | null
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
expiresAt?: Date | null
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
}
|
||||
37
examples/omnivore/api/api/src/entity/feed.ts
Normal file
37
examples/omnivore/api/api/src/entity/feed.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
@Entity()
|
||||
export class Feed {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
title!: string
|
||||
|
||||
@Column('text')
|
||||
url!: string
|
||||
|
||||
@Column('text')
|
||||
author?: string
|
||||
|
||||
@Column('text')
|
||||
description?: string
|
||||
|
||||
@Column('text')
|
||||
image?: string
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
publishedAt?: Date | null
|
||||
}
|
||||
52
examples/omnivore/api/api/src/entity/filter.ts
Normal file
52
examples/omnivore/api/api/src/entity/filter.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'filters' })
|
||||
@Unique('filter_unique_key', ['user', 'name'])
|
||||
export class Filter {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
name!: string
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, default: null })
|
||||
description?: string | null
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
filter!: string
|
||||
|
||||
@Column('varchar', { length: 255, default: 'Search' })
|
||||
category!: string
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
position!: number
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
defaultFilter!: boolean
|
||||
|
||||
@Column('boolean', { default: true })
|
||||
visible!: boolean
|
||||
|
||||
@Column('text')
|
||||
folder!: string
|
||||
}
|
||||
26
examples/omnivore/api/api/src/entity/follower.ts
Normal file
26
examples/omnivore/api/api/src/entity/follower.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'user_friends' })
|
||||
export class Follower {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id?: string
|
||||
|
||||
@OneToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@OneToOne(() => User)
|
||||
@JoinColumn({ name: 'friend_user_id' })
|
||||
followee!: User
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
}
|
||||
47
examples/omnivore/api/api/src/entity/groups/group.ts
Normal file
47
examples/omnivore/api/api/src/entity/groups/group.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
import { User } from '../user'
|
||||
import { GroupMembership } from './group_membership'
|
||||
|
||||
@Entity()
|
||||
export class Group {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@OneToOne(() => User)
|
||||
@JoinColumn()
|
||||
createdBy!: User
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@OneToMany(() => GroupMembership, (groupMembership) => groupMembership.group)
|
||||
members!: GroupMembership[]
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
topics?: string | null
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
onlyAdminCanPost!: boolean
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
onlyAdminCanSeeMembers!: boolean
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
import { User } from '../user'
|
||||
import { Group } from './group'
|
||||
import { Invite } from './invite'
|
||||
|
||||
@Entity()
|
||||
@Unique('group_membership_unique', ['user', 'group'])
|
||||
export class GroupMembership {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@OneToOne(() => User)
|
||||
@JoinColumn()
|
||||
user!: User
|
||||
|
||||
@ManyToOne(() => Group, (group) => group.members)
|
||||
@JoinColumn()
|
||||
group!: Group
|
||||
|
||||
@OneToOne(() => Invite)
|
||||
@JoinColumn()
|
||||
invite!: Invite
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
isAdmin!: boolean
|
||||
}
|
||||
41
examples/omnivore/api/api/src/entity/groups/invite.ts
Normal file
41
examples/omnivore/api/api/src/entity/groups/invite.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
import { User } from '../user'
|
||||
import { Group } from './group'
|
||||
|
||||
@Entity()
|
||||
export class Invite {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
code!: string
|
||||
|
||||
@OneToOne(() => User)
|
||||
@JoinColumn()
|
||||
createdBy!: User
|
||||
|
||||
@OneToOne(() => Group)
|
||||
@JoinColumn()
|
||||
group!: Group
|
||||
|
||||
@Column('integer')
|
||||
maxMembers!: number
|
||||
|
||||
@Column('timestamp')
|
||||
expirationTime!: Date
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
}
|
||||
87
examples/omnivore/api/api/src/entity/highlight.ts
Normal file
87
examples/omnivore/api/api/src/entity/highlight.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { Label } from './label'
|
||||
import { LibraryItem } from './library_item'
|
||||
import { User } from './user'
|
||||
|
||||
export enum HighlightType {
|
||||
Highlight = 'HIGHLIGHT',
|
||||
Redaction = 'REDACTION', // allowing people to remove text from the page
|
||||
Note = 'NOTE', // to be deleted in favor of note on library item
|
||||
}
|
||||
|
||||
@Entity({ name: 'highlight' })
|
||||
export class Highlight {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column({ type: 'varchar', length: 14 })
|
||||
shortId!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@ManyToOne(() => LibraryItem, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'library_item_id' })
|
||||
libraryItem!: LibraryItem
|
||||
|
||||
@Column('text')
|
||||
quote?: string | null
|
||||
|
||||
@Column({ type: 'varchar', length: 5000 })
|
||||
prefix?: string | null
|
||||
|
||||
@Column({ type: 'varchar', length: 5000 })
|
||||
suffix?: string | null
|
||||
|
||||
@Column('text')
|
||||
patch?: string | null
|
||||
|
||||
@Column('text')
|
||||
annotation?: string | null
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date | null
|
||||
|
||||
@Column('timestamp')
|
||||
sharedAt?: Date
|
||||
|
||||
@Column('real', { default: 0 })
|
||||
highlightPositionPercent!: number
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
highlightPositionAnchorIndex!: number
|
||||
|
||||
@Column('enum', {
|
||||
enum: HighlightType,
|
||||
default: HighlightType.Highlight,
|
||||
})
|
||||
highlightType!: HighlightType
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
html?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
color?: string | null
|
||||
|
||||
@ManyToMany(() => Label, { cascade: true, eager: true })
|
||||
@JoinTable({
|
||||
name: 'entity_labels',
|
||||
joinColumn: { name: 'highlight_id' },
|
||||
inverseJoinColumn: { name: 'label_id' },
|
||||
})
|
||||
labels?: Label[]
|
||||
}
|
||||
62
examples/omnivore/api/api/src/entity/integration.ts
Normal file
62
examples/omnivore/api/api/src/entity/integration.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
export enum IntegrationType {
|
||||
Export = 'EXPORT',
|
||||
Import = 'IMPORT',
|
||||
}
|
||||
|
||||
export enum ImportItemState {
|
||||
Unread = 'UNREAD',
|
||||
Unarchived = 'UNARCHIVED',
|
||||
Archived = 'ARCHIVED',
|
||||
All = 'ALL',
|
||||
}
|
||||
|
||||
@Entity({ name: 'integrations' })
|
||||
export class Integration {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', eager: true })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('varchar', { length: 40 })
|
||||
name!: string
|
||||
|
||||
@Column('enum', {
|
||||
enum: IntegrationType,
|
||||
default: IntegrationType.Export,
|
||||
})
|
||||
type!: IntegrationType
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
token!: string
|
||||
|
||||
@Column('boolean', { default: true })
|
||||
enabled!: boolean
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
syncedAt?: Date | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
taskName?: string | null
|
||||
|
||||
@Column('enum', { enum: ImportItemState, nullable: true })
|
||||
importItemState?: ImportItemState | null
|
||||
}
|
||||
41
examples/omnivore/api/api/src/entity/label.ts
Normal file
41
examples/omnivore/api/api/src/entity/label.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'labels' })
|
||||
export class Label {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
color!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description?: string | null
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
position!: number
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
internal!: boolean
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date | null
|
||||
}
|
||||
211
examples/omnivore/api/api/src/entity/library_item.ts
Normal file
211
examples/omnivore/api/api/src/entity/library_item.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { Highlight } from './highlight'
|
||||
import { Label } from './label'
|
||||
import { Recommendation } from './recommendation'
|
||||
import { UploadFile } from './upload_file'
|
||||
import { User } from './user'
|
||||
|
||||
export enum LibraryItemState {
|
||||
Failed = 'FAILED',
|
||||
Processing = 'PROCESSING',
|
||||
Succeeded = 'SUCCEEDED',
|
||||
Deleted = 'DELETED',
|
||||
Archived = 'ARCHIVED',
|
||||
}
|
||||
|
||||
export enum ContentReaderType {
|
||||
WEB = 'WEB',
|
||||
PDF = 'PDF',
|
||||
EPUB = 'EPUB',
|
||||
}
|
||||
|
||||
export enum DirectionalityType {
|
||||
LTR = 'LTR',
|
||||
RTL = 'RTL',
|
||||
}
|
||||
|
||||
@Unique('library_item_user_original_url', ['user', 'originalUrl'])
|
||||
@Entity({ name: 'library_item' })
|
||||
export class LibraryItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('enum', {
|
||||
enum: LibraryItemState,
|
||||
default: LibraryItemState.Succeeded,
|
||||
})
|
||||
state!: LibraryItemState
|
||||
|
||||
@Column('text')
|
||||
originalUrl!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
downloadUrl?: string | null
|
||||
|
||||
@Column('text')
|
||||
slug!: string
|
||||
|
||||
@Column('text')
|
||||
title!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
author?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description?: string | null
|
||||
|
||||
@Column('timestamptz')
|
||||
savedAt!: Date
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@Column('timestamptz', { nullable: true })
|
||||
publishedAt?: Date | null
|
||||
|
||||
@Column('timestamptz')
|
||||
archivedAt?: Date | null
|
||||
|
||||
@Column('timestamptz')
|
||||
deletedAt?: Date | null
|
||||
|
||||
@Column('timestamptz')
|
||||
readAt?: Date | null
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
itemLanguage?: string | null
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
wordCount?: number | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
siteName?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
siteIcon?: string | null
|
||||
|
||||
@Column('integer')
|
||||
readingProgressLastReadAnchor!: number
|
||||
|
||||
@Column('integer')
|
||||
readingProgressHighestReadAnchor!: number
|
||||
|
||||
@Column('real')
|
||||
readingProgressTopPercent!: number
|
||||
|
||||
@Column('real')
|
||||
readingProgressBottomPercent!: number
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
thumbnail?: string | null
|
||||
|
||||
@Column('text')
|
||||
itemType!: string
|
||||
|
||||
@OneToOne(() => UploadFile, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'upload_file_id' })
|
||||
uploadFile?: UploadFile
|
||||
|
||||
// get upload_file_id without joining relations
|
||||
@Column('text', { nullable: true })
|
||||
uploadFileId?: string
|
||||
|
||||
@Column('enum', { enum: ContentReaderType, default: ContentReaderType.WEB })
|
||||
contentReader!: ContentReaderType
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
originalContent?: string | null
|
||||
|
||||
@Column('text')
|
||||
readableContent!: string
|
||||
|
||||
// NOT SUPPORTED IN TYPEORM
|
||||
// @Column('vector', { nullable: true })
|
||||
// embedding?: number[]
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
textContentHash?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
subscription?: string | null
|
||||
|
||||
@ManyToMany(() => Label, { cascade: true })
|
||||
@JoinTable({
|
||||
name: 'entity_labels',
|
||||
joinColumn: { name: 'library_item_id' },
|
||||
inverseJoinColumn: { name: 'label_id' },
|
||||
})
|
||||
labels?: Label[]
|
||||
|
||||
@OneToMany(
|
||||
() => Recommendation,
|
||||
(recommendation) => recommendation.libraryItem,
|
||||
{ cascade: true }
|
||||
)
|
||||
@JoinTable({
|
||||
name: 'recommendation',
|
||||
joinColumn: { name: 'library_item_id' },
|
||||
inverseJoinColumn: { name: 'id' },
|
||||
})
|
||||
recommendations?: Recommendation[]
|
||||
|
||||
@Column('enum', { enum: DirectionalityType, default: DirectionalityType.LTR })
|
||||
directionality!: DirectionalityType
|
||||
|
||||
@OneToMany(() => Highlight, (highlight) => highlight.libraryItem, {
|
||||
cascade: true,
|
||||
})
|
||||
@JoinTable({
|
||||
name: 'highlight',
|
||||
joinColumn: { name: 'library_item_id' },
|
||||
inverseJoinColumn: { name: 'id' },
|
||||
})
|
||||
highlights?: Highlight[]
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
labelNames?: string[] | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
highlightLabels?: string[] | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
highlightAnnotations?: string[] | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
note?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
recommenderNames?: string[] | null
|
||||
|
||||
@Column('jsonb')
|
||||
links?: any | null
|
||||
|
||||
@Column('text')
|
||||
previewContent?: string | null
|
||||
|
||||
@Column('text')
|
||||
previewContentType?: string | null
|
||||
|
||||
@Column('text')
|
||||
folder!: string
|
||||
}
|
||||
37
examples/omnivore/api/api/src/entity/newsletter_email.ts
Normal file
37
examples/omnivore/api/api/src/entity/newsletter_email.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
import { Subscription } from './subscription'
|
||||
|
||||
@Entity({ name: 'newsletter_emails' })
|
||||
export class NewsletterEmail {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('varchar')
|
||||
address!: string
|
||||
|
||||
@ManyToOne(() => User, (user) => user.newsletterEmails)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
confirmationCode?: string | null
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@OneToMany(() => Subscription, (subscription) => subscription.newsletterEmail)
|
||||
subscriptions!: Subscription[]
|
||||
}
|
||||
39
examples/omnivore/api/api/src/entity/profile.ts
Normal file
39
examples/omnivore/api/api/src/entity/profile.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'user_profile' })
|
||||
export class Profile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
username!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
bio?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
pictureUrl?: string | null
|
||||
|
||||
@OneToOne(() => User, (user) => user.profile)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
private!: boolean
|
||||
}
|
||||
44
examples/omnivore/api/api/src/entity/received_email.ts
Normal file
44
examples/omnivore/api/api/src/entity/received_email.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'received_emails' })
|
||||
export class ReceivedEmail {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
from!: string
|
||||
|
||||
@Column('text')
|
||||
to!: string
|
||||
|
||||
@Column('text')
|
||||
subject!: string
|
||||
|
||||
@Column('text')
|
||||
text!: string
|
||||
|
||||
@Column('text')
|
||||
html!: string
|
||||
|
||||
@Column('text')
|
||||
type!: 'article' | 'non-article'
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
}
|
||||
35
examples/omnivore/api/api/src/entity/recommendation.ts
Normal file
35
examples/omnivore/api/api/src/entity/recommendation.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { Group } from './groups/group'
|
||||
import { LibraryItem } from './library_item'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity()
|
||||
export class Recommendation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'recommender_id' })
|
||||
recommender!: User
|
||||
|
||||
@ManyToOne(() => LibraryItem, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'library_item_id' })
|
||||
libraryItem!: LibraryItem
|
||||
|
||||
@ManyToOne(() => Group, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'group_id' })
|
||||
group!: Group
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
note?: string | null
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
}
|
||||
50
examples/omnivore/api/api/src/entity/reminder.ts
Normal file
50
examples/omnivore/api/api/src/entity/reminder.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'reminders' })
|
||||
export class Reminder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('uuid', { name: 'article_saving_request_id' })
|
||||
articleSavingRequest?: string
|
||||
|
||||
@Column('uuid', { name: 'link_id' })
|
||||
link?: string
|
||||
|
||||
@Column('boolean')
|
||||
archiveUntil?: boolean
|
||||
|
||||
@Column('boolean')
|
||||
sendNotification?: boolean
|
||||
|
||||
@Column('text')
|
||||
taskName?: string
|
||||
|
||||
@Column('text')
|
||||
status?: string
|
||||
|
||||
@Column('timestamp')
|
||||
remindAt!: Date
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
|
||||
@Column('text')
|
||||
elasticPageId?: string
|
||||
}
|
||||
38
examples/omnivore/api/api/src/entity/reports/abuse_report.ts
Normal file
38
examples/omnivore/api/api/src/entity/reports/abuse_report.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { ReportType } from '../../generated/graphql'
|
||||
|
||||
@Entity()
|
||||
export class AbuseReport {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id?: string
|
||||
|
||||
@Column('text')
|
||||
libraryItemId?: string
|
||||
|
||||
@Column('text')
|
||||
sharedBy!: string
|
||||
|
||||
@Column('text')
|
||||
itemUrl!: string
|
||||
|
||||
@Column('text')
|
||||
reportedBy!: string
|
||||
|
||||
@Column('enum', { enum: ReportType, array: true })
|
||||
reportTypes!: ReportType[]
|
||||
|
||||
@Column('text')
|
||||
reportComment!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from '../user'
|
||||
|
||||
@Entity()
|
||||
export class ContentDisplayReport {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id?: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
libraryItemId?: string
|
||||
|
||||
@Column('text')
|
||||
content!: string
|
||||
|
||||
@Column('text')
|
||||
originalHtml!: string | undefined
|
||||
|
||||
@Column('text')
|
||||
originalUrl!: string
|
||||
|
||||
@Column('text')
|
||||
reportComment!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
}
|
||||
61
examples/omnivore/api/api/src/entity/rule.ts
Normal file
61
examples/omnivore/api/api/src/entity/rule.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
export enum RuleActionType {
|
||||
AddLabel = 'ADD_LABEL',
|
||||
Archive = 'ARCHIVE',
|
||||
MarkAsRead = 'MARK_AS_READ',
|
||||
SendNotification = 'SEND_NOTIFICATION',
|
||||
}
|
||||
|
||||
export enum RuleEventType {
|
||||
PageCreated = 'PAGE_CREATED',
|
||||
PageUpdated = 'PAGE_UPDATED',
|
||||
}
|
||||
|
||||
export interface RuleAction {
|
||||
type: RuleActionType
|
||||
params: string[]
|
||||
}
|
||||
|
||||
@Entity({ name: 'rules' })
|
||||
export class Rule {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column('text')
|
||||
filter!: string
|
||||
|
||||
@Column('simple-json')
|
||||
actions!: RuleAction[]
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description?: string | null
|
||||
|
||||
@Column('text', { array: true })
|
||||
eventTypes!: RuleEventType[]
|
||||
|
||||
@Column('boolean', { default: true })
|
||||
enabled!: boolean
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
}
|
||||
27
examples/omnivore/api/api/src/entity/search_history.ts
Normal file
27
examples/omnivore/api/api/src/entity/search_history.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'search_history' })
|
||||
@Unique('search_history_user_id_term_key', ['user', 'term'])
|
||||
export class SearchHistory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
term!: string
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
}
|
||||
48
examples/omnivore/api/api/src/entity/speech.ts
Normal file
48
examples/omnivore/api/api/src/entity/speech.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
export enum SpeechState {
|
||||
INITIALIZED = 'INITIALIZED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'speech' })
|
||||
export class Speech {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
elasticPageId!: string
|
||||
|
||||
@Column('text', { default: '' })
|
||||
audioFileName!: string
|
||||
|
||||
@Column('text', { default: '' })
|
||||
speechMarksFileName!: string
|
||||
|
||||
@Column('text')
|
||||
voice!: string
|
||||
|
||||
@Column('enum', { enum: SpeechState, default: SpeechState.INITIALIZED })
|
||||
state!: SpeechState
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
}
|
||||
79
examples/omnivore/api/api/src/entity/subscription.ts
Normal file
79
examples/omnivore/api/api/src/entity/subscription.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { SubscriptionStatus, SubscriptionType } from '../generated/graphql'
|
||||
import { NewsletterEmail } from './newsletter_email'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'subscriptions' })
|
||||
export class Subscription {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column('enum', {
|
||||
enum: SubscriptionStatus,
|
||||
default: SubscriptionStatus.Active,
|
||||
})
|
||||
status!: SubscriptionStatus
|
||||
|
||||
@ManyToOne(() => NewsletterEmail, { nullable: true })
|
||||
@JoinColumn({ name: 'newsletter_email_id' })
|
||||
newsletterEmail?: NewsletterEmail | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description?: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
url?: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
unsubscribeMailTo?: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
unsubscribeHttpUrl?: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
icon?: string | null
|
||||
|
||||
@Column('enum', {
|
||||
enum: SubscriptionType,
|
||||
})
|
||||
type!: SubscriptionType
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
count!: number
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
lastFetchedAt?: Date | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
lastFetchedChecksum?: string | null
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
scheduledAt?: Date | null
|
||||
|
||||
@Column('boolean')
|
||||
isPrivate?: boolean | null
|
||||
|
||||
@Column('boolean')
|
||||
autoAddToLibrary?: boolean | null
|
||||
}
|
||||
38
examples/omnivore/api/api/src/entity/upload_file.ts
Normal file
38
examples/omnivore/api/api/src/entity/upload_file.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'upload_files' })
|
||||
export class UploadFile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
url!: string
|
||||
|
||||
@Column('text')
|
||||
fileName!: string
|
||||
|
||||
@Column('text')
|
||||
contentType!: string
|
||||
|
||||
@Column('text')
|
||||
status!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
}
|
||||
77
examples/omnivore/api/api/src/entity/user.ts
Normal file
77
examples/omnivore/api/api/src/entity/user.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { Label } from './label'
|
||||
import { NewsletterEmail } from './newsletter_email'
|
||||
import { Profile } from './profile'
|
||||
import { Subscription } from './subscription'
|
||||
import { UserPersonalization } from './user_personalization'
|
||||
|
||||
export enum RegistrationType {
|
||||
Google = 'GOOGLE',
|
||||
Apple = 'APPLE',
|
||||
Email = 'EMAIL',
|
||||
}
|
||||
|
||||
export enum StatusType {
|
||||
Active = 'ACTIVE',
|
||||
Pending = 'PENDING',
|
||||
Deleted = 'DELETED',
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'enum', enum: RegistrationType })
|
||||
source!: string
|
||||
|
||||
@Column('text')
|
||||
email!: string
|
||||
|
||||
@Column('text')
|
||||
sourceUserId!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@OneToMany(() => NewsletterEmail, (newsletterEmail) => newsletterEmail.user)
|
||||
newsletterEmails?: NewsletterEmail[]
|
||||
|
||||
@OneToOne(() => Profile, (profile) => profile.user, {
|
||||
eager: true,
|
||||
cascade: true,
|
||||
})
|
||||
profile!: Profile
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true })
|
||||
password?: string
|
||||
|
||||
@OneToMany(() => Label, (label) => label.user)
|
||||
labels?: Label[]
|
||||
|
||||
@OneToMany(() => Subscription, (subscription) => subscription.user)
|
||||
subscriptions?: Subscription[]
|
||||
|
||||
@Column({ type: 'enum', enum: StatusType })
|
||||
status!: StatusType
|
||||
|
||||
@OneToOne(
|
||||
() => UserPersonalization,
|
||||
(userPersonalization) => userPersonalization.user
|
||||
)
|
||||
userPersonalization!: UserPersonalization
|
||||
}
|
||||
25
examples/omnivore/api/api/src/entity/user_device_tokens.ts
Normal file
25
examples/omnivore/api/api/src/entity/user_device_tokens.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'user_device_tokens' })
|
||||
export class UserDeviceToken {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
token!: string
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
}
|
||||
59
examples/omnivore/api/api/src/entity/user_personalization.ts
Normal file
59
examples/omnivore/api/api/src/entity/user_personalization.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'user_personalization' })
|
||||
export class UserPersonalization {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@OneToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
fontFamily?: string | null
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
fontSize?: number | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
margin?: number | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
theme?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
libraryLayoutType?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
librarySortOrder?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
speechVoice?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
speechSecondaryVoice?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
speechRate?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
speechVolume?: string | null
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('json')
|
||||
fields?: any | null
|
||||
}
|
||||
41
examples/omnivore/api/api/src/entity/webhook.ts
Normal file
41
examples/omnivore/api/api/src/entity/webhook.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'webhooks' })
|
||||
export class Webhook {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
url!: string
|
||||
|
||||
@Column('text', { array: true })
|
||||
eventTypes!: string[]
|
||||
|
||||
@Column('text', { default: 'POST' })
|
||||
method!: string
|
||||
|
||||
@Column('text', { default: 'application/json' })
|
||||
contentType!: string
|
||||
|
||||
@Column('boolean', { default: true })
|
||||
enabled!: boolean
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
}
|
||||
7
examples/omnivore/api/api/src/env.ts
Executable file
7
examples/omnivore/api/api/src/env.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
import { getEnv } from './util'
|
||||
|
||||
export const env = getEnv()
|
||||
|
||||
export function homePageURL(): string {
|
||||
return env.client.url
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
InsertEvent,
|
||||
} from 'typeorm'
|
||||
import { ContentDisplayReport } from '../../entity/reports/content_display_report'
|
||||
import { env } from '../../env'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { sendEmail } from '../../utils/sendEmail'
|
||||
|
||||
@EventSubscriber()
|
||||
export class ContentDisplayReportSubscriber
|
||||
implements EntitySubscriberInterface<ContentDisplayReport>
|
||||
{
|
||||
listenTo() {
|
||||
return ContentDisplayReport
|
||||
}
|
||||
|
||||
async afterInsert(event: InsertEvent<ContentDisplayReport>): Promise<void> {
|
||||
const report = event.entity
|
||||
const message = `A new content display report was created by:
|
||||
${report.user.id} for URL: ${report.originalUrl}
|
||||
${report.reportComment}`
|
||||
|
||||
logger.info(message)
|
||||
|
||||
if (!env.dev.isLocal) {
|
||||
// If we are in the local environment, just log a message, otherwise email the report
|
||||
await sendEmail({
|
||||
to: env.sender.feedback,
|
||||
subject: 'New content display report',
|
||||
text: message,
|
||||
from: env.sender.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
InsertEvent,
|
||||
} from 'typeorm'
|
||||
|
||||
import { GroupMembership } from '../../entity/groups/group_membership'
|
||||
|
||||
@EventSubscriber()
|
||||
export class FollowAllGroupMembers
|
||||
implements EntitySubscriberInterface<GroupMembership>
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
listenTo() {
|
||||
return GroupMembership
|
||||
}
|
||||
|
||||
async afterInsert(event: InsertEvent<GroupMembership>): Promise<void> {
|
||||
// Make all existing group members follow the new user
|
||||
await event.manager.query(
|
||||
`insert into omnivore.user_friends (user_id, friend_user_id)
|
||||
select user_id, $1 from omnivore.group_membership where group_id = $2 and user_id != $1
|
||||
`,
|
||||
[event.entity.user.id, event.entity.group.id]
|
||||
)
|
||||
|
||||
// Make the new user follow all existing group members
|
||||
await event.manager.query(
|
||||
`insert into omnivore.user_friends (user_id, friend_user_id)
|
||||
select $1, user_id from omnivore.group_membership where group_id = $2 and user_id != $1
|
||||
`,
|
||||
[event.entity.user.id, event.entity.group.id]
|
||||
)
|
||||
}
|
||||
}
|
||||
6780
examples/omnivore/api/api/src/generated/graphql.ts
Normal file
6780
examples/omnivore/api/api/src/generated/graphql.ts
Normal file
File diff suppressed because it is too large
Load Diff
2753
examples/omnivore/api/api/src/generated/schema.graphql
Normal file
2753
examples/omnivore/api/api/src/generated/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
0
examples/omnivore/api/api/src/global.d.ts
vendored
Normal file
0
examples/omnivore/api/api/src/global.d.ts
vendored
Normal file
53
examples/omnivore/api/api/src/graphql.d.ts
vendored
Executable file
53
examples/omnivore/api/api/src/graphql.d.ts
vendored
Executable file
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
declare module '*.graphql' {
|
||||
import { DocumentNode } from 'graphql'
|
||||
const schema: DocumentNode
|
||||
|
||||
export = schema
|
||||
}
|
||||
|
||||
declare module 'knex-stringcase' {
|
||||
import { Knex } from 'knex'
|
||||
|
||||
type StringCase =
|
||||
| 'camelcase'
|
||||
| 'capitalcase'
|
||||
| 'constcase'
|
||||
| 'cramcase'
|
||||
| 'decapitalcase'
|
||||
| 'dotcase'
|
||||
| 'enumcase'
|
||||
| 'lowercase'
|
||||
| 'pascalcase'
|
||||
| 'pathcase'
|
||||
| 'sentencecase'
|
||||
| 'snakecase'
|
||||
| 'spacecase'
|
||||
| 'spinalcase'
|
||||
| 'titlecase'
|
||||
| 'trimcase'
|
||||
| 'uppercase'
|
||||
|
||||
interface KnexStringCaseConfig extends Knex.Config {
|
||||
appStringcase?: StringCase | StringCase[]
|
||||
dbStringcase?: StringCase | StringCase[]
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
beforePostProcessResponse?(
|
||||
result: any[] | object,
|
||||
queryContext: object
|
||||
): any[] | object
|
||||
beforeWrapIdentifier?(value: string, queryContext: object): string
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
ignoreStringcase?(obj: object): boolean
|
||||
}
|
||||
|
||||
function knexStringcase(config: KnexStringCaseConfig): Knex.Config
|
||||
export = knexStringcase
|
||||
}
|
||||
|
||||
declare module 'voca/slugify' {
|
||||
function slugify(subject?: string): string
|
||||
|
||||
export = slugify
|
||||
}
|
||||
23
examples/omnivore/api/api/src/permissions.ts
Normal file
23
examples/omnivore/api/api/src/permissions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { rule, shield } from 'graphql-shield'
|
||||
|
||||
const isNotAuthenticated = rule({ cache: 'contextual' })(
|
||||
async (_parent, _args, ctx, _info) => {
|
||||
return ctx.claims?.uid === undefined
|
||||
}
|
||||
)
|
||||
|
||||
const permissions = shield({
|
||||
Query: {
|
||||
// me: isAuthenticated,
|
||||
// article: and(isAuthenticated, isFullUser),
|
||||
// articles: and(isAuthenticated, isFullUser),
|
||||
},
|
||||
Mutation: {
|
||||
googleSignup: isNotAuthenticated,
|
||||
},
|
||||
})
|
||||
|
||||
export default permissions
|
||||
159
examples/omnivore/api/api/src/pubsub.ts
Normal file
159
examples/omnivore/api/api/src/pubsub.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { PubSub } from '@google-cloud/pubsub'
|
||||
import express from 'express'
|
||||
import { env } from './env'
|
||||
import { ReportType } from './generated/graphql'
|
||||
import { deepDelete } from './utils/helpers'
|
||||
import { buildLogger } from './utils/logger'
|
||||
|
||||
const logger = buildLogger('pubsub')
|
||||
|
||||
const client = new PubSub()
|
||||
|
||||
export const createPubSubClient = (): PubsubClient => {
|
||||
const fieldsToDelete = ['user'] as const
|
||||
|
||||
const publish = (topicName: string, msg: Buffer): Promise<void> => {
|
||||
if (env.dev.isLocal) {
|
||||
logger.info(`Publishing ${topicName}: ${msg.toString()}`)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return client
|
||||
.topic(topicName)
|
||||
.publishMessage({ data: msg })
|
||||
.catch((err) => {
|
||||
logger.error(`[PubSub] error: ${topicName}`, err)
|
||||
})
|
||||
.then(() => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
userCreated: (
|
||||
userId: string,
|
||||
email: string,
|
||||
name: string,
|
||||
username: string
|
||||
): Promise<void> => {
|
||||
return publish(
|
||||
'userCreated',
|
||||
Buffer.from(JSON.stringify({ userId, email, name, username }))
|
||||
)
|
||||
},
|
||||
entityCreated: <T>(
|
||||
type: EntityType,
|
||||
data: T,
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
const cleanData = deepDelete(
|
||||
data as T & Record<typeof fieldsToDelete[number], unknown>,
|
||||
[...fieldsToDelete]
|
||||
)
|
||||
|
||||
return publish(
|
||||
'entityCreated',
|
||||
Buffer.from(JSON.stringify({ type, userId, ...cleanData }))
|
||||
)
|
||||
},
|
||||
entityUpdated: <T>(
|
||||
type: EntityType,
|
||||
data: T,
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
const cleanData = deepDelete(
|
||||
data as T & Record<typeof fieldsToDelete[number], unknown>,
|
||||
[...fieldsToDelete]
|
||||
)
|
||||
|
||||
return publish(
|
||||
'entityUpdated',
|
||||
Buffer.from(JSON.stringify({ type, userId, ...cleanData }))
|
||||
)
|
||||
},
|
||||
entityDeleted: (
|
||||
type: EntityType,
|
||||
id: string,
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
return publish(
|
||||
'entityDeleted',
|
||||
Buffer.from(JSON.stringify({ type, id, userId }))
|
||||
)
|
||||
},
|
||||
reportSubmitted: (
|
||||
submitterId: string,
|
||||
itemUrl: string,
|
||||
reportType: ReportType[],
|
||||
reportComment: string
|
||||
): Promise<void> => {
|
||||
return publish(
|
||||
'reportSubmitted',
|
||||
Buffer.from(
|
||||
JSON.stringify({ submitterId, itemUrl, reportType, reportComment })
|
||||
)
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export enum EntityType {
|
||||
PAGE = 'page',
|
||||
HIGHLIGHT = 'highlight',
|
||||
LABEL = 'label',
|
||||
}
|
||||
|
||||
export interface PubsubClient {
|
||||
userCreated: (
|
||||
userId: string,
|
||||
email: string,
|
||||
name: string,
|
||||
username: string
|
||||
) => Promise<void>
|
||||
entityCreated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
|
||||
entityUpdated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
|
||||
entityDeleted: (type: EntityType, id: string, userId: string) => Promise<void>
|
||||
reportSubmitted(
|
||||
submitterId: string | undefined,
|
||||
itemUrl: string,
|
||||
reportType: ReportType[],
|
||||
reportComment: string
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
interface PubSubRequestMessage {
|
||||
data: string
|
||||
publishTime: string
|
||||
}
|
||||
|
||||
export interface PubSubRequestBody {
|
||||
message: PubSubRequestMessage
|
||||
}
|
||||
|
||||
const expired = (body: PubSubRequestBody): boolean => {
|
||||
const now = new Date()
|
||||
const expiredTime = new Date(body.message.publishTime)
|
||||
expiredTime.setHours(expiredTime.getHours() + 1)
|
||||
|
||||
return now > expiredTime
|
||||
}
|
||||
|
||||
export const readPushSubscription = (
|
||||
req: express.Request
|
||||
): { message: string | undefined; expired: boolean } => {
|
||||
if (req.query.token !== process.env.PUBSUB_VERIFICATION_TOKEN) {
|
||||
logger.info('query does not include valid pubsub token')
|
||||
return { message: undefined, expired: false }
|
||||
}
|
||||
|
||||
// GCP PubSub sends the request as a base64 encoded string
|
||||
if (!('message' in req.body)) {
|
||||
logger.info('Invalid pubsub message: message not in body')
|
||||
return { message: undefined, expired: false }
|
||||
}
|
||||
|
||||
const body = req.body as PubSubRequestBody
|
||||
const message = Buffer.from(body.message.data, 'base64').toString('utf-8')
|
||||
|
||||
return { message: message, expired: expired(body) }
|
||||
}
|
||||
173
examples/omnivore/api/api/src/readability.d.ts
vendored
Normal file
173
examples/omnivore/api/api/src/readability.d.ts
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
// Type definitions for non-npm package mozilla-readability 0.2
|
||||
// Project: https://github.com/mozilla/readability
|
||||
// Definitions by: Charles Vandevoorde <https://github.com/charlesvdv>, Alex Wendland <https://github.com/awendland>
|
||||
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||
// TypeScript Version: 2.2
|
||||
|
||||
declare module '@omnivore/readability' {
|
||||
/**
|
||||
* A standalone version of the readability library used for Firefox Reader View.
|
||||
*
|
||||
* Note that isProbablyReaderable() was moved into a separate file in https://github.com/mozilla/readability/commit/2620542dd1e8380220d82afa97a2c283ae636e40
|
||||
* and therefore is no longer part of the Readability class.
|
||||
*/
|
||||
class Readability {
|
||||
/**
|
||||
* ## Usage on the web
|
||||
*
|
||||
* To parse a document, you must create a new Readability object from a
|
||||
* DOM document object, and then call parse(). Here's an example:
|
||||
*
|
||||
* ```js
|
||||
* var article = new Readability(document).parse();
|
||||
* ```
|
||||
*
|
||||
* If you're using Readability on the web, you will likely be able to
|
||||
* use a document reference from elsewhere (e.g. fetched via XMLHttpRequest,
|
||||
* in a same-origin <iframe> you have access to, etc.).
|
||||
*
|
||||
* ## Usage from node.js
|
||||
*
|
||||
* In node.js, you won't generally have a DOM document object. To obtain one, you can use external
|
||||
* libraries like [jsdom](https://github.com/tmpvar/jsdom). While this repository contains a parser of
|
||||
* its own (`JSDOMParser`), that is restricted to reading XML-compatible markup and therefore we do
|
||||
* not recommend it for general use.
|
||||
*
|
||||
* If you're using `jsdom` to create a DOM object, you should ensure that the page doesn't run (page)
|
||||
* scripts (avoid fetching remote resources etc.) as well as passing it the page's URI as the `url`
|
||||
* property of the `options` object you pass the `JSDOM` constructor.
|
||||
*
|
||||
* ```js
|
||||
* var JSDOM = require('jsdom').JSDOM;
|
||||
* var doc = new JSDOM("<body>Here's a bunch of text</body>", {
|
||||
* url: "https://www.example.com/the-page-i-got-the-source-from",
|
||||
* });
|
||||
* let reader = new Readability(doc.window.document);
|
||||
* let article = reader.parse();
|
||||
* ```
|
||||
*/
|
||||
constructor(doc: Document, options?: Readability.Options)
|
||||
|
||||
/**
|
||||
* Runs readability.
|
||||
*
|
||||
* ## Workflow:
|
||||
*
|
||||
* 1. Prep the document by removing script tags, css, etc.
|
||||
* 2. Build readability's DOM tree.
|
||||
* 3. Grab the article content from the current dom tree.
|
||||
* 4. Replace the current DOM tree with the new one.
|
||||
* 5. Read peacefully.
|
||||
*
|
||||
* ## Additional notes:
|
||||
*
|
||||
* Readability's parse() works by modifying the DOM. This removes some
|
||||
* elements in the web page. You could avoid this by passing the clone
|
||||
* of the document object while creating a Readability object.
|
||||
*
|
||||
* ```js
|
||||
* var documentClone = document.cloneNode(true);
|
||||
* var article = new Readability(documentClone).parse();
|
||||
* ```
|
||||
*
|
||||
* The response will be null if the processing failed (https://github.com/mozilla/readability/blob/52ab9b5c8916c306a47b2119270dcdabebf9d203/Readability.js#L2038)
|
||||
*/
|
||||
async parse(): Promise<Readability.ParseResult | null>
|
||||
}
|
||||
|
||||
namespace Readability {
|
||||
interface Options {
|
||||
/**
|
||||
* Control whether log messages are sent to the console
|
||||
*/
|
||||
debug?: boolean
|
||||
|
||||
/**
|
||||
* Set a maximum size on the documents that will be processed. This size is
|
||||
* checked before any parsing operations occur. If the number of elements in
|
||||
* the document exceeds this threshold then an Error will be thrown.
|
||||
*
|
||||
* See implementation details at https://github.com/mozilla/readability/blob/52ab9b5c8916c306a47b2119270dcdabebf9d203/Readability.js#L2019
|
||||
*/
|
||||
maxElemsToParse?: number
|
||||
|
||||
nbTopCandidates?: number
|
||||
|
||||
/**
|
||||
* Minimum number of characters in the extracted textContent in order to
|
||||
* consider the article correctly identified. If the threshold is not met then
|
||||
* the extraction process will automatically run again with different flags.
|
||||
*
|
||||
* See implementation details at https://github.com/mozilla/readability/blob/52ab9b5c8916c306a47b2119270dcdabebf9d203/Readability.js#L1208
|
||||
*
|
||||
* Changed from wordThreshold in https://github.com/mozilla/readability/commit/3ff9a166fb27928f222c4c0722e730eda412658a
|
||||
*/
|
||||
charThreshold?: number
|
||||
|
||||
/**
|
||||
* parse() removes the class="" attribute from every element in the given
|
||||
* subtree, except those that match CLASSES_TO_PRESERVE and
|
||||
* the classesToPreserve array from the options object.
|
||||
*/
|
||||
classesToPreserve?: string[]
|
||||
|
||||
/**
|
||||
* By default Readability will strip all classes from the HTML elements in the
|
||||
* processed article. By setting this to `true` the classes will be retained.
|
||||
*
|
||||
* This is a blanket alternative to `classesToPreserve`.
|
||||
*
|
||||
* Added in https://github.com/mozilla/readability/commit/2982216913af2c66b0690e88606b03116553ad92
|
||||
*/
|
||||
|
||||
keepClasses?: boolean
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* Function that converts a regular image url into imageproxy url
|
||||
* @param url string
|
||||
*/
|
||||
createImageProxyUrl?: (
|
||||
url: string,
|
||||
width?: number,
|
||||
height?: number
|
||||
) => string
|
||||
|
||||
/**
|
||||
* By default, Readability will clean all tables from the HTML elements in the
|
||||
* processed article. But newsletters in emails use tables to display their content.
|
||||
* By setting this to `true`, these tables will be retained.
|
||||
*/
|
||||
keepTables?: boolean
|
||||
ignoreLinkDensity?: boolean
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
/** Article title */
|
||||
title: string
|
||||
/** Author metadata */
|
||||
byline?: string | null
|
||||
/** Content direction */
|
||||
dir?: string | null
|
||||
/** HTML string of processed article content */
|
||||
content: string
|
||||
/** non-HTML version of `content` */
|
||||
textContent: string
|
||||
/** Length of an article, in characters */
|
||||
length: number
|
||||
/** Article description, or short excerpt from the content */
|
||||
excerpt: string
|
||||
/** Article site name */
|
||||
siteName?: string | null
|
||||
/** Article site icon */
|
||||
siteIcon?: string | null
|
||||
/** Article preview image */
|
||||
previewImage?: string | null
|
||||
/** Article published date */
|
||||
publishedDate?: Date | null
|
||||
language?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export { Readability }
|
||||
}
|
||||
33
examples/omnivore/api/api/src/repository/feed.ts
Normal file
33
examples/omnivore/api/api/src/repository/feed.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ILike } from 'typeorm'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { Feed } from '../entity/feed'
|
||||
|
||||
export const feedRepository = appDataSource.getRepository(Feed).extend({
|
||||
async searchFeeds(
|
||||
query = '',
|
||||
take = 10,
|
||||
skip = 0,
|
||||
orderBy = 'title',
|
||||
order = 'ASC'
|
||||
) {
|
||||
const where = []
|
||||
if (query !== '') {
|
||||
query = `%${query}%`
|
||||
where.push({ title: ILike(query) }, { url: ILike(query) })
|
||||
}
|
||||
|
||||
const feeds = await this.find({
|
||||
where,
|
||||
order: { [orderBy]: order },
|
||||
take,
|
||||
skip,
|
||||
})
|
||||
|
||||
const count = await this.countBy(where)
|
||||
|
||||
return {
|
||||
feeds,
|
||||
count,
|
||||
}
|
||||
},
|
||||
})
|
||||
53
examples/omnivore/api/api/src/repository/highlight.ts
Normal file
53
examples/omnivore/api/api/src/repository/highlight.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DeepPartial } from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { Highlight } from '../entity/highlight'
|
||||
import { unescapeHtml } from '../utils/helpers'
|
||||
|
||||
const unescapeHighlight = (highlight: DeepPartial<Highlight>) => {
|
||||
// unescape HTML entities
|
||||
if (highlight.annotation !== undefined && highlight.annotation !== null) {
|
||||
highlight.annotation = unescapeHtml(highlight.annotation.toString())
|
||||
}
|
||||
if (highlight.quote !== undefined && highlight.quote !== null) {
|
||||
highlight.quote = unescapeHtml(highlight.quote.toString())
|
||||
}
|
||||
|
||||
return highlight
|
||||
}
|
||||
|
||||
export const highlightRepository = appDataSource
|
||||
.getRepository(Highlight)
|
||||
.extend({
|
||||
findById(id: string) {
|
||||
return this.findOneBy({ id })
|
||||
},
|
||||
|
||||
findByLibraryItemId(libraryItemId: string, userId: string) {
|
||||
return this.findBy({
|
||||
libraryItem: { id: libraryItemId },
|
||||
user: { id: userId },
|
||||
})
|
||||
},
|
||||
|
||||
createAndSave(highlight: DeepPartial<Highlight>) {
|
||||
return this.save(unescapeHighlight(highlight))
|
||||
},
|
||||
|
||||
createAndSaves(highlights: DeepPartial<Highlight>[]) {
|
||||
return this.save(highlights.map(unescapeHighlight))
|
||||
},
|
||||
|
||||
updateAndSave(
|
||||
highlightId: string,
|
||||
highlight: QueryDeepPartialEntity<Highlight>
|
||||
) {
|
||||
if (highlight.annotation !== undefined && highlight.annotation !== null) {
|
||||
highlight.annotation = unescapeHtml(highlight.annotation.toString())
|
||||
}
|
||||
if (highlight.quote !== undefined && highlight.quote !== null) {
|
||||
highlight.quote = unescapeHtml(highlight.quote.toString())
|
||||
}
|
||||
return this.update(highlightId, highlight)
|
||||
},
|
||||
})
|
||||
47
examples/omnivore/api/api/src/repository/index.ts
Normal file
47
examples/omnivore/api/api/src/repository/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as httpContext from 'express-http-context2'
|
||||
import { EntityManager, EntityTarget, Repository } from 'typeorm'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { Claims } from '../resolvers/types'
|
||||
import { SetClaimsRole } from '../utils/dictionary'
|
||||
|
||||
export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
|
||||
return repository.metadata.columns.map(
|
||||
(col) => col.propertyName
|
||||
) as (keyof T)[]
|
||||
}
|
||||
|
||||
export const setClaims = async (
|
||||
manager: EntityManager,
|
||||
uid = '00000000-0000-0000-0000-000000000000',
|
||||
userRole = 'user'
|
||||
): Promise<unknown> => {
|
||||
const dbRole =
|
||||
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
|
||||
return manager.query('SELECT * from omnivore.set_claims($1, $2)', [
|
||||
uid,
|
||||
dbRole,
|
||||
])
|
||||
}
|
||||
|
||||
export const authTrx = async <T>(
|
||||
fn: (manager: EntityManager) => Promise<T>,
|
||||
em = appDataSource.manager,
|
||||
uid?: string,
|
||||
userRole?: string
|
||||
): Promise<T> => {
|
||||
// if uid and dbRole are not passed in, then get them from the claims
|
||||
if (!uid && !userRole) {
|
||||
const claims: Claims | undefined = httpContext.get('claims')
|
||||
uid = claims?.uid
|
||||
userRole = claims?.userRole
|
||||
}
|
||||
|
||||
return em.transaction(async (tx) => {
|
||||
await setClaims(tx, uid, userRole)
|
||||
return fn(tx)
|
||||
})
|
||||
}
|
||||
|
||||
export const getRepository = <T>(entity: EntityTarget<T>) => {
|
||||
return appDataSource.getRepository(entity)
|
||||
}
|
||||
87
examples/omnivore/api/api/src/repository/label.ts
Normal file
87
examples/omnivore/api/api/src/repository/label.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { In } from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { Label } from '../entity/label'
|
||||
import { generateRandomColor } from '../utils/helpers'
|
||||
|
||||
export interface CreateLabelInput {
|
||||
name: string
|
||||
color?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
const INTERNAL_LABELS_WITH_COLOR = new Map<
|
||||
string,
|
||||
{ name: string; color: string }
|
||||
>([
|
||||
['favorites', { name: 'Favorites', color: '#FFD700' }],
|
||||
['library', { name: 'Library', color: '#584C42' }],
|
||||
['rss', { name: 'RSS', color: '#F26522' }],
|
||||
['newsletter', { name: 'Newsletter', color: '#07D2D1' }],
|
||||
])
|
||||
|
||||
export const getInternalLabelWithColor = (name: string) => {
|
||||
return INTERNAL_LABELS_WITH_COLOR.get(name.toLowerCase())
|
||||
}
|
||||
|
||||
const isLabelInternal = (name: string): boolean => {
|
||||
return INTERNAL_LABELS_WITH_COLOR.has(name.toLowerCase())
|
||||
}
|
||||
|
||||
const convertToLabel = (label: CreateLabelInput, userId: string) => {
|
||||
return {
|
||||
user: { id: userId },
|
||||
name: label.name,
|
||||
color:
|
||||
label.color ||
|
||||
getInternalLabelWithColor(label.name)?.color ||
|
||||
generateRandomColor(), // assign a random color if not provided
|
||||
description: label.description,
|
||||
internal: isLabelInternal(label.name),
|
||||
}
|
||||
}
|
||||
|
||||
export const labelRepository = appDataSource.getRepository(Label).extend({
|
||||
findById(id: string) {
|
||||
return this.findOneBy({ id })
|
||||
},
|
||||
|
||||
findByName(name: string) {
|
||||
return this.createQueryBuilder()
|
||||
.where('LOWER(name) = LOWER(:name)', { name }) // case insensitive
|
||||
.getOne()
|
||||
},
|
||||
|
||||
findByNames(names: string[], userId: string) {
|
||||
return this.createQueryBuilder()
|
||||
.where('LOWER(name) IN (:...names)', {
|
||||
names: names.map((n) => n.toLowerCase()),
|
||||
})
|
||||
.andWhere('user_id = :userId', { userId })
|
||||
.getMany()
|
||||
},
|
||||
|
||||
findLabelsById(labelIds: string[]) {
|
||||
return this.find({
|
||||
where: { id: In(labelIds) },
|
||||
select: ['id', 'name', 'color', 'description', 'createdAt'],
|
||||
})
|
||||
},
|
||||
|
||||
createLabel(label: CreateLabelInput, userId: string) {
|
||||
return this.save(convertToLabel(label, userId))
|
||||
},
|
||||
|
||||
createLabels(labels: CreateLabelInput[], userId: string) {
|
||||
return this.save(labels.map((l) => convertToLabel(l, userId)))
|
||||
},
|
||||
|
||||
deleteById(id: string) {
|
||||
return this.delete({ id, internal: false })
|
||||
},
|
||||
|
||||
updateLabel(id: string, label: QueryDeepPartialEntity<Label>) {
|
||||
// internal labels should not be updated
|
||||
return this.update({ id, internal: false }, label)
|
||||
},
|
||||
})
|
||||
62
examples/omnivore/api/api/src/repository/library_item.ts
Normal file
62
examples/omnivore/api/api/src/repository/library_item.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { appDataSource } from '../data_source'
|
||||
import { LibraryItem } from '../entity/library_item'
|
||||
|
||||
export const libraryItemRepository = appDataSource
|
||||
.getRepository(LibraryItem)
|
||||
.extend({
|
||||
findById(id: string) {
|
||||
return this.findOneBy({ id })
|
||||
},
|
||||
|
||||
findByUrl(url: string) {
|
||||
return this.findOneBy({
|
||||
originalUrl: url,
|
||||
})
|
||||
},
|
||||
|
||||
countByCreatedAt(createdAt: Date) {
|
||||
return this.countBy({ createdAt })
|
||||
},
|
||||
|
||||
createByPopularRead(name: string, userId: string) {
|
||||
return this.query(
|
||||
`
|
||||
INSERT INTO omnivore.library_item (
|
||||
slug,
|
||||
readable_content,
|
||||
original_content,
|
||||
description,
|
||||
title,
|
||||
author,
|
||||
original_url,
|
||||
item_type,
|
||||
thumbnail,
|
||||
published_at,
|
||||
site_name,
|
||||
user_id,
|
||||
word_count
|
||||
)
|
||||
SELECT
|
||||
slug,
|
||||
readable_content,
|
||||
original_content,
|
||||
description,
|
||||
title,
|
||||
author,
|
||||
original_url,
|
||||
$1,
|
||||
thumbnail,
|
||||
published_at,
|
||||
site_name,
|
||||
$2,
|
||||
word_count
|
||||
FROM
|
||||
omnivore.popular_read
|
||||
WHERE
|
||||
key = $3
|
||||
RETURNING *
|
||||
`,
|
||||
['ARTICLE', userId, name]
|
||||
) as Promise<LibraryItem[]>
|
||||
},
|
||||
})
|
||||
37
examples/omnivore/api/api/src/repository/user.ts
Normal file
37
examples/omnivore/api/api/src/repository/user.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { In } from 'typeorm'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { StatusType, User } from './../entity/user'
|
||||
|
||||
const TOP_USERS = [
|
||||
'jacksonh',
|
||||
'nat',
|
||||
'luis',
|
||||
'satindar',
|
||||
'malandrina',
|
||||
'patrick',
|
||||
'alexgutjahr',
|
||||
'hongbowu',
|
||||
]
|
||||
export const MAX_RECORDS_LIMIT = 1000
|
||||
|
||||
export const userRepository = appDataSource.getRepository(User).extend({
|
||||
findById(id: string) {
|
||||
return this.findOneBy({ id, status: StatusType.Active })
|
||||
},
|
||||
|
||||
findByEmail(email: string) {
|
||||
return this.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.profile', 'profile')
|
||||
.where('LOWER(email) = LOWER(:email)', { email }) // case insensitive
|
||||
.getOne()
|
||||
},
|
||||
|
||||
findTopUsers() {
|
||||
return this.createQueryBuilder()
|
||||
.where({
|
||||
profile: { username: In(TOP_USERS) },
|
||||
})
|
||||
.take(MAX_RECORDS_LIMIT)
|
||||
.getMany()
|
||||
},
|
||||
})
|
||||
118
examples/omnivore/api/api/src/resolvers/api_key/index.ts
Normal file
118
examples/omnivore/api/api/src/resolvers/api_key/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ApiKey } from '../../entity/api_key'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ApiKeysError,
|
||||
ApiKeysErrorCode,
|
||||
ApiKeysSuccess,
|
||||
GenerateApiKeyError,
|
||||
GenerateApiKeyErrorCode,
|
||||
GenerateApiKeySuccess,
|
||||
MutationGenerateApiKeyArgs,
|
||||
MutationRevokeApiKeyArgs,
|
||||
RevokeApiKeyError,
|
||||
RevokeApiKeyErrorCode,
|
||||
RevokeApiKeySuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../repository'
|
||||
import { findApiKeys } from '../../services/api_key'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { generateApiKey, hashApiKey } from '../../utils/auth'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const apiKeysResolver = authorized<ApiKeysSuccess, ApiKeysError>(
|
||||
async (_, __, { log, uid }) => {
|
||||
try {
|
||||
const apiKeys = await findApiKeys(uid)
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('apiKeysResolver error', e)
|
||||
|
||||
return {
|
||||
errorCodes: [ApiKeysErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const generateApiKeyResolver = authorized<
|
||||
GenerateApiKeySuccess,
|
||||
GenerateApiKeyError,
|
||||
MutationGenerateApiKeyArgs
|
||||
>(async (_, { input: { name, expiresAt } }, { log, uid }) => {
|
||||
try {
|
||||
const exp = new Date(expiresAt)
|
||||
const originalKey = generateApiKey()
|
||||
const apiKeyCreated = await getRepository(ApiKey).save({
|
||||
user: { id: uid },
|
||||
name,
|
||||
key: hashApiKey(originalKey),
|
||||
expiresAt: exp,
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'api_key_generated',
|
||||
properties: {
|
||||
name,
|
||||
expiresAt: exp,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
...apiKeyCreated,
|
||||
key: originalKey,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('generateApiKeyResolver', error)
|
||||
|
||||
return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] }
|
||||
}
|
||||
})
|
||||
|
||||
export const revokeApiKeyResolver = authorized<
|
||||
RevokeApiKeySuccess,
|
||||
RevokeApiKeyError,
|
||||
MutationRevokeApiKeyArgs
|
||||
>(async (_, { id }, { claims: { uid }, log }) => {
|
||||
try {
|
||||
const apiRepo = getRepository(ApiKey)
|
||||
|
||||
const apiKey = await apiRepo.findOneBy({ id, user: { id: uid } })
|
||||
if (!apiKey) {
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const deletedApiKey = await apiRepo.remove(apiKey)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'api_key_revoked',
|
||||
properties: {
|
||||
id,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
...deletedApiKey,
|
||||
id,
|
||||
key: null,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('revokeApiKeyResolver error', e)
|
||||
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
990
examples/omnivore/api/api/src/resolvers/article/index.ts
Normal file
990
examples/omnivore/api/api/src/resolvers/article/index.ts
Normal file
@@ -0,0 +1,990 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import { Readability } from '@omnivore/readability'
|
||||
import graphqlFields from 'graphql-fields'
|
||||
import { IsNull } from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ArticleError,
|
||||
ArticleErrorCode,
|
||||
ArticleSuccess,
|
||||
BulkActionError,
|
||||
BulkActionErrorCode,
|
||||
BulkActionSuccess,
|
||||
BulkActionType,
|
||||
ContentReader,
|
||||
CreateArticleError,
|
||||
CreateArticleErrorCode,
|
||||
CreateArticleSuccess,
|
||||
MoveToFolderError,
|
||||
MoveToFolderErrorCode,
|
||||
MoveToFolderSuccess,
|
||||
MutationBulkActionArgs,
|
||||
MutationCreateArticleArgs,
|
||||
MutationMoveToFolderArgs,
|
||||
MutationSaveArticleReadingProgressArgs,
|
||||
MutationSetBookmarkArticleArgs,
|
||||
MutationSetFavoriteArticleArgs,
|
||||
PageType,
|
||||
QueryArticleArgs,
|
||||
QuerySearchArgs,
|
||||
QueryTypeaheadSearchArgs,
|
||||
QueryUpdatesSinceArgs,
|
||||
SaveArticleReadingProgressError,
|
||||
SaveArticleReadingProgressErrorCode,
|
||||
SaveArticleReadingProgressSuccess,
|
||||
SearchError,
|
||||
SearchErrorCode,
|
||||
SearchSuccess,
|
||||
SetBookmarkArticleError,
|
||||
SetBookmarkArticleSuccess,
|
||||
SetFavoriteArticleError,
|
||||
SetFavoriteArticleErrorCode,
|
||||
SetFavoriteArticleSuccess,
|
||||
TypeaheadSearchError,
|
||||
TypeaheadSearchErrorCode,
|
||||
TypeaheadSearchSuccess,
|
||||
UpdateReason,
|
||||
UpdatesSinceError,
|
||||
UpdatesSinceSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getColumns } from '../../repository'
|
||||
import { getInternalLabelWithColor } from '../../repository/label'
|
||||
import { libraryItemRepository } from '../../repository/library_item'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { createPageSaveRequest } from '../../services/create_page_save_request'
|
||||
import { findHighlightsByLibraryItemId } from '../../services/highlights'
|
||||
import {
|
||||
addLabelsToLibraryItem,
|
||||
createAndSaveLabelsInLibraryItem,
|
||||
findLabelsByIds,
|
||||
findOrCreateLabels,
|
||||
} from '../../services/labels'
|
||||
import {
|
||||
createLibraryItem,
|
||||
findLibraryItemByUrl,
|
||||
findLibraryItemsByPrefix,
|
||||
searchLibraryItems,
|
||||
sortParamsToSort,
|
||||
updateLibraryItem,
|
||||
updateLibraryItemReadingProgress,
|
||||
updateLibraryItems,
|
||||
} from '../../services/library_item'
|
||||
import { parsedContentToLibraryItem } from '../../services/save_page'
|
||||
import {
|
||||
findUploadFileById,
|
||||
setFileUploadComplete,
|
||||
} from '../../services/upload_file'
|
||||
import { traceAs } from '../../tracing'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { isSiteBlockedForParse } from '../../utils/blocked'
|
||||
import {
|
||||
authorized,
|
||||
cleanUrl,
|
||||
errorHandler,
|
||||
generateSlug,
|
||||
isParsingTimeout,
|
||||
libraryItemToArticle,
|
||||
libraryItemToSearchItem,
|
||||
titleForFilePath,
|
||||
userDataToUser,
|
||||
} from '../../utils/helpers'
|
||||
import {
|
||||
contentConverter,
|
||||
getDistillerResult,
|
||||
htmlToMarkdown,
|
||||
ParsedContentPuppeteer,
|
||||
parsePreparedContent,
|
||||
} from '../../utils/parser'
|
||||
import { getStorageFileDetails } from '../../utils/uploads'
|
||||
import { itemTypeForContentType } from '../upload_files'
|
||||
|
||||
export enum ArticleFormat {
|
||||
Markdown = 'markdown',
|
||||
Html = 'html',
|
||||
Distiller = 'distiller',
|
||||
HighlightedMarkdown = 'highlightedMarkdown',
|
||||
}
|
||||
|
||||
// These two page types are better handled by the backend
|
||||
// where we can use APIs to fetch their underlying content.
|
||||
const FORCE_PUPPETEER_URLS = [
|
||||
// twitter status url regex
|
||||
/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/,
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/,
|
||||
]
|
||||
const UNPARSEABLE_CONTENT = '<p>We were unable to parse this page.</p>'
|
||||
|
||||
export const createArticleResolver = authorized<
|
||||
CreateArticleSuccess,
|
||||
CreateArticleError,
|
||||
MutationCreateArticleArgs
|
||||
>(
|
||||
async (
|
||||
_,
|
||||
{
|
||||
input: {
|
||||
url,
|
||||
preparedDocument,
|
||||
articleSavingRequestId,
|
||||
uploadFileId,
|
||||
skipParsing,
|
||||
source,
|
||||
state,
|
||||
labels: inputLabels,
|
||||
folder,
|
||||
rssFeedUrl,
|
||||
savedAt,
|
||||
publishedAt,
|
||||
},
|
||||
},
|
||||
{ log, uid, pubsub }
|
||||
) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url,
|
||||
source,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const userData = await userRepository.findById(uid)
|
||||
if (!userData) {
|
||||
return errorHandler(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.Unauthorized],
|
||||
},
|
||||
uid,
|
||||
articleSavingRequestId,
|
||||
pubsub
|
||||
)
|
||||
}
|
||||
const user = userDataToUser(userData)
|
||||
|
||||
try {
|
||||
if (isSiteBlockedForParse(url)) {
|
||||
return errorHandler(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.NotAllowedToParse],
|
||||
},
|
||||
uid,
|
||||
articleSavingRequestId,
|
||||
pubsub
|
||||
)
|
||||
}
|
||||
|
||||
url = cleanUrl(url)
|
||||
const { pathname } = new URL(url)
|
||||
|
||||
const croppedPathname = decodeURIComponent(
|
||||
pathname
|
||||
.split('/')
|
||||
[pathname.split('/').length - 1].split('.')
|
||||
.slice(0, -1)
|
||||
.join('.')
|
||||
).replace(/_/gi, ' ')
|
||||
|
||||
let title: string | undefined
|
||||
let parsedContent: Readability.ParseResult | null = null
|
||||
let canonicalUrl
|
||||
let uploadFileHash = null
|
||||
let domContent = null
|
||||
let itemType = PageType.Unknown
|
||||
|
||||
const DUMMY_RESPONSE: CreateArticleSuccess = {
|
||||
user,
|
||||
created: false,
|
||||
createdArticle: {
|
||||
id: '',
|
||||
slug: '',
|
||||
createdAt: new Date(),
|
||||
originalHtml: domContent,
|
||||
content: '',
|
||||
description: '',
|
||||
title: '',
|
||||
pageType: itemType,
|
||||
contentReader: ContentReader.Web,
|
||||
author: '',
|
||||
url,
|
||||
hash: '',
|
||||
isArchived: false,
|
||||
readingProgressAnchorIndex: 0,
|
||||
readingProgressPercent: 0,
|
||||
highlights: [],
|
||||
savedAt: savedAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
folder: '',
|
||||
publishedAt,
|
||||
subscription: rssFeedUrl,
|
||||
},
|
||||
}
|
||||
|
||||
if (uploadFileId) {
|
||||
/* We do not trust the values from client, lookup upload file by querying
|
||||
* with filtering on user ID and URL to verify client's uploadFileId is valid.
|
||||
*/
|
||||
const uploadFile = await findUploadFileById(uploadFileId)
|
||||
if (!uploadFile) {
|
||||
return errorHandler(
|
||||
{ errorCodes: [CreateArticleErrorCode.UploadFileMissing] },
|
||||
uid,
|
||||
articleSavingRequestId,
|
||||
pubsub
|
||||
)
|
||||
}
|
||||
const uploadFileDetails = await getStorageFileDetails(
|
||||
uploadFileId,
|
||||
uploadFile.fileName
|
||||
)
|
||||
uploadFileHash = uploadFileDetails.md5Hash
|
||||
canonicalUrl = uploadFile.url
|
||||
itemType = itemTypeForContentType(uploadFile.contentType)
|
||||
title = titleForFilePath(uploadFile.url)
|
||||
} else if (
|
||||
source !== 'puppeteer-parse' &&
|
||||
FORCE_PUPPETEER_URLS.some((regex) => regex.test(url))
|
||||
) {
|
||||
await createPageSaveRequest({
|
||||
userId: uid,
|
||||
url,
|
||||
state: state || undefined,
|
||||
labels: inputLabels || undefined,
|
||||
folder: folder || undefined,
|
||||
savedAt,
|
||||
publishedAt,
|
||||
subscription: rssFeedUrl || undefined,
|
||||
})
|
||||
return DUMMY_RESPONSE
|
||||
} else if (!skipParsing && preparedDocument?.document) {
|
||||
const parseResults = await traceAs<Promise<ParsedContentPuppeteer>>(
|
||||
{ spanName: 'article.parse' },
|
||||
async (): Promise<ParsedContentPuppeteer> => {
|
||||
return parsePreparedContent(url, preparedDocument)
|
||||
}
|
||||
)
|
||||
parsedContent = parseResults.parsedContent
|
||||
canonicalUrl = parseResults.canonicalUrl
|
||||
domContent = parseResults.domContent
|
||||
itemType = parseResults.pageType
|
||||
} else if (!preparedDocument?.document) {
|
||||
// We have a URL but no document, so we try to send this to puppeteer
|
||||
// and return a dummy response.
|
||||
await createPageSaveRequest({
|
||||
userId: uid,
|
||||
url,
|
||||
state: state || undefined,
|
||||
labels: inputLabels || undefined,
|
||||
folder: folder || undefined,
|
||||
savedAt,
|
||||
publishedAt,
|
||||
subscription: rssFeedUrl || undefined,
|
||||
})
|
||||
return DUMMY_RESPONSE
|
||||
}
|
||||
|
||||
const slug = generateSlug(parsedContent?.title || croppedPathname)
|
||||
const libraryItemToSave = parsedContentToLibraryItem({
|
||||
url,
|
||||
title,
|
||||
parsedContent,
|
||||
userId: uid,
|
||||
slug,
|
||||
croppedPathname,
|
||||
originalHtml: domContent,
|
||||
itemType,
|
||||
preparedDocument,
|
||||
uploadFileHash,
|
||||
canonicalUrl,
|
||||
uploadFileId,
|
||||
state,
|
||||
folder,
|
||||
publishedAt,
|
||||
rssFeedUrl,
|
||||
savedAt,
|
||||
})
|
||||
|
||||
log.info('New article saving', {
|
||||
parsedArticle: Object.assign({}, libraryItemToSave, {
|
||||
readableContent: undefined,
|
||||
originalContent: undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (uploadFileId) {
|
||||
const uploadFileData = await setFileUploadComplete(uploadFileId)
|
||||
if (!uploadFileData || !uploadFileData.id || !uploadFileData.fileName) {
|
||||
return errorHandler(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.UploadFileMissing],
|
||||
},
|
||||
uid,
|
||||
articleSavingRequestId,
|
||||
pubsub
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let libraryItemToReturn: LibraryItem
|
||||
|
||||
const existingLibraryItem = await findLibraryItemByUrl(
|
||||
libraryItemToSave.originalUrl,
|
||||
uid
|
||||
)
|
||||
articleSavingRequestId = existingLibraryItem?.id || articleSavingRequestId
|
||||
if (articleSavingRequestId) {
|
||||
// update existing item's state from processing to succeeded
|
||||
libraryItemToReturn = await updateLibraryItem(
|
||||
articleSavingRequestId,
|
||||
libraryItemToSave as QueryDeepPartialEntity<LibraryItem>,
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
} else {
|
||||
// create new item in database
|
||||
libraryItemToReturn = await createLibraryItem(
|
||||
libraryItemToSave,
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
}
|
||||
|
||||
await createAndSaveLabelsInLibraryItem(
|
||||
libraryItemToReturn.id,
|
||||
uid,
|
||||
inputLabels,
|
||||
rssFeedUrl
|
||||
)
|
||||
|
||||
log.info(
|
||||
'item created in database',
|
||||
libraryItemToReturn.id,
|
||||
libraryItemToReturn.originalUrl,
|
||||
libraryItemToReturn.slug,
|
||||
libraryItemToReturn.title
|
||||
)
|
||||
|
||||
return {
|
||||
user,
|
||||
created: true,
|
||||
createdArticle: libraryItemToArticle(libraryItemToReturn),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error creating article', error)
|
||||
return errorHandler(
|
||||
{
|
||||
errorCodes: [CreateArticleErrorCode.ElasticError],
|
||||
},
|
||||
uid,
|
||||
articleSavingRequestId,
|
||||
pubsub
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const getArticleResolver = authorized<
|
||||
ArticleSuccess,
|
||||
ArticleError,
|
||||
QueryArticleArgs
|
||||
>(async (_obj, { slug, format }, { authTrx, uid, log }, info) => {
|
||||
try {
|
||||
const selectColumns = getColumns(libraryItemRepository)
|
||||
const includeOriginalHtml =
|
||||
format === ArticleFormat.Distiller ||
|
||||
!!graphqlFields(info).article.originalHtml
|
||||
if (!includeOriginalHtml) {
|
||||
selectColumns.splice(selectColumns.indexOf('originalContent'), 1)
|
||||
}
|
||||
// We allow the backend to use the ID instead of a slug to fetch the article
|
||||
// query against id if slug is a uuid
|
||||
const where = slug.match(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
||||
? { id: slug }
|
||||
: { slug }
|
||||
const libraryItem = await authTrx((tx) =>
|
||||
tx.withRepository(libraryItemRepository).findOne({
|
||||
select: selectColumns,
|
||||
where: {
|
||||
...where,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
relations: {
|
||||
labels: true,
|
||||
highlights: {
|
||||
user: true,
|
||||
labels: true,
|
||||
},
|
||||
uploadFile: true,
|
||||
recommendations: {
|
||||
recommender: true,
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
if (!libraryItem) {
|
||||
return { errorCodes: [ArticleErrorCode.NotFound] }
|
||||
}
|
||||
|
||||
if (isParsingTimeout(libraryItem)) {
|
||||
libraryItem.readableContent = UNPARSEABLE_CONTENT
|
||||
}
|
||||
|
||||
if (format === ArticleFormat.Markdown) {
|
||||
libraryItem.readableContent = htmlToMarkdown(libraryItem.readableContent)
|
||||
} else if (format === ArticleFormat.Distiller) {
|
||||
if (!libraryItem.originalContent) {
|
||||
return { errorCodes: [ArticleErrorCode.BadData] }
|
||||
}
|
||||
const distillerResult = await getDistillerResult(
|
||||
uid,
|
||||
libraryItem.originalContent
|
||||
)
|
||||
if (!distillerResult) {
|
||||
return { errorCodes: [ArticleErrorCode.BadData] }
|
||||
}
|
||||
libraryItem.readableContent = distillerResult
|
||||
}
|
||||
|
||||
return {
|
||||
article: libraryItemToArticle(libraryItem),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return { errorCodes: [ArticleErrorCode.BadData] }
|
||||
}
|
||||
})
|
||||
|
||||
// type PaginatedPartialArticles = {
|
||||
// edges: { cursor: string; node: PartialArticle }[]
|
||||
// pageInfo: PageInfo
|
||||
// }
|
||||
|
||||
// export type SetShareArticleSuccessPartial = Merge<
|
||||
// SetShareArticleSuccess,
|
||||
// {
|
||||
// updatedFeedArticle?: Omit<
|
||||
// FeedArticle,
|
||||
// | 'sharedBy'
|
||||
// | 'article'
|
||||
// | 'highlightsCount'
|
||||
// | 'annotationsCount'
|
||||
// | 'reactions'
|
||||
// >
|
||||
// updatedFeedArticleId?: string
|
||||
// updatedArticle: PartialArticle
|
||||
// }
|
||||
// >
|
||||
|
||||
// export const setShareArticleResolver = authorized<
|
||||
// SetShareArticleSuccessPartial,
|
||||
// SetShareArticleError,
|
||||
// MutationSetShareArticleArgs
|
||||
// >(
|
||||
// async (
|
||||
// _,
|
||||
// { input: { articleID, share, sharedComment, sharedWithHighlights } },
|
||||
// { models, authTrx, claims: { uid }, log }
|
||||
// ) => {
|
||||
// const article = await models.article.get(articleID)
|
||||
// if (!article) {
|
||||
// return { errorCodes: [SetShareArticleErrorCode.NotFound] }
|
||||
// }
|
||||
|
||||
// const sharedAt = share ? new Date() : null
|
||||
|
||||
// log.info(`${share ? 'S' : 'Uns'}haring an article`, {
|
||||
// article: Object.assign({}, article, {
|
||||
// content: undefined,
|
||||
// originalHtml: undefined,
|
||||
// sharedAt,
|
||||
// }),
|
||||
// labels: {
|
||||
// source: 'resolver',
|
||||
// resolver: 'setShareArticleResolver',
|
||||
// articleId: article.id,
|
||||
// userId: uid,
|
||||
// },
|
||||
// })
|
||||
|
||||
// const result = await authTrx((tx) =>
|
||||
// models.userArticle.updateByArticleId(
|
||||
// uid,
|
||||
// articleID,
|
||||
// { sharedAt, sharedComment, sharedWithHighlights },
|
||||
// tx
|
||||
// )
|
||||
// )
|
||||
|
||||
// if (!result) {
|
||||
// return { errorCodes: [SetShareArticleErrorCode.NotFound] }
|
||||
// }
|
||||
|
||||
// // Make sure article.id instead of userArticle.id has passed. We use it for cache updates
|
||||
// const updatedArticle = {
|
||||
// ...result,
|
||||
// ...article,
|
||||
// postedByViewer: !!sharedAt,
|
||||
// }
|
||||
// const updatedFeedArticle = sharedAt ? { ...result, sharedAt } : undefined
|
||||
// return {
|
||||
// updatedFeedArticleId: result.id,
|
||||
// updatedFeedArticle,
|
||||
// updatedArticle,
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
export const setBookmarkArticleResolver = authorized<
|
||||
SetBookmarkArticleSuccess,
|
||||
SetBookmarkArticleError,
|
||||
MutationSetBookmarkArticleArgs
|
||||
>(async (_, { input: { articleID } }, { uid, log, pubsub }) => {
|
||||
// delete the item and its metadata
|
||||
const deletedLibraryItem = await updateLibraryItem(
|
||||
articleID,
|
||||
{
|
||||
state: LibraryItemState.Deleted,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'link_removed',
|
||||
properties: {
|
||||
id: articleID,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
log.info('Article unbookmarked', {
|
||||
item: Object.assign({}, deletedLibraryItem, {
|
||||
readableContent: undefined,
|
||||
originalContent: undefined,
|
||||
}),
|
||||
})
|
||||
// Make sure article.id instead of userArticle.id has passed. We use it for cache updates
|
||||
return {
|
||||
bookmarkedArticle: libraryItemToArticle(deletedLibraryItem),
|
||||
}
|
||||
})
|
||||
|
||||
export const saveArticleReadingProgressResolver = authorized<
|
||||
SaveArticleReadingProgressSuccess,
|
||||
SaveArticleReadingProgressError,
|
||||
MutationSaveArticleReadingProgressArgs
|
||||
>(
|
||||
async (
|
||||
_,
|
||||
{
|
||||
input: {
|
||||
id,
|
||||
readingProgressPercent,
|
||||
readingProgressAnchorIndex,
|
||||
readingProgressTopPercent,
|
||||
force,
|
||||
},
|
||||
},
|
||||
{ log, pubsub, uid }
|
||||
) => {
|
||||
if (
|
||||
readingProgressPercent < 0 ||
|
||||
readingProgressPercent > 100 ||
|
||||
(readingProgressTopPercent &&
|
||||
(readingProgressTopPercent < 0 ||
|
||||
readingProgressTopPercent > readingProgressPercent)) ||
|
||||
(readingProgressAnchorIndex && readingProgressAnchorIndex < 0)
|
||||
) {
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
|
||||
}
|
||||
try {
|
||||
if (force) {
|
||||
// update reading progress without checking the current value
|
||||
const updatedItem = await updateLibraryItem(
|
||||
id,
|
||||
{
|
||||
readingProgressBottomPercent: readingProgressPercent,
|
||||
readingProgressTopPercent: readingProgressTopPercent ?? undefined,
|
||||
readingProgressHighestReadAnchor:
|
||||
readingProgressAnchorIndex ?? undefined,
|
||||
readAt: new Date(),
|
||||
},
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
return {
|
||||
updatedArticle: libraryItemToArticle(updatedItem),
|
||||
}
|
||||
}
|
||||
|
||||
// update reading progress only if the current value is lower
|
||||
const updatedItem = await updateLibraryItemReadingProgress(
|
||||
id,
|
||||
uid,
|
||||
readingProgressPercent,
|
||||
readingProgressTopPercent,
|
||||
readingProgressAnchorIndex,
|
||||
pubsub
|
||||
)
|
||||
if (!updatedItem) {
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
|
||||
}
|
||||
|
||||
return {
|
||||
updatedArticle: libraryItemToArticle(updatedItem),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('saveArticleReadingProgressResolver error', error)
|
||||
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.Unauthorized] }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const searchResolver = authorized<
|
||||
SearchSuccess,
|
||||
SearchError,
|
||||
QuerySearchArgs
|
||||
>(async (_obj, params, { log, uid }) => {
|
||||
const startCursor = params.after || ''
|
||||
const first = Math.min(params.first || 10, 100) // limit to 100 items
|
||||
|
||||
// the query size is limited to 255 characters
|
||||
if (params.query && params.query.length > 255) {
|
||||
return { errorCodes: [SearchErrorCode.QueryTooLong] }
|
||||
}
|
||||
|
||||
const { libraryItems, count } = await searchLibraryItems(
|
||||
{
|
||||
from: Number(startCursor),
|
||||
size: first + 1, // fetch one more item to get next cursor
|
||||
includePending: true,
|
||||
includeContent: !!params.includeContent,
|
||||
includeDeleted: params.query?.includes('in:trash'),
|
||||
query: params.query,
|
||||
useFolders: params.query?.includes('use:folders'),
|
||||
},
|
||||
uid
|
||||
)
|
||||
|
||||
const start =
|
||||
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
|
||||
const hasNextPage = libraryItems.length > first
|
||||
const endCursor = String(start + libraryItems.length - (hasNextPage ? 1 : 0))
|
||||
|
||||
if (hasNextPage) {
|
||||
// remove an extra if exists
|
||||
libraryItems.pop()
|
||||
}
|
||||
|
||||
const edges = await Promise.all(
|
||||
libraryItems.map(async (libraryItem) => {
|
||||
if (
|
||||
libraryItem.highlightAnnotations &&
|
||||
libraryItem.highlightAnnotations.length > 0
|
||||
) {
|
||||
libraryItem.highlights = await findHighlightsByLibraryItemId(
|
||||
libraryItem.id,
|
||||
uid
|
||||
)
|
||||
}
|
||||
|
||||
if (params.includeContent && libraryItem.readableContent) {
|
||||
// convert html to the requested format
|
||||
const format = params.format || ArticleFormat.Html
|
||||
try {
|
||||
const converter = contentConverter(format)
|
||||
if (converter) {
|
||||
libraryItem.readableContent = converter(
|
||||
libraryItem.readableContent,
|
||||
libraryItem.highlights
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error converting content', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
node: libraryItemToSearchItem(libraryItem),
|
||||
cursor: endCursor,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
startCursor,
|
||||
hasNextPage,
|
||||
endCursor,
|
||||
totalCount: count,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const typeaheadSearchResolver = authorized<
|
||||
TypeaheadSearchSuccess,
|
||||
TypeaheadSearchError,
|
||||
QueryTypeaheadSearchArgs
|
||||
>(async (_obj, { query, first }, { log, uid }) => {
|
||||
try {
|
||||
const items = await findLibraryItemsByPrefix(query, uid, first || undefined)
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
contentReader: item.contentReader as unknown as ContentReader,
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('typeaheadSearchResolver error', error)
|
||||
return { errorCodes: [TypeaheadSearchErrorCode.Unauthorized] }
|
||||
}
|
||||
})
|
||||
|
||||
export const updatesSinceResolver = authorized<
|
||||
UpdatesSinceSuccess,
|
||||
UpdatesSinceError,
|
||||
QueryUpdatesSinceArgs
|
||||
>(async (_obj, { since, first, after, sort: sortParams, folder }, { uid }) => {
|
||||
const startCursor = after || ''
|
||||
const size = Math.min(first || 10, 100) // limit to 100 items
|
||||
let startDate = new Date(since)
|
||||
if (isNaN(startDate.getTime())) {
|
||||
// for android app compatibility
|
||||
startDate = new Date(0)
|
||||
}
|
||||
const sort = sortParamsToSort(sortParams)
|
||||
|
||||
// create a search query
|
||||
const query = `updated:${startDate.toISOString()}${
|
||||
folder ? ' in:' + folder : ''
|
||||
} sort:${sort.by}-${sort.order}`
|
||||
|
||||
const { libraryItems, count } = await searchLibraryItems(
|
||||
{
|
||||
from: Number(startCursor),
|
||||
size: size + 1, // fetch one more item to get next cursor
|
||||
includeDeleted: true,
|
||||
query,
|
||||
},
|
||||
uid
|
||||
)
|
||||
|
||||
const start =
|
||||
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
|
||||
const hasNextPage = libraryItems.length > size
|
||||
const endCursor = String(start + libraryItems.length - (hasNextPage ? 1 : 0))
|
||||
|
||||
//TODO: refactor so that the lastCursor included
|
||||
if (hasNextPage) {
|
||||
// remove an extra if exists
|
||||
libraryItems.pop()
|
||||
}
|
||||
|
||||
const edges = libraryItems.map((item) => {
|
||||
const updateReason = getUpdateReason(item, startDate)
|
||||
return {
|
||||
node: libraryItemToSearchItem(item),
|
||||
cursor: endCursor,
|
||||
itemID: item.id,
|
||||
updateReason,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasPreviousPage: false,
|
||||
startCursor,
|
||||
hasNextPage,
|
||||
endCursor,
|
||||
totalCount: count,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const bulkActionResolver = authorized<
|
||||
BulkActionSuccess,
|
||||
BulkActionError,
|
||||
MutationBulkActionArgs
|
||||
>(
|
||||
async (
|
||||
_parent,
|
||||
{ query, action, labelIds, arguments: args }, // arguments is a reserved keyword in JS
|
||||
{ uid, log }
|
||||
) => {
|
||||
try {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'BulkAction',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
action,
|
||||
},
|
||||
})
|
||||
|
||||
// the query size is limited to 4000 characters to allow for 100 items
|
||||
if (!query || query.length > 4000) {
|
||||
log.error('bulkActionResolver error', {
|
||||
error: 'QueryTooLong',
|
||||
query,
|
||||
})
|
||||
return { errorCodes: [BulkActionErrorCode.BadRequest] }
|
||||
}
|
||||
|
||||
// get labels if needed
|
||||
let labels = undefined
|
||||
if (action === BulkActionType.AddLabels) {
|
||||
if (!labelIds || labelIds.length === 0) {
|
||||
return { errorCodes: [BulkActionErrorCode.BadRequest] }
|
||||
}
|
||||
|
||||
labels = await findLabelsByIds(labelIds, uid)
|
||||
}
|
||||
|
||||
await updateLibraryItems(
|
||||
action,
|
||||
{
|
||||
query,
|
||||
useFolders: query.includes('use:folders'),
|
||||
},
|
||||
uid,
|
||||
labels,
|
||||
args
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
log.error('bulkActionResolver error', error)
|
||||
return { errorCodes: [BulkActionErrorCode.BadRequest] }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const setFavoriteArticleResolver = authorized<
|
||||
SetFavoriteArticleSuccess,
|
||||
SetFavoriteArticleError,
|
||||
MutationSetFavoriteArticleArgs
|
||||
>(async (_, { id }, { uid, log }) => {
|
||||
try {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'setFavoriteArticle',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
id,
|
||||
},
|
||||
})
|
||||
|
||||
const label = getInternalLabelWithColor('Favorites')
|
||||
if (!label) {
|
||||
return { errorCodes: [SetFavoriteArticleErrorCode.BadRequest] }
|
||||
}
|
||||
|
||||
const labels = await findOrCreateLabels([label], uid)
|
||||
// adds Favorites label to item
|
||||
await addLabelsToLibraryItem(labels, id, uid)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
log.info('Error adding Favorites label', error)
|
||||
return { errorCodes: [SetFavoriteArticleErrorCode.BadRequest] }
|
||||
}
|
||||
})
|
||||
|
||||
export const moveToFolderResolver = authorized<
|
||||
MoveToFolderSuccess,
|
||||
MoveToFolderError,
|
||||
MutationMoveToFolderArgs
|
||||
>(async (_, { id, folder }, { authTrx, pubsub, uid }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'move_to_folder',
|
||||
properties: {
|
||||
id,
|
||||
folder,
|
||||
},
|
||||
})
|
||||
|
||||
const item = await authTrx((tx) =>
|
||||
tx.getRepository(LibraryItem).findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
)
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
errorCodes: [MoveToFolderErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
if (item.folder === folder) {
|
||||
return {
|
||||
errorCodes: [MoveToFolderErrorCode.AlreadyExists],
|
||||
}
|
||||
}
|
||||
|
||||
const savedAt = new Date()
|
||||
|
||||
// // if the content is not fetched yet, create a page save request
|
||||
// if (!item.readableContent) {
|
||||
// const articleSavingRequest = await createPageSaveRequest({
|
||||
// userId: uid,
|
||||
// url: item.originalUrl,
|
||||
// articleSavingRequestId: id,
|
||||
// priority: 'high',
|
||||
// publishedAt: item.publishedAt || undefined,
|
||||
// savedAt,
|
||||
// pubsub,
|
||||
// })
|
||||
|
||||
// return {
|
||||
// __typename: 'MoveToFolderSuccess',
|
||||
// articleSavingRequest,
|
||||
// }
|
||||
// }
|
||||
|
||||
await updateLibraryItem(
|
||||
item.id,
|
||||
{
|
||||
folder,
|
||||
savedAt,
|
||||
},
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
return {
|
||||
__typename: 'MoveToFolderSuccess',
|
||||
success: true,
|
||||
}
|
||||
})
|
||||
|
||||
const getUpdateReason = (libraryItem: LibraryItem, since: Date) => {
|
||||
if (libraryItem.deletedAt) {
|
||||
return UpdateReason.Deleted
|
||||
}
|
||||
if (libraryItem.createdAt >= since) {
|
||||
return UpdateReason.Created
|
||||
}
|
||||
return UpdateReason.Updated
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable prefer-const */
|
||||
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ArticleSavingRequestError,
|
||||
ArticleSavingRequestErrorCode,
|
||||
ArticleSavingRequestSuccess,
|
||||
CreateArticleSavingRequestError,
|
||||
CreateArticleSavingRequestErrorCode,
|
||||
CreateArticleSavingRequestSuccess,
|
||||
MutationCreateArticleSavingRequestArgs,
|
||||
QueryArticleSavingRequestArgs,
|
||||
} from '../../generated/graphql'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { createPageSaveRequest } from '../../services/create_page_save_request'
|
||||
import {
|
||||
findLibraryItemById,
|
||||
findLibraryItemByUrl,
|
||||
} from '../../services/library_item'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import {
|
||||
authorized,
|
||||
cleanUrl,
|
||||
isParsingTimeout,
|
||||
libraryItemToArticleSavingRequest,
|
||||
} from '../../utils/helpers'
|
||||
import { isErrorWithCode } from '../user'
|
||||
|
||||
export const createArticleSavingRequestResolver = authorized<
|
||||
CreateArticleSavingRequestSuccess,
|
||||
CreateArticleSavingRequestError,
|
||||
MutationCreateArticleSavingRequestArgs
|
||||
>(async (_, { input: { url } }, { uid, pubsub, log }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: url,
|
||||
method: 'article_saving_request',
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const articleSavingRequest = await createPageSaveRequest({
|
||||
userId: uid,
|
||||
url,
|
||||
pubsub,
|
||||
})
|
||||
return {
|
||||
articleSavingRequest,
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('createArticleSavingRequestResolver error', err)
|
||||
if (isErrorWithCode(err)) {
|
||||
return {
|
||||
errorCodes: [err.errorCode as CreateArticleSavingRequestErrorCode],
|
||||
}
|
||||
}
|
||||
return { errorCodes: [CreateArticleSavingRequestErrorCode.BadData] }
|
||||
}
|
||||
})
|
||||
|
||||
export const articleSavingRequestResolver = authorized<
|
||||
ArticleSavingRequestSuccess,
|
||||
ArticleSavingRequestError,
|
||||
QueryArticleSavingRequestArgs
|
||||
>(async (_, { id, url }, { uid, log }) => {
|
||||
try {
|
||||
if (!id && !url) {
|
||||
return { errorCodes: [ArticleSavingRequestErrorCode.BadData] }
|
||||
}
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [ArticleSavingRequestErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
let libraryItem: LibraryItem | null = null
|
||||
if (id) {
|
||||
libraryItem = await findLibraryItemById(id, uid)
|
||||
} else if (url) {
|
||||
libraryItem = await findLibraryItemByUrl(cleanUrl(url), uid)
|
||||
}
|
||||
|
||||
if (!libraryItem) {
|
||||
return { errorCodes: [ArticleSavingRequestErrorCode.NotFound] }
|
||||
}
|
||||
if (isParsingTimeout(libraryItem)) {
|
||||
libraryItem.state = LibraryItemState.Succeeded
|
||||
}
|
||||
return {
|
||||
articleSavingRequest: libraryItemToArticleSavingRequest(
|
||||
user,
|
||||
libraryItem
|
||||
),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('articleSavingRequestResolver error', error)
|
||||
return { errorCodes: [ArticleSavingRequestErrorCode.NotFound] }
|
||||
}
|
||||
})
|
||||
61
examples/omnivore/api/api/src/resolvers/features/index.ts
Normal file
61
examples/omnivore/api/api/src/resolvers/features/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
MutationOptInFeatureArgs,
|
||||
OptInFeatureError,
|
||||
OptInFeatureErrorCode,
|
||||
OptInFeatureSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import {
|
||||
getFeatureName,
|
||||
optInFeature,
|
||||
signFeatureToken,
|
||||
} from '../../services/features'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const optInFeatureResolver = authorized<
|
||||
OptInFeatureSuccess,
|
||||
OptInFeatureError,
|
||||
MutationOptInFeatureArgs
|
||||
>(async (_, { input: { name } }, { claims, log }) => {
|
||||
log.info('Opting in to a feature', {
|
||||
feature: name,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'optInFeatureResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const featureName = getFeatureName(name)
|
||||
if (!featureName) {
|
||||
return {
|
||||
errorCodes: [OptInFeatureErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const optedInFeature = await optInFeature(featureName, claims.uid)
|
||||
if (!optedInFeature) {
|
||||
return {
|
||||
errorCodes: [OptInFeatureErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
log.info('Opted in to a feature', optedInFeature)
|
||||
|
||||
const token = signFeatureToken(optedInFeature, claims.uid)
|
||||
|
||||
return {
|
||||
feature: {
|
||||
...optedInFeature,
|
||||
token,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Error opting in to a feature', {
|
||||
error: e,
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [OptInFeatureErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
267
examples/omnivore/api/api/src/resolvers/filters/index.ts
Normal file
267
examples/omnivore/api/api/src/resolvers/filters/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { isNil, mergeWith } from 'lodash'
|
||||
import { Between } from 'typeorm'
|
||||
import { Filter } from '../../entity/filter'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteFilterError,
|
||||
DeleteFilterErrorCode,
|
||||
DeleteFilterSuccess,
|
||||
FiltersError,
|
||||
FiltersErrorCode,
|
||||
FiltersSuccess,
|
||||
MoveFilterError,
|
||||
MoveFilterErrorCode,
|
||||
MoveFilterSuccess,
|
||||
MutationDeleteFilterArgs,
|
||||
MutationMoveFilterArgs,
|
||||
MutationSaveFilterArgs,
|
||||
MutationUpdateFilterArgs,
|
||||
SaveFilterError,
|
||||
SaveFilterErrorCode,
|
||||
SaveFilterSuccess,
|
||||
UpdateFilterError,
|
||||
UpdateFilterErrorCode,
|
||||
UpdateFilterSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { authTrx } from '../../repository'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const saveFilterResolver = authorized<
|
||||
SaveFilterSuccess,
|
||||
SaveFilterError,
|
||||
MutationSaveFilterArgs
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const filter = await authTrx(async (t) => {
|
||||
return t.getRepository(Filter).save({
|
||||
user: { id: uid },
|
||||
name: input.name,
|
||||
folder: input.folder ?? 'inbox',
|
||||
description: '',
|
||||
position: input.position ?? 0,
|
||||
filter: input.filter,
|
||||
defaultFilter: false,
|
||||
visible: true,
|
||||
category: input.category ?? 'Search',
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
filter,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error saving filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [SaveFilterErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteFilterResolver = authorized<
|
||||
DeleteFilterSuccess,
|
||||
DeleteFilterError,
|
||||
MutationDeleteFilterArgs
|
||||
>(async (_, { id }, { authTrx, log }) => {
|
||||
try {
|
||||
const filter = await authTrx(async (t) => {
|
||||
const repo = t.getRepository(Filter)
|
||||
const filter = await repo.findOneByOrFail({
|
||||
id,
|
||||
})
|
||||
|
||||
await repo.delete(filter.id)
|
||||
return filter
|
||||
})
|
||||
|
||||
return {
|
||||
filter,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error deleting filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteFilterErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const filtersResolver = authorized<FiltersSuccess, FiltersError>(
|
||||
async (_, __, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const filters = await authTrx((t) =>
|
||||
t.getRepository(Filter).find({
|
||||
where: { user: { id: uid } },
|
||||
order: { position: 'ASC' },
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
filters,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error getting filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [FiltersErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const updatePosition = async (
|
||||
uid: string,
|
||||
filter: Filter,
|
||||
newPosition: number
|
||||
) => {
|
||||
const { position } = filter
|
||||
const moveUp = newPosition < position
|
||||
|
||||
// move filter to the new position
|
||||
const updated = await authTrx(async (t) => {
|
||||
const repo = t.getRepository(Filter)
|
||||
// update the position of the other filters
|
||||
const updated = await repo.update(
|
||||
{
|
||||
user: { id: uid },
|
||||
position: Between(
|
||||
Math.min(newPosition, position),
|
||||
Math.max(newPosition, position)
|
||||
),
|
||||
},
|
||||
{
|
||||
position: () => `position + ${moveUp ? 1 : -1}`,
|
||||
}
|
||||
)
|
||||
|
||||
if (!updated.affected) {
|
||||
return null
|
||||
}
|
||||
|
||||
// update the position of the filter
|
||||
return repo.save({
|
||||
...filter,
|
||||
position: newPosition,
|
||||
})
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('unable to update')
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
export const updateFilterResolver = authorized<
|
||||
UpdateFilterSuccess,
|
||||
UpdateFilterError,
|
||||
MutationUpdateFilterArgs
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
const { id } = input
|
||||
|
||||
try {
|
||||
const filter = await authTrx((t) =>
|
||||
t.getRepository(Filter).findOneBy({ id })
|
||||
)
|
||||
if (!filter) {
|
||||
return {
|
||||
__typename: 'UpdateFilterError',
|
||||
errorCodes: [UpdateFilterErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNil(input.position) && filter.position != input.position) {
|
||||
await updatePosition(uid, filter, input.position)
|
||||
}
|
||||
|
||||
const updated = await authTrx((t) =>
|
||||
t.getRepository(Filter).save({
|
||||
...mergeWith({}, filter, input, (a: unknown, b: unknown) =>
|
||||
isNil(b) ? a : undefined
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
__typename: 'UpdateFilterSuccess',
|
||||
filter: updated,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error Updating filters', error)
|
||||
|
||||
return {
|
||||
__typename: 'UpdateFilterError',
|
||||
errorCodes: [UpdateFilterErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const moveFilterResolver = authorized<
|
||||
MoveFilterSuccess,
|
||||
MoveFilterError,
|
||||
MutationMoveFilterArgs
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
const { filterId, afterFilterId } = input
|
||||
|
||||
try {
|
||||
const filter = await authTrx((t) =>
|
||||
t.getRepository(Filter).findOneBy({
|
||||
id: filterId,
|
||||
})
|
||||
)
|
||||
if (!filter) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.id === afterFilterId) {
|
||||
// nothing to do
|
||||
return { filter }
|
||||
}
|
||||
|
||||
// if afterFilterId is not provided, move to the top
|
||||
let newPosition = 0
|
||||
if (afterFilterId) {
|
||||
const afterFilter = await authTrx((t) =>
|
||||
t.getRepository(Filter).findOneBy({
|
||||
id: afterFilterId,
|
||||
})
|
||||
)
|
||||
if (!afterFilter) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
newPosition = afterFilter.position
|
||||
}
|
||||
const updated = await updatePosition(uid, filter, newPosition)
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'filter_moved',
|
||||
properties: {
|
||||
filterId,
|
||||
afterFilterId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
filter: updated,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error moving filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
521
examples/omnivore/api/api/src/resolvers/function_resolvers.ts
Normal file
521
examples/omnivore/api/api/src/resolvers/function_resolvers.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { createHmac } from 'crypto'
|
||||
import { Subscription } from '../entity/subscription'
|
||||
import { env } from '../env'
|
||||
import {
|
||||
Article,
|
||||
Label,
|
||||
PageType,
|
||||
Recommendation,
|
||||
SearchItem,
|
||||
User,
|
||||
} from '../generated/graphql'
|
||||
import { findLabelsByLibraryItemId } from '../services/labels'
|
||||
import { findRecommendationsByLibraryItemId } from '../services/recommendation'
|
||||
import { findUploadFileById } from '../services/upload_file'
|
||||
import {
|
||||
isBase64Image,
|
||||
recommandationDataToRecommendation,
|
||||
validatedDate,
|
||||
wordsCount,
|
||||
} from '../utils/helpers'
|
||||
import { createImageProxyUrl } from '../utils/imageproxy'
|
||||
import {
|
||||
generateDownloadSignedUrl,
|
||||
generateUploadFilePathName,
|
||||
} from '../utils/uploads'
|
||||
import { optInFeatureResolver } from './features'
|
||||
import { uploadImportFileResolver } from './importers/uploadImportFileResolver'
|
||||
import {
|
||||
addPopularReadResolver,
|
||||
apiKeysResolver,
|
||||
articleSavingRequestResolver,
|
||||
bulkActionResolver,
|
||||
createArticleResolver,
|
||||
createArticleSavingRequestResolver,
|
||||
createGroupResolver,
|
||||
createHighlightResolver,
|
||||
createLabelResolver,
|
||||
createNewsletterEmailResolver,
|
||||
// createReminderResolver,
|
||||
deleteAccountResolver,
|
||||
deleteFilterResolver,
|
||||
deleteHighlightResolver,
|
||||
deleteIntegrationResolver,
|
||||
deleteLabelResolver,
|
||||
deleteNewsletterEmailResolver,
|
||||
// deleteReminderResolver,
|
||||
deleteRuleResolver,
|
||||
deleteWebhookResolver,
|
||||
deviceTokensResolver,
|
||||
feedsResolver,
|
||||
filtersResolver,
|
||||
generateApiKeyResolver,
|
||||
getAllUsersResolver,
|
||||
getArticleResolver,
|
||||
// getFollowersResolver,
|
||||
// getFollowingResolver,
|
||||
getMeUserResolver,
|
||||
// getSharedArticleResolver,
|
||||
// getUserFeedArticlesResolver,
|
||||
getUserPersonalizationResolver,
|
||||
getUserResolver,
|
||||
googleLoginResolver,
|
||||
googleSignupResolver,
|
||||
groupsResolver,
|
||||
importFromIntegrationResolver,
|
||||
integrationsResolver,
|
||||
joinGroupResolver,
|
||||
labelsResolver,
|
||||
leaveGroupResolver,
|
||||
logOutResolver,
|
||||
mergeHighlightResolver,
|
||||
moveFilterResolver,
|
||||
moveLabelResolver,
|
||||
moveToFolderResolver,
|
||||
newsletterEmailsResolver,
|
||||
recommendHighlightsResolver,
|
||||
recommendResolver,
|
||||
// reminderResolver,
|
||||
reportItemResolver,
|
||||
revokeApiKeyResolver,
|
||||
rulesResolver,
|
||||
saveArticleReadingProgressResolver,
|
||||
saveFileResolver,
|
||||
saveFilterResolver,
|
||||
savePageResolver,
|
||||
saveUrlResolver,
|
||||
scanFeedsResolver,
|
||||
searchResolver,
|
||||
sendInstallInstructionsResolver,
|
||||
setBookmarkArticleResolver,
|
||||
setDeviceTokenResolver,
|
||||
setFavoriteArticleResolver,
|
||||
// setFollowResolver,
|
||||
setIntegrationResolver,
|
||||
setLabelsForHighlightResolver,
|
||||
setLabelsResolver,
|
||||
setLinkArchivedResolver,
|
||||
setRuleResolver,
|
||||
// setShareArticleResolver,
|
||||
// setShareHighlightResolver,
|
||||
setUserPersonalizationResolver,
|
||||
setWebhookResolver,
|
||||
subscribeResolver,
|
||||
subscriptionsResolver,
|
||||
typeaheadSearchResolver,
|
||||
unsubscribeResolver,
|
||||
updateFilterResolver,
|
||||
updateHighlightResolver,
|
||||
updateLabelResolver,
|
||||
// updateLinkShareInfoResolver,
|
||||
updatePageResolver,
|
||||
// updateReminderResolver,
|
||||
// updateSharedCommentResolver,
|
||||
updatesSinceResolver,
|
||||
updateSubscriptionResolver,
|
||||
updateUserProfileResolver,
|
||||
updateUserResolver,
|
||||
uploadFileRequestResolver,
|
||||
validateUsernameResolver,
|
||||
webhookResolver,
|
||||
webhooksResolver,
|
||||
} from './index'
|
||||
import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails'
|
||||
import { recentSearchesResolver } from './recent_searches'
|
||||
import { WithDataSourcesContext } from './types'
|
||||
import { updateEmailResolver } from './user'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
type ResultResolveType = {
|
||||
[x: string]: {
|
||||
__resolveType: (obj: { errorCodes: string[] | undefined }) => string
|
||||
}
|
||||
}
|
||||
|
||||
const resultResolveTypeResolver = (
|
||||
resolverName: string
|
||||
): ResultResolveType => ({
|
||||
[`${resolverName}Result`]: {
|
||||
__resolveType: (obj) =>
|
||||
obj.errorCodes ? `${resolverName}Error` : `${resolverName}Success`,
|
||||
},
|
||||
})
|
||||
|
||||
// Provide resolver functions for your schema fields
|
||||
export const functionResolvers = {
|
||||
Mutation: {
|
||||
googleLogin: googleLoginResolver,
|
||||
googleSignup: googleSignupResolver,
|
||||
logOut: logOutResolver,
|
||||
deleteAccount: deleteAccountResolver,
|
||||
saveArticleReadingProgress: saveArticleReadingProgressResolver,
|
||||
updateUser: updateUserResolver,
|
||||
updateUserProfile: updateUserProfileResolver,
|
||||
createArticle: createArticleResolver,
|
||||
createHighlight: createHighlightResolver,
|
||||
// createReaction: createReactionResolver,
|
||||
// deleteReaction: deleteReactionResolver,
|
||||
mergeHighlight: mergeHighlightResolver,
|
||||
updateHighlight: updateHighlightResolver,
|
||||
deleteHighlight: deleteHighlightResolver,
|
||||
uploadFileRequest: uploadFileRequestResolver,
|
||||
// setShareArticle: setShareArticleResolver,
|
||||
// updateSharedComment: updateSharedCommentResolver,
|
||||
// setFollow: setFollowResolver,
|
||||
setBookmarkArticle: setBookmarkArticleResolver,
|
||||
setUserPersonalization: setUserPersonalizationResolver,
|
||||
createArticleSavingRequest: createArticleSavingRequestResolver,
|
||||
// setShareHighlight: setShareHighlightResolver,
|
||||
reportItem: reportItemResolver,
|
||||
// updateLinkShareInfo: updateLinkShareInfoResolver,
|
||||
setLinkArchived: setLinkArchivedResolver,
|
||||
createNewsletterEmail: createNewsletterEmailResolver,
|
||||
deleteNewsletterEmail: deleteNewsletterEmailResolver,
|
||||
saveUrl: saveUrlResolver,
|
||||
savePage: savePageResolver,
|
||||
saveFile: saveFileResolver,
|
||||
// createReminder: createReminderResolver,
|
||||
// updateReminder: updateReminderResolver,
|
||||
// deleteReminder: deleteReminderResolver,
|
||||
setDeviceToken: setDeviceTokenResolver,
|
||||
createLabel: createLabelResolver,
|
||||
updateLabel: updateLabelResolver,
|
||||
deleteLabel: deleteLabelResolver,
|
||||
setLabels: setLabelsResolver,
|
||||
generateApiKey: generateApiKeyResolver,
|
||||
unsubscribe: unsubscribeResolver,
|
||||
updatePage: updatePageResolver,
|
||||
subscribe: subscribeResolver,
|
||||
addPopularRead: addPopularReadResolver,
|
||||
setWebhook: setWebhookResolver,
|
||||
deleteWebhook: deleteWebhookResolver,
|
||||
revokeApiKey: revokeApiKeyResolver,
|
||||
setLabelsForHighlight: setLabelsForHighlightResolver,
|
||||
moveLabel: moveLabelResolver,
|
||||
setIntegration: setIntegrationResolver,
|
||||
deleteIntegration: deleteIntegrationResolver,
|
||||
optInFeature: optInFeatureResolver,
|
||||
setRule: setRuleResolver,
|
||||
deleteRule: deleteRuleResolver,
|
||||
saveFilter: saveFilterResolver,
|
||||
deleteFilter: deleteFilterResolver,
|
||||
moveFilter: moveFilterResolver,
|
||||
createGroup: createGroupResolver,
|
||||
recommend: recommendResolver,
|
||||
joinGroup: joinGroupResolver,
|
||||
recommendHighlights: recommendHighlightsResolver,
|
||||
leaveGroup: leaveGroupResolver,
|
||||
uploadImportFile: uploadImportFileResolver,
|
||||
markEmailAsItem: markEmailAsItemResolver,
|
||||
bulkAction: bulkActionResolver,
|
||||
importFromIntegration: importFromIntegrationResolver,
|
||||
setFavoriteArticle: setFavoriteArticleResolver,
|
||||
updateSubscription: updateSubscriptionResolver,
|
||||
updateFilter: updateFilterResolver,
|
||||
updateEmail: updateEmailResolver,
|
||||
moveToFolder: moveToFolderResolver,
|
||||
},
|
||||
Query: {
|
||||
me: getMeUserResolver,
|
||||
user: getUserResolver,
|
||||
users: getAllUsersResolver,
|
||||
validateUsername: validateUsernameResolver,
|
||||
article: getArticleResolver,
|
||||
// sharedArticle: getSharedArticleResolver,
|
||||
// feedArticles: getUserFeedArticlesResolver,
|
||||
// getFollowers: getFollowersResolver,
|
||||
// getFollowing: getFollowingResolver,
|
||||
getUserPersonalization: getUserPersonalizationResolver,
|
||||
articleSavingRequest: articleSavingRequestResolver,
|
||||
newsletterEmails: newsletterEmailsResolver,
|
||||
// reminder: reminderResolver,
|
||||
labels: labelsResolver,
|
||||
search: searchResolver,
|
||||
subscriptions: subscriptionsResolver,
|
||||
sendInstallInstructions: sendInstallInstructionsResolver,
|
||||
webhooks: webhooksResolver,
|
||||
webhook: webhookResolver,
|
||||
apiKeys: apiKeysResolver,
|
||||
typeaheadSearch: typeaheadSearchResolver,
|
||||
updatesSince: updatesSinceResolver,
|
||||
integrations: integrationsResolver,
|
||||
recentSearches: recentSearchesResolver,
|
||||
rules: rulesResolver,
|
||||
deviceTokens: deviceTokensResolver,
|
||||
filters: filtersResolver,
|
||||
groups: groupsResolver,
|
||||
recentEmails: recentEmailsResolver,
|
||||
feeds: feedsResolver,
|
||||
scanFeeds: scanFeedsResolver,
|
||||
},
|
||||
User: {
|
||||
async intercomHash(
|
||||
user: User,
|
||||
__: Record<string, unknown>,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (env.intercom.secretKey) {
|
||||
const userIdentifier = user.id.toString()
|
||||
|
||||
return createHmac('sha256', env.intercom.secretKey)
|
||||
.update(userIdentifier)
|
||||
.digest('hex')
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
Article: {
|
||||
async url(article: Article, _: unknown, ctx: WithDataSourcesContext) {
|
||||
if (
|
||||
(article.pageType == PageType.File ||
|
||||
article.pageType == PageType.Book) &&
|
||||
ctx.claims &&
|
||||
article.uploadFileId
|
||||
) {
|
||||
const upload = await findUploadFileById(article.uploadFileId)
|
||||
if (!upload || !upload.fileName) {
|
||||
return undefined
|
||||
}
|
||||
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
|
||||
return generateDownloadSignedUrl(filePath)
|
||||
}
|
||||
return article.url
|
||||
},
|
||||
originalArticleUrl(article: { url: string }) {
|
||||
return article.url
|
||||
},
|
||||
hasContent(article: {
|
||||
content: string | null
|
||||
originalHtml: string | null
|
||||
}) {
|
||||
return !!article.originalHtml && !!article.content
|
||||
},
|
||||
publishedAt(article: { publishedAt: Date }) {
|
||||
return validatedDate(article.publishedAt)
|
||||
},
|
||||
// async shareInfo(
|
||||
// article: { id: string; sharedBy?: User; shareInfo?: LinkShareInfo },
|
||||
// __: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ): Promise<LinkShareInfo | undefined> {
|
||||
// if (article.shareInfo) return article.shareInfo
|
||||
// if (!ctx.claims?.uid) return undefined
|
||||
// return getShareInfoForArticle(
|
||||
// ctx.kx,
|
||||
// ctx.claims?.uid,
|
||||
// article.id,
|
||||
// ctx.models
|
||||
// )
|
||||
// },
|
||||
image(article: { image?: string }): string | undefined {
|
||||
return article.image && createImageProxyUrl(article.image, 320, 320)
|
||||
},
|
||||
wordsCount(article: { wordCount?: number; content?: string }) {
|
||||
if (article.wordCount) return article.wordCount
|
||||
return article.content ? wordsCount(article.content) : undefined
|
||||
},
|
||||
},
|
||||
Highlight: {
|
||||
// async reactions(
|
||||
// highlight: { id: string; reactions?: Reaction[] },
|
||||
// _: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// const { reactions, id } = highlight
|
||||
// if (reactions) return reactions
|
||||
|
||||
// return await ctx.models.reaction.batchGetFromHighlight(id)
|
||||
// },
|
||||
createdByMe(
|
||||
highlight: { user: { id: string } },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
return highlight.user.id === ctx.uid
|
||||
},
|
||||
},
|
||||
// Reaction: {
|
||||
// async user(
|
||||
// reaction: { userId: string },
|
||||
// __: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// return userDataToUser(await ctx.models.user.get(reaction.userId))
|
||||
// },
|
||||
// },
|
||||
SearchItem: {
|
||||
async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) {
|
||||
if (
|
||||
(item.pageType == PageType.File || item.pageType == PageType.Book) &&
|
||||
ctx.claims &&
|
||||
item.uploadFileId
|
||||
) {
|
||||
const upload = await findUploadFileById(item.uploadFileId)
|
||||
if (!upload || !upload.fileName) {
|
||||
return undefined
|
||||
}
|
||||
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
|
||||
return generateDownloadSignedUrl(filePath)
|
||||
}
|
||||
return item.url
|
||||
},
|
||||
image(item: SearchItem) {
|
||||
return item.image && createImageProxyUrl(item.image, 320, 320)
|
||||
},
|
||||
originalArticleUrl(item: { url: string }) {
|
||||
return item.url
|
||||
},
|
||||
wordsCount(item: { wordCount?: number; content?: string }) {
|
||||
if (item.wordCount) return item.wordCount
|
||||
return item.content ? wordsCount(item.content) : undefined
|
||||
},
|
||||
siteIcon(item: { siteIcon?: string }) {
|
||||
if (item.siteIcon && !isBase64Image(item.siteIcon)) {
|
||||
return createImageProxyUrl(item.siteIcon, 128, 128)
|
||||
}
|
||||
|
||||
return item.siteIcon
|
||||
},
|
||||
async labels(
|
||||
item: { id: string; labels?: Label[]; labelNames?: string[] | null },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (item.labels) return item.labels
|
||||
|
||||
if (item.labelNames && item.labelNames.length > 0) {
|
||||
return findLabelsByLibraryItemId(item.id, ctx.uid)
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
async recommendations(
|
||||
item: {
|
||||
id: string
|
||||
recommendations?: Recommendation[]
|
||||
recommenderNames?: string[] | null
|
||||
},
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (item.recommendations) return item.recommendations
|
||||
|
||||
if (item.recommenderNames && item.recommenderNames.length > 0) {
|
||||
const recommendations = await findRecommendationsByLibraryItemId(
|
||||
item.id,
|
||||
ctx.uid
|
||||
)
|
||||
return recommendations.map(recommandationDataToRecommendation)
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
},
|
||||
Subscription: {
|
||||
newsletterEmail(subscription: Subscription) {
|
||||
return subscription.newsletterEmail?.address
|
||||
},
|
||||
icon(subscription: Subscription) {
|
||||
return (
|
||||
subscription.icon && createImageProxyUrl(subscription.icon, 128, 128)
|
||||
)
|
||||
},
|
||||
},
|
||||
...resultResolveTypeResolver('Login'),
|
||||
...resultResolveTypeResolver('LogOut'),
|
||||
...resultResolveTypeResolver('GoogleSignup'),
|
||||
...resultResolveTypeResolver('UpdateUser'),
|
||||
...resultResolveTypeResolver('UpdateUserProfile'),
|
||||
...resultResolveTypeResolver('Article'),
|
||||
// ...resultResolveTypeResolver('SharedArticle'),
|
||||
...resultResolveTypeResolver('Articles'),
|
||||
...resultResolveTypeResolver('User'),
|
||||
...resultResolveTypeResolver('Users'),
|
||||
...resultResolveTypeResolver('SaveArticleReadingProgress'),
|
||||
// ...resultResolveTypeResolver('FeedArticles'),
|
||||
...resultResolveTypeResolver('CreateArticle'),
|
||||
...resultResolveTypeResolver('CreateHighlight'),
|
||||
// ...resultResolveTypeResolver('CreateReaction'),
|
||||
// ...resultResolveTypeResolver('DeleteReaction'),
|
||||
...resultResolveTypeResolver('MergeHighlight'),
|
||||
...resultResolveTypeResolver('UpdateHighlight'),
|
||||
...resultResolveTypeResolver('DeleteHighlight'),
|
||||
...resultResolveTypeResolver('UploadFileRequest'),
|
||||
// ...resultResolveTypeResolver('SetShareArticle'),
|
||||
// ...resultResolveTypeResolver('UpdateSharedComment'),
|
||||
...resultResolveTypeResolver('SetBookmarkArticle'),
|
||||
// ...resultResolveTypeResolver('SetFollow'),
|
||||
// ...resultResolveTypeResolver('GetFollowers'),
|
||||
// ...resultResolveTypeResolver('GetFollowing'),
|
||||
...resultResolveTypeResolver('GetUserPersonalization'),
|
||||
...resultResolveTypeResolver('SetUserPersonalization'),
|
||||
...resultResolveTypeResolver('ArticleSavingRequest'),
|
||||
...resultResolveTypeResolver('CreateArticleSavingRequest'),
|
||||
// ...resultResolveTypeResolver('SetShareHighlight'),
|
||||
...resultResolveTypeResolver('ArchiveLink'),
|
||||
...resultResolveTypeResolver('CreateNewsletterEmail'),
|
||||
...resultResolveTypeResolver('NewsletterEmails'),
|
||||
...resultResolveTypeResolver('DeleteNewsletterEmail'),
|
||||
...resultResolveTypeResolver('CreateReminder'),
|
||||
...resultResolveTypeResolver('Reminder'),
|
||||
...resultResolveTypeResolver('UpdateReminder'),
|
||||
...resultResolveTypeResolver('DeleteReminder'),
|
||||
...resultResolveTypeResolver('SetDeviceToken'),
|
||||
...resultResolveTypeResolver('Save'),
|
||||
...resultResolveTypeResolver('Labels'),
|
||||
...resultResolveTypeResolver('CreateLabel'),
|
||||
...resultResolveTypeResolver('DeleteLabel'),
|
||||
...resultResolveTypeResolver('SetLabels'),
|
||||
...resultResolveTypeResolver('GenerateApiKey'),
|
||||
...resultResolveTypeResolver('Search'),
|
||||
...resultResolveTypeResolver('Subscriptions'),
|
||||
...resultResolveTypeResolver('Unsubscribe'),
|
||||
...resultResolveTypeResolver('UpdateLabel'),
|
||||
...resultResolveTypeResolver('SendInstallInstructions'),
|
||||
...resultResolveTypeResolver('UpdatePage'),
|
||||
...resultResolveTypeResolver('Subscribe'),
|
||||
...resultResolveTypeResolver('AddPopularRead'),
|
||||
...resultResolveTypeResolver('SetWebhook'),
|
||||
...resultResolveTypeResolver('Webhooks'),
|
||||
...resultResolveTypeResolver('DeleteWebhook'),
|
||||
...resultResolveTypeResolver('Webhook'),
|
||||
...resultResolveTypeResolver('ApiKeys'),
|
||||
...resultResolveTypeResolver('RevokeApiKey'),
|
||||
...resultResolveTypeResolver('DeleteAccount'),
|
||||
...resultResolveTypeResolver('TypeaheadSearch'),
|
||||
...resultResolveTypeResolver('UpdatesSince'),
|
||||
...resultResolveTypeResolver('MoveLabel'),
|
||||
...resultResolveTypeResolver('SetIntegration'),
|
||||
...resultResolveTypeResolver('Integrations'),
|
||||
...resultResolveTypeResolver('DeleteIntegration'),
|
||||
...resultResolveTypeResolver('RecentSearches'),
|
||||
...resultResolveTypeResolver('OptInFeature'),
|
||||
...resultResolveTypeResolver('SetRule'),
|
||||
...resultResolveTypeResolver('Rules'),
|
||||
...resultResolveTypeResolver('DeviceTokens'),
|
||||
...resultResolveTypeResolver('DeleteRule'),
|
||||
...resultResolveTypeResolver('SaveFilter'),
|
||||
...resultResolveTypeResolver('Filters'),
|
||||
...resultResolveTypeResolver('DeleteFilter'),
|
||||
...resultResolveTypeResolver('MoveFilter'),
|
||||
...resultResolveTypeResolver('CreateGroup'),
|
||||
...resultResolveTypeResolver('Groups'),
|
||||
...resultResolveTypeResolver('Recommend'),
|
||||
...resultResolveTypeResolver('JoinGroup'),
|
||||
...resultResolveTypeResolver('RecommendHighlights'),
|
||||
...resultResolveTypeResolver('LeaveGroup'),
|
||||
...resultResolveTypeResolver('UploadImportFile'),
|
||||
...resultResolveTypeResolver('RecentEmails'),
|
||||
...resultResolveTypeResolver('MarkEmailAsItem'),
|
||||
...resultResolveTypeResolver('BulkAction'),
|
||||
...resultResolveTypeResolver('ImportFromIntegration'),
|
||||
...resultResolveTypeResolver('SetFavoriteArticle'),
|
||||
...resultResolveTypeResolver('UpdateSubscription'),
|
||||
...resultResolveTypeResolver('UpdateEmail'),
|
||||
...resultResolveTypeResolver('ScanFeeds'),
|
||||
}
|
||||
262
examples/omnivore/api/api/src/resolvers/highlight/index.ts
Normal file
262
examples/omnivore/api/api/src/resolvers/highlight/index.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import { DeepPartial } from 'typeorm'
|
||||
import {
|
||||
Highlight as HighlightData,
|
||||
HighlightType,
|
||||
} from '../../entity/highlight'
|
||||
import { Label } from '../../entity/label'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateHighlightError,
|
||||
CreateHighlightErrorCode,
|
||||
CreateHighlightSuccess,
|
||||
DeleteHighlightError,
|
||||
DeleteHighlightErrorCode,
|
||||
DeleteHighlightSuccess,
|
||||
MergeHighlightError,
|
||||
MergeHighlightErrorCode,
|
||||
MergeHighlightSuccess,
|
||||
MutationCreateHighlightArgs,
|
||||
MutationDeleteHighlightArgs,
|
||||
MutationMergeHighlightArgs,
|
||||
MutationUpdateHighlightArgs,
|
||||
UpdateHighlightError,
|
||||
UpdateHighlightErrorCode,
|
||||
UpdateHighlightSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { highlightRepository } from '../../repository/highlight'
|
||||
import {
|
||||
createHighlight,
|
||||
deleteHighlightById,
|
||||
mergeHighlights,
|
||||
updateHighlight,
|
||||
} from '../../services/highlights'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized, highlightDataToHighlight } from '../../utils/helpers'
|
||||
|
||||
export const createHighlightResolver = authorized<
|
||||
CreateHighlightSuccess,
|
||||
CreateHighlightError,
|
||||
MutationCreateHighlightArgs
|
||||
>(async (_, { input }, { log, pubsub, uid }) => {
|
||||
try {
|
||||
const newHighlight = await createHighlight(
|
||||
{
|
||||
...input,
|
||||
user: { id: uid },
|
||||
libraryItem: { id: input.articleId },
|
||||
highlightType: input.type || HighlightType.Highlight,
|
||||
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex || 0,
|
||||
highlightPositionPercent: input.highlightPositionPercent || 0,
|
||||
},
|
||||
input.articleId,
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'highlight_created',
|
||||
properties: {
|
||||
libraryItemId: input.articleId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return { highlight: highlightDataToHighlight(newHighlight) }
|
||||
} catch (err) {
|
||||
log.error('Error creating highlight', err)
|
||||
return {
|
||||
errorCodes: [CreateHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const mergeHighlightResolver = authorized<
|
||||
MergeHighlightSuccess,
|
||||
MergeHighlightError,
|
||||
MutationMergeHighlightArgs
|
||||
>(async (_, { input }, { log, pubsub, uid }) => {
|
||||
const { overlapHighlightIdList, ...newHighlightInput } = input
|
||||
|
||||
/* Compute merged annotation form the order of highlights appearing on page */
|
||||
const mergedAnnotations: string[] = []
|
||||
const mergedLabels: Label[] = []
|
||||
const mergedColors: string[] = []
|
||||
|
||||
try {
|
||||
const existingHighlights = await highlightRepository.findByLibraryItemId(
|
||||
input.articleId,
|
||||
uid
|
||||
)
|
||||
|
||||
existingHighlights.forEach((highlight) => {
|
||||
// filter out highlights that are in the overlap list
|
||||
// and are of type highlight (not annotation or note)
|
||||
if (
|
||||
overlapHighlightIdList.includes(highlight.id) &&
|
||||
highlight.highlightType === HighlightType.Highlight
|
||||
) {
|
||||
highlight.annotation && mergedAnnotations.push(highlight.annotation)
|
||||
|
||||
if (highlight.labels) {
|
||||
// remove duplicates from labels by checking id
|
||||
highlight.labels.forEach((label) => {
|
||||
if (
|
||||
!mergedLabels.find((mergedLabel) => mergedLabel.id === label.id)
|
||||
) {
|
||||
mergedLabels.push(label)
|
||||
}
|
||||
})
|
||||
}
|
||||
// collect colors of overlap highlights
|
||||
highlight.color && mergedColors.push(highlight.color)
|
||||
}
|
||||
})
|
||||
// use new color or the color of the last overlap highlight
|
||||
const color =
|
||||
newHighlightInput.color || mergedColors[mergedColors.length - 1]
|
||||
|
||||
const highlight: DeepPartial<HighlightData> = {
|
||||
...newHighlightInput,
|
||||
annotation:
|
||||
mergedAnnotations.length > 0 ? mergedAnnotations.join('\n') : null,
|
||||
labels: mergedLabels,
|
||||
color,
|
||||
user: { id: uid },
|
||||
libraryItem: { id: input.articleId },
|
||||
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex || 0,
|
||||
highlightPositionPercent: input.highlightPositionPercent || 0,
|
||||
}
|
||||
|
||||
const newHighlight = await mergeHighlights(
|
||||
overlapHighlightIdList,
|
||||
highlight,
|
||||
input.articleId,
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'highlight_created',
|
||||
properties: {
|
||||
libraryItemId: input.articleId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
highlight: highlightDataToHighlight(newHighlight),
|
||||
overlapHighlightIdList: input.overlapHighlightIdList,
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Error merging highlight', e)
|
||||
return {
|
||||
errorCodes: [MergeHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const updateHighlightResolver = authorized<
|
||||
UpdateHighlightSuccess,
|
||||
UpdateHighlightError,
|
||||
MutationUpdateHighlightArgs
|
||||
>(async (_, { input }, { pubsub, uid, log }) => {
|
||||
try {
|
||||
const updatedHighlight = await updateHighlight(
|
||||
input.highlightId,
|
||||
{
|
||||
annotation: input.annotation,
|
||||
html: input.html,
|
||||
quote: input.quote,
|
||||
color: input.color,
|
||||
},
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
return { highlight: highlightDataToHighlight(updatedHighlight) }
|
||||
} catch (error) {
|
||||
log.error('updateHighlightResolver error', error)
|
||||
return {
|
||||
errorCodes: [UpdateHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteHighlightResolver = authorized<
|
||||
DeleteHighlightSuccess,
|
||||
DeleteHighlightError,
|
||||
MutationDeleteHighlightArgs
|
||||
>(async (_, { highlightId }, { log }) => {
|
||||
try {
|
||||
const deletedHighlight = await deleteHighlightById(highlightId)
|
||||
|
||||
if (!deletedHighlight) {
|
||||
return {
|
||||
errorCodes: [DeleteHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
return { highlight: highlightDataToHighlight(deletedHighlight) }
|
||||
} catch (error) {
|
||||
log.error('deleteHighlightResolver error', error)
|
||||
return {
|
||||
errorCodes: [DeleteHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// export const setShareHighlightResolver = authorized<
|
||||
// SetShareHighlightSuccess,
|
||||
// SetShareHighlightError,
|
||||
// MutationSetShareHighlightArgs
|
||||
// >(async (_, { input: { id, share } }, { pubsub, claims, log }) => {
|
||||
// const highlight = await getHighlightById(id)
|
||||
|
||||
// if (!highlight?.id) {
|
||||
// return {
|
||||
// errorCodes: [SetShareHighlightErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (highlight.userId !== claims.uid) {
|
||||
// return {
|
||||
// errorCodes: [SetShareHighlightErrorCode.Forbidden],
|
||||
// }
|
||||
// }
|
||||
|
||||
// const sharedAt = share ? new Date() : null
|
||||
|
||||
// log.info(`${share ? 'S' : 'Uns'}haring a highlight`, {
|
||||
// highlight,
|
||||
// labels: {
|
||||
// source: 'resolver',
|
||||
// resolver: 'setShareHighlightResolver',
|
||||
// userId: highlight.userId,
|
||||
// },
|
||||
// })
|
||||
|
||||
// const updatedHighlight: HighlightData = {
|
||||
// ...highlight,
|
||||
// sharedAt,
|
||||
// updatedAt: new Date(),
|
||||
// }
|
||||
|
||||
// const updated = await updateHighlight(updatedHighlight, {
|
||||
// pubsub,
|
||||
// uid: claims.uid,
|
||||
// refresh: true,
|
||||
// })
|
||||
|
||||
// if (!updated) {
|
||||
// return {
|
||||
// errorCodes: [SetShareHighlightErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// return { highlight: highlightDataToHighlight(updatedHighlight) }
|
||||
// })
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DateTime } from 'luxon'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MutationUploadImportFileArgs,
|
||||
UploadImportFileError,
|
||||
UploadImportFileErrorCode,
|
||||
UploadImportFileSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { logger } from '../../utils/logger'
|
||||
import {
|
||||
countOfFilesWithPrefix,
|
||||
generateUploadSignedUrl,
|
||||
} from '../../utils/uploads'
|
||||
|
||||
const MAX_DAILY_UPLOADS = 1
|
||||
const VALID_CONTENT_TYPES = ['text/csv', 'application/zip']
|
||||
|
||||
const extensionForContentType = (contentType: string) => {
|
||||
switch (contentType) {
|
||||
case 'text/csv':
|
||||
return 'csv'
|
||||
case 'application/zip':
|
||||
return 'zip'
|
||||
}
|
||||
return '.unknown'
|
||||
}
|
||||
|
||||
export const uploadImportFileResolver = authorized<
|
||||
UploadImportFileSuccess,
|
||||
UploadImportFileError,
|
||||
MutationUploadImportFileArgs
|
||||
>(async (_, { type, contentType }, { uid }) => {
|
||||
if (!VALID_CONTENT_TYPES.includes(contentType)) {
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'upload_import_file',
|
||||
properties: {
|
||||
type,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
// path style: imports/<uid>/<date>/<type>-<uuid>
|
||||
const dateStr = DateTime.now().toISODate()
|
||||
const dirPath = `imports/${uid}/${dateStr}/`
|
||||
const fileCount = await countOfFilesWithPrefix(dirPath)
|
||||
|
||||
if (fileCount >= MAX_DAILY_UPLOADS) {
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.UploadDailyLimitExceeded],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileUuid = uuidv4()
|
||||
const ext = extensionForContentType(contentType)
|
||||
const fullPath = `${dirPath}${type}-${fileUuid}.${ext}`
|
||||
const uploadSignedUrl = await generateUploadSignedUrl(fullPath, contentType)
|
||||
|
||||
return {
|
||||
uploadSignedUrl,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating uploadSignedUrl', {
|
||||
error,
|
||||
type,
|
||||
contentType,
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
26
examples/omnivore/api/api/src/resolvers/index.ts
Normal file
26
examples/omnivore/api/api/src/resolvers/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export * from './user'
|
||||
export * from './article'
|
||||
// export * from './user_friends'
|
||||
// export * from './user_feed_article'
|
||||
export * from './user_personalization'
|
||||
export * from './article_saving_request'
|
||||
export * from './upload_files'
|
||||
export * from './highlight'
|
||||
// export * from './reaction'
|
||||
export * from './report'
|
||||
export * from './links'
|
||||
export * from './newsletters'
|
||||
export * from './save'
|
||||
export * from './send_install_instructions'
|
||||
// export * from './reminders'
|
||||
export * from './user_device_tokens'
|
||||
export * from './labels'
|
||||
export * from './subscriptions'
|
||||
export * from './update'
|
||||
export * from './popular_reads'
|
||||
export * from './webhooks'
|
||||
export * from './api_key'
|
||||
export * from './integrations'
|
||||
export * from './rules'
|
||||
export * from './filters'
|
||||
export * from './recommendations'
|
||||
269
examples/omnivore/api/api/src/resolvers/integrations/index.ts
Normal file
269
examples/omnivore/api/api/src/resolvers/integrations/index.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { DeepPartial } from 'typeorm'
|
||||
import {
|
||||
ImportItemState,
|
||||
Integration,
|
||||
IntegrationType,
|
||||
} from '../../entity/integration'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteIntegrationError,
|
||||
DeleteIntegrationErrorCode,
|
||||
DeleteIntegrationSuccess,
|
||||
ImportFromIntegrationError,
|
||||
ImportFromIntegrationErrorCode,
|
||||
ImportFromIntegrationSuccess,
|
||||
IntegrationsError,
|
||||
IntegrationsErrorCode,
|
||||
IntegrationsSuccess,
|
||||
MutationDeleteIntegrationArgs,
|
||||
MutationImportFromIntegrationArgs,
|
||||
MutationSetIntegrationArgs,
|
||||
SetIntegrationError,
|
||||
SetIntegrationErrorCode,
|
||||
SetIntegrationSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { createIntegrationToken } from '../../routers/auth/jwt_helpers'
|
||||
import {
|
||||
findIntegration,
|
||||
findIntegrations,
|
||||
getIntegrationClient,
|
||||
removeIntegration,
|
||||
saveIntegration,
|
||||
updateIntegration,
|
||||
} from '../../services/integrations'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import {
|
||||
deleteTask,
|
||||
enqueueExportToIntegration,
|
||||
enqueueImportFromIntegration,
|
||||
} from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const setIntegrationResolver = authorized<
|
||||
SetIntegrationSuccess,
|
||||
SetIntegrationError,
|
||||
MutationSetIntegrationArgs
|
||||
>(async (_, { input }, { uid, log }) => {
|
||||
try {
|
||||
const integrationToSave: DeepPartial<Integration> = {
|
||||
...input,
|
||||
user: { id: uid },
|
||||
id: input.id || undefined,
|
||||
type: input.type || IntegrationType.Export,
|
||||
syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined,
|
||||
importItemState:
|
||||
input.type === IntegrationType.Import
|
||||
? input.importItemState || ImportItemState.Unarchived // default to unarchived
|
||||
: undefined,
|
||||
}
|
||||
if (input.id) {
|
||||
// Update
|
||||
const existingIntegration = await findIntegration({ id: input.id }, uid)
|
||||
if (!existingIntegration) {
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
integrationToSave.id = existingIntegration.id
|
||||
integrationToSave.taskName = existingIntegration.taskName
|
||||
} else {
|
||||
// Create
|
||||
const integrationService = getIntegrationClient(input.name)
|
||||
// authorize and get access token
|
||||
const token = await integrationService.accessToken(input.token)
|
||||
if (!token) {
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.InvalidToken],
|
||||
}
|
||||
}
|
||||
integrationToSave.token = token
|
||||
}
|
||||
|
||||
// save integration
|
||||
const integration = await saveIntegration(integrationToSave, uid)
|
||||
|
||||
if (integrationToSave.type === IntegrationType.Export && !input.id) {
|
||||
const authToken = await createIntegrationToken({
|
||||
uid,
|
||||
token: integration.token,
|
||||
})
|
||||
if (!authToken) {
|
||||
log.error('failed to create auth token', {
|
||||
integrationId: integration.id,
|
||||
})
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
// create a task to sync all the pages if new integration or enable integration (export type)
|
||||
const taskName = await enqueueExportToIntegration(
|
||||
integration.id,
|
||||
integration.name,
|
||||
0,
|
||||
authToken
|
||||
)
|
||||
log.info('enqueued task', taskName)
|
||||
|
||||
// update task name in integration
|
||||
await updateIntegration(integration.id, { taskName }, uid)
|
||||
integration.taskName = taskName
|
||||
} else if (integrationToSave.taskName) {
|
||||
// delete the task if disable integration and task exists
|
||||
const result = await deleteTask(integrationToSave.taskName)
|
||||
if (result) {
|
||||
log.info('task deleted', integrationToSave.taskName)
|
||||
}
|
||||
|
||||
// update task name in integration
|
||||
await updateIntegration(
|
||||
integration.id,
|
||||
{
|
||||
taskName: null,
|
||||
},
|
||||
uid
|
||||
)
|
||||
integration.taskName = null
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'integration_set',
|
||||
properties: {
|
||||
id: integrationToSave.id,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
integration,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const integrationsResolver = authorized<
|
||||
IntegrationsSuccess,
|
||||
IntegrationsError
|
||||
>(async (_, __, { uid, log }) => {
|
||||
try {
|
||||
const integrations = await findIntegrations(uid)
|
||||
|
||||
return {
|
||||
integrations,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
return {
|
||||
errorCodes: [IntegrationsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteIntegrationResolver = authorized<
|
||||
DeleteIntegrationSuccess,
|
||||
DeleteIntegrationError,
|
||||
MutationDeleteIntegrationArgs
|
||||
>(async (_, { id }, { claims: { uid }, log }) => {
|
||||
log.info('deleteIntegrationResolver')
|
||||
|
||||
try {
|
||||
const integration = await findIntegration({ id }, uid)
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
errorCodes: [DeleteIntegrationErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (integration.taskName) {
|
||||
// delete the task if task exists
|
||||
await deleteTask(integration.taskName)
|
||||
log.info('task deleted', integration.taskName)
|
||||
}
|
||||
|
||||
const deletedIntegration = await removeIntegration(integration, uid)
|
||||
deletedIntegration.id = id
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'integration_delete',
|
||||
properties: {
|
||||
integrationId: deletedIntegration.id,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
integration,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteIntegrationErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const importFromIntegrationResolver = authorized<
|
||||
ImportFromIntegrationSuccess,
|
||||
ImportFromIntegrationError,
|
||||
MutationImportFromIntegrationArgs
|
||||
>(async (_, { integrationId }, { claims: { uid }, log }) => {
|
||||
try {
|
||||
const integration = await findIntegration({ id: integrationId }, uid)
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
errorCodes: [ImportFromIntegrationErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const authToken = await createIntegrationToken({
|
||||
uid: integration.user.id,
|
||||
token: integration.token,
|
||||
})
|
||||
if (!authToken) {
|
||||
return {
|
||||
errorCodes: [ImportFromIntegrationErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
// create a task to import all the pages
|
||||
const taskName = await enqueueImportFromIntegration(
|
||||
integration.id,
|
||||
integration.name,
|
||||
integration.syncedAt?.getTime() || 0,
|
||||
authToken,
|
||||
integration.importItemState || ImportItemState.Unarchived
|
||||
)
|
||||
// update task name in integration
|
||||
await updateIntegration(integration.id, { taskName }, uid)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'integration_import',
|
||||
properties: {
|
||||
integrationId,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
return {
|
||||
errorCodes: [ImportFromIntegrationErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
398
examples/omnivore/api/api/src/resolvers/labels/index.ts
Normal file
398
examples/omnivore/api/api/src/resolvers/labels/index.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { Between } from 'typeorm'
|
||||
import { isLabelSource, LabelSource } from '../../entity/entity_label'
|
||||
import { Label } from '../../entity/label'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateLabelError,
|
||||
CreateLabelErrorCode,
|
||||
CreateLabelSuccess,
|
||||
DeleteLabelError,
|
||||
DeleteLabelErrorCode,
|
||||
DeleteLabelSuccess,
|
||||
LabelsError,
|
||||
LabelsErrorCode,
|
||||
LabelsSuccess,
|
||||
MoveLabelError,
|
||||
MoveLabelErrorCode,
|
||||
MoveLabelSuccess,
|
||||
MutationCreateLabelArgs,
|
||||
MutationDeleteLabelArgs,
|
||||
MutationMoveLabelArgs,
|
||||
MutationSetLabelsArgs,
|
||||
MutationSetLabelsForHighlightArgs,
|
||||
MutationUpdateLabelArgs,
|
||||
SetLabelsError,
|
||||
SetLabelsErrorCode,
|
||||
SetLabelsSuccess,
|
||||
UpdateLabelError,
|
||||
UpdateLabelErrorCode,
|
||||
UpdateLabelSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { labelRepository } from '../../repository/label'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import {
|
||||
findOrCreateLabels,
|
||||
saveLabelsInHighlight,
|
||||
saveLabelsInLibraryItem,
|
||||
updateLabel,
|
||||
} from '../../services/labels'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
|
||||
async (_obj, _params, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const labels = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).find({
|
||||
where: {
|
||||
user: { id: uid },
|
||||
},
|
||||
order: {
|
||||
name: 'ASC',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
$set: {
|
||||
email: user.email,
|
||||
username: user.profile.username,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
labels,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('labelsResolver', error)
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const createLabelResolver = authorized<
|
||||
CreateLabelSuccess,
|
||||
CreateLabelError,
|
||||
MutationCreateLabelArgs
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const label = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).createLabel(input, uid)
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'label_created',
|
||||
properties: {
|
||||
...input,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
label,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('createLabelResolver', error)
|
||||
return {
|
||||
errorCodes: [CreateLabelErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteLabelResolver = authorized<
|
||||
DeleteLabelSuccess,
|
||||
DeleteLabelError,
|
||||
MutationDeleteLabelArgs
|
||||
>(async (_, { id: labelId }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const deleteResult = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).deleteById(labelId)
|
||||
})
|
||||
|
||||
if (!deleteResult.affected) {
|
||||
return {
|
||||
errorCodes: [DeleteLabelErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'label_deleted',
|
||||
properties: {
|
||||
labelId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
label: {
|
||||
id: labelId,
|
||||
name: '',
|
||||
color: '',
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('error deleting label', error)
|
||||
return {
|
||||
errorCodes: [DeleteLabelErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const setLabelsResolver = authorized<
|
||||
SetLabelsSuccess,
|
||||
SetLabelsError,
|
||||
MutationSetLabelsArgs
|
||||
>(
|
||||
async (
|
||||
_,
|
||||
{ input: { pageId, labelIds, labels, source } },
|
||||
{ uid, log, authTrx, pubsub }
|
||||
) => {
|
||||
if (!labelIds && !labels) {
|
||||
log.error('labelIds or labels must be provided')
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
let labelSource: LabelSource | undefined
|
||||
|
||||
// check if source is valid
|
||||
if (source) {
|
||||
if (!isLabelSource(source)) {
|
||||
log.error('invalid source', source)
|
||||
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
labelSource = source
|
||||
}
|
||||
|
||||
try {
|
||||
let labelsSet: Label[] = []
|
||||
|
||||
if (labels && labels.length > 0) {
|
||||
// for new clients that send label names
|
||||
// create labels if they don't exist
|
||||
labelsSet = await findOrCreateLabels(labels, uid)
|
||||
} else if (labelIds && labelIds.length > 0) {
|
||||
// for old clients that send labelIds
|
||||
labelsSet = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).findLabelsById(labelIds)
|
||||
})
|
||||
|
||||
if (labelsSet.length !== labelIds.length) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save labels in the library item
|
||||
await saveLabelsInLibraryItem(labelsSet, pageId, uid, labelSource, pubsub)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels_set',
|
||||
properties: {
|
||||
pageId,
|
||||
labelIds,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
labels: labelsSet,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('setLabelsResolver error', error)
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const updateLabelResolver = authorized<
|
||||
UpdateLabelSuccess,
|
||||
UpdateLabelError,
|
||||
MutationUpdateLabelArgs
|
||||
>(async (_, { input: { name, color, description, labelId } }, { uid, log }) => {
|
||||
try {
|
||||
const label = await updateLabel(labelId, { name, color, description }, uid)
|
||||
|
||||
return { label }
|
||||
} catch (error) {
|
||||
log.error('error updating label', error)
|
||||
return {
|
||||
errorCodes: [UpdateLabelErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const setLabelsForHighlightResolver = authorized<
|
||||
SetLabelsSuccess,
|
||||
SetLabelsError,
|
||||
MutationSetLabelsForHighlightArgs
|
||||
>(async (_, { input }, { uid, log, pubsub, authTrx }) => {
|
||||
const { highlightId, labelIds, labels } = input
|
||||
|
||||
if (!labelIds && !labels) {
|
||||
log.info('labelIds or labels must be provided')
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let labelsSet: Label[] = []
|
||||
|
||||
if (labels && labels.length > 0) {
|
||||
// for new clients that send label names
|
||||
// create labels if they don't exist
|
||||
labelsSet = await findOrCreateLabels(labels, uid)
|
||||
} else if (labelIds && labelIds.length > 0) {
|
||||
// for old clients that send labelIds
|
||||
labelsSet = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).findLabelsById(labelIds)
|
||||
})
|
||||
if (labelsSet.length !== labelIds.length) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save labels in the library item
|
||||
await saveLabelsInHighlight(labelsSet, input.highlightId, uid, pubsub)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels_set_for_highlight',
|
||||
properties: {
|
||||
highlightId,
|
||||
labelIds,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
labels: labelsSet,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('setLabelsForHighlightResolver error', error)
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const moveLabelResolver = authorized<
|
||||
MoveLabelSuccess,
|
||||
MoveLabelError,
|
||||
MutationMoveLabelArgs
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
const { labelId, afterLabelId } = input
|
||||
|
||||
try {
|
||||
const label = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).findById(labelId)
|
||||
})
|
||||
if (!label) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (label.id === afterLabelId) {
|
||||
// nothing to do
|
||||
return { label }
|
||||
}
|
||||
|
||||
const oldPosition = label.position
|
||||
// if afterLabelId is not provided, move to the top
|
||||
let newPosition = 1
|
||||
if (afterLabelId) {
|
||||
const afterLabel = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).findById(afterLabelId)
|
||||
})
|
||||
if (!afterLabel) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
newPosition = afterLabel.position
|
||||
}
|
||||
const moveUp = newPosition < oldPosition
|
||||
|
||||
// move label to the new position
|
||||
const updated = await authTrx(async (tx) => {
|
||||
const labelRepo = tx.withRepository(labelRepository)
|
||||
// update the position of the other labels
|
||||
const updated = await labelRepo.update(
|
||||
{
|
||||
position: Between(
|
||||
Math.min(newPosition, oldPosition),
|
||||
Math.max(newPosition, oldPosition)
|
||||
),
|
||||
},
|
||||
{
|
||||
position: () => `position + ${moveUp ? 1 : -1}`,
|
||||
}
|
||||
)
|
||||
if (!updated.affected) {
|
||||
return null
|
||||
}
|
||||
|
||||
// update the position of the label
|
||||
return labelRepo.save({
|
||||
id: labelId,
|
||||
position: newPosition,
|
||||
})
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'label_moved',
|
||||
properties: {
|
||||
labelId,
|
||||
afterLabelId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
label: updated,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('error moving label', error)
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
96
examples/omnivore/api/api/src/resolvers/links/index.ts
Normal file
96
examples/omnivore/api/api/src/resolvers/links/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { LibraryItemState } from '../../entity/library_item'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ArchiveLinkError,
|
||||
ArchiveLinkErrorCode,
|
||||
ArchiveLinkSuccess,
|
||||
MutationSetLinkArchivedArgs,
|
||||
} from '../../generated/graphql'
|
||||
import { updateLibraryItem } from '../../services/library_item'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
// export const updateLinkShareInfoResolver = authorized<
|
||||
// UpdateLinkShareInfoSuccess,
|
||||
// UpdateLinkShareInfoError,
|
||||
// MutationUpdateLinkShareInfoArgs
|
||||
// >(async (_obj, args, { models, claims, authTrx, log }) => {
|
||||
// const { title, description } = args.input
|
||||
|
||||
// log.info('updateLinkShareInfoResolver', args.input.linkId, title, description)
|
||||
|
||||
// // TEMP: because the old API uses articles instead of Links, we are actually
|
||||
// // getting an article ID here and need to map it to a link ID. When the API
|
||||
// // is updated to use Links instead of Articles this will be removed.
|
||||
// const link = await authTrx((tx) =>
|
||||
// models.userArticle.getByArticleId(claims.uid, args.input.linkId, tx)
|
||||
// )
|
||||
|
||||
// if (!link?.id) {
|
||||
// return {
|
||||
// __typename: 'UpdateLinkShareInfoError',
|
||||
// errorCodes: [UpdateLinkShareInfoErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
|
||||
// const result = await authTrx((tx) =>
|
||||
// createOrUpdateLinkShareInfo(tx, link.id, title, description)
|
||||
// )
|
||||
// if (!result) {
|
||||
// return {
|
||||
// __typename: 'UpdateLinkShareInfoError',
|
||||
// errorCodes: [UpdateLinkShareInfoErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
|
||||
// return {
|
||||
// __typename: 'UpdateLinkShareInfoSuccess',
|
||||
// message: 'Updated Share Information',
|
||||
// }
|
||||
// })
|
||||
|
||||
export const setLinkArchivedResolver = authorized<
|
||||
ArchiveLinkSuccess,
|
||||
ArchiveLinkError,
|
||||
MutationSetLinkArchivedArgs
|
||||
>(async (_obj, args, { uid }) => {
|
||||
let state = LibraryItemState.Archived
|
||||
let archivedAt: Date | null = new Date()
|
||||
let event = 'link_archived'
|
||||
|
||||
const isUnarchive = !args.input.archived
|
||||
if (isUnarchive) {
|
||||
state = LibraryItemState.Succeeded
|
||||
archivedAt = null
|
||||
event = 'link_unarchived'
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event,
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await updateLibraryItem(
|
||||
args.input.linkId,
|
||||
{
|
||||
state,
|
||||
archivedAt,
|
||||
},
|
||||
uid
|
||||
)
|
||||
} catch (e) {
|
||||
return {
|
||||
message: 'An error occurred',
|
||||
errorCodes: [ArchiveLinkErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
linkId: args.input.linkId,
|
||||
message: event,
|
||||
}
|
||||
})
|
||||
130
examples/omnivore/api/api/src/resolvers/newsletters/index.ts
Normal file
130
examples/omnivore/api/api/src/resolvers/newsletters/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NewsletterEmail } from '../../entity/newsletter_email'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateNewsletterEmailError,
|
||||
CreateNewsletterEmailErrorCode,
|
||||
CreateNewsletterEmailSuccess,
|
||||
DeleteNewsletterEmailError,
|
||||
DeleteNewsletterEmailErrorCode,
|
||||
DeleteNewsletterEmailSuccess,
|
||||
MutationDeleteNewsletterEmailArgs,
|
||||
NewsletterEmailsError,
|
||||
NewsletterEmailsErrorCode,
|
||||
NewsletterEmailsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../repository'
|
||||
import {
|
||||
createNewsletterEmail,
|
||||
deleteNewsletterEmail,
|
||||
getNewsletterEmails,
|
||||
} from '../../services/newsletters'
|
||||
import { unsubscribeAll } from '../../services/subscriptions'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const createNewsletterEmailResolver = authorized<
|
||||
CreateNewsletterEmailSuccess,
|
||||
CreateNewsletterEmailError
|
||||
>(async (_parent, _args, { claims, log }) => {
|
||||
log.info('createNewsletterEmailResolver')
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
event: 'newsletter_email_address_created',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const newsletterEmail = await createNewsletterEmail(claims.uid)
|
||||
|
||||
return {
|
||||
newsletterEmail: {
|
||||
...newsletterEmail,
|
||||
subscriptionCount: 0,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
log.info(e)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateNewsletterEmailErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const newsletterEmailsResolver = authorized<
|
||||
NewsletterEmailsSuccess,
|
||||
NewsletterEmailsError
|
||||
>(async (_parent, _args, { uid, log }) => {
|
||||
try {
|
||||
const newsletterEmails = await getNewsletterEmails(uid)
|
||||
|
||||
return {
|
||||
newsletterEmails: newsletterEmails.map((newsletterEmail) => ({
|
||||
...newsletterEmail,
|
||||
subscriptionCount: newsletterEmail.subscriptions.length,
|
||||
})),
|
||||
}
|
||||
} catch (e) {
|
||||
log.info(e)
|
||||
|
||||
return {
|
||||
errorCodes: [NewsletterEmailsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteNewsletterEmailResolver = authorized<
|
||||
DeleteNewsletterEmailSuccess,
|
||||
DeleteNewsletterEmailError,
|
||||
MutationDeleteNewsletterEmailArgs
|
||||
>(async (_parent, args, { uid, log }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'newsletter_email_address_deleted',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const newsletterEmail = await getRepository(NewsletterEmail).findOne({
|
||||
where: {
|
||||
id: args.newsletterEmailId,
|
||||
user: { id: uid },
|
||||
},
|
||||
relations: ['user', 'subscriptions'],
|
||||
})
|
||||
|
||||
if (!newsletterEmail) {
|
||||
return {
|
||||
errorCodes: [DeleteNewsletterEmailErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// unsubscribe all before deleting
|
||||
await unsubscribeAll(newsletterEmail)
|
||||
|
||||
const deleted = await deleteNewsletterEmail(args.newsletterEmailId)
|
||||
if (deleted) {
|
||||
return {
|
||||
newsletterEmail: {
|
||||
...newsletterEmail,
|
||||
subscriptionCount: newsletterEmail.subscriptions.length,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// when user tries to delete other's newsletters emails or email already deleted
|
||||
return {
|
||||
errorCodes: [DeleteNewsletterEmailErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('deleteNewsletterEmailResolver', error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteNewsletterEmailErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
AddPopularReadError,
|
||||
AddPopularReadErrorCode,
|
||||
AddPopularReadSuccess,
|
||||
MutationAddPopularReadArgs,
|
||||
} from '../../generated/graphql'
|
||||
import { addPopularRead } from '../../services/popular_reads'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
export const addPopularReadResolver = authorized<
|
||||
AddPopularReadSuccess,
|
||||
AddPopularReadError,
|
||||
MutationAddPopularReadArgs
|
||||
>(async (_, { name }, { uid }) => {
|
||||
const items = await addPopularRead(uid, name)
|
||||
if (items.length === 0) {
|
||||
return { errorCodes: [AddPopularReadErrorCode.NotFound] }
|
||||
}
|
||||
|
||||
return {
|
||||
pageId: items[0].id,
|
||||
}
|
||||
})
|
||||
132
examples/omnivore/api/api/src/resolvers/reaction/index.ts
Normal file
132
examples/omnivore/api/api/src/resolvers/reaction/index.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Merge } from '../../util'
|
||||
import { CreateReactionSuccess, Reaction } from './../../generated/graphql'
|
||||
|
||||
export type PartialReaction = Omit<Reaction, 'user'>
|
||||
|
||||
export type PartialCreateReactionSuccess = Merge<
|
||||
CreateReactionSuccess,
|
||||
{ reaction: PartialReaction }
|
||||
>
|
||||
|
||||
// export const createReactionResolver = authorized<
|
||||
// PartialCreateReactionSuccess,
|
||||
// CreateReactionError,
|
||||
// MutationCreateReactionArgs
|
||||
// >(async (_, { input }, { models, claims, log, authTrx }) => {
|
||||
// const { userArticleId, highlightId } = input
|
||||
|
||||
// if ((!userArticleId && !highlightId) || (userArticleId && highlightId)) {
|
||||
// // One reaction target is required
|
||||
// // Highlight replies hasn't supported yet
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.BadTarget],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (input.code && input.code.length > 50) {
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.BadCode],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (userArticleId) {
|
||||
// if (!(await models.userArticle.get(userArticleId))) {
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.BadTarget],
|
||||
// }
|
||||
// }
|
||||
// } else if (highlightId) {
|
||||
// if (!(await models.highlight.get(highlightId))) {
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.BadTarget],
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// try {
|
||||
// const previousReaction = userArticleId
|
||||
// ? await models.reaction.getByUserAndParam(claims.uid, { userArticleId })
|
||||
// : await models.reaction.getByUserAndParam(claims.uid, { highlightId })
|
||||
|
||||
// let reaction
|
||||
// if (!previousReaction) {
|
||||
// reaction = await authTrx((tx) =>
|
||||
// models.reaction.create({ ...input, userId: claims.uid }, tx)
|
||||
// )
|
||||
// } else {
|
||||
// reaction = await authTrx((tx) =>
|
||||
// models.reaction.update(
|
||||
// previousReaction.id,
|
||||
// {
|
||||
// code: input.code,
|
||||
// },
|
||||
// tx
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
|
||||
// if (!reaction) {
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
// log.info(`${previousReaction ? 'Updating' : 'Creating'} a new reaction`, {
|
||||
// reaction,
|
||||
// labels: {
|
||||
// source: 'resolver',
|
||||
// resolver: 'createReactionResolver',
|
||||
// uid: claims.uid,
|
||||
// },
|
||||
// })
|
||||
|
||||
// return {
|
||||
// reaction: reaction as PartialReaction,
|
||||
// }
|
||||
// } catch (err) {
|
||||
// log.info(err)
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// export const deleteReactionResolver = authorized<
|
||||
// PartialCreateReactionSuccess,
|
||||
// DeleteReactionError,
|
||||
// MutationDeleteReactionArgs
|
||||
// >(async (_, { id }, { authTrx, models, claims, log }) => {
|
||||
// const reaction = await models.reaction.get(id)
|
||||
|
||||
// if (!reaction?.id) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReactionErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (reaction.userId !== claims.uid) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReactionErrorCode.Forbidden],
|
||||
// }
|
||||
// }
|
||||
|
||||
// const deleted = await authTrx((tx) => models.reaction.delete(id, tx))
|
||||
|
||||
// if ('error' in deleted) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReactionErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// log.info('Deleting a highlight', {
|
||||
// deleted,
|
||||
// labels: {
|
||||
// source: 'resolver',
|
||||
// resolver: 'deleteHighlightResolver',
|
||||
// uid: claims.uid,
|
||||
// },
|
||||
// })
|
||||
|
||||
// return {
|
||||
// reaction: reaction as PartialReaction,
|
||||
// }
|
||||
// })
|
||||
135
examples/omnivore/api/api/src/resolvers/recent_emails/index.ts
Normal file
135
examples/omnivore/api/api/src/resolvers/recent_emails/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ILike } from 'typeorm'
|
||||
import { NewsletterEmail } from '../../entity/newsletter_email'
|
||||
import { ReceivedEmail } from '../../entity/received_email'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MarkEmailAsItemError,
|
||||
MarkEmailAsItemErrorCode,
|
||||
MarkEmailAsItemSuccess,
|
||||
MutationMarkEmailAsItemArgs,
|
||||
RecentEmailsError,
|
||||
RecentEmailsErrorCode,
|
||||
RecentEmailsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { updateReceivedEmail } from '../../services/received_emails'
|
||||
import { saveNewsletter } from '../../services/save_newsletter_email'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { generateUniqueUrl, parseEmailAddress } from '../../utils/parser'
|
||||
import { sendEmail } from '../../utils/sendEmail'
|
||||
|
||||
export const recentEmailsResolver = authorized<
|
||||
RecentEmailsSuccess,
|
||||
RecentEmailsError
|
||||
>(async (_, __, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const recentEmails = await authTrx((t) =>
|
||||
t.getRepository(ReceivedEmail).find({
|
||||
where: {
|
||||
user: { id: uid },
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
recentEmails,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error getting recent emails', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RecentEmailsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const markEmailAsItemResolver = authorized<
|
||||
MarkEmailAsItemSuccess,
|
||||
MarkEmailAsItemError,
|
||||
MutationMarkEmailAsItemArgs
|
||||
>(async (_, { recentEmailId }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const recentEmail = await authTrx((t) =>
|
||||
t.getRepository(ReceivedEmail).findOneBy({
|
||||
id: recentEmailId,
|
||||
user: { id: uid },
|
||||
type: 'non-article',
|
||||
})
|
||||
)
|
||||
if (!recentEmail) {
|
||||
log.info('no recent email', recentEmailId)
|
||||
|
||||
return {
|
||||
errorCodes: [MarkEmailAsItemErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const newsletterEmail = await authTrx((t) =>
|
||||
t.getRepository(NewsletterEmail).findOne({
|
||||
where: {
|
||||
user: { id: uid },
|
||||
address: ILike(recentEmail.to),
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
)
|
||||
if (!newsletterEmail) {
|
||||
log.info('no newsletter email for', {
|
||||
id: recentEmail.id,
|
||||
to: recentEmail.to,
|
||||
from: recentEmail.from,
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [MarkEmailAsItemErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const success = await saveNewsletter(
|
||||
{
|
||||
from: recentEmail.from,
|
||||
email: recentEmail.to,
|
||||
title: recentEmail.subject,
|
||||
content: recentEmail.html,
|
||||
url: generateUniqueUrl(),
|
||||
author: parseEmailAddress(recentEmail.from).name,
|
||||
receivedEmailId: recentEmail.id,
|
||||
},
|
||||
newsletterEmail
|
||||
)
|
||||
if (!success) {
|
||||
log.info('newsletter not created', recentEmail.id)
|
||||
|
||||
return {
|
||||
errorCodes: [MarkEmailAsItemErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
// update received email type
|
||||
await updateReceivedEmail(recentEmail.id, 'article', uid)
|
||||
|
||||
const text = `A recent email marked as a library item
|
||||
by: ${uid}
|
||||
from: ${recentEmail.from}
|
||||
subject: ${recentEmail.subject}`
|
||||
|
||||
// email us to let us know that an email failed to parse as an article
|
||||
await sendEmail({
|
||||
to: env.sender.feedback,
|
||||
subject: 'A recent email marked as a library item',
|
||||
text,
|
||||
from: env.sender.message,
|
||||
})
|
||||
|
||||
return {
|
||||
success,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error marking email as item', error)
|
||||
|
||||
return {
|
||||
errorCodes: [MarkEmailAsItemErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
RecentSearchesError,
|
||||
RecentSearchesSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRecentSearches } from '../../services/search_history'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const recentSearchesResolver = authorized<
|
||||
RecentSearchesSuccess,
|
||||
RecentSearchesError
|
||||
>(async (_obj, _params) => {
|
||||
const searches = await getRecentSearches()
|
||||
return {
|
||||
searches,
|
||||
}
|
||||
})
|
||||
339
examples/omnivore/api/api/src/resolvers/recommendations/index.ts
Normal file
339
examples/omnivore/api/api/src/resolvers/recommendations/index.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { In } from 'typeorm'
|
||||
import { Group } from '../../entity/groups/group'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateGroupError,
|
||||
CreateGroupErrorCode,
|
||||
CreateGroupSuccess,
|
||||
GroupsError,
|
||||
GroupsErrorCode,
|
||||
GroupsSuccess,
|
||||
JoinGroupError,
|
||||
JoinGroupErrorCode,
|
||||
JoinGroupSuccess,
|
||||
LeaveGroupError,
|
||||
LeaveGroupErrorCode,
|
||||
LeaveGroupSuccess,
|
||||
MutationCreateGroupArgs,
|
||||
MutationJoinGroupArgs,
|
||||
MutationLeaveGroupArgs,
|
||||
MutationRecommendArgs,
|
||||
MutationRecommendHighlightsArgs,
|
||||
RecommendError,
|
||||
RecommendErrorCode,
|
||||
RecommendHighlightsError,
|
||||
RecommendHighlightsErrorCode,
|
||||
RecommendHighlightsSuccess,
|
||||
RecommendSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../repository'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import {
|
||||
createGroup,
|
||||
createLabelAndRuleForGroup,
|
||||
getGroupsWhereUserCanPost,
|
||||
getInviteUrl,
|
||||
getRecommendationGroups,
|
||||
joinGroup,
|
||||
leaveGroup,
|
||||
} from '../../services/groups'
|
||||
import { findLibraryItemById } from '../../services/library_item'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { enqueueRecommendation } from '../../utils/createTask'
|
||||
import { authorized, userDataToUser } from '../../utils/helpers'
|
||||
|
||||
export const createGroupResolver = authorized<
|
||||
CreateGroupSuccess,
|
||||
CreateGroupError,
|
||||
MutationCreateGroupArgs
|
||||
>(async (_, { input }, { uid, log }) => {
|
||||
try {
|
||||
const userData = await userRepository.findById(uid)
|
||||
if (!userData) {
|
||||
return {
|
||||
errorCodes: [CreateGroupErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const [group, invite] = await createGroup({
|
||||
admin: userData,
|
||||
name: input.name,
|
||||
maxMembers: input.maxMembers,
|
||||
expiresInDays: input.expiresInDays,
|
||||
description: input.description,
|
||||
topics: input.topics,
|
||||
onlyAdminCanPost: input.onlyAdminCanPost,
|
||||
onlyAdminCanSeeMembers: input.onlyAdminCanSeeMembers,
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'group_created',
|
||||
properties: {
|
||||
group_id: group.id,
|
||||
group_name: group.name,
|
||||
group_invite_code: invite.code,
|
||||
},
|
||||
})
|
||||
|
||||
await createLabelAndRuleForGroup(uid, group.name)
|
||||
|
||||
const inviteUrl = getInviteUrl(invite)
|
||||
const user = userDataToUser(userData)
|
||||
|
||||
return {
|
||||
group: {
|
||||
...group,
|
||||
inviteUrl,
|
||||
admins: [user],
|
||||
members: [user],
|
||||
canSeeMembers: true,
|
||||
canPost: true,
|
||||
description: group.description,
|
||||
topics: group.topics?.split(','),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error creating group', error)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateGroupErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const groupsResolver = authorized<GroupsSuccess, GroupsError>(
|
||||
async (_, __, { uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [GroupsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const groups = await getRecommendationGroups(user)
|
||||
|
||||
return {
|
||||
groups,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error getting groups', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'groupsResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [GroupsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const recommendResolver = authorized<
|
||||
RecommendSuccess,
|
||||
RecommendError,
|
||||
MutationRecommendArgs
|
||||
>(async (_, { input }, { uid, log, signToken }) => {
|
||||
try {
|
||||
const item = await findLibraryItemById(input.pageId, uid)
|
||||
if (!item) {
|
||||
return {
|
||||
errorCodes: [RecommendErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// find groups where id is in the groupIds and the user is a member of the group and the user is allowed to post
|
||||
const groups = await getGroupsWhereUserCanPost(uid, input.groupIds)
|
||||
if (groups.length === 0) {
|
||||
return {
|
||||
errorCodes: [RecommendErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// only recommend highlights created by the user
|
||||
const recommendedHighlightIds = input.recommendedWithHighlights
|
||||
? item.highlights?.filter((h) => h.user.id === uid)?.map((h) => h.id)
|
||||
: undefined
|
||||
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
|
||||
const auth = (await signToken({ uid, exp }, env.server.jwtSecret)) as string
|
||||
const taskNames = await Promise.all(
|
||||
groups
|
||||
.map((group) =>
|
||||
group.members.map((member) =>
|
||||
enqueueRecommendation(
|
||||
member.user.id,
|
||||
item.id,
|
||||
{
|
||||
group: { id: group.id },
|
||||
note: input.note,
|
||||
recommender: { id: uid },
|
||||
createdAt: new Date(),
|
||||
libraryItem: { id: item.id },
|
||||
},
|
||||
auth,
|
||||
recommendedHighlightIds
|
||||
)
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
)
|
||||
log.info('taskNames', taskNames)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error recommending', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RecommendErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const joinGroupResolver = authorized<
|
||||
JoinGroupSuccess,
|
||||
JoinGroupError,
|
||||
MutationJoinGroupArgs
|
||||
>(async (_, { inviteCode }, { uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [JoinGroupErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const group = await joinGroup(user, inviteCode)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'group_joined',
|
||||
properties: {
|
||||
group_id: group.id,
|
||||
group_name: group.name,
|
||||
},
|
||||
})
|
||||
|
||||
await createLabelAndRuleForGroup(user.id, group.name)
|
||||
|
||||
return {
|
||||
group,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error joining group', error)
|
||||
|
||||
return {
|
||||
errorCodes: [JoinGroupErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const recommendHighlightsResolver = authorized<
|
||||
RecommendHighlightsSuccess,
|
||||
RecommendHighlightsError,
|
||||
MutationRecommendHighlightsArgs
|
||||
>(async (_, { input }, { uid, log, signToken }) => {
|
||||
try {
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const groups = await getRepository(Group).find({
|
||||
where: { id: In(input.groupIds) },
|
||||
relations: ['members', 'members.user'],
|
||||
})
|
||||
if (groups.length === 0) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const item = await findLibraryItemById(input.pageId, uid)
|
||||
if (!item) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
|
||||
const auth = (await signToken({ uid, exp }, env.server.jwtSecret)) as string
|
||||
await Promise.all(
|
||||
groups
|
||||
.map((group) =>
|
||||
group.members
|
||||
.filter((member) => member.user.id !== uid)
|
||||
.map((member) =>
|
||||
enqueueRecommendation(
|
||||
member.user.id,
|
||||
item.id,
|
||||
{
|
||||
id: group.id,
|
||||
note: input.note,
|
||||
recommender: { id: uid },
|
||||
createdAt: new Date(),
|
||||
libraryItem: { id: item.id },
|
||||
},
|
||||
auth,
|
||||
input.highlightIds
|
||||
)
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error recommending highlights', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const leaveGroupResolver = authorized<
|
||||
LeaveGroupSuccess,
|
||||
LeaveGroupError,
|
||||
MutationLeaveGroupArgs
|
||||
>(async (_, { groupId }, { uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [LeaveGroupErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const success = await leaveGroup(user, groupId)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'group_left',
|
||||
properties: {
|
||||
group_id: groupId,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error leaving group', error)
|
||||
|
||||
return {
|
||||
errorCodes: [LeaveGroupErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
331
examples/omnivore/api/api/src/resolvers/reminders/index.ts
Normal file
331
examples/omnivore/api/api/src/resolvers/reminders/index.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
const validScheduleTime = (str: string): Date | undefined => {
|
||||
const scheduleTime = DateTime.fromISO(str, { setZone: true }).set({
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
})
|
||||
if (scheduleTime <= DateTime.now()) {
|
||||
return undefined
|
||||
}
|
||||
return scheduleTime.toJSDate()
|
||||
}
|
||||
|
||||
// export const createReminderResolver = authorized<
|
||||
// CreateReminderSuccess,
|
||||
// CreateReminderError,
|
||||
// MutationCreateReminderArgs
|
||||
// >(async (_, { input }, { models, claims: { uid }, log }) => {
|
||||
// log.info('createReminderResolver')
|
||||
|
||||
// const { clientRequestId, linkId, archiveUntil, sendNotification } = input
|
||||
|
||||
// const scheduledTime = validScheduleTime(input.remindAt)
|
||||
// if (!scheduledTime) {
|
||||
// log.error('Invalid scheduled time', input.remindAt)
|
||||
// return {
|
||||
// errorCodes: [CreateReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
|
||||
// const pageId = linkId || clientRequestId
|
||||
// if (!pageId) {
|
||||
// log.error('client request id or link id is required')
|
||||
// return {
|
||||
// errorCodes: [CreateReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
|
||||
// analytics.track({
|
||||
// userId: uid,
|
||||
// event: 'reminder_created',
|
||||
// properties: {
|
||||
// clientRequestId,
|
||||
// remindAt: scheduledTime,
|
||||
// archiveUntil,
|
||||
// sendNotification,
|
||||
// linkId,
|
||||
// env: env.server.apiEnv,
|
||||
// },
|
||||
// })
|
||||
|
||||
// try {
|
||||
// // saving from web
|
||||
// const page = await getPageById(pageId)
|
||||
// if (!page) {
|
||||
// log.error('page not found', pageId)
|
||||
|
||||
// return {
|
||||
// errorCodes: [CreateReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (page.userId !== uid) {
|
||||
// log.error('user not authorized', uid)
|
||||
|
||||
// return {
|
||||
// errorCodes: [CreateReminderErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
// if (archiveUntil) {
|
||||
// await archivePage(uid, page)
|
||||
// }
|
||||
|
||||
// const taskName = await groupReminders(scheduledTime, uid, models)
|
||||
// log.info('scheduled task name', taskName)
|
||||
|
||||
// // insert reminder to db
|
||||
// const reminder = await models.reminder.create({
|
||||
// userId: uid,
|
||||
// taskName: taskName,
|
||||
// archiveUntil: archiveUntil,
|
||||
// sendNotification: sendNotification,
|
||||
// createdAt: new Date(),
|
||||
// remindAt: scheduledTime,
|
||||
// elasticPageId: pageId,
|
||||
// })
|
||||
// log.info('created reminder', reminder)
|
||||
|
||||
// return {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil,
|
||||
// sendNotification,
|
||||
// remindAt: scheduledTime,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.info('error creating reminder', e)
|
||||
|
||||
// return {
|
||||
// errorCodes: [CreateReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// // Attempts to find a link and archive it if it exists.
|
||||
// // It is possible that the link has not been created
|
||||
// // yet if it is still in the saving process. In that
|
||||
// // case it will be archived when the link is created.
|
||||
// const archivePage = async (uid: string, page: Page) => {
|
||||
// try {
|
||||
// await setLinkArchived(uid, page.id, true)
|
||||
// } catch (e) {
|
||||
// logger.info('error archiving link', e)
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const reminderResolver = authorized<
|
||||
// ReminderSuccess,
|
||||
// ReminderError,
|
||||
// QueryReminderArgs
|
||||
// >(async (_, { linkId: pageId }, { models, claims: { uid }, log }) => {
|
||||
// log.info('reminderResolver')
|
||||
|
||||
// analytics.track({
|
||||
// userId: uid,
|
||||
// event: 'reminder',
|
||||
// properties: {
|
||||
// linkId: pageId,
|
||||
// env: env.server.apiEnv,
|
||||
// },
|
||||
// })
|
||||
|
||||
// try {
|
||||
// // get page from articleId
|
||||
// const page = await getPageById(pageId)
|
||||
// if (!page) {
|
||||
// return {
|
||||
// errorCodes: [ReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
// if (page.userId !== uid) {
|
||||
// return {
|
||||
// errorCodes: [ReminderErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
// const reminder = await models.reminder.getCreatedByParameters(uid, {
|
||||
// elasticPageId: page.id,
|
||||
// })
|
||||
|
||||
// if (!reminder) {
|
||||
// log.error('reminder not found: pageId: ', pageId)
|
||||
|
||||
// return {
|
||||
// errorCodes: [ReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// return {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil: reminder.archiveUntil || false,
|
||||
// sendNotification: reminder.sendNotification || true,
|
||||
// remindAt: reminder.remindAt,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.error(e)
|
||||
|
||||
// return {
|
||||
// errorCodes: [ReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// export const updateReminderResolver = authorized<
|
||||
// UpdateReminderSuccess,
|
||||
// UpdateReminderError,
|
||||
// MutationUpdateReminderArgs
|
||||
// >(async (_, { input }, { models, claims: { uid }, log, authTrx }) => {
|
||||
// log.info('updateReminderResolver')
|
||||
|
||||
// const { id, archiveUntil, sendNotification } = input
|
||||
|
||||
// const scheduledTime = validScheduleTime(input.remindAt)
|
||||
// if (!scheduledTime) {
|
||||
// log.error('Invalid scheduled time', input.remindAt)
|
||||
// return {
|
||||
// errorCodes: [UpdateReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
|
||||
// analytics.track({
|
||||
// userId: uid,
|
||||
// event: 'reminder_updated',
|
||||
// properties: {
|
||||
// id,
|
||||
// remindAt: scheduledTime,
|
||||
// archiveUntil,
|
||||
// sendNotification,
|
||||
// env: env.server.apiEnv,
|
||||
// },
|
||||
// })
|
||||
|
||||
// try {
|
||||
// const reminder = await models.reminder.getCreated(id)
|
||||
|
||||
// if (!reminder) {
|
||||
// log.error('reminder not found:', id)
|
||||
|
||||
// return {
|
||||
// errorCodes: [UpdateReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (reminder.userId !== uid) {
|
||||
// return {
|
||||
// errorCodes: [UpdateReminderErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
|
||||
// // delete old google cloud task
|
||||
// if (reminder.taskName) {
|
||||
// await deleteTask(reminder.taskName)
|
||||
// }
|
||||
|
||||
// const taskName = await groupReminders(scheduledTime, uid, models)
|
||||
|
||||
// // update db
|
||||
// await authTrx((tx) =>
|
||||
// models.reminder.update(
|
||||
// id,
|
||||
// {
|
||||
// taskName: taskName,
|
||||
// archiveUntil,
|
||||
// sendNotification,
|
||||
// remindAt: scheduledTime,
|
||||
// },
|
||||
// tx
|
||||
// )
|
||||
// )
|
||||
|
||||
// return {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil,
|
||||
// sendNotification,
|
||||
// remindAt: scheduledTime,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.error(e)
|
||||
|
||||
// return {
|
||||
// errorCodes: [UpdateReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// export const deleteReminderResolver = authorized<
|
||||
// DeleteReminderSuccess,
|
||||
// DeleteReminderError,
|
||||
// MutationDeleteReminderArgs
|
||||
// >(async (_, { id }, { models, claims: { uid }, log, authTrx }) => {
|
||||
// log.info('deleteReminderResolver')
|
||||
|
||||
// analytics.track({
|
||||
// userId: uid,
|
||||
// event: 'reminder_deleted',
|
||||
// properties: {
|
||||
// id: id,
|
||||
// env: env.server.apiEnv,
|
||||
// },
|
||||
// })
|
||||
|
||||
// try {
|
||||
// const reminder = await models.reminder.getCreated(id)
|
||||
|
||||
// if (!reminder) {
|
||||
// log.error('reminder not found:', id)
|
||||
|
||||
// return {
|
||||
// errorCodes: [DeleteReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (reminder.userId !== uid) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReminderErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
|
||||
// // update db
|
||||
// await authTrx((tx) => models.reminder.delete(id, tx))
|
||||
|
||||
// return {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil: reminder.archiveUntil || false,
|
||||
// sendNotification: reminder.sendNotification || true,
|
||||
// remindAt: reminder.remindAt,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.error(e)
|
||||
|
||||
// return {
|
||||
// errorCodes: [DeleteReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// // check if there exists reminders for the same user at the same time
|
||||
// // create a Google cloud task if no existing task and return task name
|
||||
// const groupReminders = async (
|
||||
// scheduledTime: Date,
|
||||
// userId: string,
|
||||
// models: DataModels
|
||||
// ): Promise<string | undefined> => {
|
||||
// const exists = await models.reminder.existByUserAndRemindAt(
|
||||
// userId,
|
||||
// scheduledTime
|
||||
// )
|
||||
|
||||
// if (!exists) {
|
||||
// return enqueueReminder(userId, scheduledTime.getTime())
|
||||
// }
|
||||
|
||||
// return undefined
|
||||
// }
|
||||
93
examples/omnivore/api/api/src/resolvers/report/index.ts
Normal file
93
examples/omnivore/api/api/src/resolvers/report/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import { WithDataSourcesContext } from '../types'
|
||||
import {
|
||||
MutationReportItemArgs,
|
||||
ReportItemResult,
|
||||
ReportType,
|
||||
ResolverFn,
|
||||
} from '../../generated/graphql'
|
||||
import {
|
||||
saveAbuseReport,
|
||||
saveContentDisplayReport,
|
||||
} from '../../services/reports'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { env } from '../../env'
|
||||
|
||||
const SUCCESS_MESSAGE = `Your report has been submitted. Thank you.`
|
||||
const FAILURE_MESSAGE =
|
||||
'There was an error submitting your report. If this issue persists please use email or the feedback tool.'
|
||||
|
||||
const isAbuseReport = (types: ReportType[]): boolean => {
|
||||
// If the list contains any types other than ReportType.Content
|
||||
// it is an abuse report.
|
||||
if (types.length == 1 && types[0] == ReportType.ContentDisplay) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const isContentDisplayReport = (types: ReportType[]): boolean => {
|
||||
if (types.length == 1 && types[0] == ReportType.ContentDisplay) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const reportItemResolver: ResolverFn<
|
||||
ReportItemResult,
|
||||
unknown,
|
||||
WithDataSourcesContext,
|
||||
MutationReportItemArgs
|
||||
> = async (_obj, args, ctx) => {
|
||||
const { sharedBy, reportTypes } = args.input
|
||||
|
||||
if (sharedBy && isAbuseReport(reportTypes)) {
|
||||
analytics.track({
|
||||
userId: sharedBy,
|
||||
event: 'report_created',
|
||||
properties: {
|
||||
type: 'abuse',
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
// SharedBy is nullable for some report types, but not for abuse reports
|
||||
// So we force it
|
||||
const uid = ctx.claims?.uid || ''
|
||||
const value = { sharedBy: uid, reportedBy: uid, ...args.input }
|
||||
|
||||
const report = await saveAbuseReport(uid, value)
|
||||
const message = report ? SUCCESS_MESSAGE : FAILURE_MESSAGE
|
||||
return {
|
||||
message: message,
|
||||
}
|
||||
} else if (isContentDisplayReport(reportTypes)) {
|
||||
// Content Display messages require a uid, since only users can see content
|
||||
const uid = ctx.claims?.uid
|
||||
if (!uid) {
|
||||
return {
|
||||
message: FAILURE_MESSAGE,
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'report_created',
|
||||
properties: {
|
||||
type: 'content',
|
||||
url: args.input.itemUrl,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const report = await saveContentDisplayReport(uid, args.input)
|
||||
const message = report ? SUCCESS_MESSAGE : FAILURE_MESSAGE
|
||||
return {
|
||||
message: message,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: FAILURE_MESSAGE,
|
||||
}
|
||||
}
|
||||
88
examples/omnivore/api/api/src/resolvers/rules/index.ts
Normal file
88
examples/omnivore/api/api/src/resolvers/rules/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Rule } from '../../entity/rule'
|
||||
import {
|
||||
DeleteRuleError,
|
||||
DeleteRuleErrorCode,
|
||||
DeleteRuleSuccess,
|
||||
MutationDeleteRuleArgs,
|
||||
MutationSetRuleArgs,
|
||||
QueryRulesArgs,
|
||||
RulesError,
|
||||
RulesErrorCode,
|
||||
RulesSuccess,
|
||||
SetRuleError,
|
||||
SetRuleErrorCode,
|
||||
SetRuleSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { deleteRule } from '../../services/rules'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const setRuleResolver = authorized<
|
||||
SetRuleSuccess,
|
||||
SetRuleError,
|
||||
MutationSetRuleArgs
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const rule = await authTrx((t) =>
|
||||
t.getRepository(Rule).save({
|
||||
...input,
|
||||
id: input.id || undefined,
|
||||
user: { id: uid },
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
rule,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error setting rules', error)
|
||||
|
||||
return {
|
||||
errorCodes: [SetRuleErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const rulesResolver = authorized<
|
||||
RulesSuccess,
|
||||
RulesError,
|
||||
QueryRulesArgs
|
||||
>(async (_, { enabled }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const rules = await authTrx((t) =>
|
||||
t.getRepository(Rule).findBy({
|
||||
user: { id: uid },
|
||||
enabled: enabled === null ? undefined : enabled,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
rules,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error getting rules', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RulesErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteRuleResolver = authorized<
|
||||
DeleteRuleSuccess,
|
||||
DeleteRuleError,
|
||||
MutationDeleteRuleArgs
|
||||
>(async (_, { id }, { uid, log }) => {
|
||||
try {
|
||||
const rule = await deleteRule(id, uid)
|
||||
|
||||
return {
|
||||
rule,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error deleting rule', error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteRuleErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
})
|
||||
87
examples/omnivore/api/api/src/resolvers/save/index.ts
Normal file
87
examples/omnivore/api/api/src/resolvers/save/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MutationSaveFileArgs,
|
||||
MutationSavePageArgs,
|
||||
MutationSaveUrlArgs,
|
||||
SaveError,
|
||||
SaveErrorCode,
|
||||
SaveSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { saveFile } from '../../services/save_file'
|
||||
import { savePage } from '../../services/save_page'
|
||||
import { saveUrl } from '../../services/save_url'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const savePageResolver = authorized<
|
||||
SaveSuccess,
|
||||
SaveError,
|
||||
MutationSavePageArgs
|
||||
>(async (_, { input }, { uid }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: input.url,
|
||||
method: 'page',
|
||||
source: input.source,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
return savePage(input, user)
|
||||
})
|
||||
|
||||
export const saveUrlResolver = authorized<
|
||||
SaveSuccess,
|
||||
SaveError,
|
||||
MutationSaveUrlArgs
|
||||
>(async (_, { input }, { uid }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: input.url,
|
||||
method: 'url',
|
||||
source: input.source,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
return saveUrl(input, user)
|
||||
})
|
||||
|
||||
export const saveFileResolver = authorized<
|
||||
SaveSuccess,
|
||||
SaveError,
|
||||
MutationSaveFileArgs
|
||||
>(async (_, { input }, { uid }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: input.url,
|
||||
method: 'file',
|
||||
source: input.source,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
return saveFile(input, user)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
SendInstallInstructionsError,
|
||||
SendInstallInstructionsErrorCode,
|
||||
SendInstallInstructionsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { sendEmail } from '../../utils/sendEmail'
|
||||
|
||||
const INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID =
|
||||
'd-c576bdc3b9a849dab250655ba14c7794'
|
||||
|
||||
export const sendInstallInstructionsResolver = authorized<
|
||||
SendInstallInstructionsSuccess,
|
||||
SendInstallInstructionsError
|
||||
>(async (_parent, _args, { uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findById(uid)
|
||||
|
||||
if (!user) {
|
||||
return { errorCodes: [SendInstallInstructionsErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
const sendInstallInstructions = await sendEmail({
|
||||
from: env.sender.message,
|
||||
templateId:
|
||||
env.sendgrid.installationTemplateId ||
|
||||
INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID,
|
||||
to: user?.email,
|
||||
})
|
||||
|
||||
return {
|
||||
sent: sendInstallInstructions,
|
||||
}
|
||||
} catch (e) {
|
||||
log.info(e)
|
||||
|
||||
return {
|
||||
errorCodes: [SendInstallInstructionsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
494
examples/omnivore/api/api/src/resolvers/subscriptions/index.ts
Normal file
494
examples/omnivore/api/api/src/resolvers/subscriptions/index.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import axios from 'axios'
|
||||
import { parseHTML } from 'linkedom'
|
||||
import { Brackets } from 'typeorm'
|
||||
import { Subscription } from '../../entity/subscription'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
FeedEdge,
|
||||
FeedsError,
|
||||
FeedsErrorCode,
|
||||
FeedsSuccess,
|
||||
MutationSubscribeArgs,
|
||||
MutationUnsubscribeArgs,
|
||||
MutationUpdateSubscriptionArgs,
|
||||
QueryFeedsArgs,
|
||||
QueryScanFeedsArgs,
|
||||
QuerySubscriptionsArgs,
|
||||
ScanFeedsError,
|
||||
ScanFeedsErrorCode,
|
||||
ScanFeedsSuccess,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
SubscribeError,
|
||||
SubscribeErrorCode,
|
||||
SubscribeSuccess,
|
||||
SubscriptionsError,
|
||||
SubscriptionsErrorCode,
|
||||
SubscriptionsSuccess,
|
||||
SubscriptionStatus,
|
||||
SubscriptionType,
|
||||
UnsubscribeError,
|
||||
UnsubscribeErrorCode,
|
||||
UnsubscribeSuccess,
|
||||
UpdateSubscriptionError,
|
||||
UpdateSubscriptionErrorCode,
|
||||
UpdateSubscriptionSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../repository'
|
||||
import { feedRepository } from '../../repository/feed'
|
||||
import { unsubscribe } from '../../services/subscriptions'
|
||||
import { Merge } from '../../util'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { enqueueRssFeedFetch } from '../../utils/createTask'
|
||||
import {
|
||||
authorized,
|
||||
getAbsoluteUrl,
|
||||
keysToCamelCase,
|
||||
} from '../../utils/helpers'
|
||||
import { parseFeed, parseOpml, RSS_PARSER_CONFIG } from '../../utils/parser'
|
||||
|
||||
type PartialSubscription = Omit<Subscription, 'newsletterEmail'>
|
||||
|
||||
export type SubscriptionsSuccessPartial = Merge<
|
||||
SubscriptionsSuccess,
|
||||
{ subscriptions: PartialSubscription[] }
|
||||
>
|
||||
export const subscriptionsResolver = authorized<
|
||||
SubscriptionsSuccessPartial,
|
||||
SubscriptionsError,
|
||||
QuerySubscriptionsArgs
|
||||
>(async (_obj, { sort, type }, { uid, log }) => {
|
||||
try {
|
||||
const sortBy =
|
||||
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
|
||||
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
|
||||
|
||||
const queryBuilder = getRepository(Subscription)
|
||||
.createQueryBuilder('subscription')
|
||||
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
|
||||
.where({
|
||||
user: { id: uid },
|
||||
})
|
||||
|
||||
if (type && type == SubscriptionType.Newsletter) {
|
||||
queryBuilder.andWhere({
|
||||
type,
|
||||
status: SubscriptionStatus.Active,
|
||||
})
|
||||
} else if (type && type == SubscriptionType.Rss) {
|
||||
queryBuilder.andWhere({
|
||||
type,
|
||||
})
|
||||
} else {
|
||||
queryBuilder.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where({
|
||||
type: SubscriptionType.Newsletter,
|
||||
status: SubscriptionStatus.Active,
|
||||
}).orWhere({
|
||||
type: SubscriptionType.Rss,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const subscriptions = await queryBuilder
|
||||
.orderBy('subscription.status', 'ASC')
|
||||
.addOrderBy(`subscription.${sortBy}`, sortOrder, 'NULLS LAST')
|
||||
.getMany()
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return {
|
||||
errorCodes: [SubscriptionsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type UnsubscribeSuccessPartial = Merge<
|
||||
UnsubscribeSuccess,
|
||||
{ subscription: PartialSubscription }
|
||||
>
|
||||
export const unsubscribeResolver = authorized<
|
||||
UnsubscribeSuccessPartial,
|
||||
UnsubscribeError,
|
||||
MutationUnsubscribeArgs
|
||||
>(async (_, { name, subscriptionId }, { uid, log }) => {
|
||||
log.info('unsubscribeResolver')
|
||||
|
||||
try {
|
||||
const queryBuilder = getRepository(Subscription)
|
||||
.createQueryBuilder('subscription')
|
||||
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
|
||||
.where({ user: { id: uid } })
|
||||
|
||||
if (subscriptionId) {
|
||||
// if subscriptionId is provided, ignore name
|
||||
queryBuilder.andWhere({ id: subscriptionId })
|
||||
} else {
|
||||
// if subscriptionId is not provided, use name for old clients
|
||||
queryBuilder.andWhere({ name })
|
||||
}
|
||||
|
||||
const subscription = await queryBuilder.getOne()
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
errorCodes: [UnsubscribeErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
subscription.type === SubscriptionType.Newsletter &&
|
||||
!subscription.unsubscribeMailTo &&
|
||||
!subscription.unsubscribeHttpUrl
|
||||
) {
|
||||
log.info('No unsubscribe method found for newsletter subscription')
|
||||
}
|
||||
|
||||
await unsubscribe(subscription)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'unsubscribed',
|
||||
properties: {
|
||||
name,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
subscription,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('failed to unsubscribe', error)
|
||||
return {
|
||||
errorCodes: [UnsubscribeErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type SubscribeSuccessPartial = Merge<
|
||||
SubscribeSuccess,
|
||||
{ subscriptions: PartialSubscription[] }
|
||||
>
|
||||
export const subscribeResolver = authorized<
|
||||
SubscribeSuccessPartial,
|
||||
SubscribeError,
|
||||
MutationSubscribeArgs
|
||||
>(async (_, { input }, { uid, log }) => {
|
||||
try {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'subscribed',
|
||||
properties: {
|
||||
...input,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
// find existing subscription
|
||||
const existingSubscription = await getRepository(Subscription).findOneBy({
|
||||
url: input.url,
|
||||
user: { id: uid },
|
||||
type: SubscriptionType.Rss,
|
||||
})
|
||||
if (existingSubscription) {
|
||||
if (existingSubscription.status === SubscriptionStatus.Active) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.AlreadySubscribed],
|
||||
}
|
||||
}
|
||||
|
||||
// re-subscribe
|
||||
const updatedSubscription = await getRepository(Subscription).save({
|
||||
...existingSubscription,
|
||||
status: SubscriptionStatus.Active,
|
||||
})
|
||||
|
||||
// create a cloud task to fetch rss feed item for resub subscription
|
||||
await enqueueRssFeedFetch({
|
||||
userIds: [uid],
|
||||
url: input.url,
|
||||
subscriptionIds: [updatedSubscription.id],
|
||||
scheduledDates: [new Date()], // fetch immediately
|
||||
fetchedDates: [updatedSubscription.lastFetchedAt || null],
|
||||
checksums: [updatedSubscription.lastFetchedChecksum || null],
|
||||
addToLibraryFlags: [!!updatedSubscription.autoAddToLibrary],
|
||||
})
|
||||
|
||||
return {
|
||||
subscriptions: [updatedSubscription],
|
||||
}
|
||||
}
|
||||
|
||||
// create new rss subscription
|
||||
const MAX_RSS_SUBSCRIPTIONS = env.subscription.feed.max
|
||||
// validate rss feed
|
||||
const feed = await parseFeed(input.url)
|
||||
if (!feed) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// limit number of rss subscriptions to max
|
||||
const results = (await getRepository(Subscription).query(
|
||||
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon, auto_add_to_library, is_private)
|
||||
select $1, $2, $3, $4, $5, $6, $7, $8 from omnivore.subscriptions
|
||||
where user_id = $5 and type = 'RSS' and status = 'ACTIVE'
|
||||
having count(*) < $9
|
||||
returning *;`,
|
||||
[
|
||||
feed.title,
|
||||
feed.url,
|
||||
feed.description || null,
|
||||
SubscriptionType.Rss,
|
||||
uid,
|
||||
feed.thumbnail || null,
|
||||
input.autoAddToLibrary ?? null,
|
||||
input.isPrivate ?? null,
|
||||
MAX_RSS_SUBSCRIPTIONS,
|
||||
]
|
||||
)) as any[]
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.ExceededMaxSubscriptions],
|
||||
}
|
||||
}
|
||||
|
||||
// convert to camel case
|
||||
const newSubscription = keysToCamelCase(results[0]) as Subscription
|
||||
|
||||
// create a cloud task to fetch rss feed item for the new subscription
|
||||
await enqueueRssFeedFetch({
|
||||
userIds: [uid],
|
||||
url: feed.url,
|
||||
subscriptionIds: [newSubscription.id],
|
||||
scheduledDates: [new Date()], // fetch immediately
|
||||
fetchedDates: [null],
|
||||
checksums: [null],
|
||||
addToLibraryFlags: [!!newSubscription.autoAddToLibrary],
|
||||
})
|
||||
|
||||
return {
|
||||
subscriptions: [newSubscription],
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('failed to subscribe', error)
|
||||
if (error instanceof Error && error.message === 'Status code 404') {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type UpdateSubscriptionSuccessPartial = Merge<
|
||||
UpdateSubscriptionSuccess,
|
||||
{ subscription: PartialSubscription }
|
||||
>
|
||||
export const updateSubscriptionResolver = authorized<
|
||||
UpdateSubscriptionSuccessPartial,
|
||||
UpdateSubscriptionError,
|
||||
MutationUpdateSubscriptionArgs
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'update_subscription',
|
||||
properties: {
|
||||
...input,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const updatedSubscription = await authTrx(async (t) => {
|
||||
const repo = t.getRepository(Subscription)
|
||||
|
||||
// update subscription
|
||||
await t.getRepository(Subscription).save({
|
||||
id: input.id,
|
||||
name: input.name || undefined,
|
||||
description: input.description || undefined,
|
||||
lastFetchedAt: input.lastFetchedAt
|
||||
? new Date(input.lastFetchedAt)
|
||||
: undefined,
|
||||
lastFetchedChecksum: input.lastFetchedChecksum || undefined,
|
||||
status: input.status || undefined,
|
||||
scheduledAt: input.scheduledAt
|
||||
? new Date(input.scheduledAt)
|
||||
: undefined,
|
||||
autoAddToLibrary: input.autoAddToLibrary ?? undefined,
|
||||
isPrivate: input.isPrivate ?? undefined,
|
||||
})
|
||||
|
||||
return repo.findOneByOrFail({
|
||||
id: input.id,
|
||||
user: { id: uid },
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
subscription: updatedSubscription,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('failed to update subscription', error)
|
||||
return {
|
||||
errorCodes: [UpdateSubscriptionErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const feedsResolver = authorized<
|
||||
FeedsSuccess,
|
||||
FeedsError,
|
||||
QueryFeedsArgs
|
||||
>(async (_, { input }, { log }) => {
|
||||
try {
|
||||
const startCursor = input.after || ''
|
||||
const start =
|
||||
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
|
||||
const first = Math.min(input.first || 10, 100) // cap at 100
|
||||
|
||||
const { feeds, count } = await feedRepository.searchFeeds(
|
||||
input.query || '',
|
||||
first + 1, // fetch one extra to check if there is a next page
|
||||
start,
|
||||
input.sort?.by,
|
||||
input.sort?.order || undefined
|
||||
)
|
||||
|
||||
const hasNextPage = feeds.length > first
|
||||
const endCursor = String(start + feeds.length - (hasNextPage ? 1 : 0))
|
||||
|
||||
if (hasNextPage) {
|
||||
// remove an extra if exists
|
||||
feeds.pop()
|
||||
}
|
||||
|
||||
const edges: FeedEdge[] = feeds.map((feed) => ({
|
||||
node: feed,
|
||||
cursor: endCursor,
|
||||
}))
|
||||
|
||||
return {
|
||||
__typename: 'FeedsSuccess',
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasPreviousPage: start > 0,
|
||||
hasNextPage,
|
||||
startCursor,
|
||||
endCursor,
|
||||
totalCount: count,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error fetching feeds', error)
|
||||
|
||||
return {
|
||||
errorCodes: [FeedsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const scanFeedsResolver = authorized<
|
||||
ScanFeedsSuccess,
|
||||
ScanFeedsError,
|
||||
QueryScanFeedsArgs
|
||||
>(async (_, { input: { opml, url } }, { log, uid }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'scan_feeds',
|
||||
properties: {
|
||||
opml,
|
||||
url,
|
||||
},
|
||||
})
|
||||
|
||||
if (opml) {
|
||||
// parse opml
|
||||
const feeds = parseOpml(opml)
|
||||
if (!feeds) {
|
||||
return {
|
||||
errorCodes: [ScanFeedsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
feeds: feeds.map((feed) => ({
|
||||
url: feed.url,
|
||||
title: feed.title,
|
||||
type: feed.type || 'rss',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
log.error('Missing opml and url')
|
||||
|
||||
return {
|
||||
errorCodes: [ScanFeedsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// fetch page content and parse feeds
|
||||
const response = await axios.get(url, RSS_PARSER_CONFIG)
|
||||
const content = response.data as string
|
||||
// check if the content is html or xml
|
||||
const contentType = response.headers['Content-Type']
|
||||
const isHtml = contentType?.includes('text/html')
|
||||
if (isHtml) {
|
||||
// this is an html page, parse rss feed links
|
||||
const dom = parseHTML(content).document
|
||||
// type is application/rss+xml or application/atom+xml
|
||||
const links = dom.querySelectorAll(
|
||||
'link[type="application/rss+xml"], link[type="application/atom+xml"]'
|
||||
)
|
||||
|
||||
const feeds = Array.from(links)
|
||||
.map((link) => {
|
||||
const href = link.getAttribute('href') || ''
|
||||
const feedUrl = getAbsoluteUrl(href, url)
|
||||
|
||||
return {
|
||||
url: feedUrl,
|
||||
title: link.getAttribute('title') || '',
|
||||
type: 'rss',
|
||||
}
|
||||
})
|
||||
.filter((feed) => feed.url)
|
||||
|
||||
return {
|
||||
feeds,
|
||||
}
|
||||
}
|
||||
|
||||
// this is the url to an RSS feed
|
||||
const feed = await parseFeed(url, content)
|
||||
if (!feed) {
|
||||
log.error('Failed to parse RSS feed')
|
||||
return {
|
||||
feeds: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
feeds: [feed],
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error scanning URL', error)
|
||||
|
||||
return {
|
||||
errorCodes: [ScanFeedsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
46
examples/omnivore/api/api/src/resolvers/types.ts
Normal file
46
examples/omnivore/api/api/src/resolvers/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import { Span } from '@opentelemetry/api'
|
||||
import { Context as ApolloContext } from 'apollo-server-core'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { EntityManager } from 'typeorm'
|
||||
import winston from 'winston'
|
||||
import { PubsubClient } from '../pubsub'
|
||||
|
||||
export interface Claims {
|
||||
uid: string
|
||||
iat: number
|
||||
userRole?: string
|
||||
scope?: string // scope is used for api key like page:search
|
||||
exp?: number
|
||||
email?: string
|
||||
system?: boolean
|
||||
}
|
||||
|
||||
export type ClaimsToSet = {
|
||||
uid: string
|
||||
userRole?: string | null
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
log: winston.Logger
|
||||
claims: Claims | undefined
|
||||
pubsub: PubsubClient
|
||||
setAuth: (claims: ClaimsToSet, secret?: string) => Promise<void>
|
||||
clearAuth: () => void
|
||||
setClaims: (em: EntityManager, uuid?: string | undefined) => Promise<void>
|
||||
signToken: (
|
||||
arg1: string | object | Buffer,
|
||||
arg2: jwt.Secret
|
||||
) => Promise<unknown>
|
||||
authTrx: <TResult>(
|
||||
cb: (em: EntityManager) => TResult,
|
||||
userRole?: string
|
||||
) => Promise<TResult>
|
||||
tracingSpan: Span
|
||||
}
|
||||
|
||||
export type ResolverContext = ApolloContext<RequestContext>
|
||||
|
||||
export type WithDataSourcesContext = {
|
||||
uid: string
|
||||
} & ResolverContext
|
||||
33
examples/omnivore/api/api/src/resolvers/update/index.ts
Normal file
33
examples/omnivore/api/api/src/resolvers/update/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { LibraryItemState } from '../../entity/library_item'
|
||||
import {
|
||||
MutationUpdatePageArgs,
|
||||
UpdatePageError,
|
||||
UpdatePageSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { updateLibraryItem } from '../../services/library_item'
|
||||
import { authorized, libraryItemToArticle } from '../../utils/helpers'
|
||||
|
||||
export const updatePageResolver = authorized<
|
||||
UpdatePageSuccess,
|
||||
UpdatePageError,
|
||||
MutationUpdatePageArgs
|
||||
>(async (_, { input }, { uid }) => {
|
||||
const updatedPage = await updateLibraryItem(
|
||||
input.pageId,
|
||||
{
|
||||
title: input.title ?? undefined,
|
||||
description: input.description ?? undefined,
|
||||
author: input.byline ?? undefined,
|
||||
savedAt: input.savedAt ? new Date(input.savedAt) : undefined,
|
||||
publishedAt: input.publishedAt ? new Date(input.publishedAt) : undefined,
|
||||
thumbnail: input.previewImage ?? undefined,
|
||||
state: input.state
|
||||
? (input.state as unknown as LibraryItemState)
|
||||
: undefined,
|
||||
},
|
||||
uid
|
||||
)
|
||||
return {
|
||||
updatedPage: libraryItemToArticle(updatedPage),
|
||||
}
|
||||
})
|
||||
168
examples/omnivore/api/api/src/resolvers/upload_files/index.ts
Normal file
168
examples/omnivore/api/api/src/resolvers/upload_files/index.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import normalizeUrl from 'normalize-url'
|
||||
import path from 'path'
|
||||
import { LibraryItemState } from '../../entity/library_item'
|
||||
import { UploadFile } from '../../entity/upload_file'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MutationUploadFileRequestArgs,
|
||||
PageType,
|
||||
UploadFileRequestError,
|
||||
UploadFileRequestErrorCode,
|
||||
UploadFileRequestSuccess,
|
||||
UploadFileStatus,
|
||||
} from '../../generated/graphql'
|
||||
import { validateUrl } from '../../services/create_page_save_request'
|
||||
import {
|
||||
createLibraryItem,
|
||||
findLibraryItemByUrl,
|
||||
updateLibraryItem,
|
||||
} from '../../services/library_item'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized, generateSlug } from '../../utils/helpers'
|
||||
import {
|
||||
contentReaderForLibraryItem,
|
||||
generateUploadFilePathName,
|
||||
generateUploadSignedUrl,
|
||||
} from '../../utils/uploads'
|
||||
|
||||
const isFileUrl = (url: string): boolean => {
|
||||
const parsedUrl = new URL(url)
|
||||
return parsedUrl.protocol == 'file:'
|
||||
}
|
||||
|
||||
export const itemTypeForContentType = (contentType: string) => {
|
||||
if (contentType == 'application/epub+zip') {
|
||||
return PageType.Book
|
||||
}
|
||||
return PageType.File
|
||||
}
|
||||
|
||||
export const uploadFileRequestResolver = authorized<
|
||||
UploadFileRequestSuccess,
|
||||
UploadFileRequestError,
|
||||
MutationUploadFileRequestArgs
|
||||
>(async (_, { input }, ctx) => {
|
||||
const { authTrx, uid, log } = ctx
|
||||
let uploadFileData: { id: string | null } = {
|
||||
id: null,
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'file_upload_request',
|
||||
properties: {
|
||||
url: input.url,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
let title: string
|
||||
let fileName: string
|
||||
try {
|
||||
const url = normalizeUrl(new URL(input.url).href, {
|
||||
stripHash: true,
|
||||
stripWWW: false,
|
||||
})
|
||||
title = decodeURI(path.basename(new URL(url).pathname, '.pdf'))
|
||||
fileName = decodeURI(path.basename(new URL(url).pathname)).replace(
|
||||
/[^a-zA-Z0-9-_.]/g,
|
||||
''
|
||||
)
|
||||
|
||||
if (!fileName) {
|
||||
fileName = 'content.pdf'
|
||||
}
|
||||
|
||||
if (!isFileUrl(url)) {
|
||||
try {
|
||||
validateUrl(url)
|
||||
} catch (error) {
|
||||
log.info('illegal file input url', error)
|
||||
return {
|
||||
errorCodes: [UploadFileRequestErrorCode.BadInput],
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { errorCodes: [UploadFileRequestErrorCode.BadInput] }
|
||||
}
|
||||
|
||||
uploadFileData = await authTrx((t) =>
|
||||
t.getRepository(UploadFile).save({
|
||||
url: input.url,
|
||||
user: { id: uid },
|
||||
fileName,
|
||||
status: UploadFileStatus.Initialized,
|
||||
contentType: input.contentType,
|
||||
})
|
||||
)
|
||||
|
||||
if (uploadFileData.id) {
|
||||
const uploadFileId = uploadFileData.id
|
||||
const uploadFilePathName = generateUploadFilePathName(
|
||||
uploadFileId,
|
||||
fileName
|
||||
)
|
||||
const uploadSignedUrl = await generateUploadSignedUrl(
|
||||
uploadFilePathName,
|
||||
input.contentType
|
||||
)
|
||||
|
||||
// If this is a file URL, we swap in a special URL
|
||||
const attachmentUrl = `https://omnivore.app/attachments/${uploadFilePathName}`
|
||||
if (isFileUrl(input.url)) {
|
||||
await authTrx(async (tx) => {
|
||||
await tx.getRepository(UploadFile).update(uploadFileId, {
|
||||
url: attachmentUrl,
|
||||
status: UploadFileStatus.Initialized,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let createdItemId: string | undefined = undefined
|
||||
if (input.createPageEntry) {
|
||||
// If we have a file:// URL, don't try to match it
|
||||
// and create a copy of the item, just create a
|
||||
// new item.
|
||||
const item = await findLibraryItemByUrl(input.url, uid)
|
||||
if (item) {
|
||||
await updateLibraryItem(
|
||||
item.id,
|
||||
{
|
||||
state: LibraryItemState.Processing,
|
||||
},
|
||||
uid
|
||||
)
|
||||
createdItemId = item.id
|
||||
} else {
|
||||
const itemType = itemTypeForContentType(input.contentType)
|
||||
const uploadFileId = uploadFileData.id
|
||||
const item = await createLibraryItem(
|
||||
{
|
||||
id: input.clientRequestId || undefined,
|
||||
originalUrl: isFileUrl(input.url) ? attachmentUrl : input.url,
|
||||
user: { id: uid },
|
||||
title,
|
||||
readableContent: '',
|
||||
itemType,
|
||||
uploadFile: { id: uploadFileData.id },
|
||||
slug: generateSlug(uploadFilePathName),
|
||||
state: LibraryItemState.Processing,
|
||||
contentReader: contentReaderForLibraryItem(itemType, uploadFileId),
|
||||
},
|
||||
uid
|
||||
)
|
||||
createdItemId = item.id
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: uploadFileData.id,
|
||||
uploadSignedUrl,
|
||||
createdPageId: createdItemId,
|
||||
}
|
||||
} else {
|
||||
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
|
||||
}
|
||||
})
|
||||
380
examples/omnivore/api/api/src/resolvers/user/index.ts
Normal file
380
examples/omnivore/api/api/src/resolvers/user/index.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import {
|
||||
RegistrationType,
|
||||
StatusType,
|
||||
User as UserEntity,
|
||||
} from '../../entity/user'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteAccountError,
|
||||
DeleteAccountErrorCode,
|
||||
DeleteAccountSuccess,
|
||||
GoogleSignupResult,
|
||||
LoginErrorCode,
|
||||
LoginResult,
|
||||
LogOutErrorCode,
|
||||
LogOutResult,
|
||||
MutationDeleteAccountArgs,
|
||||
MutationGoogleLoginArgs,
|
||||
MutationGoogleSignupArgs,
|
||||
MutationUpdateEmailArgs,
|
||||
MutationUpdateUserArgs,
|
||||
MutationUpdateUserProfileArgs,
|
||||
QueryUserArgs,
|
||||
QueryValidateUsernameArgs,
|
||||
ResolverFn,
|
||||
SignupErrorCode,
|
||||
UpdateEmailError,
|
||||
UpdateEmailErrorCode,
|
||||
UpdateEmailSuccess,
|
||||
UpdateUserError,
|
||||
UpdateUserErrorCode,
|
||||
UpdateUserProfileError,
|
||||
UpdateUserProfileErrorCode,
|
||||
UpdateUserProfileSuccess,
|
||||
UpdateUserSuccess,
|
||||
User,
|
||||
UserErrorCode,
|
||||
UserResult,
|
||||
UsersError,
|
||||
UsersSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { createUser } from '../../services/create_user'
|
||||
import { sendVerificationEmail } from '../../services/send_emails'
|
||||
import { updateUser } from '../../services/user'
|
||||
import { authorized, userDataToUser } from '../../utils/helpers'
|
||||
import { validateUsername } from '../../utils/usernamePolicy'
|
||||
import { WithDataSourcesContext } from '../types'
|
||||
|
||||
export const updateUserResolver = authorized<
|
||||
UpdateUserSuccess,
|
||||
UpdateUserError,
|
||||
MutationUpdateUserArgs
|
||||
>(async (_, { input: { name, bio } }, { uid, authTrx }) => {
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [UpdateUserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
||||
const errorCodes = []
|
||||
|
||||
if (!name) {
|
||||
errorCodes.push(UpdateUserErrorCode.EmptyName)
|
||||
}
|
||||
|
||||
if (bio && bio.length > 400) {
|
||||
errorCodes.push(UpdateUserErrorCode.BioTooLong)
|
||||
}
|
||||
|
||||
if (errorCodes.length > 0) {
|
||||
return { errorCodes }
|
||||
}
|
||||
|
||||
const updatedUser = await authTrx((tx) =>
|
||||
tx.getRepository(UserEntity).save({
|
||||
...user,
|
||||
name,
|
||||
profile: {
|
||||
...user.profile,
|
||||
bio,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return { user: userDataToUser(updatedUser) }
|
||||
})
|
||||
|
||||
export const updateUserProfileResolver = authorized<
|
||||
UpdateUserProfileSuccess,
|
||||
UpdateUserProfileError,
|
||||
MutationUpdateUserProfileArgs
|
||||
>(async (_, { input: { userId, username, pictureUrl } }, { uid, authTrx }) => {
|
||||
const user = await userRepository.findById(userId)
|
||||
if (!user) {
|
||||
return { errorCodes: [UpdateUserProfileErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
if (user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
|
||||
if (!(username || pictureUrl)) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.BadData],
|
||||
}
|
||||
}
|
||||
|
||||
const lowerCasedUsername = username?.toLowerCase()
|
||||
if (lowerCasedUsername) {
|
||||
const existingUser = await userRepository.findOneBy({
|
||||
profile: {
|
||||
username: lowerCasedUsername,
|
||||
},
|
||||
status: StatusType.Active,
|
||||
})
|
||||
if (existingUser?.id) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.UsernameExists],
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateUsername(lowerCasedUsername)) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.BadUsername],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await authTrx((tx) =>
|
||||
tx.getRepository(UserEntity).save({
|
||||
...user,
|
||||
profile: {
|
||||
...user.profile,
|
||||
username: lowerCasedUsername,
|
||||
pictureUrl,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return { user: userDataToUser(updatedUser) }
|
||||
})
|
||||
|
||||
export const googleLoginResolver: ResolverFn<
|
||||
LoginResult,
|
||||
unknown,
|
||||
WithDataSourcesContext,
|
||||
MutationGoogleLoginArgs
|
||||
> = async (_obj, { input }, { setAuth }) => {
|
||||
const { email, secret } = input
|
||||
|
||||
try {
|
||||
jwt.verify(secret, env.server.jwtSecret)
|
||||
} catch {
|
||||
return { errorCodes: [LoginErrorCode.AuthFailed] }
|
||||
}
|
||||
|
||||
const user = await userRepository.findOneBy({
|
||||
email,
|
||||
status: StatusType.Active,
|
||||
})
|
||||
if (!user?.id) {
|
||||
return { errorCodes: [LoginErrorCode.UserNotFound] }
|
||||
}
|
||||
|
||||
// set auth cookie in response header
|
||||
await setAuth({ uid: user.id })
|
||||
return { me: userDataToUser(user) }
|
||||
}
|
||||
|
||||
export const validateUsernameResolver: ResolverFn<
|
||||
boolean,
|
||||
Record<string, unknown>,
|
||||
WithDataSourcesContext,
|
||||
QueryValidateUsernameArgs
|
||||
> = async (_obj, { username }) => {
|
||||
const lowerCasedUsername = username.toLowerCase()
|
||||
if (!validateUsername(lowerCasedUsername)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const user = await userRepository.findOneBy({
|
||||
profile: {
|
||||
username: lowerCasedUsername,
|
||||
},
|
||||
})
|
||||
return !user
|
||||
}
|
||||
|
||||
export const googleSignupResolver: ResolverFn<
|
||||
GoogleSignupResult,
|
||||
Record<string, unknown>,
|
||||
WithDataSourcesContext,
|
||||
MutationGoogleSignupArgs
|
||||
> = async (_obj, { input }, { setAuth, log }) => {
|
||||
const { email, username, name, bio, sourceUserId, pictureUrl, secret } = input
|
||||
const lowerCasedUsername = username.toLowerCase()
|
||||
|
||||
try {
|
||||
jwt.verify(secret, env.server.jwtSecret)
|
||||
} catch {
|
||||
return { errorCodes: [SignupErrorCode.ExpiredToken] }
|
||||
}
|
||||
|
||||
try {
|
||||
const [user, profile] = await createUser({
|
||||
email,
|
||||
sourceUserId,
|
||||
provider: 'GOOGLE',
|
||||
name,
|
||||
username: lowerCasedUsername,
|
||||
pictureUrl: pictureUrl,
|
||||
bio: bio || undefined,
|
||||
inviteCode: undefined,
|
||||
})
|
||||
|
||||
await setAuth({ uid: user.id })
|
||||
return {
|
||||
me: userDataToUser({ ...user, profile: { ...profile, private: false } }),
|
||||
}
|
||||
} catch (err) {
|
||||
log.info('error signing up with google', err)
|
||||
if (isErrorWithCode(err)) {
|
||||
return { errorCodes: [err.errorCode as SignupErrorCode] }
|
||||
}
|
||||
return { errorCodes: [SignupErrorCode.Unknown] }
|
||||
}
|
||||
}
|
||||
|
||||
export const logOutResolver: ResolverFn<
|
||||
LogOutResult,
|
||||
unknown,
|
||||
WithDataSourcesContext,
|
||||
unknown
|
||||
> = (_, __, { clearAuth, log }) => {
|
||||
try {
|
||||
clearAuth()
|
||||
return { message: 'User successfully logged out' }
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return { errorCodes: [LogOutErrorCode.LogOutFailed] }
|
||||
}
|
||||
}
|
||||
|
||||
export const getMeUserResolver: ResolverFn<
|
||||
User | undefined,
|
||||
unknown,
|
||||
WithDataSourcesContext,
|
||||
unknown
|
||||
> = async (_obj, __, { claims }) => {
|
||||
try {
|
||||
if (!claims?.uid) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const user = await userRepository.findById(claims.uid)
|
||||
if (!user) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return userDataToUser(user)
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserResolver: ResolverFn<
|
||||
UserResult,
|
||||
unknown,
|
||||
WithDataSourcesContext,
|
||||
QueryUserArgs
|
||||
> = async (_obj, { userId: id, username }, { uid }) => {
|
||||
if (!(id || username)) {
|
||||
return { errorCodes: [UserErrorCode.BadRequest] }
|
||||
}
|
||||
|
||||
const userId =
|
||||
id ||
|
||||
(username &&
|
||||
(
|
||||
await userRepository.findOneBy({
|
||||
profile: { username },
|
||||
status: StatusType.Active,
|
||||
})
|
||||
)?.id)
|
||||
if (!userId) {
|
||||
return { errorCodes: [UserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
||||
const userRecord = await userRepository.findById(userId)
|
||||
if (!userRecord) {
|
||||
return { errorCodes: [UserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
||||
return { user: userDataToUser(userRecord) }
|
||||
}
|
||||
|
||||
export const getAllUsersResolver = authorized<UsersSuccess, UsersError>(
|
||||
async (_obj, _params) => {
|
||||
const users = await userRepository.findTopUsers()
|
||||
const result = { users: users.map((userData) => userDataToUser(userData)) }
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
type ErrorWithCode = {
|
||||
errorCode: string
|
||||
}
|
||||
|
||||
export function isErrorWithCode(error: unknown): error is ErrorWithCode {
|
||||
return (
|
||||
(error as ErrorWithCode).errorCode !== undefined &&
|
||||
typeof (error as ErrorWithCode).errorCode === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export const deleteAccountResolver = authorized<
|
||||
DeleteAccountSuccess,
|
||||
DeleteAccountError,
|
||||
MutationDeleteAccountArgs
|
||||
>(async (_, { userID }, { log }) => {
|
||||
// soft delete user
|
||||
const result = await updateUser(userID, {
|
||||
status: StatusType.Deleted,
|
||||
})
|
||||
if (!result.affected) {
|
||||
log.error('Error deleting user account')
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteAccountErrorCode.UserNotFound],
|
||||
}
|
||||
}
|
||||
|
||||
return { userID }
|
||||
})
|
||||
|
||||
export const updateEmailResolver = authorized<
|
||||
UpdateEmailSuccess,
|
||||
UpdateEmailError,
|
||||
MutationUpdateEmailArgs
|
||||
>(async (_, { input: { email } }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findById(uid)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UpdateEmailErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
if (user.source === RegistrationType.Email) {
|
||||
await authTrx(async (tx) =>
|
||||
tx.withRepository(userRepository).update(user.id, {
|
||||
email,
|
||||
})
|
||||
)
|
||||
|
||||
return { email }
|
||||
}
|
||||
|
||||
const result = await sendVerificationEmail({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email,
|
||||
})
|
||||
if (!result) {
|
||||
return {
|
||||
errorCodes: [UpdateEmailErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
return { email, verificationEmailSent: true }
|
||||
} catch (error) {
|
||||
log.error('Error updating email', error)
|
||||
return {
|
||||
errorCodes: [UpdateEmailErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { DatabaseError } from 'pg'
|
||||
import { QueryFailedError } from 'typeorm'
|
||||
import { UserDeviceToken } from '../../entity/user_device_tokens'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeviceToken,
|
||||
DeviceTokensError,
|
||||
DeviceTokensErrorCode,
|
||||
DeviceTokensSuccess,
|
||||
MutationSetDeviceTokenArgs,
|
||||
SetDeviceTokenError,
|
||||
SetDeviceTokenErrorCode,
|
||||
SetDeviceTokenSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import {
|
||||
createDeviceToken,
|
||||
deleteDeviceToken,
|
||||
findDeviceTokenById,
|
||||
findDeviceTokenByToken,
|
||||
findDeviceTokensByUserId,
|
||||
} from '../../services/user_device_tokens'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
const PG_UNIQUE_CONSTRAINT_VIOLATION = '23505'
|
||||
|
||||
export const setDeviceTokenResolver = authorized<
|
||||
SetDeviceTokenSuccess,
|
||||
SetDeviceTokenError,
|
||||
MutationSetDeviceTokenArgs
|
||||
>(async (_parent, { input }, { uid, log }) => {
|
||||
const { id, token } = input
|
||||
|
||||
if (!id && !token) {
|
||||
log.error('id or token is required')
|
||||
|
||||
return {
|
||||
errorCodes: [SetDeviceTokenErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// when token is null, we are deleting it
|
||||
if (!token && id) {
|
||||
const deviceToken = await findDeviceTokenById(id, uid)
|
||||
if (!deviceToken) {
|
||||
log.error('device token not found', id)
|
||||
|
||||
return {
|
||||
errorCodes: [SetDeviceTokenErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// delete token
|
||||
const result = await deleteDeviceToken(id, uid)
|
||||
if (!result) {
|
||||
log.error('device token not deleted', id)
|
||||
|
||||
return {
|
||||
errorCodes: [SetDeviceTokenErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'device_token_deleted',
|
||||
properties: {
|
||||
id: deviceToken.id,
|
||||
token: deviceToken.token,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
deviceToken: deviceTokenToData(deviceToken),
|
||||
}
|
||||
} else if (token) {
|
||||
// create token
|
||||
const deviceToken = await createDeviceToken(uid, token)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'device_token_created',
|
||||
properties: {
|
||||
id: deviceToken.id,
|
||||
token: deviceToken.token,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
deviceToken: deviceTokenToData(deviceToken),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errorCodes: [SetDeviceTokenErrorCode.BadRequest],
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
|
||||
if (
|
||||
e instanceof QueryFailedError &&
|
||||
(e.driverError as DatabaseError).code ===
|
||||
PG_UNIQUE_CONSTRAINT_VIOLATION &&
|
||||
token
|
||||
) {
|
||||
// duplicate token
|
||||
const deviceToken = await findDeviceTokenByToken(token, uid)
|
||||
|
||||
if (!deviceToken) {
|
||||
return {
|
||||
errorCodes: [SetDeviceTokenErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deviceToken: deviceTokenToData(deviceToken),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errorCodes: [SetDeviceTokenErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deviceTokensResolver = authorized<
|
||||
DeviceTokensSuccess,
|
||||
DeviceTokensError
|
||||
>(async (_parent, _args, { claims: { uid }, log }) => {
|
||||
try {
|
||||
log.info('deviceTokensResolver', {
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deviceTokensResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
const deviceTokens = await findDeviceTokensByUserId(uid)
|
||||
log.info('deviceTokens', deviceTokens)
|
||||
|
||||
return {
|
||||
deviceTokens: deviceTokens.map(deviceTokenToData),
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Error getting device tokens', {
|
||||
e,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'rulesResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [DeviceTokensErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const deviceTokenToData = (deviceToken: UserDeviceToken): DeviceToken => {
|
||||
return {
|
||||
id: deviceToken.id,
|
||||
token: deviceToken.token,
|
||||
createdAt: deviceToken.createdAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { FeedArticle, PageInfo } from '../../generated/graphql'
|
||||
|
||||
export type PartialFeedArticle = Omit<
|
||||
FeedArticle,
|
||||
'sharedBy' | 'article' | 'reactions'
|
||||
>
|
||||
|
||||
type PaginatedFeedArticlesSuccessPartial = {
|
||||
edges: { cursor: string; node: PartialFeedArticle }[]
|
||||
pageInfo: PageInfo
|
||||
}
|
||||
|
||||
// export const getSharedArticleResolver: ResolverFn<
|
||||
// SharedArticleSuccessPartial | SharedArticleError,
|
||||
// Record<string, unknown>,
|
||||
// WithDataSourcesContext,
|
||||
// QuerySharedArticleArgs
|
||||
// > = async (_obj, { username, slug, selectedHighlightId }, { kx, models }) => {
|
||||
// try {
|
||||
// const user = await models.user.getWhere({ username })
|
||||
// if (!user) {
|
||||
// return {
|
||||
// errorCodes: [SharedArticleErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// const article = await models.userArticle.getBySlug(username, slug)
|
||||
// if (!article || !article.sharedAt) {
|
||||
// return {
|
||||
// errorCodes: [SharedArticleErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (selectedHighlightId) {
|
||||
// const highlightResult = await models.highlight.getWhereIn('shortId', [
|
||||
// selectedHighlightId,
|
||||
// ])
|
||||
// if (!highlightResult || !highlightResult[0].sharedAt) {
|
||||
// return {
|
||||
// errorCodes: [SharedArticleErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const shareInfo = await getShareInfoForArticle(
|
||||
// kx,
|
||||
// user.id,
|
||||
// article.id,
|
||||
// models
|
||||
// )
|
||||
|
||||
// return { article: { ...article, userId: user.id, shareInfo: shareInfo } }
|
||||
// } catch (error) {
|
||||
// return { errorCodes: [SharedArticleErrorCode.NotFound] }
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const getUserFeedArticlesResolver: ResolverFn<
|
||||
// PaginatedFeedArticlesSuccessPartial,
|
||||
// unknown,
|
||||
// WithDataSourcesContext,
|
||||
// QueryFeedArticlesArgs
|
||||
// > = async (
|
||||
// _obj,
|
||||
// { after: _startCursor, first: _first, sharedByUser },
|
||||
// { models, claims, authTrx }
|
||||
// ) => {
|
||||
// if (!(sharedByUser || claims?.uid)) {
|
||||
// return {
|
||||
// edges: [],
|
||||
// pageInfo: {
|
||||
// startCursor: '',
|
||||
// endCursor: '',
|
||||
// hasNextPage: false,
|
||||
// hasPreviousPage: false,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
|
||||
// const first = _first || 0
|
||||
// const startCursor = _startCursor || ''
|
||||
|
||||
// const feedArticles =
|
||||
// (await authTrx((tx) =>
|
||||
// models.userArticle.getUserFeedArticlesPaginatedWithHighlights(
|
||||
// { cursor: startCursor, first: first + 1, sharedByUser }, // fetch one more item to get next cursor
|
||||
// claims?.uid || '',
|
||||
// tx
|
||||
// )
|
||||
// )) || []
|
||||
|
||||
// const endCursor = feedArticles[feedArticles.length - 1]?.sharedAt
|
||||
// .getTime()
|
||||
// ?.toString()
|
||||
// const hasNextPage = feedArticles.length > first
|
||||
|
||||
// if (hasNextPage) {
|
||||
// // remove an extra if exists
|
||||
// feedArticles.pop()
|
||||
// }
|
||||
|
||||
// const edges = feedArticles.map((fa) => {
|
||||
// return {
|
||||
// node: fa,
|
||||
// cursor: fa.sharedAt.getTime()?.toString(),
|
||||
// }
|
||||
// })
|
||||
|
||||
// return {
|
||||
// edges,
|
||||
// pageInfo: {
|
||||
// hasPreviousPage: false,
|
||||
// startCursor: '',
|
||||
// hasNextPage,
|
||||
// endCursor,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const updateSharedCommentResolver = authorized<
|
||||
// UpdateSharedCommentSuccess,
|
||||
// UpdateSharedCommentError,
|
||||
// MutationUpdateSharedCommentArgs
|
||||
// >(
|
||||
// async (
|
||||
// _,
|
||||
// { input: { articleID, sharedComment } },
|
||||
// { models, authTrx, claims: { uid } }
|
||||
// ) => {
|
||||
// const ua = await authTrx((tx) =>
|
||||
// models.userArticle.getByParameters(uid, { articleId: articleID }, tx)
|
||||
// )
|
||||
// if (!ua) {
|
||||
// return { errorCodes: [UpdateSharedCommentErrorCode.NotFound] }
|
||||
// }
|
||||
|
||||
// await authTrx((tx) =>
|
||||
// models.userArticle.updateByArticleId(
|
||||
// uid,
|
||||
// articleID,
|
||||
// { sharedComment },
|
||||
// tx
|
||||
// )
|
||||
// )
|
||||
|
||||
// return { articleID, sharedComment }
|
||||
// }
|
||||
// )
|
||||
101
examples/omnivore/api/api/src/resolvers/user_friends/index.ts
Normal file
101
examples/omnivore/api/api/src/resolvers/user_friends/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// export const setFollowResolver = authorized<
|
||||
// SetFollowSuccess,
|
||||
// SetFollowError,
|
||||
// MutationSetFollowArgs
|
||||
// >(
|
||||
// async (
|
||||
// _,
|
||||
// { input: { userId: friendUserId, follow } },
|
||||
// { models, authTrx, claims: { uid } }
|
||||
// ) => {
|
||||
// const user = await models.user.getUserDetails(uid, friendUserId)
|
||||
// if (!user) return { errorCodes: [SetFollowErrorCode.NotFound] }
|
||||
|
||||
// const userFriendRecord = await authTrx((tx) =>
|
||||
// models.userFriends.getByUserFriendId(uid, friendUserId, tx)
|
||||
// )
|
||||
|
||||
// if (follow) {
|
||||
// if (!userFriendRecord) {
|
||||
// await authTrx((tx) =>
|
||||
// models.userFriends.create({ friendUserId, userId: uid }, tx)
|
||||
// )
|
||||
// }
|
||||
// } else if (userFriendRecord) {
|
||||
// await authTrx((tx) => models.userFriends.delete(userFriendRecord.id, tx))
|
||||
// }
|
||||
|
||||
// const updatedUser = await models.user.getUserDetails(uid, friendUserId)
|
||||
// if (!updatedUser) return { errorCodes: [SetFollowErrorCode.NotFound] }
|
||||
|
||||
// return {
|
||||
// updatedUser: {
|
||||
// ...userDataToUser(updatedUser),
|
||||
// isFriend: updatedUser.viewerIsFollowing,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
// const getUserList = async (
|
||||
// uid: string,
|
||||
// users: UserData[],
|
||||
// models: DataModels,
|
||||
// authTrx: <TResult>(
|
||||
// cb: (tx: Knex.Transaction) => TResult,
|
||||
// userRole?: string
|
||||
// ) => Promise<TResult>
|
||||
// ): Promise<User[]> => {
|
||||
// const usersIds = users.map(({ id }) => id)
|
||||
// const friends = await authTrx((tx) =>
|
||||
// models.userFriends.getByFriendIds(uid, usersIds, tx)
|
||||
// )
|
||||
|
||||
// const friendsIds = friends.map(({ friendUserId }) => friendUserId)
|
||||
// users = users.map((f) => ({
|
||||
// ...f,
|
||||
// isFriend: friendsIds.includes(f.id),
|
||||
// viewerIsFollowing: friendsIds.includes(f.id),
|
||||
// }))
|
||||
|
||||
// return users.map((u) => userDataToUser(u))
|
||||
// }
|
||||
|
||||
// export const getFollowersResolver: ResolverFn<
|
||||
// GetFollowersResult,
|
||||
// unknown,
|
||||
// WithDataSourcesContext,
|
||||
// QueryGetFollowersArgs
|
||||
// > = async (_parent, { userId }, { models, claims, authTrx }) => {
|
||||
// const followers = userId
|
||||
// ? await authTrx((tx) => models.user.getUserFollowersList(userId, tx))
|
||||
// : []
|
||||
// if (!claims?.uid) return { followers: usersWithNoFriends(followers) }
|
||||
// return {
|
||||
// followers: await getUserList(claims?.uid, followers, models, authTrx),
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const getFollowingResolver: ResolverFn<
|
||||
// GetFollowingResult,
|
||||
// unknown,
|
||||
// WithDataSourcesContext,
|
||||
// QueryGetFollowingArgs
|
||||
// > = async (_parent, { userId }, { models, claims, authTrx }) => {
|
||||
// const following = userId
|
||||
// ? await authTrx((tx) => models.user.getUserFollowingList(userId, tx))
|
||||
// : []
|
||||
// if (!claims?.uid) return { following: usersWithNoFriends(following) }
|
||||
// return {
|
||||
// following: await getUserList(claims?.uid, following, models, authTrx),
|
||||
// }
|
||||
// }
|
||||
|
||||
// const usersWithNoFriends = (users: UserData[]): User[] => {
|
||||
// return users.map((f) =>
|
||||
// userDataToUser({
|
||||
// ...f,
|
||||
// isFriend: false,
|
||||
// } as UserData)
|
||||
// )
|
||||
// }
|
||||
@@ -0,0 +1,66 @@
|
||||
import { UserPersonalization } from '../../entity/user_personalization'
|
||||
import {
|
||||
GetUserPersonalizationError,
|
||||
GetUserPersonalizationResult,
|
||||
MutationSetUserPersonalizationArgs,
|
||||
SetUserPersonalizationError,
|
||||
SetUserPersonalizationErrorCode,
|
||||
SetUserPersonalizationSuccess,
|
||||
SortOrder,
|
||||
} from '../../generated/graphql'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const setUserPersonalizationResolver = authorized<
|
||||
SetUserPersonalizationSuccess,
|
||||
SetUserPersonalizationError,
|
||||
MutationSetUserPersonalizationArgs
|
||||
>(async (_, { input }, { authTrx, uid }) => {
|
||||
const result = await authTrx(async (t) => {
|
||||
return t.getRepository(UserPersonalization).upsert(
|
||||
{
|
||||
user: { id: uid },
|
||||
...input,
|
||||
},
|
||||
['user']
|
||||
)
|
||||
})
|
||||
|
||||
if (result.identifiers.length === 0) {
|
||||
return {
|
||||
errorCodes: [SetUserPersonalizationErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUserPersonalization = await authTrx((t) =>
|
||||
t
|
||||
.getRepository(UserPersonalization)
|
||||
.findOneBy({ id: result.identifiers[0].id as string })
|
||||
)
|
||||
|
||||
// Cast SortOrder from string to enum
|
||||
const librarySortOrder =
|
||||
updatedUserPersonalization?.librarySortOrder as SortOrder
|
||||
|
||||
return {
|
||||
updatedUserPersonalization: {
|
||||
...updatedUserPersonalization,
|
||||
librarySortOrder,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const getUserPersonalizationResolver = authorized<
|
||||
GetUserPersonalizationResult,
|
||||
GetUserPersonalizationError
|
||||
>(async (_parent, _args, { authTrx, uid }) => {
|
||||
const userPersonalization = await authTrx((t) =>
|
||||
t.getRepository(UserPersonalization).findOneBy({
|
||||
user: { id: uid },
|
||||
})
|
||||
)
|
||||
|
||||
// Cast SortOrder from string to enum
|
||||
const librarySortOrder = userPersonalization?.librarySortOrder as SortOrder
|
||||
|
||||
return { userPersonalization: { ...userPersonalization, librarySortOrder } }
|
||||
})
|
||||
171
examples/omnivore/api/api/src/resolvers/webhooks/index.ts
Normal file
171
examples/omnivore/api/api/src/resolvers/webhooks/index.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Webhook } from '../../entity/webhook'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteWebhookError,
|
||||
DeleteWebhookErrorCode,
|
||||
DeleteWebhookSuccess,
|
||||
MutationDeleteWebhookArgs,
|
||||
MutationSetWebhookArgs,
|
||||
QueryWebhookArgs,
|
||||
SetWebhookError,
|
||||
SetWebhookErrorCode,
|
||||
SetWebhookSuccess,
|
||||
Webhook as WebhookResponse,
|
||||
WebhookError,
|
||||
WebhookErrorCode,
|
||||
WebhookEvent,
|
||||
WebhooksError,
|
||||
WebhooksErrorCode,
|
||||
WebhooksSuccess,
|
||||
WebhookSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { authTrx } from '../../repository'
|
||||
import { deleteWebhook } from '../../services/webhook'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const webhooksResolver = authorized<WebhooksSuccess, WebhooksError>(
|
||||
async (_obj, _params, { uid, log }) => {
|
||||
try {
|
||||
const webhooks = await authTrx((t) =>
|
||||
t.getRepository(Webhook).findBy({
|
||||
user: { id: uid },
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
webhooks: webhooks.map((webhook) => webhookDataToResponse(webhook)),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
return {
|
||||
errorCodes: [WebhooksErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const webhookResolver = authorized<
|
||||
WebhookSuccess,
|
||||
WebhookError,
|
||||
QueryWebhookArgs
|
||||
>(async (_, { id }, { authTrx, log }) => {
|
||||
try {
|
||||
const webhook = await authTrx((t) =>
|
||||
t.getRepository(Webhook).findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
})
|
||||
)
|
||||
|
||||
if (!webhook) {
|
||||
return {
|
||||
errorCodes: [WebhookErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
webhook: webhookDataToResponse(webhook),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
return {
|
||||
errorCodes: [WebhookErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteWebhookResolver = authorized<
|
||||
DeleteWebhookSuccess,
|
||||
DeleteWebhookError,
|
||||
MutationDeleteWebhookArgs
|
||||
>(async (_, { id }, { uid, log }) => {
|
||||
try {
|
||||
const webhook = await deleteWebhook(id, uid)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'webhook_delete',
|
||||
properties: {
|
||||
webhookId: id,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
webhook: webhookDataToResponse(webhook),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error deleting webhook', error)
|
||||
return {
|
||||
errorCodes: [DeleteWebhookErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const setWebhookResolver = authorized<
|
||||
SetWebhookSuccess,
|
||||
SetWebhookError,
|
||||
MutationSetWebhookArgs
|
||||
>(async (_, { input }, { authTrx, claims: { uid }, log }) => {
|
||||
log.info('setWebhookResolver')
|
||||
|
||||
try {
|
||||
const webhookToSave: Partial<Webhook> = {
|
||||
url: input.url,
|
||||
eventTypes: input.eventTypes as string[],
|
||||
method: input.method || 'POST',
|
||||
contentType: input.contentType || 'application/json',
|
||||
enabled: input.enabled === null ? true : input.enabled,
|
||||
}
|
||||
|
||||
if (input.id) {
|
||||
// Update
|
||||
const existingWebhook = await authTrx((t) =>
|
||||
t.getRepository(Webhook).findOne({
|
||||
where: { id: input.id || '' },
|
||||
relations: ['user'],
|
||||
})
|
||||
)
|
||||
if (!existingWebhook) {
|
||||
return {
|
||||
errorCodes: [SetWebhookErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
webhookToSave.id = input.id
|
||||
}
|
||||
const webhook = await authTrx((t) =>
|
||||
t.getRepository(Webhook).save({
|
||||
user: { id: uid },
|
||||
...webhookToSave,
|
||||
})
|
||||
)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'webhook_set',
|
||||
properties: {
|
||||
webhookId: webhook.id,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
webhook: webhookDataToResponse(webhook),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
return {
|
||||
errorCodes: [SetWebhookErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const webhookDataToResponse = (webhook: Webhook): WebhookResponse => ({
|
||||
...webhook,
|
||||
eventTypes: webhook.eventTypes as WebhookEvent[],
|
||||
})
|
||||
135
examples/omnivore/api/api/src/routers/article_router.ts
Normal file
135
examples/omnivore/api/api/src/routers/article_router.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler'
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { Speech } from '../entity/speech'
|
||||
import { env } from '../env'
|
||||
import { CreateArticleErrorCode } from '../generated/graphql'
|
||||
import { Claims } from '../resolvers/types'
|
||||
import { createPageSaveRequest } from '../services/create_page_save_request'
|
||||
import { findLibraryItemById } from '../services/library_item'
|
||||
import { getClaimsByToken } from '../utils/auth'
|
||||
import { isSiteBlockedForParse } from '../utils/blocked'
|
||||
import { corsConfig } from '../utils/corsConfig'
|
||||
import { logger } from '../utils/logger'
|
||||
import { generateDownloadSignedUrl } from '../utils/uploads'
|
||||
|
||||
interface SpeechInput {
|
||||
voice?: string
|
||||
secondaryVoice?: string
|
||||
priority?: 'low' | 'high'
|
||||
language?: string
|
||||
}
|
||||
const outputFormats = ['mp3', 'speech-marks', 'speech']
|
||||
|
||||
export function articleRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
router.options('/save', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
|
||||
router.post('/save', cors<express.Request>(corsConfig), async (req, res) => {
|
||||
const { url } = req.body as {
|
||||
url?: string
|
||||
}
|
||||
|
||||
const token = req?.cookies?.auth || req?.headers?.authorization
|
||||
const claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
return res.status(401).send('UNAUTHORIZED')
|
||||
}
|
||||
|
||||
const { uid } = claims
|
||||
|
||||
logger.info('Article saving request', {
|
||||
body: req.body,
|
||||
labels: {
|
||||
source: 'SaveEndpoint',
|
||||
userId: uid,
|
||||
},
|
||||
})
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).send({ errorCode: 'BAD_DATA' })
|
||||
}
|
||||
|
||||
const result = await createPageSaveRequest({ userId: uid, url })
|
||||
|
||||
if (isSiteBlockedForParse(url)) {
|
||||
return res
|
||||
.status(400)
|
||||
.send({ errorCode: CreateArticleErrorCode.NotAllowedToParse })
|
||||
}
|
||||
|
||||
if (result.errorCode) {
|
||||
return res.status(400).send({ errorCode: result.errorCode })
|
||||
}
|
||||
|
||||
return res.send({
|
||||
articleSavingRequestId: result.id,
|
||||
url: result.url,
|
||||
})
|
||||
})
|
||||
|
||||
router.get(
|
||||
'/:id/:outputFormat',
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req, res) => {
|
||||
const articleId = req.params.id
|
||||
const outputFormat = req.params.outputFormat
|
||||
const { voice, secondaryVoice, language } = req.query as SpeechInput
|
||||
if (!articleId || outputFormats.indexOf(outputFormat) === -1) {
|
||||
return res.status(400).send('Invalid data')
|
||||
}
|
||||
const token = req.cookies?.auth || req.headers?.authorization
|
||||
if (!token || !jwt.verify(token, env.server.jwtSecret)) {
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
const { uid } = jwt.decode(token) as Claims
|
||||
if (!uid) {
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
logger.info(`Get article speech in ${outputFormat} format`, {
|
||||
params: req.params,
|
||||
labels: {
|
||||
userId: uid,
|
||||
source: `GetArticleSpeech-${outputFormat}`,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const item = await findLibraryItemById(articleId, uid)
|
||||
if (!item) {
|
||||
return res.status(404).send('Page not found')
|
||||
}
|
||||
|
||||
const speechFile = htmlToSpeechFile({
|
||||
title: item.title,
|
||||
content: item.readableContent,
|
||||
options: {
|
||||
primaryVoice: voice,
|
||||
secondaryVoice: secondaryVoice,
|
||||
language: language || item.itemLanguage || undefined,
|
||||
},
|
||||
})
|
||||
return res.send({ ...speechFile, pageId: articleId })
|
||||
} catch (error) {
|
||||
logger.error('Error getting article speech:', error)
|
||||
res.status(500).send({ errorCode: 'INTERNAL_ERROR' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
const redirectUrl = async (speech: Speech, outputFormat: string) => {
|
||||
switch (outputFormat) {
|
||||
case 'mp3':
|
||||
return generateDownloadSignedUrl(speech.audioFileName)
|
||||
case 'speech-marks':
|
||||
return generateDownloadSignedUrl(speech.speechMarksFileName)
|
||||
default:
|
||||
return generateDownloadSignedUrl(speech.audioFileName)
|
||||
}
|
||||
}
|
||||
214
examples/omnivore/api/api/src/routers/auth/apple_auth.ts
Normal file
214
examples/omnivore/api/api/src/routers/auth/apple_auth.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import jwksClient from 'jwks-rsa'
|
||||
import { env, homePageURL } from '../../env'
|
||||
import { LoginErrorCode } from '../../generated/graphql'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { createSsoToken, ssoRedirectURL } from '../../utils/sso'
|
||||
import { DecodeTokenResult } from './auth_types'
|
||||
import {
|
||||
createPendingUserToken,
|
||||
createWebAuthToken,
|
||||
suggestedUsername,
|
||||
} from './jwt_helpers'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { StatusType } from '../../entity/user'
|
||||
|
||||
const appleBaseURL = 'https://appleid.apple.com'
|
||||
const audienceName = 'app.omnivore.app'
|
||||
const webAudienceName = 'app.omnivore'
|
||||
|
||||
async function fetchApplePublicKey(kid: string): Promise<string | null> {
|
||||
const client = jwksClient({
|
||||
cache: true,
|
||||
jwksUri: `${appleBaseURL}/auth/keys`,
|
||||
})
|
||||
|
||||
try {
|
||||
const key: jwksClient.SigningKey = await new Promise((resolve, reject) => {
|
||||
client.getSigningKey(kid, (error, result) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
return resolve(result)
|
||||
})
|
||||
})
|
||||
return key.getPublicKey()
|
||||
} catch (e) {
|
||||
logger.error('fetchApplePublicKey error', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeAppleToken(
|
||||
token: string
|
||||
): Promise<DecodeTokenResult> {
|
||||
const decodedToken = jwt.decode(token, { complete: true })
|
||||
const { kid, alg } = (decodedToken as any).header
|
||||
|
||||
try {
|
||||
const publicKey = await fetchApplePublicKey(kid)
|
||||
if (!publicKey) {
|
||||
return { errorCode: 500 }
|
||||
}
|
||||
const jwtClaims: any = jwt.verify(token, publicKey, { algorithms: [alg] })
|
||||
const issVerified = (jwtClaims.iss ?? '') === appleBaseURL
|
||||
const audience = jwtClaims.aud ?? ''
|
||||
const audVerified = audience == webAudienceName || audience === audienceName
|
||||
if (issVerified && audVerified && jwtClaims.email) {
|
||||
return {
|
||||
email: jwtClaims.email,
|
||||
sourceUserId: jwtClaims.sub,
|
||||
name: jwtClaims.name,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
errorCode: 401,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('decodeAppleToken error', e)
|
||||
return { errorCode: 500 }
|
||||
}
|
||||
}
|
||||
|
||||
type AppleWebAuthResponse = {
|
||||
redirectURL: string
|
||||
authToken?: string
|
||||
pendingUserToken?: string
|
||||
}
|
||||
|
||||
type AppleUserData = {
|
||||
name?: AppleUserName
|
||||
email?: string
|
||||
}
|
||||
|
||||
type AppleUserName = {
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
}
|
||||
|
||||
export async function handleAppleWebAuth(
|
||||
idToken: string,
|
||||
appleUserData?: AppleUserData,
|
||||
isLocal = false,
|
||||
isVercel = false
|
||||
): Promise<AppleWebAuthResponse> {
|
||||
const baseURL = () => {
|
||||
if (isLocal) {
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
if (isVercel) {
|
||||
return homePageURL()
|
||||
}
|
||||
|
||||
return env.client.url
|
||||
}
|
||||
const decodedTokenResult = await decodeAppleToken(idToken)
|
||||
const authFailedRedirect = `${baseURL()}/login?errorCodes=${
|
||||
LoginErrorCode.AuthFailed
|
||||
}`
|
||||
|
||||
if (!decodedTokenResult.email || decodedTokenResult.errorCode) {
|
||||
return Promise.resolve({
|
||||
redirectURL: authFailedRedirect,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userRepository.findOneBy({
|
||||
sourceUserId: decodedTokenResult.sourceUserId,
|
||||
source: 'APPLE',
|
||||
status: StatusType.Active,
|
||||
})
|
||||
const userId = user?.id
|
||||
|
||||
if (!userId) {
|
||||
// create a temp token so the user can create a new profile
|
||||
const payload = await createTempAppleUserPayload({
|
||||
authFailedRedirect,
|
||||
appleUserData,
|
||||
baseURL: baseURL(),
|
||||
sourceUserId: decodedTokenResult.sourceUserId,
|
||||
email: decodedTokenResult.email,
|
||||
})
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const authToken = await createWebAuthToken(userId)
|
||||
if (authToken) {
|
||||
const ssoToken = createSsoToken(authToken, `${baseURL()}/home`)
|
||||
const redirectURL = isVercel
|
||||
? ssoRedirectURL(ssoToken)
|
||||
: `${baseURL()}/home`
|
||||
|
||||
analytics.track({
|
||||
userId: user.id,
|
||||
event: 'login',
|
||||
properties: {
|
||||
method: 'apple',
|
||||
email: user.email,
|
||||
username: user.profile.username,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
authToken,
|
||||
redirectURL,
|
||||
}
|
||||
} else {
|
||||
return { redirectURL: authFailedRedirect }
|
||||
}
|
||||
} catch (e) {
|
||||
logger.info('handleAppleWebAuth error', e)
|
||||
return { redirectURL: authFailedRedirect }
|
||||
}
|
||||
}
|
||||
|
||||
type CreateTempAppleUserPayloadInputs = {
|
||||
appleUserData?: AppleUserData
|
||||
authFailedRedirect: string
|
||||
baseURL: string
|
||||
sourceUserId?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
async function createTempAppleUserPayload(
|
||||
inputs: CreateTempAppleUserPayloadInputs
|
||||
): Promise<AppleWebAuthResponse> {
|
||||
if (!inputs.email || !inputs.sourceUserId) {
|
||||
throw new Error('missing email or sourceUserId')
|
||||
}
|
||||
|
||||
const firstName = inputs.appleUserData?.name?.firstName ?? ''
|
||||
const lastName = inputs.appleUserData?.name?.lastName ?? ''
|
||||
const name = `${firstName} ${lastName}`
|
||||
const username = suggestedUsername(name)
|
||||
|
||||
try {
|
||||
const pendingUserToken = await createPendingUserToken({
|
||||
email: inputs.email,
|
||||
sourceUserId: inputs.sourceUserId,
|
||||
provider: 'APPLE',
|
||||
name,
|
||||
username,
|
||||
})
|
||||
|
||||
if (!pendingUserToken) {
|
||||
throw new Error('Failed to create pending user token')
|
||||
}
|
||||
|
||||
return {
|
||||
redirectURL: `${inputs.baseURL}/confirm-profile?username=${username}&name=${name}`,
|
||||
pendingUserToken,
|
||||
}
|
||||
} catch {
|
||||
return { redirectURL: inputs.authFailedRedirect }
|
||||
}
|
||||
}
|
||||
739
examples/omnivore/api/api/src/routers/auth/auth_router.ts
Normal file
739
examples/omnivore/api/api/src/routers/auth/auth_router.ts
Normal file
@@ -0,0 +1,739 @@
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import axios from 'axios'
|
||||
import cors from 'cors'
|
||||
import type { Request, Response } from 'express'
|
||||
import express from 'express'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import url from 'url'
|
||||
import { promisify } from 'util'
|
||||
import { appDataSource } from '../../data_source'
|
||||
import { RegistrationType, StatusType, User } from '../../entity/user'
|
||||
import { env } from '../../env'
|
||||
import { LoginErrorCode, SignupErrorCode } from '../../generated/graphql'
|
||||
import { getRepository, setClaims } from '../../repository'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { isErrorWithCode } from '../../resolvers'
|
||||
import { createUser } from '../../services/create_user'
|
||||
import {
|
||||
sendConfirmationEmail,
|
||||
sendPasswordResetEmail,
|
||||
} from '../../services/send_emails'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import {
|
||||
comparePassword,
|
||||
getClaimsByToken,
|
||||
hashPassword,
|
||||
setAuthInCookie,
|
||||
} from '../../utils/auth'
|
||||
import { corsConfig } from '../../utils/corsConfig'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { createSsoToken, ssoRedirectURL } from '../../utils/sso'
|
||||
import { handleAppleWebAuth } from './apple_auth'
|
||||
import type { AuthProvider } from './auth_types'
|
||||
import {
|
||||
generateGoogleLoginURL,
|
||||
googleAuth,
|
||||
handleGoogleWebAuth,
|
||||
validateGoogleUser,
|
||||
} from './google_auth'
|
||||
import { createWebAuthToken } from './jwt_helpers'
|
||||
import { createMobileAccountCreationResponse } from './mobile/account_creation'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
export interface SignupRequest {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
username: string
|
||||
bio?: string
|
||||
pictureUrl?: string
|
||||
}
|
||||
|
||||
const signToken = promisify(jwt.sign)
|
||||
|
||||
const cookieParams = {
|
||||
httpOnly: true,
|
||||
maxAge: 365 * 24 * 60 * 60 * 1000,
|
||||
}
|
||||
|
||||
export const isValidSignupRequest = (obj: any): obj is SignupRequest => {
|
||||
return (
|
||||
'email' in obj &&
|
||||
obj.email.trim().length > 0 &&
|
||||
obj.email.trim().length < 512 && // email must not be empty
|
||||
'password' in obj &&
|
||||
obj.password.length >= 8 &&
|
||||
obj.password.trim().length < 512 && // password must be at least 8 characters
|
||||
'name' in obj &&
|
||||
obj.name.trim().length > 0 &&
|
||||
obj.name.trim().length < 512 && // name must not be empty
|
||||
'username' in obj &&
|
||||
obj.username.trim().length > 0 &&
|
||||
obj.username.trim().length < 512 // username must not be empty
|
||||
)
|
||||
}
|
||||
|
||||
// The hourly limiter is used on the create account,
|
||||
// and reset password endpoints
|
||||
// this limits users to five operations per an hour
|
||||
const hourlyLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
skip: (req) => env.dev.isLocal,
|
||||
})
|
||||
|
||||
export function authRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
router.post('/apple-redirect', curriedAuthHandler('APPLE', false))
|
||||
router.post('/gauth-redirect', curriedAuthHandler('GOOGLE', false))
|
||||
router.post(
|
||||
'/vercel/apple-redirect',
|
||||
curriedAuthHandler('APPLE', false, true)
|
||||
)
|
||||
router.post(
|
||||
'/vercel/gauth-redirect',
|
||||
curriedAuthHandler('GOOGLE', false, true)
|
||||
)
|
||||
router.post(
|
||||
'/apple-redirect-localhost',
|
||||
curriedAuthHandler('APPLE', true, true)
|
||||
)
|
||||
router.post(
|
||||
'/gauth-redirect-localhost',
|
||||
curriedAuthHandler('GOOGLE', true, true)
|
||||
)
|
||||
|
||||
router.options(
|
||||
'/create-account',
|
||||
cors<express.Request>({ ...corsConfig, maxAge: 600 })
|
||||
)
|
||||
router.post(
|
||||
'/create-account',
|
||||
hourlyLimiter,
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req, res) => {
|
||||
const { name, bio, username } = req.body
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const token = req.cookies?.pendingUserAuth as string | undefined
|
||||
|
||||
const payload = await createMobileAccountCreationResponse(token, {
|
||||
name,
|
||||
username,
|
||||
bio,
|
||||
})
|
||||
|
||||
if (payload.json.authToken) {
|
||||
res.cookie('auth', payload.json.authToken, cookieParams)
|
||||
res.clearCookie('pendingUserAuth')
|
||||
}
|
||||
|
||||
res.status(payload.statusCode).json({})
|
||||
}
|
||||
)
|
||||
|
||||
function curriedAuthHandler(
|
||||
provider: AuthProvider,
|
||||
isLocal: boolean,
|
||||
isVercel = false
|
||||
): (req: Request, res: Response) => void {
|
||||
return (req: Request, res: Response) =>
|
||||
authHandler(req, res, provider, isLocal, isVercel)
|
||||
}
|
||||
|
||||
async function authHandler(
|
||||
req: Request,
|
||||
res: Response,
|
||||
provider: AuthProvider,
|
||||
isLocal: boolean,
|
||||
isVercel: boolean
|
||||
) {
|
||||
const completion = (
|
||||
res: Response,
|
||||
redirectURL: string,
|
||||
jwt?: string,
|
||||
pendingUserJwt?: string
|
||||
) => {
|
||||
if (jwt) {
|
||||
res.cookie('auth', jwt, cookieParams)
|
||||
}
|
||||
|
||||
if (pendingUserJwt) {
|
||||
res.cookie('pendingUserAuth', pendingUserJwt, cookieParams)
|
||||
}
|
||||
|
||||
return res.redirect(redirectURL)
|
||||
}
|
||||
|
||||
if (provider === 'APPLE') {
|
||||
const { id_token, user } = req.body
|
||||
const authResponse = await handleAppleWebAuth(
|
||||
id_token,
|
||||
user,
|
||||
isLocal,
|
||||
isVercel
|
||||
)
|
||||
completion(
|
||||
res,
|
||||
authResponse.redirectURL,
|
||||
authResponse.authToken,
|
||||
authResponse.pendingUserToken
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (provider === 'GOOGLE') {
|
||||
const { credential } = req.body
|
||||
const authResponse = await handleGoogleWebAuth(
|
||||
credential,
|
||||
isLocal,
|
||||
isVercel
|
||||
)
|
||||
completion(
|
||||
res,
|
||||
authResponse.redirectURL,
|
||||
authResponse.authToken,
|
||||
authResponse.pendingUserAuth
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
res.status(500).send('Unknown provider')
|
||||
}
|
||||
|
||||
router.options(
|
||||
'/verify',
|
||||
cors<express.Request>({ ...corsConfig, maxAge: 600 })
|
||||
)
|
||||
router.get('/verify', cors<express.Request>(corsConfig), async (req, res) => {
|
||||
// return 'AUTHENTICATED', 'PENDING_USER', or 'NOT_AUTHENTICATED'
|
||||
|
||||
if (req.cookies?.auth || req.headers['authorization']) {
|
||||
res.status(200).json({ authStatus: 'AUTHENTICATED' })
|
||||
} else if (req.cookies?.pendingUserAuth || req.headers['pendingUserAuth']) {
|
||||
res.status(200).json({ authStatus: 'PENDING_USER' })
|
||||
} else {
|
||||
res.status(200).json({ authStatus: 'NOT_AUTHENTICATED' })
|
||||
}
|
||||
})
|
||||
|
||||
// Remove code below this line once we update google auth to new version
|
||||
|
||||
router.get('/google-redirect/login', async (req, res) => {
|
||||
let redirect_uri = ''
|
||||
if (req.query.redirect_uri) {
|
||||
redirect_uri = encodeURIComponent(req.query.redirect_uri as string)
|
||||
}
|
||||
const state = JSON.stringify({ redirect_uri })
|
||||
res.redirect(
|
||||
generateGoogleLoginURL(
|
||||
googleAuth(),
|
||||
`/api/auth/google-login/login`,
|
||||
state
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
router.get('/google-login/login', async (req, res) => {
|
||||
const { code } = req.query
|
||||
|
||||
const userData = await validateGoogleUser(`${code}`)
|
||||
|
||||
if (!userData || !userData.email || !userData.id) {
|
||||
return { errorCodes: [SignupErrorCode.GoogleAuthError] }
|
||||
}
|
||||
|
||||
const user = await userRepository.findOneBy({ email: userData.email })
|
||||
|
||||
// eslint-disable-next @typescript-eslint/ban-ts-comment
|
||||
const secret = (await signToken(
|
||||
{ email: userData.email },
|
||||
env.server.jwtSecret,
|
||||
// @ts-ignore
|
||||
{
|
||||
expiresIn: 300,
|
||||
}
|
||||
)) as string
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/join?email=${userData.email}&name=${userData.name}&sourceUserId=${userData.id}&pictureUrl=${userData.picture}&secret=${secret}`
|
||||
)
|
||||
}
|
||||
|
||||
if (user.source !== RegistrationType.Google) {
|
||||
const errorCodes = [LoginErrorCode.WrongSource]
|
||||
return res.redirect(
|
||||
`${env.client.url}/${
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(req.params as any)?.action
|
||||
}?errorCodes=${errorCodes}`
|
||||
)
|
||||
}
|
||||
|
||||
const query = `
|
||||
mutation googleLogin{
|
||||
googleLogin(input: {
|
||||
secret: "${secret}",
|
||||
email: "${userData.email}",
|
||||
}) {
|
||||
__typename
|
||||
... on LoginError { errorCodes }
|
||||
... on LoginSuccess {
|
||||
me {
|
||||
id
|
||||
name
|
||||
profile {
|
||||
pictureUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
const result = await axios.post(env.server.gateway_url + '/graphql', {
|
||||
query,
|
||||
})
|
||||
const { data } = result.data
|
||||
|
||||
if (data.googleLogin.__typename === 'LoginError') {
|
||||
if (data.googleLogin.errorCodes.includes(LoginErrorCode.UserNotFound)) {
|
||||
return res.redirect(`${env.client.url}/login`)
|
||||
}
|
||||
|
||||
const errorCodes = data.googleLogin.errorCodes.join(',')
|
||||
return res.redirect(
|
||||
`${env.client.url}/${
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(req.params as any)?.action
|
||||
}?errorCodes=${errorCodes}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!result.headers['set-cookie']) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/${
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(req.params as any)?.action
|
||||
}?errorCodes=unknown`
|
||||
)
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: user.id,
|
||||
event: 'login',
|
||||
properties: {
|
||||
method: 'google',
|
||||
email: user.email,
|
||||
username: user.profile.username,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
res.setHeader('set-cookie', result.headers['set-cookie'])
|
||||
|
||||
await handleSuccessfulLogin(req, res, user, data.googleLogin.newUser)
|
||||
})
|
||||
|
||||
async function handleSuccessfulLogin(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
user: User,
|
||||
newUser: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
let redirectUri: string | null = null
|
||||
if (req.query.state) {
|
||||
// Google login case: redirect_uri is in query state param.
|
||||
try {
|
||||
const state = JSON.parse((req.query?.state || '') as string)
|
||||
redirectUri = state?.redirect_uri
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'handleSuccessfulLogin: failed to parse redirect query state param',
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (newUser) {
|
||||
if (redirectUri && redirectUri !== '/') {
|
||||
redirectUri = url.resolve(
|
||||
env.client.url,
|
||||
decodeURIComponent(redirectUri)
|
||||
)
|
||||
} else {
|
||||
redirectUri = `${env.client.url}/home`
|
||||
}
|
||||
}
|
||||
|
||||
redirectUri = redirectUri ? redirectUri : `${env.client.url}/home`
|
||||
|
||||
const message = res.get('Message')
|
||||
if (message) {
|
||||
const u = new URL(redirectUri)
|
||||
u.searchParams.append('message', message)
|
||||
redirectUri = u.toString()
|
||||
}
|
||||
|
||||
// If we do have an auth token, we want to try redirecting to the
|
||||
// sso endpoint which will set a cookie for the client domain (omnivore.app)
|
||||
// after we set a cookie for the API domain (api-prod.omnivore.app)
|
||||
const authToken = await createWebAuthToken(user.id)
|
||||
if (authToken) {
|
||||
const ssoToken = createSsoToken(authToken, redirectUri)
|
||||
redirectUri = ssoRedirectURL(ssoToken)
|
||||
}
|
||||
|
||||
await setAuthInCookie({ uid: user.id }, res)
|
||||
|
||||
return res.redirect(redirectUri)
|
||||
} catch (error) {
|
||||
logger.info('handleSuccessfulLogin exception:', error)
|
||||
return res.redirect(`${env.client.url}/login?errorCodes=AUTH_FAILED`)
|
||||
}
|
||||
}
|
||||
|
||||
router.options(
|
||||
'/email-login',
|
||||
cors<express.Request>({ ...corsConfig, maxAge: 600 })
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/email-login',
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
function isValidLoginRequest(obj: any): obj is LoginRequest {
|
||||
return (
|
||||
'email' in obj &&
|
||||
obj.email.trim().length > 0 && // email must not be empty
|
||||
'password' in obj &&
|
||||
obj.password.length >= 8 // password must be at least 8 characters
|
||||
)
|
||||
}
|
||||
if (!isValidLoginRequest(req.body)) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}`
|
||||
)
|
||||
}
|
||||
const { email, password } = req.body
|
||||
try {
|
||||
const user = await userRepository.findByEmail(email.trim())
|
||||
if (!user || user.status === StatusType.Deleted) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.UserNotFound}`
|
||||
)
|
||||
}
|
||||
|
||||
if (user.status === StatusType.Pending && user.email) {
|
||||
await sendConfirmationEmail({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=PENDING_VERIFICATION`
|
||||
)
|
||||
}
|
||||
|
||||
if (!user?.password) {
|
||||
// user has no password, so they need to set one
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.WrongSource}`
|
||||
)
|
||||
}
|
||||
// check if password is correct
|
||||
const validPassword = await comparePassword(password, user.password)
|
||||
if (!validPassword) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}`
|
||||
)
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: user.id,
|
||||
event: 'login',
|
||||
properties: {
|
||||
method: 'email',
|
||||
email: user.email,
|
||||
username: user.profile.username,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
await handleSuccessfulLogin(req, res, user, false)
|
||||
} catch (e) {
|
||||
logger.info('email-login exception:', e)
|
||||
res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=AUTH_FAILED`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.options(
|
||||
'/email-signup',
|
||||
cors<express.Request>({ ...corsConfig, maxAge: 600 })
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/email-signup',
|
||||
hourlyLimiter,
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
if (!isValidSignupRequest(req.body)) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-signup?errorCodes=INVALID_CREDENTIALS`
|
||||
)
|
||||
}
|
||||
const { email, password, name, username, bio, pictureUrl } = req.body
|
||||
// trim whitespace in email address
|
||||
const trimmedEmail = email.trim()
|
||||
try {
|
||||
// hash password
|
||||
const hashedPassword = await hashPassword(password)
|
||||
await createUser({
|
||||
email: trimmedEmail,
|
||||
provider: 'EMAIL',
|
||||
sourceUserId: trimmedEmail,
|
||||
name: name.trim(),
|
||||
username: username.trim().toLowerCase(), // lowercase username
|
||||
pictureUrl,
|
||||
bio,
|
||||
password: hashedPassword,
|
||||
pendingConfirmation: true,
|
||||
})
|
||||
|
||||
res.redirect(
|
||||
`${env.client.url}/auth/verify-email?message=SIGNUP_SUCCESS`
|
||||
)
|
||||
} catch (e) {
|
||||
logger.info('email-signup exception:', e)
|
||||
if (isErrorWithCode(e)) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-signup?errorCodes=${e.errorCode}`
|
||||
)
|
||||
}
|
||||
res.redirect(`${env.client.url}/auth/email-signup?errorCodes=UNKNOWN`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.options(
|
||||
'/confirm-email',
|
||||
cors<express.Request>({ ...corsConfig, maxAge: 600 })
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/confirm-email',
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
const token = req.body.token
|
||||
|
||||
try {
|
||||
// verify token
|
||||
const claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/confirm-email?errorCodes=INVALID_TOKEN`
|
||||
)
|
||||
}
|
||||
|
||||
const user = await getRepository(User).findOneBy({ id: claims.uid })
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/confirm-email?errorCodes=USER_NOT_FOUND`
|
||||
)
|
||||
}
|
||||
|
||||
if (user.status === StatusType.Pending) {
|
||||
const updated = await appDataSource.transaction(
|
||||
async (entityManager) => {
|
||||
await setClaims(entityManager, user.id)
|
||||
return entityManager
|
||||
.getRepository(User)
|
||||
.update({ id: user.id }, { status: StatusType.Active })
|
||||
}
|
||||
)
|
||||
|
||||
if (!updated.affected) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/confirm-email?errorCodes=UNKNOWN`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: user.id,
|
||||
event: 'login',
|
||||
properties: {
|
||||
method: 'email_verification',
|
||||
email: user.email,
|
||||
username: user.profile.username,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
res.set('Message', 'EMAIL_CONFIRMED')
|
||||
await handleSuccessfulLogin(req, res, user, false)
|
||||
} catch (e) {
|
||||
logger.info('confirm-email exception:', e)
|
||||
if (e instanceof jwt.TokenExpiredError) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/confirm-email?errorCodes=TOKEN_EXPIRED`
|
||||
)
|
||||
}
|
||||
|
||||
res.redirect(
|
||||
`${env.client.url}/auth/confirm-email?errorCodes=INVALID_TOKEN`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.options(
|
||||
'/forgot-password',
|
||||
cors<express.Request>({ ...corsConfig, maxAge: 600 })
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/forgot-password',
|
||||
hourlyLimiter,
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
const email = req.body.email?.trim() as string // trim whitespace
|
||||
if (!email) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/forgot-password?errorCodes=INVALID_EMAIL`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userRepository.findByEmail(email)
|
||||
if (!user || user.status === StatusType.Deleted) {
|
||||
return res.redirect(`${env.client.url}/auth/reset-sent`)
|
||||
}
|
||||
|
||||
if (user.status === StatusType.Pending) {
|
||||
return res.redirect(`${env.client.url}/auth/reset-sent`)
|
||||
}
|
||||
|
||||
if (!(await sendPasswordResetEmail(user))) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/forgot-password?errorCodes=INVALID_EMAIL`
|
||||
)
|
||||
}
|
||||
|
||||
res.clearCookie('auth')
|
||||
res.clearCookie('pendingUserAuth')
|
||||
res.redirect(`${env.client.url}/auth/reset-sent`)
|
||||
} catch (e) {
|
||||
logger.info('forgot-password exception:', e)
|
||||
|
||||
res.redirect(
|
||||
`${env.client.url}/auth/forgot-password?errorCodes=UNKNOWN`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.options(
|
||||
'/reset-password',
|
||||
cors<express.Request>({ ...corsConfig, maxAge: 600 })
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/reset-password',
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
const { token, password } = req.body
|
||||
|
||||
try {
|
||||
// verify token
|
||||
const claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/reset-password/${token}?errorCodes=INVALID_TOKEN`
|
||||
)
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/reset-password/${token}?errorCodes=INVALID_PASSWORD`
|
||||
)
|
||||
}
|
||||
|
||||
const user = await getRepository(User).findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/reset-password/${token}?errorCodes=USER_NOT_FOUND`
|
||||
)
|
||||
}
|
||||
|
||||
if (user.status === StatusType.Pending) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=PENDING_VERIFICATION`
|
||||
)
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password)
|
||||
const updated = await appDataSource.transaction(
|
||||
async (entityManager) => {
|
||||
await setClaims(entityManager, user.id)
|
||||
return entityManager.getRepository(User).update(user.id, {
|
||||
password: hashedPassword,
|
||||
email: claims.email ?? undefined, // update email address if it was provided
|
||||
source: RegistrationType.Email, // reset password will always be email
|
||||
})
|
||||
}
|
||||
)
|
||||
if (!updated.affected) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/reset-password/${token}?errorCodes=UNKNOWN`
|
||||
)
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: user.id,
|
||||
event: 'login',
|
||||
properties: {
|
||||
method: 'password_reset',
|
||||
email: user.email,
|
||||
username: user.profile.username,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
await handleSuccessfulLogin(req, res, user, false)
|
||||
} catch (e) {
|
||||
logger.info('reset-password exception:', e)
|
||||
if (e instanceof jwt.TokenExpiredError) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/reset-password/?errorCodes=TOKEN_EXPIRED`
|
||||
)
|
||||
}
|
||||
|
||||
res.redirect(
|
||||
`${env.client.url}/auth/reset-password/?errorCodes=INVALID_TOKEN`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return router
|
||||
}
|
||||
47
examples/omnivore/api/api/src/routers/auth/auth_types.ts
Normal file
47
examples/omnivore/api/api/src/routers/auth/auth_types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type JsonResponsePayload = {
|
||||
statusCode: number
|
||||
json: any
|
||||
}
|
||||
|
||||
export type DecodeTokenResult = {
|
||||
errorCode?: 401 | 500
|
||||
email?: string
|
||||
sourceUserId?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type AuthProvider = 'APPLE' | 'GOOGLE' | 'EMAIL'
|
||||
|
||||
export type UserProfile = {
|
||||
username: string
|
||||
name: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
export type PendingUserTokenPayload = {
|
||||
email: string
|
||||
sourceUserId: string
|
||||
provider: AuthProvider
|
||||
name: string
|
||||
username: string
|
||||
}
|
||||
|
||||
// Type guard for PendingUserTokenPayload
|
||||
export function isPendingUserTokenPayload(
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
object: any
|
||||
): object is PendingUserTokenPayload {
|
||||
return (
|
||||
'email' in object &&
|
||||
'sourceUserId' in object &&
|
||||
'provider' in object &&
|
||||
'name' in object &&
|
||||
'username' in object
|
||||
)
|
||||
}
|
||||
|
||||
export type IntegrationTokenPayload = {
|
||||
uid: string
|
||||
token: string
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user