diff --git a/bootstrapping-lambda/local/index.ts b/bootstrapping-lambda/local/index.ts index 7b69706a..444fb06d 100644 --- a/bootstrapping-lambda/local/index.ts +++ b/bootstrapping-lambda/local/index.ts @@ -1,31 +1,5 @@ -import { AppSync } from "@aws-sdk/client-appsync"; -import { - pinboardConfigPromiseGetter, - STAGE, - standardAwsConfig, -} from "../../shared/awsIntegration"; -import { APP } from "../../shared/constants"; -import { ENVIRONMENT_VARIABLE_KEYS } from "../../shared/environmentVariables"; +import { initEnvVars } from "./initLocalEnvVars"; -pinboardConfigPromiseGetter("sentryDSN").then( - (sentryDSN) => (process.env[ENVIRONMENT_VARIABLE_KEYS.sentryDSN] = sentryDSN) -); - -new AppSync(standardAwsConfig) - .listGraphqlApis({ - maxResults: 25, // TODO consider implementing paging (for absolute future proofing) - }) - .then((_) => - _.graphqlApis?.find( - (api) => api.tags?.["Stage"] === STAGE && api.tags?.["App"] === APP - ) - ) - .then((appSyncAPI) => { - if (!appSyncAPI) { - throw Error("could not find AppSync API"); - } - process.env[ENVIRONMENT_VARIABLE_KEYS.graphqlEndpoint] = - appSyncAPI.uris?.["GRAPHQL"]; - - import("../src/server"); // actually start the server, once the environment variable is set - }); +initEnvVars().then(() => { + import("../src/server"); // actually start the server, once the environment variables are set +}); diff --git a/bootstrapping-lambda/local/initLocalEnvVars.ts b/bootstrapping-lambda/local/initLocalEnvVars.ts new file mode 100644 index 00000000..c0d5f54f --- /dev/null +++ b/bootstrapping-lambda/local/initLocalEnvVars.ts @@ -0,0 +1,32 @@ +import { AppSync } from "@aws-sdk/client-appsync"; +import { + pinboardConfigPromiseGetter, + STAGE, + standardAwsConfig, +} from "shared/awsIntegration"; +import { APP } from "shared/constants"; +import { ENVIRONMENT_VARIABLE_KEYS } from "shared/environmentVariables"; + +export const initEnvVars = (): Promise => { + pinboardConfigPromiseGetter("sentryDSN").then( + (sentryDSN) => + (process.env[ENVIRONMENT_VARIABLE_KEYS.sentryDSN] = sentryDSN) + ); + + return new AppSync(standardAwsConfig) + .listGraphqlApis({ + maxResults: 25, // TODO consider implementing paging (for absolute future proofing) + }) + .then((_) => + _.graphqlApis?.find( + (api) => api.tags?.["Stage"] === STAGE && api.tags?.["App"] === APP + ) + ) + .then((appSyncAPI) => { + if (!appSyncAPI) { + throw Error("could not find AppSync API"); + } + process.env[ENVIRONMENT_VARIABLE_KEYS.graphqlEndpoint] = + appSyncAPI.uris?.["GRAPHQL"]; + }); +}; diff --git a/bootstrapping-lambda/local/simulateOctopus.ts b/bootstrapping-lambda/local/simulateOctopus.ts new file mode 100644 index 00000000..8b53d35b --- /dev/null +++ b/bootstrapping-lambda/local/simulateOctopus.ts @@ -0,0 +1,58 @@ +import prompts from "prompts"; +import { handleImagingCallFromOctopus } from "../src/octopusImagingHandler"; +import { S3 } from "@aws-sdk/client-s3"; +import { standardAwsConfig } from "shared/awsIntegration"; +import { IMAGINE_REQUEST_TYPES } from "shared/octopusImaging"; +import { getYourEmail } from "shared/local/yourEmail"; +import { initEnvVars } from "./initLocalEnvVars"; + +initEnvVars().then(async () => { + const s3 = new S3(standardAwsConfig); + + const newGridId = "TBC"; //TODO get media id of modified image + + // noinspection InfiniteLoopJS + while ( + // eslint-disable-next-line no-constant-condition + true + ) { + const { args } = await prompts( + { + type: "select", + name: "args", + message: "Operation?", + choices: [ + { + title: "ImagingOrderPickedUp", + value: {}, + }, + { + title: "GeneratePreSignedGridUploadUrl", + value: { + originalGridId: "223636f8d305a77e60fb2aa4525effbd66a7560d", + filename: "Historic_yachts_22.JPG", + newGridId, + requestType: IMAGINE_REQUEST_TYPES[0], + }, + }, + { + title: "ImagingOrderCompleted", + value: { + newGridId, + }, + }, + ], + }, + { onCancel: () => process.exit() } + ); + + console.log( + (await handleImagingCallFromOctopus(s3, { + userEmail: await getYourEmail(), + workflowId: "65518", + pinboardItemId: "3458", + ...args, + })) || "DONE" + ); + } +}); diff --git a/bootstrapping-lambda/package.json b/bootstrapping-lambda/package.json index cf46e73b..908549e1 100644 --- a/bootstrapping-lambda/package.json +++ b/bootstrapping-lambda/package.json @@ -9,6 +9,7 @@ "test": "jest --coverage --forceExit", "bundle": "esbuild src/server.ts --bundle --minify --outfile=dist/index.js --platform=node --external:aws-sdk --external:@aws-sdk", "watch": "ts-node-dev --respawn local/index.ts", + "simulate-octopus-call": "ts-node-dev --respawn local/simulateOctopus.ts", "build": "run-p --print-label type-check bundle" }, "devDependencies": { diff --git a/bootstrapping-lambda/src/appSyncRequest.ts b/bootstrapping-lambda/src/appSyncRequest.ts new file mode 100644 index 00000000..0a20e71b --- /dev/null +++ b/bootstrapping-lambda/src/appSyncRequest.ts @@ -0,0 +1,24 @@ +import { CreateItemInput } from "shared/graphql/graphql"; +import { AppSyncConfig } from "shared/appSyncConfig"; +import { itemReturnFields } from "shared/itemReturnFields"; +import fetch from "node-fetch"; + +export const appSyncCreateItem = ( + config: AppSyncConfig, + input: CreateItemInput +) => + fetch(config.graphqlEndpoint, { + method: "POST", + headers: { + authorization: config.authToken, + "content-type": "application/json", + }, + body: JSON.stringify({ + operationName: "SendMessage", + variables: { + input, + }, + // the client listening to the subscription requires various fields to be returned, hence reuse of itemReturnFields + query: `mutation SendMessage($input: CreateItemInput!) { createItem(input: $input) { ${itemReturnFields} } }`, + }), + }); diff --git a/bootstrapping-lambda/src/octopusImagingHandler.ts b/bootstrapping-lambda/src/octopusImagingHandler.ts new file mode 100644 index 00000000..64b68638 --- /dev/null +++ b/bootstrapping-lambda/src/octopusImagingHandler.ts @@ -0,0 +1,79 @@ +import { S3 } from "@aws-sdk/client-s3"; +import { generateAppSyncConfig } from "./generateAppSyncConfig"; +import { generatePreSignedGridUploadUrl } from "shared/grid"; +import { CreateItemInput } from "shared/graphql/graphql"; +import { appSyncCreateItem } from "./appSyncRequest"; +import { + IMAGING_COMPLETED_ITEM_TYPE, + IMAGING_PICKED_UP_ITEM_TYPE, +} from "shared/octopusImaging"; + +interface CommonArgs { + /* the email of the user who did the imaging work */ + userEmail: string; //TODO probably receive the desktop auth token instead (to verify on pinboard side) + /* the id of the pinboard itself */ + workflowId: string; + /* the itemId of the original request item in pinboard */ + pinboardItemId: string; +} + +type ImagingOrderPickedUp = CommonArgs; +interface GeneratePreSignedGridUploadUrl extends CommonArgs { + originalGridId: string; + filename: string; + /* SHA1 hash of the file content */ + newGridId: string; + /* e.g. cutout, composite, etc */ + requestType: string; +} +interface ImagingOrderCompleted extends CommonArgs { + /* SHA1 hash of the file content */ + newGridId: string; +} + +export type ImagingCallFromOctopus = + | ImagingOrderPickedUp + | GeneratePreSignedGridUploadUrl + | ImagingOrderCompleted; + +export const isImagingCallFromOctopus = ( + detail: any +): detail is ImagingCallFromOctopus => !!detail && "pinboardItemId" in detail; + +export const handleImagingCallFromOctopus = async ( + s3: S3, + detail: ImagingCallFromOctopus +): Promise => { + console.log("Handling imaging call from Octopus", detail); + if ("originalGridId" in detail) { + return await generatePreSignedGridUploadUrl(detail); + } + const appSyncConfig = await generateAppSyncConfig(detail.userEmail, s3); + const appSyncCreateItemInput: CreateItemInput = { + pinboardId: detail.workflowId, + relatedItemId: detail.pinboardItemId, + claimable: false, + mentions: null, //TODO consider @ing the original requester for these updates + message: null, + groupMentions: null, + ...("newGridId" in detail + ? { + type: IMAGING_COMPLETED_ITEM_TYPE, + payload: null, //TODO make use of the newGridId + } + : { + type: IMAGING_PICKED_UP_ITEM_TYPE, + payload: null, + }), + }; + return appSyncCreateItem(appSyncConfig, appSyncCreateItemInput).then( + async (response) => { + console.log(await response.text()); + if (!response.ok) { + throw new Error( + `Failed to create item: ${response.status} ${response.statusText}` + ); + } + } + ); +}; diff --git a/bootstrapping-lambda/src/server.ts b/bootstrapping-lambda/src/server.ts index 45311225..786b46ba 100644 --- a/bootstrapping-lambda/src/server.ts +++ b/bootstrapping-lambda/src/server.ts @@ -25,6 +25,11 @@ import { } from "./middleware/auth-middleware"; import { getMetrics } from "./reporting/reportingServiceClient"; +import { + handleImagingCallFromOctopus, + ImagingCallFromOctopus, + isImagingCallFromOctopus, +} from "./octopusImagingHandler"; const s3 = new S3(standardAwsConfig); @@ -162,8 +167,17 @@ if (IS_RUNNING_LOCALLY) { const PORT = 3030; server.listen(PORT, () => console.log(`Listening on port ${PORT}`)); } else { - exports.handler = ( - event: lambda.APIGatewayProxyEvent, + exports.handler = async ( + payload: lambda.APIGatewayProxyEvent | ImagingCallFromOctopus, context: lambda.Context - ) => proxy(createServer(server), event, context); + ) => { + if ("httpMethod" in payload) { + // from API Gateway + return proxy(createServer(server), payload, context); + } else if (isImagingCallFromOctopus(payload)) { + return await handleImagingCallFromOctopus(s3, payload); + } + console.error("unexpected payload", payload); + throw new Error("Not implemented"); + }; } diff --git a/cdk/lib/__snapshots__/stack.test.ts.snap b/cdk/lib/__snapshots__/stack.test.ts.snap index b1a740cf..29c155ac 100644 --- a/cdk/lib/__snapshots__/stack.test.ts.snap +++ b/cdk/lib/__snapshots__/stack.test.ts.snap @@ -29,6 +29,15 @@ Object { "gu:cdk:version": "49.5.0", }, "Outputs": Object { + "BootstrappingLambdaFunctionName": Object { + "Description": "pinboard-bootstrapping-lambda-TEST function name", + "Export": Object { + "Name": "pinboard-bootstrapping-lambda-TEST-function-name", + }, + "Value": Object { + "Ref": "pinboardbootstrappinglambdaD2C487DA", + }, + }, "pinboardbootstrappinglambdaapiEndpoint4DE1E032": Object { "Value": Object { "Fn::Join": Array [ diff --git a/cdk/lib/stack.ts b/cdk/lib/stack.ts index 6bf7823b..0f00e618 100644 --- a/cdk/lib/stack.ts +++ b/cdk/lib/stack.ts @@ -644,6 +644,7 @@ export class PinBoardStack extends GuStack { const bootstrappingLambdaBasename = "pinboard-bootstrapping-lambda"; const bootstrappingLambdaApiBaseName = `${bootstrappingLambdaBasename}-api`; + const bootstrappingLambdaFunctionName = `${bootstrappingLambdaBasename}-${this.stage}`; const bootstrappingLambdaFunction = new lambda.Function( this, @@ -665,7 +666,7 @@ export class PinBoardStack extends GuStack { "/pinboard/sentryDSN" ), }, - functionName: `${bootstrappingLambdaBasename}-${this.stage}`, + functionName: bootstrappingLambdaFunctionName, code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${bootstrappingLambdaApiBaseName}/${bootstrappingLambdaApiBaseName}.zip` @@ -740,5 +741,11 @@ export class PinBoardStack extends GuStack { description: `${bootstrappingLambdaApiBaseName}-hostname`, value: `${bootstrappingApiDomainName.domainNameAliasDomainName}`, }); + + new CfnOutput(this, `BootstrappingLambdaFunctionName`, { + exportName: `${bootstrappingLambdaFunctionName}-function-name`, + description: `${bootstrappingLambdaFunctionName} function name`, + value: `${bootstrappingLambdaFunction.functionName}`, + }); } } diff --git a/client/gql.ts b/client/gql.ts index 550a643c..cdf390b5 100644 --- a/client/gql.ts +++ b/client/gql.ts @@ -1,4 +1,5 @@ import { gql } from "@apollo/client"; +import { itemReturnFields } from "shared/itemReturnFields"; const pinboardReturnFields = ` composerId @@ -47,29 +48,6 @@ export const gqlGetItemCounts = gql` } `; -const itemReturnFields = ` - id - type - userEmail - timestamp - pinboardId - message - payload - mentions { - label - isMe - } - groupMentions { - label - isMe - } - claimedByEmail - claimable - relatedItemId - editHistory - deletedAt -`; - // TODO: consider updating the resolver (cdk/stack.ts) to use a Query with a secondary index (if performance degrades when we have lots of items) export const gqlGetInitialItems = (pinboardId: string) => gql` query MyQuery { diff --git a/client/src/itemDisplay.tsx b/client/src/itemDisplay.tsx index 71fbc71a..bc5f851c 100644 --- a/client/src/itemDisplay.tsx +++ b/client/src/itemDisplay.tsx @@ -24,7 +24,11 @@ import Pencil from "../icons/pencil.svg"; import { ITEM_HOVER_MENU_CLASS_NAME, ItemHoverMenu } from "./itemHoverMenu"; import { EditItem } from "./editItem"; import { Reply } from "./reply"; -import { IMAGING_REQUEST_ITEM_TYPE } from "shared/octopusImaging"; +import { + IMAGING_COMPLETED_ITEM_TYPE, + IMAGING_PICKED_UP_ITEM_TYPE, + IMAGING_REQUEST_ITEM_TYPE, +} from "shared/octopusImaging"; interface ItemDisplayProps { item: Item | PendingItem; @@ -97,6 +101,8 @@ export const ItemDisplay = ({ item.userEmail === userEmail && item.type !== "claim" && item.type !== IMAGING_REQUEST_ITEM_TYPE && + item.type !== IMAGING_PICKED_UP_ITEM_TYPE && + item.type !== IMAGING_COMPLETED_ITEM_TYPE && !item.claimedByEmail; return ( diff --git a/grid-bridge-lambda/src/index.ts b/grid-bridge-lambda/src/index.ts index 70d33473..a75564f4 100644 --- a/grid-bridge-lambda/src/index.ts +++ b/grid-bridge-lambda/src/index.ts @@ -1,20 +1,14 @@ -import fetch from "node-fetch"; -import { - pinboardSecretPromiseGetter, - STAGE, -} from "../../shared/awsIntegration"; import type { GridSearchQueryBreakdown, GridSearchSummary, -} from "../../shared/graphql/graphql"; +} from "shared/graphql/graphql"; import { isCollectionResponse, isSearchResponse } from "./types"; import { PayloadAndType } from "client/src/types/PayloadAndType"; +import { gridFetch, gridTopLevelDomain } from "shared/grid"; -const gutoolsDomain = - STAGE === "PROD" ? "gutools.co.uk" : "test.dev-gutools.co.uk"; -const mediaApiDomain = `api.media.${gutoolsDomain}`; -const collectionsDomain = `media-collections.${gutoolsDomain}`; +const mediaApiHost = `api.media.${gridTopLevelDomain}`; +const collectionsHost = `media-collections.${gridTopLevelDomain}`; const maxImagesInSummary = "4"; export const handler = async (event: { @@ -28,20 +22,8 @@ export const handler = async (event: { } }; -const gridFetch = async (url: string): Promise => { - const response = await fetch(url, { - headers: { - "X-Gu-Media-Key": await pinboardSecretPromiseGetter( - `grid/${STAGE === "PROD" ? "PROD" : "CODE"}/apiKey` - ), - }, - }); - - return await response.json(); -}; - const getSearchSummary = async (url: URL): Promise => { - if (url.hostname !== mediaApiDomain || url.pathname !== "/images") { + if (url.hostname !== mediaApiHost || url.pathname !== "/images") { throw new Error("Invalid Grid search API URL"); } url.searchParams.set("length", maxImagesInSummary); @@ -87,7 +69,7 @@ async function breakdownQuery( [...q.matchAll(COLLECTIONS)].map(async (match) => { const text = match[1] ?? match[2] ?? match[3]; const collectionResponse = await gridFetch( - `https://${collectionsDomain}/collections/${text}` + `https://${collectionsHost}/collections/${text}` ); if (!isCollectionResponse(collectionResponse)) { @@ -127,7 +109,7 @@ async function breakdownQuery( async function buildPayloadFor(gridUrl: URL): Promise { if (gridUrl.pathname === "/search") { const apiUrl = new URL(gridUrl.href); - apiUrl.hostname = mediaApiDomain; + apiUrl.hostname = mediaApiHost; apiUrl.pathname = apiUrl.pathname.replace("/search", "/images"); apiUrl.searchParams.set("countAll", "true"); apiUrl.searchParams.set("length", "0"); @@ -150,7 +132,7 @@ async function buildPayloadFor(gridUrl: URL): Promise { if (gridUrl.pathname.startsWith("/images/")) { const maybeCrop = gridUrl.searchParams.get("crop"); const apiUrl = new URL(gridUrl.href); - apiUrl.hostname = mediaApiDomain; + apiUrl.hostname = mediaApiHost; const imageResponse = (await gridFetch(apiUrl.href)) as { data: { thumbnail: { secureUrl: string }; diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index cb1d8029..1989d872 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -144,7 +144,7 @@ input CreateItemInput { pinboardId: String! mentions: [String!] groupMentions: [String!] - claimable: Boolean + claimable: Boolean! relatedItemId: String } diff --git a/shared/grid.ts b/shared/grid.ts new file mode 100644 index 00000000..53ebf006 --- /dev/null +++ b/shared/grid.ts @@ -0,0 +1,55 @@ +import { pinboardSecretPromiseGetter, STAGE } from "./awsIntegration"; +import fetch from "node-fetch"; + +export const gridTopLevelDomain = + STAGE === "PROD" ? "gutools.co.uk" : "test.dev-gutools.co.uk"; + +const loaderHost = `loader.media.${gridTopLevelDomain}`; + +export const gridFetch = async >( + url: string, + postBody?: BODY +): Promise> => { + console.log(postBody); + const response = await fetch(url, { + method: postBody ? "POST" : "GET", + headers: { + "X-Gu-Media-Key": await pinboardSecretPromiseGetter( + `grid/${STAGE === "PROD" ? "PROD" : "CODE"}/apiKey` + ), + ...(postBody && { "Content-Type": "application/json" }), + }, + body: postBody && JSON.stringify(postBody), + }); + + if (response.ok) { + // TODO perhaps also check the content type + return await response.json(); + } + throw new Error( + `Failed to fetch ${url}: ${response.status} ${response.statusText}` + ); +}; + +interface GeneratePreSignedGridUploadUrlArgs { + newGridId: string; + filename: string; + originalGridId: string; + /* the email of the user who did the imaging work, who we'll use for on onBehalfOf*/ + userEmail: string; +} +export const generatePreSignedGridUploadUrl = async ({ + newGridId, + filename, + originalGridId, + userEmail, +}: GeneratePreSignedGridUploadUrlArgs): Promise => { + const requestBody = { + [newGridId]: filename, + }; + const response = await gridFetch( + `https://${loaderHost}/prepare?originalMediaId=${originalGridId}&onBehalfOf=${userEmail}`, + requestBody + ); + return response[newGridId] as string; +}; diff --git a/shared/itemReturnFields.ts b/shared/itemReturnFields.ts new file mode 100644 index 00000000..14b1ac42 --- /dev/null +++ b/shared/itemReturnFields.ts @@ -0,0 +1,22 @@ +export const itemReturnFields = ` + id + type + userEmail + timestamp + pinboardId + message + payload + mentions { + label + isMe + } + groupMentions { + label + isMe + } + claimedByEmail + claimable + relatedItemId + editHistory + deletedAt +`; diff --git a/shared/octopusImaging.ts b/shared/octopusImaging.ts index 7c1e5d70..df7cbd40 100644 --- a/shared/octopusImaging.ts +++ b/shared/octopusImaging.ts @@ -1,4 +1,6 @@ export const IMAGING_REQUEST_ITEM_TYPE = "imaging-request"; +export const IMAGING_PICKED_UP_ITEM_TYPE = "imaging-picked-up"; +export const IMAGING_COMPLETED_ITEM_TYPE = "imaging-completed"; export const IMAGINE_REQUEST_TYPES = [ "Cut out", // only 'cut out' is required for now