mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
commit
44f951a94c
235 changed files with 8479 additions and 13896 deletions
2
.github/workflows/run-tests.yaml
vendored
2
.github/workflows/run-tests.yaml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
image: ankane/pgvector
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
|
|
|
|||
|
|
@ -16,26 +16,6 @@ services:
|
|||
expose:
|
||||
- 5432
|
||||
|
||||
elastic-test:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
|
||||
container_name: "omnivore-elastic-test"
|
||||
healthcheck:
|
||||
test: curl 0.0.0.0:9201/_cat/health >/dev/null || exit 1
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 5
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- http.cors.allow-origin=*
|
||||
- http.cors.enabled=true
|
||||
- http.cors.allow-headers=X-Requested-With,X-Auth-Token,Content-Type,Content-Length,Authorization
|
||||
- http.cors.allow-credentials=true
|
||||
- http.port=9201
|
||||
volumes:
|
||||
- ./.docker/elastic-test-data:/usr/share/elasticsearch/data
|
||||
ports:
|
||||
- "9201:9201"
|
||||
|
||||
api-test:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -49,7 +29,6 @@ services:
|
|||
- PG_DB=omnivore_test
|
||||
- PG_PORT=5432
|
||||
- PG_POOL_MAX=20
|
||||
- ELASTIC_URL=http://elastic-test:9201
|
||||
- IMAGE_PROXY_URL=http://localhost:9999
|
||||
- IMAGE_PROXY_SECRET=some-secret
|
||||
- JWT_SECRET=some_secret
|
||||
|
|
@ -69,5 +48,3 @@ services:
|
|||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
elastic-test:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -28,33 +28,9 @@ services:
|
|||
- PG_USER=postgres
|
||||
- PG_PASSWORD=postgres
|
||||
- PG_DB=omnivore
|
||||
- ELASTIC_URL=http://elastic:9200
|
||||
- ELASTIC_USERNAME=blank
|
||||
- ELASTIC_PASSWORD=blank
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
elastic:
|
||||
condition: service_healthy
|
||||
|
||||
elastic:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
|
||||
container_name: "omnivore-elastic"
|
||||
healthcheck:
|
||||
test: curl 0.0.0.0:9200/_cat/health >/dev/null || exit 1
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 5
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- http.cors.allow-origin=*
|
||||
- http.cors.enabled=true
|
||||
- http.cors.allow-headers=X-Requested-With,X-Auth-Token,Content-Type,Content-Length,Authorization
|
||||
- http.cors.allow-credentials=true
|
||||
volumes:
|
||||
- ./.docker/elastic-data:/usr/share/elasticsearch/data
|
||||
ports:
|
||||
- "9200:9200"
|
||||
|
||||
api:
|
||||
build:
|
||||
|
|
@ -75,7 +51,6 @@ services:
|
|||
- PG_DB=omnivore
|
||||
- PG_PORT=5432
|
||||
- PG_POOL_MAX=20
|
||||
- ELASTIC_URL=http://elastic:9200
|
||||
- JAEGER_HOST=jaeger
|
||||
- IMAGE_PROXY_SECRET=some-secret
|
||||
- JWT_SECRET=some_secret
|
||||
|
|
@ -86,8 +61,6 @@ services:
|
|||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
elastic:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ GCS_UPLOAD_BUCKET=
|
|||
GCS_UPLOAD_SA_KEY_FILE_PATH=
|
||||
TWITTER_BEARER_TOKEN=
|
||||
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
|
||||
REMINDER_TASK_HANDLER_URL=
|
||||
ELASTIC_URL=http://localhost:9200
|
||||
SENDER_MESSAGE=msgs@sender.domain
|
||||
SENDER_FEEDBACK=feedback@sender.domain
|
||||
SENDER_GENERAL=no-reply@sender.domain
|
||||
CONTENT_FETCH_URL=http://localhost:9090/
|
||||
|
|
|
|||
|
|
@ -26,7 +26,5 @@ GCS_UPLOAD_PRIVATE_BUCKET=
|
|||
TWITTER_BEARER_TOKEN=
|
||||
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
|
||||
SEGMENT_WRITE_KEY='test'
|
||||
REMINDER_TASK_HANDLER_URL=http://localhost:4000/svc/reminders/trigger
|
||||
PUBSUB_VERIFICATION_TOKEN='123456'
|
||||
CONTENT_FETCH_URL=http://localhost:9090/
|
||||
ELASTIC_URL=http://localhost:9200
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"copy-files": "copyfiles -u 1 src/**/*.html dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elastic/elasticsearch": "~7.12.0",
|
||||
"@google-cloud/logging-winston": "^6.0.0",
|
||||
"@google-cloud/monitoring": "^4.0.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0",
|
||||
|
|
@ -55,7 +54,6 @@
|
|||
"dompurify": "^2.0.17",
|
||||
"dot-case": "^3.0.4",
|
||||
"dotenv": "^8.2.0",
|
||||
"elastic-ts": "^0.9.0",
|
||||
"express": "^4.17.1",
|
||||
"express-http-context2": "^1.0.0",
|
||||
"express-rate-limit": "^6.3.0",
|
||||
|
|
@ -71,8 +69,6 @@
|
|||
"intercom-client": "^3.1.4",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwks-rsa": "^2.0.3",
|
||||
"knex": "2.4.2",
|
||||
"knex-stringcase": "^1.4.2",
|
||||
"linkedom": "^0.14.9",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.2.1",
|
||||
|
|
|
|||
|
|
@ -9,18 +9,18 @@ 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 { Knex } from 'knex'
|
||||
import { EntityManager } from 'typeorm'
|
||||
import { promisify } from 'util'
|
||||
import { kx } from './datalayer/knex_config'
|
||||
import { createPubSubClient } from './datalayer/pubsub'
|
||||
import { sanitizeDirectiveTransformer } from './directives'
|
||||
import { env } from './env'
|
||||
import { createPubSubClient } from './pubsub'
|
||||
import { entityManager } from './repository'
|
||||
import { functionResolvers } from './resolvers/function_resolvers'
|
||||
import { ClaimsToSet, ResolverContext } from './resolvers/types'
|
||||
import ScalarResolvers from './scalars'
|
||||
import typeDefs from './schema'
|
||||
import { initModels } from './server'
|
||||
import { tracer } from './tracing'
|
||||
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
|
||||
import { SetClaimsRole } from './utils/dictionary'
|
||||
|
|
@ -46,8 +46,10 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
|||
const token = req?.cookies?.auth || req?.headers?.authorization
|
||||
const claims = await getClaimsByToken(token)
|
||||
|
||||
httpContext.set('claims', claims)
|
||||
|
||||
async function setClaims(
|
||||
tx: Knex.Transaction,
|
||||
em: EntityManager,
|
||||
uuid?: string,
|
||||
userRole?: string
|
||||
): Promise<void> {
|
||||
|
|
@ -55,18 +57,14 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
|||
(claims && claims.uid) || uuid || '00000000-0000-0000-0000-000000000000'
|
||||
const dbRole =
|
||||
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
|
||||
return tx.raw('SELECT * from omnivore.set_claims(?, ?)', [uid, dbRole])
|
||||
return em.query('SELECT * from omnivore.set_claims($1, $2)', [uid, dbRole])
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
log: logger,
|
||||
claims,
|
||||
kx,
|
||||
pubsub,
|
||||
// no caching for subscriptions
|
||||
// TODO: create per request caching for connections
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
models: initModels(kx, true),
|
||||
clearAuth: () => {
|
||||
res.clearCookie('auth')
|
||||
res.clearCookie('pendingUserAuth')
|
||||
|
|
@ -78,10 +76,10 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
|||
) => await setAuthInCookie(claims, res, secret),
|
||||
setClaims,
|
||||
authTrx: <TResult>(
|
||||
cb: (tx: Knex.Transaction) => TResult,
|
||||
cb: (em: EntityManager) => TResult,
|
||||
userRole?: string
|
||||
): Promise<TResult> =>
|
||||
kx.transaction(async (tx) => {
|
||||
entityManager.transaction(async (tx) => {
|
||||
await setClaims(tx, undefined, userRole)
|
||||
return cb(tx)
|
||||
}),
|
||||
|
|
|
|||
21
packages/api/src/data_source.ts
Normal file
21
packages/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
|
||||
})
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { ArticleData, CreateSet, keys as modelKeys, UpdateSet } from './model'
|
||||
import DataModel from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import { logMethod } from '../helpers'
|
||||
|
||||
class ArticleModel extends DataModel<ArticleData, CreateSet, UpdateSet> {
|
||||
public tableName = Table.PAGES
|
||||
protected modelKeys = modelKeys
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUrlAndHash(
|
||||
params: { url: ArticleData['url']; hash: ArticleData['hash'] },
|
||||
tx = this.kx
|
||||
): Promise<ArticleData | null> {
|
||||
const row: ArticleData | null = await tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.where(params)
|
||||
.first()
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUploadFileId(
|
||||
uploadFileId: string,
|
||||
tx = this.kx
|
||||
): Promise<ArticleData | null> {
|
||||
const row: ArticleData | null = await tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.where({ uploadFileId })
|
||||
.first()
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticleModel
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { ContentReader, PageType } from '../../generated/graphql'
|
||||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ---------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* title | text | | not null |
|
||||
* description | text | | |
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* published_at | timestamp with time zone | | |
|
||||
* url | text | | not null |
|
||||
* hash | text | | not null |
|
||||
* original_html | text | | |
|
||||
* content | text | | not null |
|
||||
* author | text | | |
|
||||
* image | text | | |
|
||||
* upload_file_id| uuid reference | | |
|
||||
* ```
|
||||
* */
|
||||
|
||||
export interface ArticleData {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
createdAt: Date
|
||||
publishedAt?: Date | null
|
||||
url: string
|
||||
hash: string
|
||||
originalHtml?: string | null
|
||||
content: string
|
||||
pageType: PageType
|
||||
author?: string | null
|
||||
image?: string | null
|
||||
uploadFileId?: string | null
|
||||
contentReader: ContentReader
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
'createdAt',
|
||||
'publishedAt',
|
||||
'url',
|
||||
'hash',
|
||||
'originalHtml',
|
||||
'content',
|
||||
'pageType',
|
||||
'author',
|
||||
'image',
|
||||
'uploadFileId',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'createdAt'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<ArticleData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<ArticleData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = [] as const
|
||||
|
||||
export type UpdateSet = Partialize<PickTuple<ArticleData, typeof updateKeys>>
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import {
|
||||
ArticleSavingRequestData,
|
||||
CreateSet,
|
||||
keys as modelKeys,
|
||||
UpdateSet,
|
||||
} from './model'
|
||||
import DataModel from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import { logMethod } from '../helpers'
|
||||
|
||||
class ArticleSavingRequestModel extends DataModel<
|
||||
ArticleSavingRequestData,
|
||||
CreateSet,
|
||||
UpdateSet
|
||||
> {
|
||||
public tableName = Table.ARTICLE_SAVING_REQUEST
|
||||
protected modelKeys = modelKeys
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUserId(
|
||||
userId: ArticleSavingRequestData['userId'],
|
||||
tx = this.kx
|
||||
): Promise<ArticleSavingRequestData | null> {
|
||||
const row: ArticleSavingRequestData | null = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId })
|
||||
.first(this.modelKeys)
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUserIdAndArticleId(
|
||||
userId: ArticleSavingRequestData['userId'],
|
||||
articleId: ArticleSavingRequestData['articleId'],
|
||||
tx = this.kx
|
||||
): Promise<ArticleSavingRequestData | null> {
|
||||
const row: ArticleSavingRequestData | null = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId, articleId })
|
||||
.first(this.modelKeys)
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticleSavingRequestModel
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* user_id | uuid | | not null |
|
||||
* article_id | uuid | | |
|
||||
* status | text | | | 'PROCESSING'::text
|
||||
* error_code | text | | |
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* task_name | text | | |
|
||||
* ```
|
||||
* */
|
||||
export interface ArticleSavingRequestData {
|
||||
id: string
|
||||
userId: string
|
||||
articleId?: string | null
|
||||
status?: string | null
|
||||
errorCode?: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
taskName?: string
|
||||
elasticPageId?: string
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'userId',
|
||||
'articleId',
|
||||
'status',
|
||||
'errorCode',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'taskName',
|
||||
'elasticPageId',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'createdAt', 'updatedAt', 'status'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<ArticleSavingRequestData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<ArticleSavingRequestData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = [
|
||||
'articleId',
|
||||
'status',
|
||||
'errorCode',
|
||||
'taskName',
|
||||
'elasticPageId',
|
||||
] as const
|
||||
|
||||
export type UpdateSet = PickTuple<ArticleSavingRequestData, typeof updateKeys>
|
||||
|
||||
export const getByParametersKeys = exclude(keys, ['id'] as const)
|
||||
|
||||
export type ParametersSet = PickTuple<
|
||||
ArticleSavingRequestData,
|
||||
typeof getByParametersKeys
|
||||
>
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import DataModel from './model'
|
||||
import { Knex } from 'knex'
|
||||
import DataLoader from 'dataloader'
|
||||
import { snakeCase } from 'snake-case'
|
||||
import { buildLogger } from '../utils/logger'
|
||||
import { SetClaimsRole } from '../utils/dictionary'
|
||||
|
||||
export const logger = buildLogger('datalayer')
|
||||
|
||||
export const setClaims = async (
|
||||
tx: Knex.Transaction,
|
||||
uuid?: string,
|
||||
userRole?: string
|
||||
): Promise<void> => {
|
||||
const uid = uuid || '00000000-0000-0000-0000-000000000000'
|
||||
const dbRole =
|
||||
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
|
||||
return tx.raw('SELECT * from omnivore.set_claims(?, ?)', [uid, dbRole])
|
||||
}
|
||||
|
||||
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
|
||||
|
||||
/**
|
||||
* Set to true to enable DB request statistics collection
|
||||
* When on, every 5 seconds CallCounter will be printing an object that counts DB calls by types.
|
||||
* **Must be off on prod**.
|
||||
* */
|
||||
export const ENABLE_DB_REQUEST_LOGGING = false
|
||||
|
||||
/** Doesnt preserve function context */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function debounce<A extends any[]>(
|
||||
func: (...args: A) => void,
|
||||
wait: number,
|
||||
immediate: boolean
|
||||
): (...args: A) => void {
|
||||
let timeout: NodeJS.Timeout | null
|
||||
return function (...args: A): void {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = global.setTimeout(function () {
|
||||
timeout = null
|
||||
if (!immediate) func(...args)
|
||||
}, wait)
|
||||
if (immediate && !timeout) func(...args)
|
||||
}
|
||||
}
|
||||
|
||||
class CallCounter {
|
||||
private counter: Record<string, number> = {}
|
||||
|
||||
log(tableName: string, methodName: string, params: string): void {
|
||||
const key = `${tableName}.${methodName}`
|
||||
if (key in this.counter) {
|
||||
this.counter[key]++
|
||||
} else {
|
||||
this.counter[key] = 1
|
||||
}
|
||||
const count = this.counter[key]
|
||||
// display in console the function call details
|
||||
logger.info(`Call (${count}): ${key}(${params})`, {
|
||||
labels: {
|
||||
source: 'callCounter',
|
||||
},
|
||||
})
|
||||
this.printCounts()
|
||||
}
|
||||
|
||||
printCounts = debounce(() => logger.info(this.counter), 5000, false)
|
||||
}
|
||||
|
||||
export const globalCounter = new CallCounter()
|
||||
|
||||
export function logMethod(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
target: Record<string, any>,
|
||||
propertyName: string,
|
||||
propertyDesciptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
const method = propertyDesciptor.value
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
propertyDesciptor.value = async function (
|
||||
this: DataModel<any, any, any>,
|
||||
...args: any[]
|
||||
) {
|
||||
// invoke wrapped function and get its return value
|
||||
const result = await method.apply(this, args)
|
||||
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
const params = args.map((a) => JSON.stringify(a)).join()
|
||||
globalCounter.log(this.tableName, propertyName, params)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
return propertyDesciptor
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a non-caching loader that fetches model data by a foreign key
|
||||
* @example Fetching replies by question ID
|
||||
* */
|
||||
export const edgeLoader = <
|
||||
ModelData extends { id: string },
|
||||
ForeignKey extends keyof ModelData
|
||||
>(
|
||||
kx: Knex,
|
||||
tableName: string,
|
||||
foreignKey: ForeignKey,
|
||||
modelKeys: readonly string[]
|
||||
): DataLoader<string, ModelData[]> =>
|
||||
new DataLoader(
|
||||
async (keys: readonly string[]) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(
|
||||
tableName,
|
||||
`load_by_${foreignKey}`,
|
||||
JSON.stringify(keys)
|
||||
)
|
||||
}
|
||||
const columnName = snakeCase(foreignKey as string)
|
||||
try {
|
||||
const rows: ModelData[] = await kx(tableName)
|
||||
.select(modelKeys)
|
||||
.whereIn(columnName, keys)
|
||||
|
||||
const keyMap: Record<string, ModelData[]> = {}
|
||||
for (const row of rows) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const keyValue = row[foreignKey] as any as string
|
||||
if (keyValue in keyMap) {
|
||||
keyMap[keyValue].push(row)
|
||||
} else keyMap[keyValue] = [row]
|
||||
}
|
||||
const result = []
|
||||
for (const key of keys) {
|
||||
result.push(keyMap[key] || [])
|
||||
}
|
||||
if (result.length !== keys.length) {
|
||||
logger.error('DataModel error: count mismatch ', keys, result)
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error('DataModel error: ', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
{ cache: false }
|
||||
)
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model'
|
||||
import { CreateSet, HighlightData, keys as modelKeys, UpdateSet } from './model'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import { Knex } from 'knex'
|
||||
import { ENABLE_DB_REQUEST_LOGGING, globalCounter, logger } from '../helpers'
|
||||
import DataLoader from 'dataloader'
|
||||
|
||||
class HighlightModel extends DataModel<HighlightData, CreateSet, UpdateSet> {
|
||||
protected batchLoader: DataLoader<string, HighlightData[]>
|
||||
public tableName = Table.HIGHLIGHT
|
||||
protected modelKeys = modelKeys
|
||||
|
||||
batchGet: DataLoader<string, HighlightData[]>['load']
|
||||
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
|
||||
// override dataloader to skip rows where 'deleted = true'
|
||||
this.loader = new DataLoader(
|
||||
async (keys) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(this.tableName, 'load', JSON.stringify(keys))
|
||||
}
|
||||
|
||||
try {
|
||||
const rows: HighlightData[] = await kx
|
||||
.table(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.whereIn('id', keys)
|
||||
.andWhere('deleted', false)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
const keyMap: Record<string, HighlightData> = {}
|
||||
for (const row of rows) {
|
||||
if (row.id in keyMap) continue
|
||||
keyMap[row.id] = row
|
||||
}
|
||||
const result = keys.map((key) => keyMap[key])
|
||||
if (result.length !== keys.length) {
|
||||
logger.error('DataModel error: count mismatch ', keys, result)
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error('DataModel error ', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
{ cache }
|
||||
)
|
||||
|
||||
// separate dataloader for fetching grouped highlights
|
||||
this.batchLoader = new DataLoader(async (articleIds) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(
|
||||
this.tableName,
|
||||
'batchLoad',
|
||||
JSON.stringify(articleIds)
|
||||
)
|
||||
}
|
||||
|
||||
const result = await this.kx
|
||||
.table(Table.HIGHLIGHT)
|
||||
.select(modelKeys)
|
||||
.whereIn('elasticPageId', articleIds)
|
||||
.andWhere('deleted', false)
|
||||
.orderBy(`${Table.HIGHLIGHT}.created_at`, 'desc')
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
.then((highlights: HighlightData[]) => {
|
||||
// group highlights so that each article has its own array of highlights
|
||||
const result: HighlightData[][] = Array.from(
|
||||
Array(articleIds.length),
|
||||
() => []
|
||||
)
|
||||
// keep track of nested array indices to preserve the order
|
||||
const positions = articleIds.reduce(
|
||||
(res, cur, i) => ({ ...res, [cur]: i }),
|
||||
{} as { [key: string]: number }
|
||||
)
|
||||
|
||||
highlights.forEach((highlight) => {
|
||||
const index = positions[highlight.elasticPageId]
|
||||
result[index].push({
|
||||
...highlight,
|
||||
updatedAt: highlight.updatedAt || highlight.createdAt,
|
||||
})
|
||||
this.loader.prime(highlight.id, highlight)
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
if (!result.length) {
|
||||
return new Array(articleIds.length).fill([])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
this.get = this.loader.load.bind(this.loader)
|
||||
this.getMany = this.loader.loadMany.bind(this.loader)
|
||||
this.batchGet = this.batchLoader.load.bind(this.batchLoader)
|
||||
}
|
||||
|
||||
async unshareAllHighlights(
|
||||
articleId: string,
|
||||
userId: string,
|
||||
tx: Knex.Transaction
|
||||
): Promise<HighlightData[]> {
|
||||
const rows: HighlightData[] = await tx(this.tableName)
|
||||
.update({ sharedAt: null })
|
||||
.where({ elasticPageId: articleId, userId })
|
||||
.andWhere(tx.raw(`shared_at is not null`))
|
||||
.returning(this.modelKeys)
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
async delete(
|
||||
id: string,
|
||||
tx: Knex.Transaction
|
||||
): Promise<HighlightData | { error: DataModelError }> {
|
||||
const [row]: HighlightData[] = await tx(this.tableName)
|
||||
.update({ deleted: true })
|
||||
.where({ id })
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!row) return { error: DataModelError.notFound }
|
||||
|
||||
this.loader.clear(id)
|
||||
return row
|
||||
}
|
||||
|
||||
async deleteMany(
|
||||
idList: string[],
|
||||
tx: Knex.Transaction
|
||||
): Promise<HighlightData[] | { error: DataModelError }> {
|
||||
const rows: HighlightData[] = await tx(this.tableName)
|
||||
.update({ deleted: true })
|
||||
.whereIn('id', idList)
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!rows.length) return { error: DataModelError.notFound }
|
||||
|
||||
idList.forEach((id) => this.loader.clear(id))
|
||||
return rows
|
||||
}
|
||||
|
||||
async getForUserArticle(
|
||||
userId: string,
|
||||
articleId: string
|
||||
): Promise<HighlightData[]> {
|
||||
const highlights: HighlightData[] = await this.kx
|
||||
.table(Table.HIGHLIGHT)
|
||||
.select(modelKeys)
|
||||
.where('user_id', userId)
|
||||
.andWhere('elastic_page_id', articleId)
|
||||
.andWhere('deleted', false)
|
||||
.orderBy(`${Table.HIGHLIGHT}.created_at`, 'desc')
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
const result = highlights.map((highlight) => {
|
||||
if (!highlight.updatedAt) {
|
||||
highlight.updatedAt = highlight.createdAt
|
||||
}
|
||||
this.loader.prime(highlight.id, highlight)
|
||||
return highlight
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export default HighlightModel
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ---------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* short_id | varchar(14) | | not null |
|
||||
* user_id | uuid | | not null |
|
||||
* article_id | uuid | | not null |
|
||||
* quote | text | | not null |
|
||||
* prefix | varchar(5000) | | |
|
||||
* suffix | varchar(5000) | | |
|
||||
* patch | text | | not null |
|
||||
* annotation | text | | |
|
||||
* deleted | boolean | | not null | false
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* updated_at | timestamp with time zone | | |
|
||||
* shared_at | timestamp with time zone | | |
|
||||
* ```
|
||||
* */
|
||||
|
||||
export interface HighlightData {
|
||||
id: string
|
||||
shortId: string
|
||||
userId: string
|
||||
articleId?: string
|
||||
quote: string
|
||||
prefix?: string | null
|
||||
suffix?: string | null
|
||||
patch: string
|
||||
annotation?: string | null
|
||||
deleted: boolean
|
||||
createdAt: Date
|
||||
updatedAt?: Date | null
|
||||
sharedAt?: Date | null
|
||||
elasticPageId: string
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'shortId',
|
||||
'userId',
|
||||
'articleId',
|
||||
'quote',
|
||||
'prefix',
|
||||
'suffix',
|
||||
'patch',
|
||||
'annotation',
|
||||
'deleted',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'sharedAt',
|
||||
'elasticPageId',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = [
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'sharedAt',
|
||||
'deleted',
|
||||
] as const
|
||||
|
||||
type DefaultedSet = PickTuple<HighlightData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<HighlightData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = ['annotation', 'sharedAt'] as const
|
||||
|
||||
export type UpdateSet = Partialize<PickTuple<HighlightData, typeof updateKeys>>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { env } from '../env'
|
||||
import knex from 'knex'
|
||||
import knexStringcase from 'knex-stringcase'
|
||||
|
||||
export const kx = knex(
|
||||
knexStringcase({
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: env.pg.host,
|
||||
port: env.pg.port,
|
||||
user: env.pg.userName,
|
||||
password: env.pg.password,
|
||||
database: env.pg.dbName,
|
||||
},
|
||||
pool: {
|
||||
max: env.pg.pool.max,
|
||||
acquireTimeoutMillis: 40000,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
@ -1,685 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
import {
|
||||
CreateSet,
|
||||
keys as modelKeys,
|
||||
ParametersSet,
|
||||
UpdateSet,
|
||||
UserArticleData,
|
||||
UserFeedArticleData,
|
||||
} from './model'
|
||||
import DataModel, { MAX_RECORDS_LIMIT } from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import {
|
||||
Article,
|
||||
PageType,
|
||||
SortOrder,
|
||||
SortParams,
|
||||
} from '../../generated/graphql'
|
||||
import {
|
||||
ENABLE_DB_REQUEST_LOGGING,
|
||||
globalCounter,
|
||||
logMethod,
|
||||
logger,
|
||||
} from '../helpers'
|
||||
import DataLoader from 'dataloader'
|
||||
import { ArticleData } from '../article/model'
|
||||
import { InFilter, LabelFilter, ReadFilter } from '../../utils/search'
|
||||
|
||||
type PartialArticle = Omit<
|
||||
Article,
|
||||
'updatedAt' | 'readingProgressPercent' | 'readingProgressAnchorIndex'
|
||||
>
|
||||
|
||||
type UserArticleStats = {
|
||||
highlightsCount: string
|
||||
annotationsCount: string
|
||||
}
|
||||
|
||||
const LINK_COLS = [
|
||||
'omnivore.links.id as linkId',
|
||||
'omnivore.links.userId',
|
||||
'omnivore.links.slug',
|
||||
'omnivore.links.article_url as url',
|
||||
'omnivore.links.createdAt',
|
||||
'omnivore.links.sharedAt',
|
||||
'omnivore.links.savedAt',
|
||||
'omnivore.links.sharedComment',
|
||||
'omnivore.links.articleReadingProgress',
|
||||
'omnivore.links.articleReadingProgressAnchorIndex',
|
||||
'omnivore.pages.id',
|
||||
'omnivore.pages.pageType',
|
||||
'omnivore.pages.url as originalArticleUrl',
|
||||
'omnivore.pages.title',
|
||||
'omnivore.pages.description',
|
||||
'omnivore.pages.hash',
|
||||
'omnivore.pages.author',
|
||||
'omnivore.pages.image',
|
||||
'omnivore.pages.pageType',
|
||||
'omnivore.pages.publishedAt',
|
||||
]
|
||||
|
||||
// When fetching the library list we don't need to
|
||||
// pull all the content out of the database into
|
||||
// memory just to discard it later
|
||||
const linkColsWithoutContent = (tx: Knex) => {
|
||||
return [
|
||||
tx.raw(`
|
||||
CASE
|
||||
WHEN omnivore.links.article_reading_progress < 98 THEN 'UNREAD'
|
||||
ELSE 'READ'
|
||||
END
|
||||
as read_status
|
||||
`),
|
||||
tx.raw(`
|
||||
CASE
|
||||
WHEN omnivore.links.archived_at is null THEN false
|
||||
ELSE true
|
||||
END
|
||||
as is_archived
|
||||
`),
|
||||
...LINK_COLS,
|
||||
]
|
||||
}
|
||||
|
||||
const linkCols = (tx: Knex) => {
|
||||
return [
|
||||
'omnivore.pages.content',
|
||||
'omnivore.pages.originalHtml',
|
||||
...linkColsWithoutContent(tx),
|
||||
]
|
||||
}
|
||||
|
||||
const readFilterQuery = (filter: ReadFilter) => {
|
||||
switch (filter) {
|
||||
case ReadFilter.ALL:
|
||||
return 'true'
|
||||
case ReadFilter.UNREAD:
|
||||
return 'omnivore.links.article_reading_progress < 98'
|
||||
case ReadFilter.READ:
|
||||
return 'omnivore.links.article_reading_progress >= 98'
|
||||
}
|
||||
}
|
||||
|
||||
class UserArticleModel extends DataModel<
|
||||
UserArticleData,
|
||||
CreateSet,
|
||||
UpdateSet
|
||||
> {
|
||||
public tableName = Table.LINKS
|
||||
protected modelKeys = modelKeys
|
||||
protected userAndArticleLoader: DataLoader<
|
||||
{ userId: string; articleId: string },
|
||||
UserArticleData
|
||||
>
|
||||
protected userArticleStatsLoader: DataLoader<string, UserArticleStats>
|
||||
getStats: DataLoader<string, UserArticleStats>['load']
|
||||
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
this.userAndArticleLoader = new DataLoader(
|
||||
async (keys) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(
|
||||
this.tableName,
|
||||
'userId_articleId_load',
|
||||
JSON.stringify(keys)
|
||||
)
|
||||
}
|
||||
try {
|
||||
const rows: UserArticleData[] = await this.kx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.whereIn(
|
||||
['articleId', 'userId'],
|
||||
keys.map((key) => [key.articleId, key.userId])
|
||||
)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
const keyMap: Map<string, UserArticleData> = new Map()
|
||||
for (const row of rows) {
|
||||
const hash = `${row.userId}.${row.articleId}`
|
||||
if (keyMap.has(hash)) continue
|
||||
keyMap.set(hash, row)
|
||||
}
|
||||
|
||||
const result = keys.map(({ userId, articleId }) =>
|
||||
keyMap.get(`${userId}.${articleId}`)
|
||||
) as UserArticleData[]
|
||||
// logger.debug('\n\n\n\n\nResult for userId_articleId_load', { keys, result });
|
||||
|
||||
if (result.length !== keys.length) {
|
||||
logger.error('DataModel error: count mismatch ', keys, result)
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error('DataModel error: ', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
{ cache }
|
||||
)
|
||||
|
||||
this.userArticleStatsLoader = new DataLoader(
|
||||
async (keys) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(
|
||||
this.tableName,
|
||||
'userArticleId_stats_load',
|
||||
JSON.stringify(keys)
|
||||
)
|
||||
}
|
||||
try {
|
||||
const rows: ({ id: string } & UserArticleStats)[] = await this.kx(
|
||||
`${this.tableName} as ua`
|
||||
)
|
||||
.select([
|
||||
'ua.id',
|
||||
this.kx.raw('count(h2.id) as highlights_count'),
|
||||
this.kx.raw('count(h2.annotation) as annotations_count'),
|
||||
])
|
||||
.leftJoin(`${Table.HIGHLIGHT} as h2`, function () {
|
||||
this.on('h2.article_id', '=', 'ua.article_id')
|
||||
this.andOn('h2.user_id', '=', 'ua.user_id')
|
||||
this.andOn('h2.deleted', '=', kx.raw('FALSE'))
|
||||
})
|
||||
.whereIn('ua.id', keys)
|
||||
.groupBy(['ua.id'])
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
const keyMap: Map<string, UserArticleStats> = new Map()
|
||||
for (const row of rows) {
|
||||
if (keyMap.has(row.id)) continue
|
||||
keyMap.set(row.id, row)
|
||||
}
|
||||
|
||||
const result = keys.map((id) => {
|
||||
const stats = keyMap.get(id)
|
||||
if (!stats)
|
||||
throw new Error('User article stats data loader state missmatch!')
|
||||
return stats
|
||||
})
|
||||
|
||||
// logger.debug('\n\n\n\n\nResult for userArticleId_stats_load', { keys, result });
|
||||
|
||||
if (result.length !== keys.length) {
|
||||
logger.error('DataModel error: count mismatch ', keys, result)
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error('DataModel error: ', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
{ cache }
|
||||
)
|
||||
|
||||
this.getStats = this.userArticleStatsLoader.load.bind(
|
||||
this.userArticleStatsLoader
|
||||
)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByParameters<K extends keyof ParametersSet>(
|
||||
userId: UserArticleData['userId'],
|
||||
params: Record<K, UserArticleData[K]>,
|
||||
tx = this.kx
|
||||
): Promise<UserArticleData | null> {
|
||||
const row: UserArticleData | null = await tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.where({ userId })
|
||||
.andWhere(params)
|
||||
.orderBy('created_at', 'desc')
|
||||
.first()
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async articlesForUser<K extends keyof ParametersSet>(
|
||||
userId: UserArticleData['userId'],
|
||||
tx = this.kx
|
||||
): Promise<UserArticleData[] | null> {
|
||||
const rows: UserArticleData[] | null = await tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.where({ userId })
|
||||
.orderBy('saved_at', 'desc')
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
if (!rows || !rows.length) {
|
||||
return null
|
||||
}
|
||||
rows.forEach((r) => this.loader.prime(r.id, r))
|
||||
return rows
|
||||
}
|
||||
|
||||
// @logMethod
|
||||
async getByArticleId(
|
||||
userId: UserArticleData['userId'],
|
||||
articleId: UserArticleData['articleId'],
|
||||
_tx = this.kx
|
||||
): Promise<UserArticleData | null> {
|
||||
return this.userAndArticleLoader.load({ userId, articleId })
|
||||
}
|
||||
|
||||
/* TODO: move to separate dataloader for checking list of articles have been saved or not */
|
||||
@logMethod
|
||||
async getCountByParameters<K extends keyof ParametersSet>(
|
||||
userId: UserArticleData['userId'],
|
||||
params: Record<K, UserArticleData[K]>,
|
||||
tx = this.kx
|
||||
): Promise<number> {
|
||||
const [{ rowCount }] = await tx(this.tableName)
|
||||
.count('id as rowCount')
|
||||
.where({ userId })
|
||||
.andWhere(params)
|
||||
return rowCount as number
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async updateByArticleId<K extends keyof UpdateSet>(
|
||||
userId: UserArticleData['userId'],
|
||||
articleId: UserArticleData['articleId'],
|
||||
params: Record<K, UserArticleData[K]>,
|
||||
tx = this.kx
|
||||
): Promise<UserArticleData | null> {
|
||||
const rows: UserArticleData[] | null = await tx(this.tableName)
|
||||
.update(params)
|
||||
.where({ articleId, userId })
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!rows) return null
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async updateByIds<K extends keyof UpdateSet>(
|
||||
ids: UserArticleData['id'][],
|
||||
params: Record<K, UserArticleData[K]>,
|
||||
tx = this.kx
|
||||
): Promise<UserArticleData | null> {
|
||||
const rows: UserArticleData[] | null = await tx(this.tableName)
|
||||
.update(params)
|
||||
.whereIn('id', ids)
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!rows) return null
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
async getUserFeedArticlesLegacy(
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<UserFeedArticleData[] | null> {
|
||||
const rows = await tx(this.tableName)
|
||||
.select([
|
||||
'omnivore.links.id',
|
||||
'omnivore.links.user_id',
|
||||
'omnivore.links.article_id',
|
||||
'omnivore.links.shared_at',
|
||||
'omnivore.links.saved_at',
|
||||
'omnivore.links.shared_comment',
|
||||
])
|
||||
.leftJoin('omnivore.user_friends', function () {
|
||||
this.on(
|
||||
tx.raw('omnivore.user_friends.user_id::text = ?', [userId])
|
||||
).andOn(
|
||||
'omnivore.user_friends.friend_user_id',
|
||||
'=',
|
||||
'omnivore.links.user_id'
|
||||
)
|
||||
})
|
||||
.whereNotNull('omnivore.links.shared_at')
|
||||
.andWhere(function () {
|
||||
this.whereRaw('omnivore.links.user_id::text = ?', [
|
||||
userId,
|
||||
]).orWhereNotNull('omnivore.user_friends.id')
|
||||
})
|
||||
.orderBy('omnivore.links.shared_at', 'DESC')
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getSharedArticlesCount(
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<UserFeedArticleData[] | null> {
|
||||
const rows = await tx(this.tableName)
|
||||
.select([tx.raw('count(omnivore.links.id) as shared_articles_count')])
|
||||
.whereNotNull('omnivore.links.shared_at')
|
||||
.andWhere('omnivore.links.user_id', userId)
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows[0]?.sharedArticlesCount || 0
|
||||
}
|
||||
|
||||
async getUserSharedArticles(
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<UserFeedArticleData[] | null> {
|
||||
const rows = await tx(this.tableName)
|
||||
.select([
|
||||
'omnivore.links.id',
|
||||
'omnivore.links.user_id',
|
||||
'omnivore.links.article_id',
|
||||
'omnivore.links.shared_at',
|
||||
'omnivore.links.saved_at',
|
||||
'omnivore.links.shared_comment',
|
||||
])
|
||||
.whereNotNull('omnivore.links.shared_at')
|
||||
.andWhere('omnivore.links.user_id', userId)
|
||||
.orderBy('omnivore.links.shared_at', 'DESC')
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getPaginated(
|
||||
args: {
|
||||
cursor: string
|
||||
first: number
|
||||
sort?: SortParams
|
||||
query?: string
|
||||
inFilter: InFilter
|
||||
readFilter: ReadFilter
|
||||
typeFilter: PageType | undefined
|
||||
labelFilters: LabelFilter[]
|
||||
},
|
||||
userId: string,
|
||||
tx = this.kx,
|
||||
notNullField: string | null = null
|
||||
): Promise<[PartialArticle[], number] | null> {
|
||||
const { cursor, first, sort, query, readFilter } = args
|
||||
|
||||
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
|
||||
const whereOperator = sort?.order === SortOrder.Ascending ? '>=' : '<='
|
||||
|
||||
const queryPromise = tx(this.tableName)
|
||||
.select(linkColsWithoutContent(tx))
|
||||
.innerJoin(Table.PAGES, 'pages.id', 'links.article_id')
|
||||
.where({ 'links.user_id': userId })
|
||||
.where(tx.raw(readFilterQuery(readFilter)))
|
||||
|
||||
if (query) {
|
||||
const searchQuery = tx.raw(`tsv @@ websearch_to_tsquery(?)`, query)
|
||||
queryPromise.where(searchQuery)
|
||||
}
|
||||
|
||||
if (args.typeFilter) {
|
||||
queryPromise.where(
|
||||
tx.raw(`omnivore.pages.page_type = ?`, args.typeFilter)
|
||||
)
|
||||
}
|
||||
|
||||
if (args.inFilter !== InFilter.ALL) {
|
||||
switch (args.inFilter) {
|
||||
case InFilter.INBOX:
|
||||
queryPromise.whereNull('links.archivedAt')
|
||||
break
|
||||
case InFilter.ARCHIVE:
|
||||
queryPromise.whereNotNull('links.archivedAt')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (notNullField) {
|
||||
queryPromise.whereNotNull(notNullField)
|
||||
}
|
||||
|
||||
const [{ count: totalCount }] =
|
||||
(await tx(queryPromise.clone().as('subq')).count()) || '0'
|
||||
|
||||
// This is a temp hack as we move from time based cursors to
|
||||
// using offset, this will be replaced when we change the
|
||||
// storage backend.
|
||||
if (cursor && Number(cursor) > 1546300800000) {
|
||||
queryPromise.where(
|
||||
`omnivore.links.saved_at`,
|
||||
whereOperator,
|
||||
new Date(parseInt(cursor) + (sortOrder === 'ASC' ? -1 : 1)) //Time Comparison Bias
|
||||
)
|
||||
} else if (cursor && Number(cursor) <= 1546300800000) {
|
||||
queryPromise.offset(Number(cursor))
|
||||
}
|
||||
|
||||
// If first is greater than 100 set it to 100
|
||||
const limit = first > 100 ? 100 : first
|
||||
queryPromise
|
||||
.orderBy('omnivore.links.saved_at', sortOrder)
|
||||
.orderBy('omnivore.links.created_at', sortOrder)
|
||||
.orderBy('omnivore.links.id', sortOrder)
|
||||
.limit(limit)
|
||||
|
||||
const rows = await queryPromise
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
|
||||
return [rows, parseInt(totalCount as string)]
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getUserFeedArticlesPaginatedWithHighlights(
|
||||
args: { cursor: string; first: number; sharedByUser?: string | null },
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<
|
||||
| (UserFeedArticleData & {
|
||||
highlightsCount: number
|
||||
annotationsCount: number
|
||||
})[]
|
||||
| null
|
||||
> {
|
||||
const { cursor, first, sharedByUser } = args
|
||||
|
||||
// let userArticlesListQuery;
|
||||
|
||||
// Getting the list of friends user ids
|
||||
const friendsListQuery = sharedByUser
|
||||
? ([] as never)
|
||||
: tx(Table.USER_FRIEND).select('friendUserId').where('userId', userId)
|
||||
|
||||
// Getting the links ids list that applies to the "My feed" page
|
||||
const userArticlesListQuery = tx(this.tableName)
|
||||
.select('id')
|
||||
.where(function () {
|
||||
this.whereIn('userId', friendsListQuery)
|
||||
this.orWhere('userId', sharedByUser || userId)
|
||||
})
|
||||
.whereNotNull('sharedAt')
|
||||
|
||||
// Collecting the highlights and annotations stats for the links records
|
||||
const userArticlesStatsQuery = tx(`${this.tableName} as ua`)
|
||||
.select([
|
||||
'ua.article_id',
|
||||
'ua.user_id',
|
||||
tx.raw('count(h2.id) as highlights_count'),
|
||||
tx.raw(
|
||||
`count(case when h2.annotation is not null and h2.annotation <> '' then 1 else null end) as annotations_count`
|
||||
),
|
||||
])
|
||||
.leftJoin(`${Table.HIGHLIGHT} as h2`, function () {
|
||||
this.on('h2.article_id', '=', 'ua.article_id')
|
||||
this.andOn('h2.user_id', '=', 'ua.user_id')
|
||||
this.andOn('h2.deleted', '=', tx.raw('FALSE'))
|
||||
})
|
||||
// TODO: Check if using the join isntead could be more efficient approach here
|
||||
// (https://github.com/omnivore-app/omnivore/pull/1053#discussion_r604914773)
|
||||
.whereIn('ua.id', userArticlesListQuery)
|
||||
.where('ua.shared_with_highlights', 'TRUE')
|
||||
.groupBy(['ua.article_id', 'ua.user_id'])
|
||||
|
||||
// Combining required link columns with the stats calculated
|
||||
const userArticlesQuery = tx(`${this.tableName} as ua`)
|
||||
.select([
|
||||
'ua.id',
|
||||
'ua.article_id',
|
||||
tx.raw('null as highlight_id'),
|
||||
'ua.user_id',
|
||||
'uas.highlights_count',
|
||||
'uas.annotations_count',
|
||||
'ua.shared_at',
|
||||
'ua.saved_at',
|
||||
'ua.shared_comment',
|
||||
'ua.shared_with_highlights',
|
||||
])
|
||||
.leftJoin(userArticlesStatsQuery.as('uas'), function () {
|
||||
this.on('uas.article_id', '=', 'ua.article_id')
|
||||
this.andOn('uas.user_id', '=', 'ua.user_id')
|
||||
})
|
||||
.whereIn('ua.id', userArticlesListQuery)
|
||||
|
||||
// Getting the shared highlights
|
||||
const highlightsQuery = tx(`${Table.HIGHLIGHT} as hi`)
|
||||
.select([
|
||||
'id',
|
||||
'article_id',
|
||||
'id as highlight_id',
|
||||
'user_id',
|
||||
tx.raw('0 as highlights_count'),
|
||||
tx.raw('0 as annotations_count'),
|
||||
'shared_at',
|
||||
tx.raw('null as saved_at'),
|
||||
tx.raw('null as shared_comment'),
|
||||
tx.raw('null as shared_with_highlights'),
|
||||
])
|
||||
.where(function () {
|
||||
this.whereIn('userId', friendsListQuery)
|
||||
this.orWhere('userId', sharedByUser || userId)
|
||||
})
|
||||
.where(tx.raw('deleted is not true'))
|
||||
.andWhere(tx.raw('shared_at is not null'))
|
||||
|
||||
if (sharedByUser) {
|
||||
highlightsQuery.andWhere('user_id', sharedByUser)
|
||||
}
|
||||
|
||||
// Merging links record with the shared highlights
|
||||
// NOTE: Number of columns and order should match in both queries
|
||||
const feedItemsQuery = tx
|
||||
.union([userArticlesQuery, highlightsQuery])
|
||||
.orderBy('shared_at', 'DESC')
|
||||
|
||||
// Appending resulting query with a cursor if provided
|
||||
const resultQuery = cursor
|
||||
? tx
|
||||
.select('*')
|
||||
.from(feedItemsQuery.as('r'))
|
||||
.where(
|
||||
`shared_at`,
|
||||
'<=',
|
||||
new Date(parseInt(cursor) + 1) //Time Comparison Bias
|
||||
)
|
||||
: feedItemsQuery
|
||||
|
||||
resultQuery.limit(first)
|
||||
|
||||
const rows = await resultQuery
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
this.userArticleStatsLoader.prime(row.id, {
|
||||
highlightsCount: row.highlightsCount,
|
||||
annotationsCount: row.annotationsCount,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getForUser(
|
||||
userId: string,
|
||||
articleId: string,
|
||||
tx = this.kx
|
||||
): Promise<(ArticleData & UserArticleData) | null> {
|
||||
const row = await tx(Table.LINKS)
|
||||
.select(linkCols(tx))
|
||||
.innerJoin(Table.PAGES, 'links.article_id', 'pages.id')
|
||||
.where('links.user_id', userId)
|
||||
.where('links.article_id', articleId)
|
||||
.first()
|
||||
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getBySlug(
|
||||
username: string,
|
||||
slug: string,
|
||||
tx = this.kx
|
||||
): Promise<(ArticleData & UserArticleData) | null> {
|
||||
const row = await tx(Table.LINKS)
|
||||
.select(linkCols(tx))
|
||||
.innerJoin(Table.PAGES, 'links.article_id', 'pages.id')
|
||||
.innerJoin(Table.USER_PROFILE, 'links.user_id', 'user_profile.user_id')
|
||||
.where('user_profile.username', username)
|
||||
.where('links.slug', slug)
|
||||
.first()
|
||||
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async getByUserIdAndSlug(
|
||||
uid: string,
|
||||
slug: string,
|
||||
tx = this.kx
|
||||
): Promise<(ArticleData & UserArticleData) | null> {
|
||||
const row = await tx(Table.LINKS)
|
||||
.select(linkCols(tx))
|
||||
.innerJoin(Table.PAGES, 'links.article_id', 'pages.id')
|
||||
.where('links.user_id', uid)
|
||||
.where('links.slug', slug)
|
||||
.first()
|
||||
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async setBookmarkOnMultiple(
|
||||
userId: string,
|
||||
articles: CreateSet[],
|
||||
tx = this.kx
|
||||
): Promise<UserArticleData[] | null> {
|
||||
const rows = await tx(Table.LINKS)
|
||||
.insert(articles)
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!rows) return null
|
||||
rows.forEach((r) => this.loader.prime(r.id, r))
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
export default UserArticleModel
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ---------------------------------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* user_id | uuid | | not null |
|
||||
* article_id | uuid | | not null |
|
||||
* article_url | text | | not null |
|
||||
* slug | text | | not null |
|
||||
* article_hash | text | | not null |
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* shared_at | timestamp with time zone | | |
|
||||
* shared_comment | text | | |
|
||||
* article_reading_progress | real | | not null | 0
|
||||
* article_reading_progress_anchor_index | integer | | not null | 0
|
||||
* saved_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* shared_with_highlights | boolean | | | false
|
||||
* ```
|
||||
* */
|
||||
export interface UserArticleData {
|
||||
id: string
|
||||
userId: string
|
||||
articleId: string
|
||||
slug: string
|
||||
articleUrl: string
|
||||
articleHash: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
savedAt: Date
|
||||
sharedAt?: Date | null
|
||||
archivedAt?: Date | null
|
||||
sharedComment?: string | null
|
||||
articleReadingProgress: number
|
||||
articleReadingProgressAnchorIndex: number
|
||||
sharedWithHighlights?: boolean
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
export interface UserFeedArticleData {
|
||||
id: string
|
||||
userId: string
|
||||
articleId: string
|
||||
sharedAt: Date
|
||||
createdAt: Date
|
||||
sharedComment?: string | null
|
||||
sharedWithHighlights?: boolean
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'userId',
|
||||
'articleId',
|
||||
'slug',
|
||||
'articleUrl',
|
||||
'articleHash',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'savedAt',
|
||||
'sharedAt',
|
||||
'archivedAt',
|
||||
'sharedComment',
|
||||
'articleReadingProgress',
|
||||
'articleReadingProgressAnchorIndex',
|
||||
'sharedWithHighlights',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = [
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'savedAt',
|
||||
'articleReadingProgress',
|
||||
'articleReadingProgressAnchorIndex',
|
||||
'articleDeleted',
|
||||
'sharedWithHighlights',
|
||||
] as const
|
||||
|
||||
type DefaultedSet = PickTuple<UserArticleData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<UserArticleData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = [
|
||||
'slug',
|
||||
'savedAt',
|
||||
'articleId',
|
||||
'articleUrl',
|
||||
'articleHash',
|
||||
'sharedAt',
|
||||
'archivedAt',
|
||||
'sharedComment',
|
||||
'articleReadingProgress',
|
||||
'articleReadingProgressAnchorIndex',
|
||||
'sharedWithHighlights',
|
||||
] as const
|
||||
|
||||
export type UpdateSet = Partialize<
|
||||
PickTuple<UserArticleData, typeof updateKeys>
|
||||
>
|
||||
|
||||
export const getByParametersKeys = exclude(keys, ['id'] as const)
|
||||
|
||||
export type ParametersSet = PickTuple<
|
||||
UserArticleData,
|
||||
typeof getByParametersKeys
|
||||
>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { Knex } from 'knex'
|
||||
import { LinkShareInfo } from '../../generated/graphql'
|
||||
import { DataModels } from '../../resolvers/types'
|
||||
import { getPageByParam } from '../../elastic/pages'
|
||||
|
||||
// once we have links setup properly in the API we will remove this method
|
||||
// and have a getShareInfoForLink method
|
||||
export const getShareInfoForArticle = async (
|
||||
kx: Knex,
|
||||
userId: string,
|
||||
articleId: string,
|
||||
models: DataModels
|
||||
): Promise<LinkShareInfo | undefined> => {
|
||||
// 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 page = await getPageByParam({ userId, _id: articleId })
|
||||
|
||||
if (!page) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = await kx('omnivore.link_share_info')
|
||||
.select('*')
|
||||
.where({ elastic_page_id: page.id })
|
||||
.first()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const createOrUpdateLinkShareInfo = async (
|
||||
tx: Knex,
|
||||
linkId: string,
|
||||
title: string,
|
||||
description: string
|
||||
): Promise<LinkShareInfo> => {
|
||||
const item = { linkId, title, description }
|
||||
const [result]: LinkShareInfo[] = await tx('omnivore.link_share_info')
|
||||
.insert(item)
|
||||
.onConflict('link_id')
|
||||
.merge()
|
||||
.returning('*')
|
||||
|
||||
if (!result) {
|
||||
return Promise.reject(new Error('No result'))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import DataLoader from 'dataloader'
|
||||
import { Knex } from 'knex'
|
||||
import { ENABLE_DB_REQUEST_LOGGING, globalCounter, logger } from './helpers'
|
||||
|
||||
export enum DataModelError {
|
||||
notFound = 'NOT_FOUND',
|
||||
}
|
||||
|
||||
export const MAX_RECORDS_LIMIT = 1000
|
||||
|
||||
abstract class DataModel<
|
||||
ModelData extends { id: string },
|
||||
CreateSet,
|
||||
UpdateSet
|
||||
> {
|
||||
protected loader: DataLoader<string, ModelData>
|
||||
public tableName!: string
|
||||
protected modelKeys!: readonly (keyof ModelData)[]
|
||||
kx: Knex
|
||||
get: DataLoader<string, ModelData>['load']
|
||||
getMany: DataLoader<string, ModelData>['loadMany']
|
||||
|
||||
/**
|
||||
* @param kx - DB connection
|
||||
* @param userId - user id to use when executing data loader queries
|
||||
* */
|
||||
constructor(kx: Knex, cache = true) {
|
||||
this.kx = kx
|
||||
this.loader = new DataLoader(
|
||||
async (keys) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(this.tableName, 'load', JSON.stringify(keys))
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows: ModelData[] = await this.kx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.whereIn('id', keys)
|
||||
|
||||
const keyMap: Record<string, ModelData> = {}
|
||||
for (const row of rows) {
|
||||
if (row.id in keyMap) continue
|
||||
keyMap[row.id] = row
|
||||
}
|
||||
const result = keys.map((key) => keyMap[key])
|
||||
if (result.length !== keys.length) {
|
||||
logger.error('DataModel error: count mismatch ', keys, result)
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error('DataModel error: ', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
{ cache }
|
||||
)
|
||||
this.get = this.loader.load.bind(this.loader)
|
||||
this.getMany = this.loader.loadMany.bind(this.loader)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets entity that accomplish "whereIn" condition
|
||||
* @param field - string specifying a field to use in "whereIn" condition
|
||||
* @param values - an array of values to compare in "whereIn" condition
|
||||
* @param tx - DB transaction
|
||||
* @example
|
||||
* // Return entity that match id comparison
|
||||
* const collaboratorsIds = ['1', '2'];
|
||||
* return models.user.getWhereIn('id', collaboratorsIds);
|
||||
*/
|
||||
async getWhereIn<K extends keyof ModelData>(
|
||||
field: K,
|
||||
values: ModelData[K][],
|
||||
tx = this.kx,
|
||||
notNullField: string | null = null
|
||||
): Promise<ModelData[]> {
|
||||
let queryPromise = tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.whereIn(field, values)
|
||||
.orderBy('created_at', 'desc')
|
||||
if (notNullField) {
|
||||
queryPromise = queryPromise.whereNotNull(notNullField)
|
||||
}
|
||||
const rows: ModelData[] = await queryPromise
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
async getAll(tx = this.kx): Promise<ModelData[]> {
|
||||
const rows: ModelData[] = await tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.orderBy('created_at', 'desc')
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
async create(data: CreateSet, tx = this.kx): Promise<ModelData> {
|
||||
const [row]: ModelData[] = await tx(this.tableName)
|
||||
.insert(data)
|
||||
.returning('*')
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async createMany(
|
||||
data: CreateSet[],
|
||||
tx: Knex.Transaction
|
||||
): Promise<ModelData[]> {
|
||||
const rows: ModelData[] = await tx
|
||||
.batchInsert(this.tableName, data as never)
|
||||
.returning('*')
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateSet, tx = this.kx): Promise<ModelData> {
|
||||
const [row]: ModelData[] = await tx(this.tableName)
|
||||
.update(data)
|
||||
.where({ id })
|
||||
.returning('*')
|
||||
this.loader.prime(id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async delete(
|
||||
id: string,
|
||||
tx: Knex.Transaction
|
||||
): Promise<ModelData | { error: DataModelError }> {
|
||||
const [row]: ModelData[] = await tx(this.tableName)
|
||||
.where({ id })
|
||||
.delete()
|
||||
.returning('*')
|
||||
|
||||
if (!row) return { error: DataModelError.notFound }
|
||||
|
||||
this.loader.clear(id)
|
||||
return row
|
||||
}
|
||||
|
||||
async deleteWhere<K extends keyof ModelData>(
|
||||
params: Record<K, ModelData[K]>,
|
||||
tx: Knex.Transaction
|
||||
): Promise<ModelData[]> {
|
||||
const rows: ModelData[] = await tx(this.tableName)
|
||||
.andWhere(params)
|
||||
.delete()
|
||||
.returning('*')
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.clear(row.id)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
async deleteWhereIn<K extends keyof ModelData>(
|
||||
params: Record<K, ModelData[K][]>,
|
||||
tx: Knex.Transaction
|
||||
): Promise<ModelData[]> {
|
||||
const rows: ModelData[] = await tx(this.tableName)
|
||||
.where((builder) => {
|
||||
for (const field in params) {
|
||||
builder.whereIn(field, params[field])
|
||||
}
|
||||
})
|
||||
.delete()
|
||||
.returning('*')
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.clear(row.id)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
export default DataModel
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import {
|
||||
ENABLE_DB_REQUEST_LOGGING,
|
||||
globalCounter,
|
||||
logMethod,
|
||||
} from './../helpers'
|
||||
import { CreateSet, keys as modelKeys, ReactionData, UpdateSet } from './model'
|
||||
import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
|
||||
import DataLoader from 'dataloader'
|
||||
|
||||
class ReactionModel extends DataModel<ReactionData, CreateSet, UpdateSet> {
|
||||
public tableName = Table.REACTION
|
||||
protected modelKeys = modelKeys
|
||||
|
||||
protected getBatchLoader: (
|
||||
type: 'highlightId' | 'userArticleId'
|
||||
) => DataLoader<string, ReactionData[]>
|
||||
|
||||
batchGetFromArticle: DataLoader<string, ReactionData[]>['load']
|
||||
batchGetFromHighlight: DataLoader<string, ReactionData[]>['load']
|
||||
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
|
||||
// override dataloader to skip rows where 'deleted = true'
|
||||
|
||||
// separate dataloader for fetching grouped highlights
|
||||
this.getBatchLoader = (type: 'highlightId' | 'userArticleId') =>
|
||||
new DataLoader(async (ids) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(this.tableName, 'batchLoad', JSON.stringify(ids))
|
||||
}
|
||||
|
||||
const result: ReactionData[][] = await this.kx(Table.REACTION)
|
||||
.select(modelKeys)
|
||||
.whereIn(type, ids)
|
||||
.andWhere('deleted', false)
|
||||
.orderBy(`${Table.REACTION}.created_at`, 'desc')
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
.then((reactions: ReactionData[]) => {
|
||||
// group highlights so that each article has its own array of highlights
|
||||
const arr: ReactionData[][] = Array.from(
|
||||
Array(ids.length),
|
||||
() => []
|
||||
)
|
||||
// keep track of nested array indices to preserve the order
|
||||
const positions = ids.reduce(
|
||||
(acc, cur, i) => ({ ...acc, [cur]: i }),
|
||||
{} as { [key: string]: number }
|
||||
)
|
||||
|
||||
reactions.forEach((re) => {
|
||||
const pos = re[type]
|
||||
if (!pos) {
|
||||
return
|
||||
}
|
||||
const index = positions[pos]
|
||||
arr[index].push({
|
||||
...re,
|
||||
updatedAt: re.updatedAt || re.createdAt,
|
||||
})
|
||||
this.loader.prime(re.id, re)
|
||||
})
|
||||
|
||||
return arr
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
this.get = this.loader.load.bind(this.loader)
|
||||
this.getMany = this.loader.loadMany.bind(this.loader)
|
||||
|
||||
const ba = this.getBatchLoader('userArticleId')
|
||||
const bh = this.getBatchLoader('highlightId')
|
||||
|
||||
this.batchGetFromArticle = ba.load.bind(ba)
|
||||
this.batchGetFromHighlight = bh.load.bind(bh)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUserAndParam<K extends keyof CreateSet>(
|
||||
userId: ReactionData['userId'],
|
||||
params: Record<K, ReactionData[K]>,
|
||||
tx = this.kx
|
||||
): Promise<ReactionData | null> {
|
||||
const row: ReactionData | null = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId })
|
||||
.andWhere(params)
|
||||
.andWhere('deleted', false)
|
||||
.first(this.modelKeys)
|
||||
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
async delete(
|
||||
id: string,
|
||||
tx: Knex.Transaction
|
||||
): Promise<ReactionData | { error: DataModelError }> {
|
||||
const [row]: ReactionData[] = await tx(this.tableName)
|
||||
.update({ deleted: true })
|
||||
.where({ id })
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!row) return { error: DataModelError.notFound }
|
||||
|
||||
this.loader.clear(id)
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
export default ReactionModel
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* --------------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* user_id | uuid | | not null |
|
||||
* user_article_id | uuid | | |
|
||||
* highlight_id | uuid | | |
|
||||
* highlight_reply_id | uuid | | |
|
||||
* code | varchar(50) | | not null |
|
||||
* deleted | boolean | | not null | false
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* updated_at | timestamp with time zone | | |
|
||||
* ```
|
||||
* */
|
||||
|
||||
export interface ReactionData {
|
||||
id: string
|
||||
userId: string
|
||||
userArticleId?: string | null
|
||||
highlightId?: string | null
|
||||
highlightReplyId?: string | null
|
||||
code: string
|
||||
deleted: boolean
|
||||
createdAt: Date
|
||||
updatedAt?: Date | null
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'userId',
|
||||
'userArticleId',
|
||||
'highlightId',
|
||||
'highlightReplyId',
|
||||
'code',
|
||||
'deleted',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = [
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deleted',
|
||||
] as const
|
||||
|
||||
type DefaultedSet = PickTuple<ReactionData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<ReactionData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = ['code'] as const
|
||||
|
||||
export type UpdateSet = Partialize<PickTuple<ReactionData, typeof updateKeys>>
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import {
|
||||
CreateSet,
|
||||
keys as modelKeys,
|
||||
ParametersSet,
|
||||
ReminderData,
|
||||
UpdateSet,
|
||||
} from './model'
|
||||
import DataModel, { DataModelError } from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import { logMethod } from '../helpers'
|
||||
import { ArticleData } from '../article/model'
|
||||
import { UserArticleData } from '../links/model'
|
||||
|
||||
const JOIN_COLS = [
|
||||
'links2.id',
|
||||
'links2.slug',
|
||||
'pages.title',
|
||||
'pages.description',
|
||||
'pages.author',
|
||||
'pages.image',
|
||||
'reminders.send_notification',
|
||||
]
|
||||
|
||||
class ReminderModel extends DataModel<ReminderData, CreateSet, UpdateSet> {
|
||||
public tableName = Table.REMINDER
|
||||
protected modelKeys = modelKeys
|
||||
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async setRemindersComplete(
|
||||
userId: ReminderData['userId'],
|
||||
remindAt: ReminderData['remindAt'],
|
||||
tx = this.kx
|
||||
): Promise<ReminderData | null> {
|
||||
const [row]: ReminderData[] = await tx(this.tableName)
|
||||
.where({ userId, remindAt })
|
||||
.update({ status: 'COMPLETED' })
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!row) return null
|
||||
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getCreated(
|
||||
id: ReminderData['id'],
|
||||
tx = this.kx
|
||||
): Promise<ReminderData | null> {
|
||||
const row: ReminderData = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ id, status: 'CREATED' })
|
||||
.first(this.modelKeys)
|
||||
|
||||
if (!row) return null
|
||||
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getCreatedByParameters<K extends keyof ParametersSet>(
|
||||
userId: ReminderData['userId'],
|
||||
params: Record<K, ReminderData[K]>,
|
||||
tx = this.kx
|
||||
): Promise<ReminderData | null> {
|
||||
const row: ReminderData = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId: userId, status: 'CREATED' })
|
||||
.andWhere(params)
|
||||
.first(this.modelKeys)
|
||||
|
||||
if (!row) return null
|
||||
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByRequestId(
|
||||
userId: string,
|
||||
requestId: string,
|
||||
tx = this.kx
|
||||
): Promise<ReminderData | null> {
|
||||
const row: ReminderData = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId, elasticPageId: requestId })
|
||||
.first(this.modelKeys)
|
||||
|
||||
if (!row) return null
|
||||
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async existByUserAndRemindAt(
|
||||
userId: ReminderData['userId'],
|
||||
remindAt: ReminderData['remindAt'],
|
||||
tx = this.kx
|
||||
): Promise<boolean> {
|
||||
const row: ReminderData | null = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId: userId, status: 'CREATED', remindAt: remindAt })
|
||||
.first('id')
|
||||
|
||||
return !!row?.id
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUserAndRemindAt(
|
||||
userId: ReminderData['userId'],
|
||||
remindAt: ReminderData['remindAt'],
|
||||
tx = this.kx
|
||||
): Promise<(ReminderData & ArticleData & UserArticleData)[] | null> {
|
||||
const rows: (ReminderData & ArticleData & UserArticleData)[] | null =
|
||||
await tx(this.tableName)
|
||||
.select(JOIN_COLS)
|
||||
.leftJoin(Table.LINKS, 'links.id', 'reminders.link_id')
|
||||
.leftJoin(
|
||||
Table.ARTICLE_SAVING_REQUEST,
|
||||
'article_saving_request.id',
|
||||
'reminders.article_saving_request_id'
|
||||
)
|
||||
.leftJoin(Table.PAGES, function () {
|
||||
this.on('pages.id ', '=', 'links.article_id')
|
||||
this.orOn('pages.id', '=', 'article_saving_request.article_id')
|
||||
})
|
||||
.leftJoin(`${Table.LINKS} as links2`, 'links2.article_id', 'pages.id')
|
||||
.where({
|
||||
'reminders.user_id': userId,
|
||||
'reminders.status': 'CREATED',
|
||||
'reminders.remind_at': remindAt,
|
||||
})
|
||||
|
||||
if (rows.length == 0) return null
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async delete(
|
||||
id: ReminderData['id'],
|
||||
tx = this.kx
|
||||
): Promise<ReminderData | { error: DataModelError }> {
|
||||
const [row]: ReminderData[] = await tx(this.tableName)
|
||||
.where({ id })
|
||||
.update({ status: 'DELETED' })
|
||||
.returning(this.modelKeys)
|
||||
|
||||
if (!row) return { error: DataModelError.notFound }
|
||||
|
||||
this.loader.clear(id)
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
export default ReminderModel
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ---------------------------+--------------------------+-----------+----------+----------------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* user_id | uuid | | not null |
|
||||
* article_saving_request_id | uuid | | |
|
||||
* link_id | uuid | | |
|
||||
* task_name | text | | |
|
||||
* type | reminder_type | | not null |
|
||||
* status | reminder_status | | not null | 'CREATED'::reminder_status
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* updated_at | timestamp with time zone | | |
|
||||
* remind_at | timestamp with time zone | | not null |
|
||||
* archive_until | boolean | | not null | false
|
||||
* send_notification | boolean | | not null | true
|
||||
* ```
|
||||
* */
|
||||
export interface ReminderData {
|
||||
id: string
|
||||
userId: string
|
||||
articleSavingRequestId?: string
|
||||
linkId?: string
|
||||
archiveUntil?: boolean
|
||||
sendNotification?: boolean
|
||||
taskName?: string
|
||||
type: string
|
||||
status?: string
|
||||
createdAt: Date
|
||||
updatedAt?: Date
|
||||
remindAt: Date
|
||||
elasticPageId?: string
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'userId',
|
||||
'articleSavingRequestId',
|
||||
'linkId',
|
||||
'archiveUntil',
|
||||
'sendNotification',
|
||||
'taskName',
|
||||
'remindAt',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'elasticPageId',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'updatedAt', 'status'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<ReminderData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<ReminderData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = [
|
||||
'taskName',
|
||||
'remindAt',
|
||||
'status',
|
||||
'sendNotification',
|
||||
'archiveUntil',
|
||||
] as const
|
||||
|
||||
export type UpdateSet = PickTuple<ReminderData, typeof updateKeys>
|
||||
|
||||
export const getByParametersKeys = exclude(keys, ['id'] as const)
|
||||
|
||||
export type ParametersSet = PickTuple<ReminderData, typeof getByParametersKeys>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Knex } from 'knex'
|
||||
import { ReportType } from '../../generated/graphql'
|
||||
|
||||
interface ReportItem {
|
||||
pageId: string
|
||||
itemUrl: string
|
||||
|
||||
sharedBy: string
|
||||
reportedBy: string | undefined
|
||||
|
||||
reportTypes: ReportType[]
|
||||
reportComment: string
|
||||
}
|
||||
|
||||
// Returns the ID of the report
|
||||
export const createAbuseReport = async (
|
||||
tx: Knex,
|
||||
reportedBy: string | undefined,
|
||||
input: ReportItem
|
||||
): Promise<string> => {
|
||||
const report = { ...input, reportedBy }
|
||||
const result: string[] = await tx<ReportItem>(
|
||||
'omnivore.abuse_reports'
|
||||
).insert(report, ['id'])
|
||||
|
||||
if (!result || result.length !== 1) {
|
||||
throw new Error('Unable to create abuse report.')
|
||||
}
|
||||
|
||||
return result[0]
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { CreateSet, keys as modelKeys, TaskData, UpdateSet } from './model'
|
||||
import DataModel from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { logMethod } from '../helpers'
|
||||
|
||||
class TaskModel extends DataModel<TaskData, CreateSet, UpdateSet> {
|
||||
public tableName = 'omnivore.task'
|
||||
protected modelKeys = modelKeys
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async create(set: CreateSet, tx?: Knex.Transaction): Promise<TaskData> {
|
||||
if (tx) {
|
||||
return super.create(set, tx)
|
||||
}
|
||||
return this.kx.transaction((tx) => super.create(set, tx))
|
||||
}
|
||||
}
|
||||
|
||||
export default TaskModel
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ---------------------+--------------------------+------------+-------------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* title | text | | not null |
|
||||
* created_by | uuid | | not null |
|
||||
* created_at | timestamp with time zone | | not null |
|
||||
* ```
|
||||
* */
|
||||
export interface TaskData {
|
||||
id: string
|
||||
title: string
|
||||
createdBy: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export const keys = ['id', 'title', 'createdBy', 'createdAt'] as const
|
||||
|
||||
export const defaultedKeys = ['id'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<TaskData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<TaskData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = ['title'] as const
|
||||
|
||||
export type UpdateSet = PickTuple<TaskData, typeof updateKeys>
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
CreateSet,
|
||||
keys as modelKeys,
|
||||
UpdateSet,
|
||||
UploadFileData,
|
||||
} from './model'
|
||||
import DataModel from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import { logMethod } from '../helpers'
|
||||
|
||||
class UploadFileDataModel extends DataModel<
|
||||
UploadFileData,
|
||||
CreateSet,
|
||||
UpdateSet
|
||||
> {
|
||||
public tableName = Table.UPLOAD_FILES
|
||||
protected modelKeys = modelKeys
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async create(set: CreateSet, tx?: Knex.Transaction): Promise<UploadFileData> {
|
||||
if (tx) {
|
||||
return super.create(set, tx)
|
||||
}
|
||||
return this.kx.transaction((tx) => super.create(set, tx))
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getWhere(
|
||||
params: {
|
||||
id?: UploadFileData['id']
|
||||
userId?: UploadFileData['userId']
|
||||
url?: UploadFileData['url']
|
||||
},
|
||||
tx = this.kx
|
||||
): Promise<UploadFileData | null> {
|
||||
const row: UploadFileData | null = await tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.where(params)
|
||||
.first()
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async setFileUploadComplete(
|
||||
id: UploadFileData['id'],
|
||||
tx = this.kx
|
||||
): Promise<UploadFileData | null> {
|
||||
const row: UploadFileData | null = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ id })
|
||||
.update({ status: 'COMPLETED' })
|
||||
.returning(this.modelKeys)
|
||||
.limit(2)
|
||||
.then((result) => {
|
||||
if (result.length == 1) {
|
||||
return result[0]
|
||||
}
|
||||
throw Error('Should never return multiple rows')
|
||||
})
|
||||
if (row?.id) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
async uploadFileForUserAndArticle(
|
||||
userId: string,
|
||||
articleId: string
|
||||
): Promise<UploadFileData | null> {
|
||||
const row: UploadFileData | null = await this.kx(Table.PAGES)
|
||||
.select(this.modelKeys.map((k) => `${Table.UPLOAD_FILES}.${k}`))
|
||||
.join(Table.UPLOAD_FILES, 'upload_files.id', '=', 'pages.upload_file_id')
|
||||
.where({ 'upload_files.userId': userId, 'pages.id': articleId })
|
||||
.limit(1)
|
||||
.first()
|
||||
return row
|
||||
}
|
||||
|
||||
async uploadFileForArticle(
|
||||
articleId: string
|
||||
): Promise<UploadFileData | null> {
|
||||
const row: UploadFileData | null = await this.kx(Table.PAGES)
|
||||
.select(this.modelKeys.map((k) => `${Table.UPLOAD_FILES}.${k}`))
|
||||
.join(Table.UPLOAD_FILES, 'upload_files.id', '=', 'pages.upload_file_id')
|
||||
.where({ 'pages.id': articleId })
|
||||
.limit(1)
|
||||
.first()
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
export default UploadFileDataModel
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* -------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* user_id | uuid | | not null |
|
||||
* url | text | | not null |
|
||||
* file_name | text | | not null |
|
||||
* content_type| text | | not null |
|
||||
* status | upload_status_type (text)| | not null |
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* ```
|
||||
* */
|
||||
export interface UploadFileData {
|
||||
id: string
|
||||
userId: string
|
||||
url: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
status: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'userId',
|
||||
'url',
|
||||
'fileName',
|
||||
'contentType',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'createdAt', 'updatedAt'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<UploadFileData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<UploadFileData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = ['url', 'status'] as const
|
||||
|
||||
export type UpdateSet = PickTuple<UploadFileData, typeof updateKeys>
|
||||
|
||||
export const getByParametersKeys = exclude(keys, ['id'] as const)
|
||||
|
||||
export type ParametersSet = PickTuple<
|
||||
UploadFileData,
|
||||
typeof getByParametersKeys
|
||||
>
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
CreateSet,
|
||||
keys as modelKeys,
|
||||
ProfileCreateSet,
|
||||
profileKeys,
|
||||
ProfileUpdateSet,
|
||||
UpdateSet,
|
||||
UserData,
|
||||
} from './model'
|
||||
import DataModel, { DataModelError, MAX_RECORDS_LIMIT } from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import {
|
||||
ENABLE_DB_REQUEST_LOGGING,
|
||||
globalCounter,
|
||||
logMethod,
|
||||
logger,
|
||||
} from '../helpers'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import DataLoader from 'dataloader'
|
||||
import { Partialize } from '../../util'
|
||||
import { Profile } from '../../generated/graphql'
|
||||
import { kx as knexConfig } from './../knex_config'
|
||||
|
||||
const TOP_USERS = [
|
||||
'jacksonh',
|
||||
'nat',
|
||||
'luis',
|
||||
'satindar',
|
||||
'malandrina',
|
||||
'patrick',
|
||||
'alexgutjahr',
|
||||
]
|
||||
|
||||
class UserModel extends DataModel<UserData, CreateSet, UpdateSet> {
|
||||
public tableName = Table.USER
|
||||
protected modelKeys = modelKeys
|
||||
constructor(kx: Knex = knexConfig, cache = true) {
|
||||
super(kx, cache)
|
||||
// override DataModel's base class dataloader to include profile join in user query
|
||||
this.loader = new DataLoader(
|
||||
async (keys) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(this.tableName, 'load', JSON.stringify(keys))
|
||||
}
|
||||
try {
|
||||
const rows: UserData[] = await this.kx({ u: this.tableName })
|
||||
.select([
|
||||
...this.modelKeys.map((k) => `u.${k}`),
|
||||
this.kx.raw('to_jsonb(p) as profile'),
|
||||
])
|
||||
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
|
||||
.whereIn('u.id', keys)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
const keyMap: Record<string, UserData> = {}
|
||||
for (const row of rows) {
|
||||
if (row.id in keyMap) continue
|
||||
keyMap[row.id] = row
|
||||
}
|
||||
const result = keys.map((key) => keyMap[key])
|
||||
if (result.length !== keys.length) {
|
||||
logger.error('DataModel error: count mismatch ', keys, result)
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.error('DataModel error: ', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
{ cache }
|
||||
)
|
||||
|
||||
this.get = this.loader.load.bind(this.loader)
|
||||
this.getMany = this.loader.loadMany.bind(this.loader)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getWhere(
|
||||
params: {
|
||||
email?: UserData['email']
|
||||
source?: UserData['source']
|
||||
username?: UserData['profile']['username']
|
||||
sourceUserId?: UserData['sourceUserId']
|
||||
'p.username'?: UserData['profile']['username']
|
||||
},
|
||||
tx = this.kx
|
||||
): Promise<UserData | null> {
|
||||
// rewrite username key if it exists to use it with joined profile table
|
||||
if ('username' in params) {
|
||||
params['p.username'] = params.username
|
||||
delete params.username
|
||||
}
|
||||
|
||||
const row: UserData | null = await tx({ u: this.tableName })
|
||||
.select([
|
||||
...this.modelKeys.map((k) => `u.${k}`),
|
||||
this.kx.raw('to_jsonb(p) as profile'),
|
||||
])
|
||||
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
|
||||
.where(params)
|
||||
.first()
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async exists(
|
||||
params: {
|
||||
email?: UserData['email']
|
||||
username?: UserData['profile']['username']
|
||||
},
|
||||
tx = this.kx
|
||||
): Promise<boolean> {
|
||||
const row: UserData | null = await tx({ u: this.tableName })
|
||||
.select('u.id')
|
||||
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
|
||||
.where('email', params.email || '')
|
||||
.orWhere('p.username', params.username || '')
|
||||
.first()
|
||||
|
||||
return !!row?.id
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getSharedHighlightsStats(
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<{ sharedHighlightsCount: number; sharedNotesCount: number }> {
|
||||
const row = await tx('omnivore.highlight as h2')
|
||||
.select([
|
||||
tx.raw('count(h2.id) as shared_highlights_count'),
|
||||
tx.raw('count(h2.annotation) as shared_notes_count'),
|
||||
])
|
||||
.whereRaw('h2.user_id::text = ?', [userId])
|
||||
.andWhere('h2.deleted', '=', tx.raw('FALSE'))
|
||||
.whereNotNull('h2.shared_at')
|
||||
.first()
|
||||
|
||||
if (!row) {
|
||||
return { sharedHighlightsCount: 0, sharedNotesCount: 0 }
|
||||
}
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getUserDetails(
|
||||
viewerId: string | undefined,
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<
|
||||
| (UserData & {
|
||||
followersCount: number
|
||||
friendsCount: number
|
||||
viewerIsFollowing: boolean
|
||||
})
|
||||
| null
|
||||
> {
|
||||
/*
|
||||
* We join the user table with the user_friends table twice, once to fetch
|
||||
* the list of friends, and another join to query list of followers. We
|
||||
* group the results by user ID, aggregate the friends and followers
|
||||
* results using postgres array operators. We use coalesce() to return 0
|
||||
* when the results array is null.
|
||||
*/
|
||||
const row = await tx({ u: this.tableName })
|
||||
.select([
|
||||
'u.*',
|
||||
this.kx.raw(
|
||||
'coalesce(array_length(array_remove(array_agg(DISTINCT omnivore.user_friends.friend_user_id), null), 1), 0) as friends_count'
|
||||
),
|
||||
this.kx.raw(
|
||||
'coalesce(array_length(array_remove(array_agg(DISTINCT user_followers.user_id), null), 1), 0) as followers_count'
|
||||
),
|
||||
this.kx.raw(
|
||||
viewerId
|
||||
? 'coalesce(? = ANY(array_agg(DISTINCT user_followers.user_id)), false) as viewer_is_following'
|
||||
: '? as _unused',
|
||||
[viewerId || '']
|
||||
),
|
||||
this.kx.raw('to_jsonb(p) as profile'),
|
||||
])
|
||||
.leftJoin('omnivore.user_friends', function () {
|
||||
this.on(
|
||||
tx.raw('omnivore.user_friends.user_id::text = ?', [userId])
|
||||
).andOn('omnivore.user_friends.user_id', '=', 'u.id')
|
||||
})
|
||||
.leftJoin('omnivore.user_friends as user_followers', function () {
|
||||
this.on(tx.raw('user_followers.friend_user_id::text = ?', [userId]))
|
||||
})
|
||||
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
|
||||
.whereRaw('u.id::text = ?', [userId])
|
||||
.groupBy('u.id')
|
||||
.groupBy('p.id')
|
||||
.first()
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getTopUsers(
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<
|
||||
| (UserData & {
|
||||
followersCount: number
|
||||
friendsCount: number
|
||||
isFriend: boolean
|
||||
})[]
|
||||
| null
|
||||
> {
|
||||
const rows = await tx({ u: this.tableName })
|
||||
.select(['u.*', this.kx.raw('to_jsonb(p) as profile')])
|
||||
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
|
||||
.whereIn('p.username', TOP_USERS)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getUserFollowersList(
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<UserData[]> {
|
||||
const rows: UserData[] = await tx({ u: this.tableName })
|
||||
.select([`u.*`, this.kx.raw('to_jsonb(p) as profile')])
|
||||
.leftJoin(Table.USER_FRIEND, `user_id`, '=', `u.id`)
|
||||
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
|
||||
.where(`friend_user_id`, '=', userId)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getUserFollowingList(
|
||||
userId: string,
|
||||
tx = this.kx
|
||||
): Promise<UserData[]> {
|
||||
const rows: UserData[] = await tx({ u: this.tableName })
|
||||
.select([`u.*`, this.kx.raw('to_jsonb(p) as profile')])
|
||||
.leftJoin(Table.USER_FRIEND, `friend_user_id`, '=', `u.id`)
|
||||
.leftJoin({ p: Table.USER_PROFILE }, 'u.id', 'p.user_id')
|
||||
.where(`user_friends.user_id`, '=', userId)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async createUserWithProfile(
|
||||
createSet: CreateSet,
|
||||
username: string,
|
||||
bio?: string
|
||||
): Promise<UserData> {
|
||||
return this.kx.transaction(async (tx) => {
|
||||
const userData = await this.create(createSet, tx)
|
||||
await this.createProfile({ username, userId: userData.id, bio }, tx)
|
||||
return userData
|
||||
})
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async create(set: CreateSet, tx?: Knex.Transaction): Promise<UserData> {
|
||||
if (tx) {
|
||||
return super.create(set, tx)
|
||||
}
|
||||
return this.kx.transaction((tx) => super.create(set, tx))
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async update(
|
||||
userId: string,
|
||||
set: UpdateSet,
|
||||
tx?: Knex.Transaction
|
||||
): Promise<UserData> {
|
||||
if (tx) {
|
||||
return super.update(userId, set, tx)
|
||||
}
|
||||
return this.kx.transaction((tx) => super.update(userId, set, tx))
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async createProfile(
|
||||
set: ProfileCreateSet,
|
||||
tx?: Knex.Transaction
|
||||
): Promise<Profile> {
|
||||
if (tx) {
|
||||
const [profile] = (await tx(Table.USER_PROFILE)
|
||||
.insert(set)
|
||||
.returning(profileKeys)) as Profile[]
|
||||
return profile
|
||||
}
|
||||
|
||||
return this.kx.transaction(async (tx) => {
|
||||
const [profile] = (await tx(Table.USER_PROFILE)
|
||||
.insert(set)
|
||||
.returning(profileKeys)) as Profile[]
|
||||
return profile
|
||||
})
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
set: Partialize<ProfileUpdateSet>,
|
||||
tx?: Knex.Transaction
|
||||
): Promise<Profile> {
|
||||
if (tx) {
|
||||
const [profile]: Profile[] = await tx(Table.USER_PROFILE)
|
||||
.update(set)
|
||||
.where({ userId })
|
||||
.returning(profileKeys)
|
||||
return profile
|
||||
}
|
||||
return this.kx.transaction((tx) => this.updateProfile(userId, set, tx))
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async delete(
|
||||
userId: string,
|
||||
tx?: Knex.Transaction
|
||||
): Promise<UserData | { error: DataModelError }> {
|
||||
if (tx) {
|
||||
return super.delete(userId, tx)
|
||||
}
|
||||
|
||||
return this.kx.transaction((tx) => super.delete(userId, tx))
|
||||
}
|
||||
}
|
||||
|
||||
export default UserModel
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
// Table "omnivore.user"
|
||||
// Column | Type | Collation | Nullable | Default
|
||||
// ----------------+--------------------------+-----------+----------+---------------------------------------
|
||||
// id | uuid | | not null | uuid_generate_v1mc()
|
||||
// first_name | text | | |
|
||||
// last_name | text | | |
|
||||
// source | registration_type | | not null |
|
||||
// email | text | | |
|
||||
// phone | text | | |
|
||||
// source_user_id | text | | not null |
|
||||
// created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
// updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
|
||||
// Table "omnivore.user_profile"
|
||||
// Column | Type | Collation | Nullable | Default
|
||||
// -------------+--------------------------+-----------+----------+----------------------
|
||||
// id | uuid | | not null | uuid_generate_v1mc()
|
||||
// username | text | | not null |
|
||||
// private | boolean | | not null | false
|
||||
// bio | text | | |
|
||||
// picture_url | text | | |
|
||||
// user_id | uuid | | not null |
|
||||
// created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
// updated_at | timestamp with time zone | | |
|
||||
|
||||
export interface UserData {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
sourceUserId: string
|
||||
createdAt: Date
|
||||
// snake_case here because our knex case transformation doesn't support nested objects
|
||||
profile: {
|
||||
id: string
|
||||
username: string
|
||||
bio?: string | null
|
||||
picture_url?: string | null
|
||||
private: boolean
|
||||
}
|
||||
password?: string | null
|
||||
status?: StatusType
|
||||
}
|
||||
|
||||
export enum RegistrationType {
|
||||
Google = 'GOOGLE',
|
||||
Apple = 'APPLE',
|
||||
Email = 'EMAIL',
|
||||
}
|
||||
|
||||
export enum StatusType {
|
||||
Active = 'ACTIVE',
|
||||
Pending = 'PENDING',
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'name',
|
||||
'source',
|
||||
'email',
|
||||
'phone',
|
||||
'sourceUserId',
|
||||
'createdAt',
|
||||
'password',
|
||||
'status',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'createdAt'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<UserData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<UserData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = [
|
||||
'name',
|
||||
'sourceUserId',
|
||||
'source',
|
||||
'password',
|
||||
] as const
|
||||
|
||||
export type UpdateSet = PickTuple<UserData, typeof updateKeys>
|
||||
|
||||
// Profile-related types
|
||||
export const profileKeys = [
|
||||
'id',
|
||||
'username',
|
||||
'bio',
|
||||
'pictureUrl',
|
||||
'private',
|
||||
'userId',
|
||||
] as const
|
||||
|
||||
export const createProfileKeys = exclude(profileKeys, ['id'])
|
||||
|
||||
export type ProfileCreateSet = PickTuple<
|
||||
UserData['profile'],
|
||||
typeof createProfileKeys
|
||||
>
|
||||
|
||||
export const profileUpdateKeys = [
|
||||
'username',
|
||||
'bio',
|
||||
'picture_url',
|
||||
'private',
|
||||
] as const
|
||||
|
||||
export type ProfileUpdateSet = PickTuple<
|
||||
UserData['profile'],
|
||||
typeof profileUpdateKeys
|
||||
>
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
CreateSet,
|
||||
keys as modelKeys,
|
||||
UpdateSet,
|
||||
UserFriendData,
|
||||
} from './model'
|
||||
import DataModel, { MAX_RECORDS_LIMIT } from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import {
|
||||
ENABLE_DB_REQUEST_LOGGING,
|
||||
globalCounter,
|
||||
logMethod,
|
||||
logger,
|
||||
} from '../helpers'
|
||||
import DataLoader from 'dataloader'
|
||||
|
||||
class UserFriendModel extends DataModel<UserFriendData, CreateSet, UpdateSet> {
|
||||
public tableName = Table.USER_FRIEND
|
||||
protected modelKeys = modelKeys
|
||||
|
||||
protected getFriendsLoader: DataLoader<string, string[]>
|
||||
getFriends: DataLoader<string, string[]>['load']
|
||||
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
this.getFriendsLoader = new DataLoader(
|
||||
async (keys) => {
|
||||
if (ENABLE_DB_REQUEST_LOGGING) {
|
||||
globalCounter.log(
|
||||
this.tableName,
|
||||
'user_friends_loader',
|
||||
JSON.stringify(keys)
|
||||
)
|
||||
}
|
||||
const rows: UserFriendData[] = await this.kx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.whereIn('userId', keys)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
const keyMap: Record<string, string[]> = {}
|
||||
for (const row of rows) {
|
||||
keyMap[row.userId] = [...(keyMap[row.userId] || []), row.friendUserId]
|
||||
}
|
||||
|
||||
const result = keys.map((userId) => keyMap[userId] || [])
|
||||
// logger.debug('\n\n\n\n\nResult for userId_articleId_load', { keys, result });
|
||||
|
||||
if (result.length !== keys.length) {
|
||||
logger.error('DataModel error: count mismatch ', keys, result)
|
||||
}
|
||||
return result
|
||||
},
|
||||
{ cache }
|
||||
)
|
||||
|
||||
this.getFriends = this.getFriendsLoader.load.bind(this.getFriendsLoader)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUserFriendId(
|
||||
userId: UserFriendData['userId'],
|
||||
friendUserId: UserFriendData['friendUserId'],
|
||||
tx = this.kx
|
||||
): Promise<UserFriendData | null> {
|
||||
const row: UserFriendData | null = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId, friendUserId })
|
||||
.first(this.modelKeys)
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getFollowers(
|
||||
userId: UserFriendData['userId'],
|
||||
tx = this.kx
|
||||
): Promise<UserFriendData[]> {
|
||||
const rows: UserFriendData[] = await tx(this.tableName)
|
||||
.where({ friendUserId: userId })
|
||||
.select(this.modelKeys)
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByFriendIds(
|
||||
userId: string,
|
||||
followersIds: string[],
|
||||
tx = this.kx
|
||||
): Promise<UserFriendData[]> {
|
||||
const rows: UserFriendData[] = await tx(this.tableName)
|
||||
.select(this.modelKeys)
|
||||
.whereIn('friend_user_id', followersIds)
|
||||
.andWhere({ userId })
|
||||
.limit(MAX_RECORDS_LIMIT)
|
||||
|
||||
for (const row of rows) {
|
||||
this.loader.prime(row.id, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
export default UserFriendModel
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ---------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* user_id | uuid | | not null |
|
||||
* friend_user_id| uuid | | not null |
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* ```
|
||||
* */
|
||||
export interface UserFriendData {
|
||||
id: string
|
||||
userId: string
|
||||
friendUserId: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export const keys = ['id', 'userId', 'friendUserId', 'createdAt'] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'createdAt'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<UserFriendData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<UserFriendData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = [] as const
|
||||
|
||||
export type UpdateSet = PickTuple<UserFriendData, typeof updateKeys>
|
||||
|
||||
export const getByParametersKeys = exclude(keys, ['id'] as const)
|
||||
|
||||
export type ParametersSet = PickTuple<
|
||||
UserFriendData,
|
||||
typeof getByParametersKeys
|
||||
>
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
CreateSet,
|
||||
keys as modelKeys,
|
||||
UpdateSet,
|
||||
UserPersonalizationData,
|
||||
} from './model'
|
||||
import DataModel from '../model'
|
||||
import { Knex } from 'knex'
|
||||
import { Table } from '../../utils/dictionary'
|
||||
import { camelCase } from 'voca'
|
||||
import { logMethod } from '../helpers'
|
||||
|
||||
class UserPersonalizationModel extends DataModel<
|
||||
UserPersonalizationData,
|
||||
CreateSet,
|
||||
UpdateSet
|
||||
> {
|
||||
public tableName = Table.USER_PERSONALIZATION
|
||||
protected modelKeys = modelKeys
|
||||
constructor(kx: Knex, cache = true) {
|
||||
super(kx, cache)
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async getByUserId(
|
||||
userId: UserPersonalizationData['userId'],
|
||||
tx = this.kx
|
||||
): Promise<UserPersonalizationData | null> {
|
||||
const row: UserPersonalizationData | null = await tx(this.tableName)
|
||||
.select()
|
||||
.where({ userId })
|
||||
.first(this.modelKeys)
|
||||
if (!row) return null
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
|
||||
@logMethod
|
||||
async upsert(
|
||||
data: CreateSet,
|
||||
tx = this.kx
|
||||
): Promise<UserPersonalizationData> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { userId, id, ...updateSet } = data
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const row = {} as any
|
||||
const result = (
|
||||
await tx.raw(
|
||||
`? ON CONFLICT (user_id)
|
||||
DO ?`,
|
||||
[
|
||||
tx(this.tableName).insert(data),
|
||||
tx.update(updateSet).returning(this.modelKeys),
|
||||
]
|
||||
)
|
||||
).rows[0]
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
row[camelCase(key)] = value
|
||||
}
|
||||
this.loader.prime(row.id, row)
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPersonalizationModel
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { exclude, Partialize, PickTuple } from '../../util'
|
||||
|
||||
/**
|
||||
* ```
|
||||
* Column | Type | Collation | Nullable | Default
|
||||
* ---------------------+--------------------------+-----------+----------+----------------------
|
||||
* id | uuid | | not null | uuid_generate_v1mc()
|
||||
* user_id | uuid | | not null |
|
||||
* font_size | integer | | |
|
||||
* font_family | text | | |
|
||||
* theme | text | | |
|
||||
* created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
* margin | integer | | |
|
||||
* library_layout_type | text | | |
|
||||
* library_sort_order | text | | |
|
||||
* ```
|
||||
* */
|
||||
export interface UserPersonalizationData {
|
||||
id: string
|
||||
userId: string
|
||||
fontFamily?: string | null
|
||||
fontSize?: number | null
|
||||
margin?: number | null
|
||||
theme?: string | null
|
||||
libraryLayoutType?: string | null
|
||||
librarySortOrder?: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'userId',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'margin',
|
||||
'theme',
|
||||
'libraryLayoutType',
|
||||
'librarySortOrder',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const
|
||||
|
||||
export const defaultedKeys = ['id', 'createdAt', 'updatedAt'] as const
|
||||
|
||||
type DefaultedSet = PickTuple<UserPersonalizationData, typeof defaultedKeys>
|
||||
|
||||
export const createKeys = exclude(keys, defaultedKeys)
|
||||
|
||||
export type CreateSet = PickTuple<UserPersonalizationData, typeof createKeys> &
|
||||
Partialize<DefaultedSet>
|
||||
|
||||
export const updateKeys = [
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'margin',
|
||||
'theme',
|
||||
'libraryLayoutType',
|
||||
'librarySortOrder',
|
||||
] as const
|
||||
|
||||
export type UpdateSet = PickTuple<UserPersonalizationData, typeof updateKeys>
|
||||
|
||||
export const getByParametersKeys = exclude(keys, ['id'] as const)
|
||||
|
||||
export type ParametersSet = PickTuple<
|
||||
UserPersonalizationData,
|
||||
typeof getByParametersKeys
|
||||
>
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
import { errors } from '@elastic/elasticsearch'
|
||||
import { EntityType } from '../datalayer/pubsub'
|
||||
import { SortBy, SortOrder, SortParams } from '../utils/search'
|
||||
import { client, INDEX_ALIAS, logger } from './index'
|
||||
import {
|
||||
Highlight,
|
||||
Page,
|
||||
PageContext,
|
||||
PageType,
|
||||
SearchItem,
|
||||
SearchResponse,
|
||||
} from './types'
|
||||
|
||||
export const addHighlightToPage = async (
|
||||
id: string,
|
||||
highlight: Highlight,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.update({
|
||||
index: INDEX_ALIAS,
|
||||
id,
|
||||
body: {
|
||||
script: {
|
||||
source: `if (ctx._source.highlights == null) {
|
||||
ctx._source.highlights = [params.highlight]
|
||||
} else {
|
||||
ctx._source.highlights.add(params.highlight)
|
||||
}
|
||||
ctx._source.updatedAt = params.highlight.updatedAt`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
highlight,
|
||||
},
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
if (body.result !== 'updated') return false
|
||||
|
||||
await ctx.pubsub.entityCreated<Highlight>(
|
||||
EntityType.HIGHLIGHT,
|
||||
highlight,
|
||||
ctx.uid
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof errors.ResponseError &&
|
||||
e.message === 'document_missing_exception'
|
||||
) {
|
||||
logger.info('page has been deleted', id)
|
||||
return false
|
||||
}
|
||||
logger.error('failed to add highlight to a page in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getHighlightById = async (
|
||||
id: string
|
||||
): Promise<Highlight | undefined> => {
|
||||
try {
|
||||
const { body } = await client.search({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
query: {
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.id': id,
|
||||
},
|
||||
},
|
||||
inner_hits: {},
|
||||
},
|
||||
},
|
||||
_source: false,
|
||||
},
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (body.hits.total.value === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return
|
||||
return body.hits.hits[0].inner_hits.highlights.hits.hits[0]._source
|
||||
} catch (e) {
|
||||
logger.error('failed to get highlight from a page in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteHighlight = async (
|
||||
highlightId: string,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.updateByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
script: {
|
||||
source: `ctx._source.highlights.removeIf(h -> h.id == params.highlightId);
|
||||
ctx._source.updatedAt = params.updatedAt`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
highlightId: highlightId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
term: {
|
||||
userId: ctx.uid,
|
||||
},
|
||||
},
|
||||
{
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.id': highlightId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
body.updated > 0 &&
|
||||
(await ctx.pubsub.entityDeleted(
|
||||
EntityType.HIGHLIGHT,
|
||||
highlightId,
|
||||
ctx.uid
|
||||
))
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
logger.error('failed to delete a highlight in elastic', e)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const searchHighlights = async (
|
||||
args: {
|
||||
from?: number
|
||||
size?: number
|
||||
sort?: SortParams
|
||||
query?: string
|
||||
},
|
||||
userId: string
|
||||
): Promise<[SearchItem[], number] | undefined> => {
|
||||
try {
|
||||
const { from = 0, size = 10, sort, query } = args
|
||||
const sortOrder = sort?.order || SortOrder.DESCENDING
|
||||
// default sort by updatedAt
|
||||
const sortField =
|
||||
sort?.by === SortBy.SCORE ? SortBy.SCORE : 'highlights.updatedAt'
|
||||
|
||||
const searchBody = {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.userId': userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [
|
||||
{
|
||||
multi_match: {
|
||||
query: query || '',
|
||||
fields: [
|
||||
'highlights.quote^5',
|
||||
'title^3',
|
||||
'description^2',
|
||||
'content',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: query ? 1 : 0,
|
||||
},
|
||||
},
|
||||
sort: [
|
||||
'_score',
|
||||
{
|
||||
[sortField]: {
|
||||
order: sortOrder,
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
from,
|
||||
size,
|
||||
_source: [
|
||||
'title',
|
||||
'slug',
|
||||
'url',
|
||||
'savedAt',
|
||||
'highlights',
|
||||
'readingProgressPercent',
|
||||
'readingProgressAnchorIndex',
|
||||
],
|
||||
}
|
||||
|
||||
logger.info('searching highlights in elastic', searchBody)
|
||||
|
||||
const response = await client.search<SearchResponse<Page>>({
|
||||
index: INDEX_ALIAS,
|
||||
body: searchBody,
|
||||
})
|
||||
|
||||
if (response.body.hits.total.value === 0) {
|
||||
return [[], 0]
|
||||
}
|
||||
|
||||
const results: SearchItem[] = []
|
||||
response.body.hits.hits.forEach((hit) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
|
||||
hit._source.highlights?.forEach((highlight) => {
|
||||
results.push({
|
||||
...highlight,
|
||||
...hit._source,
|
||||
pageId: hit._id,
|
||||
pageType: PageType.Highlights,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return [results, response.body.hits.total.value]
|
||||
} catch (e) {
|
||||
logger.error('failed to search highlights in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const updateHighlight = async (
|
||||
highlight: Highlight,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.updateByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
script: {
|
||||
source: `ctx._source.highlights.removeIf(h -> h.id == params.highlight.id);
|
||||
ctx._source.highlights.add(params.highlight);
|
||||
ctx._source.updatedAt = params.highlight.updatedAt`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
highlight,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
term: {
|
||||
userId: ctx.uid,
|
||||
},
|
||||
},
|
||||
{
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.id': highlight.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
conflicts: 'proceed',
|
||||
})
|
||||
|
||||
body.updated > 0 &&
|
||||
(await ctx.pubsub.entityUpdated<Highlight>(
|
||||
EntityType.HIGHLIGHT,
|
||||
highlight,
|
||||
ctx.uid
|
||||
))
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
logger.error('failed to update highlight in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { Client } from '@elastic/elasticsearch'
|
||||
import { readFileSync } from 'fs'
|
||||
import { env } from '../env'
|
||||
import { buildLogger } from '../utils/logger'
|
||||
|
||||
export const INDEX_ALIAS = 'pages_alias'
|
||||
export const client = new Client({
|
||||
node: env.elastic.url,
|
||||
maxRetries: 3,
|
||||
requestTimeout: 50000,
|
||||
auth: {
|
||||
username: env.elastic.username,
|
||||
password: env.elastic.password,
|
||||
},
|
||||
})
|
||||
const INDEX_NAME = 'pages'
|
||||
export const logger = buildLogger('elasticsearch')
|
||||
|
||||
const createIndex = async (): Promise<void> => {
|
||||
// read index settings from file
|
||||
const indexSettings = readFileSync(
|
||||
__dirname + '/../../../db/elastic_migrations/index_settings.json',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// create index
|
||||
await client.indices.create({
|
||||
index: INDEX_NAME,
|
||||
body: indexSettings,
|
||||
})
|
||||
}
|
||||
|
||||
export const initElasticsearch = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await client.info()
|
||||
logger.info('elastic info: ', response)
|
||||
|
||||
// check if index exists
|
||||
const { body: indexExists } = await client.indices.exists({
|
||||
index: INDEX_ALIAS,
|
||||
})
|
||||
if (!indexExists) {
|
||||
logger.info('creating index...')
|
||||
await createIndex()
|
||||
|
||||
logger.info('refreshing index...')
|
||||
await refreshIndex()
|
||||
}
|
||||
logger.info('elastic client is ready')
|
||||
} catch (e) {
|
||||
logger.error('failed to init elasticsearch', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export const refreshIndex = async (): Promise<void> => {
|
||||
try {
|
||||
const { body } = await client.indices.refresh({
|
||||
index: INDEX_ALIAS,
|
||||
})
|
||||
logger.info('elastic refresh: ', body)
|
||||
} catch (e) {
|
||||
logger.error('failed to refresh elastic index', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
import { errors } from '@elastic/elasticsearch'
|
||||
import { EntityType } from '../datalayer/pubsub'
|
||||
import { client, INDEX_ALIAS, logger } from './index'
|
||||
import { Label, PageContext } from './types'
|
||||
|
||||
export const addLabelInPage = async (
|
||||
pageId: string,
|
||||
label: Label,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.update({
|
||||
index: INDEX_ALIAS,
|
||||
id: pageId,
|
||||
body: {
|
||||
script: {
|
||||
source: `if (ctx._source.labels == null) {
|
||||
ctx._source.labels = [params.label];
|
||||
ctx._source.updatedAt = params.updatedAt
|
||||
} else if (!ctx._source.labels.any(label -> label.name == params.label.name)) {
|
||||
ctx._source.labels.add(params.label);
|
||||
ctx._source.updatedAt = params.updatedAt
|
||||
} else { ctx.op = 'none' }`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
label: label,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
if (body.result !== 'updated') return false
|
||||
|
||||
await ctx.pubsub.entityCreated<Label & { pageId: string }>(
|
||||
EntityType.LABEL,
|
||||
{ pageId, ...label },
|
||||
ctx.uid
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof errors.ResponseError &&
|
||||
e.message === 'document_missing_exception'
|
||||
) {
|
||||
logger.info('page has been deleted', pageId)
|
||||
return false
|
||||
}
|
||||
logger.error('failed to add a label in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const updateLabelsInPage = async (
|
||||
pageId: string,
|
||||
labels: Label[],
|
||||
ctx: PageContext,
|
||||
labelsToAdd?: Label[]
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.update({
|
||||
index: INDEX_ALIAS,
|
||||
id: pageId,
|
||||
body: {
|
||||
doc: {
|
||||
labels: labels,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
if (body.result === 'noop') return true
|
||||
if (body.result !== 'updated') return false
|
||||
if (labelsToAdd) {
|
||||
// publish labels to be added
|
||||
await Promise.all(
|
||||
labelsToAdd.map((label) =>
|
||||
ctx.pubsub.entityCreated<Label & { pageId: string }>(
|
||||
EntityType.LABEL,
|
||||
{ pageId, ...label },
|
||||
ctx.uid
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof errors.ResponseError &&
|
||||
e.message === 'document_missing_exception'
|
||||
) {
|
||||
logger.info('page has been deleted', pageId)
|
||||
return false
|
||||
}
|
||||
logger.error('failed to update labels in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteLabel = async (
|
||||
label: string,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.updateByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
script: {
|
||||
source: `if (ctx._source.highlights != null) {
|
||||
for (h in ctx._source.highlights) {
|
||||
if (h.labels != null) {
|
||||
h.labels.removeIf(l -> l.name == params.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ctx._source.labels != null) {
|
||||
ctx._source.labels.removeIf(label -> label.name == params.label);
|
||||
}`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
label: label,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
must: {
|
||||
term: {
|
||||
userId: ctx.uid,
|
||||
},
|
||||
},
|
||||
should: [
|
||||
{
|
||||
nested: {
|
||||
path: 'labels',
|
||||
query: {
|
||||
term: {
|
||||
'labels.name': label,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
nested: {
|
||||
path: 'highlights.labels',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.labels.name': label,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
conflicts: 'proceed', // ignore conflicts
|
||||
})
|
||||
|
||||
body.updated > 0 &&
|
||||
(await ctx.pubsub.entityDeleted(EntityType.LABEL, label, ctx.uid))
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
logger.error('failed to delete a label in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const updateLabel = async (
|
||||
label: Label,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.updateByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
script: {
|
||||
source: `if (ctx._source.labels != null) {
|
||||
for (l in ctx._source.labels) {
|
||||
if (l.id == params.label.id) {
|
||||
l.name = params.label.name;
|
||||
l.color = params.label.color;
|
||||
l.description = params.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ctx._source.highlights != null) {
|
||||
for (h in ctx._source.highlights) {
|
||||
if (h.labels != null) {
|
||||
for (l in h.labels) {
|
||||
if (l.id == params.label.id) {
|
||||
l.name = params.label.name;
|
||||
l.color = params.label.color;
|
||||
l.description = params.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
label: label,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
must: {
|
||||
term: {
|
||||
userId: ctx.uid,
|
||||
},
|
||||
},
|
||||
should: [
|
||||
{
|
||||
nested: {
|
||||
path: 'labels',
|
||||
query: {
|
||||
term: {
|
||||
'labels.id': label.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
nested: {
|
||||
path: 'highlights.labels',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.labels.id': label.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
conflicts: 'proceed', // ignore conflicts
|
||||
requests_per_second: 500, // throttle the requests
|
||||
slices: 'auto', // parallelize the requests
|
||||
})
|
||||
|
||||
body.updated > 0 &&
|
||||
(await ctx.pubsub.entityUpdated(EntityType.LABEL, label, ctx.uid))
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
logger.error('failed to update label in elastic', e)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const setLabelsForHighlight = async (
|
||||
highlightId: string,
|
||||
labels: Label[],
|
||||
ctx: PageContext,
|
||||
labelsToAdd?: Label[]
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.updateByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
script: {
|
||||
source: `ctx._source.highlights.find(h -> params.highlightId == h.id).labels = params.labels;
|
||||
ctx._source.updatedAt = params.updatedAt`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
highlightId,
|
||||
labels,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
query: {
|
||||
nested: {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
term: {
|
||||
'highlights.id': highlightId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
conflicts: 'proceed', // ignore conflicts
|
||||
})
|
||||
|
||||
if (labelsToAdd) {
|
||||
// publish labels to be added
|
||||
await Promise.all(
|
||||
labelsToAdd.map((label) =>
|
||||
ctx.pubsub.entityCreated<Label & { highlightId: string }>(
|
||||
EntityType.LABEL,
|
||||
{ highlightId, ...label },
|
||||
ctx.uid
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof errors.ResponseError &&
|
||||
e.message === 'document_missing_exception'
|
||||
) {
|
||||
logger.info('highlight has been deleted', highlightId)
|
||||
return false
|
||||
}
|
||||
logger.error('failed to set labels for highlight in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,967 +0,0 @@
|
|||
import { errors } from '@elastic/elasticsearch'
|
||||
import { BuiltQuery, ESBuilder, esBuilder } from 'elastic-ts'
|
||||
import { EntityType } from '../datalayer/pubsub'
|
||||
import { BulkActionType } from '../generated/graphql'
|
||||
import { wordsCount } from '../utils/helpers'
|
||||
import {
|
||||
DateFilter,
|
||||
FieldFilter,
|
||||
HasFilter,
|
||||
InFilter,
|
||||
LabelFilter,
|
||||
LabelFilterType,
|
||||
NoFilter,
|
||||
ReadFilter,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
} from '../utils/search'
|
||||
import { client, INDEX_ALIAS, logger } from './index'
|
||||
import {
|
||||
ArticleSavingRequestStatus,
|
||||
Label,
|
||||
Page,
|
||||
PageContext,
|
||||
PageSearchArgs,
|
||||
PageType,
|
||||
ParamSet,
|
||||
SearchResponse,
|
||||
} from './types'
|
||||
|
||||
const MAX_CONTENT_LENGTH = 5 * 1024 * 1024 // 5MB and 10MB for both content and originalHtml
|
||||
const CONTENT_LENGTH_ERROR = 'Your page content is too large to be saved.'
|
||||
|
||||
const appendQuery = (builder: ESBuilder, query: string): ESBuilder => {
|
||||
interface Field {
|
||||
field: string
|
||||
boost: number
|
||||
}
|
||||
|
||||
const wildcardQuery = (field: Field) => {
|
||||
return {
|
||||
[field.field]: {
|
||||
value: query,
|
||||
case_insensitive: true,
|
||||
boost: field.boost,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// add boost to the field name like title^3
|
||||
const fieldWithBoost = (field: Field) =>
|
||||
`${field.field}${field.boost > 1 ? `^${field.boost}` : ''}`
|
||||
|
||||
// get the parent field name like highlights from highlights.annotation
|
||||
const getParentField = (nestedField: string) => nestedField.split('.')[0]
|
||||
|
||||
const nonNestedFields: Field[] = [
|
||||
{ field: 'title', boost: 3 },
|
||||
{ field: 'content', boost: 1 },
|
||||
{ field: 'author', boost: 1 },
|
||||
{ field: 'description', boost: 1 },
|
||||
{ field: 'siteName', boost: 2 },
|
||||
]
|
||||
const nestedFields: Field[] = [{ field: 'highlights.annotation', boost: 2 }]
|
||||
|
||||
// minimum_should_match: 1 means that at least one of the queries must match
|
||||
builder = builder.queryMinimumShouldMatch(1)
|
||||
|
||||
// wildcard query
|
||||
if (query.includes('*')) {
|
||||
nonNestedFields.forEach((field) => {
|
||||
builder = builder.orQuery('wildcard', wildcardQuery(field))
|
||||
})
|
||||
|
||||
nestedFields.forEach((nestedField) => {
|
||||
builder = builder.orQuery('nested', {
|
||||
path: getParentField(nestedField.field),
|
||||
query: {
|
||||
wildcard: wildcardQuery(nestedField),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
// match query
|
||||
builder = builder.orQuery('multi_match', {
|
||||
query,
|
||||
fields: nonNestedFields.map((field) => fieldWithBoost(field)),
|
||||
type: 'best_fields',
|
||||
tie_breaker: 0.3,
|
||||
operator: 'and',
|
||||
})
|
||||
|
||||
nestedFields.forEach((nestedField) => {
|
||||
builder = builder.orQuery('nested', {
|
||||
path: getParentField(nestedField.field),
|
||||
query: {
|
||||
match: {
|
||||
[nestedField.field]: {
|
||||
query,
|
||||
boost: nestedField.boost,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendTypeFilter = (builder: ESBuilder, filter: PageType): ESBuilder => {
|
||||
return builder.query('term', { pageType: filter })
|
||||
}
|
||||
|
||||
const appendReadFilter = (
|
||||
builder: ESBuilder,
|
||||
filter: ReadFilter
|
||||
): ESBuilder => {
|
||||
switch (filter) {
|
||||
case ReadFilter.UNREAD:
|
||||
return builder.query('range', {
|
||||
readingProgressPercent: {
|
||||
lt: 98,
|
||||
},
|
||||
})
|
||||
case ReadFilter.READ:
|
||||
return builder.query('range', {
|
||||
readingProgressPercent: {
|
||||
gte: 98,
|
||||
},
|
||||
})
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendInFilter = (builder: ESBuilder, filter: InFilter): ESBuilder => {
|
||||
switch (filter) {
|
||||
case InFilter.ARCHIVE:
|
||||
return builder.query('exists', { field: 'archivedAt' })
|
||||
case InFilter.INBOX:
|
||||
return builder.notQuery('exists', { field: 'archivedAt' })
|
||||
case InFilter.TRASH:
|
||||
// return only deleted pages within 14 days
|
||||
return builder
|
||||
.query('term', {
|
||||
state: ArticleSavingRequestStatus.Deleted,
|
||||
})
|
||||
.andQuery('range', {
|
||||
updatedAt: {
|
||||
gte: 'now-14d',
|
||||
},
|
||||
})
|
||||
case InFilter.LIBRARY:
|
||||
return builder
|
||||
.query('bool', {
|
||||
should: [
|
||||
{
|
||||
nested: {
|
||||
path: 'labels',
|
||||
query: {
|
||||
term: {
|
||||
'labels.name': 'library',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
bool: {
|
||||
must_not: [
|
||||
{
|
||||
nested: {
|
||||
path: 'labels',
|
||||
query: {
|
||||
terms: {
|
||||
'labels.name': ['newsletter', 'rss'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
should: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
})
|
||||
.notQuery('exists', { field: 'archivedAt' })
|
||||
case InFilter.SUBSCRIPTION:
|
||||
return builder
|
||||
.andQuery('nested', {
|
||||
path: 'labels',
|
||||
query: {
|
||||
terms: {
|
||||
'labels.name': ['newsletter', 'rss'],
|
||||
},
|
||||
},
|
||||
})
|
||||
.notQuery('nested', {
|
||||
path: 'labels',
|
||||
query: {
|
||||
term: {
|
||||
'labels.name': 'library',
|
||||
},
|
||||
},
|
||||
})
|
||||
.notQuery('exists', { field: 'archivedAt' })
|
||||
default:
|
||||
return builder
|
||||
}
|
||||
}
|
||||
|
||||
const appendHasFilters = (
|
||||
builder: ESBuilder,
|
||||
filters: HasFilter[]
|
||||
): ESBuilder => {
|
||||
filters.forEach((filter) => {
|
||||
switch (filter) {
|
||||
case HasFilter.HIGHLIGHTS:
|
||||
builder = builder.query('nested', {
|
||||
path: 'highlights',
|
||||
query: {
|
||||
exists: {
|
||||
field: 'highlights',
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
case HasFilter.SHARED_AT:
|
||||
builder = builder.query('exists', { field: 'sharedAt' })
|
||||
break
|
||||
}
|
||||
})
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendExcludeLabelFilter = (
|
||||
builder: ESBuilder,
|
||||
filters: LabelFilter[]
|
||||
): ESBuilder => {
|
||||
const labels = filters.map((filter) => filter.labels).flat()
|
||||
return builder.notQuery('nested', {
|
||||
path: 'labels',
|
||||
query: {
|
||||
terms: {
|
||||
'labels.name': labels,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const appendIncludeLabelFilter = (
|
||||
builder: ESBuilder,
|
||||
filters: LabelFilter[]
|
||||
): ESBuilder => {
|
||||
filters.forEach((filter) => {
|
||||
builder = builder.query('nested', {
|
||||
path: 'labels',
|
||||
query: {
|
||||
bool: {
|
||||
should: filter.labels.map((label) => {
|
||||
if (label.includes('*')) {
|
||||
// Wildcard query
|
||||
return {
|
||||
wildcard: {
|
||||
'labels.name': {
|
||||
value: label,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
term: {
|
||||
'labels.name': label,
|
||||
},
|
||||
}
|
||||
}),
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendDateFilters = (
|
||||
builder: ESBuilder,
|
||||
filters: DateFilter[]
|
||||
): ESBuilder => {
|
||||
filters.forEach((filter) => {
|
||||
builder = builder.query('range', {
|
||||
[filter.field]: {
|
||||
gt: filter.startDate?.getTime(),
|
||||
lt: filter.endDate?.getTime(),
|
||||
},
|
||||
})
|
||||
})
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendTermFilters = (
|
||||
builder: ESBuilder,
|
||||
filters: FieldFilter[]
|
||||
): ESBuilder => {
|
||||
filters.forEach((filter) => {
|
||||
builder = builder.query('term', {
|
||||
[filter.field]: filter.value,
|
||||
})
|
||||
})
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendMatchFilters = (
|
||||
builder: ESBuilder,
|
||||
filters: FieldFilter[]
|
||||
): ESBuilder => {
|
||||
filters.forEach((filter) => {
|
||||
if (filter.nested) {
|
||||
// nested query
|
||||
builder = builder.query('nested', {
|
||||
path: filter.field.split('.')[0], // get the nested field name
|
||||
query: {
|
||||
match: {
|
||||
[filter.field]: filter.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
builder = builder.query('match', {
|
||||
[filter.field]: filter.value,
|
||||
})
|
||||
})
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendIdsFilter = (builder: ESBuilder, ids: string[]): ESBuilder => {
|
||||
return builder.query('terms', {
|
||||
_id: ids,
|
||||
})
|
||||
}
|
||||
|
||||
const appendRecommendedBy = (
|
||||
builder: ESBuilder,
|
||||
recommendedBy: string
|
||||
): ESBuilder => {
|
||||
const query =
|
||||
recommendedBy === '*'
|
||||
? {
|
||||
exists: {
|
||||
field: 'recommendations',
|
||||
},
|
||||
}
|
||||
: {
|
||||
term: {
|
||||
'recommendations.name': recommendedBy,
|
||||
},
|
||||
}
|
||||
return builder.query('nested', {
|
||||
path: 'recommendations',
|
||||
query,
|
||||
})
|
||||
}
|
||||
|
||||
const appendNoFilters = (
|
||||
builder: ESBuilder,
|
||||
noFilters: NoFilter[]
|
||||
): ESBuilder => {
|
||||
noFilters.forEach((filter) => {
|
||||
builder = builder.notQuery('nested', {
|
||||
path: filter.field,
|
||||
query: {
|
||||
exists: {
|
||||
field: filter.field,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
return builder
|
||||
}
|
||||
|
||||
const appendSiteNameFilter = (
|
||||
builder: ESBuilder,
|
||||
siteName: string
|
||||
): ESBuilder => {
|
||||
return builder.query('bool', {
|
||||
should: [
|
||||
{
|
||||
match: {
|
||||
siteName,
|
||||
},
|
||||
},
|
||||
{
|
||||
wildcard: {
|
||||
// siteName is a domain name, so we need to wildcard the end
|
||||
url: `*${siteName}*`,
|
||||
},
|
||||
},
|
||||
],
|
||||
minimum_should_match: 1,
|
||||
})
|
||||
}
|
||||
|
||||
export const createPage = async (
|
||||
page: Page,
|
||||
ctx: PageContext
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
if (page.content.length > MAX_CONTENT_LENGTH) {
|
||||
logger.info('page content is too large', {
|
||||
pageId: page.id,
|
||||
contentLength: page.content.length,
|
||||
})
|
||||
|
||||
page.content = CONTENT_LENGTH_ERROR
|
||||
}
|
||||
|
||||
const { body } = await client.index({
|
||||
id: page.id || undefined,
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
...page,
|
||||
updatedAt: new Date(),
|
||||
savedAt: page.savedAt || new Date(),
|
||||
wordsCount: page.wordsCount ?? wordsCount(page.content),
|
||||
},
|
||||
refresh: 'wait_for', // wait for the index to be refreshed before returning
|
||||
})
|
||||
|
||||
page.id = body._id as string
|
||||
|
||||
const shouldPublish = ctx.shouldPublish ?? true
|
||||
// only publish a pubsub event if we should
|
||||
if (shouldPublish) {
|
||||
await ctx.pubsub?.entityCreated<Page>(EntityType.PAGE, page, ctx.uid)
|
||||
}
|
||||
|
||||
return page.id
|
||||
} catch (e) {
|
||||
logger.error('failed to create a page in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const updatePage = async (
|
||||
id: string,
|
||||
page: Partial<Page>,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
if (page.content && page.content.length > MAX_CONTENT_LENGTH) {
|
||||
logger.info('page content is too large', {
|
||||
pageId: page.id,
|
||||
contentLength: page.content.length,
|
||||
})
|
||||
|
||||
page.content = CONTENT_LENGTH_ERROR
|
||||
}
|
||||
|
||||
await client.update({
|
||||
index: INDEX_ALIAS,
|
||||
id,
|
||||
body: {
|
||||
doc: {
|
||||
...page,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
refresh: ctx.refresh,
|
||||
retry_on_conflict: 3,
|
||||
})
|
||||
|
||||
if (page.state === ArticleSavingRequestStatus.Deleted) {
|
||||
await ctx.pubsub.entityDeleted(EntityType.PAGE, id, ctx.uid)
|
||||
return true
|
||||
}
|
||||
|
||||
await ctx.pubsub.entityUpdated<Partial<Page>>(
|
||||
EntityType.PAGE,
|
||||
{ ...page, id },
|
||||
ctx.uid
|
||||
)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof errors.ResponseError &&
|
||||
e.message === 'document_missing_exception'
|
||||
) {
|
||||
logger.info('page has been deleted', id)
|
||||
return false
|
||||
}
|
||||
logger.error('failed to update a page in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const deletePage = async (
|
||||
id: string,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const { body } = await client.delete({
|
||||
index: INDEX_ALIAS,
|
||||
id,
|
||||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
return body.deleted !== 0
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof errors.ResponseError &&
|
||||
e.message === 'document_missing_exception'
|
||||
) {
|
||||
logger.info('page has been deleted', id)
|
||||
return false
|
||||
}
|
||||
logger.error('failed to delete a page in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getPageByParam = async <K extends keyof ParamSet>(
|
||||
params: Record<K, ParamSet[K] | ParamSet[K][]>,
|
||||
includeOriginalHtml = false
|
||||
): Promise<Page | undefined> => {
|
||||
try {
|
||||
let builder = esBuilder()
|
||||
.size(1)
|
||||
.rawOption('_source', {
|
||||
excludes: includeOriginalHtml ? [] : ['originalHtml'],
|
||||
})
|
||||
// filter out undefined and null values and empty arrays
|
||||
// and build the query
|
||||
Object.entries<ParamSet[K] | ParamSet[K][]>(params)
|
||||
.filter(
|
||||
([, value]) =>
|
||||
value != null && !(Array.isArray(value) && value.length === 0)
|
||||
)
|
||||
.forEach(([key, value]) => {
|
||||
Array.isArray(value)
|
||||
? (builder = builder.query('terms', key, value))
|
||||
: (builder = builder.query('term', key, value))
|
||||
})
|
||||
const { body } = await client.search<SearchResponse<Page>>({
|
||||
index: INDEX_ALIAS,
|
||||
body: builder.build(),
|
||||
track_total_hits: true,
|
||||
})
|
||||
|
||||
if (body.hits.total.value === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...body.hits.hits[0]._source,
|
||||
id: body.hits.hits[0]._id,
|
||||
} as Page
|
||||
} catch (e) {
|
||||
logger.error('failed to get page by param in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const getPageById = async (id: string): Promise<Page | undefined> => {
|
||||
try {
|
||||
if (!id) return undefined
|
||||
|
||||
const { body } = await client.get({
|
||||
index: INDEX_ALIAS,
|
||||
id,
|
||||
})
|
||||
|
||||
return {
|
||||
...body._source,
|
||||
id: body._id as string,
|
||||
} as Page
|
||||
} catch (e) {
|
||||
if (e instanceof errors.ResponseError && e.statusCode === 404) {
|
||||
logger.info('page has been deleted', id)
|
||||
return undefined
|
||||
}
|
||||
logger.error('failed to get page by id in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const buildSearchBody = (userId: string, args: PageSearchArgs) => {
|
||||
const {
|
||||
query,
|
||||
readFilter = ReadFilter.ALL,
|
||||
typeFilter,
|
||||
labelFilters,
|
||||
inFilter = InFilter.ALL,
|
||||
hasFilters,
|
||||
dateFilters,
|
||||
termFilters,
|
||||
matchFilters,
|
||||
ids,
|
||||
noFilters,
|
||||
siteName,
|
||||
} = args
|
||||
|
||||
const includeLabels = labelFilters?.filter(
|
||||
(filter) => filter.type === LabelFilterType.INCLUDE
|
||||
)
|
||||
const excludeLabels = labelFilters?.filter(
|
||||
(filter) => filter.type === LabelFilterType.EXCLUDE
|
||||
)
|
||||
|
||||
// start building the query
|
||||
let builder = esBuilder().query('term', { userId })
|
||||
|
||||
// append filters
|
||||
if (query) {
|
||||
builder = appendQuery(builder, query)
|
||||
}
|
||||
if (typeFilter) {
|
||||
builder = appendTypeFilter(builder, typeFilter)
|
||||
}
|
||||
if (inFilter !== InFilter.ALL) {
|
||||
builder = appendInFilter(builder, inFilter)
|
||||
}
|
||||
if (readFilter !== ReadFilter.ALL) {
|
||||
builder = appendReadFilter(builder, readFilter)
|
||||
}
|
||||
if (hasFilters && hasFilters.length > 0) {
|
||||
builder = appendHasFilters(builder, hasFilters)
|
||||
}
|
||||
if (includeLabels && includeLabels.length > 0) {
|
||||
builder = appendIncludeLabelFilter(builder, includeLabels)
|
||||
}
|
||||
if (excludeLabels && excludeLabels.length > 0) {
|
||||
builder = appendExcludeLabelFilter(builder, excludeLabels)
|
||||
}
|
||||
if (dateFilters && dateFilters.length > 0) {
|
||||
builder = appendDateFilters(builder, dateFilters)
|
||||
}
|
||||
if (termFilters) {
|
||||
builder = appendTermFilters(builder, termFilters)
|
||||
}
|
||||
if (matchFilters) {
|
||||
builder = appendMatchFilters(builder, matchFilters)
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
builder = appendIdsFilter(builder, ids)
|
||||
}
|
||||
if (args.recommendedBy) {
|
||||
builder = appendRecommendedBy(builder, args.recommendedBy)
|
||||
}
|
||||
if (!args.includePending) {
|
||||
builder = builder.notQuery('term', {
|
||||
state: ArticleSavingRequestStatus.Processing,
|
||||
})
|
||||
}
|
||||
if (!args.includeDeleted && inFilter !== InFilter.TRASH) {
|
||||
builder = builder.notQuery('term', {
|
||||
state: ArticleSavingRequestStatus.Deleted,
|
||||
})
|
||||
}
|
||||
if (noFilters) {
|
||||
builder = appendNoFilters(builder, noFilters)
|
||||
}
|
||||
if (siteName) {
|
||||
builder = appendSiteNameFilter(builder, siteName)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
export const searchPages = async (
|
||||
args: PageSearchArgs,
|
||||
userId: string
|
||||
): Promise<[Page[], number] | undefined> => {
|
||||
try {
|
||||
const { from = 0, size = 10, sort, includeContent } = args
|
||||
|
||||
// default order is descending
|
||||
const sortOrder = sort?.order || SortOrder.DESCENDING
|
||||
// default sort by saved_at
|
||||
const sortField = sort?.by || SortBy.SAVED
|
||||
|
||||
// build the query
|
||||
const builder = buildSearchBody(userId, args)
|
||||
const body = builder
|
||||
.sort('_score', 'desc') // sort by score first
|
||||
.sort(sortField, sortOrder)
|
||||
.from(from)
|
||||
.size(size)
|
||||
.rawOption('_source', {
|
||||
excludes: includeContent ? [] : ['originalHtml', 'content'],
|
||||
})
|
||||
.build()
|
||||
|
||||
logger.info('searching pages in elastic', body)
|
||||
const response = await client.search<SearchResponse<Page>, BuiltQuery>({
|
||||
index: INDEX_ALIAS,
|
||||
body,
|
||||
})
|
||||
if (response.body.hits.total.value === 0) {
|
||||
return [[], 0]
|
||||
}
|
||||
|
||||
return [
|
||||
response.body.hits.hits.map((hit: { _source: Page; _id: string }) => ({
|
||||
...hit._source,
|
||||
content: includeContent ? hit._source.content : '',
|
||||
id: hit._id,
|
||||
})),
|
||||
response.body.hits.total.value,
|
||||
]
|
||||
} catch (e) {
|
||||
if (e instanceof errors.ResponseError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
logger.error('failed to search pages in elastic', e.meta.body.error)
|
||||
return undefined
|
||||
}
|
||||
logger.error('failed to search pages in elastic', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const countByCreatedAt = async (
|
||||
userId: string,
|
||||
from?: number,
|
||||
to?: number
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const { body } = await client.count({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
term: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
range: {
|
||||
createdAt: {
|
||||
gte: from,
|
||||
lte: to,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return body.count as number
|
||||
} catch (e) {
|
||||
logger.error('failed to count pages in elastic', e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export const deletePagesByParam = async <K extends keyof ParamSet>(
|
||||
param: Record<K, ParamSet[K]>,
|
||||
ctx: PageContext
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const params = {
|
||||
query: {
|
||||
bool: {
|
||||
filter: Object.keys(param).map((key) => {
|
||||
return {
|
||||
term: {
|
||||
[key]: param[key as K],
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const { body } = await client.deleteByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
body: params,
|
||||
conflicts: 'proceed',
|
||||
})
|
||||
|
||||
if (body.deleted > 0) {
|
||||
// * means deleting all pages of the same user
|
||||
await ctx.pubsub.entityDeleted(EntityType.PAGE, '*', ctx.uid)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (e) {
|
||||
logger.error('failed to delete pages by param in elastic', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const searchAsYouType = async (
|
||||
userId: string,
|
||||
query: string,
|
||||
size = 5
|
||||
): Promise<Page[]> => {
|
||||
try {
|
||||
const { body } = await client.search<SearchResponse<Page>>({
|
||||
index: INDEX_ALIAS,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
term: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
},
|
||||
},
|
||||
{
|
||||
multi_match: {
|
||||
query,
|
||||
type: 'bool_prefix',
|
||||
fields: [
|
||||
'title',
|
||||
'title._2gram',
|
||||
'title._3gram',
|
||||
'siteName',
|
||||
'siteName._2gram',
|
||||
'siteName._3gram',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
_source: ['title', 'slug', 'siteName', 'pageType'],
|
||||
size,
|
||||
},
|
||||
})
|
||||
|
||||
if (body.hits.total.value === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return body.hits.hits.map((hit: { _source: Page; _id: string }) => ({
|
||||
...hit._source,
|
||||
id: hit._id,
|
||||
}))
|
||||
} catch (e) {
|
||||
logger.error('failed to search as you type in elastic', e)
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const updatePages = async (
|
||||
ctx: PageContext,
|
||||
action: BulkActionType,
|
||||
args: PageSearchArgs,
|
||||
maxDocs: number,
|
||||
async: boolean,
|
||||
labels?: Label[]
|
||||
): Promise<string | null> => {
|
||||
// build the script
|
||||
let script = {
|
||||
source: '',
|
||||
params: {},
|
||||
}
|
||||
switch (action) {
|
||||
case BulkActionType.Archive:
|
||||
script = {
|
||||
source: `ctx._source.archivedAt = params.archivedAt;`,
|
||||
params: {
|
||||
archivedAt: new Date(),
|
||||
},
|
||||
}
|
||||
break
|
||||
case BulkActionType.Delete:
|
||||
script = {
|
||||
source: `ctx._source.state = params.state;`,
|
||||
params: {
|
||||
state: ArticleSavingRequestStatus.Deleted,
|
||||
},
|
||||
}
|
||||
break
|
||||
case BulkActionType.AddLabels:
|
||||
script = {
|
||||
source: `if (ctx._source.labels == null) {
|
||||
ctx._source.labels = params.labels
|
||||
} else {
|
||||
for (label in params.labels) {
|
||||
if (!ctx._source.labels.any(l -> l.name == label.name)) {
|
||||
ctx._source.labels.add(label)
|
||||
}
|
||||
}
|
||||
}`,
|
||||
params: {
|
||||
labels,
|
||||
},
|
||||
}
|
||||
break
|
||||
case BulkActionType.MarkAsRead:
|
||||
script = {
|
||||
source: `ctx._source.readAt = params.readAt;
|
||||
ctx._source.readingProgressPercent = params.readingProgressPercent;`,
|
||||
params: {
|
||||
readAt: new Date(),
|
||||
readingProgressPercent: 100,
|
||||
},
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error('Invalid bulk action')
|
||||
}
|
||||
|
||||
// add updatedAt to the script
|
||||
const updatedScript = {
|
||||
source: `${script.source} ctx._source.updatedAt = params.updatedAt`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
...script.params,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
|
||||
// build the query
|
||||
const searchBody = buildSearchBody(ctx.uid, args)
|
||||
.rawOption('script', updatedScript)
|
||||
.build()
|
||||
|
||||
logger.info('updating pages in elastic', searchBody)
|
||||
|
||||
try {
|
||||
const { body } = await client.updateByQuery({
|
||||
index: INDEX_ALIAS,
|
||||
conflicts: 'proceed',
|
||||
wait_for_completion: !async,
|
||||
body: searchBody,
|
||||
max_docs: maxDocs,
|
||||
requests_per_second: 500, // throttle the requests
|
||||
slices: 'auto', // parallelize the requests
|
||||
refresh: ctx.refresh,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (body.failures && body.failures.length > 0) {
|
||||
logger.info('failed to update pages in elastic', body.failures)
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO: publish entityUpdated events for each page
|
||||
|
||||
if (async) {
|
||||
logger.info('update pages task started', body.task)
|
||||
return body.task as string
|
||||
}
|
||||
|
||||
logger.info('updated pages in elastic', body.updated)
|
||||
return body.updated as string
|
||||
} catch (e) {
|
||||
logger.info('failed to update pages in elastic', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { logger } from '.'
|
||||
import { createPage, getPageByParam, updatePage } from './pages'
|
||||
import {
|
||||
ArticleSavingRequestStatus,
|
||||
Page,
|
||||
PageContext,
|
||||
Recommendation,
|
||||
} from './types'
|
||||
|
||||
export const addRecommendation = async (
|
||||
ctx: PageContext,
|
||||
page: Page,
|
||||
recommendation: Recommendation,
|
||||
highlightIds?: string[]
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const highlights = page.highlights?.filter((highlight) =>
|
||||
highlightIds?.includes(highlight.id)
|
||||
)
|
||||
|
||||
// check if the page is already recommended to the group
|
||||
const existingPage = await getPageByParam({
|
||||
userId: ctx.uid,
|
||||
url: page.url,
|
||||
})
|
||||
if (existingPage) {
|
||||
const existingHighlights = existingPage.highlights || []
|
||||
|
||||
// remove duplicates
|
||||
const newHighlights =
|
||||
highlights?.filter(
|
||||
(highlight) =>
|
||||
!existingHighlights.find(
|
||||
(existingHighlight) => existingHighlight.quote === highlight.quote
|
||||
)
|
||||
) || []
|
||||
|
||||
const existingRecommendations = existingPage.recommendations || []
|
||||
const isRecommended = existingRecommendations.some(
|
||||
(existingRecommendation) =>
|
||||
existingRecommendation.id === recommendation.id
|
||||
)
|
||||
if (isRecommended && newHighlights.length === 0) {
|
||||
return existingPage.id
|
||||
}
|
||||
|
||||
// update recommendations in the existing page
|
||||
const recommendations = isRecommended
|
||||
? undefined
|
||||
: existingRecommendations.concat(recommendation)
|
||||
|
||||
await updatePage(
|
||||
existingPage.id,
|
||||
{
|
||||
recommendations,
|
||||
highlights: existingHighlights.concat(newHighlights),
|
||||
},
|
||||
ctx
|
||||
)
|
||||
|
||||
return existingPage.id
|
||||
}
|
||||
|
||||
// create a new page
|
||||
const newPage: Page = {
|
||||
...page,
|
||||
id: '',
|
||||
recommendations: [recommendation],
|
||||
userId: ctx.uid,
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
sharedAt: new Date(),
|
||||
highlights,
|
||||
readAt: undefined,
|
||||
labels: undefined,
|
||||
subscription: undefined,
|
||||
unsubHttpUrl: undefined,
|
||||
unsubMailTo: undefined,
|
||||
_id: undefined,
|
||||
archivedAt: undefined,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
taskName: undefined,
|
||||
}
|
||||
|
||||
return createPage(newPage, ctx)
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
// Define the type of the body for the Search request
|
||||
import { PubsubClient } from '../datalayer/pubsub'
|
||||
import { PickTuple } from '../util'
|
||||
import {
|
||||
DateFilter,
|
||||
FieldFilter,
|
||||
HasFilter,
|
||||
InFilter,
|
||||
LabelFilter,
|
||||
NoFilter,
|
||||
ReadFilter,
|
||||
SortParams,
|
||||
} from '../utils/search'
|
||||
|
||||
// Complete definition of the Search response
|
||||
export interface ShardsResponse {
|
||||
total: number
|
||||
successful: number
|
||||
failed: number
|
||||
skipped: number
|
||||
}
|
||||
|
||||
export interface Explanation {
|
||||
value: number
|
||||
description: string
|
||||
details: Explanation[]
|
||||
}
|
||||
|
||||
export interface SearchResponse<T> {
|
||||
took: number
|
||||
timed_out: boolean
|
||||
_scroll_id?: string
|
||||
_shards: ShardsResponse
|
||||
hits: {
|
||||
total: {
|
||||
value: number
|
||||
}
|
||||
max_score: number
|
||||
hits: Array<{
|
||||
_index: string
|
||||
_type: string
|
||||
_id: string
|
||||
_score: number
|
||||
_source: T
|
||||
_version?: number
|
||||
_explanation?: Explanation
|
||||
fields?: never
|
||||
highlight?: never
|
||||
inner_hits?: unknown
|
||||
matched_queries?: string[]
|
||||
sort?: string[]
|
||||
}>
|
||||
}
|
||||
aggregations?: never
|
||||
}
|
||||
|
||||
export enum PageType {
|
||||
Article = 'ARTICLE',
|
||||
Book = 'BOOK',
|
||||
File = 'FILE',
|
||||
Profile = 'PROFILE',
|
||||
Unknown = 'UNKNOWN',
|
||||
Website = 'WEBSITE',
|
||||
Highlights = 'HIGHLIGHTS',
|
||||
Tweet = 'TWEET',
|
||||
Video = 'VIDEO',
|
||||
Image = 'IMAGE',
|
||||
}
|
||||
|
||||
export enum ArticleSavingRequestStatus {
|
||||
Failed = 'FAILED',
|
||||
Processing = 'PROCESSING',
|
||||
Succeeded = 'SUCCEEDED',
|
||||
Deleted = 'DELETED',
|
||||
|
||||
Archived = 'ARCHIVED',
|
||||
}
|
||||
|
||||
export enum HighlightType {
|
||||
Highlight = 'HIGHLIGHT',
|
||||
Redaction = 'REDACTION', // allowing people to remove text from the page
|
||||
Note = 'NOTE', // allowing people to add a note at the document level
|
||||
}
|
||||
|
||||
export interface Label {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
description?: string | null
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
export interface Highlight {
|
||||
id: string
|
||||
shortId: string
|
||||
patch?: string | null
|
||||
quote?: string | null
|
||||
userId: string
|
||||
createdAt: Date
|
||||
prefix?: string | null
|
||||
suffix?: string | null
|
||||
annotation?: string | null
|
||||
sharedAt?: Date | null
|
||||
updatedAt: Date
|
||||
labels?: Label[]
|
||||
highlightPositionPercent?: number | null
|
||||
highlightPositionAnchorIndex?: number | null
|
||||
type: HighlightType
|
||||
html?: string | null
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
export interface RecommendingUser {
|
||||
userId: string
|
||||
name: string
|
||||
username: string
|
||||
profileImageURL: string | null
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string
|
||||
name: string
|
||||
note?: string | null
|
||||
user: RecommendingUser
|
||||
recommendedAt: Date
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
id: string
|
||||
userId: string
|
||||
title: string
|
||||
author?: string
|
||||
description?: string
|
||||
content: string
|
||||
url: string
|
||||
hash: string
|
||||
uploadFileId?: string | null
|
||||
image?: string
|
||||
pageType: PageType
|
||||
originalHtml?: string | null
|
||||
slug: string
|
||||
labels?: Label[]
|
||||
readingProgressTopPercent?: number
|
||||
readingProgressPercent: number
|
||||
readingProgressAnchorIndex: number
|
||||
createdAt: Date
|
||||
updatedAt?: Date
|
||||
publishedAt?: Date
|
||||
savedAt: Date
|
||||
sharedAt?: Date
|
||||
archivedAt?: Date | null
|
||||
siteName?: string
|
||||
_id?: string
|
||||
siteIcon?: string
|
||||
highlights?: Highlight[]
|
||||
subscription?: string
|
||||
unsubMailTo?: string
|
||||
unsubHttpUrl?: string
|
||||
state: ArticleSavingRequestStatus
|
||||
taskName?: string
|
||||
language?: string
|
||||
readAt?: Date
|
||||
listenedAt?: Date
|
||||
wordsCount?: number
|
||||
recommendations?: Recommendation[]
|
||||
rssFeedUrl?: string
|
||||
}
|
||||
|
||||
export interface SearchItem {
|
||||
annotation?: string | null
|
||||
author?: string | null
|
||||
createdAt: Date
|
||||
description?: string | null
|
||||
id: string
|
||||
image?: string | null
|
||||
pageId?: string
|
||||
pageType: PageType
|
||||
publishedAt?: Date
|
||||
quote?: string | null
|
||||
shortId?: string | null
|
||||
slug: string
|
||||
title: string
|
||||
uploadFileId?: string | null
|
||||
url: string
|
||||
archivedAt?: Date | null
|
||||
readingProgressTopPercent?: number
|
||||
readingProgressPercent: number
|
||||
readingProgressAnchorIndex: number
|
||||
userId: string
|
||||
state?: ArticleSavingRequestStatus
|
||||
language?: string
|
||||
readAt?: Date
|
||||
savedAt: Date
|
||||
updatedAt?: Date
|
||||
labels?: Label[]
|
||||
highlights?: Highlight[]
|
||||
wordsCount?: number
|
||||
siteName?: string
|
||||
siteIcon?: string
|
||||
recommendations?: Recommendation[]
|
||||
content?: string
|
||||
}
|
||||
|
||||
const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const
|
||||
|
||||
export type ParamSet = PickTuple<Page, typeof keys>
|
||||
|
||||
export interface PageContext {
|
||||
pubsub: PubsubClient
|
||||
refresh?: boolean
|
||||
uid: string
|
||||
shouldPublish?: boolean
|
||||
}
|
||||
|
||||
export interface PageSearchArgs {
|
||||
from?: number
|
||||
size?: number
|
||||
sort?: SortParams
|
||||
query?: string
|
||||
inFilter?: InFilter
|
||||
readFilter?: ReadFilter
|
||||
typeFilter?: PageType
|
||||
labelFilters?: LabelFilter[]
|
||||
hasFilters?: HasFilter[]
|
||||
dateFilters?: DateFilter[]
|
||||
termFilters?: FieldFilter[]
|
||||
matchFilters?: FieldFilter[]
|
||||
includePending?: boolean | null
|
||||
includeDeleted?: boolean
|
||||
ids?: string[]
|
||||
recommendedBy?: string
|
||||
includeContent?: boolean
|
||||
noFilters?: NoFilter[]
|
||||
siteName?: string
|
||||
}
|
||||
|
|
@ -5,10 +5,12 @@ import {
|
|||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity()
|
||||
@Unique('user_id_name', ['user', 'name'])
|
||||
export class ApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
|
|
|||
16
packages/api/src/entity/entity_label.ts
Normal file
16
packages/api/src/entity/entity_label.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'entity_labels' })
|
||||
export class EntityLabel {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('uuid')
|
||||
labelId!: string
|
||||
|
||||
@Column('uuid')
|
||||
libraryItemId?: string | null
|
||||
|
||||
@Column('uuid')
|
||||
highlightId?: string | null
|
||||
}
|
||||
|
|
@ -3,53 +3,85 @@ import {
|
|||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { Label } from './label'
|
||||
import { LibraryItem } from './library_item'
|
||||
import { User } from './user'
|
||||
import { Page } from './page'
|
||||
|
||||
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
|
||||
id!: string
|
||||
|
||||
@Column({ type: 'varchar', length: 14 })
|
||||
shortId!: string
|
||||
|
||||
@OneToOne(() => User)
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@OneToOne(() => Page)
|
||||
@JoinColumn({ name: 'article_id' })
|
||||
page!: Page
|
||||
@ManyToOne(() => LibraryItem, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'library_item_id' })
|
||||
libraryItem!: LibraryItem
|
||||
|
||||
@Column('text')
|
||||
quote!: string
|
||||
quote?: string | null
|
||||
|
||||
@Column({ type: 'varchar', length: 5000 })
|
||||
prefix?: string
|
||||
prefix?: string | null
|
||||
|
||||
@Column({ type: 'varchar', length: 5000 })
|
||||
suffix?: string
|
||||
suffix?: string | null
|
||||
|
||||
@Column('text')
|
||||
patch!: string
|
||||
patch?: string | null
|
||||
|
||||
@Column('text')
|
||||
annotation?: string
|
||||
|
||||
@Column('boolean')
|
||||
deleted?: boolean
|
||||
annotation?: string | null
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
updatedAt?: Date | null
|
||||
|
||||
@Column('timestamp')
|
||||
sharedAt?: Date
|
||||
|
||||
@Column('real')
|
||||
highlightPositionPercent?: number | null
|
||||
|
||||
@Column('integer')
|
||||
highlightPositionAnchorIndex?: number | null
|
||||
|
||||
@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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class Integration {
|
|||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', eager: true })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
|
|
@ -34,4 +35,7 @@ export class Label {
|
|||
|
||||
@Column('boolean', { default: false })
|
||||
internal!: boolean
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date | null
|
||||
}
|
||||
|
|
|
|||
204
packages/api/src/entity/library_item.ts
Normal file
204
packages/api/src/entity/library_item.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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('json', { nullable: true })
|
||||
metadata?: Record<string, unknown> | 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
|
||||
|
||||
@Column('enum', { enum: ContentReaderType, default: ContentReaderType.WEB })
|
||||
contentReader!: ContentReaderType
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
originalContent?: string | null
|
||||
|
||||
@Column('text')
|
||||
readableContent!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
modelName?: string | null
|
||||
|
||||
// NOT SUPPORTED IN TYPEORM
|
||||
// @Column('vector', { nullable: true })
|
||||
// embedding?: number[]
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
textContentHash?: string | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
gcsArchiveId?: 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
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// Table "omnivore.links"
|
||||
// Column | Type | Collation | Nullable | Default
|
||||
// ---------------------------------------+--------------------------+-----------+----------+----------------------
|
||||
// article_url | text | | not null |
|
||||
// article_hash | text | | not null |
|
||||
// created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
|
||||
// shared_comment | text | | |
|
||||
// article_reading_progress | real | | not null | 0
|
||||
// article_reading_progress_anchor_index | integer | | not null | 0
|
||||
// shared_with_highlights | boolean | | | false
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
import { User } from './user'
|
||||
import { Page } from './page'
|
||||
import { Label } from './label'
|
||||
|
||||
@Entity({ name: 'links' })
|
||||
export class Link {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
slug!: string
|
||||
|
||||
@OneToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@OneToOne(() => Page)
|
||||
@JoinColumn({ name: 'article_id' })
|
||||
page!: Page
|
||||
|
||||
@Column('timestamp')
|
||||
savedAt!: Date
|
||||
|
||||
@Column('timestamp')
|
||||
sharedAt!: Date | null
|
||||
|
||||
@Column('timestamp')
|
||||
archivedAt?: Date | null
|
||||
|
||||
@Column('text')
|
||||
articleUrl!: string
|
||||
|
||||
@Column('text')
|
||||
articleHash!: string
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
|
||||
@ManyToMany(() => Label)
|
||||
@JoinTable({
|
||||
name: 'link_labels',
|
||||
joinColumn: { name: 'link_id' },
|
||||
inverseJoinColumn: { name: 'label_id' },
|
||||
})
|
||||
labels?: Label[]
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import {
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { Link } from './link'
|
||||
import { Label } from './label'
|
||||
|
||||
@Entity({ name: 'link_labels' })
|
||||
export class LinkLabel {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => Link)
|
||||
@JoinColumn({ name: 'link_id' })
|
||||
link!: Link
|
||||
|
||||
@ManyToOne(() => Label)
|
||||
@JoinColumn({ name: 'label_id' })
|
||||
label!: Label
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
@Entity({ name: 'pages' })
|
||||
export class Page {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
url!: string
|
||||
|
||||
@Column('text')
|
||||
hash!: string
|
||||
|
||||
@Column('text')
|
||||
title!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
uploadFileId!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
author!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
image!: string
|
||||
|
||||
@Column('text')
|
||||
content!: string
|
||||
|
||||
@Column('text', { name: 'page_type' })
|
||||
type!: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
originalHtml!: string
|
||||
|
||||
@Column('timestamp')
|
||||
publishedAt?: Date
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt?: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
|
@ -18,11 +18,11 @@ export class Profile {
|
|||
@Column('text')
|
||||
username!: string
|
||||
|
||||
@Column('text')
|
||||
bio!: string
|
||||
@Column('text', { nullable: true })
|
||||
bio?: string | null
|
||||
|
||||
@Column('text')
|
||||
pictureUrl!: string
|
||||
@Column('text', { nullable: true })
|
||||
pictureUrl?: string | null
|
||||
|
||||
@OneToOne(() => User, (user) => user.profile)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
|
|
|
|||
35
packages/api/src/entity/recommendation.ts
Normal file
35
packages/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
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export class AbuseReport {
|
|||
id?: string
|
||||
|
||||
@Column('text')
|
||||
pageId?: string
|
||||
libraryItemId?: string
|
||||
|
||||
@Column('text')
|
||||
sharedBy!: string
|
||||
|
|
@ -35,7 +35,4 @@ export class AbuseReport {
|
|||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
|
||||
@Column('text')
|
||||
elasticPageId?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class ContentDisplayReport {
|
|||
user!: User
|
||||
|
||||
@Column('text')
|
||||
pageId?: string
|
||||
libraryItemId?: string
|
||||
|
||||
@Column('text')
|
||||
content!: string
|
||||
|
|
@ -38,7 +38,4 @@ export class ContentDisplayReport {
|
|||
|
||||
@UpdateDateColumn()
|
||||
updatedAt?: Date
|
||||
|
||||
@Column('text')
|
||||
elasticPageId?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export class Subscription {
|
|||
unsubscribeHttpUrl?: string
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
icon?: string
|
||||
icon?: string | null
|
||||
|
||||
@Column('enum', {
|
||||
enum: SubscriptionType,
|
||||
|
|
|
|||
|
|
@ -7,13 +7,23 @@ import {
|
|||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { RegistrationType, StatusType } from '../datalayer/user/model'
|
||||
import { Label } from './label'
|
||||
import { NewsletterEmail } from './newsletter_email'
|
||||
import { Profile } from './profile'
|
||||
import { Label } from './label'
|
||||
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',
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
|
|
@ -40,7 +50,10 @@ export class User {
|
|||
@OneToMany(() => NewsletterEmail, (newsletterEmail) => newsletterEmail.user)
|
||||
newsletterEmails?: NewsletterEmail[]
|
||||
|
||||
@OneToOne(() => Profile, (profile) => profile.user, { eager: true })
|
||||
@OneToOne(() => Profile, (profile) => profile.user, {
|
||||
eager: true,
|
||||
cascade: true,
|
||||
})
|
||||
profile!: Profile
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true })
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { EntityManager, EntityTarget, Repository } from 'typeorm'
|
||||
import { AppDataSource } from '../server'
|
||||
|
||||
export const setClaims = async (
|
||||
t: EntityManager,
|
||||
uid: string
|
||||
): Promise<void> => {
|
||||
const dbRole = 'omnivore_user'
|
||||
return t
|
||||
.query('SELECT * from omnivore.set_claims($1, $2)', [uid, dbRole])
|
||||
.then()
|
||||
}
|
||||
|
||||
export const getRepository = <T>(entity: EntityTarget<T>): Repository<T> => {
|
||||
return AppDataSource.getRepository(entity)
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ export class PublishEntitySubscriber implements EntitySubscriberInterface {
|
|||
const msg = JSON.stringify({
|
||||
type: 'EntityCreated',
|
||||
entity: event.entity,
|
||||
entityClass: event.entity.constructor.name,
|
||||
entityClass: event.entity?.constructor?.name,
|
||||
})
|
||||
|
||||
if (env.dev.isLocal) {
|
||||
|
|
@ -29,7 +29,7 @@ export class PublishEntitySubscriber implements EntitySubscriberInterface {
|
|||
|
||||
await client
|
||||
.topic(TOPIC_NAME)
|
||||
.publish(Buffer.from(msg))
|
||||
.publishMessage({ data: Buffer.from(msg) })
|
||||
.catch((err) => {
|
||||
logger.error('PublishEntitySubscriber error publishing event', err)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import {
|
|||
EventSubscriber,
|
||||
InsertEvent,
|
||||
} from 'typeorm'
|
||||
import { createPubSubClient } from '../../datalayer/pubsub'
|
||||
import { Profile } from '../../entity/profile'
|
||||
import { createPubSubClient } from '../../pubsub'
|
||||
import { addPopularReadsForNewUser } from '../../services/popular_reads'
|
||||
import { IntercomClient } from '../../utils/intercom'
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ export class CreateIntercomAccount
|
|||
email: profile.user.email,
|
||||
externalId: profile.user.id,
|
||||
name: profile.user.name,
|
||||
avatar: profile.pictureUrl,
|
||||
avatar: profile.pictureUrl || undefined,
|
||||
customAttributes: customAttributes,
|
||||
signedUpAt: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
|
@ -59,6 +59,6 @@ export class AddPopularReadsToNewUser
|
|||
}
|
||||
|
||||
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
|
||||
await addPopularReadsForNewUser(event.entity.user.id)
|
||||
await addPopularReadsForNewUser(event.entity.user.id, event.manager)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export type Article = {
|
|||
title: Scalars['String'];
|
||||
unsubHttpUrl?: Maybe<Scalars['String']>;
|
||||
unsubMailTo?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
uploadFileId?: Maybe<Scalars['ID']>;
|
||||
url: Scalars['String'];
|
||||
wordsCount?: Maybe<Scalars['Int']>;
|
||||
|
|
@ -167,7 +167,7 @@ export type ArticleSavingRequest = {
|
|||
id: Scalars['ID'];
|
||||
slug: Scalars['String'];
|
||||
status: ArticleSavingRequestStatus;
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
url: Scalars['String'];
|
||||
user: User;
|
||||
/** @deprecated userId has been replaced with user */
|
||||
|
|
@ -721,7 +721,7 @@ export type Feature = {
|
|||
id: Scalars['ID'];
|
||||
name: Scalars['String'];
|
||||
token: Scalars['String'];
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
};
|
||||
|
||||
export type FeedArticle = {
|
||||
|
|
@ -771,7 +771,7 @@ export type Filter = {
|
|||
id: Scalars['ID'];
|
||||
name: Scalars['String'];
|
||||
position: Scalars['Int'];
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
visible?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
|
|
@ -928,7 +928,7 @@ export type Highlight = {
|
|||
shortId: Scalars['String'];
|
||||
suffix?: Maybe<Scalars['String']>;
|
||||
type: HighlightType;
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
user: User;
|
||||
};
|
||||
|
||||
|
|
@ -938,7 +938,7 @@ export type HighlightReply = {
|
|||
highlight: Highlight;
|
||||
id: Scalars['ID'];
|
||||
text: Scalars['String'];
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
user: User;
|
||||
};
|
||||
|
||||
|
|
@ -979,7 +979,7 @@ export type Integration = {
|
|||
taskName?: Maybe<Scalars['String']>;
|
||||
token: Scalars['String'];
|
||||
type: IntegrationType;
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
};
|
||||
|
||||
export enum IntegrationType {
|
||||
|
|
@ -1082,7 +1082,7 @@ export type Link = {
|
|||
shareInfo: LinkShareInfo;
|
||||
shareStats: ShareStats;
|
||||
slug: Scalars['String'];
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
|
|
@ -1239,20 +1239,14 @@ export type Mutation = {
|
|||
createArticleSavingRequest: CreateArticleSavingRequestResult;
|
||||
createGroup: CreateGroupResult;
|
||||
createHighlight: CreateHighlightResult;
|
||||
createHighlightReply: CreateHighlightReplyResult;
|
||||
createLabel: CreateLabelResult;
|
||||
createNewsletterEmail: CreateNewsletterEmailResult;
|
||||
createReaction: CreateReactionResult;
|
||||
createReminder: CreateReminderResult;
|
||||
deleteAccount: DeleteAccountResult;
|
||||
deleteFilter: DeleteFilterResult;
|
||||
deleteHighlight: DeleteHighlightResult;
|
||||
deleteHighlightReply: DeleteHighlightReplyResult;
|
||||
deleteIntegration: DeleteIntegrationResult;
|
||||
deleteLabel: DeleteLabelResult;
|
||||
deleteNewsletterEmail: DeleteNewsletterEmailResult;
|
||||
deleteReaction: DeleteReactionResult;
|
||||
deleteReminder: DeleteReminderResult;
|
||||
deleteRule: DeleteRuleResult;
|
||||
deleteWebhook: DeleteWebhookResult;
|
||||
generateApiKey: GenerateApiKeyResult;
|
||||
|
|
@ -1279,14 +1273,11 @@ export type Mutation = {
|
|||
setBookmarkArticle: SetBookmarkArticleResult;
|
||||
setDeviceToken: SetDeviceTokenResult;
|
||||
setFavoriteArticle: SetFavoriteArticleResult;
|
||||
setFollow: SetFollowResult;
|
||||
setIntegration: SetIntegrationResult;
|
||||
setLabels: SetLabelsResult;
|
||||
setLabelsForHighlight: SetLabelsResult;
|
||||
setLinkArchived: ArchiveLinkResult;
|
||||
setRule: SetRuleResult;
|
||||
setShareArticle: SetShareArticleResult;
|
||||
setShareHighlight: SetShareHighlightResult;
|
||||
setUserPersonalization: SetUserPersonalizationResult;
|
||||
setWebhook: SetWebhookResult;
|
||||
subscribe: SubscribeResult;
|
||||
|
|
@ -1294,12 +1285,8 @@ export type Mutation = {
|
|||
updateEmail: UpdateEmailResult;
|
||||
updateFilter: UpdateFilterResult;
|
||||
updateHighlight: UpdateHighlightResult;
|
||||
updateHighlightReply: UpdateHighlightReplyResult;
|
||||
updateLabel: UpdateLabelResult;
|
||||
updateLinkShareInfo: UpdateLinkShareInfoResult;
|
||||
updatePage: UpdatePageResult;
|
||||
updateReminder: UpdateReminderResult;
|
||||
updateSharedComment: UpdateSharedCommentResult;
|
||||
updateSubscription: UpdateSubscriptionResult;
|
||||
updateUser: UpdateUserResult;
|
||||
updateUserProfile: UpdateUserProfileResult;
|
||||
|
|
@ -1342,26 +1329,11 @@ export type MutationCreateHighlightArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationCreateHighlightReplyArgs = {
|
||||
input: CreateHighlightReplyInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateLabelArgs = {
|
||||
input: CreateLabelInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateReactionArgs = {
|
||||
input: CreateReactionInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateReminderArgs = {
|
||||
input: CreateReminderInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteAccountArgs = {
|
||||
userID: Scalars['ID'];
|
||||
};
|
||||
|
|
@ -1377,11 +1349,6 @@ export type MutationDeleteHighlightArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationDeleteHighlightReplyArgs = {
|
||||
highlightReplyId: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteIntegrationArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
|
@ -1397,16 +1364,6 @@ export type MutationDeleteNewsletterEmailArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationDeleteReactionArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteReminderArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteRuleArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
|
@ -1532,11 +1489,6 @@ export type MutationSetFavoriteArticleArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationSetFollowArgs = {
|
||||
input: SetFollowInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetIntegrationArgs = {
|
||||
input: SetIntegrationInput;
|
||||
};
|
||||
|
|
@ -1562,16 +1514,6 @@ export type MutationSetRuleArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationSetShareArticleArgs = {
|
||||
input: SetShareArticleInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetShareHighlightArgs = {
|
||||
input: SetShareHighlightInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetUserPersonalizationArgs = {
|
||||
input: SetUserPersonalizationInput;
|
||||
};
|
||||
|
|
@ -1608,36 +1550,16 @@ export type MutationUpdateHighlightArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationUpdateHighlightReplyArgs = {
|
||||
input: UpdateHighlightReplyInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateLabelArgs = {
|
||||
input: UpdateLabelInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateLinkShareInfoArgs = {
|
||||
input: UpdateLinkShareInfoInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdatePageArgs = {
|
||||
input: UpdatePageInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateReminderArgs = {
|
||||
input: UpdateReminderInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateSharedCommentArgs = {
|
||||
input: UpdateSharedCommentInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateSubscriptionArgs = {
|
||||
input: UpdateSubscriptionInput;
|
||||
};
|
||||
|
|
@ -1724,7 +1646,7 @@ export type Page = {
|
|||
readableHtml: Scalars['String'];
|
||||
title: Scalars['String'];
|
||||
type: PageType;
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
|
|
@ -1794,12 +1716,8 @@ export type Query = {
|
|||
apiKeys: ApiKeysResult;
|
||||
article: ArticleResult;
|
||||
articleSavingRequest: ArticleSavingRequestResult;
|
||||
articles: ArticlesResult;
|
||||
deviceTokens: DeviceTokensResult;
|
||||
feedArticles: FeedArticlesResult;
|
||||
filters: FiltersResult;
|
||||
getFollowers: GetFollowersResult;
|
||||
getFollowing: GetFollowingResult;
|
||||
getUserPersonalization: GetUserPersonalizationResult;
|
||||
groups: GroupsResult;
|
||||
hello?: Maybe<Scalars['String']>;
|
||||
|
|
@ -1809,11 +1727,9 @@ export type Query = {
|
|||
newsletterEmails: NewsletterEmailsResult;
|
||||
recentEmails: RecentEmailsResult;
|
||||
recentSearches: RecentSearchesResult;
|
||||
reminder: ReminderResult;
|
||||
rules: RulesResult;
|
||||
search: SearchResult;
|
||||
sendInstallInstructions: SendInstallInstructionsResult;
|
||||
sharedArticle: SharedArticleResult;
|
||||
subscriptions: SubscriptionsResult;
|
||||
typeaheadSearch: TypeaheadSearchResult;
|
||||
updatesSince: UpdatesSinceResult;
|
||||
|
|
@ -1838,39 +1754,6 @@ export type QueryArticleSavingRequestArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type QueryArticlesArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
includePending?: InputMaybe<Scalars['Boolean']>;
|
||||
query?: InputMaybe<Scalars['String']>;
|
||||
sharedOnly?: InputMaybe<Scalars['Boolean']>;
|
||||
sort?: InputMaybe<SortParams>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryFeedArticlesArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
sharedByUser?: InputMaybe<Scalars['ID']>;
|
||||
sort?: InputMaybe<SortParams>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetFollowersArgs = {
|
||||
userId?: InputMaybe<Scalars['ID']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetFollowingArgs = {
|
||||
userId?: InputMaybe<Scalars['ID']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryReminderArgs = {
|
||||
linkId: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryRulesArgs = {
|
||||
enabled?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
|
@ -1885,13 +1768,6 @@ export type QuerySearchArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type QuerySharedArticleArgs = {
|
||||
selectedHighlightId?: InputMaybe<Scalars['String']>;
|
||||
slug: Scalars['String'];
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type QuerySubscriptionsArgs = {
|
||||
sort?: InputMaybe<SortParams>;
|
||||
type?: InputMaybe<SubscriptionType>;
|
||||
|
|
@ -2077,7 +1953,7 @@ export type RecommendationGroup = {
|
|||
members: Array<User>;
|
||||
name: Scalars['String'];
|
||||
topics?: Maybe<Array<Scalars['String']>>;
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
};
|
||||
|
||||
export type RecommendingUser = {
|
||||
|
|
@ -2161,7 +2037,7 @@ export type Rule = {
|
|||
filter: Scalars['String'];
|
||||
id: Scalars['ID'];
|
||||
name: Scalars['String'];
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
};
|
||||
|
||||
export type RuleAction = {
|
||||
|
|
@ -2217,7 +2093,7 @@ export enum SaveArticleReadingProgressErrorCode {
|
|||
|
||||
export type SaveArticleReadingProgressInput = {
|
||||
id: Scalars['ID'];
|
||||
readingProgressAnchorIndex: Scalars['Int'];
|
||||
readingProgressAnchorIndex?: InputMaybe<Scalars['Int']>;
|
||||
readingProgressPercent: Scalars['Float'];
|
||||
readingProgressTopPercent?: InputMaybe<Scalars['Float']>;
|
||||
};
|
||||
|
|
@ -2457,7 +2333,7 @@ export type SetFavoriteArticleResult = SetFavoriteArticleError | SetFavoriteArti
|
|||
|
||||
export type SetFavoriteArticleSuccess = {
|
||||
__typename?: 'SetFavoriteArticleSuccess';
|
||||
favoriteArticle: Article;
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type SetFollowError = {
|
||||
|
|
@ -2766,7 +2642,7 @@ export type Subscription = {
|
|||
type: SubscriptionType;
|
||||
unsubscribeHttpUrl?: Maybe<Scalars['String']>;
|
||||
unsubscribeMailTo?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
url?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
|
@ -3315,7 +3191,7 @@ export type Webhook = {
|
|||
eventTypes: Array<WebhookEvent>;
|
||||
id: Scalars['ID'];
|
||||
method: Scalars['String'];
|
||||
updatedAt: Scalars['Date'];
|
||||
updatedAt?: Maybe<Scalars['Date']>;
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
|
|
@ -4438,7 +4314,7 @@ export type ArticleResolvers<ContextType = ResolverContext, ParentType extends R
|
|||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
unsubHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
unsubMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
wordsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
|
|
@ -4467,7 +4343,7 @@ export type ArticleSavingRequestResolvers<ContextType = ResolverContext, ParentT
|
|||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
status?: Resolver<ResolversTypes['ArticleSavingRequestStatus'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
user?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
|
||||
userId?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
|
|
@ -4836,7 +4712,7 @@ export type FeatureResolvers<ContextType = ResolverContext, ParentType extends R
|
|||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
|
@ -4884,7 +4760,7 @@ export type FilterResolvers<ContextType = ResolverContext, ParentType extends Re
|
|||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
position?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
visible?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
@ -5006,7 +4882,7 @@ export type HighlightResolvers<ContextType = ResolverContext, ParentType extends
|
|||
shortId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
suffix?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['HighlightType'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
user?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
@ -5016,7 +4892,7 @@ export type HighlightReplyResolvers<ContextType = ResolverContext, ParentType ex
|
|||
highlight?: Resolver<ResolversTypes['Highlight'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
text?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
user?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
@ -5048,7 +4924,7 @@ export type IntegrationResolvers<ContextType = ResolverContext, ParentType exten
|
|||
taskName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['IntegrationType'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
|
@ -5131,7 +5007,7 @@ export type LinkResolvers<ContextType = ResolverContext, ParentType extends Reso
|
|||
shareInfo?: Resolver<ResolversTypes['LinkShareInfo'], ParentType, ContextType>;
|
||||
shareStats?: Resolver<ResolversTypes['ShareStats'], ParentType, ContextType>;
|
||||
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
@ -5235,20 +5111,14 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
|||
createArticleSavingRequest?: Resolver<ResolversTypes['CreateArticleSavingRequestResult'], ParentType, ContextType, RequireFields<MutationCreateArticleSavingRequestArgs, 'input'>>;
|
||||
createGroup?: Resolver<ResolversTypes['CreateGroupResult'], ParentType, ContextType, RequireFields<MutationCreateGroupArgs, 'input'>>;
|
||||
createHighlight?: Resolver<ResolversTypes['CreateHighlightResult'], ParentType, ContextType, RequireFields<MutationCreateHighlightArgs, 'input'>>;
|
||||
createHighlightReply?: Resolver<ResolversTypes['CreateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationCreateHighlightReplyArgs, 'input'>>;
|
||||
createLabel?: Resolver<ResolversTypes['CreateLabelResult'], ParentType, ContextType, RequireFields<MutationCreateLabelArgs, 'input'>>;
|
||||
createNewsletterEmail?: Resolver<ResolversTypes['CreateNewsletterEmailResult'], ParentType, ContextType>;
|
||||
createReaction?: Resolver<ResolversTypes['CreateReactionResult'], ParentType, ContextType, RequireFields<MutationCreateReactionArgs, 'input'>>;
|
||||
createReminder?: Resolver<ResolversTypes['CreateReminderResult'], ParentType, ContextType, RequireFields<MutationCreateReminderArgs, 'input'>>;
|
||||
deleteAccount?: Resolver<ResolversTypes['DeleteAccountResult'], ParentType, ContextType, RequireFields<MutationDeleteAccountArgs, 'userID'>>;
|
||||
deleteFilter?: Resolver<ResolversTypes['DeleteFilterResult'], ParentType, ContextType, RequireFields<MutationDeleteFilterArgs, 'id'>>;
|
||||
deleteHighlight?: Resolver<ResolversTypes['DeleteHighlightResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightArgs, 'highlightId'>>;
|
||||
deleteHighlightReply?: Resolver<ResolversTypes['DeleteHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightReplyArgs, 'highlightReplyId'>>;
|
||||
deleteIntegration?: Resolver<ResolversTypes['DeleteIntegrationResult'], ParentType, ContextType, RequireFields<MutationDeleteIntegrationArgs, 'id'>>;
|
||||
deleteLabel?: Resolver<ResolversTypes['DeleteLabelResult'], ParentType, ContextType, RequireFields<MutationDeleteLabelArgs, 'id'>>;
|
||||
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
|
||||
deleteReaction?: Resolver<ResolversTypes['DeleteReactionResult'], ParentType, ContextType, RequireFields<MutationDeleteReactionArgs, 'id'>>;
|
||||
deleteReminder?: Resolver<ResolversTypes['DeleteReminderResult'], ParentType, ContextType, RequireFields<MutationDeleteReminderArgs, 'id'>>;
|
||||
deleteRule?: Resolver<ResolversTypes['DeleteRuleResult'], ParentType, ContextType, RequireFields<MutationDeleteRuleArgs, 'id'>>;
|
||||
deleteWebhook?: Resolver<ResolversTypes['DeleteWebhookResult'], ParentType, ContextType, RequireFields<MutationDeleteWebhookArgs, 'id'>>;
|
||||
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
|
||||
|
|
@ -5275,14 +5145,11 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
|||
setBookmarkArticle?: Resolver<ResolversTypes['SetBookmarkArticleResult'], ParentType, ContextType, RequireFields<MutationSetBookmarkArticleArgs, 'input'>>;
|
||||
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
|
||||
setFavoriteArticle?: Resolver<ResolversTypes['SetFavoriteArticleResult'], ParentType, ContextType, RequireFields<MutationSetFavoriteArticleArgs, 'id'>>;
|
||||
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
|
||||
setIntegration?: Resolver<ResolversTypes['SetIntegrationResult'], ParentType, ContextType, RequireFields<MutationSetIntegrationArgs, 'input'>>;
|
||||
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
|
||||
setLabelsForHighlight?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsForHighlightArgs, 'input'>>;
|
||||
setLinkArchived?: Resolver<ResolversTypes['ArchiveLinkResult'], ParentType, ContextType, RequireFields<MutationSetLinkArchivedArgs, 'input'>>;
|
||||
setRule?: Resolver<ResolversTypes['SetRuleResult'], ParentType, ContextType, RequireFields<MutationSetRuleArgs, 'input'>>;
|
||||
setShareArticle?: Resolver<ResolversTypes['SetShareArticleResult'], ParentType, ContextType, RequireFields<MutationSetShareArticleArgs, 'input'>>;
|
||||
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
|
||||
setUserPersonalization?: Resolver<ResolversTypes['SetUserPersonalizationResult'], ParentType, ContextType, RequireFields<MutationSetUserPersonalizationArgs, 'input'>>;
|
||||
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
|
||||
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'input'>>;
|
||||
|
|
@ -5290,12 +5157,8 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
|||
updateEmail?: Resolver<ResolversTypes['UpdateEmailResult'], ParentType, ContextType, RequireFields<MutationUpdateEmailArgs, 'input'>>;
|
||||
updateFilter?: Resolver<ResolversTypes['UpdateFilterResult'], ParentType, ContextType, RequireFields<MutationUpdateFilterArgs, 'input'>>;
|
||||
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
|
||||
updateHighlightReply?: Resolver<ResolversTypes['UpdateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightReplyArgs, 'input'>>;
|
||||
updateLabel?: Resolver<ResolversTypes['UpdateLabelResult'], ParentType, ContextType, RequireFields<MutationUpdateLabelArgs, 'input'>>;
|
||||
updateLinkShareInfo?: Resolver<ResolversTypes['UpdateLinkShareInfoResult'], ParentType, ContextType, RequireFields<MutationUpdateLinkShareInfoArgs, 'input'>>;
|
||||
updatePage?: Resolver<ResolversTypes['UpdatePageResult'], ParentType, ContextType, RequireFields<MutationUpdatePageArgs, 'input'>>;
|
||||
updateReminder?: Resolver<ResolversTypes['UpdateReminderResult'], ParentType, ContextType, RequireFields<MutationUpdateReminderArgs, 'input'>>;
|
||||
updateSharedComment?: Resolver<ResolversTypes['UpdateSharedCommentResult'], ParentType, ContextType, RequireFields<MutationUpdateSharedCommentArgs, 'input'>>;
|
||||
updateSubscription?: Resolver<ResolversTypes['UpdateSubscriptionResult'], ParentType, ContextType, RequireFields<MutationUpdateSubscriptionArgs, 'input'>>;
|
||||
updateUser?: Resolver<ResolversTypes['UpdateUserResult'], ParentType, ContextType, RequireFields<MutationUpdateUserArgs, 'input'>>;
|
||||
updateUserProfile?: Resolver<ResolversTypes['UpdateUserProfileResult'], ParentType, ContextType, RequireFields<MutationUpdateUserProfileArgs, 'input'>>;
|
||||
|
|
@ -5353,7 +5216,7 @@ export type PageResolvers<ContextType = ResolverContext, ParentType extends Reso
|
|||
readableHtml?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['PageType'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
@ -5380,12 +5243,8 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
|
|||
apiKeys?: Resolver<ResolversTypes['ApiKeysResult'], ParentType, ContextType>;
|
||||
article?: Resolver<ResolversTypes['ArticleResult'], ParentType, ContextType, RequireFields<QueryArticleArgs, 'slug' | 'username'>>;
|
||||
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequestResult'], ParentType, ContextType, Partial<QueryArticleSavingRequestArgs>>;
|
||||
articles?: Resolver<ResolversTypes['ArticlesResult'], ParentType, ContextType, Partial<QueryArticlesArgs>>;
|
||||
deviceTokens?: Resolver<ResolversTypes['DeviceTokensResult'], ParentType, ContextType>;
|
||||
feedArticles?: Resolver<ResolversTypes['FeedArticlesResult'], ParentType, ContextType, Partial<QueryFeedArticlesArgs>>;
|
||||
filters?: Resolver<ResolversTypes['FiltersResult'], ParentType, ContextType>;
|
||||
getFollowers?: Resolver<ResolversTypes['GetFollowersResult'], ParentType, ContextType, Partial<QueryGetFollowersArgs>>;
|
||||
getFollowing?: Resolver<ResolversTypes['GetFollowingResult'], ParentType, ContextType, Partial<QueryGetFollowingArgs>>;
|
||||
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
|
||||
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
|
||||
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
|
|
@ -5395,11 +5254,9 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
|
|||
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
|
||||
recentEmails?: Resolver<ResolversTypes['RecentEmailsResult'], ParentType, ContextType>;
|
||||
recentSearches?: Resolver<ResolversTypes['RecentSearchesResult'], ParentType, ContextType>;
|
||||
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
|
||||
rules?: Resolver<ResolversTypes['RulesResult'], ParentType, ContextType, Partial<QueryRulesArgs>>;
|
||||
search?: Resolver<ResolversTypes['SearchResult'], ParentType, ContextType, Partial<QuerySearchArgs>>;
|
||||
sendInstallInstructions?: Resolver<ResolversTypes['SendInstallInstructionsResult'], ParentType, ContextType>;
|
||||
sharedArticle?: Resolver<ResolversTypes['SharedArticleResult'], ParentType, ContextType, RequireFields<QuerySharedArticleArgs, 'slug' | 'username'>>;
|
||||
subscriptions?: Resolver<ResolversTypes['SubscriptionsResult'], ParentType, ContextType, Partial<QuerySubscriptionsArgs>>;
|
||||
typeaheadSearch?: Resolver<ResolversTypes['TypeaheadSearchResult'], ParentType, ContextType, RequireFields<QueryTypeaheadSearchArgs, 'query'>>;
|
||||
updatesSince?: Resolver<ResolversTypes['UpdatesSinceResult'], ParentType, ContextType, RequireFields<QueryUpdatesSinceArgs, 'since'>>;
|
||||
|
|
@ -5522,7 +5379,7 @@ export type RecommendationGroupResolvers<ContextType = ResolverContext, ParentTy
|
|||
members?: Resolver<Array<ResolversTypes['User']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
topics?: Resolver<Maybe<Array<ResolversTypes['String']>>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
|
@ -5583,7 +5440,7 @@ export type RuleResolvers<ContextType = ResolverContext, ParentType extends Reso
|
|||
filter?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
|
@ -5767,7 +5624,7 @@ export type SetFavoriteArticleResultResolvers<ContextType = ResolverContext, Par
|
|||
};
|
||||
|
||||
export type SetFavoriteArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetFavoriteArticleSuccess'] = ResolversParentTypes['SetFavoriteArticleSuccess']> = {
|
||||
favoriteArticle?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
|
||||
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
|
@ -5933,7 +5790,7 @@ export type SubscriptionResolvers<ContextType = ResolverContext, ParentType exte
|
|||
type?: SubscriptionResolver<ResolversTypes['SubscriptionType'], "type", ParentType, ContextType>;
|
||||
unsubscribeHttpUrl?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeHttpUrl", ParentType, ContextType>;
|
||||
unsubscribeMailTo?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeMailTo", ParentType, ContextType>;
|
||||
updatedAt?: SubscriptionResolver<ResolversTypes['Date'], "updatedAt", ParentType, ContextType>;
|
||||
updatedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "updatedAt", ParentType, ContextType>;
|
||||
url?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "url", ParentType, ContextType>;
|
||||
};
|
||||
|
||||
|
|
@ -6281,7 +6138,7 @@ export type WebhookResolvers<ContextType = ResolverContext, ParentType extends R
|
|||
eventTypes?: Resolver<Array<ResolversTypes['WebhookEvent']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
method?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ type Article {
|
|||
title: String!
|
||||
unsubHttpUrl: String
|
||||
unsubMailTo: String
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
uploadFileId: ID
|
||||
url: String!
|
||||
wordsCount: Int
|
||||
|
|
@ -134,7 +134,7 @@ type ArticleSavingRequest {
|
|||
id: ID!
|
||||
slug: String!
|
||||
status: ArticleSavingRequestStatus!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
url: String!
|
||||
user: User!
|
||||
userId: ID! @deprecated(reason: "userId has been replaced with user")
|
||||
|
|
@ -638,7 +638,7 @@ type Feature {
|
|||
id: ID!
|
||||
name: String!
|
||||
token: String!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
type FeedArticle {
|
||||
|
|
@ -683,7 +683,7 @@ type Filter {
|
|||
id: ID!
|
||||
name: String!
|
||||
position: Int!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
visible: Boolean
|
||||
}
|
||||
|
||||
|
|
@ -825,7 +825,7 @@ type Highlight {
|
|||
shortId: String!
|
||||
suffix: String
|
||||
type: HighlightType!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
user: User!
|
||||
}
|
||||
|
||||
|
|
@ -834,7 +834,7 @@ type HighlightReply {
|
|||
highlight: Highlight!
|
||||
id: ID!
|
||||
text: String!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
user: User!
|
||||
}
|
||||
|
||||
|
|
@ -871,7 +871,7 @@ type Integration {
|
|||
taskName: String
|
||||
token: String!
|
||||
type: IntegrationType!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
enum IntegrationType {
|
||||
|
|
@ -964,7 +964,7 @@ type Link {
|
|||
shareInfo: LinkShareInfo!
|
||||
shareStats: ShareStats!
|
||||
slug: String!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
url: String!
|
||||
}
|
||||
|
||||
|
|
@ -1107,20 +1107,14 @@ type Mutation {
|
|||
createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult!
|
||||
createGroup(input: CreateGroupInput!): CreateGroupResult!
|
||||
createHighlight(input: CreateHighlightInput!): CreateHighlightResult!
|
||||
createHighlightReply(input: CreateHighlightReplyInput!): CreateHighlightReplyResult!
|
||||
createLabel(input: CreateLabelInput!): CreateLabelResult!
|
||||
createNewsletterEmail: CreateNewsletterEmailResult!
|
||||
createReaction(input: CreateReactionInput!): CreateReactionResult!
|
||||
createReminder(input: CreateReminderInput!): CreateReminderResult!
|
||||
deleteAccount(userID: ID!): DeleteAccountResult!
|
||||
deleteFilter(id: ID!): DeleteFilterResult!
|
||||
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
|
||||
deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult!
|
||||
deleteIntegration(id: ID!): DeleteIntegrationResult!
|
||||
deleteLabel(id: ID!): DeleteLabelResult!
|
||||
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
|
||||
deleteReaction(id: ID!): DeleteReactionResult!
|
||||
deleteReminder(id: ID!): DeleteReminderResult!
|
||||
deleteRule(id: ID!): DeleteRuleResult!
|
||||
deleteWebhook(id: ID!): DeleteWebhookResult!
|
||||
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
|
||||
|
|
@ -1147,14 +1141,11 @@ type Mutation {
|
|||
setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult!
|
||||
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
|
||||
setFavoriteArticle(id: ID!): SetFavoriteArticleResult!
|
||||
setFollow(input: SetFollowInput!): SetFollowResult!
|
||||
setIntegration(input: SetIntegrationInput!): SetIntegrationResult!
|
||||
setLabels(input: SetLabelsInput!): SetLabelsResult!
|
||||
setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult!
|
||||
setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult!
|
||||
setRule(input: SetRuleInput!): SetRuleResult!
|
||||
setShareArticle(input: SetShareArticleInput!): SetShareArticleResult!
|
||||
setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult!
|
||||
setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult!
|
||||
setWebhook(input: SetWebhookInput!): SetWebhookResult!
|
||||
subscribe(input: SubscribeInput!): SubscribeResult!
|
||||
|
|
@ -1162,12 +1153,8 @@ type Mutation {
|
|||
updateEmail(input: UpdateEmailInput!): UpdateEmailResult!
|
||||
updateFilter(input: UpdateFilterInput!): UpdateFilterResult!
|
||||
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
|
||||
updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult!
|
||||
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
|
||||
updateLinkShareInfo(input: UpdateLinkShareInfoInput!): UpdateLinkShareInfoResult!
|
||||
updatePage(input: UpdatePageInput!): UpdatePageResult!
|
||||
updateReminder(input: UpdateReminderInput!): UpdateReminderResult!
|
||||
updateSharedComment(input: UpdateSharedCommentInput!): UpdateSharedCommentResult!
|
||||
updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult!
|
||||
updateUser(input: UpdateUserInput!): UpdateUserResult!
|
||||
updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult!
|
||||
|
|
@ -1230,7 +1217,7 @@ type Page {
|
|||
readableHtml: String!
|
||||
title: String!
|
||||
type: PageType!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
url: String!
|
||||
}
|
||||
|
||||
|
|
@ -1297,12 +1284,8 @@ type Query {
|
|||
apiKeys: ApiKeysResult!
|
||||
article(format: String, slug: String!, username: String!): ArticleResult!
|
||||
articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult!
|
||||
articles(after: String, first: Int, includePending: Boolean, query: String, sharedOnly: Boolean, sort: SortParams): ArticlesResult!
|
||||
deviceTokens: DeviceTokensResult!
|
||||
feedArticles(after: String, first: Int, sharedByUser: ID, sort: SortParams): FeedArticlesResult!
|
||||
filters: FiltersResult!
|
||||
getFollowers(userId: ID): GetFollowersResult!
|
||||
getFollowing(userId: ID): GetFollowingResult!
|
||||
getUserPersonalization: GetUserPersonalizationResult!
|
||||
groups: GroupsResult!
|
||||
hello: String
|
||||
|
|
@ -1312,11 +1295,9 @@ type Query {
|
|||
newsletterEmails: NewsletterEmailsResult!
|
||||
recentEmails: RecentEmailsResult!
|
||||
recentSearches: RecentSearchesResult!
|
||||
reminder(linkId: ID!): ReminderResult!
|
||||
rules(enabled: Boolean): RulesResult!
|
||||
search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult!
|
||||
sendInstallInstructions: SendInstallInstructionsResult!
|
||||
sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult!
|
||||
subscriptions(sort: SortParams, type: SubscriptionType): SubscriptionsResult!
|
||||
typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult!
|
||||
updatesSince(after: String, first: Int, since: Date!, sort: SortParams): UpdatesSinceResult!
|
||||
|
|
@ -1463,7 +1444,7 @@ type RecommendationGroup {
|
|||
members: [User!]!
|
||||
name: String!
|
||||
topics: [String!]
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
type RecommendingUser {
|
||||
|
|
@ -1539,7 +1520,7 @@ type Rule {
|
|||
filter: String!
|
||||
id: ID!
|
||||
name: String!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
type RuleAction {
|
||||
|
|
@ -1591,7 +1572,7 @@ enum SaveArticleReadingProgressErrorCode {
|
|||
|
||||
input SaveArticleReadingProgressInput {
|
||||
id: ID!
|
||||
readingProgressAnchorIndex: Int!
|
||||
readingProgressAnchorIndex: Int
|
||||
readingProgressPercent: Float!
|
||||
readingProgressTopPercent: Float
|
||||
}
|
||||
|
|
@ -1814,7 +1795,7 @@ enum SetFavoriteArticleErrorCode {
|
|||
union SetFavoriteArticleResult = SetFavoriteArticleError | SetFavoriteArticleSuccess
|
||||
|
||||
type SetFavoriteArticleSuccess {
|
||||
favoriteArticle: Article!
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
type SetFollowError {
|
||||
|
|
@ -2101,7 +2082,7 @@ type Subscription {
|
|||
type: SubscriptionType!
|
||||
unsubscribeHttpUrl: String
|
||||
unsubscribeMailTo: String
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
url: String
|
||||
}
|
||||
|
||||
|
|
@ -2604,7 +2585,7 @@ type Webhook {
|
|||
eventTypes: [WebhookEvent!]!
|
||||
id: ID!
|
||||
method: String!
|
||||
updatedAt: Date!
|
||||
updatedAt: Date
|
||||
url: String!
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { PubSub } from '@google-cloud/pubsub'
|
||||
import { env } from '../env'
|
||||
import { ReportType } from '../generated/graphql'
|
||||
import express from 'express'
|
||||
import { buildLogger } from '../utils/logger'
|
||||
import { env } from './env'
|
||||
import { ReportType } from './generated/graphql'
|
||||
import { buildLogger } from './utils/logger'
|
||||
|
||||
const logger = buildLogger('pubsub')
|
||||
|
||||
53
packages/api/src/repository/highlight.ts
Normal file
53
packages/api/src/repository/highlight.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { DeepPartial } from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
import { entityManager } from '.'
|
||||
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 = entityManager
|
||||
.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)
|
||||
},
|
||||
})
|
||||
46
packages/api/src/repository/index.ts
Normal file
46
packages/api/src/repository/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as httpContext from 'express-http-context2'
|
||||
import { EntityManager, EntityTarget, Repository } from 'typeorm'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { Claims } from '../resolvers/types'
|
||||
|
||||
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',
|
||||
dbRole = 'omnivore_user'
|
||||
): Promise<unknown> => {
|
||||
return manager.query('SELECT * from omnivore.set_claims($1, $2)', [
|
||||
uid,
|
||||
dbRole,
|
||||
])
|
||||
}
|
||||
|
||||
export const authTrx = async <T>(
|
||||
fn: (manager: EntityManager) => Promise<T>,
|
||||
em = entityManager,
|
||||
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 entityManager.getRepository(entity)
|
||||
}
|
||||
|
||||
export const entityManager = appDataSource.manager
|
||||
84
packages/api/src/repository/label.ts
Normal file
84
packages/api/src/repository/label.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { In } from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
import { entityManager } from '.'
|
||||
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 || generateRandomColor(), // assign a random color if not provided
|
||||
description: label.description,
|
||||
internal: isLabelInternal(label.name),
|
||||
}
|
||||
}
|
||||
|
||||
export const labelRepository = entityManager.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)
|
||||
},
|
||||
})
|
||||
20
packages/api/src/repository/library_item.ts
Normal file
20
packages/api/src/repository/library_item.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { entityManager } from '.'
|
||||
import { LibraryItem } from '../entity/library_item'
|
||||
|
||||
export const libraryItemRepository = entityManager
|
||||
.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 })
|
||||
},
|
||||
})
|
||||
37
packages/api/src/repository/user.ts
Normal file
37
packages/api/src/repository/user.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { In } from 'typeorm'
|
||||
import { entityManager } from '.'
|
||||
import { User } from './../entity/user'
|
||||
|
||||
const TOP_USERS = [
|
||||
'jacksonh',
|
||||
'nat',
|
||||
'luis',
|
||||
'satindar',
|
||||
'malandrina',
|
||||
'patrick',
|
||||
'alexgutjahr',
|
||||
'hongbowu',
|
||||
]
|
||||
export const MAX_RECORDS_LIMIT = 1000
|
||||
|
||||
export const userRepository = entityManager.getRepository(User).extend({
|
||||
findById(id: string) {
|
||||
return this.findOneBy({ id })
|
||||
},
|
||||
|
||||
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()
|
||||
},
|
||||
})
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { ApiKey } from '../../entity/api_key'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ApiKeysError,
|
||||
|
|
@ -15,36 +13,22 @@ import {
|
|||
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 (_, __, { claims: { uid }, log }) => {
|
||||
log.info('apiKeysResolver')
|
||||
|
||||
async (_, __, { log, uid }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [ApiKeysErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeys = await getRepository(ApiKey).find({
|
||||
select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'],
|
||||
where: { user: { id: uid } },
|
||||
order: {
|
||||
usedAt: { direction: 'DESC', nulls: 'last' },
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
})
|
||||
const apiKeys = await findApiKeys(uid)
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
log.error('apiKeysResolver error', e)
|
||||
|
||||
return {
|
||||
errorCodes: [ApiKeysErrorCode.BadRequest],
|
||||
|
|
@ -57,32 +41,14 @@ export const generateApiKeyResolver = authorized<
|
|||
GenerateApiKeySuccess,
|
||||
GenerateApiKeyError,
|
||||
MutationGenerateApiKeyArgs
|
||||
>(async (_, { input: { name, expiresAt } }, { claims: { uid }, log }) => {
|
||||
>(async (_, { input: { name, expiresAt } }, { log, uid }) => {
|
||||
try {
|
||||
log.info('generateApiKeyResolver')
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [GenerateApiKeyErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const existingApiKey = await getRepository(ApiKey).findOneBy({
|
||||
user: { id: uid },
|
||||
name,
|
||||
})
|
||||
if (existingApiKey) {
|
||||
return {
|
||||
errorCodes: [GenerateApiKeyErrorCode.AlreadyExists],
|
||||
}
|
||||
}
|
||||
|
||||
const exp = new Date(expiresAt)
|
||||
const apiKey = generateApiKey()
|
||||
const apiKeyData = await getRepository(ApiKey).save({
|
||||
const originalKey = generateApiKey()
|
||||
const apiKeyCreated = await getRepository(ApiKey).save({
|
||||
user: { id: uid },
|
||||
name,
|
||||
key: hashApiKey(apiKey),
|
||||
key: hashApiKey(originalKey),
|
||||
expiresAt: exp,
|
||||
})
|
||||
|
||||
|
|
@ -98,12 +64,12 @@ export const generateApiKeyResolver = authorized<
|
|||
|
||||
return {
|
||||
apiKey: {
|
||||
...apiKeyData,
|
||||
key: apiKey,
|
||||
...apiKeyCreated,
|
||||
key: originalKey,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
log.error('generateApiKeyResolver', error)
|
||||
|
||||
return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] }
|
||||
}
|
||||
|
|
@ -114,33 +80,26 @@ export const revokeApiKeyResolver = authorized<
|
|||
RevokeApiKeyError,
|
||||
MutationRevokeApiKeyArgs
|
||||
>(async (_, { id }, { claims: { uid }, log }) => {
|
||||
log.info('RevokeApiKeyResolver')
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
const apiRepo = getRepository(ApiKey)
|
||||
|
||||
const apiKey = await getRepository(ApiKey).findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
})
|
||||
const apiKey = await apiRepo.findOneBy({ id, user: { id: uid } })
|
||||
if (!apiKey) {
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKey.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
const deletedApiKey = await apiRepo.remove(apiKey)
|
||||
|
||||
const deletedApiKey = await getRepository(ApiKey).remove(apiKey)
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'api_key_revoked',
|
||||
properties: {
|
||||
id,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
|
|
@ -150,7 +109,7 @@ export const revokeApiKeyResolver = authorized<
|
|||
},
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
log.error('revokeApiKeyResolver error', e)
|
||||
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.BadRequest],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,9 @@
|
|||
/* eslint-disable prefer-const */
|
||||
import { getPageByParam } from '../../elastic/pages'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ArticleSavingRequestError,
|
||||
ArticleSavingRequestErrorCode,
|
||||
ArticleSavingRequestStatus,
|
||||
ArticleSavingRequestSuccess,
|
||||
CreateArticleSavingRequestError,
|
||||
CreateArticleSavingRequestErrorCode,
|
||||
|
|
@ -14,13 +11,18 @@ import {
|
|||
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,
|
||||
pageToArticleSavingRequest,
|
||||
libraryItemToArticleSavingRequest,
|
||||
} from '../../utils/helpers'
|
||||
import { isErrorWithCode } from '../user'
|
||||
|
||||
|
|
@ -28,9 +30,9 @@ export const createArticleSavingRequestResolver = authorized<
|
|||
CreateArticleSavingRequestSuccess,
|
||||
CreateArticleSavingRequestError,
|
||||
MutationCreateArticleSavingRequestArgs
|
||||
>(async (_, { input: { url } }, { claims, pubsub, log }) => {
|
||||
>(async (_, { input: { url } }, { uid, pubsub, log }) => {
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: url,
|
||||
|
|
@ -40,16 +42,16 @@ export const createArticleSavingRequestResolver = authorized<
|
|||
})
|
||||
|
||||
try {
|
||||
const request = await createPageSaveRequest({
|
||||
userId: claims.uid,
|
||||
const articleSavingRequest = await createPageSaveRequest({
|
||||
userId: uid,
|
||||
url,
|
||||
pubsub,
|
||||
})
|
||||
return {
|
||||
articleSavingRequest: request,
|
||||
articleSavingRequest,
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('error saving article', err)
|
||||
log.error('createArticleSavingRequestResolver error', err)
|
||||
if (isErrorWithCode(err)) {
|
||||
return {
|
||||
errorCodes: [err.errorCode as CreateArticleSavingRequestErrorCode],
|
||||
|
|
@ -63,35 +65,37 @@ export const articleSavingRequestResolver = authorized<
|
|||
ArticleSavingRequestSuccess,
|
||||
ArticleSavingRequestError,
|
||||
QueryArticleSavingRequestArgs
|
||||
>(async (_, { id, url }, { claims }) => {
|
||||
if (!id && !url) {
|
||||
return { errorCodes: [ArticleSavingRequestErrorCode.BadData] }
|
||||
}
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { id: claims.uid },
|
||||
relations: ['profile'],
|
||||
})
|
||||
if (!user) {
|
||||
return { errorCodes: [ArticleSavingRequestErrorCode.Unauthorized] }
|
||||
}
|
||||
>(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] }
|
||||
}
|
||||
|
||||
const normalizedUrl = url ? cleanUrl(url) : undefined
|
||||
let libraryItem: LibraryItem | null = null
|
||||
if (id) {
|
||||
libraryItem = await findLibraryItemById(id, uid)
|
||||
} else if (url) {
|
||||
libraryItem = await findLibraryItemByUrl(cleanUrl(url), uid)
|
||||
}
|
||||
|
||||
const params = {
|
||||
_id: id || undefined,
|
||||
url: normalizedUrl,
|
||||
userId: claims.uid,
|
||||
state: [
|
||||
ArticleSavingRequestStatus.Succeeded,
|
||||
ArticleSavingRequestStatus.Processing,
|
||||
],
|
||||
}
|
||||
const page = await getPageByParam(params)
|
||||
if (!page) {
|
||||
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] }
|
||||
}
|
||||
if (isParsingTimeout(page)) {
|
||||
page.state = ArticleSavingRequestStatus.Succeeded
|
||||
}
|
||||
return { articleSavingRequest: pageToArticleSavingRequest(user, page) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { authorized } from '../../utils/helpers'
|
||||
import { isNil, mergeWith } from 'lodash'
|
||||
import { Between } from 'typeorm'
|
||||
import { Filter } from '../../entity/filter'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteFilterError,
|
||||
DeleteFilterErrorCode,
|
||||
|
|
@ -17,63 +20,37 @@ import {
|
|||
SaveFilterErrorCode,
|
||||
SaveFilterSuccess,
|
||||
UpdateFilterError,
|
||||
UpdateFilterSuccess,
|
||||
UpdateFilterErrorCode,
|
||||
UpdateFilterSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { Filter } from '../../entity/filter'
|
||||
import { getRepository, setClaims } from '../../entity/utils'
|
||||
import { User } from '../../entity/user'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { Between } from 'typeorm'
|
||||
import { authTrx } from '../../repository'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { env } from '../../env'
|
||||
import { isNil, mergeWith } from 'lodash'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const saveFilterResolver = authorized<
|
||||
SaveFilterSuccess,
|
||||
SaveFilterError,
|
||||
MutationSaveFilterArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('Saving filters', {
|
||||
input,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'saveFilterResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SaveFilterErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const filter = await getRepository(Filter).save({
|
||||
user: { id: uid },
|
||||
name: input.name,
|
||||
category: 'Search',
|
||||
description: '',
|
||||
position: input.position ?? 0,
|
||||
filter: input.filter,
|
||||
defaultFilter: false,
|
||||
visible: true,
|
||||
const filter = await authTrx(async (t) => {
|
||||
return t.getRepository(Filter).save({
|
||||
user: { id: uid },
|
||||
name: input.name,
|
||||
category: 'Search',
|
||||
description: '',
|
||||
position: input.position ?? 0,
|
||||
filter: input.filter,
|
||||
defaultFilter: false,
|
||||
visible: true,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
filter,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error saving filters', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'saveFilterResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error saving filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [SaveFilterErrorCode.BadRequest],
|
||||
|
|
@ -85,48 +62,23 @@ export const deleteFilterResolver = authorized<
|
|||
DeleteFilterSuccess,
|
||||
DeleteFilterError,
|
||||
MutationDeleteFilterArgs
|
||||
>(async (_, { id }, { claims, log }) => {
|
||||
log.info('Deleting filters', {
|
||||
id,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteFilterResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { id }, { authTrx, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: claims.uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [DeleteFilterErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
const filter = await authTrx(async (t) => {
|
||||
const repo = t.getRepository(Filter)
|
||||
const filter = await repo.findOneByOrFail({
|
||||
id,
|
||||
})
|
||||
|
||||
const filter = await getRepository(Filter).findOneBy({
|
||||
id,
|
||||
user: { id: claims.uid },
|
||||
await repo.delete(filter.id)
|
||||
return filter
|
||||
})
|
||||
if (!filter) {
|
||||
return {
|
||||
errorCodes: [DeleteFilterErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
await getRepository(Filter).delete({ id })
|
||||
|
||||
return {
|
||||
filter,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error deleting filters', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteFilterResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
log.error('Error deleting filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteFilterErrorCode.BadRequest],
|
||||
|
|
@ -135,40 +87,20 @@ export const deleteFilterResolver = authorized<
|
|||
})
|
||||
|
||||
export const filtersResolver = authorized<FiltersSuccess, FiltersError>(
|
||||
async (_, __, { claims, log }) => {
|
||||
log.info('Getting filters', {
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'filtersResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
async (_, __, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: claims.uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [FiltersErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const filters = await getRepository(Filter).find({
|
||||
where: { user: { id: claims.uid } },
|
||||
order: { position: 'ASC' },
|
||||
})
|
||||
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,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'filtersResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
log.error('Error getting filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [FiltersErrorCode.BadRequest],
|
||||
|
|
@ -185,11 +117,10 @@ const updatePosition = async (
|
|||
const moveUp = newPosition < position
|
||||
|
||||
// move filter to the new position
|
||||
const updated = await AppDataSource.transaction(async (t) => {
|
||||
await setClaims(t, uid)
|
||||
|
||||
const updated = await authTrx(async (t) => {
|
||||
const repo = t.getRepository(Filter)
|
||||
// update the position of the other filters
|
||||
const updated = await t.getRepository(Filter).update(
|
||||
const updated = await repo.update(
|
||||
{
|
||||
user: { id: uid },
|
||||
position: Between(
|
||||
|
|
@ -207,7 +138,7 @@ const updatePosition = async (
|
|||
}
|
||||
|
||||
// update the position of the filter
|
||||
return t.getRepository(Filter).save({
|
||||
return repo.save({
|
||||
...filter,
|
||||
position: newPosition,
|
||||
})
|
||||
|
|
@ -224,58 +155,38 @@ export const updateFilterResolver = authorized<
|
|||
UpdateFilterSuccess,
|
||||
UpdateFilterError,
|
||||
MutationUpdateFilterArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
const repo = getRepository(Filter)
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
const { id } = input
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UpdateFilterErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const filter = await getRepository(Filter).findOne({
|
||||
where: { id, user: { id: uid } },
|
||||
relations: ['user'],
|
||||
})
|
||||
const filter = await authTrx((t) =>
|
||||
t.getRepository(Filter).findOneBy({ id })
|
||||
)
|
||||
if (!filter) {
|
||||
return {
|
||||
__typename: 'UpdateFilterError',
|
||||
errorCodes: [UpdateFilterErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (filter.user.id !== uid) {
|
||||
return {
|
||||
__typename: 'UpdateFilterError',
|
||||
errorCodes: [UpdateFilterErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNil(input.position) && filter.position != input.position) {
|
||||
await updatePosition(uid, filter, input.position)
|
||||
}
|
||||
|
||||
const updated = await repo.save({
|
||||
...mergeWith({}, filter, input, (a: unknown, b: unknown) =>
|
||||
isNil(b) ? a : undefined
|
||||
),
|
||||
})
|
||||
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,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'UpdateFilterResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error Updating filters', error)
|
||||
|
||||
return {
|
||||
__typename: 'UpdateFilterError',
|
||||
|
|
@ -288,40 +199,20 @@ export const moveFilterResolver = authorized<
|
|||
MoveFilterSuccess,
|
||||
MoveFilterError,
|
||||
MutationMoveFilterArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('Moving filters', {
|
||||
input,
|
||||
filters: {
|
||||
source: 'resolver',
|
||||
resolver: 'moveFilterResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
const { filterId, afterFilterId } = input
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const filter = await getRepository(Filter).findOne({
|
||||
where: { id: filterId },
|
||||
relations: ['user'],
|
||||
})
|
||||
const filter = await authTrx((t) =>
|
||||
t.getRepository(Filter).findOneBy({
|
||||
id: filterId,
|
||||
})
|
||||
)
|
||||
if (!filter) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (filter.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.id === afterFilterId) {
|
||||
// nothing to do
|
||||
|
|
@ -331,20 +222,17 @@ export const moveFilterResolver = authorized<
|
|||
// if afterFilterId is not provided, move to the top
|
||||
let newPosition = 0
|
||||
if (afterFilterId) {
|
||||
const afterFilter = await getRepository(Filter).findOne({
|
||||
where: { id: afterFilterId },
|
||||
relations: ['user'],
|
||||
})
|
||||
const afterFilter = await authTrx((t) =>
|
||||
t.getRepository(Filter).findOneBy({
|
||||
id: afterFilterId,
|
||||
})
|
||||
)
|
||||
if (!afterFilter) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (afterFilter.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
newPosition = afterFilter.position
|
||||
}
|
||||
const updated = await updatePosition(uid, filter, newPosition)
|
||||
|
|
@ -369,14 +257,7 @@ export const moveFilterResolver = authorized<
|
|||
filter: updated,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error moving filters', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'moveFilterResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error moving filters', error)
|
||||
|
||||
return {
|
||||
errorCodes: [MoveFilterErrorCode.BadRequest],
|
||||
|
|
|
|||
|
|
@ -3,25 +3,27 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { getShareInfoForArticle } from '../datalayer/links/share_info'
|
||||
import { getPageByParam } from '../elastic/pages'
|
||||
import { Subscription } from '../entity/subscription'
|
||||
import {
|
||||
Article,
|
||||
ArticleHighlightsInput,
|
||||
Highlight,
|
||||
HighlightType,
|
||||
LinkShareInfo,
|
||||
Label,
|
||||
PageType,
|
||||
Reaction,
|
||||
Recommendation,
|
||||
SearchItem,
|
||||
User,
|
||||
} from '../generated/graphql'
|
||||
import { userDataToUser, validatedDate, wordsCount } from '../utils/helpers'
|
||||
import { createImageProxyUrl } from '../utils/imageproxy'
|
||||
import { logger } from '../utils/logger'
|
||||
import { findHighlightsByLibraryItemId } from '../services/highlights'
|
||||
import { findLabelsByLibraryItemId } from '../services/labels'
|
||||
import { findRecommendationsByLibraryItemId } from '../services/recommendation'
|
||||
import { findUploadFileById } from '../services/upload_file'
|
||||
import {
|
||||
highlightDataToHighlight,
|
||||
recommandationDataToRecommendation,
|
||||
validatedDate,
|
||||
wordsCount,
|
||||
} from '../utils/helpers'
|
||||
import { createImageProxyUrl } from '../utils/imageproxy'
|
||||
import {
|
||||
contentReaderForPage,
|
||||
generateDownloadSignedUrl,
|
||||
generateUploadFilePathName,
|
||||
} from '../utils/uploads'
|
||||
|
|
@ -38,14 +40,14 @@ import {
|
|||
createHighlightResolver,
|
||||
createLabelResolver,
|
||||
createNewsletterEmailResolver,
|
||||
createReminderResolver,
|
||||
// createReminderResolver,
|
||||
deleteAccountResolver,
|
||||
deleteFilterResolver,
|
||||
deleteHighlightResolver,
|
||||
deleteIntegrationResolver,
|
||||
deleteLabelResolver,
|
||||
deleteNewsletterEmailResolver,
|
||||
deleteReminderResolver,
|
||||
// deleteReminderResolver,
|
||||
deleteRuleResolver,
|
||||
deleteWebhookResolver,
|
||||
deviceTokensResolver,
|
||||
|
|
@ -53,12 +55,11 @@ import {
|
|||
generateApiKeyResolver,
|
||||
getAllUsersResolver,
|
||||
getArticleResolver,
|
||||
getArticlesResolver,
|
||||
getFollowersResolver,
|
||||
getFollowingResolver,
|
||||
// getFollowersResolver,
|
||||
// getFollowingResolver,
|
||||
getMeUserResolver,
|
||||
getSharedArticleResolver,
|
||||
getUserFeedArticlesResolver,
|
||||
// getSharedArticleResolver,
|
||||
// getUserFeedArticlesResolver,
|
||||
getUserPersonalizationResolver,
|
||||
getUserResolver,
|
||||
googleLoginResolver,
|
||||
|
|
@ -76,7 +77,7 @@ import {
|
|||
newsletterEmailsResolver,
|
||||
recommendHighlightsResolver,
|
||||
recommendResolver,
|
||||
reminderResolver,
|
||||
// reminderResolver,
|
||||
reportItemResolver,
|
||||
revokeApiKeyResolver,
|
||||
rulesResolver,
|
||||
|
|
@ -90,14 +91,14 @@ import {
|
|||
setBookmarkArticleResolver,
|
||||
setDeviceTokenResolver,
|
||||
setFavoriteArticleResolver,
|
||||
setFollowResolver,
|
||||
// setFollowResolver,
|
||||
setIntegrationResolver,
|
||||
setLabelsForHighlightResolver,
|
||||
setLabelsResolver,
|
||||
setLinkArchivedResolver,
|
||||
setRuleResolver,
|
||||
setShareArticleResolver,
|
||||
setShareHighlightResolver,
|
||||
// setShareArticleResolver,
|
||||
// setShareHighlightResolver,
|
||||
setUserPersonalizationResolver,
|
||||
setWebhookResolver,
|
||||
subscribeResolver,
|
||||
|
|
@ -107,10 +108,10 @@ import {
|
|||
updateFilterResolver,
|
||||
updateHighlightResolver,
|
||||
updateLabelResolver,
|
||||
updateLinkShareInfoResolver,
|
||||
// updateLinkShareInfoResolver,
|
||||
updatePageResolver,
|
||||
updateReminderResolver,
|
||||
updateSharedCommentResolver,
|
||||
// updateReminderResolver,
|
||||
// updateSharedCommentResolver,
|
||||
updatesSinceResolver,
|
||||
updateSubscriptionResolver,
|
||||
updateUserProfileResolver,
|
||||
|
|
@ -120,10 +121,9 @@ import {
|
|||
webhookResolver,
|
||||
webhooksResolver,
|
||||
} from './index'
|
||||
import { createReactionResolver, deleteReactionResolver } from './reaction'
|
||||
import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails'
|
||||
import { recentSearchesResolver } from './recent_searches'
|
||||
import { Claims, WithDataSourcesContext } from './types'
|
||||
import { WithDataSourcesContext } from './types'
|
||||
import { updateEmailResolver } from './user'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
|
@ -154,30 +154,30 @@ export const functionResolvers = {
|
|||
updateUserProfile: updateUserProfileResolver,
|
||||
createArticle: createArticleResolver,
|
||||
createHighlight: createHighlightResolver,
|
||||
createReaction: createReactionResolver,
|
||||
deleteReaction: deleteReactionResolver,
|
||||
// createReaction: createReactionResolver,
|
||||
// deleteReaction: deleteReactionResolver,
|
||||
mergeHighlight: mergeHighlightResolver,
|
||||
updateHighlight: updateHighlightResolver,
|
||||
deleteHighlight: deleteHighlightResolver,
|
||||
uploadFileRequest: uploadFileRequestResolver,
|
||||
setShareArticle: setShareArticleResolver,
|
||||
updateSharedComment: updateSharedCommentResolver,
|
||||
setFollow: setFollowResolver,
|
||||
// setShareArticle: setShareArticleResolver,
|
||||
// updateSharedComment: updateSharedCommentResolver,
|
||||
// setFollow: setFollowResolver,
|
||||
setBookmarkArticle: setBookmarkArticleResolver,
|
||||
setUserPersonalization: setUserPersonalizationResolver,
|
||||
createArticleSavingRequest: createArticleSavingRequestResolver,
|
||||
setShareHighlight: setShareHighlightResolver,
|
||||
// setShareHighlight: setShareHighlightResolver,
|
||||
reportItem: reportItemResolver,
|
||||
updateLinkShareInfo: updateLinkShareInfoResolver,
|
||||
// updateLinkShareInfo: updateLinkShareInfoResolver,
|
||||
setLinkArchived: setLinkArchivedResolver,
|
||||
createNewsletterEmail: createNewsletterEmailResolver,
|
||||
deleteNewsletterEmail: deleteNewsletterEmailResolver,
|
||||
saveUrl: saveUrlResolver,
|
||||
savePage: savePageResolver,
|
||||
saveFile: saveFileResolver,
|
||||
createReminder: createReminderResolver,
|
||||
updateReminder: updateReminderResolver,
|
||||
deleteReminder: deleteReminderResolver,
|
||||
// createReminder: createReminderResolver,
|
||||
// updateReminder: updateReminderResolver,
|
||||
// deleteReminder: deleteReminderResolver,
|
||||
setDeviceToken: setDeviceTokenResolver,
|
||||
createLabel: createLabelResolver,
|
||||
updateLabel: updateLabelResolver,
|
||||
|
|
@ -221,15 +221,14 @@ export const functionResolvers = {
|
|||
users: getAllUsersResolver,
|
||||
validateUsername: validateUsernameResolver,
|
||||
article: getArticleResolver,
|
||||
sharedArticle: getSharedArticleResolver,
|
||||
articles: getArticlesResolver,
|
||||
feedArticles: getUserFeedArticlesResolver,
|
||||
getFollowers: getFollowersResolver,
|
||||
getFollowing: getFollowingResolver,
|
||||
// sharedArticle: getSharedArticleResolver,
|
||||
// feedArticles: getUserFeedArticlesResolver,
|
||||
// getFollowers: getFollowersResolver,
|
||||
// getFollowing: getFollowingResolver,
|
||||
getUserPersonalization: getUserPersonalizationResolver,
|
||||
articleSavingRequest: articleSavingRequestResolver,
|
||||
newsletterEmails: newsletterEmailsResolver,
|
||||
reminder: reminderResolver,
|
||||
// reminder: reminderResolver,
|
||||
labels: labelsResolver,
|
||||
search: searchResolver,
|
||||
subscriptions: subscriptionsResolver,
|
||||
|
|
@ -247,141 +246,141 @@ export const functionResolvers = {
|
|||
groups: groupsResolver,
|
||||
recentEmails: recentEmailsResolver,
|
||||
},
|
||||
User: {
|
||||
async sharedArticles(
|
||||
user: User,
|
||||
__: Record<string, unknown>,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
return ctx.models.userArticle.getUserSharedArticles(user.id, ctx.kx)
|
||||
},
|
||||
async sharedArticlesCount(
|
||||
user: { id: string; sharedArticlesCount?: number },
|
||||
__: Record<string, unknown>,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (user.sharedArticlesCount) return user.sharedArticlesCount
|
||||
return ctx.models.userArticle.getSharedArticlesCount(user.id, ctx.kx)
|
||||
},
|
||||
async sharedHighlightsCount(
|
||||
user: { id: string; sharedHighlightsCount?: number },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
// #TODO: restructure highlightStats and sharedArticlesCount in order to get it within a single query
|
||||
if (user.sharedHighlightsCount) return user.sharedHighlightsCount
|
||||
const { sharedHighlightsCount } =
|
||||
await ctx.models.user.getSharedHighlightsStats(user.id)
|
||||
return sharedHighlightsCount
|
||||
},
|
||||
async sharedNotesCount(
|
||||
user: User,
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (user.sharedNotesCount) return user.sharedNotesCount
|
||||
const { sharedNotesCount } =
|
||||
await ctx.models.user.getSharedHighlightsStats(user.id)
|
||||
return sharedNotesCount
|
||||
},
|
||||
},
|
||||
FeedArticle: {
|
||||
async article(
|
||||
feedArticle: { articleId: string; userId: string; article?: Article },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (feedArticle.article) return feedArticle.article
|
||||
// User: {
|
||||
// async sharedArticles(
|
||||
// user: User,
|
||||
// __: Record<string, unknown>,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// return ctx.models.userArticle.getUserSharedArticles(user.id, ctx.kx)
|
||||
// },
|
||||
// async sharedArticlesCount(
|
||||
// user: { id: string; sharedArticlesCount?: number },
|
||||
// __: Record<string, unknown>,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// if (user.sharedArticlesCount) return user.sharedArticlesCount
|
||||
// return ctx.models.userArticle.getSharedArticlesCount(user.id, ctx.kx)
|
||||
// },
|
||||
// async sharedHighlightsCount(
|
||||
// user: { id: string; sharedHighlightsCount?: number },
|
||||
// _: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// // #TODO: restructure highlightStats and sharedArticlesCount in order to get it within a single query
|
||||
// if (user.sharedHighlightsCount) return user.sharedHighlightsCount
|
||||
// const { sharedHighlightsCount } =
|
||||
// await ctx.models.user.getSharedHighlightsStats(user.id)
|
||||
// return sharedHighlightsCount
|
||||
// },
|
||||
// async sharedNotesCount(
|
||||
// user: User,
|
||||
// _: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// if (user.sharedNotesCount) return user.sharedNotesCount
|
||||
// const { sharedNotesCount } =
|
||||
// await ctx.models.user.getSharedHighlightsStats(user.id)
|
||||
// return sharedNotesCount
|
||||
// },
|
||||
// },
|
||||
// FeedArticle: {
|
||||
// async article(
|
||||
// feedArticle: { articleId: string; userId: string; article?: Article },
|
||||
// __: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// if (feedArticle.article) return feedArticle.article
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let a: any
|
||||
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// let a: any
|
||||
|
||||
const savedArticle =
|
||||
ctx.claims?.uid &&
|
||||
(await ctx.models.userArticle.getForUser(
|
||||
ctx.claims?.uid,
|
||||
feedArticle.articleId,
|
||||
ctx.kx
|
||||
))
|
||||
// const savedArticle =
|
||||
// ctx.claims?.uid &&
|
||||
// (await ctx.models.userArticle.getForUser(
|
||||
// ctx.claims?.uid,
|
||||
// feedArticle.articleId,
|
||||
// ctx.kx
|
||||
// ))
|
||||
|
||||
if (savedArticle) {
|
||||
// If user has saved the article, use his version (slug) then
|
||||
a = {
|
||||
...savedArticle,
|
||||
savedByViewer: true,
|
||||
postedByViewer: !!savedArticle.sharedAt,
|
||||
}
|
||||
} else {
|
||||
a = await ctx.models.userArticle.getForUser(
|
||||
feedArticle.userId,
|
||||
feedArticle.articleId,
|
||||
ctx.kx
|
||||
)
|
||||
}
|
||||
// if (savedArticle) {
|
||||
// // If user has saved the article, use his version (slug) then
|
||||
// a = {
|
||||
// ...savedArticle,
|
||||
// savedByViewer: true,
|
||||
// postedByViewer: !!savedArticle.sharedAt,
|
||||
// }
|
||||
// } else {
|
||||
// a = await ctx.models.userArticle.getForUser(
|
||||
// feedArticle.userId,
|
||||
// feedArticle.articleId,
|
||||
// ctx.kx
|
||||
// )
|
||||
// }
|
||||
|
||||
if (a && a.image) {
|
||||
a.image = createImageProxyUrl(a.image, 0, 180)
|
||||
} else {
|
||||
logger.info(
|
||||
'error getting article for feedItem',
|
||||
feedArticle.userId,
|
||||
feedArticle.articleId
|
||||
)
|
||||
}
|
||||
// if (a && a.image) {
|
||||
// a.image = createImageProxyUrl(a.image, 0, 180)
|
||||
// } else {
|
||||
// logger.info(
|
||||
// 'error getting article for feedItem',
|
||||
// feedArticle.userId,
|
||||
// feedArticle.articleId
|
||||
// )
|
||||
// }
|
||||
|
||||
return a
|
||||
},
|
||||
async sharedBy(
|
||||
feedArticle: { userId: string; sharedBy?: User },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (feedArticle.sharedBy) return feedArticle.sharedBy
|
||||
return userDataToUser(await ctx.models.user.get(feedArticle.userId))
|
||||
},
|
||||
async highlight(
|
||||
feedArticle: { highlightId?: string; highlight?: Highlight },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (feedArticle.highlight) return feedArticle.highlight
|
||||
return feedArticle.highlightId
|
||||
? await ctx.models.highlight.get(feedArticle.highlightId)
|
||||
: null
|
||||
},
|
||||
async reactions(
|
||||
feedArticle: { id: string; reactions?: Reaction[] },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
const { reactions, id } = feedArticle
|
||||
if (reactions) return reactions
|
||||
// return a
|
||||
// },
|
||||
// async sharedBy(
|
||||
// feedArticle: { userId: string; sharedBy?: User },
|
||||
// __: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// if (feedArticle.sharedBy) return feedArticle.sharedBy
|
||||
// return userDataToUser(await ctx.models.user.get(feedArticle.userId))
|
||||
// },
|
||||
// async highlight(
|
||||
// feedArticle: { highlightId?: string; highlight?: Highlight },
|
||||
// _: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// if (feedArticle.highlight) return feedArticle.highlight
|
||||
// return feedArticle.highlightId
|
||||
// ? await ctx.models.highlight.get(feedArticle.highlightId)
|
||||
// : null
|
||||
// },
|
||||
// async reactions(
|
||||
// feedArticle: { id: string; reactions?: Reaction[] },
|
||||
// _: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// const { reactions, id } = feedArticle
|
||||
// if (reactions) return reactions
|
||||
|
||||
return await ctx.models.reaction.batchGetFromArticle(id)
|
||||
},
|
||||
async highlightsCount(
|
||||
feedArticle: { id: string; highlightsCount?: number },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (feedArticle.highlightsCount) return feedArticle.highlightsCount
|
||||
const { highlightsCount } = await ctx.models.userArticle.getStats(
|
||||
feedArticle.id
|
||||
)
|
||||
return highlightsCount
|
||||
},
|
||||
async annotationsCount(
|
||||
feedArticle: { id: string; annotationsCount?: number },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (feedArticle.annotationsCount) return feedArticle.annotationsCount
|
||||
const { annotationsCount } = await ctx.models.userArticle.getStats(
|
||||
feedArticle.id
|
||||
)
|
||||
return annotationsCount
|
||||
},
|
||||
},
|
||||
// return await ctx.models.reaction.batchGetFromArticle(id)
|
||||
// },
|
||||
// async highlightsCount(
|
||||
// feedArticle: { id: string; highlightsCount?: number },
|
||||
// _: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// if (feedArticle.highlightsCount) return feedArticle.highlightsCount
|
||||
// const { highlightsCount } = await ctx.models.userArticle.getStats(
|
||||
// feedArticle.id
|
||||
// )
|
||||
// return highlightsCount
|
||||
// },
|
||||
// async annotationsCount(
|
||||
// feedArticle: { id: string; annotationsCount?: number },
|
||||
// _: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ) {
|
||||
// if (feedArticle.annotationsCount) return feedArticle.annotationsCount
|
||||
// const { annotationsCount } = await ctx.models.userArticle.getStats(
|
||||
// feedArticle.id
|
||||
// )
|
||||
// return annotationsCount
|
||||
// },
|
||||
// },
|
||||
Article: {
|
||||
async url(article: Article, _: unknown, ctx: WithDataSourcesContext) {
|
||||
if (
|
||||
|
|
@ -390,7 +389,7 @@ export const functionResolvers = {
|
|||
ctx.claims &&
|
||||
article.uploadFileId
|
||||
) {
|
||||
const upload = await ctx.models.uploadFile.get(article.uploadFileId)
|
||||
const upload = await findUploadFileById(article.uploadFileId)
|
||||
if (!upload || !upload.fileName) {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -399,57 +398,9 @@ export const functionResolvers = {
|
|||
}
|
||||
return article.url
|
||||
},
|
||||
async originalArticleUrl(article: { url: string }) {
|
||||
originalArticleUrl(article: { url: string }) {
|
||||
return article.url
|
||||
},
|
||||
async savedByViewer(
|
||||
article: { id: string; savedByViewer?: boolean },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext & { claims: Claims }
|
||||
) {
|
||||
if (article.savedByViewer) {
|
||||
return article.savedByViewer
|
||||
}
|
||||
if (!ctx.claims?.uid) return undefined
|
||||
const page = await getPageByParam({
|
||||
userId: ctx.claims.uid,
|
||||
_id: article.id,
|
||||
})
|
||||
return !!page
|
||||
},
|
||||
async postedByViewer(
|
||||
article: { id: string; postedByViewer?: boolean },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext & { claims: Claims }
|
||||
) {
|
||||
if (article.postedByViewer) {
|
||||
return article.postedByViewer
|
||||
}
|
||||
if (!ctx.claims?.uid) return false
|
||||
const page = await getPageByParam({
|
||||
userId: ctx.claims.uid,
|
||||
_id: article.id,
|
||||
})
|
||||
return !!page?.sharedAt
|
||||
},
|
||||
async savedAt(
|
||||
article: { id: string; savedAt?: Date; createdAt?: Date },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext & { claims: Claims }
|
||||
) {
|
||||
if (!ctx.claims?.uid) return new Date()
|
||||
if (article.savedAt) return article.savedAt
|
||||
return (
|
||||
(
|
||||
await getPageByParam({
|
||||
userId: ctx.claims.uid,
|
||||
_id: article.id,
|
||||
})
|
||||
)?.savedAt ||
|
||||
article.createdAt ||
|
||||
new Date()
|
||||
)
|
||||
},
|
||||
hasContent(article: {
|
||||
content: string | null
|
||||
originalHtml: string | null
|
||||
|
|
@ -459,115 +410,64 @@ export const functionResolvers = {
|
|||
publishedAt(article: { publishedAt: Date }) {
|
||||
return validatedDate(article.publishedAt)
|
||||
},
|
||||
async isArchived(
|
||||
article: {
|
||||
id: string
|
||||
isArchived?: boolean | null
|
||||
archivedAt?: Date | undefined
|
||||
},
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext & { claims: Claims }
|
||||
) {
|
||||
if ('isArchived' in article) return article.isArchived
|
||||
if ('archivedAt' in article) return !!article.archivedAt
|
||||
if (!ctx.claims?.uid) return false
|
||||
const page = await getPageByParam({
|
||||
userId: ctx.claims.uid,
|
||||
_id: article.id,
|
||||
})
|
||||
return !!page?.archivedAt || false
|
||||
},
|
||||
contentReader(article: {
|
||||
pageType: PageType
|
||||
uploadFileId: string | undefined
|
||||
}) {
|
||||
return contentReaderForPage(article.pageType, article.uploadFileId)
|
||||
},
|
||||
highlights(
|
||||
article: { id: string; userId?: string; highlights?: Highlight[] },
|
||||
_: { input: ArticleHighlightsInput },
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
return article.highlights || []
|
||||
},
|
||||
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
|
||||
)
|
||||
},
|
||||
// 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: { wordsCount?: number; content?: string }) {
|
||||
if (article.wordsCount) return article.wordsCount
|
||||
wordsCount(article: { wordCount?: number; content?: string }) {
|
||||
if (article.wordCount) return article.wordCount
|
||||
return article.content ? wordsCount(article.content) : undefined
|
||||
},
|
||||
},
|
||||
ArticleSavingRequest: {
|
||||
async article(request: { userId: string; articleId: string }, __: unknown) {
|
||||
if (!request.userId || !request.articleId) return undefined
|
||||
|
||||
return getPageByParam({
|
||||
userId: request.userId,
|
||||
_id: request.articleId,
|
||||
})
|
||||
},
|
||||
},
|
||||
Highlight: {
|
||||
async user(
|
||||
highlight: { userId: string },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
return userDataToUser(await ctx.models.user.get(highlight.userId))
|
||||
},
|
||||
async reactions(
|
||||
highlight: { id: string; reactions?: Reaction[] },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
const { reactions, id } = highlight
|
||||
if (reactions) return reactions
|
||||
// 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)
|
||||
},
|
||||
async createdByMe(
|
||||
highlight: { userId: string; createdByMe?: boolean },
|
||||
// return await ctx.models.reaction.batchGetFromHighlight(id)
|
||||
// },
|
||||
createdByMe(
|
||||
highlight: { user: { id: string } },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
return highlight.createdByMe ?? highlight.userId === ctx.claims?.uid
|
||||
},
|
||||
type(highlight: { type: HighlightType }) {
|
||||
return highlight.type || HighlightType.Highlight
|
||||
},
|
||||
},
|
||||
Reaction: {
|
||||
async user(
|
||||
reaction: { userId: string },
|
||||
__: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
return userDataToUser(await ctx.models.user.get(reaction.userId))
|
||||
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 &&
|
||||
ctx.uid &&
|
||||
item.uploadFileId
|
||||
) {
|
||||
const upload = await ctx.models.uploadFile.get(item.uploadFileId)
|
||||
const upload = await findUploadFileById(item.uploadFileId)
|
||||
if (!upload || !upload.fileName) {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -576,8 +476,43 @@ export const functionResolvers = {
|
|||
}
|
||||
return item.url
|
||||
},
|
||||
pageType(item: SearchItem) {
|
||||
return item.pageType || PageType.Unknown
|
||||
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
|
||||
},
|
||||
async highlights(
|
||||
item: { id: string; highlights?: Highlight[] },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (item.highlights) return item.highlights
|
||||
|
||||
const highlights = await findHighlightsByLibraryItemId(item.id, ctx.uid)
|
||||
return highlights.map(highlightDataToHighlight)
|
||||
},
|
||||
async labels(
|
||||
item: { id: string; labels?: Label[] },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (item.labels) return item.labels
|
||||
|
||||
return findLabelsByLibraryItemId(item.id, ctx.uid)
|
||||
},
|
||||
async recommendations(item: {
|
||||
id: string
|
||||
recommendations?: Recommendation[]
|
||||
}) {
|
||||
if (item.recommendations) return item.recommendations
|
||||
|
||||
const recommendations = await findRecommendationsByLibraryItemId(item.id)
|
||||
return recommendations.map(recommandationDataToRecommendation)
|
||||
},
|
||||
},
|
||||
Subscription: {
|
||||
|
|
@ -596,31 +531,31 @@ export const functionResolvers = {
|
|||
...resultResolveTypeResolver('UpdateUser'),
|
||||
...resultResolveTypeResolver('UpdateUserProfile'),
|
||||
...resultResolveTypeResolver('Article'),
|
||||
...resultResolveTypeResolver('SharedArticle'),
|
||||
// ...resultResolveTypeResolver('SharedArticle'),
|
||||
...resultResolveTypeResolver('Articles'),
|
||||
...resultResolveTypeResolver('User'),
|
||||
...resultResolveTypeResolver('Users'),
|
||||
...resultResolveTypeResolver('SaveArticleReadingProgress'),
|
||||
...resultResolveTypeResolver('FeedArticles'),
|
||||
// ...resultResolveTypeResolver('FeedArticles'),
|
||||
...resultResolveTypeResolver('CreateArticle'),
|
||||
...resultResolveTypeResolver('CreateHighlight'),
|
||||
...resultResolveTypeResolver('CreateReaction'),
|
||||
...resultResolveTypeResolver('DeleteReaction'),
|
||||
// ...resultResolveTypeResolver('CreateReaction'),
|
||||
// ...resultResolveTypeResolver('DeleteReaction'),
|
||||
...resultResolveTypeResolver('MergeHighlight'),
|
||||
...resultResolveTypeResolver('UpdateHighlight'),
|
||||
...resultResolveTypeResolver('DeleteHighlight'),
|
||||
...resultResolveTypeResolver('UploadFileRequest'),
|
||||
...resultResolveTypeResolver('SetShareArticle'),
|
||||
...resultResolveTypeResolver('UpdateSharedComment'),
|
||||
// ...resultResolveTypeResolver('SetShareArticle'),
|
||||
// ...resultResolveTypeResolver('UpdateSharedComment'),
|
||||
...resultResolveTypeResolver('SetBookmarkArticle'),
|
||||
...resultResolveTypeResolver('SetFollow'),
|
||||
...resultResolveTypeResolver('GetFollowers'),
|
||||
...resultResolveTypeResolver('GetFollowing'),
|
||||
// ...resultResolveTypeResolver('SetFollow'),
|
||||
// ...resultResolveTypeResolver('GetFollowers'),
|
||||
// ...resultResolveTypeResolver('GetFollowing'),
|
||||
...resultResolveTypeResolver('GetUserPersonalization'),
|
||||
...resultResolveTypeResolver('SetUserPersonalization'),
|
||||
...resultResolveTypeResolver('ArticleSavingRequest'),
|
||||
...resultResolveTypeResolver('CreateArticleSavingRequest'),
|
||||
...resultResolveTypeResolver('SetShareHighlight'),
|
||||
// ...resultResolveTypeResolver('SetShareHighlight'),
|
||||
...resultResolveTypeResolver('ArchiveLink'),
|
||||
...resultResolveTypeResolver('CreateNewsletterEmail'),
|
||||
...resultResolveTypeResolver('NewsletterEmails'),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import {
|
||||
addHighlightToPage,
|
||||
deleteHighlight,
|
||||
getHighlightById,
|
||||
updateHighlight,
|
||||
} from '../../elastic/highlights'
|
||||
import { getPageById, updatePage } from '../../elastic/pages'
|
||||
import { DeepPartial } from 'typeorm'
|
||||
import {
|
||||
Highlight as HighlightData,
|
||||
HighlightType,
|
||||
Label,
|
||||
} from '../../elastic/types'
|
||||
} from '../../entity/highlight'
|
||||
import { Label } from '../../entity/label'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateHighlightError,
|
||||
|
|
@ -21,108 +15,59 @@ import {
|
|||
DeleteHighlightError,
|
||||
DeleteHighlightErrorCode,
|
||||
DeleteHighlightSuccess,
|
||||
Highlight,
|
||||
MergeHighlightError,
|
||||
MergeHighlightErrorCode,
|
||||
MergeHighlightSuccess,
|
||||
MutationCreateHighlightArgs,
|
||||
MutationDeleteHighlightArgs,
|
||||
MutationMergeHighlightArgs,
|
||||
MutationSetShareHighlightArgs,
|
||||
MutationUpdateHighlightArgs,
|
||||
SetShareHighlightError,
|
||||
SetShareHighlightErrorCode,
|
||||
SetShareHighlightSuccess,
|
||||
UpdateHighlightError,
|
||||
UpdateHighlightErrorCode,
|
||||
UpdateHighlightSuccess,
|
||||
User,
|
||||
} from '../../generated/graphql'
|
||||
import { highlightRepository } from '../../repository/highlight'
|
||||
import {
|
||||
createHighlight,
|
||||
deleteHighlightById,
|
||||
mergeHighlights,
|
||||
updateHighlight,
|
||||
} from '../../services/highlights'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized, unescapeHtml } from '../../utils/helpers'
|
||||
|
||||
const highlightDataToHighlight = (highlight: HighlightData): Highlight => ({
|
||||
...highlight,
|
||||
user: highlight.userId as unknown as User,
|
||||
updatedAt: highlight.updatedAt || highlight.createdAt,
|
||||
replies: [],
|
||||
reactions: [],
|
||||
createdByMe: undefined as never,
|
||||
})
|
||||
import { authorized, highlightDataToHighlight } from '../../utils/helpers'
|
||||
|
||||
export const createHighlightResolver = authorized<
|
||||
CreateHighlightSuccess,
|
||||
CreateHighlightError,
|
||||
MutationCreateHighlightArgs
|
||||
>(async (_, { input }, { claims, log, pubsub }) => {
|
||||
const { articleId: pageId } = input
|
||||
const page = await getPageById(pageId)
|
||||
if (!page) {
|
||||
return {
|
||||
errorCodes: [CreateHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (page.userId !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [CreateHighlightErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
if (input.annotation && input.annotation.length > 4000) {
|
||||
return {
|
||||
errorCodes: [CreateHighlightErrorCode.BadData],
|
||||
}
|
||||
}
|
||||
|
||||
// unescape HTML entities
|
||||
const annotation = input.annotation
|
||||
? unescapeHtml(input.annotation)
|
||||
: undefined
|
||||
|
||||
>(async (_, { input }, { log, pubsub, uid }) => {
|
||||
try {
|
||||
const highlight: HighlightData = {
|
||||
...input,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
userId: claims.uid,
|
||||
annotation,
|
||||
type: input.type || HighlightType.Highlight,
|
||||
}
|
||||
|
||||
if (
|
||||
!(await addHighlightToPage(pageId, highlight, {
|
||||
pubsub,
|
||||
uid: claims.uid,
|
||||
refresh: true,
|
||||
}))
|
||||
) {
|
||||
return {
|
||||
errorCodes: [CreateHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Creating a new highlight', {
|
||||
highlight,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'createHighlightResolver',
|
||||
uid: claims.uid,
|
||||
const newHighlight = await createHighlight(
|
||||
{
|
||||
...input,
|
||||
user: { id: uid },
|
||||
libraryItem: { id: input.articleId },
|
||||
highlightType: input.type as HighlightType,
|
||||
},
|
||||
})
|
||||
input.articleId,
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
userId: uid,
|
||||
event: 'highlight_created',
|
||||
properties: {
|
||||
pageId,
|
||||
libraryItemId: input.articleId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return { highlight: highlightDataToHighlight(highlight) }
|
||||
return { highlight: highlightDataToHighlight(newHighlight) }
|
||||
} catch (err) {
|
||||
log.error('Error creating highlight', err)
|
||||
return {
|
||||
errorCodes: [CreateHighlightErrorCode.AlreadyExists],
|
||||
errorCodes: [CreateHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -131,101 +76,82 @@ export const mergeHighlightResolver = authorized<
|
|||
MergeHighlightSuccess,
|
||||
MergeHighlightError,
|
||||
MutationMergeHighlightArgs
|
||||
>(async (_, { input }, { claims, log, pubsub }) => {
|
||||
const { articleId: pageId } = input
|
||||
>(async (_, { input }, { log, pubsub, uid }) => {
|
||||
const { overlapHighlightIdList, ...newHighlightInput } = input
|
||||
const page = await getPageById(pageId)
|
||||
if (!page || !page.highlights) {
|
||||
return {
|
||||
errorCodes: [MergeHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (page.userId !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [MergeHighlightErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
/* Compute merged annotation form the order of highlights appearing on page */
|
||||
const mergedAnnotations: string[] = []
|
||||
const mergedLabels: Label[] = []
|
||||
const mergedColors: string[] = []
|
||||
const pageHighlights = page.highlights.filter((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.type === HighlightType.Highlight
|
||||
) {
|
||||
if (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)
|
||||
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
// use new color or the color of the last overlap highlight
|
||||
const color = newHighlightInput.color || mergedColors[mergedColors.length - 1]
|
||||
try {
|
||||
const highlight: HighlightData = {
|
||||
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,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
userId: claims.uid,
|
||||
annotation:
|
||||
mergedAnnotations.length > 0 ? mergedAnnotations.join('\n') : null,
|
||||
type: HighlightType.Highlight,
|
||||
labels: mergedLabels,
|
||||
color,
|
||||
user: { id: uid },
|
||||
libraryItem: { id: input.articleId },
|
||||
}
|
||||
|
||||
const merged = await updatePage(
|
||||
pageId,
|
||||
{ highlights: pageHighlights.concat(highlight) },
|
||||
{ pubsub, uid: claims.uid, refresh: true }
|
||||
)
|
||||
if (!merged) {
|
||||
throw new Error('Failed to create merged highlight')
|
||||
}
|
||||
|
||||
log.info('Creating a merged highlight', {
|
||||
const newHighlight = await mergeHighlights(
|
||||
overlapHighlightIdList,
|
||||
highlight,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'mergeHighlightResolver',
|
||||
uid: claims.uid,
|
||||
pageId,
|
||||
input.articleId,
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'highlight_created',
|
||||
properties: {
|
||||
libraryItemId: input.articleId,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
highlight: highlightDataToHighlight(highlight),
|
||||
highlight: highlightDataToHighlight(newHighlight),
|
||||
overlapHighlightIdList: input.overlapHighlightIdList,
|
||||
}
|
||||
} catch (e) {
|
||||
log.info('Failed to create a merged highlight', {
|
||||
error: e,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'mergeHighlightResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
log.error('Error merging highlight', e)
|
||||
return {
|
||||
errorCodes: [MergeHighlightErrorCode.AlreadyExists],
|
||||
errorCodes: [MergeHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -234,149 +160,99 @@ export const updateHighlightResolver = authorized<
|
|||
UpdateHighlightSuccess,
|
||||
UpdateHighlightError,
|
||||
MutationUpdateHighlightArgs
|
||||
>(async (_, { input }, { pubsub, claims, log }) => {
|
||||
const highlight = await getHighlightById(input.highlightId)
|
||||
>(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
|
||||
)
|
||||
|
||||
if (!highlight?.id) {
|
||||
return {
|
||||
errorCodes: [UpdateHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (highlight.userId !== claims.uid) {
|
||||
return { highlight: highlightDataToHighlight(updatedHighlight) }
|
||||
} catch (error) {
|
||||
log.error('updateHighlightResolver error', error)
|
||||
return {
|
||||
errorCodes: [UpdateHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
|
||||
// unescape HTML entities
|
||||
const annotation = input.annotation
|
||||
? unescapeHtml(input.annotation)
|
||||
: undefined
|
||||
const quote = input.quote ? unescapeHtml(input.quote) : highlight.quote
|
||||
|
||||
const updatedHighlight: HighlightData = {
|
||||
...highlight,
|
||||
annotation,
|
||||
quote,
|
||||
updatedAt: new Date(),
|
||||
color: input.color,
|
||||
}
|
||||
|
||||
log.info('Updating a highlight', {
|
||||
updatedHighlight,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'updateHighlightResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = await updateHighlight(updatedHighlight, {
|
||||
pubsub,
|
||||
uid: claims.uid,
|
||||
refresh: true,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
errorCodes: [UpdateHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
return { highlight: highlightDataToHighlight(updatedHighlight) }
|
||||
})
|
||||
|
||||
export const deleteHighlightResolver = authorized<
|
||||
DeleteHighlightSuccess,
|
||||
DeleteHighlightError,
|
||||
MutationDeleteHighlightArgs
|
||||
>(async (_, { highlightId }, { claims, log, pubsub }) => {
|
||||
const highlight = await getHighlightById(highlightId)
|
||||
>(async (_, { highlightId }, { log }) => {
|
||||
try {
|
||||
const deletedHighlight = await deleteHighlightById(highlightId)
|
||||
|
||||
if (!highlight?.id) {
|
||||
return {
|
||||
errorCodes: [DeleteHighlightErrorCode.NotFound],
|
||||
if (!deletedHighlight) {
|
||||
return {
|
||||
errorCodes: [DeleteHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (highlight.userId !== claims.uid) {
|
||||
return { highlight: highlightDataToHighlight(deletedHighlight) }
|
||||
} catch (error) {
|
||||
log.error('deleteHighlightResolver error', error)
|
||||
return {
|
||||
errorCodes: [DeleteHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
|
||||
const deleted = await deleteHighlight(highlightId, {
|
||||
pubsub,
|
||||
uid: claims.uid,
|
||||
refresh: true,
|
||||
})
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
errorCodes: [DeleteHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Deleting a highlight', {
|
||||
highlight,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteHighlightResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
return { highlight: highlightDataToHighlight(highlight) }
|
||||
})
|
||||
|
||||
export const setShareHighlightResolver = authorized<
|
||||
SetShareHighlightSuccess,
|
||||
SetShareHighlightError,
|
||||
MutationSetShareHighlightArgs
|
||||
>(async (_, { input: { id, share } }, { pubsub, claims, log }) => {
|
||||
const highlight = await getHighlightById(id)
|
||||
// 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?.id) {
|
||||
// return {
|
||||
// errorCodes: [SetShareHighlightErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
if (highlight.userId !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [SetShareHighlightErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
// if (highlight.userId !== claims.uid) {
|
||||
// return {
|
||||
// errorCodes: [SetShareHighlightErrorCode.Forbidden],
|
||||
// }
|
||||
// }
|
||||
|
||||
const sharedAt = share ? new Date() : null
|
||||
// const sharedAt = share ? new Date() : null
|
||||
|
||||
log.info(`${share ? 'S' : 'Uns'}haring a highlight`, {
|
||||
highlight,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'setShareHighlightResolver',
|
||||
userId: highlight.userId,
|
||||
},
|
||||
})
|
||||
// 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 updatedHighlight: HighlightData = {
|
||||
// ...highlight,
|
||||
// sharedAt,
|
||||
// updatedAt: new Date(),
|
||||
// }
|
||||
|
||||
const updated = await updateHighlight(updatedHighlight, {
|
||||
pubsub,
|
||||
uid: claims.uid,
|
||||
refresh: true,
|
||||
})
|
||||
// const updated = await updateHighlight(updatedHighlight, {
|
||||
// pubsub,
|
||||
// uid: claims.uid,
|
||||
// refresh: true,
|
||||
// })
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
errorCodes: [SetShareHighlightErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// if (!updated) {
|
||||
// return {
|
||||
// errorCodes: [SetShareHighlightErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
return { highlight: highlightDataToHighlight(updatedHighlight) }
|
||||
})
|
||||
// return { highlight: highlightDataToHighlight(updatedHighlight) }
|
||||
// })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MutationUploadImportFileArgs,
|
||||
|
|
@ -9,6 +7,7 @@ import {
|
|||
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'
|
||||
|
|
@ -35,15 +34,13 @@ export const uploadImportFileResolver = authorized<
|
|||
UploadImportFileError,
|
||||
MutationUploadImportFileArgs
|
||||
>(async (_, { type, contentType }, { claims: { uid }, log }) => {
|
||||
log.info('uploadImportFileResolver')
|
||||
|
||||
if (!VALID_CONTENT_TYPES.includes(contentType)) {
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
const user = await userRepository.findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.Unauthorized],
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
export * from './user'
|
||||
export * from './article'
|
||||
export * from './user_friends'
|
||||
export * from './user_feed_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 './reaction'
|
||||
export * from './report'
|
||||
export * from './links'
|
||||
export * from './newsletters'
|
||||
export * from './save'
|
||||
export * from './send_install_instructions'
|
||||
export * from './reminders'
|
||||
// export * from './reminders'
|
||||
export * from './user_device_tokens'
|
||||
export * from './labels'
|
||||
export * from './subscriptions'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { DeepPartial } from 'typeorm'
|
||||
import { Integration, IntegrationType } from '../../entity/integration'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteIntegrationError,
|
||||
|
|
@ -19,7 +18,14 @@ import {
|
|||
SetIntegrationErrorCode,
|
||||
SetIntegrationSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getIntegrationService } from '../../services/integrations'
|
||||
import {
|
||||
findIntegration,
|
||||
findIntegrations,
|
||||
getIntegrationService,
|
||||
removeIntegration,
|
||||
saveIntegration,
|
||||
updateIntegration,
|
||||
} from '../../services/integrations'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import {
|
||||
deleteTask,
|
||||
|
|
@ -32,39 +38,22 @@ export const setIntegrationResolver = authorized<
|
|||
SetIntegrationSuccess,
|
||||
SetIntegrationError,
|
||||
MutationSetIntegrationArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('setIntegrationResolver')
|
||||
|
||||
>(async (_, { input }, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const integrationToSave: Partial<Integration> = {
|
||||
const integrationToSave: DeepPartial<Integration> = {
|
||||
...input,
|
||||
user,
|
||||
user: { id: uid },
|
||||
id: input.id || undefined,
|
||||
type: input.type || IntegrationType.Export,
|
||||
}
|
||||
if (input.id) {
|
||||
// Update
|
||||
const existingIntegration = await getRepository(Integration).findOne({
|
||||
where: { id: input.id },
|
||||
relations: ['user'],
|
||||
})
|
||||
const existingIntegration = await findIntegration({ id: input.id }, uid)
|
||||
if (!existingIntegration) {
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (existingIntegration.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
integrationToSave.id = existingIntegration.id
|
||||
integrationToSave.taskName = existingIntegration.taskName
|
||||
|
|
@ -82,18 +71,18 @@ export const setIntegrationResolver = authorized<
|
|||
}
|
||||
|
||||
// save integration
|
||||
const integration = await getRepository(Integration).save(integrationToSave)
|
||||
const integration = await saveIntegration(integrationToSave, uid)
|
||||
|
||||
if (
|
||||
integrationToSave.type === IntegrationType.Export &&
|
||||
(!integrationToSave.id || integrationToSave.enabled)
|
||||
) {
|
||||
// create a task to sync all the pages if new integration or enable integration (export type)
|
||||
const taskName = await enqueueSyncWithIntegration(user.id, input.name)
|
||||
const taskName = await enqueueSyncWithIntegration(uid, input.name)
|
||||
log.info('enqueued task', taskName)
|
||||
|
||||
// update task name in integration
|
||||
await getRepository(Integration).update(integration.id, { taskName })
|
||||
await updateIntegration(integration.id, { taskName }, uid)
|
||||
integration.taskName = taskName
|
||||
} else if (integrationToSave.taskName) {
|
||||
// delete the task if disable integration and task exists
|
||||
|
|
@ -101,9 +90,13 @@ export const setIntegrationResolver = authorized<
|
|||
log.info('task deleted', integrationToSave.taskName)
|
||||
|
||||
// update task name in integration
|
||||
await getRepository(Integration).update(integration.id, {
|
||||
taskName: null,
|
||||
})
|
||||
await updateIntegration(
|
||||
integration.id,
|
||||
{
|
||||
taskName: null,
|
||||
},
|
||||
uid
|
||||
)
|
||||
integration.taskName = null
|
||||
}
|
||||
|
||||
|
|
@ -131,19 +124,9 @@ export const setIntegrationResolver = authorized<
|
|||
export const integrationsResolver = authorized<
|
||||
IntegrationsSuccess,
|
||||
IntegrationsError
|
||||
>(async (_, __, { claims: { uid }, log }) => {
|
||||
log.info('integrationsResolver')
|
||||
|
||||
>(async (_, __, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [IntegrationsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
const integrations = await getRepository(Integration).findBy({
|
||||
user: { id: uid },
|
||||
})
|
||||
const integrations = await findIntegrations(uid)
|
||||
|
||||
return {
|
||||
integrations,
|
||||
|
|
@ -165,17 +148,7 @@ export const deleteIntegrationResolver = authorized<
|
|||
log.info('deleteIntegrationResolver')
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [DeleteIntegrationErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const integration = await getRepository(Integration).findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
})
|
||||
const integration = await findIntegration({ id }, uid)
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
|
|
@ -183,21 +156,13 @@ export const deleteIntegrationResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
if (integration.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [DeleteIntegrationErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
if (integration.taskName) {
|
||||
// delete the task if task exists
|
||||
await deleteTask(integration.taskName)
|
||||
log.info('task deleted', integration.taskName)
|
||||
}
|
||||
|
||||
const deletedIntegration = await getRepository(Integration).remove(
|
||||
integration
|
||||
)
|
||||
const deletedIntegration = await removeIntegration(integration, uid)
|
||||
deletedIntegration.id = id
|
||||
|
||||
analytics.track({
|
||||
|
|
@ -229,10 +194,7 @@ export const importFromIntegrationResolver = authorized<
|
|||
log.info('importFromIntegrationResolver')
|
||||
|
||||
try {
|
||||
const integration = await getRepository(Integration).findOne({
|
||||
where: { id: integrationId, user: { id: uid } },
|
||||
relations: ['user'],
|
||||
})
|
||||
const integration = await findIntegration({ id: integrationId }, uid)
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
|
|
@ -251,7 +213,7 @@ export const importFromIntegrationResolver = authorized<
|
|||
authToken
|
||||
)
|
||||
// update task name in integration
|
||||
await getRepository(Integration).update(integration.id, { taskName })
|
||||
await updateIntegration(integration.id, { taskName }, uid)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,5 @@
|
|||
import { Between } from 'typeorm'
|
||||
import { createPubSubClient } from '../../datalayer/pubsub'
|
||||
import { getHighlightById } from '../../elastic/highlights'
|
||||
import {
|
||||
deleteLabel,
|
||||
setLabelsForHighlight,
|
||||
updateLabel,
|
||||
updateLabelsInPage,
|
||||
} from '../../elastic/labels'
|
||||
import { getPageById } from '../../elastic/pages'
|
||||
import { Label } from '../../entity/label'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository, setClaims } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateLabelError,
|
||||
|
|
@ -38,36 +27,38 @@ import {
|
|||
UpdateLabelErrorCode,
|
||||
UpdateLabelSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { labelRepository } from '../../repository/label'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import {
|
||||
createLabel,
|
||||
createLabels,
|
||||
getLabelByName,
|
||||
getLabelsByIds,
|
||||
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, { claims: { uid }, log }) => {
|
||||
log.info('labelsResolver')
|
||||
|
||||
async (_obj, _params, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { id: uid },
|
||||
relations: ['labels'],
|
||||
order: {
|
||||
labels: {
|
||||
position: 'ASC',
|
||||
},
|
||||
},
|
||||
})
|
||||
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: {
|
||||
position: 'ASC',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels',
|
||||
|
|
@ -81,10 +72,10 @@ export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
|
|||
})
|
||||
|
||||
return {
|
||||
labels: user.labels || [],
|
||||
labels,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
log.error('labelsResolver', error)
|
||||
return {
|
||||
errorCodes: [LabelsErrorCode.BadRequest],
|
||||
}
|
||||
|
|
@ -96,26 +87,11 @@ export const createLabelResolver = authorized<
|
|||
CreateLabelSuccess,
|
||||
CreateLabelError,
|
||||
MutationCreateLabelArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('createLabelResolver')
|
||||
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [CreateLabelErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
// Check if label already exists ignoring case of name
|
||||
const existingLabel = await getLabelByName(uid, input.name)
|
||||
if (existingLabel) {
|
||||
return {
|
||||
errorCodes: [CreateLabelErrorCode.LabelAlreadyExists],
|
||||
}
|
||||
}
|
||||
|
||||
const label = await createLabel(uid, input)
|
||||
const label = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).createLabel(input, uid)
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
|
|
@ -130,7 +106,7 @@ export const createLabelResolver = authorized<
|
|||
label,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
log.error('createLabelResolver', error)
|
||||
return {
|
||||
errorCodes: [CreateLabelErrorCode.BadRequest],
|
||||
}
|
||||
|
|
@ -141,53 +117,18 @@ export const deleteLabelResolver = authorized<
|
|||
DeleteLabelSuccess,
|
||||
DeleteLabelError,
|
||||
MutationDeleteLabelArgs
|
||||
>(async (_, { id: labelId }, { claims: { uid }, log }) => {
|
||||
log.info('deleteLabelResolver')
|
||||
|
||||
>(async (_, { id: labelId }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [DeleteLabelErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const label = await getRepository(Label).findOne({
|
||||
where: { id: labelId, user: { id: uid } },
|
||||
relations: ['user'],
|
||||
const deleteResult = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).deleteById(labelId)
|
||||
})
|
||||
if (!label) {
|
||||
|
||||
if (!deleteResult.affected) {
|
||||
return {
|
||||
errorCodes: [DeleteLabelErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// internal labels cannot be deleted
|
||||
if (label.internal) {
|
||||
log.info('internal labels cannot be deleted')
|
||||
return {
|
||||
errorCodes: [DeleteLabelErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
|
||||
const result = await AppDataSource.transaction(async (t) => {
|
||||
await setClaims(t, uid)
|
||||
return t.getRepository(Label).delete(labelId)
|
||||
})
|
||||
if (!result.affected) {
|
||||
log.error('Failed to delete label', labelId)
|
||||
return {
|
||||
errorCodes: [DeleteLabelErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
// delete label in elastic pages and highlights
|
||||
await deleteLabel(label.name, {
|
||||
pubsub: createPubSubClient(),
|
||||
uid,
|
||||
refresh: true,
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'label_deleted',
|
||||
|
|
@ -198,7 +139,11 @@ export const deleteLabelResolver = authorized<
|
|||
})
|
||||
|
||||
return {
|
||||
label,
|
||||
label: {
|
||||
id: labelId,
|
||||
name: '',
|
||||
color: '',
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('error deleting label', error)
|
||||
|
|
@ -212,160 +157,71 @@ export const setLabelsResolver = authorized<
|
|||
SetLabelsSuccess,
|
||||
SetLabelsError,
|
||||
MutationSetLabelsArgs
|
||||
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
|
||||
log.info('setLabelsResolver')
|
||||
|
||||
const { pageId, labelIds, labels } = input
|
||||
|
||||
if (!labelIds && !labels) {
|
||||
log.info('labelIds or labels must be provided')
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
>(
|
||||
async (
|
||||
_,
|
||||
{ input: { pageId, labelIds, labels } },
|
||||
{ uid, log, authTrx, pubsub }
|
||||
) => {
|
||||
if (!labelIds && !labels) {
|
||||
log.error('labelIds or labels must be provided')
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.Unauthorized],
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
const page = await getPageById(pageId)
|
||||
if (!page) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (page.userId !== uid) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
try {
|
||||
let labelsSet: Label[] = []
|
||||
|
||||
const ctx = {
|
||||
uid,
|
||||
pubsub,
|
||||
refresh: true,
|
||||
}
|
||||
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 (labels && labels.length > 0) {
|
||||
// for new clients that send label names
|
||||
// create labels if they don't exist
|
||||
labelsSet = await createLabels(ctx, labels)
|
||||
} else if (labelIds && labelIds.length > 0) {
|
||||
// for old clients that send labelIds
|
||||
labelsSet = await getLabelsByIds(uid, labelIds)
|
||||
if (labelsSet.length !== labelIds.length) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
if (labelsSet.length !== labelIds.length) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// filter out labels that are already set
|
||||
const labelsToAdd = labelsSet.filter(
|
||||
(label) => !page.labels?.some((pageLabel) => pageLabel.id === label.id)
|
||||
)
|
||||
// update labels in the page
|
||||
const updated = await updateLabelsInPage(
|
||||
pageId,
|
||||
labelsSet,
|
||||
ctx,
|
||||
labelsToAdd
|
||||
)
|
||||
if (!updated) {
|
||||
|
||||
// save labels in the library item
|
||||
await saveLabelsInLibraryItem(labelsSet, pageId, uid, pubsub)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels_set',
|
||||
properties: {
|
||||
pageId,
|
||||
labelIds,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
labels: labelsSet,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('setLabelsResolver error', error)
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'labels_set',
|
||||
properties: {
|
||||
pageId,
|
||||
labelIds,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
labels: labelsSet,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const updateLabelResolver = authorized<
|
||||
UpdateLabelSuccess,
|
||||
UpdateLabelError,
|
||||
MutationUpdateLabelArgs
|
||||
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
|
||||
log.info('updateLabelResolver')
|
||||
|
||||
>(async (_, { input: { name, color, description, labelId } }, { uid, log }) => {
|
||||
try {
|
||||
const { name, color, description, labelId } = input
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UpdateLabelErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const label = await getRepository(Label).findOne({
|
||||
where: { id: labelId, user: { id: uid } },
|
||||
select: ['id', 'name', 'color', 'description', 'createdAt'],
|
||||
})
|
||||
if (!label) {
|
||||
return {
|
||||
errorCodes: [UpdateLabelErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// internal labels cannot be updated
|
||||
if (label.internal && label.name.toLowerCase() !== name.toLowerCase()) {
|
||||
log.info('internal labels cannot be updated')
|
||||
|
||||
return {
|
||||
errorCodes: [UpdateLabelErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Updating a label', {
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'updateLabelResolver',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await AppDataSource.transaction(async (t) => {
|
||||
await setClaims(t, uid)
|
||||
label.name = name
|
||||
label.color = color
|
||||
label.description = description || undefined
|
||||
label.createdAt = new Date()
|
||||
|
||||
return t.getRepository(Label).update({ id: labelId }, label)
|
||||
})
|
||||
|
||||
if (!result.affected) {
|
||||
log.error('failed to update')
|
||||
return {
|
||||
errorCodes: [UpdateLabelErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
await updateLabel(label, {
|
||||
pubsub,
|
||||
uid,
|
||||
refresh: true,
|
||||
})
|
||||
const label = await updateLabel(labelId, { name, color, description }, uid)
|
||||
|
||||
return { label }
|
||||
} catch (error) {
|
||||
|
|
@ -380,9 +236,7 @@ export const setLabelsForHighlightResolver = authorized<
|
|||
SetLabelsSuccess,
|
||||
SetLabelsError,
|
||||
MutationSetLabelsForHighlightArgs
|
||||
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
|
||||
log.info('setLabelsForHighlightResolver')
|
||||
|
||||
>(async (_, { input }, { uid, log, pubsub, authTrx }) => {
|
||||
const { highlightId, labelIds, labels } = input
|
||||
|
||||
if (!labelIds && !labels) {
|
||||
|
|
@ -393,63 +247,26 @@ export const setLabelsForHighlightResolver = authorized<
|
|||
}
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const highlight = await getHighlightById(highlightId)
|
||||
if (!highlight) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (highlight.userId !== uid) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
uid,
|
||||
pubsub,
|
||||
refresh: true,
|
||||
}
|
||||
let labelsSet: Label[] = []
|
||||
|
||||
if (labels && labels.length > 0) {
|
||||
// for new clients that send label names
|
||||
// create labels if they don't exist
|
||||
labelsSet = await createLabels(ctx, labels)
|
||||
labelsSet = await findOrCreateLabels(labels, uid)
|
||||
} else if (labelIds && labelIds.length > 0) {
|
||||
// for old clients that send labelIds
|
||||
labelsSet = await getLabelsByIds(uid, labelIds)
|
||||
labelsSet = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).findLabelsById(labelIds)
|
||||
})
|
||||
if (labelsSet.length !== labelIds.length) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
}
|
||||
// filter out labels that are already set
|
||||
const labelsToAdd = labelsSet.filter(
|
||||
(label) =>
|
||||
!highlight.labels?.some(
|
||||
(highlightLabel) => highlightLabel.id === label.id
|
||||
)
|
||||
)
|
||||
// set labels in the highlights
|
||||
const updated = await setLabelsForHighlight(
|
||||
highlightId,
|
||||
labelsSet,
|
||||
ctx,
|
||||
labelsToAdd
|
||||
)
|
||||
if (!updated) {
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// save labels in the library item
|
||||
await saveLabelsInHighlight(labelsSet, input.highlightId, uid, pubsub)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
|
|
@ -465,7 +282,7 @@ export const setLabelsForHighlightResolver = authorized<
|
|||
labels: labelsSet,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
log.error('setLabelsForHighlightResolver error', error)
|
||||
return {
|
||||
errorCodes: [SetLabelsErrorCode.BadRequest],
|
||||
}
|
||||
|
|
@ -476,33 +293,18 @@ export const moveLabelResolver = authorized<
|
|||
MoveLabelSuccess,
|
||||
MoveLabelError,
|
||||
MutationMoveLabelArgs
|
||||
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
|
||||
log.info('moveLabelResolver')
|
||||
|
||||
>(async (_, { input }, { authTrx, log, uid }) => {
|
||||
const { labelId, afterLabelId } = input
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const label = await getRepository(Label).findOne({
|
||||
where: { id: labelId },
|
||||
relations: ['user'],
|
||||
const label = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).findById(labelId)
|
||||
})
|
||||
if (!label) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (label.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
if (label.id === afterLabelId) {
|
||||
// nothing to do
|
||||
|
|
@ -513,32 +315,25 @@ export const moveLabelResolver = authorized<
|
|||
// if afterLabelId is not provided, move to the top
|
||||
let newPosition = 1
|
||||
if (afterLabelId) {
|
||||
const afterLabel = await getRepository(Label).findOne({
|
||||
where: { id: afterLabelId },
|
||||
relations: ['user'],
|
||||
const afterLabel = await authTrx(async (tx) => {
|
||||
return tx.withRepository(labelRepository).findById(afterLabelId)
|
||||
})
|
||||
if (!afterLabel) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (afterLabel.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [MoveLabelErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
newPosition = afterLabel.position
|
||||
}
|
||||
const moveUp = newPosition < oldPosition
|
||||
|
||||
// move label to the new position
|
||||
const updated = await AppDataSource.transaction(async (t) => {
|
||||
await setClaims(t, uid)
|
||||
|
||||
const updated = await authTrx(async (tx) => {
|
||||
const labelRepo = tx.withRepository(labelRepository)
|
||||
// update the position of the other labels
|
||||
const updated = await t.getRepository(Label).update(
|
||||
const updated = await labelRepo.update(
|
||||
{
|
||||
user: { id: uid },
|
||||
position: Between(
|
||||
Math.min(newPosition, oldPosition),
|
||||
Math.max(newPosition, oldPosition)
|
||||
|
|
@ -553,8 +348,8 @@ export const moveLabelResolver = authorized<
|
|||
}
|
||||
|
||||
// update the position of the label
|
||||
return t.getRepository(Label).save({
|
||||
...label,
|
||||
return labelRepo.save({
|
||||
id: labelId,
|
||||
position: newPosition,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,67 +1,61 @@
|
|||
import { createOrUpdateLinkShareInfo } from '../../datalayer/links/share_info'
|
||||
import { updatePage } from '../../elastic/pages'
|
||||
import { LibraryItemState } from '../../entity/library_item'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ArchiveLinkError,
|
||||
ArchiveLinkErrorCode,
|
||||
ArchiveLinkSuccess,
|
||||
MutationSetLinkArchivedArgs,
|
||||
MutationUpdateLinkShareInfoArgs,
|
||||
UpdateLinkShareInfoError,
|
||||
UpdateLinkShareInfoErrorCode,
|
||||
UpdateLinkShareInfoSuccess,
|
||||
} 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
|
||||
// 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)
|
||||
// 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)
|
||||
)
|
||||
// // 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],
|
||||
}
|
||||
}
|
||||
// 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],
|
||||
}
|
||||
}
|
||||
// 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',
|
||||
}
|
||||
})
|
||||
// return {
|
||||
// __typename: 'UpdateLinkShareInfoSuccess',
|
||||
// message: 'Updated Share Information',
|
||||
// }
|
||||
// })
|
||||
|
||||
export const setLinkArchivedResolver = authorized<
|
||||
ArchiveLinkSuccess,
|
||||
ArchiveLinkError,
|
||||
MutationSetLinkArchivedArgs
|
||||
>(async (_obj, args, { claims, pubsub, log }) => {
|
||||
log.info('setLinkArchivedResolver', args.input.linkId)
|
||||
|
||||
>(async (_obj, args, { uid }) => {
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
userId: uid,
|
||||
event: args.input.archived ? 'link_archived' : 'link_unarchived',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
|
|
@ -69,12 +63,15 @@ export const setLinkArchivedResolver = authorized<
|
|||
})
|
||||
|
||||
try {
|
||||
await updatePage(
|
||||
await updateLibraryItem(
|
||||
args.input.linkId,
|
||||
{
|
||||
archivedAt: args.input.archived ? new Date() : null,
|
||||
state: args.input.archived
|
||||
? LibraryItemState.Archived
|
||||
: LibraryItemState.Succeeded,
|
||||
},
|
||||
{ pubsub, uid: claims.uid, refresh: true } // refresh index to update search results
|
||||
uid
|
||||
)
|
||||
} catch (e) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { NewsletterEmail } from '../../entity/newsletter_email'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateNewsletterEmailError,
|
||||
|
|
@ -14,6 +12,7 @@ import {
|
|||
NewsletterEmailsErrorCode,
|
||||
NewsletterEmailsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../repository'
|
||||
import {
|
||||
createNewsletterEmail,
|
||||
deleteNewsletterEmail,
|
||||
|
|
@ -57,20 +56,9 @@ export const createNewsletterEmailResolver = authorized<
|
|||
export const newsletterEmailsResolver = authorized<
|
||||
NewsletterEmailsSuccess,
|
||||
NewsletterEmailsError
|
||||
>(async (_parent, _args, { claims, log }) => {
|
||||
log.info('newsletterEmailsResolver')
|
||||
|
||||
>(async (_parent, _args, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
if (!user) {
|
||||
return Promise.reject({
|
||||
errorCode: NewsletterEmailsErrorCode.Unauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
const newsletterEmails = await getNewsletterEmails(user.id)
|
||||
const newsletterEmails = await getNewsletterEmails(uid)
|
||||
|
||||
return {
|
||||
newsletterEmails: newsletterEmails.map((newsletterEmail) => ({
|
||||
|
|
@ -91,10 +79,9 @@ export const deleteNewsletterEmailResolver = authorized<
|
|||
DeleteNewsletterEmailSuccess,
|
||||
DeleteNewsletterEmailError,
|
||||
MutationDeleteNewsletterEmailArgs
|
||||
>(async (_parent, args, { claims, log }) => {
|
||||
log.info('deleteNewsletterEmailResolver')
|
||||
>(async (_parent, args, { uid, log }) => {
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
userId: uid,
|
||||
event: 'newsletter_email_address_deleted',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
|
|
@ -105,6 +92,7 @@ export const deleteNewsletterEmailResolver = authorized<
|
|||
const newsletterEmail = await getRepository(NewsletterEmail).findOne({
|
||||
where: {
|
||||
id: args.newsletterEmailId,
|
||||
user: { id: uid },
|
||||
},
|
||||
relations: ['user', 'subscriptions'],
|
||||
})
|
||||
|
|
@ -115,12 +103,6 @@ export const deleteNewsletterEmailResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
if (newsletterEmail.user.id !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [DeleteNewsletterEmailErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
// unsubscribe all before deleting
|
||||
await unsubscribeAll(newsletterEmail)
|
||||
|
||||
|
|
@ -138,8 +120,8 @@ export const deleteNewsletterEmailResolver = authorized<
|
|||
errorCodes: [DeleteNewsletterEmailErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.info(e)
|
||||
} catch (error) {
|
||||
log.error('deleteNewsletterEmailResolver', error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteNewsletterEmailErrorCode.BadRequest],
|
||||
|
|
|
|||
|
|
@ -1,44 +1,22 @@
|
|||
import {
|
||||
MutationAddPopularReadArgs,
|
||||
AddPopularReadError,
|
||||
AddPopularReadErrorCode,
|
||||
AddPopularReadSuccess,
|
||||
MutationAddPopularReadArgs,
|
||||
} from '../../generated/graphql'
|
||||
|
||||
import { authorized, userDataToUser } from '../../utils/helpers'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { addPopularRead } from '../../services/popular_reads'
|
||||
import { env } from '../../env'
|
||||
|
||||
import { authorized } from '../../utils/helpers'
|
||||
export const addPopularReadResolver = authorized<
|
||||
AddPopularReadSuccess,
|
||||
AddPopularReadError,
|
||||
MutationAddPopularReadArgs
|
||||
>(async (_, { name }, ctx) => {
|
||||
const {
|
||||
models,
|
||||
claims: { uid },
|
||||
} = ctx
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'popular_read_added',
|
||||
properties: {
|
||||
name: name,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const user = userDataToUser(await models.user.get(uid))
|
||||
if (!user) {
|
||||
return { errorCodes: [AddPopularReadErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
const pageId = await addPopularRead(uid, name)
|
||||
if (!pageId) {
|
||||
>(async (_, { name }, { uid }) => {
|
||||
const item = await addPopularRead(uid, name)
|
||||
if (!item) {
|
||||
return { errorCodes: [AddPopularReadErrorCode.NotFound] }
|
||||
}
|
||||
|
||||
return {
|
||||
pageId,
|
||||
pageId: item.id,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
import { Merge } from '../../util'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import {
|
||||
CreateReactionError,
|
||||
CreateReactionErrorCode,
|
||||
CreateReactionSuccess,
|
||||
DeleteReactionError,
|
||||
DeleteReactionErrorCode,
|
||||
MutationCreateReactionArgs,
|
||||
MutationDeleteReactionArgs,
|
||||
Reaction,
|
||||
} from './../../generated/graphql'
|
||||
import { CreateReactionSuccess, Reaction } from './../../generated/graphql'
|
||||
|
||||
export type PartialReaction = Omit<Reaction, 'user'>
|
||||
|
||||
|
|
@ -18,125 +8,125 @@ export type PartialCreateReactionSuccess = Merge<
|
|||
{ reaction: PartialReaction }
|
||||
>
|
||||
|
||||
export const createReactionResolver = authorized<
|
||||
PartialCreateReactionSuccess,
|
||||
CreateReactionError,
|
||||
MutationCreateReactionArgs
|
||||
>(async (_, { input }, { models, claims, log, authTrx }) => {
|
||||
const { userArticleId, highlightId } = input
|
||||
// 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
|
||||
// Higlight replies hasn't supported yet
|
||||
return {
|
||||
errorCodes: [CreateReactionErrorCode.BadTarget],
|
||||
}
|
||||
}
|
||||
// if ((!userArticleId && !highlightId) || (userArticleId && highlightId)) {
|
||||
// // One reaction target is required
|
||||
// // Higlight replies hasn't supported yet
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.BadTarget],
|
||||
// }
|
||||
// }
|
||||
|
||||
if (input.code && input.code.length > 50) {
|
||||
return {
|
||||
errorCodes: [CreateReactionErrorCode.BadCode],
|
||||
}
|
||||
}
|
||||
// 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],
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 })
|
||||
// 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
|
||||
)
|
||||
)
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
// 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
// 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)
|
||||
// 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?.id) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReactionErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
if (reaction.userId !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [DeleteReactionErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
// if (reaction.userId !== claims.uid) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReactionErrorCode.Forbidden],
|
||||
// }
|
||||
// }
|
||||
|
||||
const deleted = await authTrx((tx) => models.reaction.delete(id, tx))
|
||||
// const deleted = await authTrx((tx) => models.reaction.delete(id, tx))
|
||||
|
||||
if ('error' in deleted) {
|
||||
return {
|
||||
errorCodes: [DeleteReactionErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// if ('error' in deleted) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReactionErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
log.info('Deleting a highlight', {
|
||||
deleted,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteHighlightResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
// log.info('Deleting a highlight', {
|
||||
// deleted,
|
||||
// labels: {
|
||||
// source: 'resolver',
|
||||
// resolver: 'deleteHighlightResolver',
|
||||
// uid: claims.uid,
|
||||
// },
|
||||
// })
|
||||
|
||||
return {
|
||||
reaction: reaction as PartialReaction,
|
||||
}
|
||||
})
|
||||
// return {
|
||||
// reaction: reaction as PartialReaction,
|
||||
// }
|
||||
// })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { ILike } from 'typeorm'
|
||||
import { NewsletterEmail } from '../../entity/newsletter_email'
|
||||
import { ReceivedEmail } from '../../entity/received_email'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MarkEmailAsItemError,
|
||||
|
|
@ -13,7 +12,7 @@ import {
|
|||
RecentEmailsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { updateReceivedEmail } from '../../services/received_emails'
|
||||
import { saveNewsletterEmail } from '../../services/save_newsletter_email'
|
||||
import { saveNewsletter } from '../../services/save_newsletter_email'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { generateUniqueUrl, parseEmailAddress } from '../../utils/parser'
|
||||
import { sendEmail } from '../../utils/sendEmail'
|
||||
|
|
@ -21,34 +20,23 @@ import { sendEmail } from '../../utils/sendEmail'
|
|||
export const recentEmailsResolver = authorized<
|
||||
RecentEmailsSuccess,
|
||||
RecentEmailsError
|
||||
>(async (_, __, { claims, log }) => {
|
||||
log.info('Getting recent emails', {
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recentEmailsResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, __, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const recentEmails = await getRepository(ReceivedEmail).find({
|
||||
where: { user: { id: claims.uid } },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
})
|
||||
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,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recentEmailsResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
log.error('Error getting recent emails', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RecentEmailsErrorCode.BadRequest],
|
||||
|
|
@ -60,22 +48,15 @@ export const markEmailAsItemResolver = authorized<
|
|||
MarkEmailAsItemSuccess,
|
||||
MarkEmailAsItemError,
|
||||
MutationMarkEmailAsItemArgs
|
||||
>(async (_, { recentEmailId }, { claims, log }) => {
|
||||
log.info('Marking email as item', {
|
||||
recentEmailId,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'markEmailAsItemResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { recentEmailId }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const recentEmail = await getRepository(ReceivedEmail).findOneBy({
|
||||
id: recentEmailId,
|
||||
user: { id: claims.uid },
|
||||
type: 'non-article',
|
||||
})
|
||||
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)
|
||||
|
||||
|
|
@ -84,13 +65,15 @@ export const markEmailAsItemResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
const newsletterEmail = await getRepository(NewsletterEmail).findOne({
|
||||
where: {
|
||||
address: ILike(recentEmail.to),
|
||||
user: { id: claims.uid },
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
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,
|
||||
|
|
@ -103,7 +86,7 @@ export const markEmailAsItemResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
const success = await saveNewsletterEmail(
|
||||
const success = await saveNewsletter(
|
||||
{
|
||||
from: recentEmail.from,
|
||||
email: recentEmail.to,
|
||||
|
|
@ -124,10 +107,10 @@ export const markEmailAsItemResolver = authorized<
|
|||
}
|
||||
|
||||
// update received email type
|
||||
await updateReceivedEmail(recentEmail.id, 'article')
|
||||
await updateReceivedEmail(recentEmail.id, 'article', uid)
|
||||
|
||||
const text = `A recent email marked as a library item
|
||||
by: ${claims.uid}
|
||||
by: ${uid}
|
||||
from: ${recentEmail.from}
|
||||
subject: ${recentEmail.subject}`
|
||||
|
||||
|
|
@ -143,14 +126,7 @@ export const markEmailAsItemResolver = authorized<
|
|||
success,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error marking email as item', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'markEmailAsItemResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
log.error('Error marking email as item', error)
|
||||
|
||||
return {
|
||||
errorCodes: [MarkEmailAsItemErrorCode.BadRequest],
|
||||
|
|
|
|||
|
|
@ -1,35 +1,15 @@
|
|||
import { authorized } from '../../utils/helpers'
|
||||
import {
|
||||
RecentSearchesError,
|
||||
RecentSearchesErrorCode,
|
||||
RecentSearchesSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { env } from '../../env'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRecentSearches } from '../../services/search_history'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const recentSearchesResolver = authorized<
|
||||
RecentSearchesSuccess,
|
||||
RecentSearchesError
|
||||
>(async (_obj, _params, { claims: { uid }, log }) => {
|
||||
log.info('recentSearches')
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'recentSearches',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return { errorCodes: [RecentSearchesErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
const searches = await getRecentSearches(uid)
|
||||
>(async (_obj, _params) => {
|
||||
const searches = await getRecentSearches()
|
||||
return {
|
||||
searches,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { In } from 'typeorm'
|
||||
import { getPageByParam } from '../../elastic/pages'
|
||||
import { Group } from '../../entity/groups/group'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateGroupError,
|
||||
|
|
@ -29,6 +26,8 @@ import {
|
|||
RecommendHighlightsSuccess,
|
||||
RecommendSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../repository'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import {
|
||||
createGroup,
|
||||
createLabelAndRuleForGroup,
|
||||
|
|
@ -38,6 +37,7 @@ import {
|
|||
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'
|
||||
|
|
@ -46,20 +46,10 @@ export const createGroupResolver = authorized<
|
|||
CreateGroupSuccess,
|
||||
CreateGroupError,
|
||||
MutationCreateGroupArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('Creating group', {
|
||||
input,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'createGroupResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { input }, { uid, log }) => {
|
||||
try {
|
||||
const userData = await getRepository(User).findOne({
|
||||
where: { id: uid },
|
||||
relations: ['profile'],
|
||||
const userData = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
if (!userData) {
|
||||
return {
|
||||
|
|
@ -106,14 +96,7 @@ export const createGroupResolver = authorized<
|
|||
},
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error creating group', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'createGroupResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error creating group', error)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateGroupErrorCode.BadRequest],
|
||||
|
|
@ -122,17 +105,9 @@ export const createGroupResolver = authorized<
|
|||
})
|
||||
|
||||
export const groupsResolver = authorized<GroupsSuccess, GroupsError>(
|
||||
async (_, __, { claims: { uid }, log }) => {
|
||||
log.info('Getting groups', {
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'groupsResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
async (_, __, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
if (!user) {
|
||||
|
|
@ -167,29 +142,10 @@ export const recommendResolver = authorized<
|
|||
RecommendSuccess,
|
||||
RecommendError,
|
||||
MutationRecommendArgs
|
||||
>(async (_, { input }, { claims: { uid }, log, signToken }) => {
|
||||
log.info('Recommend', {
|
||||
input,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recommendResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { input }, { uid, log, signToken }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { id: uid },
|
||||
relations: ['profile'],
|
||||
})
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [RecommendErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const page = await getPageByParam({ _id: input.pageId, userId: uid })
|
||||
if (!page) {
|
||||
const item = await findLibraryItemById(input.pageId, uid)
|
||||
if (!item) {
|
||||
return {
|
||||
errorCodes: [RecommendErrorCode.NotFound],
|
||||
}
|
||||
|
|
@ -205,7 +161,7 @@ export const recommendResolver = authorized<
|
|||
|
||||
// only recommend highlights created by the user
|
||||
const recommendedHighlightIds = input.recommendedWithHighlights
|
||||
? page.highlights?.filter((h) => h.userId === uid)?.map((h) => h.id)
|
||||
? 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
|
||||
|
|
@ -216,18 +172,13 @@ export const recommendResolver = authorized<
|
|||
group.members.map((member) =>
|
||||
enqueueRecommendation(
|
||||
member.user.id,
|
||||
page.id,
|
||||
item.id,
|
||||
{
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
note: input.note ?? null,
|
||||
user: {
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
username: user.profile.username,
|
||||
profileImageURL: user.profile.pictureUrl,
|
||||
},
|
||||
recommendedAt: new Date(),
|
||||
group: { id: group.id },
|
||||
note: input.note,
|
||||
recommender: { id: uid },
|
||||
createdAt: new Date(),
|
||||
libraryItem: { id: item.id },
|
||||
},
|
||||
auth,
|
||||
recommendedHighlightIds
|
||||
|
|
@ -242,14 +193,7 @@ export const recommendResolver = authorized<
|
|||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error recommending', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recommendResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error recommending', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RecommendErrorCode.BadRequest],
|
||||
|
|
@ -261,21 +205,9 @@ export const joinGroupResolver = authorized<
|
|||
JoinGroupSuccess,
|
||||
JoinGroupError,
|
||||
MutationJoinGroupArgs
|
||||
>(async (_, { inviteCode }, { claims: { uid }, log }) => {
|
||||
log.info('Joining group', {
|
||||
inviteCode,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'joinGroupResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { inviteCode }, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { id: uid },
|
||||
relations: ['profile'],
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [JoinGroupErrorCode.Unauthorized],
|
||||
|
|
@ -299,14 +231,7 @@ export const joinGroupResolver = authorized<
|
|||
group,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error joining group', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'joinGroupResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error joining group', error)
|
||||
|
||||
return {
|
||||
errorCodes: [JoinGroupErrorCode.BadRequest],
|
||||
|
|
@ -318,21 +243,9 @@ export const recommendHighlightsResolver = authorized<
|
|||
RecommendHighlightsSuccess,
|
||||
RecommendHighlightsError,
|
||||
MutationRecommendHighlightsArgs
|
||||
>(async (_, { input }, { claims: { uid }, log, signToken }) => {
|
||||
log.info('Recommend highlights', {
|
||||
input,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recommendHighlightsResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { input }, { uid, log, signToken }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { id: uid },
|
||||
relations: ['profile'],
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.Unauthorized],
|
||||
|
|
@ -349,8 +262,8 @@ export const recommendHighlightsResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
const page = await getPageByParam({ _id: input.pageId, userId: uid })
|
||||
if (!page) {
|
||||
const item = await findLibraryItemById(input.pageId, uid)
|
||||
if (!item) {
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.NotFound],
|
||||
}
|
||||
|
|
@ -366,18 +279,13 @@ export const recommendHighlightsResolver = authorized<
|
|||
.map((member) =>
|
||||
enqueueRecommendation(
|
||||
member.user.id,
|
||||
page.id,
|
||||
item.id,
|
||||
{
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
note: input.note,
|
||||
user: {
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
username: user.profile.username,
|
||||
profileImageURL: user.profile.pictureUrl,
|
||||
},
|
||||
recommendedAt: new Date(),
|
||||
recommender: { id: uid },
|
||||
createdAt: new Date(),
|
||||
libraryItem: { id: item.id },
|
||||
},
|
||||
auth,
|
||||
input.highlightIds
|
||||
|
|
@ -391,14 +299,7 @@ export const recommendHighlightsResolver = authorized<
|
|||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error recommending highlights', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'recommendHighlightsResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error recommending highlights', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RecommendHighlightsErrorCode.BadRequest],
|
||||
|
|
@ -410,20 +311,9 @@ export const leaveGroupResolver = authorized<
|
|||
LeaveGroupSuccess,
|
||||
LeaveGroupError,
|
||||
MutationLeaveGroupArgs
|
||||
>(async (_, { groupId }, { claims: { uid }, log }) => {
|
||||
log.info('Leaving group', {
|
||||
groupId,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'leaveGroupResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { groupId }, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [LeaveGroupErrorCode.Unauthorized],
|
||||
|
|
@ -444,14 +334,7 @@ export const leaveGroupResolver = authorized<
|
|||
success,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error leaving group', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'leaveGroupResolver',
|
||||
uid,
|
||||
},
|
||||
})
|
||||
log.error('Error leaving group', error)
|
||||
|
||||
return {
|
||||
errorCodes: [LeaveGroupErrorCode.BadRequest],
|
||||
|
|
|
|||
|
|
@ -1,31 +1,4 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { getPageById } from '../../elastic/pages'
|
||||
import { Page } from '../../elastic/types'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
CreateReminderError,
|
||||
CreateReminderErrorCode,
|
||||
CreateReminderSuccess,
|
||||
DeleteReminderError,
|
||||
DeleteReminderErrorCode,
|
||||
DeleteReminderSuccess,
|
||||
MutationCreateReminderArgs,
|
||||
MutationDeleteReminderArgs,
|
||||
MutationUpdateReminderArgs,
|
||||
QueryReminderArgs,
|
||||
ReminderError,
|
||||
ReminderErrorCode,
|
||||
ReminderSuccess,
|
||||
UpdateReminderError,
|
||||
UpdateReminderErrorCode,
|
||||
UpdateReminderSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { setLinkArchived } from '../../services/archive_link'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { deleteTask, enqueueReminder } from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { DataModels } from '../types'
|
||||
|
||||
const validScheduleTime = (str: string): Date | undefined => {
|
||||
const scheduleTime = DateTime.fromISO(str, { setZone: true }).set({
|
||||
|
|
@ -39,320 +12,320 @@ const validScheduleTime = (str: string): Date | undefined => {
|
|||
return scheduleTime.toJSDate()
|
||||
}
|
||||
|
||||
export const createReminderResolver = authorized<
|
||||
CreateReminderSuccess,
|
||||
CreateReminderError,
|
||||
MutationCreateReminderArgs
|
||||
>(async (_, { input }, { models, claims: { uid }, log }) => {
|
||||
log.info('createReminderResolver')
|
||||
// export const createReminderResolver = authorized<
|
||||
// CreateReminderSuccess,
|
||||
// CreateReminderError,
|
||||
// MutationCreateReminderArgs
|
||||
// >(async (_, { input }, { models, claims: { uid }, log }) => {
|
||||
// log.info('createReminderResolver')
|
||||
|
||||
const { clientRequestId, linkId, archiveUntil, sendNotification } = input
|
||||
// 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 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],
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
// 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)
|
||||
// try {
|
||||
// // saving from web
|
||||
// const page = await getPageById(pageId)
|
||||
// if (!page) {
|
||||
// log.error('page not found', pageId)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateReminderErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// return {
|
||||
// errorCodes: [CreateReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
if (page.userId !== uid) {
|
||||
log.error('user not authorized', uid)
|
||||
// if (page.userId !== uid) {
|
||||
// log.error('user not authorized', uid)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateReminderErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
if (archiveUntil) {
|
||||
await archivePage(uid, page)
|
||||
}
|
||||
// return {
|
||||
// errorCodes: [CreateReminderErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
// if (archiveUntil) {
|
||||
// await archivePage(uid, page)
|
||||
// }
|
||||
|
||||
const taskName = await groupReminders(scheduledTime, uid, models)
|
||||
log.info('scheduled task name', taskName)
|
||||
// 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)
|
||||
// // 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 {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil,
|
||||
// sendNotification,
|
||||
// remindAt: scheduledTime,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.info('error creating reminder', e)
|
||||
|
||||
return {
|
||||
errorCodes: [CreateReminderErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// // 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')
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
// 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,
|
||||
})
|
||||
// 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)
|
||||
// if (!reminder) {
|
||||
// log.error('reminder not found: pageId: ', pageId)
|
||||
|
||||
return {
|
||||
errorCodes: [ReminderErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil: reminder.archiveUntil || false,
|
||||
// sendNotification: reminder.sendNotification || true,
|
||||
// remindAt: reminder.remindAt,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.error(e)
|
||||
|
||||
return {
|
||||
errorCodes: [ReminderErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
// return {
|
||||
// errorCodes: [ReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
export const updateReminderResolver = authorized<
|
||||
UpdateReminderSuccess,
|
||||
UpdateReminderError,
|
||||
MutationUpdateReminderArgs
|
||||
>(async (_, { input }, { models, claims: { uid }, log, authTrx }) => {
|
||||
log.info('updateReminderResolver')
|
||||
// export const updateReminderResolver = authorized<
|
||||
// UpdateReminderSuccess,
|
||||
// UpdateReminderError,
|
||||
// MutationUpdateReminderArgs
|
||||
// >(async (_, { input }, { models, claims: { uid }, log, authTrx }) => {
|
||||
// log.info('updateReminderResolver')
|
||||
|
||||
const { id, archiveUntil, sendNotification } = input
|
||||
// const { id, archiveUntil, sendNotification } = input
|
||||
|
||||
const scheduledTime = validScheduleTime(input.remindAt)
|
||||
if (!scheduledTime) {
|
||||
log.error('Invalid scheduled time', input.remindAt)
|
||||
return {
|
||||
errorCodes: [UpdateReminderErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
// 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)
|
||||
// try {
|
||||
// const reminder = await models.reminder.getCreated(id)
|
||||
|
||||
if (!reminder) {
|
||||
log.error('reminder not found:', id)
|
||||
// if (!reminder) {
|
||||
// log.error('reminder not found:', id)
|
||||
|
||||
return {
|
||||
errorCodes: [UpdateReminderErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// return {
|
||||
// errorCodes: [UpdateReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
if (reminder.userId !== uid) {
|
||||
return {
|
||||
errorCodes: [UpdateReminderErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
// if (reminder.userId !== uid) {
|
||||
// return {
|
||||
// errorCodes: [UpdateReminderErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
|
||||
// delete old google cloud task
|
||||
if (reminder.taskName) {
|
||||
await deleteTask(reminder.taskName)
|
||||
}
|
||||
// // delete old google cloud task
|
||||
// if (reminder.taskName) {
|
||||
// await deleteTask(reminder.taskName)
|
||||
// }
|
||||
|
||||
const taskName = await groupReminders(scheduledTime, uid, models)
|
||||
// const taskName = await groupReminders(scheduledTime, uid, models)
|
||||
|
||||
// update db
|
||||
await authTrx((tx) =>
|
||||
models.reminder.update(
|
||||
id,
|
||||
{
|
||||
taskName: taskName,
|
||||
archiveUntil,
|
||||
sendNotification,
|
||||
remindAt: scheduledTime,
|
||||
},
|
||||
tx
|
||||
)
|
||||
)
|
||||
// // 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 {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil,
|
||||
// sendNotification,
|
||||
// remindAt: scheduledTime,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.error(e)
|
||||
|
||||
return {
|
||||
errorCodes: [UpdateReminderErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
// return {
|
||||
// errorCodes: [UpdateReminderErrorCode.BadRequest],
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
export const deleteReminderResolver = authorized<
|
||||
DeleteReminderSuccess,
|
||||
DeleteReminderError,
|
||||
MutationDeleteReminderArgs
|
||||
>(async (_, { id }, { models, claims: { uid }, log, authTrx }) => {
|
||||
log.info('deleteReminderResolver')
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
// analytics.track({
|
||||
// userId: uid,
|
||||
// event: 'reminder_deleted',
|
||||
// properties: {
|
||||
// id: id,
|
||||
// env: env.server.apiEnv,
|
||||
// },
|
||||
// })
|
||||
|
||||
try {
|
||||
const reminder = await models.reminder.getCreated(id)
|
||||
// try {
|
||||
// const reminder = await models.reminder.getCreated(id)
|
||||
|
||||
if (!reminder) {
|
||||
log.error('reminder not found:', id)
|
||||
// if (!reminder) {
|
||||
// log.error('reminder not found:', id)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteReminderErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// return {
|
||||
// errorCodes: [DeleteReminderErrorCode.NotFound],
|
||||
// }
|
||||
// }
|
||||
|
||||
if (reminder.userId !== uid) {
|
||||
return {
|
||||
errorCodes: [DeleteReminderErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
// if (reminder.userId !== uid) {
|
||||
// return {
|
||||
// errorCodes: [DeleteReminderErrorCode.Unauthorized],
|
||||
// }
|
||||
// }
|
||||
|
||||
// update db
|
||||
await authTrx((tx) => models.reminder.delete(id, tx))
|
||||
// // 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 {
|
||||
// reminder: {
|
||||
// id: reminder.id,
|
||||
// archiveUntil: reminder.archiveUntil || false,
|
||||
// sendNotification: reminder.sendNotification || true,
|
||||
// remindAt: reminder.remindAt,
|
||||
// },
|
||||
// }
|
||||
// } catch (e) {
|
||||
// log.error(e)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteReminderErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
// 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
|
||||
)
|
||||
// // 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())
|
||||
}
|
||||
// if (!exists) {
|
||||
// return enqueueReminder(userId, scheduledTime.getTime())
|
||||
// }
|
||||
|
||||
return undefined
|
||||
}
|
||||
// return undefined
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { authorized } from '../../utils/helpers'
|
||||
import { Rule } from '../../entity/rule'
|
||||
import {
|
||||
DeleteRuleError,
|
||||
DeleteRuleErrorCode,
|
||||
|
|
@ -13,50 +13,28 @@ import {
|
|||
SetRuleErrorCode,
|
||||
SetRuleSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { User } from '../../entity/user'
|
||||
import { Rule } from '../../entity/rule'
|
||||
import { deleteRule } from '../../services/rules'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const setRuleResolver = authorized<
|
||||
SetRuleSuccess,
|
||||
SetRuleError,
|
||||
MutationSetRuleArgs
|
||||
>(async (_, { input }, { claims, log }) => {
|
||||
log.info('Setting rules', {
|
||||
input,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'setRulesResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: claims.uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SetRuleErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const rule = await getRepository(Rule).save({
|
||||
...input,
|
||||
id: input.id || undefined,
|
||||
user: { id: claims.uid },
|
||||
})
|
||||
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,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'setRulesResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
log.error('Error setting rules', error)
|
||||
|
||||
return {
|
||||
errorCodes: [SetRuleErrorCode.BadRequest],
|
||||
|
|
@ -68,41 +46,20 @@ export const rulesResolver = authorized<
|
|||
RulesSuccess,
|
||||
RulesError,
|
||||
QueryRulesArgs
|
||||
>(async (_, { enabled }, { claims, log }) => {
|
||||
log.info('Getting rules', {
|
||||
enabled,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'rulesResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { enabled }, { authTrx, log, uid }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: claims.uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [RulesErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const rules = await getRepository(Rule).findBy({
|
||||
user: { id: claims.uid },
|
||||
enabled: enabled === null ? undefined : enabled,
|
||||
})
|
||||
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,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'rulesResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
log.error('Error getting rules', error)
|
||||
|
||||
return {
|
||||
errorCodes: [RulesErrorCode.BadRequest],
|
||||
|
|
@ -114,46 +71,18 @@ export const deleteRuleResolver = authorized<
|
|||
DeleteRuleSuccess,
|
||||
DeleteRuleError,
|
||||
MutationDeleteRuleArgs
|
||||
>(async (_, { id }, { claims, log }) => {
|
||||
log.info('Deleting rule', {
|
||||
id,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteRuleResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_, { id }, { uid, log }) => {
|
||||
try {
|
||||
const rule = await getRepository(Rule).findOneBy({
|
||||
id,
|
||||
user: { id: claims.uid },
|
||||
})
|
||||
if (!rule) {
|
||||
return {
|
||||
errorCodes: [DeleteRuleErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
await getRepository(Rule).delete({
|
||||
id: rule.id,
|
||||
})
|
||||
const rule = await deleteRule(id, uid)
|
||||
|
||||
return {
|
||||
rule,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error deleting rule', {
|
||||
error,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteRuleResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
log.error('Error deleting rule', error)
|
||||
|
||||
return {
|
||||
errorCodes: [DeleteRuleErrorCode.BadRequest],
|
||||
errorCodes: [DeleteRuleErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MutationSaveFileArgs,
|
||||
|
|
@ -9,23 +7,20 @@ import {
|
|||
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, userDataToUser } from '../../utils/helpers'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const savePageResolver = authorized<
|
||||
SaveSuccess,
|
||||
SaveError,
|
||||
MutationSavePageArgs
|
||||
>(async (_, { input }, ctx) => {
|
||||
const {
|
||||
models,
|
||||
claims: { uid },
|
||||
} = ctx
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
userId: ctx.uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: input.url,
|
||||
|
|
@ -35,16 +30,14 @@ export const savePageResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const user = userDataToUser(await models.user.get(uid))
|
||||
const user = await userRepository.findOneBy({
|
||||
id: ctx.uid,
|
||||
})
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
return savePage(
|
||||
{ ...ctx, uid, refresh: true },
|
||||
{ userId: user.id, username: user.profile.username },
|
||||
input
|
||||
)
|
||||
return savePage(input, user)
|
||||
})
|
||||
|
||||
export const saveUrlResolver = authorized<
|
||||
|
|
@ -67,14 +60,14 @@ export const saveUrlResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const user = await getRepository(User).findOneBy({
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
return (await saveUrl({ ...ctx, uid }, user, input)) as SaveSuccess
|
||||
return saveUrl(input, user)
|
||||
})
|
||||
|
||||
export const saveFileResolver = authorized<
|
||||
|
|
@ -82,13 +75,8 @@ export const saveFileResolver = authorized<
|
|||
SaveError,
|
||||
MutationSaveFileArgs
|
||||
>(async (_, { input }, ctx) => {
|
||||
const {
|
||||
models,
|
||||
claims: { uid },
|
||||
} = ctx
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
userId: ctx.uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: input.url,
|
||||
|
|
@ -98,10 +86,12 @@ export const saveFileResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const user = userDataToUser(await models.user.get(uid))
|
||||
const user = await userRepository.findOneBy({
|
||||
id: ctx.uid,
|
||||
})
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
return (await saveFile({ ...ctx, uid }, user, input)) as SaveSuccess
|
||||
return saveFile(input, user)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { User } from '../../entity/user'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
SendInstallInstructionsError,
|
||||
SendInstallInstructionsErrorCode,
|
||||
SendInstallInstructionsSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { sendEmail } from '../../utils/sendEmail'
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ export const sendInstallInstructionsResolver = authorized<
|
|||
SendInstallInstructionsError
|
||||
>(async (_parent, _args, { claims, log }) => {
|
||||
try {
|
||||
const user = await AppDataSource.getRepository(User).findOneBy({
|
||||
const user = await userRepository.findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import Parser from 'rss-parser'
|
||||
import { Brackets } from 'typeorm'
|
||||
import { Subscription } from '../../entity/subscription'
|
||||
import { User } from '../../entity/user'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
MutationSubscribeArgs,
|
||||
|
|
@ -26,8 +24,8 @@ import {
|
|||
UpdateSubscriptionErrorCode,
|
||||
UpdateSubscriptionSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { getSubscribeHandler, unsubscribe } from '../../services/subscriptions'
|
||||
import { getRepository } from '../../repository'
|
||||
import { unsubscribe } from '../../services/subscriptions'
|
||||
import { Merge } from '../../util'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { enqueueRssFeedFetch } from '../../utils/createTask'
|
||||
|
|
@ -55,27 +53,11 @@ export const subscriptionsResolver = authorized<
|
|||
SubscriptionsSuccessPartial,
|
||||
SubscriptionsError,
|
||||
QuerySubscriptionsArgs
|
||||
>(async (_obj, { sort, type }, { claims: { uid }, log }) => {
|
||||
log.info('subscriptionsResolver')
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'subscriptions',
|
||||
properties: {
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
>(async (_obj, { sort, type }, { uid, log }) => {
|
||||
try {
|
||||
const sortBy =
|
||||
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
|
||||
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SubscriptionsErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const queryBuilder = getRepository(Subscription)
|
||||
.createQueryBuilder('subscription')
|
||||
|
|
@ -107,7 +89,7 @@ export const subscriptionsResolver = authorized<
|
|||
}
|
||||
|
||||
const subscriptions = await queryBuilder
|
||||
.orderBy('subscription.' + sortBy, sortOrder)
|
||||
.orderBy(`subscription.${sortBy}`, sortOrder, 'NULLS LAST')
|
||||
.getMany()
|
||||
|
||||
return {
|
||||
|
|
@ -129,17 +111,10 @@ export const unsubscribeResolver = authorized<
|
|||
UnsubscribeSuccessPartial,
|
||||
UnsubscribeError,
|
||||
MutationUnsubscribeArgs
|
||||
>(async (_, { name, subscriptionId }, { claims: { uid }, log }) => {
|
||||
>(async (_, { name, subscriptionId }, { uid, log }) => {
|
||||
log.info('unsubscribeResolver')
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UnsubscribeErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const queryBuilder = getRepository(Subscription)
|
||||
.createQueryBuilder('subscription')
|
||||
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
|
||||
|
|
@ -154,19 +129,13 @@ export const unsubscribeResolver = authorized<
|
|||
}
|
||||
|
||||
const subscription = await queryBuilder.getOne()
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
errorCodes: [UnsubscribeErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
// if subscription is already unsubscribed, throw error
|
||||
if (subscription.status === SubscriptionStatus.Unsubscribed) {
|
||||
return {
|
||||
errorCodes: [UnsubscribeErrorCode.AlreadyUnsubscribed],
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
subscription.type === SubscriptionType.Newsletter &&
|
||||
!subscription.unsubscribeMailTo &&
|
||||
|
|
@ -205,25 +174,20 @@ export const subscribeResolver = authorized<
|
|||
SubscribeSuccessPartial,
|
||||
SubscribeError,
|
||||
MutationSubscribeArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
log.info('subscribeResolver')
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
// find existing subscription
|
||||
const subscription = await getRepository(Subscription).findOneBy({
|
||||
url: input.url || undefined,
|
||||
name: input.name || undefined,
|
||||
user: { id: uid },
|
||||
status: SubscriptionStatus.Active,
|
||||
type: input.subscriptionType || SubscriptionType.Rss, // default to rss
|
||||
})
|
||||
const subscription = await authTrx((t) =>
|
||||
t.getRepository(Subscription).findOneBy({
|
||||
url: input.url || undefined,
|
||||
name: input.name || undefined,
|
||||
user: { id: uid },
|
||||
status: SubscriptionStatus.Active,
|
||||
type: input.subscriptionType || SubscriptionType.Rss, // default to rss
|
||||
})
|
||||
)
|
||||
if (subscription) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.AlreadySubscribed],
|
||||
|
|
@ -239,30 +203,6 @@ export const subscribeResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
// create new newsletter subscription
|
||||
if (input.name && input.subscriptionType === SubscriptionType.Newsletter) {
|
||||
const subscribeHandler = getSubscribeHandler(input.name)
|
||||
if (!subscribeHandler) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const newSubscriptions = await subscribeHandler.handleSubscribe(
|
||||
uid,
|
||||
input.name
|
||||
)
|
||||
if (!newSubscriptions) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptions: newSubscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
// create new rss subscription
|
||||
if (input.url) {
|
||||
const MAX_RSS_SUBSCRIPTIONS = 150
|
||||
|
|
@ -270,21 +210,23 @@ export const subscribeResolver = authorized<
|
|||
const feed = await parser.parseURL(input.url)
|
||||
|
||||
// limit number of rss subscriptions to 50
|
||||
const newSubscriptions = (await AppDataSource.query(
|
||||
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon)
|
||||
const newSubscriptions = (await authTrx((t) =>
|
||||
t.query(
|
||||
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon)
|
||||
select $1, $2, $3, $4, $5, $6 from omnivore.subscriptions
|
||||
where user_id = $5 and type = 'RSS' and status = 'ACTIVE'
|
||||
having count(*) < $7
|
||||
returning *;`,
|
||||
[
|
||||
feed.title,
|
||||
input.url,
|
||||
feed.description || null,
|
||||
SubscriptionType.Rss,
|
||||
uid,
|
||||
feed.image?.url || null,
|
||||
MAX_RSS_SUBSCRIPTIONS,
|
||||
]
|
||||
[
|
||||
feed.title,
|
||||
input.url,
|
||||
feed.description || null,
|
||||
SubscriptionType.Rss,
|
||||
uid,
|
||||
feed.image?.url || null,
|
||||
MAX_RSS_SUBSCRIPTIONS,
|
||||
]
|
||||
)
|
||||
)) as Subscription[]
|
||||
|
||||
if (newSubscriptions.length === 0) {
|
||||
|
|
@ -326,9 +268,7 @@ export const updateSubscriptionResolver = authorized<
|
|||
UpdateSubscriptionSuccessPartial,
|
||||
UpdateSubscriptionError,
|
||||
MutationUpdateSubscriptionArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
log.info('updateSubscriptionResolver')
|
||||
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
|
|
@ -339,34 +279,24 @@ export const updateSubscriptionResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UpdateSubscriptionErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
const updatedSubscription = await authTrx(async (t) => {
|
||||
const repo = t.getRepository(Subscription)
|
||||
|
||||
// find existing subscription
|
||||
const subscription = await getRepository(Subscription).findOneBy({
|
||||
id: input.id,
|
||||
user: { id: uid },
|
||||
})
|
||||
if (!subscription) {
|
||||
log.info('subscription not found')
|
||||
return {
|
||||
errorCodes: [UpdateSubscriptionErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
status: input.status || undefined,
|
||||
})
|
||||
|
||||
// update subscription
|
||||
const updatedSubscription = await getRepository(Subscription).save({
|
||||
id: input.id,
|
||||
name: input.name || undefined,
|
||||
description: input.description || undefined,
|
||||
lastFetchedAt: input.lastFetchedAt
|
||||
? new Date(input.lastFetchedAt)
|
||||
: undefined,
|
||||
status: input.status || undefined,
|
||||
return repo.findOneByOrFail({
|
||||
id: input.id,
|
||||
user: { id: uid },
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,10 @@
|
|||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import { Context as ApolloContext } from 'apollo-server-core'
|
||||
import winston from 'winston'
|
||||
import { Knex } from 'knex'
|
||||
import UserModel from '../datalayer/user'
|
||||
import ArticleModel from '../datalayer/article'
|
||||
import UserArticleModel from '../datalayer/links'
|
||||
import UserFriendModel from '../datalayer/user_friends'
|
||||
import UserPersonalizationModel from '../datalayer/user_personalization'
|
||||
import ArticleSavingRequestModel from '../datalayer/article_saving_request'
|
||||
import UploadFileDataModel from '../datalayer/upload_files'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { Span } from '@opentelemetry/api'
|
||||
import HighlightModel from '../datalayer/highlight'
|
||||
import ReactionModel from '../datalayer/reaction'
|
||||
import { PubsubClient } from '../datalayer/pubsub'
|
||||
import ReminderModel from '../datalayer/reminders'
|
||||
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
|
||||
|
|
@ -30,35 +20,19 @@ export type ClaimsToSet = {
|
|||
userRole?: string | null
|
||||
}
|
||||
|
||||
export type DataModels = {
|
||||
user: UserModel
|
||||
article: ArticleModel
|
||||
userArticle: UserArticleModel
|
||||
userFriends: UserFriendModel
|
||||
userPersonalization: UserPersonalizationModel
|
||||
articleSavingRequest: ArticleSavingRequestModel
|
||||
uploadFile: UploadFileDataModel
|
||||
highlight: HighlightModel
|
||||
reaction: ReactionModel
|
||||
reminder: ReminderModel
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
log: winston.Logger
|
||||
claims: Claims | undefined
|
||||
kx: Knex
|
||||
pubsub: PubsubClient
|
||||
models: DataModels
|
||||
setAuth: (claims: ClaimsToSet, secret?: string) => Promise<void>
|
||||
clearAuth: () => void
|
||||
setClaims: (tx: Knex.Transaction, uuid?: string | undefined) => Promise<void>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
setClaims: (em: EntityManager, uuid?: string | undefined) => Promise<void>
|
||||
signToken: (
|
||||
arg1: string | object | Buffer,
|
||||
arg2: jwt.Secret
|
||||
) => Promise<unknown>
|
||||
authTrx: <TResult>(
|
||||
cb: (tx: Knex.Transaction) => TResult,
|
||||
cb: (em: EntityManager) => TResult,
|
||||
userRole?: string
|
||||
) => Promise<TResult>
|
||||
tracingSpan: Span
|
||||
|
|
|
|||
|
|
@ -1,61 +1,33 @@
|
|||
import { getPageById, updatePage } from '../../elastic/pages'
|
||||
import { Page } from '../../elastic/types'
|
||||
import { LibraryItemState } from '../../entity/library_item'
|
||||
import {
|
||||
MutationUpdatePageArgs,
|
||||
UpdatePageError,
|
||||
UpdatePageErrorCode,
|
||||
UpdatePageSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { Merge } from '../../util'
|
||||
import { authorized, userDataToUser } from '../../utils/helpers'
|
||||
|
||||
export type UpdatePageSuccessPartial = Merge<
|
||||
UpdatePageSuccess,
|
||||
{ updatedPage: Partial<Page> }
|
||||
>
|
||||
import { updateLibraryItem } from '../../services/library_item'
|
||||
import { authorized, libraryItemToArticle } from '../../utils/helpers'
|
||||
|
||||
export const updatePageResolver = authorized<
|
||||
UpdatePageSuccessPartial,
|
||||
UpdatePageSuccess,
|
||||
UpdatePageError,
|
||||
MutationUpdatePageArgs
|
||||
>(async (_, { input }, ctx) => {
|
||||
const {
|
||||
models,
|
||||
claims: { uid },
|
||||
} = ctx
|
||||
|
||||
const user = userDataToUser(await models.user.get(uid))
|
||||
if (!user) {
|
||||
return { errorCodes: [UpdatePageErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
const page = await getPageById(input.pageId)
|
||||
|
||||
if (!page) return { errorCodes: [UpdatePageErrorCode.NotFound] }
|
||||
else if (page.userId !== user.id)
|
||||
return { errorCodes: [UpdatePageErrorCode.Unauthorized] }
|
||||
|
||||
const pageData = {
|
||||
id: 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,
|
||||
image: input.previewImage ?? undefined,
|
||||
state: input.state ?? undefined,
|
||||
}
|
||||
|
||||
const updateResult = await updatePage(input.pageId, pageData, {
|
||||
pubsub: ctx.pubsub,
|
||||
uid,
|
||||
refresh: true,
|
||||
})
|
||||
if (!updateResult) return { errorCodes: [UpdatePageErrorCode.UpdateFailed] }
|
||||
|
||||
const updatedPage = (await getPageById(input.pageId)) as unknown as Page
|
||||
>(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: updatedPage,
|
||||
__typename: 'UpdatePageSuccess',
|
||||
updatedPage: libraryItemToArticle(updatedPage),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,56 +1,56 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import normalizeUrl from 'normalize-url'
|
||||
import path from 'path'
|
||||
import { createPage, getPageByParam, updatePage } from '../../elastic/pages'
|
||||
import { PageType } from '../../elastic/types'
|
||||
import { LibraryItemState } from '../../entity/library_item'
|
||||
import { UploadFile } from '../../entity/upload_file'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
ArticleSavingRequestStatus,
|
||||
MutationUploadFileRequestArgs,
|
||||
ResolverFn,
|
||||
PageType,
|
||||
UploadFileRequestError,
|
||||
UploadFileRequestErrorCode,
|
||||
UploadFileRequestResult,
|
||||
UploadFileRequestSuccess,
|
||||
UploadFileStatus,
|
||||
} from '../../generated/graphql'
|
||||
import { validateUrl } from '../../services/create_page_save_request'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { generateSlug } from '../../utils/helpers'
|
||||
import {
|
||||
createLibraryItem,
|
||||
findLibraryItemByUrl,
|
||||
updateLibraryItem,
|
||||
} from '../../services/library_item'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized, generateSlug } from '../../utils/helpers'
|
||||
import {
|
||||
contentReaderForLibraryItem,
|
||||
generateUploadFilePathName,
|
||||
generateUploadSignedUrl,
|
||||
getFilePublicUrl,
|
||||
} from '../../utils/uploads'
|
||||
import { WithDataSourcesContext } from '../types'
|
||||
|
||||
const isFileUrl = (url: string): boolean => {
|
||||
const parsedUrl = new URL(url)
|
||||
return parsedUrl.protocol == 'file:'
|
||||
}
|
||||
|
||||
export const pageTypeForContentType = (contentType: string): PageType => {
|
||||
export const itemTypeForContentType = (contentType: string) => {
|
||||
if (contentType == 'application/epub+zip') {
|
||||
return PageType.Book
|
||||
}
|
||||
return PageType.File
|
||||
}
|
||||
|
||||
export const uploadFileRequestResolver: ResolverFn<
|
||||
UploadFileRequestResult,
|
||||
unknown,
|
||||
WithDataSourcesContext,
|
||||
export const uploadFileRequestResolver = authorized<
|
||||
UploadFileRequestSuccess,
|
||||
UploadFileRequestError,
|
||||
MutationUploadFileRequestArgs
|
||||
> = async (_obj, { input }, ctx) => {
|
||||
const { models, authTrx, claims, log } = ctx
|
||||
>(async (_, { input }, ctx) => {
|
||||
const { authTrx, uid, log } = ctx
|
||||
let uploadFileData: { id: string | null } = {
|
||||
id: null,
|
||||
}
|
||||
|
||||
if (!claims?.uid) {
|
||||
return { errorCodes: [UploadFileRequestErrorCode.Unauthorized] }
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
userId: uid,
|
||||
event: 'file_upload_request',
|
||||
properties: {
|
||||
url: input.url,
|
||||
|
|
@ -89,13 +89,15 @@ export const uploadFileRequestResolver: ResolverFn<
|
|||
return { errorCodes: [UploadFileRequestErrorCode.BadInput] }
|
||||
}
|
||||
|
||||
uploadFileData = await models.uploadFile.create({
|
||||
url: input.url,
|
||||
userId: claims.uid,
|
||||
fileName: fileName,
|
||||
status: UploadFileStatus.Initialized,
|
||||
contentType: input.contentType,
|
||||
})
|
||||
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
|
||||
|
|
@ -113,76 +115,56 @@ export const uploadFileRequestResolver: ResolverFn<
|
|||
// If this is a file URL, we swap in the GCS public URL
|
||||
if (isFileUrl(input.url)) {
|
||||
await authTrx(async (tx) => {
|
||||
await models.uploadFile.update(
|
||||
uploadFileId,
|
||||
{
|
||||
url: publicUrl,
|
||||
status: UploadFileStatus.Initialized,
|
||||
},
|
||||
tx
|
||||
)
|
||||
await tx.getRepository(UploadFile).update(uploadFileId, {
|
||||
url: publicUrl,
|
||||
status: UploadFileStatus.Initialized,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let createdPageId: string | undefined = undefined
|
||||
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 page, just create a
|
||||
// and create a copy of the item, just create a
|
||||
// new item.
|
||||
const page = isFileUrl(input.url)
|
||||
? await getPageByParam({
|
||||
userId: claims.uid,
|
||||
url: input.url,
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (page) {
|
||||
if (
|
||||
!(await updatePage(
|
||||
page.id,
|
||||
{
|
||||
savedAt: new Date(),
|
||||
archivedAt: null,
|
||||
},
|
||||
ctx
|
||||
))
|
||||
) {
|
||||
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
|
||||
}
|
||||
createdPageId = page.id
|
||||
} else {
|
||||
const pageId = await createPage(
|
||||
const item = await findLibraryItemByUrl(input.url, uid)
|
||||
if (item) {
|
||||
await updateLibraryItem(
|
||||
item.id,
|
||||
{
|
||||
url: isFileUrl(input.url) ? publicUrl : input.url,
|
||||
id: input.clientRequestId || '',
|
||||
userId: claims.uid,
|
||||
title: title,
|
||||
hash: uploadFilePathName,
|
||||
content: '',
|
||||
pageType: pageTypeForContentType(input.contentType),
|
||||
uploadFileId: uploadFileData.id,
|
||||
slug: generateSlug(uploadFilePathName),
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
state: LibraryItemState.Processing,
|
||||
},
|
||||
ctx
|
||||
uid
|
||||
)
|
||||
if (!pageId) {
|
||||
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
|
||||
}
|
||||
createdPageId = pageId
|
||||
createdItemId = item.id
|
||||
} else {
|
||||
const itemType = itemTypeForContentType(input.contentType)
|
||||
const uploadFileId = uploadFileData.id
|
||||
const item = await createLibraryItem(
|
||||
{
|
||||
originalUrl: isFileUrl(input.url) ? publicUrl : input.url,
|
||||
id: input.clientRequestId || undefined,
|
||||
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: createdPageId,
|
||||
createdPageId: createdItemId,
|
||||
}
|
||||
} else {
|
||||
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import * as jwt from 'jsonwebtoken'
|
||||
import { RegistrationType } from '../../datalayer/user/model'
|
||||
import { deletePagesByParam } from '../../elastic/pages'
|
||||
import { User as UserEntity } from '../../entity/user'
|
||||
import { getRepository, setClaims } from '../../entity/utils'
|
||||
import { RegistrationType, User as UserEntity } from '../../entity/user'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteAccountError,
|
||||
|
|
@ -38,7 +35,7 @@ import {
|
|||
UsersError,
|
||||
UsersSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { createUser } from '../../services/create_user'
|
||||
import { sendVerificationEmail } from '../../services/send_emails'
|
||||
import { authorized, userDataToUser } from '../../utils/helpers'
|
||||
|
|
@ -49,8 +46,10 @@ export const updateUserResolver = authorized<
|
|||
UpdateUserSuccess,
|
||||
UpdateUserError,
|
||||
MutationUpdateUserArgs
|
||||
>(async (_, { input: { name, bio } }, { models, authTrx, claims }) => {
|
||||
const user = await models.user.get(claims.uid)
|
||||
>(async (_, { input: { name, bio } }, { uid, authTrx }) => {
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
if (!user) {
|
||||
return { errorCodes: [UpdateUserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
|
@ -69,17 +68,16 @@ export const updateUserResolver = authorized<
|
|||
return { errorCodes }
|
||||
}
|
||||
|
||||
const updatedUser = await authTrx(async (tx) => {
|
||||
const [updatedRecord, profile] = await Promise.all([
|
||||
models.user.update(
|
||||
claims.uid,
|
||||
{ name, source: user.source, sourceUserId: user.sourceUserId },
|
||||
tx
|
||||
),
|
||||
models.user.updateProfile(claims.uid, { bio }, tx),
|
||||
])
|
||||
return { ...updatedRecord, profile }
|
||||
})
|
||||
const updatedUser = await authTrx((tx) =>
|
||||
tx.getRepository(UserEntity).save({
|
||||
...user,
|
||||
name,
|
||||
profile: {
|
||||
...user.profile,
|
||||
bio,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return { user: userDataToUser(updatedUser) }
|
||||
})
|
||||
|
|
@ -88,69 +86,66 @@ export const updateUserProfileResolver = authorized<
|
|||
UpdateUserProfileSuccess,
|
||||
UpdateUserProfileError,
|
||||
MutationUpdateUserProfileArgs
|
||||
>(
|
||||
async (
|
||||
_,
|
||||
{ input: { userId, username, pictureUrl } },
|
||||
{ models, authTrx, claims }
|
||||
) => {
|
||||
const user = await models.user.get(userId)
|
||||
if (user.id !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.Forbidden],
|
||||
}
|
||||
}
|
||||
|
||||
if (!(username || pictureUrl)) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.BadData],
|
||||
}
|
||||
}
|
||||
|
||||
const lowerCasedUsername = username?.toLowerCase()
|
||||
if (lowerCasedUsername) {
|
||||
const existingUser = await models.user.getWhere({
|
||||
username: lowerCasedUsername,
|
||||
})
|
||||
if (existingUser?.id) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.UsernameExists],
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateUsername(lowerCasedUsername)) {
|
||||
return {
|
||||
errorCodes: [UpdateUserProfileErrorCode.BadUsername],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedProfile = await authTrx((tx) =>
|
||||
models.user.updateProfile(
|
||||
userId,
|
||||
{
|
||||
username: lowerCasedUsername,
|
||||
picture_url: pictureUrl,
|
||||
},
|
||||
tx
|
||||
)
|
||||
)
|
||||
|
||||
user.profile = {
|
||||
...updatedProfile,
|
||||
picture_url: updatedProfile.pictureUrl,
|
||||
}
|
||||
|
||||
return { user: userDataToUser(user) }
|
||||
>(async (_, { input: { userId, username, pictureUrl } }, { uid, authTrx }) => {
|
||||
const user = await userRepository.findOneBy({
|
||||
id: 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,
|
||||
},
|
||||
})
|
||||
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 }, { models, setAuth }) => {
|
||||
> = async (_obj, { input }, { setAuth }) => {
|
||||
const { email, secret } = input
|
||||
|
||||
try {
|
||||
|
|
@ -159,7 +154,7 @@ export const googleLoginResolver: ResolverFn<
|
|||
return { errorCodes: [LoginErrorCode.AuthFailed] }
|
||||
}
|
||||
|
||||
const user = await models.user.getWhere({
|
||||
const user = await userRepository.findOneBy({
|
||||
email,
|
||||
})
|
||||
if (!user?.id) {
|
||||
|
|
@ -176,13 +171,18 @@ export const validateUsernameResolver: ResolverFn<
|
|||
Record<string, unknown>,
|
||||
WithDataSourcesContext,
|
||||
QueryValidateUsernameArgs
|
||||
> = async (_obj, { username }, { models }) => {
|
||||
> = async (_obj, { username }) => {
|
||||
const lowerCasedUsername = username.toLowerCase()
|
||||
if (!validateUsername(lowerCasedUsername)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(await models.user.exists({ username: lowerCasedUsername }))
|
||||
const user = await userRepository.findOneBy({
|
||||
profile: {
|
||||
username: lowerCasedUsername,
|
||||
},
|
||||
})
|
||||
return !user
|
||||
}
|
||||
|
||||
export const googleSignupResolver: ResolverFn<
|
||||
|
|
@ -245,11 +245,20 @@ export const getMeUserResolver: ResolverFn<
|
|||
unknown,
|
||||
WithDataSourcesContext,
|
||||
unknown
|
||||
> = async (_obj, __, { models, claims }) => {
|
||||
> = async (_obj, __, { claims }) => {
|
||||
try {
|
||||
return claims?.uid
|
||||
? userDataToUser(await models.user.get(claims.uid))
|
||||
: undefined
|
||||
if (!claims?.uid) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const user = await userRepository.findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
if (!user) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return userDataToUser(user)
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -260,18 +269,20 @@ export const getUserResolver: ResolverFn<
|
|||
unknown,
|
||||
WithDataSourcesContext,
|
||||
QueryUserArgs
|
||||
> = async (_obj, { userId: id, username }, { models, claims }) => {
|
||||
> = async (_obj, { userId: id, username }, { uid }) => {
|
||||
if (!(id || username)) {
|
||||
return { errorCodes: [UserErrorCode.BadRequest] }
|
||||
}
|
||||
|
||||
const userId =
|
||||
id || (username && (await models.user.getWhere({ username }))?.id)
|
||||
id ||
|
||||
(username &&
|
||||
(await userRepository.findOneBy({ profile: { username } }))?.id)
|
||||
if (!userId) {
|
||||
return { errorCodes: [UserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
||||
const userRecord = await models.user.getUserDetails(claims?.uid, userId)
|
||||
const userRecord = await userRepository.findOneBy({ id: userId })
|
||||
if (!userRecord) {
|
||||
return { errorCodes: [UserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
|
@ -280,9 +291,8 @@ export const getUserResolver: ResolverFn<
|
|||
}
|
||||
|
||||
export const getAllUsersResolver = authorized<UsersSuccess, UsersError>(
|
||||
async (_obj, _params, { models, claims, authTrx }) => {
|
||||
const users =
|
||||
(await authTrx((tx) => models.user.getTopUsers(claims.uid, tx))) || []
|
||||
async (_obj, _params) => {
|
||||
const users = await userRepository.findTopUsers()
|
||||
const result = { users: users.map((userData) => userDataToUser(userData)) }
|
||||
return result
|
||||
}
|
||||
|
|
@ -303,32 +313,9 @@ export const deleteAccountResolver = authorized<
|
|||
DeleteAccountSuccess,
|
||||
DeleteAccountError,
|
||||
MutationDeleteAccountArgs
|
||||
>(async (_, { userID }, { models, claims, log, pubsub }) => {
|
||||
const user = await models.user.get(userID)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [DeleteAccountErrorCode.UserNotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (user.id !== claims.uid) {
|
||||
return {
|
||||
errorCodes: [DeleteAccountErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Deleting a user account', {
|
||||
userID,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'deleteAccountResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await AppDataSource.transaction(async (t) => {
|
||||
await setClaims(t, claims.uid)
|
||||
return t.getRepository(UserEntity).delete(userID)
|
||||
>(async (_, { userID }, { authTrx, log }) => {
|
||||
const result = await authTrx(async (t) => {
|
||||
return t.withRepository(userRepository).delete(userID)
|
||||
})
|
||||
if (!result.affected) {
|
||||
log.error('Error deleting user account')
|
||||
|
|
@ -338,9 +325,6 @@ export const deleteAccountResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
// delete this user's pages in elastic
|
||||
await deletePagesByParam({ userId: userID }, { uid: userID, pubsub })
|
||||
|
||||
return { userID }
|
||||
})
|
||||
|
||||
|
|
@ -348,9 +332,9 @@ export const updateEmailResolver = authorized<
|
|||
UpdateEmailSuccess,
|
||||
UpdateEmailError,
|
||||
MutationUpdateEmailArgs
|
||||
>(async (_, { input: { email } }, { uid, log }) => {
|
||||
>(async (_, { input: { email } }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(UserEntity).findOneBy({
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
|
||||
|
|
@ -361,12 +345,11 @@ export const updateEmailResolver = authorized<
|
|||
}
|
||||
|
||||
if (user.source === RegistrationType.Email) {
|
||||
await AppDataSource.transaction(async (entityManager) => {
|
||||
await setClaims(entityManager, user.id)
|
||||
return entityManager.getRepository(UserEntity).update(user.id, {
|
||||
await authTrx(async (tx) =>
|
||||
tx.withRepository(userRepository).update(user.id, {
|
||||
email,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return { email }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ import {
|
|||
import {
|
||||
createDeviceToken,
|
||||
deleteDeviceToken,
|
||||
getDeviceToken,
|
||||
getDeviceTokenByToken,
|
||||
getDeviceTokensByUserId,
|
||||
findDeviceTokenById,
|
||||
findDeviceTokenByToken,
|
||||
findDeviceTokensByUserId,
|
||||
} from '../../services/user_device_tokens'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
|
@ -28,13 +28,11 @@ export const setDeviceTokenResolver = authorized<
|
|||
SetDeviceTokenSuccess,
|
||||
SetDeviceTokenError,
|
||||
MutationSetDeviceTokenArgs
|
||||
>(async (_parent, { input }, { claims: { uid }, log }) => {
|
||||
log.info('setDeviceTokenResolver', input)
|
||||
|
||||
>(async (_parent, { input }, { uid, log }) => {
|
||||
const { id, token } = input
|
||||
|
||||
if (!id && !token) {
|
||||
log.info('id or token is required')
|
||||
log.error('id or token is required')
|
||||
|
||||
return {
|
||||
errorCodes: [SetDeviceTokenErrorCode.BadRequest],
|
||||
|
|
@ -44,7 +42,7 @@ export const setDeviceTokenResolver = authorized<
|
|||
try {
|
||||
// when token is null, we are deleting it
|
||||
if (!token && id) {
|
||||
const deviceToken = await getDeviceToken(id)
|
||||
const deviceToken = await findDeviceTokenById(id, uid)
|
||||
if (!deviceToken) {
|
||||
log.error('device token not found', id)
|
||||
|
||||
|
|
@ -107,7 +105,7 @@ export const setDeviceTokenResolver = authorized<
|
|||
token
|
||||
) {
|
||||
// duplicate token
|
||||
const deviceToken = await getDeviceTokenByToken(token)
|
||||
const deviceToken = await findDeviceTokenByToken(token, uid)
|
||||
|
||||
if (!deviceToken) {
|
||||
return {
|
||||
|
|
@ -139,7 +137,7 @@ export const deviceTokensResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const deviceTokens = await getDeviceTokensByUserId(uid)
|
||||
const deviceTokens = await findDeviceTokensByUserId(uid)
|
||||
log.info('deviceTokens', deviceTokens)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { PartialArticle } from '..'
|
||||
import { getShareInfoForArticle } from '../../datalayer/links/share_info'
|
||||
import {
|
||||
FeedArticle,
|
||||
MutationUpdateSharedCommentArgs,
|
||||
PageInfo,
|
||||
QueryFeedArticlesArgs,
|
||||
QuerySharedArticleArgs,
|
||||
ResolverFn,
|
||||
SharedArticleError,
|
||||
SharedArticleErrorCode,
|
||||
SharedArticleSuccess,
|
||||
UpdateSharedCommentError,
|
||||
UpdateSharedCommentErrorCode,
|
||||
UpdateSharedCommentSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { Merge } from '../../util'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { WithDataSourcesContext } from '../types'
|
||||
import { FeedArticle, PageInfo } from '../../generated/graphql'
|
||||
|
||||
export type PartialFeedArticle = Omit<
|
||||
FeedArticle,
|
||||
|
|
@ -30,144 +12,139 @@ type PaginatedFeedArticlesSuccessPartial = {
|
|||
pageInfo: PageInfo
|
||||
}
|
||||
|
||||
export type SharedArticleSuccessPartial = Merge<
|
||||
SharedArticleSuccess,
|
||||
{ article: PartialArticle }
|
||||
>
|
||||
// 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],
|
||||
// }
|
||||
// }
|
||||
|
||||
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],
|
||||
// }
|
||||
// }
|
||||
|
||||
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],
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
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
|
||||
// )
|
||||
|
||||
const shareInfo = await getShareInfoForArticle(
|
||||
kx,
|
||||
user.id,
|
||||
article.id,
|
||||
models
|
||||
)
|
||||
// return { article: { ...article, userId: user.id, shareInfo: shareInfo } }
|
||||
// } catch (error) {
|
||||
// return { errorCodes: [SharedArticleErrorCode.NotFound] }
|
||||
// }
|
||||
// }
|
||||
|
||||
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,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
|
||||
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 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 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
|
||||
|
||||
const endCursor = feedArticles[feedArticles.length - 1]?.sharedAt
|
||||
.getTime()
|
||||
?.toString()
|
||||
const hasNextPage = feedArticles.length > first
|
||||
// if (hasNextPage) {
|
||||
// // remove an extra if exists
|
||||
// feedArticles.pop()
|
||||
// }
|
||||
|
||||
if (hasNextPage) {
|
||||
// remove an extra if exists
|
||||
feedArticles.pop()
|
||||
}
|
||||
// const edges = feedArticles.map((fa) => {
|
||||
// return {
|
||||
// node: fa,
|
||||
// cursor: fa.sharedAt.getTime()?.toString(),
|
||||
// }
|
||||
// })
|
||||
|
||||
const edges = feedArticles.map((fa) => {
|
||||
return {
|
||||
node: fa,
|
||||
cursor: fa.sharedAt.getTime()?.toString(),
|
||||
}
|
||||
})
|
||||
// return {
|
||||
// edges,
|
||||
// pageInfo: {
|
||||
// hasPreviousPage: false,
|
||||
// startCursor: '',
|
||||
// hasNextPage,
|
||||
// endCursor,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
|
||||
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] }
|
||||
// }
|
||||
|
||||
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
|
||||
// )
|
||||
// )
|
||||
|
||||
await authTrx((tx) =>
|
||||
models.userArticle.updateByArticleId(
|
||||
uid,
|
||||
articleID,
|
||||
{ sharedComment },
|
||||
tx
|
||||
)
|
||||
)
|
||||
|
||||
return { articleID, sharedComment }
|
||||
}
|
||||
)
|
||||
// return { articleID, sharedComment }
|
||||
// }
|
||||
// )
|
||||
|
|
|
|||
|
|
@ -1,118 +1,101 @@
|
|||
import { Knex } from 'knex'
|
||||
import { UserData } from '../../datalayer/user/model'
|
||||
import {
|
||||
GetFollowersResult,
|
||||
GetFollowingResult,
|
||||
MutationSetFollowArgs,
|
||||
QueryGetFollowersArgs,
|
||||
QueryGetFollowingArgs,
|
||||
ResolverFn,
|
||||
SetFollowError,
|
||||
SetFollowErrorCode,
|
||||
SetFollowSuccess,
|
||||
User,
|
||||
} from '../../generated/graphql'
|
||||
import { authorized, userDataToUser } from '../../utils/helpers'
|
||||
import { DataModels, WithDataSourcesContext } from '../types'
|
||||
// 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] }
|
||||
|
||||
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)
|
||||
// )
|
||||
|
||||
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))
|
||||
// }
|
||||
|
||||
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] }
|
||||
|
||||
const updatedUser = await models.user.getUserDetails(uid, friendUserId)
|
||||
if (!updatedUser) return { errorCodes: [SetFollowErrorCode.NotFound] }
|
||||
// return {
|
||||
// updatedUser: {
|
||||
// ...userDataToUser(updatedUser),
|
||||
// isFriend: updatedUser.viewerIsFollowing,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
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 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),
|
||||
// }))
|
||||
|
||||
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))
|
||||
// }
|
||||
|
||||
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 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),
|
||||
// }
|
||||
// }
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
// const usersWithNoFriends = (users: UserData[]): User[] => {
|
||||
// return users.map((f) =>
|
||||
// userDataToUser({
|
||||
// ...f,
|
||||
// isFriend: false,
|
||||
// } as UserData)
|
||||
// )
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { UserPersonalization } from '../../entity/user_personalization'
|
||||
import {
|
||||
GetUserPersonalizationError,
|
||||
GetUserPersonalizationResult,
|
||||
|
|
@ -8,21 +9,16 @@ import {
|
|||
SortOrder,
|
||||
} from '../../generated/graphql'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { UserPersonalization } from '../../entity/user_personalization'
|
||||
import { AppDataSource } from '../../server'
|
||||
import { getRepository, setClaims } from '../../entity/utils'
|
||||
|
||||
export const setUserPersonalizationResolver = authorized<
|
||||
SetUserPersonalizationSuccess,
|
||||
SetUserPersonalizationError,
|
||||
MutationSetUserPersonalizationArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
>(async (_, { input }, { authTrx, claims: { uid }, log }) => {
|
||||
log.info('setUserPersonalizationResolver', { uid, input })
|
||||
|
||||
const result = await AppDataSource.transaction(async (entityManager) => {
|
||||
await setClaims(entityManager, uid)
|
||||
|
||||
return entityManager.getRepository(UserPersonalization).upsert(
|
||||
const result = await authTrx(async (t) => {
|
||||
return t.getRepository(UserPersonalization).upsert(
|
||||
{
|
||||
user: { id: uid },
|
||||
...input,
|
||||
|
|
@ -37,9 +33,11 @@ export const setUserPersonalizationResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
const updatedUserPersonalization = await getRepository(
|
||||
UserPersonalization
|
||||
).findOneBy({ id: result.identifiers[0].id as string })
|
||||
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
|
||||
|
|
@ -58,8 +56,12 @@ export const setUserPersonalizationResolver = authorized<
|
|||
export const getUserPersonalizationResolver = authorized<
|
||||
GetUserPersonalizationResult,
|
||||
GetUserPersonalizationError
|
||||
>(async (_parent, _args, { models, claims: { uid } }) => {
|
||||
const userPersonalization = await models.userPersonalization.getByUserId(uid)
|
||||
>(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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { authorized } from '../../utils/helpers'
|
||||
import { Webhook } from '../../entity/webhook'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteWebhookError,
|
||||
DeleteWebhookErrorCode,
|
||||
|
|
@ -18,27 +19,19 @@ import {
|
|||
WebhooksSuccess,
|
||||
WebhookSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { User } from '../../entity/user'
|
||||
import { Webhook } from '../../entity/webhook'
|
||||
import { authTrx } from '../../repository'
|
||||
import { deleteWebhook } from '../../services/webhook'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { env } from '../../env'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
export const webhooksResolver = authorized<WebhooksSuccess, WebhooksError>(
|
||||
async (_obj, _params, { claims: { uid }, log }) => {
|
||||
log.info('webhooksResolver')
|
||||
|
||||
async (_obj, _params, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [WebhooksErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const webhooks = await getRepository(Webhook).findBy({
|
||||
user: { id: uid },
|
||||
})
|
||||
const webhooks = await authTrx((t) =>
|
||||
t.getRepository(Webhook).findBy({
|
||||
user: { id: uid },
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
webhooks: webhooks.map((webhook) => webhookDataToResponse(webhook)),
|
||||
|
|
@ -57,21 +50,14 @@ export const webhookResolver = authorized<
|
|||
WebhookSuccess,
|
||||
WebhookError,
|
||||
QueryWebhookArgs
|
||||
>(async (_, { id }, { claims: { uid }, log }) => {
|
||||
log.info('webhookResolver')
|
||||
|
||||
>(async (_, { id }, { authTrx, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [WebhookErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const webhook = await getRepository(Webhook).findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
})
|
||||
const webhook = await authTrx((t) =>
|
||||
t.getRepository(Webhook).findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
})
|
||||
)
|
||||
|
||||
if (!webhook) {
|
||||
return {
|
||||
|
|
@ -79,12 +65,6 @@ export const webhookResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
if (webhook.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [WebhookErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
webhook: webhookDataToResponse(webhook),
|
||||
}
|
||||
|
|
@ -101,52 +81,24 @@ export const deleteWebhookResolver = authorized<
|
|||
DeleteWebhookSuccess,
|
||||
DeleteWebhookError,
|
||||
MutationDeleteWebhookArgs
|
||||
>(async (_, { id }, { claims: { uid }, log }) => {
|
||||
log.info('deleteWebhookResolver')
|
||||
|
||||
>(async (_, { id }, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [DeleteWebhookErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const webhook = await getRepository(Webhook).findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
})
|
||||
|
||||
if (!webhook) {
|
||||
return {
|
||||
errorCodes: [DeleteWebhookErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (webhook.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [DeleteWebhookErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const deletedWebhook = await getRepository(Webhook).remove(webhook)
|
||||
deletedWebhook.id = id
|
||||
const webhook = await deleteWebhook(id, uid)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'webhook_delete',
|
||||
properties: {
|
||||
webhookId: webhook.id,
|
||||
webhookId: id,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
webhook: webhookDataToResponse(deletedWebhook),
|
||||
webhook: webhookDataToResponse(webhook),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
|
||||
log.error('Error deleting webhook', error)
|
||||
return {
|
||||
errorCodes: [DeleteWebhookErrorCode.BadRequest],
|
||||
}
|
||||
|
|
@ -157,17 +109,10 @@ export const setWebhookResolver = authorized<
|
|||
SetWebhookSuccess,
|
||||
SetWebhookError,
|
||||
MutationSetWebhookArgs
|
||||
>(async (_, { input }, { claims: { uid }, log }) => {
|
||||
>(async (_, { input }, { authTrx, claims: { uid }, log }) => {
|
||||
log.info('setWebhookResolver')
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [SetWebhookErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const webhookToSave: Partial<Webhook> = {
|
||||
url: input.url,
|
||||
eventTypes: input.eventTypes as string[],
|
||||
|
|
@ -178,27 +123,26 @@ export const setWebhookResolver = authorized<
|
|||
|
||||
if (input.id) {
|
||||
// Update
|
||||
const existingWebhook = await getRepository(Webhook).findOne({
|
||||
where: { id: input.id },
|
||||
relations: ['user'],
|
||||
})
|
||||
const existingWebhook = await authTrx((t) =>
|
||||
t.getRepository(Webhook).findOne({
|
||||
where: { id: input.id || '' },
|
||||
relations: ['user'],
|
||||
})
|
||||
)
|
||||
if (!existingWebhook) {
|
||||
return {
|
||||
errorCodes: [SetWebhookErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
if (existingWebhook.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [SetWebhookErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
webhookToSave.id = input.id
|
||||
}
|
||||
const webhook = await getRepository(Webhook).save({
|
||||
user,
|
||||
...webhookToSave,
|
||||
})
|
||||
const webhook = await authTrx((t) =>
|
||||
t.getRepository(Webhook).save({
|
||||
user: { id: uid },
|
||||
...webhookToSave,
|
||||
})
|
||||
)
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
|
|
|
|||
|
|
@ -4,18 +4,15 @@ import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler'
|
|||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { createPubSubClient } from '../datalayer/pubsub'
|
||||
import { getPageById, updatePage } from '../elastic/pages'
|
||||
import { Speech, SpeechState } from '../entity/speech'
|
||||
import { getRepository } from '../entity/utils'
|
||||
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 { enqueueTextToSpeech } from '../utils/createTask'
|
||||
import { logger } from '../utils/logger'
|
||||
import { generateDownloadSignedUrl } from '../utils/uploads'
|
||||
|
||||
|
|
@ -80,8 +77,7 @@ export function articleRouter() {
|
|||
async (req, res) => {
|
||||
const articleId = req.params.id
|
||||
const outputFormat = req.params.outputFormat
|
||||
const { voice, priority, secondaryVoice, language } =
|
||||
req.query as SpeechInput
|
||||
const { voice, secondaryVoice, language } = req.query as SpeechInput
|
||||
if (!articleId || outputFormats.indexOf(outputFormat) === -1) {
|
||||
return res.status(400).send('Invalid data')
|
||||
}
|
||||
|
|
@ -102,91 +98,21 @@ export function articleRouter() {
|
|||
})
|
||||
|
||||
try {
|
||||
if (outputFormat === 'speech') {
|
||||
const page = await getPageById(articleId)
|
||||
if (!page) {
|
||||
return res.status(404).send('Page not found')
|
||||
}
|
||||
if (page.userId !== uid) {
|
||||
logger.info('User is not allowed to access speech of the article', {
|
||||
userId: uid,
|
||||
articleId,
|
||||
})
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
const speechFile = htmlToSpeechFile({
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
options: {
|
||||
primaryVoice: voice,
|
||||
secondaryVoice: secondaryVoice,
|
||||
language: language || page.language,
|
||||
},
|
||||
})
|
||||
return res.send({ ...speechFile, pageId: articleId })
|
||||
}
|
||||
|
||||
const existingSpeech = await getRepository(Speech).findOne({
|
||||
where: {
|
||||
elasticPageId: articleId,
|
||||
voice,
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
if (existingSpeech) {
|
||||
if (existingSpeech.user.id !== uid) {
|
||||
logger.info('User is not allowed to access speech of the article', {
|
||||
userId: uid,
|
||||
articleId,
|
||||
})
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
if (existingSpeech.state === SpeechState.COMPLETED) {
|
||||
logger.info('Found existing completed speech', {
|
||||
audioUrl: existingSpeech.audioFileName,
|
||||
speechMarksUrl: existingSpeech.speechMarksFileName,
|
||||
})
|
||||
await updatePage(
|
||||
existingSpeech.elasticPageId,
|
||||
{
|
||||
listenedAt: new Date(),
|
||||
},
|
||||
{ uid, pubsub: createPubSubClient() }
|
||||
)
|
||||
return res.redirect(await redirectUrl(existingSpeech, outputFormat))
|
||||
}
|
||||
if (existingSpeech.state === SpeechState.INITIALIZED) {
|
||||
logger.info('Found existing in progress speech')
|
||||
// retry later
|
||||
return res.status(202).send('Speech is in progress')
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Create Text to speech task', { articleId })
|
||||
const page = await getPageById(articleId)
|
||||
if (!page) {
|
||||
const item = await findLibraryItemById(articleId, uid)
|
||||
if (!item) {
|
||||
return res.status(404).send('Page not found')
|
||||
}
|
||||
// initialize state
|
||||
const speech = await getRepository(Speech).save({
|
||||
user: { id: uid },
|
||||
elasticPageId: articleId,
|
||||
state: SpeechState.INITIALIZED,
|
||||
voice,
|
||||
|
||||
const speechFile = htmlToSpeechFile({
|
||||
title: item.title,
|
||||
content: item.readableContent,
|
||||
options: {
|
||||
primaryVoice: voice,
|
||||
secondaryVoice: secondaryVoice,
|
||||
language: language || item.itemLanguage || undefined,
|
||||
},
|
||||
})
|
||||
// enqueue a task to convert text to speech
|
||||
const taskName = await enqueueTextToSpeech({
|
||||
userId: uid,
|
||||
speechId: speech.id,
|
||||
text: page.content,
|
||||
voice: speech.voice,
|
||||
priority: priority || 'high',
|
||||
})
|
||||
logger.info('Start Text to speech task', { taskName })
|
||||
res.status(202).send('Text to speech task started')
|
||||
return res.send({ ...speechFile, pageId: articleId })
|
||||
} catch (error) {
|
||||
logger.error('Error getting article speech:', error)
|
||||
res.status(500).send({ errorCode: 'INTERNAL_ERROR' })
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import jwksClient from 'jwks-rsa'
|
||||
import UserModel from '../../datalayer/user'
|
||||
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'
|
||||
|
|
@ -119,8 +119,7 @@ export async function handleAppleWebAuth(
|
|||
}
|
||||
|
||||
try {
|
||||
const model = new UserModel()
|
||||
const user = await model.getWhere({
|
||||
const user = await userRepository.findOneBy({
|
||||
sourceUserId: decodedTokenResult.sourceUserId,
|
||||
source: 'APPLE',
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue