diff --git a/apps/hash-api/src/graph/knowledge/primitive/entity.ts b/apps/hash-api/src/graph/knowledge/primitive/entity.ts index 0d27aeed7b6..c6295bc72bb 100644 --- a/apps/hash-api/src/graph/knowledge/primitive/entity.ts +++ b/apps/hash-api/src/graph/knowledge/primitive/entity.ts @@ -338,7 +338,14 @@ export const createEntityWithLinks = async < ...args: Parameters> ): ReturnType> => { const [context, authentication, params] = args; - const { entityTypeIds, properties, linkedEntities, ...createParams } = params; + const { + entityTypeIds, + properties, + linkedEntities, + entityUuid, + policies, + ...createParams + } = params; const entitiesInTree = linkedTreeFlatten< EntityDefinition, @@ -381,12 +388,15 @@ export const createEntityWithLinks = async < * draft entities, but would need changing if we change this. H-2430 which would introduce draft/live versions of * pages which may affect this. */ + const isRootEntity = definition.parentIndex === -1; + const entity = existingEntityId ? await getLatestEntityById(context, authentication, { entityId: existingEntityId, }) : await createEntity(context, authentication, { ...createParams, + ...(isRootEntity ? { entityUuid, policies } : {}), properties: definition.entityProperties!, entityTypeIds: definition.entityTypeIds as Properties["entityTypeIds"], diff --git a/apps/hash-api/src/graph/knowledge/system-types/file.ts b/apps/hash-api/src/graph/knowledge/system-types/file.ts index 944d95b7c26..483e639cc29 100644 --- a/apps/hash-api/src/graph/knowledge/system-types/file.ts +++ b/apps/hash-api/src/graph/knowledge/system-types/file.ts @@ -28,6 +28,7 @@ import type { import type { BaseUrl, Entity, + EntityUuid, VersionedUrl, WebId, } from "@blockprotocol/type-system"; @@ -152,6 +153,7 @@ export const createFileFromUploadRequest: ImpureGraphFunction< description, displayName: providedDisplayName, name: unnormalizedFilename, + makePublic, size, } = params; @@ -235,10 +237,23 @@ export const createFileFromUploadRequest: ImpureGraphFunction< let fileEntity = existingEntity; if (!fileEntity) { + const entityUuid = generateUuid() as EntityUuid; + fileEntity = await createEntity(ctx, authentication, { webId, + entityUuid, properties: initialProperties, entityTypeIds: entityTypeIds as File["entityTypeIds"], + policies: makePublic + ? [ + { + name: `public-view-entity-${entityUuid}`, + effect: "permit", + actions: ["viewEntity"], + principal: null, + }, + ] + : undefined, }); } @@ -302,7 +317,12 @@ export const createFileFromExternalUrl: ImpureGraphFunction< false, true > = async (ctx, authentication, params) => { - const { description, displayName: providedDisplayName, url } = params; + const { + description, + displayName: providedDisplayName, + makePublic, + url, + } = params; const urlValidation = validateExternalUrl(url); if (!urlValidation.valid) { @@ -390,6 +410,8 @@ export const createFileFromExternalUrl: ImpureGraphFunction< }, }; + const entityUuid = generateUuid() as EntityUuid; + return existingEntity ? await updateEntity(ctx, authentication, { entity: existingEntity, @@ -407,8 +429,19 @@ export const createFileFromExternalUrl: ImpureGraphFunction< }) : await createEntity(ctx, authentication, { webId, + entityUuid, properties, entityTypeIds: entityTypeIds as File["entityTypeIds"], + policies: makePublic + ? [ + { + name: `public-view-entity-${entityUuid}`, + effect: "permit", + actions: ["viewEntity"], + principal: null, + }, + ] + : undefined, }); } catch (error) { throw new Error( diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts b/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts index 6c9b1ba06e5..7110e6d27a0 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts @@ -10,12 +10,14 @@ import { serializeQueryEntitiesResponse, serializeQueryEntitySubgraphResponse, summarizeEntities, + type CreateEntityParameters, } from "@local/hash-graph-sdk/entity"; import { createPolicy, deletePolicyById, queryPolicies, } from "@local/hash-graph-sdk/policy"; +import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; import { canUserReadEntity, @@ -50,7 +52,12 @@ import type { ResolverFn, } from "../../../api-types.gen"; import type { GraphQLContext, LoggedInGraphQLContext } from "../../../context"; -import type { Entity, EntityId, WebId } from "@blockprotocol/type-system"; +import type { + Entity, + EntityId, + EntityUuid, + WebId, +} from "@blockprotocol/type-system"; import type { EntityValidationReport } from "@local/hash-graph-sdk/validation"; export const createEntityResolver: ResolverFn< @@ -60,7 +67,15 @@ export const createEntityResolver: ResolverFn< MutationCreateEntityArgs > = async ( _, - { webId, properties, entityTypeIds, linkedEntities, linkData, draft }, + { + webId, + properties, + entityTypeIds, + linkedEntities, + linkData, + draft, + makePublic, + }, graphQLContext, ) => { const { authentication, user } = graphQLContext; @@ -68,6 +83,18 @@ export const createEntityResolver: ResolverFn< let entity: Entity; + const entityUuid = generateUuid() as EntityUuid; + const policies: CreateEntityParameters["policies"] = makePublic + ? [ + { + name: `public-view-entity-${entityUuid}`, + effect: "permit", + actions: ["viewEntity"], + principal: null, + } as const, + ] + : undefined; + if (linkData) { const { leftEntityId, rightEntityId } = linkData; @@ -84,6 +111,7 @@ export const createEntityResolver: ResolverFn< entity = await createLinkEntity(context, authentication, { webId: webId ?? (user.accountId as WebId), + entityUuid, properties, linkData: { leftEntityId, @@ -91,14 +119,17 @@ export const createEntityResolver: ResolverFn< }, entityTypeIds: mustHaveAtLeastOne(entityTypeIds), draft: draft ?? undefined, + policies, }); } else { entity = await createEntityWithLinks(context, authentication, { webId: webId ?? (user.accountId as WebId), + entityUuid, entityTypeIds: mustHaveAtLeastOne(entityTypeIds), properties, linkedEntities: linkedEntities ?? undefined, draft: draft ?? undefined, + policies, }); } diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/file/create-file-from-url.ts b/apps/hash-api/src/graphql/resolvers/knowledge/file/create-file-from-url.ts index 2bfd2cd6caf..069a8641ebe 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/file/create-file-from-url.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/file/create-file-from-url.ts @@ -24,6 +24,7 @@ export const createFileFromUrl: ResolverFn< fileEntityCreationInput, fileEntityUpdateInput, displayName, + makePublic, url, }, graphQLContext, @@ -36,6 +37,7 @@ export const createFileFromUrl: ResolverFn< displayName, fileEntityCreationInput, fileEntityUpdateInput, + makePublic, url, }); diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/file/request-file-upload.ts b/apps/hash-api/src/graphql/resolvers/knowledge/file/request-file-upload.ts index 2b22e5c48c5..bb422e189b0 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/file/request-file-upload.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/file/request-file-upload.ts @@ -35,6 +35,7 @@ export const requestFileUpload: ResolverFn< displayName, fileEntityCreationInput, fileEntityUpdateInput, + makePublic, name, size, }, @@ -57,6 +58,7 @@ export const requestFileUpload: ResolverFn< displayName, fileEntityCreationInput, fileEntityUpdateInput, + makePublic, name, size, }, diff --git a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts index 47458ba0850..4cb81293fa0 100644 --- a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts +++ b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts @@ -7,6 +7,7 @@ export const createEntityMutation = gql` $properties: PropertyObjectWithMetadata! $linkData: LinkData $draft: Boolean + $makePublic: Boolean = false ) { # This is a scalar, which has no selection. createEntity( @@ -15,6 +16,7 @@ export const createEntityMutation = gql` properties: $properties linkData: $linkData draft: $draft + makePublic: $makePublic ) } `; diff --git a/apps/hash-frontend/src/graphql/queries/knowledge/file.queries.ts b/apps/hash-frontend/src/graphql/queries/knowledge/file.queries.ts index 236f4b8f869..fef20b5afe7 100644 --- a/apps/hash-frontend/src/graphql/queries/knowledge/file.queries.ts +++ b/apps/hash-frontend/src/graphql/queries/knowledge/file.queries.ts @@ -8,6 +8,7 @@ export const requestFileUpload = gql` $displayName: String $fileEntityCreationInput: FileEntityCreationInput $fileEntityUpdateInput: FileEntityUpdateInput + $makePublic: Boolean = false ) { requestFileUpload( size: $size @@ -16,6 +17,7 @@ export const requestFileUpload = gql` displayName: $displayName fileEntityCreationInput: $fileEntityCreationInput fileEntityUpdateInput: $fileEntityUpdateInput + makePublic: $makePublic ) { presignedPut { url @@ -32,6 +34,7 @@ export const createFileFromUrl = gql` $description: String $fileEntityCreationInput: FileEntityCreationInput $fileEntityUpdateInput: FileEntityUpdateInput + $makePublic: Boolean = false ) { createFileFromUrl( url: $url @@ -39,6 +42,7 @@ export const createFileFromUrl = gql` description: $description fileEntityCreationInput: $fileEntityCreationInput fileEntityUpdateInput: $fileEntityUpdateInput + makePublic: $makePublic ) } `; diff --git a/apps/hash-frontend/src/shared/file-upload-context.tsx b/apps/hash-frontend/src/shared/file-upload-context.tsx index 9823868679a..3a3d613aeab 100644 --- a/apps/hash-frontend/src/shared/file-upload-context.tsx +++ b/apps/hash-frontend/src/shared/file-upload-context.tsx @@ -14,9 +14,7 @@ import { mergePropertyObjectAndMetadata, } from "@local/hash-graph-sdk/entity"; -import { AuthorizationSubjectKind } from "../graphql/api-types.gen"; import { - addEntityViewerMutation, archiveEntityMutation, createEntityMutation, updateEntityMutation, @@ -29,8 +27,6 @@ import { uploadFileToStorageProvider } from "./upload-to-storage-provider"; import type { UploadFileRequestData } from "../components/hooks/block-protocol-functions/knowledge/knowledge-shim"; import type { - AddEntityViewerMutation, - AddEntityViewerMutationVariables, ArchiveEntityMutation, ArchiveEntityMutationVariables, CreateEntityMutation, @@ -185,11 +181,6 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { {}, ); - const [addEntityViewer] = useMutation< - AddEntityViewerMutation, - AddEntityViewerMutationVariables - >(addEntityViewerMutation); - const [archiveEntity] = useMutation< ArchiveEntityMutation, ArchiveEntityMutationVariables @@ -258,6 +249,7 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { variables: { description, displayName: name, + makePublic, url: fileData.url, ...("fileEntityUpdateInput" in fileData ? { fileEntityUpdateInput: fileData.fileEntityUpdateInput } @@ -275,16 +267,6 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { } fileEntity = new HashEntity(data.createFileFromUrl); - - if (makePublic) { - /** @todo: make entity public as part of `createEntity` query once this is supported */ - await addEntityViewer({ - variables: { - entityId: fileEntity.metadata.recordId.entityId, - viewer: { kind: AuthorizationSubjectKind.Public }, - }, - }); - } } catch (err) { // createFileFromUrlFn might itself throw rather than return errors, thus this catch @@ -321,6 +303,7 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { displayName: name, name: fileData.file.name, size: fileData.file.size, + makePublic, ...("fileEntityUpdateInput" in fileData ? { fileEntityUpdateInput: fileData.fileEntityUpdateInput } : { @@ -340,16 +323,6 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { data.requestFileUpload.entity, ); - if (makePublic) { - /** @todo: make entity public as part of `createEntity` query once this is supported */ - await addEntityViewer({ - variables: { - entityId: fileEntity.metadata.recordId.entityId, - viewer: { kind: AuthorizationSubjectKind.Public }, - }, - }); - } - presignedPut = data.requestFileUpload.presignedPut; // eslint-disable-next-line no-param-reassign @@ -519,6 +492,7 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { properties: linkProperties ? mergePropertyObjectAndMetadata(linkProperties, undefined) : { value: {} }, + makePublic, }, }); @@ -528,16 +502,6 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { const linkEntity = new HashLinkEntity(data.createEntity); - if (makePublic) { - /** @todo: make entity public as part of `createEntity` query once this is supported */ - await addEntityViewer({ - variables: { - entityId: linkEntity.metadata.recordId.entityId, - viewer: { kind: AuthorizationSubjectKind.Public }, - }, - }); - } - const updatedUpload: FileUpload = { ...upload, status: "complete", @@ -567,7 +531,6 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { } }, [ - addEntityViewer, archiveEntity, createEntity, createFileFromUrlFn, diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts index b79c06a381b..99b0f7ff270 100644 --- a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts +++ b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts @@ -216,6 +216,10 @@ export const entityTypedef = gql` Whether the created entity should be a draft """ draft: Boolean + """ + Whether the created entity should be publicly visible + """ + makePublic: Boolean = false ): Entity! """ diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/file.typedef.ts b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/file.typedef.ts index 3058ebb6ef0..fb8bc7ab708 100644 --- a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/file.typedef.ts +++ b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/file.typedef.ts @@ -74,6 +74,10 @@ export const fileTypedef = gql` The entityId of the existing file entity, if this is replacing an existing file """ fileEntityUpdateInput: FileEntityUpdateInput + """ + Whether to make the file entity publicly visible + """ + makePublic: Boolean = false ): RequestFileUploadResponse! """ @@ -101,6 +105,10 @@ export const fileTypedef = gql` The entityId of the existing file entity, if this is replacing an existing file """ fileEntityUpdateInput: FileEntityUpdateInput + """ + Whether to make the file entity publicly visible + """ + makePublic: Boolean = false ): Entity! } `; diff --git a/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts b/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts index e9e5c806600..c2aa269c47d 100644 --- a/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts +++ b/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts @@ -17,6 +17,7 @@ import { createEntityType, } from "@apps/hash-api/src/graph/ontology/primitive/entity-type"; import { createPropertyType } from "@apps/hash-api/src/graph/ontology/primitive/property-type"; +import { extractEntityUuidFromEntityId } from "@blockprotocol/type-system"; import { typedEntries } from "@local/advanced-types/typed-entries"; import { Logger } from "@local/hash-backend-utils/logger"; import { @@ -25,6 +26,7 @@ import { queryEntities, } from "@local/hash-graph-sdk/entity"; import { getClosedMultiEntityTypes } from "@local/hash-graph-sdk/entity-type"; +import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; import { blockProtocolDataTypes, @@ -47,6 +49,7 @@ import type { Org } from "@apps/hash-api/src/graph/knowledge/system-types/org"; import type { User } from "@apps/hash-api/src/graph/knowledge/system-types/user"; import type { EntityTypeWithMetadata, + EntityUuid, PropertyTypeWithMetadata, WebId, } from "@blockprotocol/type-system"; @@ -471,6 +474,92 @@ describe("Entity CRU", () => { ); }); + it("assigns distinct entity UUIDs when creating entity with linked new entities", async () => { + const rootEntityUuid = generateUuid() as EntityUuid; + + const rootEntity = await createEntityWithLinks( + graphContext, + { actorId: testUser.accountId }, + { + webId: testUser.accountId as WebId, + entityUuid: rootEntityUuid, + entityTypeIds: [entityType.schema.$id], + properties: { + value: { + [namePropertyType.metadata.recordId.baseUrl]: { + value: "Bob", + metadata: { + dataTypeId: blockProtocolDataTypes.text.dataTypeId, + }, + }, + [favoriteBookPropertyType.metadata.recordId.baseUrl]: { + value: "some text", + metadata: { + dataTypeId: blockProtocolDataTypes.text.dataTypeId, + }, + }, + }, + }, + linkedEntities: [ + { + destinationAccountId: testUser.accountId, + linkEntityTypeId: linkEntityTypeFriend.schema.$id, + entity: { + entityTypeIds: [entityType.schema.$id], + entityProperties: { + value: { + [namePropertyType.metadata.recordId.baseUrl]: { + value: "Charlie", + metadata: { + dataTypeId: blockProtocolDataTypes.text.dataTypeId, + }, + }, + [favoriteBookPropertyType.metadata.recordId.baseUrl]: { + value: "another book", + metadata: { + dataTypeId: blockProtocolDataTypes.text.dataTypeId, + }, + }, + }, + }, + }, + }, + ], + }, + ); + + const linkEntity = ( + await getEntityOutgoingLinks( + graphContext, + { actorId: testUser.accountId }, + { + entityId: rootEntity.metadata.recordId.entityId, + }, + ) + )[0]!; + + const linkedEntity = await getLinkEntityRightEntity( + graphContext, + { actorId: testUser.accountId }, + { linkEntity }, + ); + + const rootUuid = extractEntityUuidFromEntityId( + rootEntity.metadata.recordId.entityId, + ); + const linkedUuid = extractEntityUuidFromEntityId( + linkedEntity.metadata.recordId.entityId, + ); + const linkUuid = extractEntityUuidFromEntityId( + linkEntity.metadata.recordId.entityId, + ); + + expect(rootUuid).toEqual(rootEntityUuid); + expect(linkedUuid).not.toEqual(rootUuid); + expect(linkUuid).not.toEqual(rootUuid); + expect(linkUuid).not.toEqual(linkedUuid); + }); + it("Cannot instantiate actor entity type", async () => { const authentication = { actorId: testUser.accountId };