From 70ef691ae596fe22653e8df1332d3fbb790af811 Mon Sep 17 00:00:00 2001 From: caichi <54824604+caichi-t@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:14:06 +0900 Subject: [PATCH 01/23] fix(web): resolve button disabling issue on request field (#1343) * fix: incorrect behavior that the button is disabled on request field * Revert "fix: incorrect behavior that the button is disabled on request field" This reverts commit ae050198fe82b164b54b38972f4fe0979d64cf93. * fix * Revert "Revert "fix: incorrect behavior that the button is disabled on request field"" This reverts commit c27eb41e4d0232c9cddf9bd00d7a468759658542. --- .../FieldCreationModalWithSteps/index.tsx | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/web/src/components/molecules/Schema/FieldModal/FieldCreationModalWithSteps/index.tsx b/web/src/components/molecules/Schema/FieldModal/FieldCreationModalWithSteps/index.tsx index e33bc1cc4..bac6f2e42 100644 --- a/web/src/components/molecules/Schema/FieldModal/FieldCreationModalWithSteps/index.tsx +++ b/web/src/components/molecules/Schema/FieldModal/FieldCreationModalWithSteps/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState, useRef, MutableRefObject } f import Button from "@reearth-cms/components/atoms/Button"; import Checkbox from "@reearth-cms/components/atoms/Checkbox"; -import Form, { FormInstance } from "@reearth-cms/components/atoms/Form"; +import Form, { FormInstance, ValidateErrorEntity } from "@reearth-cms/components/atoms/Form"; import Icon from "@reearth-cms/components/atoms/Icon"; import Input from "@reearth-cms/components/atoms/Input"; import Modal from "@reearth-cms/components/atoms/Modal"; @@ -73,15 +73,20 @@ const FieldCreationModalWithSteps: React.FC = ({ const changedKeys = useRef(new Set()); const formValidate = useCallback( - (form: FormInstance) => { + async (form: FormInstance) => { if ( form.getFieldValue("model") || (form.getFieldValue("title") && form.getFieldValue("key")) ) { - form - .validateFields() - .then(() => setIsDisabled(currentStep === numSteps && changedKeys.current.size === 0)) - .catch(() => setIsDisabled(true)); + try { + await form.validateFields(); + } catch (e) { + if ((e as ValidateErrorEntity).errorFields.length > 0) { + setIsDisabled(true); + return; + } + } + setIsDisabled(currentStep === numSteps && changedKeys.current.size === 0); } else { setIsDisabled(true); } @@ -107,18 +112,24 @@ const FieldCreationModalWithSteps: React.FC = ({ const SettingValues = Form.useWatch([], modelForm); useEffect(() => { - formValidate(modelForm); - }, [modelForm, SettingValues, formValidate]); + if (currentStep === 0) { + formValidate(modelForm); + } + }, [modelForm, SettingValues, formValidate, currentStep]); const FieldValues = Form.useWatch([], field1Form); useEffect(() => { - formValidate(field1Form); - }, [field1Form, FieldValues, formValidate]); + if (currentStep === 1) { + formValidate(field1Form); + } + }, [field1Form, FieldValues, formValidate, currentStep]); const CorrespondingValues = Form.useWatch([], field2Form); useEffect(() => { - formValidate(field2Form); - }, [field2Form, CorrespondingValues, formValidate]); + if (currentStep === 2) { + formValidate(field2Form); + } + }, [field2Form, CorrespondingValues, formValidate, currentStep]); useEffect(() => { modelForm.setFieldsValue({ From 2943e6f4cf05161d461de441e50071163d475a40 Mon Sep 17 00:00:00 2001 From: caichi <54824604+caichi-t@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:25:52 +0900 Subject: [PATCH 02/23] fix(web): handling initial values of multiple field (#1345) fix: type and multi default value --- web/src/components/molecules/Content/types.ts | 16 +++++++++ web/src/components/molecules/Schema/types.ts | 8 ++--- .../Project/Content/ContentDetails/hooks.ts | 35 ++++++++++++------- .../Project/Content/ContentDetails/utils.ts | 2 +- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/web/src/components/molecules/Content/types.ts b/web/src/components/molecules/Content/types.ts index 6a868033f..feb0ace2c 100644 --- a/web/src/components/molecules/Content/types.ts +++ b/web/src/components/molecules/Content/types.ts @@ -1,9 +1,25 @@ +import { type Dayjs } from "dayjs"; + import { User } from "@reearth-cms/components/molecules/AccountSettings/types"; import { Request } from "@reearth-cms/components/molecules/Request/types"; import { FieldType } from "@reearth-cms/components/molecules/Schema/types"; export type ItemStatus = "DRAFT" | "PUBLIC" | "REVIEW" | "PUBLIC_REVIEW" | "PUBLIC_DRAFT"; +export type FormValue = + | string + | string[] + | number + | number[] + | boolean + | boolean[] + | Dayjs + | ("" | Dayjs)[] + | null + | undefined; + +export type FormGroupValue = Record; + export type ItemValue = string | string[] | number | number[] | boolean | boolean[]; export type ItemField = { diff --git a/web/src/components/molecules/Schema/types.ts b/web/src/components/molecules/Schema/types.ts index 4239b207e..27ec7b41f 100644 --- a/web/src/components/molecules/Schema/types.ts +++ b/web/src/components/molecules/Schema/types.ts @@ -81,11 +81,11 @@ export type CorrespondingField = { }; export type TypeProperty = { - defaultValue?: string | boolean | string[] | boolean[]; + defaultValue?: string | string[] | boolean | boolean[] | null; maxLength?: number; - assetDefaultValue?: string; - selectDefaultValue?: string | string[]; - integerDefaultValue?: number; + assetDefaultValue?: string | string[] | null; + selectDefaultValue?: string | string[] | null; + integerDefaultValue?: number | number[] | null; min?: number; max?: number; numberMin?: number; diff --git a/web/src/components/organisms/Project/Content/ContentDetails/hooks.ts b/web/src/components/organisms/Project/Content/ContentDetails/hooks.ts index 1df55c5ed..96d18c4f2 100644 --- a/web/src/components/organisms/Project/Content/ContentDetails/hooks.ts +++ b/web/src/components/organisms/Project/Content/ContentDetails/hooks.ts @@ -4,6 +4,8 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import Notification from "@reearth-cms/components/atoms/Notification"; import { User } from "@reearth-cms/components/molecules/AccountSettings/types"; import { + FormValue, + FormGroupValue, FormItem, Item, ItemStatus, @@ -366,18 +368,27 @@ export default () => { ); const valueGet = useCallback((field: Field) => { + let result: FormValue; switch (field.type) { case "Select": - return field.typeProperty?.selectDefaultValue; + result = field.typeProperty?.selectDefaultValue; + break; case "Integer": - return field.typeProperty?.integerDefaultValue; + result = field.typeProperty?.integerDefaultValue; + break; case "Asset": - return field.typeProperty?.assetDefaultValue; + result = field.typeProperty?.assetDefaultValue; + break; case "Date": - return dateConvert(field.typeProperty?.defaultValue); + result = dateConvert(field.typeProperty?.defaultValue); + break; default: - return field.typeProperty?.defaultValue; + result = field.typeProperty?.defaultValue; } + if (field.multiple && !result) { + result = []; + } + return result; }, []); const updateValueConvert = useCallback(({ type, value }: ItemField) => { @@ -394,18 +405,18 @@ export default () => { } }, []); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [initialFormValues, setInitialFormValues] = useState>({}); + const [initialFormValues, setInitialFormValues] = useState< + Record + >({}); useEffect(() => { if (itemLoading) return; const handleInitialValuesSet = async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const initialValues: Record = {}; + const initialValues: Record = {}; const groupInitialValuesUpdate = (group: Group, itemGroupId: string) => { group?.schema?.fields?.forEach(field => { initialValues[field.id] = { - ...initialValues[field.id], + ...(initialValues[field.id] as FormGroupValue), ...{ [itemGroupId]: valueGet(field) }, }; }); @@ -415,7 +426,7 @@ export default () => { currentItem?.fields?.forEach(field => { if (field.itemGroupId) { initialValues[field.schemaFieldId] = { - ...initialValues[field.schemaFieldId], + ...(initialValues[field.schemaFieldId] as FormGroupValue), ...{ [field.itemGroupId]: updateValueConvert(field) }, }; } else { @@ -549,7 +560,7 @@ export default () => { const handleCheckItemReference = useCallback( async (itemId: string, correspondingFieldId: string, groupId?: string) => { const initialValue = groupId - ? initialFormValues[groupId][correspondingFieldId] + ? (initialFormValues[groupId] as FormGroupValue)[correspondingFieldId] : initialFormValues[correspondingFieldId]; if (initialValue === itemId) { return false; diff --git a/web/src/components/organisms/Project/Content/ContentDetails/utils.ts b/web/src/components/organisms/Project/Content/ContentDetails/utils.ts index 7f6d92a17..bb31c1a1e 100644 --- a/web/src/components/organisms/Project/Content/ContentDetails/utils.ts +++ b/web/src/components/organisms/Project/Content/ContentDetails/utils.ts @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import { ItemValue } from "@reearth-cms/components/molecules/Content/types"; -export function dateConvert(value?: ItemValue) { +export function dateConvert(value?: ItemValue | null) { if (Array.isArray(value)) { return (value as string[]).map(valueItem => (valueItem ? dayjs(valueItem) : "")); } else { From 6253e1560dab9f6713d09be867c565ccb9bc251c Mon Sep 17 00:00:00 2001 From: caichi <54824604+caichi-t@users.noreply.github.com> Date: Thu, 9 Jan 2025 19:02:32 +0900 Subject: [PATCH 03/23] ci(web): set up playwright (#1342) * wip * env setting * revert webServer setting * fix: login logic * fix: install only chromium * fix: extend timeout * fix * fix * fix: get report * fix path * fix * fix * fix * fix * test * test * fix * fix: pass secrets * fix: e2e tests * ci: setup e2e workflow cron * test * Revert "test" This reverts commit 8fab00a9d1ae563c94586efce718893254875111. * fix: job name * fix: use cache * rename * test * Update .github/workflows/e2e_web.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Revert "test" This reverts commit 35acab6a7e7bd089298eb7092136eafd119c179e. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/e2e_web.yml | 51 ++++++++++++++++ web/.gitignore | 1 + web/e2e/auth.setup.ts | 21 +++++++ web/e2e/dashboard.spec.ts | 6 -- web/e2e/project/accessibility.spec.ts | 5 +- web/e2e/project/item/fields/asset.spec.ts | 8 ++- web/e2e/project/item/metadata/boolean.spec.ts | 13 ++--- .../project/item/metadata/checkbox.spec.ts | 13 ++--- web/e2e/project/item/metadata/date.spec.ts | 22 +++---- web/e2e/project/item/metadata/tag.spec.ts | 20 +++---- web/e2e/project/item/metadata/text.spec.ts | 13 ++--- web/e2e/project/item/metadata/url.spec.ts | 24 +++----- web/e2e/project/request.spec.ts | 1 + web/e2e/settings/settings.spec.ts | 4 ++ web/e2e/utils/config.ts | 10 +--- web/e2e/utils/login.ts | 58 ------------------- web/e2e/utils/setup.ts | 8 --- web/playwright.config.ts | 28 +++++++-- 18 files changed, 156 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/e2e_web.yml create mode 100644 web/e2e/auth.setup.ts delete mode 100644 web/e2e/dashboard.spec.ts delete mode 100644 web/e2e/utils/login.ts delete mode 100644 web/e2e/utils/setup.ts diff --git a/.github/workflows/e2e_web.yml b/.github/workflows/e2e_web.yml new file mode 100644 index 000000000..0a15472ed --- /dev/null +++ b/.github/workflows/e2e_web.yml @@ -0,0 +1,51 @@ +name: Web E2E test +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 1-5" +jobs: + e2e: + name: playwright + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: yarn + cache-dependency-path: "**/yarn.lock" + - name: Install dependencies + run: yarn install + - name: Get installed Playwright version + id: playwright-version + run: echo "version=$( node -e "console.log(require('@playwright/test/package.json').version)" )" >> $GITHUB_OUTPUT + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: "${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}" + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install Playwright system dependencies and browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: yarn playwright install --with-deps chromium + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: yarn playwright install-deps chromium + - name: Run Playwright tests + run: yarn e2e + env: + REEARTH_CMS_API: https://api.cms.test.reearth.dev/api + REEARTH_CMS_E2E_BASEURL: https://cms.test.reearth.dev + REEARTH_CMS_E2E_USERNAME: ${{ secrets.REEARTH_WEB_E2E_USERNAME }} + REEARTH_CMS_E2E_PASSWORD: ${{ secrets.REEARTH_WEB_E2E_PASSWORD }} + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: web/test-results/ + retention-days: 7 diff --git a/web/.gitignore b/web/.gitignore index 0b5b98da8..95061d89e 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -57,3 +57,4 @@ amplifytools.xcconfig /playwright-report/ /blob-report/ /playwright/.cache/ +/e2e/utils/.auth diff --git a/web/e2e/auth.setup.ts b/web/e2e/auth.setup.ts new file mode 100644 index 000000000..6a67dbc29 --- /dev/null +++ b/web/e2e/auth.setup.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@playwright/test"; + +import { baseURL, authFile } from "../playwright.config"; + +import { config } from "./utils/config"; + +const { userName, password } = config; + +test("authenticate", async ({ page }) => { + expect(userName).toBeTruthy(); + expect(password).toBeTruthy(); + await page.goto(baseURL); + await page.getByPlaceholder("username/email").fill(userName as string); + await page.getByPlaceholder("your password").fill(password as string); + await page.getByText("LOG IN").click(); + await page.waitForURL(baseURL); + await expect(page.getByRole("button", { name: "New Project" }).first()).toBeVisible({ + timeout: 10 * 1000, + }); + await page.context().storageState({ path: authFile }); +}); diff --git a/web/e2e/dashboard.spec.ts b/web/e2e/dashboard.spec.ts deleted file mode 100644 index b9cf2c3d0..000000000 --- a/web/e2e/dashboard.spec.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expect, test } from "@reearth-cms/e2e/utils"; - -test("Home page is displayed", async ({ reearth, page }) => { - await reearth.goto("/", { waitUntil: "domcontentloaded" }); - await expect(page.getByRole("textbox")).toBeVisible(); -}); diff --git a/web/e2e/project/accessibility.spec.ts b/web/e2e/project/accessibility.spec.ts index a3f911c0f..50e6646bc 100644 --- a/web/e2e/project/accessibility.spec.ts +++ b/web/e2e/project/accessibility.spec.ts @@ -1,6 +1,8 @@ import { closeNotification } from "@reearth-cms/e2e/common/notification"; import { expect, test } from "@reearth-cms/e2e/utils"; +import { config } from "../utils/config"; + import { createProject, deleteProject } from "./utils/project"; test.beforeEach(async ({ reearth, page }) => { @@ -14,6 +16,7 @@ test.afterEach(async ({ page }) => { test("Update settings on Accesibility page has succeeded", async ({ page }) => { await page.getByText("Accessibility").click(); + await expect(page.getByRole("textbox")).not.toBeEmpty(); const alias = await page.getByRole("textbox").inputValue(); await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled(); await page.getByText("Private").click(); @@ -24,7 +27,7 @@ test("Update settings on Accesibility page has succeeded", async ({ page }) => { await expect(page.locator("form")).toContainText("Public"); await expect(page.getByRole("textbox")).toHaveValue(alias); await expect(page.getByRole("switch")).toHaveAttribute("aria-checked", "true"); - await expect(page.locator("tbody")).toContainText(`http://localhost:8080/api/p/${alias}/assets`); + await expect(page.locator("tbody")).toContainText(`${config.api}/p/${alias}/assets`); await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled(); }); diff --git a/web/e2e/project/item/fields/asset.spec.ts b/web/e2e/project/item/fields/asset.spec.ts index 15b3121c2..f4f818026 100644 --- a/web/e2e/project/item/fields/asset.spec.ts +++ b/web/e2e/project/item/fields/asset.spec.ts @@ -51,7 +51,7 @@ test("Asset field creating and updating has succeeded", async ({ page }) => { await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page); await page.getByLabel("Back").click(); - await expect(page.getByText("tileset.json")).toBeVisible(); + await expect(page.getByText(uploadFileName_1)).toBeVisible(); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); await page.getByRole("button", { name: `folder ${uploadFileName_1}` }).click(); await page.getByRole("button", { name: "upload Upload Asset" }).click(); @@ -67,7 +67,7 @@ test("Asset field creating and updating has succeeded", async ({ page }) => { await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page); await page.getByLabel("Back").click(); - await expect(page.getByText("lowpolycar.gltf")).toBeVisible(); + await expect(page.getByText(uploadFileName_2)).toBeVisible(); }); test("Asset field editing has succeeded", async ({ page }) => { @@ -162,5 +162,7 @@ test("Asset field editing has succeeded", async ({ page }) => { await closeNotification(page); await page.getByLabel("Back").click(); await page.getByRole("button", { name: "x2" }).click(); - await expect(page.getByRole("tooltip")).toContainText("new asset1 lowpolycar.gltf tileset.json"); + await expect(page.getByRole("tooltip")).toContainText(`new asset1`); + await expect(page.getByRole("tooltip").locator("p").first()).toContainText(uploadFileName_2); + await expect(page.getByRole("tooltip").locator("p").last()).toContainText(uploadFileName_1); }); diff --git a/web/e2e/project/item/metadata/boolean.spec.ts b/web/e2e/project/item/metadata/boolean.spec.ts index 272a2c892..d1cd12327 100644 --- a/web/e2e/project/item/metadata/boolean.spec.ts +++ b/web/e2e/project/item/metadata/boolean.spec.ts @@ -53,6 +53,8 @@ test("Boolean metadata creating and updating has succeeded", async ({ page }) => await page.getByLabel("Back").click(); await expect(page.getByRole("switch", { name: "close" })).toBeVisible(); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByLabel("boolean1").click(); await closeNotification(page); await expect(page.getByLabel("boolean1")).toHaveAttribute("aria-checked", "true"); @@ -65,6 +67,7 @@ test("Boolean metadata creating and updating has succeeded", async ({ page }) => }); test("Boolean metadata editing has succeeded", async ({ page }) => { + test.slow(); await page.getByRole("tab", { name: "Meta Data" }).click(); await page.locator("li").filter({ hasText: "Boolean" }).locator("div").first().click(); await page.getByLabel("Display name").click(); @@ -137,22 +140,16 @@ test("Boolean metadata editing has succeeded", async ({ page }) => { await expect(page.getByRole("switch").nth(0)).toHaveAttribute("aria-checked", "false"); await expect(page.getByRole("switch").nth(1)).toHaveAttribute("aria-checked", "false"); await expect(page.getByRole("switch").nth(2)).toHaveAttribute("aria-checked", "true"); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByRole("button", { name: "plus New" }).click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("switch").nth(2).click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "plus New" }).click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("switch").nth(4).click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "delete" }).first().click(); await closeNotification(page); await page.getByLabel("Back").click(); diff --git a/web/e2e/project/item/metadata/checkbox.spec.ts b/web/e2e/project/item/metadata/checkbox.spec.ts index 48818a5e0..96f0210ac 100644 --- a/web/e2e/project/item/metadata/checkbox.spec.ts +++ b/web/e2e/project/item/metadata/checkbox.spec.ts @@ -53,6 +53,8 @@ test("Checkbox metadata creating and updating has succeeded", async ({ page }) = await page.getByLabel("Back").click(); await expect(page.getByLabel("", { exact: true }).nth(1)).not.toBeChecked(); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByLabel("checkbox1").check(); await closeNotification(page); await expect(page.getByLabel("checkbox1")).toBeChecked(); @@ -68,6 +70,7 @@ test("Checkbox metadata creating and updating has succeeded", async ({ page }) = }); test("Checkbox metadata editing has succeeded", async ({ page }) => { + test.slow(); await page.getByRole("tab", { name: "Meta Data" }).click(); await page.locator("li").filter({ hasText: "Check Box" }).locator("div").first().click(); await page.getByLabel("Display name").click(); @@ -147,22 +150,16 @@ test("Checkbox metadata editing has succeeded", async ({ page }) => { await expect(page.getByLabel("", { exact: true }).nth(0)).toBeChecked(); await expect(page.getByLabel("", { exact: true }).nth(1)).toBeChecked(); await expect(page.getByLabel("", { exact: true }).nth(2)).toBeChecked(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByRole("button", { name: "plus New" }).click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByLabel("", { exact: true }).nth(2).uncheck(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "plus New" }).click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByLabel("", { exact: true }).nth(4).click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "delete" }).first().click(); await closeNotification(page); await expect(page.getByLabel("", { exact: true }).nth(0)).toBeChecked(); diff --git a/web/e2e/project/item/metadata/date.spec.ts b/web/e2e/project/item/metadata/date.spec.ts index e09636be1..bd7ca2e04 100644 --- a/web/e2e/project/item/metadata/date.spec.ts +++ b/web/e2e/project/item/metadata/date.spec.ts @@ -56,6 +56,8 @@ test("Date metadata creating and updating has succeeded", async ({ page }) => { await page.getByLabel("Back").click(); await expect(page.getByPlaceholder("-")).toHaveValue("2024-01-01"); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByPlaceholder("Select date").click(); await page.getByPlaceholder("Select date").fill("2024-01-02"); await page.getByPlaceholder("Select date").press("Enter"); @@ -73,6 +75,7 @@ test("Date metadata creating and updating has succeeded", async ({ page }) => { }); test("Date metadata editing has succeeded", async ({ page }) => { + test.slow(); await page.getByRole("tab", { name: "Meta Data" }).click(); await page.locator("li").filter({ hasText: "Date" }).locator("div").first().click(); await page.getByLabel("Display name").click(); @@ -157,34 +160,25 @@ test("Date metadata editing has succeeded", async ({ page }) => { await expect(page.getByRole("textbox").nth(0)).toHaveValue("2024-01-01"); await expect(page.getByRole("textbox").nth(1)).toHaveValue("2024-01-04"); await expect(page.getByRole("textbox").nth(2)).toHaveValue("2024-01-02"); - await page.getByRole("button", { name: "plus New" }).click(); - await closeNotification(page); // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); + await page.waitForTimeout(500); + await page.getByRole("button", { name: "plus New" }).click(); await page.getByRole("textbox").nth(3).click(); await page.getByRole("textbox").nth(3).fill("2024-01-05"); await page.getByRole("textbox").nth(3).press("Enter"); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "plus New" }).click(); - await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("textbox").nth(4).click(); await page.getByRole("textbox").nth(4).fill("2024-01-06"); await page.getByRole("textbox").nth(4).press("Enter"); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "delete" }).first().click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "plus New" }).click(); + await page.getByRole("textbox").nth(4).click(); + await page.getByRole("textbox").nth(4).fill("2024-01-07"); + await page.getByRole("textbox").nth(4).press("Enter"); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "close-circle" }).nth(4).click(); await closeNotification(page); await expect(page.getByRole("textbox").nth(0)).toHaveValue("2024-01-04"); diff --git a/web/e2e/project/item/metadata/tag.spec.ts b/web/e2e/project/item/metadata/tag.spec.ts index 53922b658..670fda1e6 100644 --- a/web/e2e/project/item/metadata/tag.spec.ts +++ b/web/e2e/project/item/metadata/tag.spec.ts @@ -23,10 +23,10 @@ test("Tag metadata creating and updating has succeeded", async ({ page }) => { await page.getByLabel("Settings").locator("#description").click(); await page.getByLabel("Settings").locator("#description").fill("tag1 description"); await page.getByRole("button", { name: "plus New" }).click(); - await page.locator("div").filter({ hasText: /^Tag$/ }).click(); + await page.locator("div").filter({ hasText: /^Tag$/ }).last().click(); await page.getByLabel("Set Tags").fill("Tag1"); await page.getByRole("button", { name: "plus New" }).click(); - await page.locator("div").filter({ hasText: /^Tag$/ }).click(); + await page.locator("div").filter({ hasText: /^Tag$/ }).last().click(); await page.locator("#tags").nth(1).fill(""); await expect(page.getByText("Empty values are not allowed")).toBeVisible(); await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); @@ -72,6 +72,8 @@ test("Tag metadata creating and updating has succeeded", async ({ page }) => { await page.getByLabel("Back").click(); await expect(page.getByText("Tag1", { exact: true })).toBeVisible(); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByLabel("close-circle").locator("svg").click(); await closeNotification(page); await page.getByLabel("tag1").click(); @@ -92,8 +94,6 @@ test("Tag metadata creating and updating has succeeded", async ({ page }) => { .click(); await closeNotification(page); await expect(page.locator("tbody").getByText("Tag1").first()).toBeVisible(); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("cell", { name: "Tag1", exact: true }).locator("svg").click(); await closeNotification(page); await expect(page.locator("#root").getByText("Tag1", { exact: true }).first()).toBeHidden(); @@ -111,10 +111,10 @@ test("Tag metadata editing has succeeded", async ({ page }) => { await page.getByLabel("Settings").locator("#description").click(); await page.getByLabel("Settings").locator("#description").fill("tag1 description"); await page.getByRole("button", { name: "plus New" }).click(); - await page.locator("div").filter({ hasText: /^Tag$/ }).click(); + await page.locator("div").filter({ hasText: /^Tag$/ }).last().click(); await page.getByLabel("Set Tags").fill("Tag1"); await page.getByRole("button", { name: "plus New" }).click(); - await page.locator("div").filter({ hasText: /^Tag$/ }).click(); + await page.locator("div").filter({ hasText: /^Tag$/ }).last().click(); await page.locator("#tags").nth(1).fill("Tag2"); await page.getByRole("tab", { name: "Default value" }).click(); await page.getByLabel("Set default value").click(); @@ -145,7 +145,7 @@ test("Tag metadata editing has succeeded", async ({ page }) => { await page.getByLabel("Description(optional)").click(); await page.getByLabel("Description(optional)").fill("new tag1 description"); await page.getByRole("button", { name: "plus New" }).click(); - await page.locator("div").filter({ hasText: /^Tag$/ }).click(); + await page.locator("div").filter({ hasText: /^Tag$/ }).last().click(); await page.locator("#tags").nth(2).fill("Tag3"); await page.getByLabel("Support multiple values").check(); await page.getByRole("tab", { name: "Validation" }).click(); @@ -157,11 +157,11 @@ test("Tag metadata editing has succeeded", async ({ page }) => { await expect(page.getByText("Tag1").last()).toBeVisible(); await page.getByText("Tag2").nth(2).click(); await page.getByText("Tag3").nth(2).click(); - await expect(page.getByLabel("Update Tag").getByText("Tag1Tag2Tag3")).toBeVisible(); + await expect(page.getByLabel("Update Tag").getByText("Tag1Tag2Tag3").last()).toBeVisible(); await page.getByRole("tab", { name: "Settings" }).click(); await page.getByLabel("Update Tag").getByRole("button", { name: "delete" }).first().click(); await page.getByRole("tab", { name: "Default value" }).click(); - await expect(page.getByLabel("Update Tag").getByText("Tag2Tag3")).toBeVisible(); + await expect(page.getByLabel("Update Tag").getByText("Tag2Tag3").last()).toBeVisible(); await page.locator(".ant-select-selector").click(); await expect(page.getByText("Tag1").last()).toBeHidden(); await page.locator(".ant-select-selector").click(); @@ -189,7 +189,7 @@ test("Tag metadata editing has succeeded", async ({ page }) => { await expect(page.getByText("Please input field!")).toBeVisible(); await page.locator(".ant-select-selector").click(); await page.getByText("Tag2").click(); - await page.getByLabel("Back").click(); await closeNotification(page); + await page.getByLabel("Back").click(); await expect(page.getByText("Tag2")).toBeVisible(); }); diff --git a/web/e2e/project/item/metadata/text.spec.ts b/web/e2e/project/item/metadata/text.spec.ts index 380f5811a..ea99683a8 100644 --- a/web/e2e/project/item/metadata/text.spec.ts +++ b/web/e2e/project/item/metadata/text.spec.ts @@ -56,10 +56,12 @@ test("Text metadata creating and updating has succeeded", async ({ page }) => { await page.getByLabel("Back").click(); await expect(page.getByPlaceholder("-")).toHaveValue("text1"); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByLabel("text1").click(); await page.getByLabel("text1").fill("new text1"); - await page.getByLabel("Back").click(); await closeNotification(page); + await page.getByLabel("Back").click(); await expect(page.getByPlaceholder("-")).toHaveValue("new text1"); await page.getByPlaceholder("-").click(); @@ -73,6 +75,7 @@ test("Text metadata creating and updating has succeeded", async ({ page }) => { }); test("Text metadata editing has succeeded", async ({ page }) => { + test.slow(); await page.getByRole("tab", { name: "Meta Data" }).click(); await page.locator("li").filter({ hasText: "Text" }).locator("div").first().click(); await page.getByLabel("Display name").click(); @@ -155,6 +158,8 @@ test("Text metadata editing has succeeded", async ({ page }) => { await page.getByRole("tooltip").getByText("new text1").click(); await closeNotification(page); await page.getByRole("cell").getByLabel("edit").locator("svg").first().click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await expect(page.getByLabel("new text1(unique)")).toHaveValue("text3"); await page.getByRole("button", { name: "plus New" }).click(); await page @@ -169,19 +174,13 @@ test("Text metadata editing has succeeded", async ({ page }) => { .fill("text2"); await page.getByText("new text1 description").click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "arrow-down" }).first().click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "arrow-down" }).nth(1).click(); await closeNotification(page); await expect(page.getByLabel("new text1(unique)")).toHaveValue("text1"); await expect(page.getByRole("textbox").nth(1)).toHaveValue("text2"); await expect(page.getByRole("textbox").nth(2)).toHaveValue("text3"); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "delete" }).first().click(); await closeNotification(page); await page.getByLabel("Back").click(); diff --git a/web/e2e/project/item/metadata/url.spec.ts b/web/e2e/project/item/metadata/url.spec.ts index 4dba338f3..275a9d473 100644 --- a/web/e2e/project/item/metadata/url.spec.ts +++ b/web/e2e/project/item/metadata/url.spec.ts @@ -53,10 +53,12 @@ test("Url metadata creating and updating has succeeded", async ({ page }) => { await page.getByLabel("Back").click(); await expect(page.getByRole("link", { name: "http://test1.com" })).toBeVisible(); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByLabel("url1").click(); await page.getByLabel("url1").fill("http://test2.com"); - await page.getByLabel("Back").click(); await closeNotification(page); + await page.getByLabel("Back").click(); await expect(page.getByRole("link", { name: "http://test2.com" })).toBeVisible(); await page.getByRole("link", { name: "http://test2.com" }).hover(); @@ -70,6 +72,7 @@ test("Url metadata creating and updating has succeeded", async ({ page }) => { }); test("Url metadata editing has succeeded", async ({ page }) => { + test.slow(); await page.getByRole("tab", { name: "Meta Data" }).click(); await page.locator("li").filter({ hasText: "Url" }).locator("div").first().click(); await page.getByLabel("Display name").click(); @@ -149,26 +152,15 @@ test("Url metadata editing has succeeded", async ({ page }) => { await page.getByRole("tooltip").getByText("new url1").click(); await closeNotification(page); await page.getByRole("cell").getByLabel("edit").locator("svg").first().click(); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await expect(page.getByRole("textbox").nth(0)).toHaveValue("http://new-default2.com"); await page.getByRole("button", { name: "plus New" }).click(); - await page - .locator("div") - .filter({ hasText: /^0 \/ 500$/ }) - .getByRole("textbox") - .click(); - await page - .locator("div") - .filter({ hasText: /^0 \/ 500$/ }) - .getByRole("textbox") - .fill("http://default3.com"); - await page.getByText("url1 description").click(); + await page.getByRole("textbox").last().click(); + await page.getByRole("textbox").last().fill("http://default3.com"); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "delete" }).first().click(); await closeNotification(page); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(100); await page.getByRole("button", { name: "arrow-up" }).nth(1).click(); await closeNotification(page); await page.getByLabel("Back").click(); diff --git a/web/e2e/project/request.spec.ts b/web/e2e/project/request.spec.ts index fa1a4dc2f..c66a7d9cc 100644 --- a/web/e2e/project/request.spec.ts +++ b/web/e2e/project/request.spec.ts @@ -140,6 +140,7 @@ test("Navigating between item and request has succeeded", async ({ page }) => { await expect(page.getByText(`Request / ${requestTitle}`)).toBeVisible(); await expect(page.getByRole("heading", { name: requestTitle })).toBeVisible(); await page.getByRole("button", { name: itemTitle }).last().click(); + await expect(page.getByLabel(`${titleFieldName}Title`)).not.toBeEmpty(); await page.getByLabel(`${titleFieldName}Title`).click(); await page.getByLabel(`${titleFieldName}Title`).fill(""); await page.getByRole("button", { name: "Save" }).click(); diff --git a/web/e2e/settings/settings.spec.ts b/web/e2e/settings/settings.spec.ts index 979e3507b..8ee565736 100644 --- a/web/e2e/settings/settings.spec.ts +++ b/web/e2e/settings/settings.spec.ts @@ -61,6 +61,8 @@ test("Tiles CRUD has succeeded", async ({ page }) => { }); test("Terrain on/off and CRUD has succeeded", async ({ page }) => { + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await expect(page.getByRole("switch")).toBeEnabled(); await page.getByRole("switch").click(); await expect(page.getByRole("switch")).toHaveAttribute("aria-checked", "true"); @@ -150,6 +152,8 @@ test("Tiles reordering has succeeded", async ({ page }) => { }); test("Terrain reordering has succeeded", async ({ page }) => { + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(500); await page.getByRole("switch").click(); await page.getByRole("button", { name: "plus Add new Terrain option" }).click(); await page.getByRole("button", { name: "OK" }).click(); diff --git a/web/e2e/utils/config.ts b/web/e2e/utils/config.ts index a19089722..05fdbf838 100644 --- a/web/e2e/utils/config.ts +++ b/web/e2e/utils/config.ts @@ -1,11 +1,7 @@ export const config = { - api: process.env["REEARTH_CMS_API"], - userName: process.env["REEARTH_CMS_E2E_USERNAME"], - password: process.env["REEARTH_CMS_E2E_PASSWORD"], - workspaceId: process.env["REEARTH_CMS_E2E_WORKSPACE_ID"], - authAudience: process.env["REEARTH_CMS_AUTH0_AUDIENCE"], - authClientId: process.env["REEARTH_CMS_AUTH0_CLIENT_ID"], - authUrl: process.env["REEARTH_CMS_AUTH0_DOMAIN"], + api: process.env.REEARTH_CMS_API, + userName: process.env.REEARTH_CMS_E2E_USERNAME, + password: process.env.REEARTH_CMS_E2E_PASSWORD, }; export type Config = typeof config; diff --git a/web/e2e/utils/login.ts b/web/e2e/utils/login.ts deleted file mode 100644 index 1251d46cd..000000000 --- a/web/e2e/utils/login.ts +++ /dev/null @@ -1,58 +0,0 @@ -import axios from "axios"; - -import { config } from "./config"; - -export async function login(): Promise { - const { authUrl, userName, password, authAudience, authClientId } = config; - - if (!authUrl || !userName || !password || !authAudience || !authClientId) { - throw new Error( - `either authUrl, userName, password, authAudience and authClientId are missing: ${JSON.stringify( - { - authUrl, - userName, - password: password ? "***" : "", - authAudience, - authClientId, - }, - )}`, - ); - } - - try { - const resp = await axios.post<{ access_token?: string }>( - `${oauthDomain(authUrl)}/oauth/token`, - { - username: userName, - password, - audience: authAudience, - client_id: authClientId, - grant_type: "password", - scope: "openid profile email", - }, - ); - - if (!resp.data.access_token) { - throw new Error("access token is missing"); - } - return resp.data.access_token; - } catch (e) { - throw new Error( - `${e}, config=${JSON.stringify({ - authUrl, - userName, - password: password ? "***" : "", - authAudience, - authClientId, - })}`, - ); - } -} - -function oauthDomain(u: string | undefined): string { - if (!u) return ""; - if (!u.startsWith("https://") && !u.startsWith("http://")) { - u = "https://" + u; - } - return u.endsWith("/") ? u.slice(0, -1) : u; -} diff --git a/web/e2e/utils/setup.ts b/web/e2e/utils/setup.ts deleted file mode 100644 index 47e7c198d..000000000 --- a/web/e2e/utils/setup.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { getAccessToken, setAccessToken } from "./config"; -import { login } from "./login"; - -export default async function globalSetup() { - if (!getAccessToken()) { - setAccessToken(await login()); - } -} diff --git a/web/playwright.config.ts b/web/playwright.config.ts index e0cbe2281..19c4f8f3b 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,20 +1,40 @@ -import { type PlaywrightTestConfig } from "@playwright/test"; +import path, { dirname } from "path"; +import { fileURLToPath } from "url"; + +import { devices, type PlaywrightTestConfig } from "@playwright/test"; import dotenv from "dotenv"; dotenv.config(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +export const authFile = path.join(__dirname, "./e2e/utils/.auth/user.json"); + +export const baseURL = process.env.REEARTH_CMS_E2E_BASEURL || "http://localhost:3000/"; + const config: PlaywrightTestConfig = { + workers: process.env.CI ? 1 : undefined, retries: 2, use: { - baseURL: process.env.REEARTH_CMS_E2E_BASEURL || "http://localhost:3000/", + baseURL, screenshot: "only-on-failure", - video: "retain-on-failure", + video: process.env.CI ? "on-first-retry" : "retain-on-failure", locale: "en-US", }, testDir: "e2e", - globalSetup: "./e2e/utils/setup.ts", reporter: process.env.CI ? "github" : "list", fullyParallel: true, + projects: [ + { name: "setup", testMatch: /.*\.setup\.ts/ }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: authFile, + }, + dependencies: ["setup"], + }, + ], }; export default config; From eda8b2445e34b3fd65b04944c8f3167791c29b39 Mon Sep 17 00:00:00 2001 From: yuya-soneda Date: Tue, 14 Jan 2025 18:56:45 +0900 Subject: [PATCH 04/23] chore(server): enable to set db name from env file (#1348) * chore(server): enable to set db name from env file * fix: review from Yaser --- server/.env.example | 5 +-- server/internal/app/config.go | 3 +- server/internal/app/repo.go | 59 +++++++++++++++++++++-------------- 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/server/.env.example b/server/.env.example index 5409a7c46..44b776810 100644 --- a/server/.env.example +++ b/server/.env.example @@ -2,14 +2,15 @@ PORT=8080 REEARTH_CMS_DB=mongodb://localhost REEARTH_CMS_HOST=https://localhost:8080 -REEARTH_CMS_SERVERHOST=https://localhost:8080 +REEARTH_CMS_SERVERHOST=localhost REEARTH_CMS_HOST_WEB=https://localhost:3000 REEARTH_CMS_ASSETBASEURL=https://localhost:8080/assets REEARTH_CMS_DEV=false REEARTH_CMS_SIGNUPSECRET= REEARTH_CMS_ORIGINS=www.example.com,localhost:3000 -REEARTH_CMS_DB_ACCOUNT=mongodb://localhost +REEARTH_CMS_DB_ACCOUNT=reearth_account +REEARTH_CMS_DB_CMS=reearth_cms REEARTH_CMS_DB_USERS=Test1=mongodb://localhost,Test2=mongodb://localhost #GraphQL diff --git a/server/internal/app/config.go b/server/internal/app/config.go index 4c8440d4a..222c50ea6 100644 --- a/server/internal/app/config.go +++ b/server/internal/app/config.go @@ -57,7 +57,8 @@ type Config struct { // auth for m2m AuthM2M AuthM2MConfig `pp:",omitempty"` - DB_Account string `pp:",omitempty"` + DB_Account string `default:"reearth_account" pp:",omitempty"` + DB_CMS string `default:"reearth_cms" pp:",omitempty"` DB_Users []appx.NamedURI `pp:",omitempty"` } diff --git a/server/internal/app/repo.go b/server/internal/app/repo.go index 0ec3191b3..5de159c86 100644 --- a/server/internal/app/repo.go +++ b/server/internal/app/repo.go @@ -23,7 +23,38 @@ import ( "go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo" ) -const databaseName = "reearth_cms" +func initAccountDB(client *mongo.Client, txAvailable bool, ctx context.Context, conf *Config) *accountrepo.Container { + accountDatabase := conf.DB_Account + log.Infof("account database: %s", accountDatabase) + + accountUsers := make([]accountrepo.User, 0, len(conf.DB_Users)) + for _, u := range conf.DB_Users { + c, err := mongo.Connect(ctx, options.Client().ApplyURI(u.URI).SetMonitor(otelmongo.NewMonitor())) + if err != nil { + log.Fatalf("mongo error: %+v\n", err) + } + accountUsers = append(accountUsers, accountmongo.NewUserWithHost(mongox.NewClient(accountDatabase, c), u.Name)) + } + + acRepos, err := accountmongo.New(ctx, client, accountDatabase, txAvailable, false, accountUsers) + if err != nil { + log.Fatalf("Failed to init mongo: %+v\n", err) + } + + return acRepos +} + +func initCMSDB(client *mongo.Client, txAvailable bool, acRepos *accountrepo.Container, ctx context.Context, conf *Config) *repo.Container { + cmsDatabase := conf.DB_CMS + log.Infof("cms database: %s", cmsDatabase) + + repos, err := mongorepo.New(ctx, client, cmsDatabase, txAvailable, acRepos) + if err != nil { + log.Fatalf("Failed to init mongo: %+v\n", err) + } + + return repos +} func initReposAndGateways(ctx context.Context, conf *Config) (*repo.Container, *gateway.Container, *accountrepo.Container, *accountgateway.Container) { gateways := &gateway.Container{} @@ -42,31 +73,11 @@ func initReposAndGateways(ctx context.Context, conf *Config) (*repo.Container, * log.Fatalf("repo initialization error: %+v\n", err) } - accountDatabase := conf.DB_Account - if accountDatabase == "" { - accountDatabase = databaseName - } - - accountUsers := make([]accountrepo.User, 0, len(conf.DB_Users)) - for _, u := range conf.DB_Users { - c, err := mongo.Connect(ctx, options.Client().ApplyURI(u.URI).SetMonitor(otelmongo.NewMonitor())) - if err != nil { - log.Fatalf("mongo error: %+v\n", err) - } - accountUsers = append(accountUsers, accountmongo.NewUserWithHost(mongox.NewClient(accountDatabase, c), u.Name)) - } - txAvailable := mongox.IsTransactionAvailable(conf.DB) - acRepos, err := accountmongo.New(ctx, client, accountDatabase, txAvailable, false, accountUsers) - if err != nil { - log.Fatalf("Failed to init mongo: %+v\n", err) - } + acRepos := initAccountDB(client, txAvailable, ctx, conf) + cmsRepos := initCMSDB(client, txAvailable, acRepos, ctx, conf) - repos, err := mongorepo.New(ctx, client, databaseName, txAvailable, acRepos) - if err != nil { - log.Fatalf("Failed to init mongo: %+v\n", err) - } // File var fileRepo gateway.File if conf.GCS.BucketName != "" { @@ -117,7 +128,7 @@ func initReposAndGateways(ctx context.Context, conf *Config) (*repo.Container, * log.Infof("task runner: not used") } - return repos, gateways, acRepos, acGateways + return cmsRepos, gateways, acRepos, acGateways } func NewLogMonitor() *event.CommandMonitor { From c0a9ef29da64b8a1065cfaf7e7ae9aad6bb93caf Mon Sep 17 00:00:00 2001 From: jasonkarel <55156603+jasonkarel@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:08:44 +0700 Subject: [PATCH 05/23] chore(server): move export csv items and GeoJSON ability to Item Usecase Layer (#1347) * chore(server): move export csv items and GeoJSON to Item Usecase Layer * fix comments of the function * delete old item export file * add error return consistency --- server/internal/adapter/integration/item.go | 67 ++-- server/internal/usecase/interactor/item.go | 82 +++++ .../interactor}/item_export.go | 12 +- .../interactor}/item_export_test.go | 7 +- .../internal/usecase/interactor/item_test.go | 338 ++++++++++++++++++ server/internal/usecase/interfaces/item.go | 15 + 6 files changed, 465 insertions(+), 56 deletions(-) rename server/internal/{adapter/integration => usecase/interactor}/item_export.go (96%) rename server/internal/{adapter/integration => usecase/interactor}/item_export_test.go (99%) diff --git a/server/internal/adapter/integration/item.go b/server/internal/adapter/integration/item.go index f79f87690..479db985e 100644 --- a/server/internal/adapter/integration/item.go +++ b/server/internal/adapter/integration/item.go @@ -3,7 +3,6 @@ package integration import ( "context" "errors" - "io" "github.com/reearth/reearth-cms/server/internal/usecase" "github.com/reearth/reearth-cms/server/pkg/model" @@ -74,13 +73,7 @@ func (s *Server) ItemsAsGeoJSON(ctx context.Context, request ItemsAsGeoJSONReque op := adapter.Operator(ctx) uc := adapter.Usecases(ctx) - sp, err := uc.Schema.FindByModel(ctx, request.ModelId, op) - if err != nil { - return ItemsAsGeoJSON400Response{}, err - } - - p := fromPagination(request.Params.Page, request.Params.PerPage) - items, _, err := uc.Item.FindBySchema(ctx, sp.Schema().ID(), nil, p, op) + schemaPackage, err := uc.Schema.FindByModel(ctx, request.ModelId, op) if err != nil { if errors.Is(err, rerror.ErrNotFound) { return ItemsAsGeoJSON404Response{}, err @@ -88,14 +81,17 @@ func (s *Server) ItemsAsGeoJSON(ctx context.Context, request ItemsAsGeoJSONReque return ItemsAsGeoJSON400Response{}, err } - fc, err := featureCollectionFromItems(items, sp.Schema()) + featureCollections, err := uc.Item.ItemsAsGeoJSON(ctx, schemaPackage, request.Params.Page, request.Params.PerPage, op) if err != nil { + if errors.Is(err, rerror.ErrNotFound) { + return ItemsAsGeoJSON404Response{}, err + } return ItemsAsGeoJSON400Response{}, err } return ItemsAsGeoJSON200JSONResponse{ - Features: fc.Features, - Type: fc.Type, + Features: featureCollections.FeatureCollections.Features, + Type: featureCollections.FeatureCollections.Type, }, nil } @@ -103,13 +99,7 @@ func (s *Server) ItemsAsCSV(ctx context.Context, request ItemsAsCSVRequestObject op := adapter.Operator(ctx) uc := adapter.Usecases(ctx) - sp, err := uc.Schema.FindByModel(ctx, request.ModelId, op) - if err != nil { - return ItemsAsCSV400Response{}, err - } - - p := fromPagination(request.Params.Page, request.Params.PerPage) - items, _, err := uc.Item.FindBySchema(ctx, sp.Schema().ID(), nil, p, op) + schemaPackage, err := uc.Schema.FindByModel(ctx, request.ModelId, op) if err != nil { if errors.Is(err, rerror.ErrNotFound) { return ItemsAsCSV404Response{}, err @@ -117,14 +107,16 @@ func (s *Server) ItemsAsCSV(ctx context.Context, request ItemsAsCSVRequestObject return ItemsAsCSV400Response{}, err } - pr, pw := io.Pipe() - err = csvFromItems(pw, items, sp.Schema()) + pr, err := uc.Item.ItemsAsCSV(ctx, schemaPackage, request.Params.Page, request.Params.PerPage, op) if err != nil { + if errors.Is(err, rerror.ErrNotFound) { + return ItemsAsCSV404Response{}, err + } return ItemsAsCSV400Response{}, err } return ItemsAsCSV200TextcsvResponse{ - Body: pr, + Body: pr.PipeReader, }, nil } @@ -217,13 +209,7 @@ func (s *Server) ItemsWithProjectAsGeoJSON(ctx context.Context, request ItemsWit return ItemsWithProjectAsGeoJSON400Response{}, err } - sp, err := uc.Schema.FindByModel(ctx, m.ID(), op) - if err != nil { - return ItemsWithProjectAsGeoJSON400Response{}, err - } - - p := fromPagination(request.Params.Page, request.Params.PerPage) - items, _, err := uc.Item.FindBySchema(ctx, sp.Schema().ID(), nil, p, op) + schemaPackage, err := uc.Schema.FindByModel(ctx, m.ID(), op) if err != nil { if errors.Is(err, rerror.ErrNotFound) { return ItemsWithProjectAsGeoJSON404Response{}, err @@ -231,14 +217,17 @@ func (s *Server) ItemsWithProjectAsGeoJSON(ctx context.Context, request ItemsWit return ItemsWithProjectAsGeoJSON400Response{}, err } - fc, err := featureCollectionFromItems(items, sp.Schema()) + featureCollections, err := uc.Item.ItemsAsGeoJSON(ctx, schemaPackage, request.Params.Page, request.Params.PerPage, op) if err != nil { + if errors.Is(err, rerror.ErrNotFound) { + return ItemsWithProjectAsGeoJSON404Response{}, err + } return ItemsWithProjectAsGeoJSON400Response{}, err } return ItemsWithProjectAsGeoJSON200JSONResponse{ - Features: fc.Features, - Type: fc.Type, + Features: featureCollections.FeatureCollections.Features, + Type: featureCollections.FeatureCollections.Type, }, nil } @@ -262,13 +251,7 @@ func (s *Server) ItemsWithProjectAsCSV(ctx context.Context, request ItemsWithPro return ItemsWithProjectAsCSV400Response{}, err } - sp, err := uc.Schema.FindByModel(ctx, m.ID(), op) - if err != nil { - return ItemsWithProjectAsCSV400Response{}, err - } - - p := fromPagination(request.Params.Page, request.Params.PerPage) - items, _, err := uc.Item.FindBySchema(ctx, sp.Schema().ID(), nil, p, op) + schemaPackage, err := uc.Schema.FindByModel(ctx, m.ID(), op) if err != nil { if errors.Is(err, rerror.ErrNotFound) { return ItemsWithProjectAsCSV404Response{}, err @@ -276,14 +259,16 @@ func (s *Server) ItemsWithProjectAsCSV(ctx context.Context, request ItemsWithPro return ItemsWithProjectAsCSV400Response{}, err } - pr, pw := io.Pipe() - err = csvFromItems(pw, items, sp.Schema()) + pr, err := uc.Item.ItemsAsCSV(ctx, schemaPackage, request.Params.Page, request.Params.PerPage, op) if err != nil { + if errors.Is(err, rerror.ErrNotFound) { + return ItemsWithProjectAsCSV404Response{}, err + } return ItemsWithProjectAsCSV400Response{}, err } return ItemsWithProjectAsCSV200TextcsvResponse{ - Body: pr, + Body: pr.PipeReader, }, nil } diff --git a/server/internal/usecase/interactor/item.go b/server/internal/usecase/interactor/item.go index e66a157a4..8955c3dc2 100644 --- a/server/internal/usecase/interactor/item.go +++ b/server/internal/usecase/interactor/item.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "time" "github.com/reearth/reearth-cms/server/internal/usecase" @@ -25,6 +26,9 @@ import ( "golang.org/x/exp/slices" ) +const maxPerPage = 100 +const defaultPerPage int64 = 50 + type Item struct { repos *repo.Container gateways *gateway.Container @@ -1179,3 +1183,81 @@ func (i Item) getReferencedItems(ctx context.Context, fields []*item.Field) ([]i } return i.repos.Item.FindByIDs(ctx, ids, nil) } + +// ItemsAsCSV exports items data in content to csv file by schema package. +func (i Item) ItemsAsCSV(ctx context.Context, schemaPackage *schema.Package, page *int, perPage *int, operator *usecase.Operator) (interfaces.ExportItemsToCSVResponse, error) { + if operator.AcOperator.User == nil && operator.Integration == nil { + return interfaces.ExportItemsToCSVResponse{}, interfaces.ErrInvalidOperator + } + return Run1(ctx, operator, i.repos, Usecase().Transaction(), func(ctx context.Context) (interfaces.ExportItemsToCSVResponse, error) { + + // fromPagination + paginationOffset := fromPagination(page, perPage) + + items, _, err := i.repos.Item.FindBySchema(ctx, schemaPackage.Schema().ID(), nil, nil, paginationOffset) + if err != nil { + return interfaces.ExportItemsToCSVResponse{}, err + } + + pr, pw := io.Pipe() + err = csvFromItems(pw, items, schemaPackage.Schema()) + if err != nil { + return interfaces.ExportItemsToCSVResponse{}, err + } + + return interfaces.ExportItemsToCSVResponse{ + PipeReader: pr, + }, nil + }) +} + +// ItemsAsGeoJSON converts items to Geo JSON type given the schema package +func (i Item) ItemsAsGeoJSON(ctx context.Context, schemaPackage *schema.Package, page *int, perPage *int, operator *usecase.Operator) (interfaces.ExportItemsToGeoJSONResponse, error) { + + if operator.AcOperator.User == nil && operator.Integration == nil { + return interfaces.ExportItemsToGeoJSONResponse{}, interfaces.ErrInvalidOperator + } + + return Run1(ctx, operator, i.repos, Usecase().Transaction(), func(ctx context.Context) (interfaces.ExportItemsToGeoJSONResponse, error) { + + // fromPagination + paginationOffset := fromPagination(page, perPage) + + items, _, err := i.repos.Item.FindBySchema(ctx, schemaPackage.Schema().ID(), nil, nil, paginationOffset) + if err != nil { + return interfaces.ExportItemsToGeoJSONResponse{}, err + } + + featureCollections, err := featureCollectionFromItems(items, schemaPackage.Schema()) + if err != nil { + return interfaces.ExportItemsToGeoJSONResponse{}, err + } + + return interfaces.ExportItemsToGeoJSONResponse{ + FeatureCollections: featureCollections, + }, nil + }) +} + +func fromPagination(page, perPage *int) *usecasex.Pagination { + p := int64(1) + if page != nil && *page > 0 { + p = int64(*page) + } + + pp := defaultPerPage + if perPage != nil { + if ppr := *perPage; 1 <= ppr { + if ppr > maxPerPage { + pp = int64(maxPerPage) + } else { + pp = int64(ppr) + } + } + } + + return usecasex.OffsetPagination{ + Offset: (p - 1) * pp, + Limit: pp, + }.Wrap() +} diff --git a/server/internal/adapter/integration/item_export.go b/server/internal/usecase/interactor/item_export.go similarity index 96% rename from server/internal/adapter/integration/item_export.go rename to server/internal/usecase/interactor/item_export.go index fcc4ee77e..cae495280 100644 --- a/server/internal/adapter/integration/item_export.go +++ b/server/internal/usecase/interactor/item_export.go @@ -1,14 +1,14 @@ -package integration +package interactor import ( "encoding/csv" "io" + "github.com/labstack/gommon/log" "github.com/reearth/reearth-cms/server/pkg/integrationapi" "github.com/reearth/reearth-cms/server/pkg/item" "github.com/reearth/reearth-cms/server/pkg/schema" "github.com/reearth/reearthx/i18n" - "github.com/reearth/reearthx/log" "github.com/reearth/reearthx/rerror" "github.com/samber/lo" ) @@ -27,12 +27,9 @@ func csvFromItems(pw *io.PipeWriter, l item.VersionedList, s *schema.Schema) err if !s.IsPointFieldSupported() { return pointFieldIsNotSupportedError } - go handleCSVGeneration(pw, l, s) - return nil } - func handleCSVGeneration(pw *io.PipeWriter, l item.VersionedList, s *schema.Schema) { err := generateCSV(pw, l, s) if err != nil { @@ -42,20 +39,16 @@ func handleCSVGeneration(pw *io.PipeWriter, l item.VersionedList, s *schema.Sche _ = pw.Close() } } - func generateCSV(pw *io.PipeWriter, l item.VersionedList, s *schema.Schema) error { w := csv.NewWriter(pw) defer w.Flush() - headers := integrationapi.BuildCSVHeaders(s) if err := w.Write(headers); err != nil { return err } - nonGeoFields := lo.Filter(s.Fields(), func(f *schema.Field, _ int) bool { return !f.IsGeometryField() }) - for _, ver := range l { row, ok := integrationapi.RowFromItem(ver.Value(), nonGeoFields) if ok { @@ -67,4 +60,3 @@ func generateCSV(pw *io.PipeWriter, l item.VersionedList, s *schema.Schema) erro return w.Error() } - diff --git a/server/internal/adapter/integration/item_export_test.go b/server/internal/usecase/interactor/item_export_test.go similarity index 99% rename from server/internal/adapter/integration/item_export_test.go rename to server/internal/usecase/interactor/item_export_test.go index 774f31abf..67e3af31b 100644 --- a/server/internal/adapter/integration/item_export_test.go +++ b/server/internal/usecase/interactor/item_export_test.go @@ -1,4 +1,4 @@ -package integration +package interactor import ( "io" @@ -44,13 +44,11 @@ func TestCSVFromItems(t *testing.T) { MustBuild() v1 := version.New() vi1 := version.MustBeValue(v1, nil, version.NewRefs(version.Latest), util.Now(), i1) - // with geometry fields ver1 := item.VersionedList{vi1} _, pw := io.Pipe() err := csvFromItems(pw, ver1, s1) assert.Nil(t, err) - // no geometry fields iid2 := id.NewItemID() sid2 := id.NewSchemaID() @@ -73,7 +71,6 @@ func TestCSVFromItems(t *testing.T) { _, pw1 := io.Pipe() err = csvFromItems(pw1, ver2, s2) assert.Equal(t, expectErr2, err) - // point field is not supported iid3 := id.NewItemID() sid3 := id.NewSchemaID() @@ -97,4 +94,4 @@ func TestCSVFromItems(t *testing.T) { _, pw2 := io.Pipe() err = csvFromItems(pw2, ver3, s3) assert.Equal(t, expectErr3, err) -} \ No newline at end of file +} diff --git a/server/internal/usecase/interactor/item_test.go b/server/internal/usecase/interactor/item_test.go index a8ce0df47..081cee6b4 100644 --- a/server/internal/usecase/interactor/item_test.go +++ b/server/internal/usecase/interactor/item_test.go @@ -3,6 +3,7 @@ package interactor import ( "context" "errors" + "io" "testing" "time" @@ -11,6 +12,7 @@ import ( "github.com/reearth/reearth-cms/server/internal/usecase/interfaces" "github.com/reearth/reearth-cms/server/internal/usecase/repo" "github.com/reearth/reearth-cms/server/pkg/id" + "github.com/reearth/reearth-cms/server/pkg/integrationapi" "github.com/reearth/reearth-cms/server/pkg/item" "github.com/reearth/reearth-cms/server/pkg/model" "github.com/reearth/reearth-cms/server/pkg/project" @@ -22,6 +24,7 @@ import ( "github.com/reearth/reearthx/account/accountdomain/user" "github.com/reearth/reearthx/account/accountdomain/workspace" "github.com/reearth/reearthx/account/accountusecase" + "github.com/reearth/reearthx/i18n" "github.com/reearth/reearthx/rerror" "github.com/reearth/reearthx/usecasex" "github.com/reearth/reearthx/util" @@ -1082,3 +1085,338 @@ func TestWorkFlow(t *testing.T) { assert.NoError(t, err) assert.Equal(t, map[id.ItemID]item.Status{i.ID(): item.StatusPublic}, status) } + +func TestItem_ItemsAsCSV(t *testing.T) { + r := []workspace.Role{workspace.RoleReader, workspace.RoleWriter} + w := accountdomain.NewWorkspaceID() + prj := project.New().NewID().Workspace(w).RequestRoles(r).MustBuild() + + gst := schema.GeometryObjectSupportedTypeList{schema.GeometryObjectSupportedTypePoint, schema.GeometryObjectSupportedTypeLineString} + gest := schema.GeometryEditorSupportedTypeList{schema.GeometryEditorSupportedTypePoint, schema.GeometryEditorSupportedTypeLineString} + + // Geometry Object type + sid1 := id.NewSchemaID() + fid1 := id.NewFieldID() + sf1 := schema.NewField(schema.NewGeometryObject(gst).TypeProperty()).NewID().Name("geo1").Key(id.RandomKey()).ID(fid1).MustBuild() + s1 := schema.New().ID(sid1).Workspace(w).Project(prj.ID()).Fields(schema.FieldList{sf1}).MustBuild() + sp1 := schema.NewPackage(s1, nil, nil, nil) + m1 := model.New().NewID().Schema(s1.ID()).Key(id.RandomKey()).Project(s1.Project()).MustBuild() + fi1 := item.NewField(sf1.ID(), value.TypeGeometryObject.Value("{\"coordinates\":[139.28179282584915,36.58570985749664],\"type\":\"Point\"}").AsMultiple(), nil) + fs1 := []*item.Field{fi1} + i1 := item.New().ID(id.NewItemID()).Schema(s1.ID()).Model(m1.ID()).Project(s1.Project()).Thread(id.NewThreadID()).Fields(fs1).MustBuild() + i1IDStr := i1.ID().String() + + // GeometryEditor type item + sid2 := id.NewSchemaID() + fid2 := id.NewFieldID() + sf2 := schema.NewField(schema.NewGeometryEditor(gest).TypeProperty()).NewID().Name("geo2").Key(id.RandomKey()).ID(fid2).MustBuild() + s2 := schema.New().ID(sid2).Workspace(accountdomain.NewWorkspaceID()).Project(prj.ID()).Fields(schema.FieldList{sf2}).MustBuild() + m2 := model.New().NewID().Schema(s2.ID()).Key(id.RandomKey()).Project(s2.Project()).MustBuild() + fi2 := item.NewField(sf2.ID(), value.TypeGeometryEditor.Value("{\"coordinates\": [[[ ],[138.90306434425662,36.33622175736386],[138.67187898370287,36.33622175736386],[138.67187898370287,36.11737907906834],[138.90306434425662,36.11737907906834]]],\"type\": \"Polygon\"}").AsMultiple(), nil) + fs2 := []*item.Field{fi2} + i2 := item.New().NewID().Schema(s2.ID()).Model(m2.ID()).Project(s2.Project()).Thread(id.NewThreadID()).Fields(fs2).MustBuild() + sp2 := schema.NewPackage(s2, nil, nil, nil) + + // integer type item + fid3 := id.NewFieldID() + in4, _ := schema.NewInteger(lo.ToPtr(int64(1)), lo.ToPtr(int64(100))) + tp4 := in4.TypeProperty() + sf3 := schema.NewField(tp4).NewID().Name("age").Key(id.RandomKey()).ID(fid3).MustBuild() + s3 := schema.New().ID(sid2).Workspace(accountdomain.NewWorkspaceID()).Project(prj.ID()).Fields(schema.FieldList{sf3}).MustBuild() + m3 := model.New().NewID().Schema(s3.ID()).Key(id.RandomKey()).Project(s3.Project()).MustBuild() + fs3 := []*item.Field{item.NewField(sf3.ID(), value.TypeReference.Value(nil).AsMultiple(), nil)} + i3 := item.New().NewID().Schema(s3.ID()).Model(m3.ID()).Project(s3.Project()).Thread(id.NewThreadID()).Fields(fs3).MustBuild() + sp3 := schema.NewPackage(s3, nil, nil, nil) + + page1 := 1 + perPage1 := 10 + + wid := accountdomain.NewWorkspaceID() + u := user.New().NewID().Email("aaa@bbb.com").Workspace(wid).Name("foo").MustBuild() + op := &usecase.Operator{ + AcOperator: &accountusecase.Operator{ + User: lo.ToPtr(u.ID()), + }, + } + + opUserNil := &usecase.Operator{ + AcOperator: &accountusecase.Operator{}, + } + ctx := context.Background() + + type args struct { + ctx context.Context + schemaPackage *schema.Package + page *int + perPage *int + op *usecase.Operator + } + tests := []struct { + name string + args args + seedsItems item.List + seedSchemas *schema.Schema + seedModels *model.Model + want []byte + wantError error + }{ + { + name: "success", + args: args{ + ctx: ctx, + schemaPackage: sp1, + page: &page1, + perPage: &perPage1, + op: op, + }, + seedsItems: item.List{i1}, + seedSchemas: s1, + seedModels: m1, + want: []byte("id,location_lat,location_lng\n" + i1IDStr + ",139.28179282584915,36.58570985749664\n"), + wantError: nil, + }, + { + name: "success geometry editor type", + args: args{ + ctx: ctx, + schemaPackage: sp2, + page: &page1, + perPage: &perPage1, + op: op, + }, + seedsItems: item.List{i2}, + seedSchemas: s2, + seedModels: m2, + want: []byte("id,location_lat,location_lng\n"), + wantError: nil, + }, + { + name: "error point type is not supported in any geometry field non geometry field", + args: args{ + ctx: ctx, + schemaPackage: sp3, + page: &page1, + perPage: &perPage1, + op: op, + }, + seedsItems: item.List{i3}, + seedSchemas: s3, + seedModels: m3, + want: []byte(nil), + wantError: pointFieldIsNotSupportedError, + }, + { + name: "error operator user is nil", + args: args{ + ctx: ctx, + schemaPackage: sp3, + page: &page1, + perPage: &perPage1, + op: opUserNil, + }, + want: []byte(nil), + wantError: interfaces.ErrInvalidOperator, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db := memory.New() + for _, seed := range tt.seedsItems { + err := db.Item.Save(ctx, seed) + assert.NoError(t, err) + } + + if tt.seedSchemas != nil { + err := db.Schema.Save(ctx, tt.seedSchemas) + assert.NoError(t, err) + } + if tt.seedModels != nil { + err := db.Model.Save(ctx, tt.seedModels) + assert.NoError(t, err) + } + itemUC := NewItem(db, nil) + itemUC.ignoreEvent = true + + pr, err := itemUC.ItemsAsCSV(ctx, tt.args.schemaPackage, tt.args.page, tt.args.perPage, tt.args.op) + + var result []byte + if pr.PipeReader != nil { + result, _ = io.ReadAll(pr.PipeReader) + } + + assert.Equal(t, tt.want, result) + assert.Equal(t, tt.wantError, err) + }) + } +} + +func TestItem_ItemsAsGeoJSON(t *testing.T) { + r := []workspace.Role{workspace.RoleReader, workspace.RoleWriter} + w := accountdomain.NewWorkspaceID() + prj := project.New().NewID().Workspace(w).RequestRoles(r).MustBuild() + + gst := schema.GeometryObjectSupportedTypeList{schema.GeometryObjectSupportedTypePoint, schema.GeometryObjectSupportedTypeLineString} + gest := schema.GeometryEditorSupportedTypeList{schema.GeometryEditorSupportedTypePoint, schema.GeometryEditorSupportedTypeLineString} + + sid1 := id.NewSchemaID() + fid1 := id.NewFieldID() + sf1 := schema.NewField(schema.NewGeometryObject(gst).TypeProperty()).NewID().Name("geo1").Key(id.RandomKey()).ID(fid1).MustBuild() + s1 := schema.New().ID(sid1).Workspace(w).Project(prj.ID()).Fields(schema.FieldList{sf1}).MustBuild() + sp1 := schema.NewPackage(s1, nil, nil, nil) + m1 := model.New().NewID().Schema(s1.ID()).Key(id.RandomKey()).Project(s1.Project()).MustBuild() + fi1 := item.NewField(sf1.ID(), value.TypeGeometryObject.Value("{\"coordinates\":[139.28179282584915,36.58570985749664],\"type\":\"Point\"}").AsMultiple(), nil) + fs1 := []*item.Field{fi1} + i1 := item.New().ID(id.NewItemID()).Schema(s1.ID()).Model(m1.ID()).Project(s1.Project()).Thread(id.NewThreadID()).Fields(fs1).MustBuild() + + v1 := version.New() + vi1 := version.MustBeValue(v1, nil, version.NewRefs(version.Latest), util.Now(), i1) + // with geometry fields + ver1 := item.VersionedList{vi1} + + fc1, _ := featureCollectionFromItems(ver1, s1) + + sid2 := id.NewSchemaID() + fid2 := id.NewFieldID() + sf2 := schema.NewField(schema.NewGeometryEditor(gest).TypeProperty()).NewID().Name("geo2").Key(id.RandomKey()).ID(fid2).MustBuild() + s2 := schema.New().ID(sid2).Workspace(accountdomain.NewWorkspaceID()).Project(prj.ID()).Fields(schema.FieldList{sf2}).MustBuild() + sp2 := schema.NewPackage(s2, nil, nil, nil) + m2 := model.New().NewID().Schema(s2.ID()).Key(id.RandomKey()).Project(s2.Project()).MustBuild() + fi2 := item.NewField(sf2.ID(), value.TypeGeometryEditor.Value("{\"coordinates\": [[[138.90306434425662,36.11737907906834],[138.90306434425662,36.33622175736386],[138.67187898370287,36.33622175736386],[138.67187898370287,36.11737907906834],[138.90306434425662,36.11737907906834]]],\"type\": \"Polygon\"}").AsMultiple(), nil) + fs2 := []*item.Field{fi2} + i2 := item.New().NewID().Schema(s2.ID()).Model(m2.ID()).Project(s2.Project()).Thread(id.NewThreadID()).Fields(fs2).MustBuild() + v2 := version.New() + vi2 := version.MustBeValue(v2, nil, version.NewRefs(version.Latest), util.Now(), i2) + + ver2 := item.VersionedList{vi2} + fc2, _ := featureCollectionFromItems(ver2, s2) + + fid3 := id.NewFieldID() + in4, _ := schema.NewInteger(lo.ToPtr(int64(1)), lo.ToPtr(int64(100))) + tp4 := in4.TypeProperty() + sf3 := schema.NewField(tp4).NewID().Name("age").Key(id.RandomKey()).ID(fid3).MustBuild() + s3 := schema.New().ID(sid2).Workspace(accountdomain.NewWorkspaceID()).Project(prj.ID()).Fields(schema.FieldList{sf3}).MustBuild() + sp3 := schema.NewPackage(s3, nil, nil, nil) + m3 := model.New().NewID().Schema(s3.ID()).Key(id.RandomKey()).Project(s3.Project()).MustBuild() + fs3 := []*item.Field{item.NewField(sf3.ID(), value.TypeReference.Value(nil).AsMultiple(), nil)} + i3 := item.New().NewID().Schema(s3.ID()).Model(m3.ID()).Project(s3.Project()).Thread(id.NewThreadID()).Fields(fs3).MustBuild() + + page1 := 1 + perPage1 := 10 + + wid := accountdomain.NewWorkspaceID() + u := user.New().NewID().Email("aaa@bbb.com").Workspace(wid).Name("foo").MustBuild() + op := &usecase.Operator{ + AcOperator: &accountusecase.Operator{ + User: lo.ToPtr(u.ID()), + }, + } + + opUserNil := &usecase.Operator{ + AcOperator: &accountusecase.Operator{}, + } + + type args struct { + ctx context.Context + schemaPackage *schema.Package + page *int + perPage *int + op *usecase.Operator + } + tests := []struct { + name string + args args + seedsItems item.List + seedSchemas *schema.Schema + seedModels *model.Model + want *integrationapi.FeatureCollection + wantError error + }{ + { + name: "success", + args: args{ + ctx: context.Background(), + schemaPackage: sp1, + page: &page1, + perPage: &perPage1, + op: op, + }, + seedsItems: item.List{i1}, + seedSchemas: s1, + seedModels: m1, + want: fc1, + wantError: nil, + }, + { + name: "success geometry editor type", + args: args{ + ctx: context.Background(), + schemaPackage: sp2, + page: &page1, + perPage: &perPage1, + op: op, + }, + seedsItems: item.List{i2}, + seedSchemas: s2, + seedModels: m2, + want: fc2, + wantError: nil, + }, + { + name: "error no geometry field in this model / integer", + args: args{ + ctx: context.Background(), + schemaPackage: sp3, + page: &page1, + perPage: &perPage1, + op: op, + }, + seedsItems: item.List{i3}, + seedSchemas: s3, + seedModels: m3, + want: nil, + wantError: rerror.NewE(i18n.T("no geometry field in this model")), + }, + { + name: "error operator user is nil", + args: args{ + ctx: context.Background(), + schemaPackage: sp3, + page: &page1, + perPage: &perPage1, + op: opUserNil, + }, + want: nil, + wantError: interfaces.ErrInvalidOperator, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + db := memory.New() + + for _, seed := range tt.seedsItems { + err := db.Item.Save(ctx, seed) + assert.NoError(t, err) + } + + if tt.seedSchemas != nil { + err := db.Schema.Save(ctx, tt.seedSchemas) + assert.NoError(t, err) + } + if tt.seedModels != nil { + err := db.Model.Save(ctx, tt.seedModels) + assert.NoError(t, err) + } + itemUC := NewItem(db, nil) + itemUC.ignoreEvent = true + result, err := itemUC.ItemsAsGeoJSON(ctx, tt.args.schemaPackage, tt.args.page, tt.args.perPage, tt.args.op) + + assert.Equal(t, tt.want, result.FeatureCollections) + assert.Equal(t, tt.wantError, err) + }) + } +} diff --git a/server/internal/usecase/interfaces/item.go b/server/internal/usecase/interfaces/item.go index 425706c1d..3935c820b 100644 --- a/server/internal/usecase/interfaces/item.go +++ b/server/internal/usecase/interfaces/item.go @@ -2,10 +2,12 @@ package interfaces import ( "context" + "io" "time" "github.com/reearth/reearth-cms/server/internal/usecase" "github.com/reearth/reearth-cms/server/pkg/id" + "github.com/reearth/reearth-cms/server/pkg/integrationapi" "github.com/reearth/reearth-cms/server/pkg/item" "github.com/reearth/reearth-cms/server/pkg/model" "github.com/reearth/reearth-cms/server/pkg/schema" @@ -80,6 +82,15 @@ type ImportItemsResponse struct { NewFields schema.FieldList } +// ExportItemsToCSVResponse contains exported csv data from items +type ExportItemsToCSVResponse struct { + PipeReader *io.PipeReader +} + +type ExportItemsToGeoJSONResponse struct { + FeatureCollections *integrationapi.FeatureCollection +} + type Item interface { FindByID(context.Context, id.ItemID, *usecase.Operator) (item.Versioned, error) FindPublicByID(context.Context, id.ItemID, *usecase.Operator) (item.Versioned, error) @@ -99,4 +110,8 @@ type Item interface { Publish(context.Context, id.ItemIDList, *usecase.Operator) (item.VersionedList, error) Unpublish(context.Context, id.ItemIDList, *usecase.Operator) (item.VersionedList, error) Import(context.Context, ImportItemsParam, *usecase.Operator) (ImportItemsResponse, error) + // ItemsAsCSV exports items data in content to csv file by schema package. + ItemsAsCSV(context.Context, *schema.Package, *int, *int, *usecase.Operator) (ExportItemsToCSVResponse, error) + // ItemsAsGeoJSON converts items to Geo JSON type given thge schema package. + ItemsAsGeoJSON(context.Context, *schema.Package, *int, *int, *usecase.Operator) (ExportItemsToGeoJSONResponse, error) } From 61a5e688c7734f45b007e1b4d923dc89a9963025 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Wed, 15 Jan 2025 11:01:55 +0300 Subject: [PATCH 06/23] feat(ci,server,worker): implement schemata and items copy (#1331) * add copy endpoint * wip: server implementation * update copy model function name * wip: copier ci * remove copy of the internal server files in worker docker image * wip: worker * wip: repo * apply new changes from worker to server * refactor changes type * refactor copier in mongo * feat: refactor copier main file * add e2e test * wip: server test cases * wip: worker tests * update TestSchema_CopyFrom * requested changes copier * add key to params * add a log after data successfully copied * improve triggerCopyEvent logging * fix db URI * add more unit tests --- .github/workflows/build_copier.yml | 106 ++++++++ .github/workflows/build_worker.yml | 53 ++++ server/e2e/integration_model_test.go | 85 ++++++ server/internal/adapter/integration/model.go | 32 +++ .../adapter/integration/server.gen.go | 250 +++++++++++++----- server/internal/infrastructure/gcp/config.go | 1 + .../internal/infrastructure/gcp/taskrunner.go | 78 +++++- .../infrastructure/gcp/taskrunner_test.go | 6 +- server/internal/usecase/interactor/model.go | 148 +++++++++++ .../internal/usecase/interactor/model_test.go | 112 ++++++++ server/internal/usecase/interfaces/model.go | 7 + server/pkg/integrationapi/types.gen.go | 11 +- server/pkg/schema/schema.go | 8 + server/pkg/schema/schema_test.go | 14 + server/pkg/task/task.go | 29 ++ server/schemas/integration.yml | 37 ++- worker/Dockerfile | 1 - worker/cmd/copier/main.go | 70 +++++ worker/copier.Dockerfile | 8 + worker/internal/adapter/http/copy.go | 38 +++ worker/internal/adapter/http/main.go | 7 +- worker/internal/app/main.go | 9 +- .../infrastructure/mongo/common_test.go | 7 + .../infrastructure/mongo/container.go | 34 +++ .../internal/infrastructure/mongo/copier.go | 97 +++++++ .../infrastructure/mongo/copier_test.go | 98 +++++++ .../infrastructure/mongo/webhook_test.go | 4 - worker/internal/usecase/interactor/copier.go | 12 + worker/internal/usecase/interactor/usecase.go | 9 +- worker/internal/usecase/interactor/webhook.go | 4 +- worker/internal/usecase/repo/container.go | 6 + worker/internal/usecase/repo/copier.go | 12 + 32 files changed, 1294 insertions(+), 99 deletions(-) create mode 100644 .github/workflows/build_copier.yml create mode 100644 worker/cmd/copier/main.go create mode 100644 worker/copier.Dockerfile create mode 100644 worker/internal/adapter/http/copy.go create mode 100644 worker/internal/infrastructure/mongo/common_test.go create mode 100644 worker/internal/infrastructure/mongo/container.go create mode 100644 worker/internal/infrastructure/mongo/copier.go create mode 100644 worker/internal/infrastructure/mongo/copier_test.go create mode 100644 worker/internal/usecase/interactor/copier.go create mode 100644 worker/internal/usecase/repo/container.go create mode 100644 worker/internal/usecase/repo/copier.go diff --git a/.github/workflows/build_copier.yml b/.github/workflows/build_copier.yml new file mode 100644 index 000000000..f8682c4d7 --- /dev/null +++ b/.github/workflows/build_copier.yml @@ -0,0 +1,106 @@ +name: copier-build +on: + workflow_run: + workflows: [ci-worker] + types: [completed] + branches: [main, release] +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + info: + name: Collect information + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion != 'failure' && github.event.repository.full_name == 'reearth/reearth-cms' && (github.event.workflow_run.head_branch == 'release' || !startsWith(github.event.workflow_run.head_commit.message, 'v')) + outputs: + sha_short: ${{ steps.info.outputs.sha_short }} + new_tag: ${{ steps.info.outputs.new_tag }} + new_tag_short: ${{ steps.info.outputs.new_tag_short }} + name: ${{ steps.info.outputs.name }} + steps: + - name: checkout + uses: actions/checkout@v4 + - name: Fetch tags + run: git fetch --prune --unshallow --tags + - name: Get info + id: info + # The tag name should be retrieved lazily, as tagging may be delayed. + env: + BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + if [[ "$BRANCH" = "release" ]]; then + TAG=$(git tag --points-at HEAD) + if [[ ! -z "$TAG" ]]; then + echo "new_tag=$TAG" >> "$GITHUB_OUTPUT" + echo "new_tag_short=${TAG#v}" >> "$GITHUB_OUTPUT" + else + echo "name=rc" >> "$GITHUB_OUTPUT" + fi + else + echo "name=nightly" >> "$GITHUB_OUTPUT" + fi + - name: Show info + env: + SHA_SHORT: ${{ steps.info.outputs.sha_short }} + NEW_TAG: ${{ steps.info.outputs.new_tag }} + NEW_TAG_SHORT: ${{ steps.info.outputs.new_tag_short }} + NAME: ${{ steps.info.outputs.name }} + run: echo "sha_short=$SHA_SHORT, new_tag=$NEW_TAG, new_tag_short=$NEW_TAG_SHORT, name=$NAME" + + docker: + name: Build and push Docker image + runs-on: ubuntu-latest + needs: + - info + if: needs.info.outputs.name || needs.info.outputs.new_tag + env: + IMAGE_NAME: reearth/reearth-cms-copier + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Get options + id: options + env: + TAG: ${{ needs.info.outputs.new_tag_short }} + NAME: ${{ needs.info.outputs.name }} + SHA: ${{ needs.info.outputs.sha_short }} + run: | + if [[ -n $TAG ]]; then + PLATFORMS=linux/amd64,linux/arm64 + VERSION=$TAG + TAGS=$IMAGE_NAME:$TAG + if [[ ! $TAG =~ '-' ]]; then + TAGS+=,${IMAGE_NAME}:${TAG%.*} + TAGS+=,${IMAGE_NAME}:${TAG%%.*} + TAGS+=,${IMAGE_NAME}:latest + fi + else + PLATFORMS=linux/amd64 + VERSION=$SHA + TAGS=$IMAGE_NAME:$NAME + fi + echo "platforms=$PLATFORMS" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + - name: Build and push docker image + uses: docker/build-push-action@v6 + with: + context: ./worker + file: ./worker/copier.Dockerfile + platforms: ${{ steps.options.outputs.platforms }} + push: true + build-args: VERSION=${{ steps.options.outputs.version }} + tags: ${{ steps.options.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/build_worker.yml b/.github/workflows/build_worker.yml index d5cce74fb..6e2d7ee30 100644 --- a/.github/workflows/build_worker.yml +++ b/.github/workflows/build_worker.yml @@ -123,3 +123,56 @@ jobs: tags: ${{ steps.options.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max + + docker_copier: + runs-on: ubuntu-latest + if: inputs.name || inputs.new_tag + env: + IMAGE_NAME: reearth/reearth-cms-copier + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Get options + id: options + env: + TAG: ${{ inputs.new_tag_short }} + NAME: ${{ inputs.name }} + SHA: ${{ inputs.sha_short }} + run: | + if [[ -n $TAG ]]; then + PLATFORMS=linux/amd64,linux/arm64 + VERSION=$TAG + TAGS=$IMAGE_NAME:$TAG + if [[ ! $TAG =~ '-' ]]; then + TAGS+=,${IMAGE_NAME}:${TAG%.*} + TAGS+=,${IMAGE_NAME}:${TAG%%.*} + TAGS+=,${IMAGE_NAME}:latest + fi + else + PLATFORMS=linux/amd64 + VERSION=$SHA + TAGS=$IMAGE_NAME:$NAME + fi + echo "platforms=$PLATFORMS" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + - name: Build and push docker image + uses: docker/build-push-action@v6 + with: + context: ./worker + file: ./worker/copier.Dockerfile + platforms: ${{ steps.options.outputs.platforms }} + push: true + build-args: VERSION=${{ steps.options.outputs.version }} + tags: ${{ steps.options.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/server/e2e/integration_model_test.go b/server/e2e/integration_model_test.go index f9e687d16..f6aff37a5 100644 --- a/server/e2e/integration_model_test.go +++ b/server/e2e/integration_model_test.go @@ -57,6 +57,91 @@ func TestIntegrationModelGetAPI(t *testing.T) { obj.Value("lastModified").NotNull() } +// POST /models/{modelId}/copy +func TestIntegrationModelCopy(t *testing.T) { + endpoint := "/api/models/{modelId}/copy" + e := StartServer(t, &app.Config{}, true, baseSeeder) + + e.POST(endpoint, id.NewModelID()). + Expect(). + Status(http.StatusUnauthorized) + + e.POST(endpoint, id.NewModelID()). + WithHeader("authorization", "secret_abc"). + Expect(). + Status(http.StatusUnauthorized) + + e.POST(endpoint, id.NewModelID()). + WithHeader("authorization", "Bearer secret_abc"). + Expect(). + Status(http.StatusUnauthorized) + + oldModelId := mId1.String() + oldModel := e.GET("/api/models/{modelId}", oldModelId). + WithHeader("authorization", "Bearer "+secret). + Expect(). + Status(http.StatusOK). + JSON(). + Object() + + newName := "new name" + newKey := id.RandomKey().Ref().StringRef() + newModel := e.POST(endpoint, oldModelId). + WithHeader("authorization", "Bearer "+secret). + WithJSON(map[string]interface{}{ + "name": newName, + "key": newKey, + }). + Expect(). + Status(http.StatusOK). + JSON(). + Object() + + newModel. + ContainsKey("id"). + ContainsKey("projectId"). + ContainsKey("schemaId"). + ContainsKey("public"). + ContainsKey("createdAt"). + ContainsKey("updatedAt"). + ContainsKey("key") + + newModelID := newModel.Value("id").String() + newModelID.NotEqual(oldModelId) + copiedModel := e.GET("/api/models/{modelId}", newModelID.Raw()). + WithHeader("authorization", "Bearer "+secret). + Expect(). + Status(http.StatusOK). + JSON(). + Object() + copiedModel. + HasValue("id", newModelID.Raw()). + HasValue("projectId", oldModel.Value("projectId").String().Raw()). + HasValue("public", oldModel.Value("public").Boolean().Raw()). + HasValue("name", newName). + HasValue("key", newKey). + HasValue("description", oldModel.Value("description").String().Raw()) + + copiedModel.Value("schemaId").NotNull() + oldSchemaId := oldModel.Value("schemaId").String() + copiedSchemaId := copiedModel.Value("schemaId").String() + copiedSchemaId.NotEqual(oldSchemaId.Raw()) + + oldSchema := oldModel.Value("schema").Object() + copiedSchema := copiedModel.Value("schema").Object() + copiedSchema.Value("fields").Array().Length().IsEqual(oldSchema.Value("fields").Array().Length().Raw()) + copiedSchema.Value("titleField").String().IsEqual(oldSchema.Value("titleField").String().Raw()) + + copiedModel.Value("metadataSchemaId").NotNull() + oldMetadataSchemaId := oldModel.Value("metadataSchemaId").String() + copiedMetadataSchemaId := copiedModel.Value("metadataSchemaId").String() + copiedMetadataSchemaId.NotEqual(oldMetadataSchemaId.Raw()) + + oldMetadataSchema := oldModel.Value("metadataSchema").Object() + copiedMetadataSchema := copiedModel.Value("metadataSchema").Object() + copiedMetadataSchema.Value("fields").Array().Length().IsEqual(oldMetadataSchema.Value("fields").Array().Length().Raw()) +} + // PATCH /models/{modelId} func TestIntegrationModelUpdateAPI(t *testing.T) { endpoint := "/api/models/{modelId}" diff --git a/server/internal/adapter/integration/model.go b/server/internal/adapter/integration/model.go index 02b74afca..4c4e96a7d 100644 --- a/server/internal/adapter/integration/model.go +++ b/server/internal/adapter/integration/model.go @@ -112,6 +112,38 @@ func (s *Server) ModelGet(ctx context.Context, request ModelGetRequestObject) (M return ModelGet200JSONResponse(integrationapi.NewModel(m, sp, lastModified)), nil } +func (s *Server) CopyModel(ctx context.Context, request CopyModelRequestObject) (CopyModelResponseObject, error) { + uc := adapter.Usecases(ctx) + op := adapter.Operator(ctx) + + m, err := uc.Model.Copy(ctx, interfaces.CopyModelParam{ + ModelId: request.ModelId, + Name: request.Body.Name, + Key: request.Body.Key, + }, op) + if err != nil { + if errors.Is(err, rerror.ErrNotFound) { + return CopyModel404Response{}, err + } + return CopyModel500Response{}, err + } + + sp, err := uc.Schema.FindByModel(ctx, m.ID(), op) + if err != nil { + if errors.Is(err, rerror.ErrNotFound) { + return CopyModel404Response{}, err + } + return CopyModel500Response{}, err + } + + lastModified, err := uc.Item.LastModifiedByModel(ctx, m.ID(), op) + if err != nil && !errors.Is(err, rerror.ErrNotFound) { + return CopyModel500Response{}, err + } + + return CopyModel200JSONResponse(integrationapi.NewModel(m, sp, lastModified)), nil +} + func (s *Server) ModelGetWithProject(ctx context.Context, request ModelGetWithProjectRequestObject) (ModelGetWithProjectResponseObject, error) { uc := adapter.Usecases(ctx) op := adapter.Operator(ctx) diff --git a/server/internal/adapter/integration/server.gen.go b/server/internal/adapter/integration/server.gen.go index 3f010f7d5..048e1b8ba 100644 --- a/server/internal/adapter/integration/server.gen.go +++ b/server/internal/adapter/integration/server.gen.go @@ -75,6 +75,9 @@ type ServerInterface interface { // Update a model. // (PATCH /models/{modelId}) ModelUpdate(ctx echo.Context, modelId ModelIdParam) error + // Copy schema and items of a selected model + // (POST /models/{modelId}/copy) + CopyModel(ctx echo.Context, modelId ModelIdParam) error // Import data under the selected model // (PUT /models/{modelId}/import) ModelImport(ctx echo.Context, modelId ModelIdParam) error @@ -507,6 +510,24 @@ func (w *ServerInterfaceWrapper) ModelUpdate(ctx echo.Context) error { return err } +// CopyModel converts echo context to params. +func (w *ServerInterfaceWrapper) CopyModel(ctx echo.Context) error { + var err error + // ------------- Path parameter "modelId" ------------- + var modelId ModelIdParam + + err = runtime.BindStyledParameterWithOptions("simple", "modelId", ctx.Param("modelId"), &modelId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter modelId: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CopyModel(ctx, modelId) + return err +} + // ModelImport converts echo context to params. func (w *ServerInterfaceWrapper) ModelImport(ctx echo.Context) error { var err error @@ -1540,6 +1561,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.DELETE(baseURL+"/models/:modelId", wrapper.ModelDelete) router.GET(baseURL+"/models/:modelId", wrapper.ModelGet) router.PATCH(baseURL+"/models/:modelId", wrapper.ModelUpdate) + router.POST(baseURL+"/models/:modelId/copy", wrapper.CopyModel) router.PUT(baseURL+"/models/:modelId/import", wrapper.ModelImport) router.GET(baseURL+"/models/:modelId/items", wrapper.ItemFilter) router.POST(baseURL+"/models/:modelId/items", wrapper.ItemCreate) @@ -2253,6 +2275,54 @@ func (response ModelUpdate401Response) VisitModelUpdateResponse(w http.ResponseW return nil } +type CopyModelRequestObject struct { + ModelId ModelIdParam `json:"modelId"` + Body *CopyModelJSONRequestBody +} + +type CopyModelResponseObject interface { + VisitCopyModelResponse(w http.ResponseWriter) error +} + +type CopyModel200JSONResponse Model + +func (response CopyModel200JSONResponse) VisitCopyModelResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type CopyModel400Response struct { +} + +func (response CopyModel400Response) VisitCopyModelResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type CopyModel401Response = UnauthorizedErrorResponse + +func (response CopyModel401Response) VisitCopyModelResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type CopyModel404Response = NotFoundErrorResponse + +func (response CopyModel404Response) VisitCopyModelResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type CopyModel500Response struct { +} + +func (response CopyModel500Response) VisitCopyModelResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + type ModelImportRequestObject struct { ModelId ModelIdParam `json:"modelId"` JSONBody *ModelImportJSONRequestBody @@ -3741,6 +3811,9 @@ type StrictServerInterface interface { // Update a model. // (PATCH /models/{modelId}) ModelUpdate(ctx context.Context, request ModelUpdateRequestObject) (ModelUpdateResponseObject, error) + // Copy schema and items of a selected model + // (POST /models/{modelId}/copy) + CopyModel(ctx context.Context, request CopyModelRequestObject) (CopyModelResponseObject, error) // Import data under the selected model // (PUT /models/{modelId}/import) ModelImport(ctx context.Context, request ModelImportRequestObject) (ModelImportResponseObject, error) @@ -4289,6 +4362,37 @@ func (sh *strictHandler) ModelUpdate(ctx echo.Context, modelId ModelIdParam) err return nil } +// CopyModel operation middleware +func (sh *strictHandler) CopyModel(ctx echo.Context, modelId ModelIdParam) error { + var request CopyModelRequestObject + + request.ModelId = modelId + + var body CopyModelJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.CopyModel(ctx.Request().Context(), request.(CopyModelRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CopyModel") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(CopyModelResponseObject); ok { + return validResponse.VisitCopyModelResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // ModelImport operation middleware func (sh *strictHandler) ModelImport(ctx echo.Context, modelId ModelIdParam) error { var request ModelImportRequestObject @@ -5183,79 +5287,79 @@ func (sh *strictHandler) ProjectFilter(ctx echo.Context, workspaceId WorkspaceId // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "WS6X/wsAAP//hHQYUfexAAA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/internal/infrastructure/gcp/config.go b/server/internal/infrastructure/gcp/config.go index 777bd64d2..47ab5ea84 100644 --- a/server/internal/infrastructure/gcp/config.go +++ b/server/internal/infrastructure/gcp/config.go @@ -11,4 +11,5 @@ type TaskConfig struct { DecompressorGzipExt string `default:"gml"` DecompressorMachineType string `default:"E2_HIGHCPU_8"` DecompressorDiskSideGb int64 `default:"2000"` + CopierImage string `default:"reearth/reearth-cms-copier"` } diff --git a/server/internal/infrastructure/gcp/taskrunner.go b/server/internal/infrastructure/gcp/taskrunner.go index c9e324421..0ae8c6cf8 100644 --- a/server/internal/infrastructure/gcp/taskrunner.go +++ b/server/internal/infrastructure/gcp/taskrunner.go @@ -71,6 +71,13 @@ func (t *TaskRunner) Retry(ctx context.Context, id string) error { } func (t *TaskRunner) runCloudBuild(ctx context.Context, p task.Payload) error { + if p.DecompressAsset != nil { + return decompressAsset(ctx, p, t.conf) + } + return copy(ctx, p, t.conf) +} + +func decompressAsset(ctx context.Context, p task.Payload, conf *TaskConfig) error { if p.DecompressAsset == nil { return nil } @@ -80,25 +87,25 @@ func (t *TaskRunner) runCloudBuild(ctx context.Context, p task.Payload) error { return rerror.ErrInternalBy(err) } - src, err := url.JoinPath("gs://"+t.conf.GCSBucket, "assets", p.DecompressAsset.Path) + src, err := url.JoinPath("gs://"+conf.GCSBucket, "assets", p.DecompressAsset.Path) if err != nil { return rerror.ErrInternalBy(err) } - dest, err := url.JoinPath("gs://"+t.conf.GCSBucket, "assets", path.Dir(p.DecompressAsset.Path)) + dest, err := url.JoinPath("gs://"+conf.GCSBucket, "assets", path.Dir(p.DecompressAsset.Path)) if err != nil { return rerror.ErrInternalBy(err) } - project := t.conf.GCPProject - region := t.conf.GCPRegion + project := conf.GCPProject + region := conf.GCPRegion machineType := "" - if v := t.conf.DecompressorMachineType; v != "" && v != "default" { + if v := conf.DecompressorMachineType; v != "" && v != "default" { machineType = v } var diskSizeGb int64 - if v := t.conf.DecompressorDiskSideGb; v > 0 { + if v := conf.DecompressorDiskSideGb; v > 0 { diskSizeGb = v } else { diskSizeGb = defaultDiskSizeGb @@ -109,11 +116,11 @@ func (t *TaskRunner) runCloudBuild(ctx context.Context, p task.Payload) error { QueueTtl: "86400s", // 1 day Steps: []*cloudbuild.BuildStep{ { - Name: t.conf.DecompressorImage, - Args: []string{"-v", "-n=192", "-gc=5000", "-chunk=1m", "-disk-limit=20g", "-gzip-ext=" + t.conf.DecompressorGzipExt, "-skip-top", "-old-windows", src, dest}, + Name: conf.DecompressorImage, + Args: []string{"-v", "-n=192", "-gc=5000", "-chunk=1m", "-disk-limit=20g", "-gzip-ext=" + conf.DecompressorGzipExt, "-skip-top", "-old-windows", src, dest}, Env: []string{ "GOOGLE_CLOUD_PROJECT=" + project, - "REEARTH_CMS_DECOMPRESSOR_TOPIC=" + t.conf.DecompressorTopic, + "REEARTH_CMS_DECOMPRESSOR_TOPIC=" + conf.DecompressorTopic, "REEARTH_CMS_DECOMPRESSOR_ASSET_ID=" + p.DecompressAsset.AssetID, }, }, @@ -137,6 +144,59 @@ func (t *TaskRunner) runCloudBuild(ctx context.Context, p task.Payload) error { return nil } +func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { + if !p.Copy.Validate() { + return nil + } + + cb, err := cloudbuild.NewService(ctx) + if err != nil { + return rerror.ErrInternalBy(err) + } + + project := conf.GCPProject + region := conf.GCPRegion + + build := &cloudbuild.Build{ + Timeout: "86400s", // 1 day + QueueTtl: "86400s", // 1 day + Steps: []*cloudbuild.BuildStep{ + { + Name: conf.CopierImage, + Args: []string{ + "-v", // Enables verbose mode for logging. + "-n=192", // Specifies a numerical configuration, possibly a limit or count (e.g., number of threads, requests, etc.). + "-gc=5000", // Configures garbage collection or memory management to a threshold of 5000 units. + "-chunk=1m", // Sets a chunk size of 1 megabyte for processing or data transfer. + "-disk-limit=20g", // Limits the disk usage to 20 gigabytes. + "-skip-top", // Enables an option to skip certain data or processing steps (e.g., skipping the "top" of a hierarchy). + "-old-windows", // Activates compatibility or a specific mode for older Windows environments. + }, + Env: []string{ + "REEARTH_CMS_COPIER_COLLECTION=" + p.Copy.Collection, + "REEARTH_CMS_COPIER_FILTER=" + p.Copy.Filter, + "REEARTH_CMS_COPIER_CHANGES=" + p.Copy.Changes, + }, + }, + }, + Options: &cloudbuild.BuildOptions{ + DiskSizeGb: defaultDiskSizeGb, + }, + } + + if region != "" { + call := cb.Projects.Locations.Builds.Create(path.Join("projects", project, "locations", region), build) + _, err = call.Do() + } else { + call := cb.Projects.Builds.Create(project, build) + _, err = call.Do() + } + if err != nil { + return rerror.ErrInternalBy(err) + } + return nil +} + func (t *TaskRunner) runPubSub(ctx context.Context, p task.Payload) error { if p.Webhook == nil { return nil diff --git a/server/internal/infrastructure/gcp/taskrunner_test.go b/server/internal/infrastructure/gcp/taskrunner_test.go index bce3571f8..47ecf3c9d 100644 --- a/server/internal/infrastructure/gcp/taskrunner_test.go +++ b/server/internal/infrastructure/gcp/taskrunner_test.go @@ -14,7 +14,8 @@ func TestTaskRunner(t *testing.T) { gcsBucket := "" gcpProject := "" gcpRegion := "" - image := "" + decompressorImage := "" + copierImage := "" if assetID == "" || path == "" || gcsBucket == "" || gcpProject == "" || gcpRegion == "" { t.Skip("assetID, path, gcsBucket, gcpProject, gcpRegion must be set") @@ -32,9 +33,10 @@ func TestTaskRunner(t *testing.T) { GCPProject: gcpProject, GCPRegion: gcpRegion, GCSBucket: gcsBucket, - DecompressorImage: image, + DecompressorImage: decompressorImage, DecompressorTopic: "decompress", DecompressorGzipExt: "gml", + CopierImage: copierImage, }) require.NoError(t, err) diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index 11ff9adbd..1ff898347 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -2,7 +2,9 @@ package interactor import ( "context" + "encoding/json" "errors" + "fmt" "github.com/reearth/reearth-cms/server/internal/usecase" "github.com/reearth/reearth-cms/server/internal/usecase/gateway" @@ -11,10 +13,13 @@ import ( "github.com/reearth/reearth-cms/server/pkg/id" "github.com/reearth/reearth-cms/server/pkg/model" "github.com/reearth/reearth-cms/server/pkg/schema" + "github.com/reearth/reearth-cms/server/pkg/task" "github.com/reearth/reearthx/i18n" + "github.com/reearth/reearthx/log" "github.com/reearth/reearthx/rerror" "github.com/reearth/reearthx/usecasex" "github.com/samber/lo" + "go.mongodb.org/mongo-driver/bson" ) type Model struct { @@ -313,3 +318,146 @@ func (i Model) UpdateOrder(ctx context.Context, ids id.ModelIDList, operator *us return ordered, nil }) } + +func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, operator *usecase.Operator) (*model.Model, error) { + return Run1(ctx, operator, i.repos, Usecase().Transaction(), + func(ctx context.Context) (*model.Model, error) { + // copy model + oldModel, newModel, err := i.copyModel(ctx, params, operator) + if err != nil { + return nil, err + } + // copy schema + if err := i.copySchema(ctx, oldModel.Schema(), newModel.Schema()); err != nil { + return nil, err + } + // copy items + if err := i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID()); err != nil { + return nil, err + } + // copy metadata + if oldModel.Metadata() != nil { + // copy meta schema + newMetaSchema, err := i.copyMetaSchema(ctx, *oldModel.Metadata(), newModel) + if err != nil { + return nil, err + } + // copy meta items + if err := i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID()); err != nil { + return nil, err + } + } + // return the new model + return newModel, nil + }) +} + +func (i Model) copyModel(ctx context.Context, params interfaces.CopyModelParam, operator *usecase.Operator) (*model.Model, *model.Model, error) { + oldModel, err := i.repos.Model.FindByID(ctx, params.ModelId) + if err != nil { + return nil, nil, err + } + name := lo.ToPtr(oldModel.Name() + " Copy") + if params.Name != nil { + name = params.Name + } + key := id.RandomKey().Ref().StringRef() + if params.Key != nil { + key = params.Key + } + newModel, err := i.Create(ctx, interfaces.CreateModelParam{ + ProjectId: oldModel.Project(), + Name: name, + Description: lo.ToPtr(oldModel.Description()), + Key: key, + Public: lo.ToPtr(oldModel.Public()), + }, operator) + if err != nil { + return nil, nil, err + } + return oldModel, newModel, nil +} + +func (i Model) copySchema(ctx context.Context, oldSchemaId, newSchemaId id.SchemaID) error { + oldSchema, err := i.repos.Schema.FindByID(ctx, oldSchemaId) + if err != nil { + return err + } + newSchema, err := i.repos.Schema.FindByID(ctx, newSchemaId) + if err != nil { + return err + } + newSchema.CopyFrom(oldSchema) + return i.repos.Schema.Save(ctx, newSchema) +} + +func (i Model) copyMetaSchema(ctx context.Context, oldMetaSchemaId id.SchemaID, newModel *model.Model) (*schema.Schema, error) { + oldMetaSchema, err := i.repos.Schema.FindByID(ctx, oldMetaSchemaId) + if err != nil { + return nil, err + } + newMetaSchema, err := schema.New(). + NewID(). + Workspace(oldMetaSchema.Workspace()). + Project(oldMetaSchema.Project()). + TitleField(nil). + Build() + if err != nil { + return nil, err + } + newMetaSchema.CopyFrom(oldMetaSchema) + newModel.SetMetadata(newMetaSchema.ID()) + if err := i.repos.Model.Save(ctx, newModel); err != nil { + return nil, err + } + if err := i.repos.Schema.Save(ctx, newMetaSchema); err != nil { + return nil, err + } + return newMetaSchema, nil +} + +func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.SchemaID, newModelID id.ModelID) error { + collection := "item" + filter, err := json.Marshal(bson.M{"schema": oldSchemaID.String()}) + if err != nil { + return err + } + changes, err := json.Marshal(task.Changes{ + "id": { + Type: task.ChangeTypeNew, + Value: "item", + }, + "schema": { + Type: task.ChangeTypeSet, + Value: newSchemaID.String(), + }, + "modelid": { + Type: task.ChangeTypeSet, + Value: newModelID.String(), + }, + }) + if err != nil { + return err + } + return i.triggerCopyEvent(ctx, collection, string(filter), string(changes)) +} + +func (i Model) triggerCopyEvent(ctx context.Context, collection, filter, changes string) error { + if i.gateways.TaskRunner == nil { + log.Infof("model: copy of %s skipped because task runner is not configured", collection) + return nil + } + + taskPayload := task.CopyPayload{ + Collection: collection, + Filter: filter, + Changes: changes, + } + + if err := i.gateways.TaskRunner.Run(ctx, taskPayload.Payload()); err != nil { + return fmt.Errorf("failed to trigger copy event: %w", err) + } + + log.Infof("model: successfully triggered copy event for collection %s, filter: %s, changes: %s", collection, filter, changes) + return nil +} diff --git a/server/internal/usecase/interactor/model_test.go b/server/internal/usecase/interactor/model_test.go index 9c412572f..171ee4f63 100644 --- a/server/internal/usecase/interactor/model_test.go +++ b/server/internal/usecase/interactor/model_test.go @@ -2,16 +2,22 @@ package interactor import ( "context" + "errors" "testing" "time" + "github.com/golang/mock/gomock" "github.com/reearth/reearth-cms/server/internal/infrastructure/memory" "github.com/reearth/reearth-cms/server/internal/usecase" + "github.com/reearth/reearth-cms/server/internal/usecase/gateway" + "github.com/reearth/reearth-cms/server/internal/usecase/gateway/gatewaymock" "github.com/reearth/reearth-cms/server/internal/usecase/interfaces" "github.com/reearth/reearth-cms/server/internal/usecase/repo" "github.com/reearth/reearth-cms/server/pkg/id" "github.com/reearth/reearth-cms/server/pkg/model" "github.com/reearth/reearth-cms/server/pkg/project" + "github.com/reearth/reearth-cms/server/pkg/schema" + "github.com/reearth/reearthx/account/accountdomain" "github.com/reearth/reearthx/account/accountdomain/user" "github.com/reearth/reearthx/account/accountusecase" "github.com/reearth/reearthx/rerror" @@ -530,3 +536,109 @@ func TestNewModel(t *testing.T) { }) } } + +func TestModel_Copy(t *testing.T) { + mockTime := time.Now() + wid := accountdomain.NewWorkspaceID() + p := project.New().NewID().Workspace(wid).MustBuild() + op := &usecase.Operator{OwningProjects: []id.ProjectID{p.ID()}} + + fId1 := id.NewFieldID() + sfKey1 := id.RandomKey() + sf1 := schema.NewField(schema.NewBool().TypeProperty()).ID(fId1).Key(sfKey1).MustBuild() + s1 := schema.New().NewID().Workspace(wid).Project(p.ID()).Fields([]*schema.Field{sf1}).MustBuild() + fId2 := id.NewFieldID() + sfKey2 := id.RandomKey() + sf2 := schema.NewField(schema.NewBool().TypeProperty()).ID(fId2).Key(sfKey2).MustBuild() + s2 := schema.New().NewID().Workspace(wid).Project(p.ID()).Fields([]*schema.Field{sf2}).MustBuild() + m := model.New().NewID().Key(id.RandomKey()).Project(p.ID()).Schema(s1.ID()).Metadata(s2.ID().Ref()).MustBuild() + + ctx := context.Background() + db := memory.New() + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mRunner := gatewaymock.NewMockTaskRunner(mockCtrl) + gw := &gateway.Container{TaskRunner: mRunner} + u := NewModel(db, gw) + + defer memory.MockNow(db, mockTime)() + + err := db.Project.Save(ctx, p.Clone()) + assert.NoError(t, err) + err = db.Model.Save(ctx, m.Clone()) + assert.NoError(t, err) + err = db.Schema.Save(ctx, s1.Clone()) + assert.NoError(t, err) + err = db.Schema.Save(ctx, s2.Clone()) + assert.NoError(t, err) + + tests := []struct { + name string + param interfaces.CopyModelParam + setupMock func() + wantErr bool + validate func(t *testing.T, got *model.Model) + }{ + { + name: "successful copy", + param: interfaces.CopyModelParam{ + ModelId: m.ID(), + Name: lo.ToPtr("Copied Model"), + }, + setupMock: func() { + mRunner.EXPECT().Run(ctx, gomock.Any()).Times(1).Return(nil) + }, + wantErr: false, + validate: func(t *testing.T, got *model.Model) { + assert.NotEqual(t, m.ID(), got.ID()) + assert.NotEqual(t, m.Key(), got.Key()) + assert.Equal(t, "Copied Model", got.Name()) + assert.Equal(t, m.Description(), got.Description()) + assert.Equal(t, m.Public(), got.Public()) + }, + }, + { + name: "missing model ID", + param: interfaces.CopyModelParam{ + ModelId: id.ModelID{}, + Name: lo.ToPtr("Copied Model"), + }, + setupMock: func() { + mRunner.EXPECT().Run(ctx, gomock.Any()).Times(0) + }, + wantErr: true, + validate: func(t *testing.T, got *model.Model) { + assert.Nil(t, got) + }, + }, + { + name: "task runner error", + param: interfaces.CopyModelParam{ + ModelId: m.ID(), + Name: lo.ToPtr("Copied Model"), + }, + setupMock: func() { + mRunner.EXPECT().Run(ctx, gomock.Any()).Times(1).Return(errors.New("task runner error")) + }, + wantErr: true, + validate: func(t *testing.T, got *model.Model) { + assert.Nil(t, got) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupMock() + got, err := u.Copy(ctx, tt.param, op) + if tt.wantErr { + assert.Error(t, err) + tt.validate(t, nil) + } else { + assert.NoError(t, err) + tt.validate(t, got) + } + }) + } +} diff --git a/server/internal/usecase/interfaces/model.go b/server/internal/usecase/interfaces/model.go index 23f00d1dc..fd7e32fb4 100644 --- a/server/internal/usecase/interfaces/model.go +++ b/server/internal/usecase/interfaces/model.go @@ -20,6 +20,12 @@ type CreateModelParam struct { Public *bool } +type CopyModelParam struct { + ModelId id.ModelID + Name *string + Key *string +} + type FindOrCreateSchemaParam struct { ModelID *id.ModelID GroupID *id.GroupID @@ -56,4 +62,5 @@ type Model interface { CheckKey(context.Context, id.ProjectID, string) (bool, error) Delete(context.Context, id.ModelID, *usecase.Operator) error Publish(context.Context, id.ModelID, bool, *usecase.Operator) (bool, error) + Copy(context.Context, CopyModelParam, *usecase.Operator) (*model.Model, error) } diff --git a/server/pkg/integrationapi/types.gen.go b/server/pkg/integrationapi/types.gen.go index ac4daf94f..76a6a0ea2 100644 --- a/server/pkg/integrationapi/types.gen.go +++ b/server/pkg/integrationapi/types.gen.go @@ -535,11 +535,11 @@ type RefOrVersionRef string // Schema defines model for schema. type Schema struct { - TitleField *id.FieldID `json:"TitleField,omitempty"` CreatedAt *time.Time `json:"createdAt,omitempty"` Fields *[]SchemaField `json:"fields,omitempty"` Id *id.SchemaID `json:"id,omitempty"` ProjectId *id.ProjectID `json:"projectId,omitempty"` + TitleField *id.FieldID `json:"titleField,omitempty"` } // SchemaField defines model for schemaField. @@ -705,6 +705,12 @@ type ModelUpdateJSONBody struct { Name *string `json:"name,omitempty"` } +// CopyModelJSONBody defines parameters for CopyModel. +type CopyModelJSONBody struct { + Key *string `json:"key,omitempty"` + Name *string `json:"name,omitempty"` +} + // ModelImportJSONBody defines parameters for ModelImport. type ModelImportJSONBody struct { AssetId id.AssetID `json:"assetId"` @@ -1022,6 +1028,9 @@ type ItemCommentUpdateJSONRequestBody ItemCommentUpdateJSONBody // ModelUpdateJSONRequestBody defines body for ModelUpdate for application/json ContentType. type ModelUpdateJSONRequestBody ModelUpdateJSONBody +// CopyModelJSONRequestBody defines body for CopyModel for application/json ContentType. +type CopyModelJSONRequestBody CopyModelJSONBody + // ModelImportJSONRequestBody defines body for ModelImport for application/json ContentType. type ModelImportJSONRequestBody ModelImportJSONBody diff --git a/server/pkg/schema/schema.go b/server/pkg/schema/schema.go index caf891534..fa16625bb 100644 --- a/server/pkg/schema/schema.go +++ b/server/pkg/schema/schema.go @@ -172,3 +172,11 @@ func (s *Schema) IsPointFieldSupported() bool { } return false } + +func (s *Schema) CopyFrom(s2 *Schema) { + if s == nil || s2 == nil { + return + } + s.fields = slices.Clone(s2.fields) + s.titleField = s2.TitleField().CloneRef() +} diff --git a/server/pkg/schema/schema_test.go b/server/pkg/schema/schema_test.go index a3c627515..7bb6cd1db 100644 --- a/server/pkg/schema/schema_test.go +++ b/server/pkg/schema/schema_test.go @@ -479,3 +479,17 @@ func TestSchema_HasFieldByKey(t *testing.T) { }) } } + +func TestSchema_CopyFrom(t *testing.T) { + fid := id.NewFieldID() + s1 := &Schema{id: id.NewSchemaID(), fields: []*Field{{id: id.NewFieldID(), name: "f1", key: id.RandomKey()}}, titleField: fid.Ref()} + s2 := &Schema{id: id.NewSchemaID(), fields: []*Field{}} + s2.CopyFrom(s1) + assert.Equal(t, s1.fields, s2.fields) + assert.Equal(t, s1.titleField, s2.titleField) + + s3 := &Schema{id: id.NewSchemaID(), fields: []*Field{}} + s3.CopyFrom(nil) + assert.Equal(t, 0, len(s3.fields)) + assert.Nil(t, s3.titleField) +} diff --git a/server/pkg/task/task.go b/server/pkg/task/task.go index 24d1caa2f..3dab9097c 100644 --- a/server/pkg/task/task.go +++ b/server/pkg/task/task.go @@ -9,6 +9,7 @@ type Payload struct { DecompressAsset *DecompressAssetPayload CompressAsset *CompressAssetPayload Webhook *WebhookPayload + Copy *CopyPayload } type DecompressAssetPayload struct { @@ -43,3 +44,31 @@ func (t WebhookPayload) Payload() Payload { Webhook: &t, } } + +type CopyPayload struct { + Collection string + Filter string + Changes string +} + +func (p *CopyPayload) Payload() Payload { + return Payload{ + Copy: p, + } +} + +func (p *CopyPayload) Validate() bool { + return p != nil && p.Changes != "" && p.Collection != "" && p.Filter != "" +} + +type Changes map[string]Change +type Change struct { + Type ChangeType + Value string +} +type ChangeType string + +const ( + ChangeTypeSet ChangeType = "set" + ChangeTypeNew ChangeType = "new" +) diff --git a/server/schemas/integration.yml b/server/schemas/integration.yml index 5f62f94e7..924af2c8c 100644 --- a/server/schemas/integration.yml +++ b/server/schemas/integration.yml @@ -449,6 +449,41 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': description: Internal server error + '/models/{modelId}/copy': + parameters: + - $ref: '#/components/parameters/modelIdParam' + post: + operationId: CopyModel + summary: Copy schema and items of a selected model + tags: + - Models + security: + - bearerAuth: [ ] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + key: + type: string + responses: + '200': + description: A JSON object of field + content: + application/json: + schema: + $ref: '#/components/schemas/model' + '400': + description: Invalid request parameter value + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + description: Internal server error '/schemata/{schemaId}/fields': parameters: - $ref: '#/components/parameters/schemaIdParam' @@ -1823,7 +1858,7 @@ components: type: array items: $ref: '#/components/schemas/schemaField' - TitleField: + titleField: x-go-type: id.FieldID type: string createdAt: diff --git a/worker/Dockerfile b/worker/Dockerfile index f3c36bed7..d9f528b11 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -15,7 +15,6 @@ WORKDIR /app RUN go mod download COPY server/pkg/ /app/server/pkg/ -COPY server/internal/infrastructure/ /app/server/internal/infrastructure/ COPY worker/cmd/ /app/worker/cmd/ COPY worker/internal/ /app/worker/internal/ COPY worker/pkg/ /app/worker/pkg/ diff --git a/worker/cmd/copier/main.go b/worker/cmd/copier/main.go new file mode 100644 index 000000000..10b5efdef --- /dev/null +++ b/worker/cmd/copier/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "os" + "time" + + "github.com/joho/godotenv" + "github.com/reearth/reearth-cms/worker/internal/adapter/http" + rmongo "github.com/reearth/reearth-cms/worker/internal/infrastructure/mongo" + "github.com/reearth/reearth-cms/worker/internal/usecase/interactor" + "github.com/reearth/reearth-cms/worker/internal/usecase/repo" + "github.com/reearth/reearthx/log" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo" +) + +func main() { + log.Infof("reearth-cms/worker: copier has started") + ctx := context.Background() + + if err := godotenv.Load(".env"); err != nil && !os.IsNotExist(err) { + log.Fatal("config: unable to load .env") + } else if err == nil { + log.Infof("config: .env loaded") + } + + dbURI := mustGetEnv("REEARTH_CMS_WORKER_DB") + collection := mustGetEnv("REEARTH_CMS_COPIER_COLLECTION") + filter := mustGetEnv("REEARTH_CMS_COPIER_FILTER") + changes := mustGetEnv("REEARTH_CMS_COPIER_CHANGES") + + repos, err := initReposWithCollection(ctx, dbURI, collection) + if err != nil { + log.Fatalf("failed to initialize repositories with DB URI %s: %v", dbURI, err) + } + + uc := interactor.NewUsecase(nil, repos) + ctrl := http.NewCopyController(uc) + if err := ctrl.Copy(ctx, http.CopyInput{Filter: filter, Changes: changes}); err != nil { + log.Fatalf("copy operation failed: %v", err) + } +} + +func mustGetEnv(key string) string { + value := os.Getenv(key) + if value == "" { + log.Fatalf("environment variable %s is required", key) + } + return value +} + +func initReposWithCollection(ctx context.Context, dbURI, collection string) (*repo.Container, error) { + client, err := mongo.Connect( + ctx, + options.Client(). + ApplyURI(dbURI). + SetConnectTimeout(10*time.Second). + SetMonitor(otelmongo.NewMonitor()), + ) + if err != nil { + return nil, err + } + + db := client.Database("reearth_cms") + mongoCopier := rmongo.NewCopier(db) + mongoCopier.SetCollection(db.Collection(collection)) + return rmongo.New(ctx, nil, mongoCopier) +} diff --git a/worker/copier.Dockerfile b/worker/copier.Dockerfile new file mode 100644 index 000000000..1072477d3 --- /dev/null +++ b/worker/copier.Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.23.3 AS build +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 go build ./cmd/copier + +FROM scratch +COPY --from=build /app/copier /copier +ENTRYPOINT ["/copier"] \ No newline at end of file diff --git a/worker/internal/adapter/http/copy.go b/worker/internal/adapter/http/copy.go new file mode 100644 index 000000000..90541f3af --- /dev/null +++ b/worker/internal/adapter/http/copy.go @@ -0,0 +1,38 @@ +package http + +import ( + "context" + "encoding/json" + + "github.com/reearth/reearth-cms/server/pkg/task" + "github.com/reearth/reearth-cms/worker/internal/usecase/interactor" + "github.com/reearth/reearthx/rerror" + "go.mongodb.org/mongo-driver/bson" +) + +type CopyController struct { + usecase *interactor.Usecase +} + +func NewCopyController(u *interactor.Usecase) *CopyController { + return &CopyController{ + usecase: u, + } +} + +type CopyInput struct { + Filter string `json:"filter"` + Changes string `json:"changes"` +} + +func (c *CopyController) Copy(ctx context.Context, input CopyInput) error { + var filter bson.M + if err := json.Unmarshal([]byte(input.Filter), &filter); err != nil { + return rerror.ErrInternalBy(err) + } + var changes task.Changes + if err := json.Unmarshal([]byte(input.Changes), &changes); err != nil { + return rerror.ErrInternalBy(err) + } + return c.usecase.Copy(ctx, filter, changes) +} diff --git a/worker/internal/adapter/http/main.go b/worker/internal/adapter/http/main.go index 01434495c..3e028f8b1 100644 --- a/worker/internal/adapter/http/main.go +++ b/worker/internal/adapter/http/main.go @@ -5,10 +5,13 @@ import "github.com/reearth/reearth-cms/worker/internal/usecase/interactor" type Controller struct { DecompressController *DecompressController WebhookController *WebhookController + CopyController *CopyController } func NewController(uc *interactor.Usecase) *Controller { - return &Controller{DecompressController: NewDecompressController(uc), - WebhookController: NewWebhookController(uc), + return &Controller{ + DecompressController: NewDecompressController(uc), + WebhookController: NewWebhookController(uc), + CopyController: NewCopyController(uc), } } diff --git a/worker/internal/app/main.go b/worker/internal/app/main.go index 6e58d3021..8dc173c38 100644 --- a/worker/internal/app/main.go +++ b/worker/internal/app/main.go @@ -15,6 +15,7 @@ import ( rmongo "github.com/reearth/reearth-cms/worker/internal/infrastructure/mongo" "github.com/reearth/reearth-cms/worker/internal/usecase/gateway" "github.com/reearth/reearth-cms/worker/internal/usecase/interactor" + "github.com/reearth/reearth-cms/worker/internal/usecase/repo" "github.com/reearth/reearthx/log" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" @@ -44,12 +45,16 @@ func Start(debug bool, version string) { } mongoWebhook := rmongo.NewWebhook(client.Database("reearth_cms")) lo.Must0(mongoWebhook.InitIndex(ctx)) + repos, err := rmongo.New(ctx, mongoWebhook, nil) + if err != nil { + log.Fatalf("repo initialization error: %+v\n", err) + } // gateways gateways := initReposAndGateways(ctx, conf, debug) // usecase - uc := interactor.NewUsecase(gateways, mongoWebhook) + uc := interactor.NewUsecase(gateways, repos) ctrl := rhttp.NewController(uc) handler := NewHandler(ctrl) @@ -58,6 +63,7 @@ func Start(debug bool, version string) { Config: conf, Debug: debug, Gateways: gateways, + Repos: repos, }, handler).Run(ctx) } @@ -70,6 +76,7 @@ type ServerConfig struct { Config *Config Debug bool Gateways *gateway.Container + Repos *repo.Container } func NewServer(ctx context.Context, cfg *ServerConfig, handler *Handler) *WebServer { diff --git a/worker/internal/infrastructure/mongo/common_test.go b/worker/internal/infrastructure/mongo/common_test.go new file mode 100644 index 000000000..766e966fb --- /dev/null +++ b/worker/internal/infrastructure/mongo/common_test.go @@ -0,0 +1,7 @@ +package mongo + +import "github.com/reearth/reearthx/mongox/mongotest" + +func init() { + mongotest.Env = "REEARTH_CMS_WORKER_DB" +} diff --git a/worker/internal/infrastructure/mongo/container.go b/worker/internal/infrastructure/mongo/container.go new file mode 100644 index 000000000..5b57c2b01 --- /dev/null +++ b/worker/internal/infrastructure/mongo/container.go @@ -0,0 +1,34 @@ +package mongo + +import ( + "context" + "errors" + + "github.com/reearth/reearth-cms/worker/internal/usecase/repo" +) + +func New(ctx context.Context, webhook *Webhook, copier *Copier) (*repo.Container, error) { + r := &repo.Container{ + Webhook: webhook, + Copier: copier, + } + + // init + if err := Init(r); err != nil { + return nil, err + } + + return r, nil +} + +func Init(r *repo.Container) error { + if r == nil { + return nil + } + + if r.Webhook == nil && r.Copier == nil { + return errors.New("invalid repository container") + } + + return nil +} diff --git a/worker/internal/infrastructure/mongo/copier.go b/worker/internal/infrastructure/mongo/copier.go new file mode 100644 index 000000000..a7ff84143 --- /dev/null +++ b/worker/internal/infrastructure/mongo/copier.go @@ -0,0 +1,97 @@ +package mongo + +import ( + "context" + "errors" + + "github.com/reearth/reearth-cms/server/pkg/id" + "github.com/reearth/reearth-cms/server/pkg/task" + "github.com/reearth/reearth-cms/worker/internal/usecase/repo" + "github.com/reearth/reearthx/log" + "github.com/reearth/reearthx/rerror" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// TODO: this should be taken from config +var batchSize int32 = 1000 + +var _ repo.Copier = (*Copier)(nil) + +type Copier struct { + c *mongo.Collection +} + +func NewCopier(_ *mongo.Database) *Copier { + return &Copier{} +} + +func (r *Copier) SetCollection(collection *mongo.Collection) { + r.c = collection +} + +func (r *Copier) Copy(ctx context.Context, f bson.M, changesMap task.Changes) error { + options := options.Find().SetBatchSize(batchSize) + cursor, err := r.c.Find(ctx, f, options) + if err != nil { + if errors.Is(err, mongo.ErrNilDocument) || errors.Is(err, mongo.ErrNoDocuments) { + return rerror.ErrNotFound + } + return rerror.ErrInternalBy(err) + } + defer cursor.Close(ctx) + + var bulkModels []mongo.WriteModel + for cursor.Next(ctx) { + if err := cursor.Err(); err != nil { + return rerror.ErrInternalBy(err) + } + + var result bson.M + if err := cursor.Decode(&result); err != nil { + return rerror.ErrInternalBy(err) + } + result["_id"] = primitive.NewObjectID() + + for k, change := range changesMap { + switch change.Type { + case task.ChangeTypeNew: + newId, _ := generateId(change.Value) + result[k] = newId + case task.ChangeTypeSet: + result[k] = change.Value + } + } + + bulkModels = append(bulkModels, mongo.NewInsertOneModel().SetDocument(result)) + } + + if err := cursor.Close(ctx); err != nil { + return rerror.ErrInternalBy(err) + } + + if len(bulkModels) > 0 { + _, err = r.c.BulkWrite(ctx, bulkModels) + if err != nil { + return rerror.ErrInternalBy(err) + } + } + log.Infof("reearth-cms/worker: all data has been successfully copied!") + + return nil +} + +func generateId(t string) (string, bool) { + switch t { + case "item": + return id.NewAssetID().String(), true + case "schema": + return id.NewSchemaID().String(), true + case "model": + return id.NewModelID().String(), true + default: + return "", false + } +} diff --git a/worker/internal/infrastructure/mongo/copier_test.go b/worker/internal/infrastructure/mongo/copier_test.go new file mode 100644 index 000000000..cb8789107 --- /dev/null +++ b/worker/internal/infrastructure/mongo/copier_test.go @@ -0,0 +1,98 @@ +package mongo + +import ( + "context" + "testing" + + "github.com/reearth/reearth-cms/server/pkg/id" + "github.com/reearth/reearth-cms/server/pkg/item" + "github.com/reearth/reearth-cms/server/pkg/task" + "github.com/reearth/reearthx/mongox/mongotest" + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/bson" +) + +func TestCopier_SetCollection(t *testing.T) { + db := mongotest.Connect(t)(t) + w := NewCopier(db) + w.SetCollection(db.Collection("item")) + assert.Equal(t, "item", w.c.Name()) +} + +func TestCopier_Copy(t *testing.T) { + ctx := context.Background() + db := mongotest.Connect(t)(t) + w := NewCopier(db) + iCol := db.Collection("item") + w.SetCollection(iCol) + + mid1 := id.NewModelID() + sid1 := id.NewSchemaID() + mid2 := id.NewModelID() + sid2 := id.NewSchemaID() + i1 := item.New().ID(id.NewItemID()).Schema(sid1).Model(mid1).Project(id.NewProjectID()).Thread(id.NewThreadID()).MustBuild() + i2 := item.New().ID(id.NewItemID()).Schema(sid1).Model(mid1).Project(id.NewProjectID()).Thread(id.NewThreadID()).MustBuild() + + _, err := iCol.InsertMany(ctx, []any{i1, i2}) + assert.NoError(t, err) + + filter := bson.M{"schema": sid1.String()} + changes := task.Changes{ + "id": { + Type: task.ChangeTypeNew, + Value: "item", + }, + "schema": { + Type: task.ChangeTypeSet, + Value: sid2.String(), + }, + "model": { + Type: task.ChangeTypeSet, + Value: mid2.String(), + }, + } + + err = w.Copy(ctx, filter, changes) + assert.NoError(t, err) +} + +func TestCopier_GenerateId(t *testing.T) { + tests := []struct { + name string + input string + expectOk bool + }{ + { + name: "Valid input - item", + input: "item", + expectOk: true, + }, + { + name: "Valid input - schema", + input: "schema", + expectOk: true, + }, + { + name: "Valid input - model", + input: "model", + expectOk: true, + }, + { + name: "Invalid input - unknown type", + input: "unknown", + expectOk: false, + }, + { + name: "Empty input", + input: "", + expectOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, ok := generateId(tt.input) + assert.Equal(t, tt.expectOk, ok) + }) + } +} diff --git a/worker/internal/infrastructure/mongo/webhook_test.go b/worker/internal/infrastructure/mongo/webhook_test.go index 394e348e4..9415bf318 100644 --- a/worker/internal/infrastructure/mongo/webhook_test.go +++ b/worker/internal/infrastructure/mongo/webhook_test.go @@ -8,10 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -func init() { - mongotest.Env = "REEARTH_CMS_WORKER_DB" -} - func TestWebhook(t *testing.T) { ctx := context.Background() db := mongotest.Connect(t)(t) diff --git a/worker/internal/usecase/interactor/copier.go b/worker/internal/usecase/interactor/copier.go new file mode 100644 index 000000000..abf56d6ae --- /dev/null +++ b/worker/internal/usecase/interactor/copier.go @@ -0,0 +1,12 @@ +package interactor + +import ( + "context" + + "github.com/reearth/reearth-cms/server/pkg/task" + "go.mongodb.org/mongo-driver/bson" +) + +func (u *Usecase) Copy(ctx context.Context, filter bson.M, changes task.Changes) error { + return u.repos.Copier.Copy(ctx, filter, changes) +} diff --git a/worker/internal/usecase/interactor/usecase.go b/worker/internal/usecase/interactor/usecase.go index 81734caa7..7b56b65af 100644 --- a/worker/internal/usecase/interactor/usecase.go +++ b/worker/internal/usecase/interactor/usecase.go @@ -7,9 +7,12 @@ import ( type Usecase struct { gateways *gateway.Container - webhook repo.Webhook + repos *repo.Container } -func NewUsecase(g *gateway.Container, webhook repo.Webhook) *Usecase { - return &Usecase{gateways: g, webhook: webhook} +func NewUsecase(g *gateway.Container, r *repo.Container) *Usecase { + return &Usecase{ + gateways: g, + repos: r, + } } diff --git a/worker/internal/usecase/interactor/webhook.go b/worker/internal/usecase/interactor/webhook.go index 3f0a2530b..1a54af007 100644 --- a/worker/internal/usecase/interactor/webhook.go +++ b/worker/internal/usecase/interactor/webhook.go @@ -10,7 +10,7 @@ import ( func (u *Usecase) SendWebhook(ctx context.Context, w *webhook.Webhook) error { eid := fmt.Sprintf("%s_%s", w.EventID, w.WebhookID) - found, err := u.webhook.GetAndSet(ctx, eid) + found, err := u.repos.Webhook.GetAndSet(ctx, eid) if err != nil { log.Errorf("webhook usecase: failed to get webhook sent: %v", err) } @@ -22,7 +22,7 @@ func (u *Usecase) SendWebhook(ctx context.Context, w *webhook.Webhook) error { if err := webhook.Send(ctx, w); err != nil { log.Errorf("webhook usecase: error response: %v", err) - if err2 := u.webhook.Delete(ctx, eid); err2 != nil { + if err2 := u.repos.Webhook.Delete(ctx, eid); err2 != nil { log.Errorf("webhook usecase: failed to set webhook sent: %v", err2) } return err diff --git a/worker/internal/usecase/repo/container.go b/worker/internal/usecase/repo/container.go new file mode 100644 index 000000000..65b795bd2 --- /dev/null +++ b/worker/internal/usecase/repo/container.go @@ -0,0 +1,6 @@ +package repo + +type Container struct { + Webhook Webhook + Copier Copier +} diff --git a/worker/internal/usecase/repo/copier.go b/worker/internal/usecase/repo/copier.go new file mode 100644 index 000000000..086e7ccf2 --- /dev/null +++ b/worker/internal/usecase/repo/copier.go @@ -0,0 +1,12 @@ +package repo + +import ( + "context" + + "github.com/reearth/reearth-cms/server/pkg/task" + "go.mongodb.org/mongo-driver/bson" +) + +type Copier interface { + Copy(context.Context, bson.M, task.Changes) error +} From 7015005164a1a058f90cb3a34634f4ceeb36e4fa Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Wed, 15 Jan 2025 12:33:27 +0300 Subject: [PATCH 07/23] fix(ci,worker): update context of copier dockerfile to root (#1350) * fix: copier.dockerfile * fix cmd path --- .github/workflows/build_worker.yml | 2 +- worker/copier.Dockerfile | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_worker.yml b/.github/workflows/build_worker.yml index 6e2d7ee30..dbcd6aebe 100644 --- a/.github/workflows/build_worker.yml +++ b/.github/workflows/build_worker.yml @@ -168,7 +168,7 @@ jobs: - name: Build and push docker image uses: docker/build-push-action@v6 with: - context: ./worker + context: . file: ./worker/copier.Dockerfile platforms: ${{ steps.options.outputs.platforms }} push: true diff --git a/worker/copier.Dockerfile b/worker/copier.Dockerfile index 1072477d3..abcdfecf8 100644 --- a/worker/copier.Dockerfile +++ b/worker/copier.Dockerfile @@ -1,8 +1,24 @@ FROM golang:1.23.3 AS build + WORKDIR /app -COPY . . -RUN CGO_ENABLED=0 go build ./cmd/copier + +COPY go.work go.work.sum /app/ +COPY server/go.mod server/go.sum server/main.go /app/server/ +COPY worker/go.mod worker/go.sum worker/main.go /app/worker/ + +RUN go mod download + +COPY server/pkg/ /app/server/pkg/ +COPY worker/cmd/ /app/worker/cmd/ +COPY worker/internal/ /app/worker/internal/ +COPY worker/pkg/ /app/worker/pkg/ + +RUN CGO_ENABLED=0 go build -trimpath ./worker/cmd/copier FROM scratch -COPY --from=build /app/copier /copier -ENTRYPOINT ["/copier"] \ No newline at end of file + +COPY --from=build /app/copier /app/copier + +WORKDIR /app + +CMD ["./copier"] \ No newline at end of file From 02d5f54ef1e92068faf30dc6d41d3a7e4dd679eb Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Wed, 15 Jan 2025 16:28:05 +0300 Subject: [PATCH 08/23] chore(server): add logging to copier (#1351) * add logging to copier * change log.Info to log.Debug --- .../internal/infrastructure/gcp/taskrunner.go | 7 +++++ server/internal/usecase/interactor/model.go | 27 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/server/internal/infrastructure/gcp/taskrunner.go b/server/internal/infrastructure/gcp/taskrunner.go index 0ae8c6cf8..799b19bce 100644 --- a/server/internal/infrastructure/gcp/taskrunner.go +++ b/server/internal/infrastructure/gcp/taskrunner.go @@ -37,6 +37,7 @@ func NewTaskRunner(ctx context.Context, conf *TaskConfig) (gateway.TaskRunner, e // Run implements gateway.TaskRunner func (t *TaskRunner) Run(ctx context.Context, p task.Payload) error { if p.Webhook == nil { + log.Debug("copy: run cloud build!") return t.runCloudBuild(ctx, p) } return t.runPubSub(ctx, p) @@ -74,6 +75,7 @@ func (t *TaskRunner) runCloudBuild(ctx context.Context, p task.Payload) error { if p.DecompressAsset != nil { return decompressAsset(ctx, p, t.conf) } + log.Debug("copy: run copy!") return copy(ctx, p, t.conf) } @@ -148,6 +150,7 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { if !p.Copy.Validate() { return nil } + log.Debug("copy: copy event running") cb, err := cloudbuild.NewService(ctx) if err != nil { @@ -156,6 +159,7 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { project := conf.GCPProject region := conf.GCPRegion + log.Debug("copy: project %v, region %v", project, region) build := &cloudbuild.Build{ Timeout: "86400s", // 1 day @@ -187,13 +191,16 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { if region != "" { call := cb.Projects.Locations.Builds.Create(path.Join("projects", project, "locations", region), build) _, err = call.Do() + log.Debug("copy: call build with region!") } else { call := cb.Projects.Builds.Create(project, build) _, err = call.Do() + log.Debug("copy: call build without region!") } if err != nil { return rerror.ErrInternalBy(err) } + log.Debug("copy: cloud build done!") return nil } diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index 1ff898347..d59f7afa9 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -327,14 +327,19 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera if err != nil { return nil, err } + log.Debug("copy: model copied") // copy schema - if err := i.copySchema(ctx, oldModel.Schema(), newModel.Schema()); err != nil { + err = i.copySchema(ctx, oldModel.Schema(), newModel.Schema()); + if err != nil { return nil, err } + log.Debug("copy: schema copied") // copy items - if err := i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID()); err != nil { + err = i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID()); + if err != nil { return nil, err } + log.Debug("copy: items copied") // copy metadata if oldModel.Metadata() != nil { // copy meta schema @@ -342,10 +347,13 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera if err != nil { return nil, err } + log.Debug("copy: meta schema copied") // copy meta items - if err := i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID()); err != nil { + err = i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID()); + if err != nil { return nil, err } + log.Debug("copy: meta items copied") } // return the new model return newModel, nil @@ -357,6 +365,7 @@ func (i Model) copyModel(ctx context.Context, params interfaces.CopyModelParam, if err != nil { return nil, nil, err } + log.Debugf("copy: old model with id %v found", oldModel.ID().String()) name := lo.ToPtr(oldModel.Name() + " Copy") if params.Name != nil { name = params.Name @@ -375,6 +384,7 @@ func (i Model) copyModel(ctx context.Context, params interfaces.CopyModelParam, if err != nil { return nil, nil, err } + log.Debugf("copy: new model with id %v created", newModel.ID().String()) return oldModel, newModel, nil } @@ -383,10 +393,12 @@ func (i Model) copySchema(ctx context.Context, oldSchemaId, newSchemaId id.Schem if err != nil { return err } + log.Debugf("copy: old schema with id %v found", oldSchema.ID().String()) newSchema, err := i.repos.Schema.FindByID(ctx, newSchemaId) if err != nil { return err } + log.Debugf("copy: new schema with id %v found", oldSchema.ID().String()) newSchema.CopyFrom(oldSchema) return i.repos.Schema.Save(ctx, newSchema) } @@ -396,6 +408,7 @@ func (i Model) copyMetaSchema(ctx context.Context, oldMetaSchemaId id.SchemaID, if err != nil { return nil, err } + log.Debugf("copy: old meta schema with id %v found", oldMetaSchema.ID().String()) newMetaSchema, err := schema.New(). NewID(). Workspace(oldMetaSchema.Workspace()). @@ -410,9 +423,11 @@ func (i Model) copyMetaSchema(ctx context.Context, oldMetaSchemaId id.SchemaID, if err := i.repos.Model.Save(ctx, newModel); err != nil { return nil, err } + log.Debug("copy: new model saved!") if err := i.repos.Schema.Save(ctx, newMetaSchema); err != nil { return nil, err } + log.Debug("copy: new meta schema saved!") return newMetaSchema, nil } @@ -439,12 +454,13 @@ func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.Schema if err != nil { return err } + log.Debugf("copy: copy event triggered. collection: s%, filter: s%, changes: s%", collection, filter, changes) return i.triggerCopyEvent(ctx, collection, string(filter), string(changes)) } func (i Model) triggerCopyEvent(ctx context.Context, collection, filter, changes string) error { if i.gateways.TaskRunner == nil { - log.Infof("model: copy of %s skipped because task runner is not configured", collection) + log.Debugf("model: copy of %s skipped because task runner is not configured", collection) return nil } @@ -453,11 +469,12 @@ func (i Model) triggerCopyEvent(ctx context.Context, collection, filter, changes Filter: filter, Changes: changes, } + log.Debugf("copy: task payload created: %v", taskPayload) if err := i.gateways.TaskRunner.Run(ctx, taskPayload.Payload()); err != nil { return fmt.Errorf("failed to trigger copy event: %w", err) } - log.Infof("model: successfully triggered copy event for collection %s, filter: %s, changes: %s", collection, filter, changes) + log.Debugf("model: successfully triggered copy event for collection %s, filter: %s, changes: %s", collection, filter, changes) return nil } From 04f3c3054b810b6046787f45c79bba05290c06f1 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Wed, 15 Jan 2025 18:40:57 +0300 Subject: [PATCH 09/23] refactor(server): simplify copy logic (#1352) refactor: copy function --- server/internal/usecase/interactor/model.go | 159 +++++++++----------- 1 file changed, 72 insertions(+), 87 deletions(-) diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index d59f7afa9..b9b4619d1 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -322,115 +322,100 @@ func (i Model) UpdateOrder(ctx context.Context, ids id.ModelIDList, operator *us func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, operator *usecase.Operator) (*model.Model, error) { return Run1(ctx, operator, i.repos, Usecase().Transaction(), func(ctx context.Context) (*model.Model, error) { - // copy model - oldModel, newModel, err := i.copyModel(ctx, params, operator) + // Copy the model + oldModel, err := i.repos.Model.FindByID(ctx, params.ModelId) if err != nil { return nil, err } - log.Debug("copy: model copied") - // copy schema - err = i.copySchema(ctx, oldModel.Schema(), newModel.Schema()); + log.Debugf("copy: old model with id %v found", oldModel.ID().String()) + + name := lo.ToPtr(oldModel.Name() + " Copy") + if params.Name != nil { + name = params.Name + } + key := id.RandomKey().Ref().StringRef() + if params.Key != nil { + key = params.Key + } + + newModel, err := i.Create(ctx, interfaces.CreateModelParam{ + ProjectId: oldModel.Project(), + Name: name, + Description: lo.ToPtr(oldModel.Description()), + Key: key, + Public: lo.ToPtr(oldModel.Public()), + }, operator) if err != nil { return nil, err } - log.Debug("copy: schema copied") - // copy items - err = i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID()); + log.Debugf("copy: new model with id %v created", newModel.ID().String()) + + // Copy the schema + oldSchema, err := i.repos.Schema.FindByID(ctx, oldModel.Schema()) + if err != nil { + return nil, err + } + log.Debugf("copy: old schema with id %v found", oldSchema.ID().String()) + + newSchema, err := i.repos.Schema.FindByID(ctx, newModel.Schema()) if err != nil { return nil, err } + log.Debugf("copy: new schema with id %v found", newSchema.ID().String()) + + newSchema.CopyFrom(oldSchema) + if err := i.repos.Schema.Save(ctx, newSchema); err != nil { + return nil, err + } + log.Debug("copy: schema copied") + + // Copy items + if err := i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID()); err != nil { + return nil, err + } log.Debug("copy: items copied") - // copy metadata + + // Copy metadata (if present) if oldModel.Metadata() != nil { - // copy meta schema - newMetaSchema, err := i.copyMetaSchema(ctx, *oldModel.Metadata(), newModel) + oldMetaSchema, err := i.repos.Schema.FindByID(ctx, *oldModel.Metadata()) if err != nil { return nil, err } - log.Debug("copy: meta schema copied") - // copy meta items - err = i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID()); + log.Debugf("copy: old meta schema with id %v found", oldMetaSchema.ID().String()) + + newMetaSchema, err := schema.New(). + NewID(). + Workspace(oldMetaSchema.Workspace()). + Project(oldMetaSchema.Project()). + TitleField(nil). + Build() if err != nil { return nil, err } + newMetaSchema.CopyFrom(oldMetaSchema) + newModel.SetMetadata(newMetaSchema.ID()) + + if err := i.repos.Model.Save(ctx, newModel); err != nil { + return nil, err + } + log.Debug("copy: new model with updated metadata saved") + + if err := i.repos.Schema.Save(ctx, newMetaSchema); err != nil { + return nil, err + } + log.Debug("copy: new meta schema saved") + + if err := i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID()); err != nil { + return nil, err + } log.Debug("copy: meta items copied") } - // return the new model + + // Return the new model return newModel, nil }) } -func (i Model) copyModel(ctx context.Context, params interfaces.CopyModelParam, operator *usecase.Operator) (*model.Model, *model.Model, error) { - oldModel, err := i.repos.Model.FindByID(ctx, params.ModelId) - if err != nil { - return nil, nil, err - } - log.Debugf("copy: old model with id %v found", oldModel.ID().String()) - name := lo.ToPtr(oldModel.Name() + " Copy") - if params.Name != nil { - name = params.Name - } - key := id.RandomKey().Ref().StringRef() - if params.Key != nil { - key = params.Key - } - newModel, err := i.Create(ctx, interfaces.CreateModelParam{ - ProjectId: oldModel.Project(), - Name: name, - Description: lo.ToPtr(oldModel.Description()), - Key: key, - Public: lo.ToPtr(oldModel.Public()), - }, operator) - if err != nil { - return nil, nil, err - } - log.Debugf("copy: new model with id %v created", newModel.ID().String()) - return oldModel, newModel, nil -} - -func (i Model) copySchema(ctx context.Context, oldSchemaId, newSchemaId id.SchemaID) error { - oldSchema, err := i.repos.Schema.FindByID(ctx, oldSchemaId) - if err != nil { - return err - } - log.Debugf("copy: old schema with id %v found", oldSchema.ID().String()) - newSchema, err := i.repos.Schema.FindByID(ctx, newSchemaId) - if err != nil { - return err - } - log.Debugf("copy: new schema with id %v found", oldSchema.ID().String()) - newSchema.CopyFrom(oldSchema) - return i.repos.Schema.Save(ctx, newSchema) -} - -func (i Model) copyMetaSchema(ctx context.Context, oldMetaSchemaId id.SchemaID, newModel *model.Model) (*schema.Schema, error) { - oldMetaSchema, err := i.repos.Schema.FindByID(ctx, oldMetaSchemaId) - if err != nil { - return nil, err - } - log.Debugf("copy: old meta schema with id %v found", oldMetaSchema.ID().String()) - newMetaSchema, err := schema.New(). - NewID(). - Workspace(oldMetaSchema.Workspace()). - Project(oldMetaSchema.Project()). - TitleField(nil). - Build() - if err != nil { - return nil, err - } - newMetaSchema.CopyFrom(oldMetaSchema) - newModel.SetMetadata(newMetaSchema.ID()) - if err := i.repos.Model.Save(ctx, newModel); err != nil { - return nil, err - } - log.Debug("copy: new model saved!") - if err := i.repos.Schema.Save(ctx, newMetaSchema); err != nil { - return nil, err - } - log.Debug("copy: new meta schema saved!") - return newMetaSchema, nil -} - func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.SchemaID, newModelID id.ModelID) error { collection := "item" filter, err := json.Marshal(bson.M{"schema": oldSchemaID.String()}) @@ -454,7 +439,7 @@ func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.Schema if err != nil { return err } - log.Debugf("copy: copy event triggered. collection: s%, filter: s%, changes: s%", collection, filter, changes) + log.Debugf("copy: copy event triggered. collection: %s, filter: %s, changes: %s", collection, filter, changes) return i.triggerCopyEvent(ctx, collection, string(filter), string(changes)) } From 8535698b3805995f0c89e3cb616664c359ba9ed4 Mon Sep 17 00:00:00 2001 From: jasonkarel <55156603+jasonkarel@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:46:30 +0700 Subject: [PATCH 10/23] fix(server): Fix switching current user reviewer filter bug (#1353) fix(server):Fix switching current user reviewer filter bug --- server/internal/adapter/gql/resolver_request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/internal/adapter/gql/resolver_request.go b/server/internal/adapter/gql/resolver_request.go index c002a5040..aae5f52e1 100644 --- a/server/internal/adapter/gql/resolver_request.go +++ b/server/internal/adapter/gql/resolver_request.go @@ -137,7 +137,7 @@ func (r *mutationResolver) DeleteRequest(ctx context.Context, input gqlmodel.Del // Requests is the resolver for the requests field. func (r *queryResolver) Requests(ctx context.Context, projectID gqlmodel.ID, key *string, state []gqlmodel.RequestState, createdBy *gqlmodel.ID, reviewer *gqlmodel.ID, pagination *gqlmodel.Pagination, sort *gqlmodel.Sort) (*gqlmodel.RequestConnection, error) { - return loaders(ctx).Request.FindByProject(ctx, projectID, key, state, reviewer, createdBy, pagination, sort) + return loaders(ctx).Request.FindByProject(ctx, projectID, key, state, createdBy, reviewer, pagination, sort) } // Thread is the resolver for the thread field. From 7720b2475bd5ebf01823d6492cb7e559a05fa994 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Thu, 16 Jan 2025 11:47:34 +0300 Subject: [PATCH 11/23] fix(server): resolve nested transaction issue in schema copy function (#1354) * fix: resolve nested transaction issue in schema copy function --- server/.env.example | 1 + server/internal/usecase/interactor/model.go | 121 +++++++++++--------- 2 files changed, 65 insertions(+), 57 deletions(-) diff --git a/server/.env.example b/server/.env.example index 44b776810..8d34202fe 100644 --- a/server/.env.example +++ b/server/.env.example @@ -97,6 +97,7 @@ REEARTH_CMS_TASK_DECOMPRESSORIMAGE= REEARTH_CMS_TASK_DECOMPRESSORTOPIC= REEARTH_CMS_TASK_DECOMPRESSORGZIPEXT= REEARTH_CMS_TASK_DECOMPRESSORMACHINETYPE= +REEARTH_CMS_TASK_COPIERIMAGE= #AWS REEARTH_CMS_AWSTASK_TOPICARN= diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index b9b4619d1..918cf7f99 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -76,67 +76,71 @@ func (i Model) Create(ctx context.Context, param interfaces.CreateModelParam, op if !operator.IsMaintainingProject(param.ProjectId) { return nil, interfaces.ErrOperationDenied } - p, err := i.repos.Project.FindByID(ctx, param.ProjectId) - if err != nil { - return nil, err - } - m, err := i.repos.Model.FindByKey(ctx, param.ProjectId, *param.Key) - if err != nil && !errors.Is(err, rerror.ErrNotFound) { - return nil, err - } - if m != nil { - return nil, id.ErrDuplicatedKey - } - s, err := schema.New().NewID().Workspace(p.Workspace()).Project(p.ID()).TitleField(nil).Build() - if err != nil { - return nil, err - } + return i.create(ctx, param) + }) +} - if err := i.repos.Schema.Save(ctx, s); err != nil { - return nil, err - } +func (i Model) create(ctx context.Context, param interfaces.CreateModelParam) (*model.Model, error) { + p, err := i.repos.Project.FindByID(ctx, param.ProjectId) + if err != nil { + return nil, err + } + m, err := i.repos.Model.FindByKey(ctx, param.ProjectId, *param.Key) + if err != nil && !errors.Is(err, rerror.ErrNotFound) { + return nil, err + } + if m != nil { + return nil, id.ErrDuplicatedKey + } + s, err := schema.New().NewID().Workspace(p.Workspace()).Project(p.ID()).TitleField(nil).Build() + if err != nil { + return nil, err + } - mb := model. - New(). - NewID(). - Schema(s.ID()). - Public(false). - Project(param.ProjectId) + if err := i.repos.Schema.Save(ctx, s); err != nil { + return nil, err + } - if param.Name != nil { - mb = mb.Name(*param.Name) - } - if param.Description != nil { - mb = mb.Description(*param.Description) - } - if param.Public != nil { - mb = mb.Public(*param.Public) - } - if param.Key != nil { - mb = mb.Key(id.NewKey(*param.Key)) - } else { - mb = mb.Key(id.RandomKey()) - } - models, _, err := i.repos.Model.FindByProject(ctx, param.ProjectId, usecasex.CursorPagination{First: lo.ToPtr(int64(1000))}.Wrap()) - if err != nil { - return nil, err - } + mb := model. + New(). + NewID(). + Schema(s.ID()). + Public(false). + Project(param.ProjectId) - if len(models) > 0 { - mb = mb.Order(len(models)) - } + if param.Name != nil { + mb = mb.Name(*param.Name) + } + if param.Description != nil { + mb = mb.Description(*param.Description) + } + if param.Public != nil { + mb = mb.Public(*param.Public) + } + if param.Key != nil { + mb = mb.Key(id.NewKey(*param.Key)) + } else { + mb = mb.Key(id.RandomKey()) + } + models, _, err := i.repos.Model.FindByProject(ctx, param.ProjectId, usecasex.CursorPagination{First: lo.ToPtr(int64(1000))}.Wrap()) + if err != nil { + return nil, err + } - m, err = mb.Build() - if err != nil { - return nil, err - } + if len(models) > 0 { + mb = mb.Order(len(models)) + } - err = i.repos.Model.Save(ctx, m) - if err != nil { - return nil, err - } - return m, nil - }) + m, err = mb.Build() + if err != nil { + return nil, err + } + + err = i.repos.Model.Save(ctx, m) + if err != nil { + return nil, err + } + return m, nil } func (i Model) Update(ctx context.Context, param interfaces.UpdateModelParam, operator *usecase.Operator) (*model.Model, error) { @@ -327,6 +331,9 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera if err != nil { return nil, err } + if !operator.IsMaintainingProject(oldModel.Project()) { + return nil, interfaces.ErrOperationDenied + } log.Debugf("copy: old model with id %v found", oldModel.ID().String()) name := lo.ToPtr(oldModel.Name() + " Copy") @@ -338,13 +345,13 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera key = params.Key } - newModel, err := i.Create(ctx, interfaces.CreateModelParam{ + newModel, err := i.create(ctx, interfaces.CreateModelParam{ ProjectId: oldModel.Project(), Name: name, Description: lo.ToPtr(oldModel.Description()), Key: key, Public: lo.ToPtr(oldModel.Public()), - }, operator) + }) if err != nil { return nil, err } From 6249212e0c24f472ab8a86683ee5383c3cf1fb7a Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Thu, 16 Jan 2025 15:20:07 +0300 Subject: [PATCH 12/23] fix(server,worker): add database URI as secret env for Cloud Build copy (#1355) * remove logs * add cms db uri as a secret * remove unnecessary args --- .../internal/infrastructure/gcp/taskrunner.go | 27 ++++++++----------- server/internal/usecase/interactor/model.go | 16 ++--------- worker/cmd/copier/main.go | 2 +- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/server/internal/infrastructure/gcp/taskrunner.go b/server/internal/infrastructure/gcp/taskrunner.go index 799b19bce..1c092eb43 100644 --- a/server/internal/infrastructure/gcp/taskrunner.go +++ b/server/internal/infrastructure/gcp/taskrunner.go @@ -37,7 +37,6 @@ func NewTaskRunner(ctx context.Context, conf *TaskConfig) (gateway.TaskRunner, e // Run implements gateway.TaskRunner func (t *TaskRunner) Run(ctx context.Context, p task.Payload) error { if p.Webhook == nil { - log.Debug("copy: run cloud build!") return t.runCloudBuild(ctx, p) } return t.runPubSub(ctx, p) @@ -75,7 +74,6 @@ func (t *TaskRunner) runCloudBuild(ctx context.Context, p task.Payload) error { if p.DecompressAsset != nil { return decompressAsset(ctx, p, t.conf) } - log.Debug("copy: run copy!") return copy(ctx, p, t.conf) } @@ -150,7 +148,6 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { if !p.Copy.Validate() { return nil } - log.Debug("copy: copy event running") cb, err := cloudbuild.NewService(ctx) if err != nil { @@ -159,7 +156,6 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { project := conf.GCPProject region := conf.GCPRegion - log.Debug("copy: project %v, region %v", project, region) build := &cloudbuild.Build{ Timeout: "86400s", // 1 day @@ -167,40 +163,39 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { Steps: []*cloudbuild.BuildStep{ { Name: conf.CopierImage, - Args: []string{ - "-v", // Enables verbose mode for logging. - "-n=192", // Specifies a numerical configuration, possibly a limit or count (e.g., number of threads, requests, etc.). - "-gc=5000", // Configures garbage collection or memory management to a threshold of 5000 units. - "-chunk=1m", // Sets a chunk size of 1 megabyte for processing or data transfer. - "-disk-limit=20g", // Limits the disk usage to 20 gigabytes. - "-skip-top", // Enables an option to skip certain data or processing steps (e.g., skipping the "top" of a hierarchy). - "-old-windows", // Activates compatibility or a specific mode for older Windows environments. - }, Env: []string{ "REEARTH_CMS_COPIER_COLLECTION=" + p.Copy.Collection, "REEARTH_CMS_COPIER_FILTER=" + p.Copy.Filter, "REEARTH_CMS_COPIER_CHANGES=" + p.Copy.Changes, }, + SecretEnv: []string{ + "REEARTH_CMS_WORKER_DB", + }, }, }, Options: &cloudbuild.BuildOptions{ DiskSizeGb: defaultDiskSizeGb, }, + AvailableSecrets: &cloudbuild.Secrets{ + SecretManager: []*cloudbuild.SecretManagerSecret{ + { + VersionName: fmt.Sprintf("projects/%s/secrets/reearth-cms-db/versions/latest", project), + Env: "REEARTH_CMS_WORKER_DB", + }, + }, + }, } if region != "" { call := cb.Projects.Locations.Builds.Create(path.Join("projects", project, "locations", region), build) _, err = call.Do() - log.Debug("copy: call build with region!") } else { call := cb.Projects.Builds.Create(project, build) _, err = call.Do() - log.Debug("copy: call build without region!") } if err != nil { return rerror.ErrInternalBy(err) } - log.Debug("copy: cloud build done!") return nil } diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index 918cf7f99..8a307dbf0 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -334,7 +334,6 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera if !operator.IsMaintainingProject(oldModel.Project()) { return nil, interfaces.ErrOperationDenied } - log.Debugf("copy: old model with id %v found", oldModel.ID().String()) name := lo.ToPtr(oldModel.Name() + " Copy") if params.Name != nil { @@ -355,32 +354,27 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera if err != nil { return nil, err } - log.Debugf("copy: new model with id %v created", newModel.ID().String()) // Copy the schema oldSchema, err := i.repos.Schema.FindByID(ctx, oldModel.Schema()) if err != nil { return nil, err } - log.Debugf("copy: old schema with id %v found", oldSchema.ID().String()) newSchema, err := i.repos.Schema.FindByID(ctx, newModel.Schema()) if err != nil { return nil, err } - log.Debugf("copy: new schema with id %v found", newSchema.ID().String()) newSchema.CopyFrom(oldSchema) if err := i.repos.Schema.Save(ctx, newSchema); err != nil { return nil, err } - log.Debug("copy: schema copied") // Copy items if err := i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID()); err != nil { return nil, err } - log.Debug("copy: items copied") // Copy metadata (if present) if oldModel.Metadata() != nil { @@ -388,7 +382,6 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera if err != nil { return nil, err } - log.Debugf("copy: old meta schema with id %v found", oldMetaSchema.ID().String()) newMetaSchema, err := schema.New(). NewID(). @@ -405,17 +398,14 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera if err := i.repos.Model.Save(ctx, newModel); err != nil { return nil, err } - log.Debug("copy: new model with updated metadata saved") if err := i.repos.Schema.Save(ctx, newMetaSchema); err != nil { return nil, err } - log.Debug("copy: new meta schema saved") if err := i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID()); err != nil { return nil, err } - log.Debug("copy: meta items copied") } // Return the new model @@ -446,13 +436,12 @@ func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.Schema if err != nil { return err } - log.Debugf("copy: copy event triggered. collection: %s, filter: %s, changes: %s", collection, filter, changes) return i.triggerCopyEvent(ctx, collection, string(filter), string(changes)) } func (i Model) triggerCopyEvent(ctx context.Context, collection, filter, changes string) error { if i.gateways.TaskRunner == nil { - log.Debugf("model: copy of %s skipped because task runner is not configured", collection) + log.Infof("model: copy of %s skipped because task runner is not configured", collection) return nil } @@ -461,12 +450,11 @@ func (i Model) triggerCopyEvent(ctx context.Context, collection, filter, changes Filter: filter, Changes: changes, } - log.Debugf("copy: task payload created: %v", taskPayload) if err := i.gateways.TaskRunner.Run(ctx, taskPayload.Payload()); err != nil { return fmt.Errorf("failed to trigger copy event: %w", err) } - log.Debugf("model: successfully triggered copy event for collection %s, filter: %s, changes: %s", collection, filter, changes) + log.Infof("model: successfully triggered copy event for collection %s, filter: %s, changes: %s", collection, filter, changes) return nil } diff --git a/worker/cmd/copier/main.go b/worker/cmd/copier/main.go index 10b5efdef..1bca79f0e 100644 --- a/worker/cmd/copier/main.go +++ b/worker/cmd/copier/main.go @@ -23,7 +23,7 @@ func main() { if err := godotenv.Load(".env"); err != nil && !os.IsNotExist(err) { log.Fatal("config: unable to load .env") } else if err == nil { - log.Infof("config: .env loaded") + log.Info("config: .env loaded") } dbURI := mustGetEnv("REEARTH_CMS_WORKER_DB") From 61175e6a7c99eeb3e1cd5536f2ba4f4e6ad14d88 Mon Sep 17 00:00:00 2001 From: KeisukeYamashita <19yamashita15@gmail.com> Date: Thu, 16 Jan 2025 19:26:40 +0100 Subject: [PATCH 13/23] ci: add missing email config for Git in `stage` workflow (#1357) fix: add missing email config in git Signed-off-by: KeisukeYamashita <19yamashita15@gmail.com> --- .github/workflows/stage.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml index a71b59c16..242b5b2b2 100644 --- a/.github/workflows/stage.yml +++ b/.github/workflows/stage.yml @@ -9,10 +9,9 @@ jobs: if: github.ref == 'refs/heads/main' steps: - name: git config - env: - GH_APP_USER: ${{ vars.GH_APP_USER }} run: | - git config --global user.name $GH_APP_USER + git config --global user.name ${{ vars.GH_APP_USER }} + git config --global user.email ${{ vars.GH_APP_ID }}+${{ vars.GH_APP_USER }}[bot]@users.noreply.github.com git config --global pull.rebase false - uses: actions/create-github-app-token@v1 id: app-token From a5819330b4cb22eef752e6ed98c615fc5f0baf2b Mon Sep 17 00:00:00 2001 From: caichi <54824604+caichi-t@users.noreply.github.com> Date: Fri, 17 Jan 2025 18:38:38 +0900 Subject: [PATCH 14/23] fix(web): resolve selecting new member issue (#1358) fix: select anyone --- .../molecules/Member/MemberAddModal/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/src/components/molecules/Member/MemberAddModal/index.tsx b/web/src/components/molecules/Member/MemberAddModal/index.tsx index ba0beecc2..b9ad8e7ea 100644 --- a/web/src/components/molecules/Member/MemberAddModal/index.tsx +++ b/web/src/components/molecules/Member/MemberAddModal/index.tsx @@ -9,7 +9,7 @@ import Modal from "@reearth-cms/components/atoms/Modal"; import Search from "@reearth-cms/components/atoms/Search"; import Select from "@reearth-cms/components/atoms/Select"; import UserAvatar from "@reearth-cms/components/atoms/UserAvatar"; -import { User , Role } from "@reearth-cms/components/molecules/Member/types"; +import { User, Role } from "@reearth-cms/components/molecules/Member/types"; import { MemberInput } from "@reearth-cms/components/molecules/Workspace/types"; import { useT } from "@reearth-cms/i18n"; @@ -27,7 +27,7 @@ type Props = { setSelectedUsers: React.Dispatch>; }; -type FormValues = Record; +type FormValues = { search: string } & Record; const { Option } = Select; @@ -84,7 +84,7 @@ const MemberAddModal: React.FC = ({ useEffect(() => { if (searchedUsers.length) { const options = searchedUsers.map(user => ({ - value: "", + value: user.id, user, label: ( @@ -106,8 +106,9 @@ const MemberAddModal: React.FC = ({ (user: User) => { onUserAdd(user); resultClear(); + form.resetFields(["search"]); }, - [resultClear, onUserAdd], + [onUserAdd, resultClear, form], ); const handleMemberRemove = useCallback( @@ -160,7 +161,7 @@ const MemberAddModal: React.FC = ({ ]}> {open && (
- + Date: Fri, 17 Jan 2025 19:13:46 +0900 Subject: [PATCH 15/23] fix(web): styling when long characters are typed (#1356) * fix: linked request styling * fix: webhook styling * fix: asset creator * fix: multi option default value * fix: item information --- .../molecules/Asset/Asset/AssetBody/Asset.tsx | 21 ++++++++-- .../MultiValueSelect/index.tsx | 10 +++-- .../molecules/Content/Form/SidebarWrapper.tsx | 17 +++++++- .../Webhook/WebhookList/WebhookCard/index.tsx | 42 ++++++++++++++----- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx b/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx index f82fcfc4a..2e40f361c 100644 --- a/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx +++ b/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx @@ -6,7 +6,6 @@ import Button from "@reearth-cms/components/atoms/Button"; import CopyButton from "@reearth-cms/components/atoms/CopyButton"; import DownloadButton from "@reearth-cms/components/atoms/DownloadButton"; import Icon from "@reearth-cms/components/atoms/Icon"; -import Space from "@reearth-cms/components/atoms/Space"; import UserAvatar from "@reearth-cms/components/atoms/UserAvatar"; import Card from "@reearth-cms/components/molecules/Asset/Asset/AssetBody/card"; import PreviewToolbar from "@reearth-cms/components/molecules/Asset/Asset/AssetBody/previewToolbar"; @@ -192,10 +191,10 @@ const AssetMolecule: React.FC = ({ {formattedCreatedAt} - + - {asset.createdBy.name} - + {asset.createdBy.name} + {asset.items.map(item => ( @@ -249,4 +248,18 @@ const StyledButton = styled(Button)` padding: 0; `; +const Creator = styled.div` + display: flex; + gap: 8px; + align-items: center; + max-width: 100%; +`; + +const Username = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + export default AssetMolecule; diff --git a/web/src/components/molecules/Common/MultiValueField/MultiValueSelect/index.tsx b/web/src/components/molecules/Common/MultiValueField/MultiValueSelect/index.tsx index e7e9a1bfc..ebf404883 100644 --- a/web/src/components/molecules/Common/MultiValueField/MultiValueSelect/index.tsx +++ b/web/src/components/molecules/Common/MultiValueField/MultiValueSelect/index.tsx @@ -61,9 +61,8 @@ const MultiValueSelect: React.FC = ({ selectedValues, value = [], onChang /> )} - + {!disabled && ( )` + flex: 1; + overflow: hidden; +`; diff --git a/web/src/components/molecules/Content/Form/SidebarWrapper.tsx b/web/src/components/molecules/Content/Form/SidebarWrapper.tsx index 3d7528912..4e9d09d26 100644 --- a/web/src/components/molecules/Content/Form/SidebarWrapper.tsx +++ b/web/src/components/molecules/Content/Form/SidebarWrapper.tsx @@ -69,7 +69,7 @@ const ContentSidebarWrapper: React.FC = ({ item, onNavigateToRequest }) = {item.requests.map(request => ( - = ({ return ( + {webhook.name} - - + + + + } extra={ @@ -77,18 +79,36 @@ const WebhookCard: React.FC = ({ /> }> - {webhook.url} + {webhook.url} ); }; +const TitleWrapper = styled.div` + display: flex; + gap: 8px; + align-items: center; + padding-right: 4px; +`; + const WebhookTitle = styled.span` - display: inline-block; - margin-right: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const SwitchWrapper = styled.div` + display: inline-flex; `; const StyledCard = styled(Card)` margin-top: 16px; `; +const Content = styled.div` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + export default WebhookCard; From a6f1815460a7b29cb0a9d38fad3c9d19135f5169 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Fri, 17 Jan 2025 13:41:57 +0300 Subject: [PATCH 16/23] fix(server): use configurable DB secret name instead of hardcoding (#1359) * fix: add secret db to env --- server/.env.example | 1 + server/internal/infrastructure/gcp/config.go | 1 + server/internal/infrastructure/gcp/taskrunner.go | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/server/.env.example b/server/.env.example index 8d34202fe..27a772da4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -98,6 +98,7 @@ REEARTH_CMS_TASK_DECOMPRESSORTOPIC= REEARTH_CMS_TASK_DECOMPRESSORGZIPEXT= REEARTH_CMS_TASK_DECOMPRESSORMACHINETYPE= REEARTH_CMS_TASK_COPIERIMAGE= +REEARTH_CMS_TASK_DBSECRETNAME= #AWS REEARTH_CMS_AWSTASK_TOPICARN= diff --git a/server/internal/infrastructure/gcp/config.go b/server/internal/infrastructure/gcp/config.go index 47ab5ea84..dcd103521 100644 --- a/server/internal/infrastructure/gcp/config.go +++ b/server/internal/infrastructure/gcp/config.go @@ -12,4 +12,5 @@ type TaskConfig struct { DecompressorMachineType string `default:"E2_HIGHCPU_8"` DecompressorDiskSideGb int64 `default:"2000"` CopierImage string `default:"reearth/reearth-cms-copier"` + DBSecretName string `default:"reearth-cms-db"` } diff --git a/server/internal/infrastructure/gcp/taskrunner.go b/server/internal/infrastructure/gcp/taskrunner.go index 1c092eb43..b56896899 100644 --- a/server/internal/infrastructure/gcp/taskrunner.go +++ b/server/internal/infrastructure/gcp/taskrunner.go @@ -156,6 +156,7 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { project := conf.GCPProject region := conf.GCPRegion + dbSecretName := conf.DBSecretName build := &cloudbuild.Build{ Timeout: "86400s", // 1 day @@ -179,7 +180,7 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { AvailableSecrets: &cloudbuild.Secrets{ SecretManager: []*cloudbuild.SecretManagerSecret{ { - VersionName: fmt.Sprintf("projects/%s/secrets/reearth-cms-db/versions/latest", project), + VersionName: fmt.Sprintf("projects/%s/secrets/%s/versions/latest", project, dbSecretName), Env: "REEARTH_CMS_WORKER_DB", }, }, From ed34b49cd104ff8600d17fd50a54b31b6583a288 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Sat, 18 Jan 2025 01:35:10 +0300 Subject: [PATCH 17/23] fix(worker): resolve copier dockerfile entry point (#1360) * fix: copier docker file --- worker/copier.Dockerfile | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/worker/copier.Dockerfile b/worker/copier.Dockerfile index abcdfecf8..c3a08e195 100644 --- a/worker/copier.Dockerfile +++ b/worker/copier.Dockerfile @@ -2,23 +2,21 @@ FROM golang:1.23.3 AS build WORKDIR /app -COPY go.work go.work.sum /app/ -COPY server/go.mod server/go.sum server/main.go /app/server/ -COPY worker/go.mod worker/go.sum worker/main.go /app/worker/ +COPY go.work go.work.sum ./ +COPY server/go.mod server/go.sum server/main.go ./server/ +COPY worker/go.mod worker/go.sum worker/main.go ./worker/ RUN go mod download -COPY server/pkg/ /app/server/pkg/ -COPY worker/cmd/ /app/worker/cmd/ -COPY worker/internal/ /app/worker/internal/ -COPY worker/pkg/ /app/worker/pkg/ +COPY server/pkg/ ./server/pkg/ +COPY worker/cmd/ ./worker/cmd/ +COPY worker/internal/ ./worker/internal/ +COPY worker/pkg/ ./worker/pkg/ -RUN CGO_ENABLED=0 go build -trimpath ./worker/cmd/copier +RUN CGO_ENABLED=0 go build -trimpath -o copier ./worker/cmd/copier FROM scratch -COPY --from=build /app/copier /app/copier +COPY --from=build /app/copier /copier -WORKDIR /app - -CMD ["./copier"] \ No newline at end of file +ENTRYPOINT ["/copier"] \ No newline at end of file From 0f41f45ac478fd9effadacffb3681cf06a9f2293 Mon Sep 17 00:00:00 2001 From: caichi <54824604+caichi-t@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:12:40 +0900 Subject: [PATCH 18/23] fix(web): add missing styling of linked request (#1361) fix --- web/src/components/molecules/Content/Form/SidebarWrapper.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/components/molecules/Content/Form/SidebarWrapper.tsx b/web/src/components/molecules/Content/Form/SidebarWrapper.tsx index 4e9d09d26..77b5d0e16 100644 --- a/web/src/components/molecules/Content/Form/SidebarWrapper.tsx +++ b/web/src/components/molecules/Content/Form/SidebarWrapper.tsx @@ -135,6 +135,7 @@ const Requests = styled.div` const StyledButton = styled(Button)` padding: 0; width: calc(100% - 14px); + justify-content: start; span { overflow: hidden; text-overflow: ellipsis; From e04cebfdfd03d00babed426e84563696d9860a96 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Mon, 20 Jan 2025 11:29:34 +0300 Subject: [PATCH 19/23] fix(server-worker): resolve copier's TLS certificate config (#1362) * update the default DBSecretName * skip tls config --- server/internal/infrastructure/gcp/config.go | 2 +- worker/cmd/copier/main.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/internal/infrastructure/gcp/config.go b/server/internal/infrastructure/gcp/config.go index dcd103521..3b780b063 100644 --- a/server/internal/infrastructure/gcp/config.go +++ b/server/internal/infrastructure/gcp/config.go @@ -12,5 +12,5 @@ type TaskConfig struct { DecompressorMachineType string `default:"E2_HIGHCPU_8"` DecompressorDiskSideGb int64 `default:"2000"` CopierImage string `default:"reearth/reearth-cms-copier"` - DBSecretName string `default:"reearth-cms-db"` + DBSecretName string `default:"reearth-cms-worker-db"` } diff --git a/worker/cmd/copier/main.go b/worker/cmd/copier/main.go index 1bca79f0e..4213e4a12 100644 --- a/worker/cmd/copier/main.go +++ b/worker/cmd/copier/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/tls" "os" "time" @@ -56,6 +57,7 @@ func initReposWithCollection(ctx context.Context, dbURI, collection string) (*re ctx, options.Client(). ApplyURI(dbURI). + SetTLSConfig(&tls.Config{InsecureSkipVerify: true}). SetConnectTimeout(10*time.Second). SetMonitor(otelmongo.NewMonitor()), ) From 859b41d164adf34a931593d406bec909bf253dc1 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Mon, 20 Jan 2025 18:20:04 +0300 Subject: [PATCH 20/23] fix(server,worker): resolve copier metadata bug (#1364) * use reearth cms db * fix: TestModel_Copy * error handling * use util.now --- server/internal/infrastructure/gcp/config.go | 2 +- .../internal/infrastructure/gcp/taskrunner.go | 4 +- server/internal/usecase/interactor/model.go | 64 ++++++++++++++++--- .../internal/usecase/interactor/model_test.go | 7 +- server/pkg/task/task.go | 7 +- worker/cmd/copier/main.go | 2 +- .../internal/infrastructure/mongo/copier.go | 34 +++++++++- 7 files changed, 103 insertions(+), 17 deletions(-) diff --git a/server/internal/infrastructure/gcp/config.go b/server/internal/infrastructure/gcp/config.go index 3b780b063..dcd103521 100644 --- a/server/internal/infrastructure/gcp/config.go +++ b/server/internal/infrastructure/gcp/config.go @@ -12,5 +12,5 @@ type TaskConfig struct { DecompressorMachineType string `default:"E2_HIGHCPU_8"` DecompressorDiskSideGb int64 `default:"2000"` CopierImage string `default:"reearth/reearth-cms-copier"` - DBSecretName string `default:"reearth-cms-worker-db"` + DBSecretName string `default:"reearth-cms-db"` } diff --git a/server/internal/infrastructure/gcp/taskrunner.go b/server/internal/infrastructure/gcp/taskrunner.go index b56896899..395866453 100644 --- a/server/internal/infrastructure/gcp/taskrunner.go +++ b/server/internal/infrastructure/gcp/taskrunner.go @@ -170,7 +170,7 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { "REEARTH_CMS_COPIER_CHANGES=" + p.Copy.Changes, }, SecretEnv: []string{ - "REEARTH_CMS_WORKER_DB", + "REEARTH_CMS_DB", }, }, }, @@ -181,7 +181,7 @@ func copy(ctx context.Context, p task.Payload, conf *TaskConfig) error { SecretManager: []*cloudbuild.SecretManagerSecret{ { VersionName: fmt.Sprintf("projects/%s/secrets/%s/versions/latest", project, dbSecretName), - Env: "REEARTH_CMS_WORKER_DB", + Env: "REEARTH_CMS_DB", }, }, }, diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index 8a307dbf0..aced50036 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "github.com/reearth/reearth-cms/server/internal/usecase" "github.com/reearth/reearth-cms/server/internal/usecase/gateway" @@ -18,6 +19,7 @@ import ( "github.com/reearth/reearthx/log" "github.com/reearth/reearthx/rerror" "github.com/reearth/reearthx/usecasex" + "github.com/reearth/reearthx/util" "github.com/samber/lo" "go.mongodb.org/mongo-driver/bson" ) @@ -372,7 +374,8 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera } // Copy items - if err := i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID()); err != nil { + timestamp := util.Now() + if err := i.copyItems(ctx, oldModel.Schema(), newModel.Schema(), newModel.ID(), timestamp, operator); err != nil { return nil, err } @@ -403,7 +406,7 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera return nil, err } - if err := i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID()); err != nil { + if err := i.copyItems(ctx, *oldModel.Metadata(), newMetaSchema.ID(), newModel.ID(), timestamp, operator); err != nil { return nil, err } } @@ -413,16 +416,16 @@ func (i Model) Copy(ctx context.Context, params interfaces.CopyModelParam, opera }) } -func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.SchemaID, newModelID id.ModelID) error { +func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.SchemaID, newModelID id.ModelID, timestamp time.Time, operator *usecase.Operator) error { collection := "item" - filter, err := json.Marshal(bson.M{"schema": oldSchemaID.String()}) + filter, err := json.Marshal(bson.M{"schema": oldSchemaID.String(), "__r": bson.M{"$in": []string{"latest"}}}) if err != nil { return err } - changes, err := json.Marshal(task.Changes{ + c := task.Changes{ "id": { - Type: task.ChangeTypeNew, - Value: "item", + Type: task.ChangeTypeULID, + Value: timestamp.UnixMilli(), }, "schema": { Type: task.ChangeTypeSet, @@ -432,7 +435,52 @@ func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.Schema Type: task.ChangeTypeSet, Value: newModelID.String(), }, - }) + "timestamp": { + Type: task.ChangeTypeSet, + Value: timestamp.String(), + }, + "updatedbyuser": { + Type: task.ChangeTypeSet, + Value: nil, + }, + "updatedbyintegration": { + Type: task.ChangeTypeSet, + Value: nil, + }, + "originalitem": { + Type: task.ChangeTypeULID, + Value: timestamp.UnixMilli(), + }, + "metadataitem": { + Type: task.ChangeTypeULID, + Value: timestamp.UnixMilli(), + }, + "__r": { // tag + Type: task.ChangeTypeSet, + Value: []string{"latest"}, + }, + "__w": { // parent + Type: task.ChangeTypeSet, + Value: nil, + }, + "__v": { // version + Type: task.ChangeTypeNew, + Value: "version", + }, + } + if operator.AcOperator.User != nil { + c["user"] = task.Change{ + Type: task.ChangeTypeSet, + Value: operator.AcOperator.User.String(), + } + } + if operator.Integration != nil { + c["integration"] = task.Change{ + Type: task.ChangeTypeSet, + Value: operator.Integration.String(), + } + } + changes, err := json.Marshal(c) if err != nil { return err } diff --git a/server/internal/usecase/interactor/model_test.go b/server/internal/usecase/interactor/model_test.go index 171ee4f63..3d5d4a8c2 100644 --- a/server/internal/usecase/interactor/model_test.go +++ b/server/internal/usecase/interactor/model_test.go @@ -541,7 +541,12 @@ func TestModel_Copy(t *testing.T) { mockTime := time.Now() wid := accountdomain.NewWorkspaceID() p := project.New().NewID().Workspace(wid).MustBuild() - op := &usecase.Operator{OwningProjects: []id.ProjectID{p.ID()}} + op := &usecase.Operator{ + OwningProjects: []id.ProjectID{p.ID()}, + AcOperator: &accountusecase.Operator{ + User: accountdomain.NewUserID().Ref(), + }, + } fId1 := id.NewFieldID() sfKey1 := id.RandomKey() diff --git a/server/pkg/task/task.go b/server/pkg/task/task.go index 3dab9097c..6b5ef0293 100644 --- a/server/pkg/task/task.go +++ b/server/pkg/task/task.go @@ -64,11 +64,12 @@ func (p *CopyPayload) Validate() bool { type Changes map[string]Change type Change struct { Type ChangeType - Value string + Value any } type ChangeType string const ( - ChangeTypeSet ChangeType = "set" - ChangeTypeNew ChangeType = "new" + ChangeTypeSet ChangeType = "set" + ChangeTypeNew ChangeType = "new" + ChangeTypeULID ChangeType = "ulid" ) diff --git a/worker/cmd/copier/main.go b/worker/cmd/copier/main.go index 4213e4a12..7859a4c12 100644 --- a/worker/cmd/copier/main.go +++ b/worker/cmd/copier/main.go @@ -27,7 +27,7 @@ func main() { log.Info("config: .env loaded") } - dbURI := mustGetEnv("REEARTH_CMS_WORKER_DB") + dbURI := mustGetEnv("REEARTH_CMS_DB") collection := mustGetEnv("REEARTH_CMS_COPIER_COLLECTION") filter := mustGetEnv("REEARTH_CMS_COPIER_FILTER") changes := mustGetEnv("REEARTH_CMS_COPIER_CHANGES") diff --git a/worker/internal/infrastructure/mongo/copier.go b/worker/internal/infrastructure/mongo/copier.go index a7ff84143..f6228e23c 100644 --- a/worker/internal/infrastructure/mongo/copier.go +++ b/worker/internal/infrastructure/mongo/copier.go @@ -4,6 +4,8 @@ import ( "context" "errors" + "github.com/google/uuid" + "github.com/oklog/ulid" "github.com/reearth/reearth-cms/server/pkg/id" "github.com/reearth/reearth-cms/server/pkg/task" "github.com/reearth/reearth-cms/worker/internal/usecase/repo" @@ -58,7 +60,35 @@ func (r *Copier) Copy(ctx context.Context, f bson.M, changesMap task.Changes) er for k, change := range changesMap { switch change.Type { case task.ChangeTypeNew: - newId, _ := generateId(change.Value) + str, ok := change.Value.(string) + if !ok { + return errors.New("invalid change value") + } + newId, ok := generateId(str) + if !ok { + return errors.New("invalid type") + } + result[k] = newId + case task.ChangeTypeULID: + if result[k] == nil { + continue + } + u, ok := result[k].(string) + if !ok { + return errors.New("invalid old id") + } + newId, err := ulid.Parse(u) + if err != nil { + return rerror.ErrInternalBy(err) + } + v, ok := change.Value.(uint64) + if !ok { + return errors.New("invalid millisecond value") + } + err = newId.SetTime(v) + if err != nil { + return rerror.ErrInternalBy(err) + } result[k] = newId case task.ChangeTypeSet: result[k] = change.Value @@ -91,6 +121,8 @@ func generateId(t string) (string, bool) { return id.NewSchemaID().String(), true case "model": return id.NewModelID().String(), true + case "version": + return uuid.New().String(), true default: return "", false } From e10d3360387cd92f5cb79cd74f0d3acaafee2e2d Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Mon, 20 Jan 2025 19:14:58 +0300 Subject: [PATCH 21/23] fix(worker): resolve parsing ULID in copier (#1365) fix: ChangeTypeULID --- worker/internal/infrastructure/mongo/copier.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/worker/internal/infrastructure/mongo/copier.go b/worker/internal/infrastructure/mongo/copier.go index f6228e23c..5d4929d34 100644 --- a/worker/internal/infrastructure/mongo/copier.go +++ b/worker/internal/infrastructure/mongo/copier.go @@ -81,10 +81,14 @@ func (r *Copier) Copy(ctx context.Context, f bson.M, changesMap task.Changes) er if err != nil { return rerror.ErrInternalBy(err) } - v, ok := change.Value.(uint64) + val, ok := change.Value.(float64) if !ok { - return errors.New("invalid millisecond value") + return errors.New("invalid millisecond value: not a float64") } + if val < 0 || val != float64(uint64(val)) { + return errors.New("invalid millisecond value: out of range or not an integer") + } + v := uint64(val) err = newId.SetTime(v) if err != nil { return rerror.ErrInternalBy(err) From 494699615cef0761ca1515b0d4533288bf83dc31 Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Tue, 21 Jan 2025 01:34:52 +0300 Subject: [PATCH 22/23] fix(server,worker): timestamp and ULID format (#1366) * fix: timestamp and ULID format --- server/internal/usecase/interactor/model.go | 2 +- worker/internal/infrastructure/mongo/copier.go | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index aced50036..40b4b25d1 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -437,7 +437,7 @@ func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.Schema }, "timestamp": { Type: task.ChangeTypeSet, - Value: timestamp.String(), + Value: timestamp.UTC().Format("2006-01-02T15:04:05.000+00:00"), //TODO: should use a better way to format }, "updatedbyuser": { Type: task.ChangeTypeSet, diff --git a/worker/internal/infrastructure/mongo/copier.go b/worker/internal/infrastructure/mongo/copier.go index 5d4929d34..d0348ef9b 100644 --- a/worker/internal/infrastructure/mongo/copier.go +++ b/worker/internal/infrastructure/mongo/copier.go @@ -64,11 +64,15 @@ func (r *Copier) Copy(ctx context.Context, f bson.M, changesMap task.Changes) er if !ok { return errors.New("invalid change value") } - newId, ok := generateId(str) - if !ok { - return errors.New("invalid type") + if str == "version" { + result[k] = uuid.New() + } else { + newId, ok := generateId(str) + if !ok { + return errors.New("invalid type") + } + result[k] = newId } - result[k] = newId case task.ChangeTypeULID: if result[k] == nil { continue @@ -93,7 +97,7 @@ func (r *Copier) Copy(ctx context.Context, f bson.M, changesMap task.Changes) er if err != nil { return rerror.ErrInternalBy(err) } - result[k] = newId + result[k] = newId.String() case task.ChangeTypeSet: result[k] = change.Value } @@ -125,8 +129,6 @@ func generateId(t string) (string, bool) { return id.NewSchemaID().String(), true case "model": return id.NewModelID().String(), true - case "version": - return uuid.New().String(), true default: return "", false } From ed5b3f195ef4ba71071ebacaf94e9ab9fff0089a Mon Sep 17 00:00:00 2001 From: Nour Balaha Date: Tue, 21 Jan 2025 17:43:08 +0300 Subject: [PATCH 23/23] fix(web,server,worker): resolve threads bug in copier (#1367) * fix the web to expect empty thread * wip: make thread optional * fix: TestBuilder_Build --- server/go.mod | 2 +- server/internal/adapter/gql/generated.go | 28 ++++--------------- .../adapter/gql/gqlmodel/convert_item.go | 7 +++-- .../adapter/gql/gqlmodel/convert_item_test.go | 2 +- .../adapter/gql/gqlmodel/models_gen.go | 4 +-- server/internal/adapter/gql/resolver_item.go | 5 +++- .../infrastructure/mongo/mongodoc/item.go | 23 +++++++++------ server/internal/usecase/interactor/model.go | 4 +++ server/pkg/item/builder.go | 6 ++-- server/pkg/item/builder_test.go | 26 ++++++++--------- server/schemas/item.graphql | 4 +-- .../Project/Content/ContentList/hooks.ts | 2 +- .../internal/infrastructure/mongo/copier.go | 3 +- 13 files changed, 58 insertions(+), 58 deletions(-) diff --git a/server/go.mod b/server/go.mod index c080eaf6b..7277803df 100644 --- a/server/go.mod +++ b/server/go.mod @@ -23,6 +23,7 @@ require ( github.com/k0kubun/pp/v3 v3.3.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/labstack/echo/v4 v4.12.0 + github.com/labstack/gommon v0.4.2 github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 github.com/oapi-codegen/runtime v1.1.1 github.com/paulmach/go.geojson v1.5.0 @@ -116,7 +117,6 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/opts v1.2.3 // indirect github.com/klauspost/compress v1.17.11 // indirect - github.com/labstack/gommon v0.4.2 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/maruel/panicparse/v2 v2.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/server/internal/adapter/gql/generated.go b/server/internal/adapter/gql/generated.go index 406311072..6808dddc0 100644 --- a/server/internal/adapter/gql/generated.go +++ b/server/internal/adapter/gql/generated.go @@ -5793,7 +5793,7 @@ extend type Mutation { {Name: "../../../schemas/item.graphql", Input: `type Item implements Node { id: ID! schemaId: ID! - threadId: ID! + threadId: ID modelId: ID! projectId: ID! integrationId: ID @@ -5808,7 +5808,7 @@ extend type Mutation { model: Model! status: ItemStatus! project: Project! - thread: Thread! + thread: Thread fields: [ItemField!]! assets: [Asset]! referencedItems:[Item!] @@ -15146,14 +15146,11 @@ func (ec *executionContext) _Item_threadId(ctx context.Context, field graphql.Co return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } - res := resTmp.(gqlmodel.ID) + res := resTmp.(*gqlmodel.ID) fc.Result = res - return ec.marshalNID2githubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐID(ctx, field.Selections, res) + return ec.marshalOID2ᚖgithubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐID(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Item_threadId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -15851,14 +15848,11 @@ func (ec *executionContext) _Item_thread(ctx context.Context, field graphql.Coll return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } res := resTmp.(*gqlmodel.Thread) fc.Result = res - return ec.marshalNThread2ᚖgithubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐThread(ctx, field.Selections, res) + return ec.marshalOThread2ᚖgithubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐThread(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Item_thread(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -43410,9 +43404,6 @@ func (ec *executionContext) _Item(ctx context.Context, sel ast.SelectionSet, obj } case "threadId": out.Values[i] = ec._Item_threadId(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } case "modelId": out.Values[i] = ec._Item_modelId(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -43620,16 +43611,13 @@ func (ec *executionContext) _Item(ctx context.Context, sel ast.SelectionSet, obj case "thread": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() res = ec._Item_thread(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } return res } @@ -51615,10 +51603,6 @@ func (ec *executionContext) marshalNTheme2githubᚗcomᚋreearthᚋreearthᚑcms return v } -func (ec *executionContext) marshalNThread2githubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐThread(ctx context.Context, sel ast.SelectionSet, v gqlmodel.Thread) graphql.Marshaler { - return ec._Thread(ctx, sel, &v) -} - func (ec *executionContext) marshalNThread2ᚖgithubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐThread(ctx context.Context, sel ast.SelectionSet, v *gqlmodel.Thread) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { diff --git a/server/internal/adapter/gql/gqlmodel/convert_item.go b/server/internal/adapter/gql/gqlmodel/convert_item.go index 9873fffaf..7869075ed 100644 --- a/server/internal/adapter/gql/gqlmodel/convert_item.go +++ b/server/internal/adapter/gql/gqlmodel/convert_item.go @@ -20,14 +20,13 @@ func ToItem(vi item.Versioned, s *schema.Schema, gsList schema.List) *Item { for _, s2 := range gsList { groupFields = append(groupFields, toItemFields(i.Fields(), s2, true)...) } - return &Item{ + itm := Item{ ID: IDFrom(i.ID()), ProjectID: IDFrom(i.Project()), SchemaID: IDFrom(i.Schema()), ModelID: IDFrom(i.Model()), UserID: IDFromRef(i.User()), IntegrationID: IDFromRef(i.Integration()), - ThreadID: IDFrom(i.Thread()), MetadataID: IDFromRef(i.MetadataItem()), IsMetadata: i.IsMetadata(), OriginalID: IDFromRef(i.MetadataItem()), @@ -39,6 +38,10 @@ func ToItem(vi item.Versioned, s *schema.Schema, gsList schema.List) *Item { Version: vi.Version().String(), Title: i.GetTitle(s), } + if !i.Thread().Ref().IsNil() { + itm.ThreadID = IDFromRef(i.Thread().Ref()) + } + return &itm } func toItemFields(fields item.Fields, s *schema.Schema, isGroupSchema bool) []*ItemField { var res []*ItemField diff --git a/server/internal/adapter/gql/gqlmodel/convert_item_test.go b/server/internal/adapter/gql/gqlmodel/convert_item_test.go index 5628201cc..1ff380f1e 100644 --- a/server/internal/adapter/gql/gqlmodel/convert_item_test.go +++ b/server/internal/adapter/gql/gqlmodel/convert_item_test.go @@ -53,7 +53,7 @@ func TestToItem(t *testing.T) { ProjectID: IDFrom(pid), ModelID: IDFrom(mid), SchemaID: IDFrom(sid), - ThreadID: IDFrom(tid), + ThreadID: IDFromRef(tid.Ref()), UserID: IDFromRef(uid.Ref()), IntegrationID: IDFromRef(nid.Ref()), CreatedAt: i.ID().Timestamp(), diff --git a/server/internal/adapter/gql/gqlmodel/models_gen.go b/server/internal/adapter/gql/gqlmodel/models_gen.go index cebafb0a1..e6f99269c 100644 --- a/server/internal/adapter/gql/gqlmodel/models_gen.go +++ b/server/internal/adapter/gql/gqlmodel/models_gen.go @@ -524,7 +524,7 @@ type IntegrationPayload struct { type Item struct { ID ID `json:"id"` SchemaID ID `json:"schemaId"` - ThreadID ID `json:"threadId"` + ThreadID *ID `json:"threadId,omitempty"` ModelID ID `json:"modelId"` ProjectID ID `json:"projectId"` IntegrationID *ID `json:"integrationId,omitempty"` @@ -539,7 +539,7 @@ type Item struct { Model *Model `json:"model"` Status ItemStatus `json:"status"` Project *Project `json:"project"` - Thread *Thread `json:"thread"` + Thread *Thread `json:"thread,omitempty"` Fields []*ItemField `json:"fields"` Assets []*Asset `json:"assets"` ReferencedItems []*Item `json:"referencedItems,omitempty"` diff --git a/server/internal/adapter/gql/resolver_item.go b/server/internal/adapter/gql/resolver_item.go index 20f03d169..d581efc4f 100644 --- a/server/internal/adapter/gql/resolver_item.go +++ b/server/internal/adapter/gql/resolver_item.go @@ -48,7 +48,10 @@ func (r *itemResolver) Project(ctx context.Context, obj *gqlmodel.Item) (*gqlmod // Thread is the resolver for the thread field. func (r *itemResolver) Thread(ctx context.Context, obj *gqlmodel.Item) (*gqlmodel.Thread, error) { - return dataloaders(ctx).Thread.Load(obj.ThreadID) + if obj.ThreadID == nil { + return nil, nil + } + return dataloaders(ctx).Thread.Load(*obj.ThreadID) } // Assets is the resolver for the assets field. diff --git a/server/internal/infrastructure/mongo/mongodoc/item.go b/server/internal/infrastructure/mongo/mongodoc/item.go index f1d68793e..fbdd1e494 100644 --- a/server/internal/infrastructure/mongo/mongodoc/item.go +++ b/server/internal/infrastructure/mongo/mongodoc/item.go @@ -62,12 +62,11 @@ func NewVersionedItemConsumer() *VersionedItemConsumer { func NewItem(i *item.Item) (*ItemDocument, string) { itmId := i.ID().String() - return &ItemDocument{ + d := ItemDocument{ ID: itmId, Schema: i.Schema().String(), ModelID: i.Model().String(), Project: i.Project().String(), - Thread: i.Thread().String(), MetadataItem: i.MetadataItem().StringRef(), OriginalItem: i.OriginalItem().StringRef(), Fields: lo.FilterMap(i.Fields(), func(f *item.Field, _ int) (ItemFieldDocument, bool) { @@ -89,7 +88,11 @@ func NewItem(i *item.Item) (*ItemDocument, string) { Integration: i.Integration().StringRef(), Assets: i.AssetIDs().Strings(), IsMetadata: i.IsMetadata(), - }, itmId + } + if !i.Thread().IsEmpty() { + d.Thread = i.Thread().String() + } + return &d, itmId } func (d *ItemDocument) Model() (*item.Item, error) { @@ -113,11 +116,6 @@ func (d *ItemDocument) Model() (*item.Item, error) { return nil, err } - tid, err := id.ThreadIDFrom(d.Thread) - if err != nil { - return nil, err - } - fields, err := util.TryMap(d.Fields, func(f ItemFieldDocument) (*item.Field, error) { // compat if f.Field != "" { @@ -152,11 +150,18 @@ func (d *ItemDocument) Model() (*item.Item, error) { Model(mid). MetadataItem(id.ItemIDFromRef(d.MetadataItem)). OriginalItem(id.ItemIDFromRef(d.OriginalItem)). - Thread(tid). IsMetadata(d.IsMetadata). Fields(fields). Timestamp(d.Timestamp) + if d.Thread != "" { + tid, err := id.ThreadIDFrom(d.Thread) + if err != nil { + return nil, err + } + ib.Thread(tid) + } + if uId := accountdomain.UserIDFromRef(d.User); uId != nil { ib = ib.User(*uId) } diff --git a/server/internal/usecase/interactor/model.go b/server/internal/usecase/interactor/model.go index 40b4b25d1..bb5481476 100644 --- a/server/internal/usecase/interactor/model.go +++ b/server/internal/usecase/interactor/model.go @@ -455,6 +455,10 @@ func (i Model) copyItems(ctx context.Context, oldSchemaID, newSchemaID id.Schema Type: task.ChangeTypeULID, Value: timestamp.UnixMilli(), }, + "thread": { + Type: task.ChangeTypeSet, + Value: nil, + }, "__r": { // tag Type: task.ChangeTypeSet, Value: []string{"latest"}, diff --git a/server/pkg/item/builder.go b/server/pkg/item/builder.go index 9ca13bb23..fee689385 100644 --- a/server/pkg/item/builder.go +++ b/server/pkg/item/builder.go @@ -30,9 +30,9 @@ func (b *Builder) Build() (*Item, error) { if b.i.model.IsNil() { return nil, ErrInvalidID } - if b.i.thread.IsNil() { - return nil, ErrInvalidID - } + // if b.i.thread.IsNil() { + // return nil, ErrInvalidID + // } if b.i.timestamp.IsZero() { b.i.timestamp = util.Now() } diff --git a/server/pkg/item/builder_test.go b/server/pkg/item/builder_test.go index 9a63bc410..42bbe07fb 100644 --- a/server/pkg/item/builder_test.go +++ b/server/pkg/item/builder_test.go @@ -167,19 +167,19 @@ func TestBuilder_Build(t *testing.T) { want: nil, wantErr: id.ErrInvalidID, }, - { - name: "should fail: invalid thread ID", - fields: fields{ - i: &Item{ - id: iid, - schema: sid, - project: pid, - model: mid, - }, - }, - want: nil, - wantErr: id.ErrInvalidID, - }, + // { + // name: "should fail: invalid thread ID", + // fields: fields{ + // i: &Item{ + // id: iid, + // schema: sid, + // project: pid, + // model: mid, + // }, + // }, + // want: nil, + // wantErr: id.ErrInvalidID, + // }, } for _, tt := range tests { diff --git a/server/schemas/item.graphql b/server/schemas/item.graphql index 1ef63a81d..8cd5a7672 100644 --- a/server/schemas/item.graphql +++ b/server/schemas/item.graphql @@ -1,7 +1,7 @@ type Item implements Node { id: ID! schemaId: ID! - threadId: ID! + threadId: ID modelId: ID! projectId: ID! integrationId: ID @@ -16,7 +16,7 @@ type Item implements Node { model: Model! status: ItemStatus! project: Project! - thread: Thread! + thread: Thread fields: [ItemField!]! assets: [Asset]! referencedItems:[Item!] diff --git a/web/src/components/organisms/Project/Content/ContentList/hooks.ts b/web/src/components/organisms/Project/Content/ContentList/hooks.ts index 1ceaac2a0..b0887bae6 100644 --- a/web/src/components/organisms/Project/Content/ContentList/hooks.ts +++ b/web/src/components/organisms/Project/Content/ContentList/hooks.ts @@ -396,7 +396,7 @@ export default () => { createdBy: { id: item.createdBy?.id ?? "", name: item.createdBy?.name ?? "" }, updatedBy: item.updatedBy?.name ?? "", fields: fieldsGet(item as unknown as Item), - comments: item.thread.comments.map(comment => + comments: item.thread?.comments.map(comment => fromGraphQLComment(comment as GQLComment), ), version: item.version, diff --git a/worker/internal/infrastructure/mongo/copier.go b/worker/internal/infrastructure/mongo/copier.go index d0348ef9b..7dc67fe56 100644 --- a/worker/internal/infrastructure/mongo/copier.go +++ b/worker/internal/infrastructure/mongo/copier.go @@ -3,6 +3,7 @@ package mongo import ( "context" "errors" + "strings" "github.com/google/uuid" "github.com/oklog/ulid" @@ -97,7 +98,7 @@ func (r *Copier) Copy(ctx context.Context, f bson.M, changesMap task.Changes) er if err != nil { return rerror.ErrInternalBy(err) } - result[k] = newId.String() + result[k] = strings.ToLower(newId.String()) case task.ChangeTypeSet: result[k] = change.Value }