diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index 55c50a0dc..cd7db4b57 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -1,8 +1,15 @@ import DataLoader from "dataloader"; import TenantContext from "talk-server/graph/tenant/context"; -import { Asset, retrieveManyAssets } from "talk-server/models/asset"; +import { + Asset, + findOrCreateAsset, + FindOrCreateAssetInput, + retrieveManyAssets, +} from "talk-server/models/asset"; export default (ctx: TenantContext) => ({ + findOrCreate: (input: FindOrCreateAssetInput) => + findOrCreateAsset(ctx.db, ctx.tenant.id, input), asset: new DataLoader(ids => retrieveManyAssets(ctx.db, ctx.tenant.id, ids) ), diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index df43c823c..719046a83 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -4,6 +4,7 @@ import Context from "talk-server/graph/tenant/context"; import { AssetToCommentsArgs, CommentToRepliesArgs, + GQLCOMMENT_SORT, } from "talk-server/graph/tenant/schema/__generated__/types"; import { retrieveCommentAssetConnection, @@ -15,14 +16,33 @@ export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => retrieveManyComments(ctx.db, ctx.tenant.id, ids) ), - forAsset: (assetID: string, input: AssetToCommentsArgs) => - retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, input), - forParent: (assetID: string, parentID: string, input: CommentToRepliesArgs) => - retrieveCommentRepliesConnection( - ctx.db, - ctx.tenant.id, - assetID, - parentID, - input - ), + forAsset: ( + assetID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: AssetToCommentsArgs + ) => + retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, { + first, + orderBy, + after, + }), + forParent: ( + assetID: string, + parentID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: CommentToRepliesArgs + ) => + retrieveCommentRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, { + first, + orderBy, + after, + }), }); diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index caf5e0a3b..56d99e1fe 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -1,7 +1,7 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; const Query: GQLQueryTypeResolver = { - asset: (source, args, ctx) => ctx.loaders.Assets.asset.load(args.id), + asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args), settings: (parent, args, ctx) => ctx.tenant, }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 2e3ae43af..9e57502d5 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -382,8 +382,8 @@ type Comment { replies will return the replies to this comment. """ replies( - first: Int! = 10 - orderBy: COMMENT_SORT! = CREATED_AT_DESC + first: Int = 10 + orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection } @@ -459,8 +459,8 @@ type Asset { comments are the comments on the Asset. """ comments( - first: Int! = 10 - orderBy: COMMENT_SORT! = CREATED_AT_DESC + first: Int = 10 + orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection @@ -533,7 +533,7 @@ type Query { """ asset is the Asset specified by its ID. """ - asset(id: ID!): Asset + asset(id: ID, url: String!): Asset """ me is the current logged in User. diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index e00dd2a56..b09583d9c 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -1,10 +1,10 @@ import dotize from "dotize"; import { defaults } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit } from "talk-common/types"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; -import Query from "./query"; function collection(db: Db) { return db.collection>("assets"); @@ -27,48 +27,103 @@ export interface Asset extends TenantResource { created_at: Date; } -export type CreateAssetInput = Pick; +export interface UpsertAssetInput { + id?: string; + url: string; +} -export async function createAsset( +export async function upsertAsset( db: Db, tenantID: string, - input: CreateAssetInput + { id, url }: UpsertAssetInput ) { const now = new Date(); - // Construct the filter. - const query = new Query(collection(db)).where({ - tenant_id: tenantID, - }); - if (input.id) { - query.where({ id: input.id }); - } else { - query.where({ url: input.url }); - } + // TODO: verify that the url for the given Asset is whitelisted by the tenant. - // Craft the update object. + // Create the asset, optionally sourcing the id from the input, additionally + // porting in the tenant_id. const update: { $setOnInsert: Asset } = { - $setOnInsert: defaults(input, { - id: uuid.v4(), - tenant_id: tenantID, - created_at: now, - }), + $setOnInsert: defaults( + { + url, + tenant_id: tenantID, + created_at: now, + }, + { id }, + { + id: uuid.v4(), + } + ), }; - // Perform the upsert operation. - const result = await collection(db).findOneAndUpdate(query.filter, update, { - // Create the object if it doesn't already exist. - upsert: true, - // False to return the updated document instead of the original - // document. - returnOriginal: false, - }); + // Perform the find and update operation to try and find and or create the + // asset. + const { value: asset } = await collection(db).findOneAndUpdate( + { url }, + update, + { + // Create the object if it doesn't already exist. + upsert: true, - return result.value || null; + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!asset) { + return null; + } + + if (!asset.scraped) { + // TODO: create scrape job to collect asset metadata + } + + return asset; +} + +export interface FindOrCreateAssetInput { + id?: string; + url?: string; +} + +export async function findOrCreateAsset( + db: Db, + tenantID: string, + { id, url }: FindOrCreateAssetInput +) { + if (id) { + if (url) { + // The URL was specified, this is an upsert operation. + return upsertAsset(db, tenantID, { + id, + url, + }); + } + + // The URL was not specified, this is a lookup operation. + return retrieveAsset(db, tenantID, id); + } + + // The ID was not specified, this is an upsert operation. Check to see that + // the URL exists. + if (!url) { + throw new Error("cannot upsert an asset without the url"); + } + + return upsertAsset(db, tenantID, { url }); +} + +export async function retrieveAssetByURL( + db: Db, + tenantID: string, + url: string +) { + return collection(db).findOne({ url, tenant_id: tenantID }); } export async function retrieveAsset(db: Db, tenantID: string, id: string) { - return await collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenant_id: tenantID }); } export async function retrieveManyAssets( @@ -86,6 +141,21 @@ export async function retrieveManyAssets( return ids.map(id => assets.find(asset => asset.id === id) || null); } +export async function retrieveManyAssetsByURL( + db: Db, + tenantID: string, + urls: string[] +) { + const cursor = await collection(db).find({ + url: { $in: urls }, + tenant_id: tenantID, + }); + + const assets = await cursor.toArray(); + + return urls.map(url => assets.find(asset => asset.url === url) || null); +} + export type UpdateAssetInput = Omit< Partial, "id" | "tenant_id" | "url" | "created_at"