Merge pull request #2679 from omnivore-app/db-migration

db migration
This commit is contained in:
Hongbo Wu 2023-10-05 15:56:46 +08:00 committed by GitHub
commit 44f951a94c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
235 changed files with 8479 additions and 13896 deletions

View file

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres
image: ankane/pgvector
env:
POSTGRES_PASSWORD: postgres
options: >-

View file

@ -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

View file

@ -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:

View file

@ -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/

View file

@ -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

View file

@ -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",

View file

@ -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)
}),

View 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
})

View file

@ -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

View file

@ -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>>

View file

@ -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

View file

@ -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
>

View file

@ -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 }
)

View file

@ -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

View file

@ -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>>

View file

@ -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,
},
})
)

View file

@ -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

View file

@ -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
>

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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>>

View file

@ -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

View file

@ -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>

View file

@ -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]
}

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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
>

View file

@ -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

View file

@ -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
>

View file

@ -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

View file

@ -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
>

View file

@ -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

View file

@ -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
>

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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

View 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
}

View file

@ -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[]
}

View file

@ -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

View file

@ -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
}

View 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
}

View file

@ -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[]
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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' })

View 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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -46,7 +46,7 @@ export class Subscription {
unsubscribeHttpUrl?: string
@Column('text', { nullable: true })
icon?: string
icon?: string | null
@Column('enum', {
enum: SubscriptionType,

View file

@ -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 })

View file

@ -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)
}

View file

@ -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)
})

View file

@ -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)
}
}

View file

@ -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>;
};

View file

@ -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!
}

View file

@ -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')

View 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)
},
})

View 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

View 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)
},
})

View 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 })
},
})

View 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()
},
})

View file

@ -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

View file

@ -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) }
})

View file

@ -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],

View file

@ -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'),

View file

@ -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) }
// })

View file

@ -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],

View file

@ -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'

View file

@ -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,

View file

@ -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,
})
})

View file

@ -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 {

View file

@ -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],

View file

@ -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,
}
})

View file

@ -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,
// }
// })

View file

@ -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],

View file

@ -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,
}

View file

@ -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],

View file

@ -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
// }

View file

@ -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],
}
}
})

View file

@ -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)
})

View file

@ -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,
})

View file

@ -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 {

View file

@ -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

View file

@ -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),
}
})

View file

@ -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] }
}
}
})

View file

@ -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 }
}

View file

@ -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 {

View file

@ -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 }
// }
// )

View file

@ -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)
// )
// }

View file

@ -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

View file

@ -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,

View file

@ -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' })

View file

@ -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