diff --git a/apps/api/src/integrations/application/services/connection.service.spec.ts b/apps/api/src/integrations/application/services/connection.service.spec.ts index b593ea47f..96ec17504 100644 --- a/apps/api/src/integrations/application/services/connection.service.spec.ts +++ b/apps/api/src/integrations/application/services/connection.service.spec.ts @@ -128,6 +128,14 @@ describe('ConnectionService', () => { }) ), update: jest.fn(), + getByRef: jest.fn().mockResolvedValue({ + id: 'cred-row-1', + ref: 'cred-ref-1', + platformType: 'prestashop', + credentialsJson: {}, + createdAt: new Date(), + updatedAt: new Date(), + }), delete: jest.fn().mockResolvedValue(true), } as unknown as jest.Mocked; @@ -815,6 +823,36 @@ describe('ConnectionService', () => { }); }); + it('should merge onto existing stored fields instead of replacing the whole blob', async () => { + const dbConnection = new Connection( + 'connection-123', + 'prestashop', + 'Test Connection', + 'active', + {}, + 'db:cred-ref-1', + new Date(), + new Date(), + undefined, + ['ProductMaster'] + ); + connectionPort.get.mockResolvedValue(dbConnection); + credentials.getByRef.mockResolvedValue({ + id: 'cred-row-1', + ref: 'cred-ref-1', + platformType: 'prestashop', + credentialsJson: { webserviceApiKey: 'OLD', otherField: 'keep-me' }, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await service.updateCredentials('connection-123', { webserviceApiKey: 'NEW' }); + + expect(credentials.update).toHaveBeenCalledWith('cred-ref-1', { + credentialsJson: { webserviceApiKey: 'NEW', otherField: 'keep-me' }, + }); + }); + it('should reject rotation on non-db-backed connection', async () => { const legacy = new Connection( 'connection-123', diff --git a/apps/api/src/integrations/application/services/connection.service.ts b/apps/api/src/integrations/application/services/connection.service.ts index 103c8209f..2e662e653 100644 --- a/apps/api/src/integrations/application/services/connection.service.ts +++ b/apps/api/src/integrations/application/services/connection.service.ts @@ -416,9 +416,16 @@ export class ConnectionService implements IConnectionService { platformType: connection.platformType, adapterKey: connection.adapterKey, }); - await this.validateCredentialsShape(metadata.adapterKey, credentials); const ref = connection.credentialsRef.slice('db:'.length); - await this.credentials.update(ref, { credentialsJson: credentials }); + // Merge onto the existing stored credentials rather than replacing the + // whole blob: callers only send the fields they actually changed (e.g. + // rotating just `apiKey`), and a full replace would silently delete any + // other previously-stored field (e.g. Erli's optional Allegro + // `allegroClientId`/`allegroClientSecret` pair, #1401 review). + const existing = await this.credentials.getByRef(ref); + const mergedCredentials = { ...existing.credentialsJson, ...credentials }; + await this.validateCredentialsShape(metadata.adapterKey, mergedCredentials); + await this.credentials.update(ref, { credentialsJson: mergedCredentials }); this.logger.log(`Rotated credentials for connection ${connectionId}`); } diff --git a/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts b/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts index b7f913e56..a5c3ca2e8 100644 --- a/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts +++ b/apps/web/src/features/listings/components/create-offer-request-to-form-values.ts @@ -53,8 +53,13 @@ function readString(params: Record | undefined, key: string): s * (product). The form-state map is keyed by parameter id alone — re-submission * re-derives the section split from the freshly-loaded category-parameters * metadata, so the section distinction is not preserved on the form side. + * + * Exported for reuse by the Erli retry mapper (#1384) — both platforms + * persist the same neutral `overrides.parameters` wire shape. */ -function readParameters(overrides: CreateOfferOverrides | undefined): CategoryParameterFormValues { +export function readParameters( + overrides: CreateOfferOverrides | undefined, +): CategoryParameterFormValues { const out: CategoryParameterFormValues = {}; appendWireParameters(out, overrides?.parameters); // Transitional fallback for snapshots persisted before #1071. diff --git a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts index e3294b746..08fd8b8d6 100644 --- a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts +++ b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.test.ts @@ -55,6 +55,24 @@ describe('createErliOfferRequestToFormValues', () => { expect(values.dispatchPeriod).toBe(2); expect(values.dispatchUnit).toBe('day'); }); + + it('restores category-parameter values from the wire snapshot on retry', () => { + const values = createErliOfferRequestToFormValues( + snapshot({ + overrides: { + title: 'With parameters', + categoryId: '12345', + parameters: [ + { id: 'p1', values: ['Red'], section: 'offer' }, + { id: 'p2', valuesIds: ['v1', 'v2'], section: 'offer' }, + ], + }, + }), + FALLBACK, + ); + + expect(values.parameters).toEqual({ p1: 'Red', p2: ['v1', 'v2'] }); + }); }); describe('readErliOfferRequestPrefill', () => { diff --git a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts index 7d06ef493..3d75bfc24 100644 --- a/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts +++ b/apps/web/src/features/listings/components/erli/create-erli-offer-request-to-form-values.ts @@ -26,6 +26,7 @@ import { SUPPORTED_OFFER_CREATION_REQUEST_SCHEMA_VERSION, type CreateOfferRequest, } from '../../api/listings.types'; +import { readParameters } from '../create-offer-request-to-form-values'; import type { ErliCreateOfferValues } from './erli-create-offer.schema'; import { isValidDispatch, type ErliDispatchTimeParam } from './erli-offer-fields.schema'; @@ -62,6 +63,10 @@ export function createErliOfferRequestToFormValues( publishImmediately: request.publishImmediately, dispatchPeriod: dispatch.period, dispatchUnit: dispatch.unit, + // Reuses the Allegro retry mapper's heuristic (#1384) — both platforms + // persist the same neutral `overrides.parameters` wire shape, so a + // retried offer re-opens with its previously entered parameter values. + parameters: readParameters(overrides), }; } diff --git a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx index 059e7ff68..eeb82c645 100644 --- a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx +++ b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.test.tsx @@ -28,6 +28,15 @@ const erliConnection: Connection = { updatedAt: '2026-01-01T00:00:00Z', }; +// #1384 — a per-connection-instance signal (`config.allegroCategoryAccessEnabled`), +// deliberately NOT reflected in `supportedCapabilities` (static per-adapterKey, +// see ADR-031 "Correction") — both connections keep the same `supportedCapabilities`. +const erliConnectionWithCategoryAccess: Connection = { + ...erliConnection, + id: 'conn_erli_2', + config: { ...erliConnection.config, allegroCategoryAccessEnabled: true }, +}; + function productWith(images: string[] | null): Product { return { id: 'ol_product_abc', @@ -205,4 +214,140 @@ describe('ErliCreateOfferWizard', () => { dispatchTime: { period: 5, unit: 'hour' }, }); }); + + // #1384 — capability-conditional category/parameters steps. + describe('Allegro category access (#1384)', () => { + it('keeps the plain-text category field and shows the fallback hint when access is not configured', async () => { + renderWithProviders( + , + { apiClient: mocks(productWith(['https://cdn.example.com/a.jpg'])) }, + ); + + await pickVariantAndAdvance(); + + expect(await screen.findByPlaceholderText(/e\.g\. 12345/i)).toBeInTheDocument(); + expect( + screen.getByText(/add allegro category browsing to this connection/i), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /configure category browsing/i })).toHaveAttribute( + 'href', + `/connections/${erliConnection.id}/edit`, + ); + // Only the 3-step shape — no "Category" / "Category parameters" steps. + expect(screen.queryByText('Category parameters')).not.toBeInTheDocument(); + }); + + it('renders the Category and Category-parameters steps, blocks on a required parameter, and submits overrides.parameters', async () => { + const mockApi = mocks(productWith(['https://cdn.example.com/a.jpg']), { + connections: { list: vi.fn().mockResolvedValue([erliConnectionWithCategoryAccess]) }, + listings: { + createOffer: vi + .fn() + .mockResolvedValue({ jobId: 'job-1', offerCreationRecordId: 'rec-1' }), + resolveCategory: vi.fn().mockResolvedValue({ allegroCategoryId: null, method: 'manual' }), + getCategoryParameters: vi.fn().mockResolvedValue({ + parameters: [ + { + id: 'p_stan', + name: 'Stan', + type: 'dictionary', + required: true, + section: 'offer', + restrictions: {}, + dictionary: [{ id: 'nowy', value: 'Nowy' }], + }, + ], + }), + }, + mappings: { + getAllegroCategories: vi + .fn() + .mockResolvedValue([{ id: '12345', name: 'Test Category', parentId: null, leaf: true }]), + }, + }); + renderWithProviders( + , + { apiClient: mockApi }, + ); + + await pickVariantAndAdvance(); + // Offer-details step: no plain-text category field or hint anymore — + // it moved to its own step. + fireEvent.change(await screen.findByLabelText(/^price \(PLN\)$/i), { + target: { value: '99.99' }, + }); + expect(screen.queryByPlaceholderText(/e\.g\. 12345/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category step — pick the leaf via the reused CategoryPicker. + const selectButton = await screen.findByRole('button', { name: /^select$/i }); + fireEvent.click(selectButton); + await waitFor(() => + expect(screen.getByRole('button', { name: /^selected$/i })).toBeInTheDocument(), + ); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category-parameters step — required "Stan" field blocks Next until filled. + await screen.findByText('Stan'); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + expect(await screen.findByText(/stan is required/i)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Stan'), { target: { value: 'nowy' } }); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + fireEvent.click(await screen.findByRole('button', { name: /create offer/i })); + + const createOffer = vi.mocked(mockApi.listings.createOffer); + await waitFor(() => expect(createOffer).toHaveBeenCalledTimes(1)); + const [, request] = createOffer.mock.calls[0] as [string, CreateOfferRequest]; + expect(request.overrides?.categoryId).toBe('12345'); + expect(request.overrides?.parameters).toEqual([ + { id: 'p_stan', valuesIds: ['nowy'], section: 'offer' }, + ]); + }); + + it('blocks Next on the Category step until a category is selected (#1401 review)', async () => { + const mockApi = mocks(productWith(['https://cdn.example.com/a.jpg']), { + connections: { list: vi.fn().mockResolvedValue([erliConnectionWithCategoryAccess]) }, + listings: { + createOffer: vi + .fn() + .mockResolvedValue({ jobId: 'job-1', offerCreationRecordId: 'rec-1' }), + resolveCategory: vi.fn().mockResolvedValue({ allegroCategoryId: null, method: 'manual' }), + getCategoryParameters: vi.fn().mockResolvedValue({ parameters: [] }), + }, + mappings: { + getAllegroCategories: vi + .fn() + .mockResolvedValue([{ id: '12345', name: 'Test Category', parentId: null, leaf: true }]), + }, + }); + renderWithProviders( + , + { apiClient: mockApi }, + ); + + await pickVariantAndAdvance(); + fireEvent.change(await screen.findByLabelText(/^price \(PLN\)$/i), { + target: { value: '99.99' }, + }); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + // Category step — click Next without touching the CategoryPicker at all. + await screen.findByText('Category'); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + expect(await screen.findByText(/select a category to continue/i)).toBeInTheDocument(); + // Still on the Category step — Category-parameters never rendered. + expect(screen.queryByText(/no additional parameters required/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx index fe56c2199..b5b73dfdd 100644 --- a/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx +++ b/apps/web/src/features/listings/components/erli/erli-create-offer-wizard.tsx @@ -31,8 +31,9 @@ * @module features/listings/components/erli */ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { Controller, FormProvider, useForm, type FieldPath } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Link } from 'react-router-dom'; import { Alert } from '../../../../shared/ui/alert'; import { Button } from '../../../../shared/ui/button'; @@ -49,8 +50,21 @@ import { useProductQuery, useProductsQuery, useVariantQuery } from '../../../pro import type { Product, ProductVariant } from '../../../products'; import { useCreateOfferMutation } from '../../hooks/use-create-offer-mutation'; import { useResolveCategoryQuery } from '../../hooks/use-resolve-category-query'; -import type { CreateOfferRequest } from '../../api/listings.types'; -import { erliCreateOfferSchema, type ErliCreateOfferValues } from './erli-create-offer.schema'; +import { useCategoryParametersQuery } from '../../hooks/use-category-parameters-query'; +import { CategoryPicker } from '../CategoryPicker'; +import { CategoryParametersStep } from '../category-parameters-step'; +import { buildParametersZodSchema } from '../build-parameters-zod-schema'; +import { + MissingCategoryParameterSectionError, + categoryParametersToOfferParameters, +} from '../category-parameters-to-offer-parameters'; +import type { CategoryParameterFormValues } from '../category-parameter-form.types'; +import type { CreateOfferRequest, OfferParameter } from '../../api/listings.types'; +import { + erliCreateOfferSchema, + type ErliCreateOfferSubmission, + type ErliCreateOfferValues, +} from './erli-create-offer.schema'; import { ErliDispatchTimeField } from './erli-dispatch-time-field'; import { formatDispatch, @@ -59,10 +73,38 @@ import { } from './erli-offer-fields.schema'; import { readErliOfferRequestPrefill } from './create-erli-offer-request-to-form-values'; -const ERLI_STEP_LABELS = ['Variant', 'Offer details', 'Review'] as const; +/** + * Step labels — capability-conditional (#1384). Erli borrows Allegro's + * category/attribute taxonomy (ADR-023, ADR-031); when the connection has + * Allegro app credentials configured (`config.allegroCategoryAccessEnabled`, + * a per-connection-instance-visible flag — NOT `supportedCapabilities`, + * which is static per-adapterKey and cannot reflect this, see ADR-031 + * "Correction"), the wizard grows a dedicated Category step (CategoryPicker) + * and a Category-parameters step, mirroring `AllegroCreateOfferWizard`. + * Otherwise it keeps today's 3-step shape with the plain-text category field + * folded into "Offer details". + */ +const ERLI_STEP_LABELS_BASIC = ['Variant', 'Offer details', 'Review'] as const; +const ERLI_STEP_LABELS_WITH_CATEGORY = [ + 'Variant', + 'Offer details', + 'Category', + 'Category parameters', + 'Review', +] as const; const VARIANT_SEARCH_DEBOUNCE_MS = 300; const VARIANT_PICKER_PAGE_SIZE = 20; +/** + * RHF cannot infer the dynamic `parameters.{paramId}` path from + * `ErliCreateOfferValues` (the `parameters` slice is `z.record(z.unknown())` + * by design — per-field shapes come from the runtime `CategoryParameter` + * list). Mirrors `AllegroCreateOfferWizard`'s `parametersFieldPath` helper. + */ +function parametersFieldPath(paramId: string): FieldPath { + return `parameters.${paramId}` as FieldPath; +} + function variantLabel(product: Product, variant: ProductVariant): string { const attrs = variant.attributes ? Object.values(variant.attributes).join(' · ') : ''; if (attrs) return `${product.name} — ${attrs}`; @@ -106,7 +148,18 @@ export function ErliCreateOfferWizard({ // Shared, declared-once Erli validator (image gate). Resolved via usePlatform. const erliValidation = usePlatform(connection.platformType)?.offerValidation; - const form = useForm({ + // #1384 — per-connection-instance signal (NOT `connection.supportedCapabilities`, + // which is static per-adapterKey — see ADR-031 "Correction"). Read directly off + // the connection's already-returned `config`, no new query needed. + const allegroCategoryAccessEnabled = connection.config.allegroCategoryAccessEnabled === true; + const stepLabels = allegroCategoryAccessEnabled + ? ERLI_STEP_LABELS_WITH_CATEGORY + : ERLI_STEP_LABELS_BASIC; + const categoryStepIndex = allegroCategoryAccessEnabled ? 2 : null; + const categoryParametersStepIndex = allegroCategoryAccessEnabled ? 3 : null; + const reviewStepIndex = stepLabels.length - 1; + + const form = useForm({ defaultValues: prefill ?? { internalVariantId: defaultVariantId ?? '', variantLabel: '', @@ -118,6 +171,7 @@ export function ErliCreateOfferWizard({ publishImmediately: false, dispatchPeriod: dispatchDefault.period, dispatchUnit: dispatchDefault.unit, + parameters: {}, }, resolver: zodResolver(erliCreateOfferSchema), mode: 'onBlur', @@ -170,6 +224,35 @@ export function ErliCreateOfferWizard({ } }, [categoryQuery.data, form]); + // #1384 — category-parameter schema, only fetched once a category is + // selected AND the connection has Allegro category access configured. + // Same hook Allegro's wizard uses (`useCategoryParametersQuery`) — no new + // hook needed, per the plan. + const currentCategoryId = form.watch('categoryId'); + const categoryParametersQuery = useCategoryParametersQuery( + allegroCategoryAccessEnabled ? connection.id : undefined, + allegroCategoryAccessEnabled ? currentCategoryId || undefined : undefined, + ); + const categoryParameters = useMemo( + () => categoryParametersQuery.data ?? [], + [categoryParametersQuery.data], + ); + // Clear the parameters slice whenever the chosen category changes — the + // shape is category-specific so prior values would never be valid under a + // new schema (mirrors `AllegroCreateOfferWizard`). + const lastCategoryIdRef = useRef(currentCategoryId); + useEffect(() => { + if (lastCategoryIdRef.current && lastCategoryIdRef.current !== currentCategoryId) { + form.setValue('parameters', {}, { shouldDirty: false, shouldValidate: false }); + form.clearErrors('parameters'); + } + lastCategoryIdRef.current = currentCategoryId; + }, [currentCategoryId, form]); + // #423 — surfaces `MissingCategoryParameterSectionError` (stale cache + // predating #417) with an actionable "reload" message, mirroring Allegro's + // wizard. + const [staleSchemaError, setStaleSchemaError] = useState(null); + useEffect(() => { function handleBeforeUnload(event: BeforeUnloadEvent): void { if (!form.formState.isDirty) return; @@ -187,10 +270,10 @@ export function ErliCreateOfferWizard({ () => erliValidation?.validateRow({ imageCount: imageUrls.length, - needsProductParameters: false, + needsProductParameters: allegroCategoryAccessEnabled, willLinkProductCard: false, }) ?? [], - [erliValidation, imageUrls.length], + [erliValidation, imageUrls.length, allegroCategoryAccessEnabled], ); const hasImageBlocker = pickedProduct !== null && imageBlockers.length > 0; @@ -207,19 +290,59 @@ export function ErliCreateOfferWizard({ setPickedVariantEan(variant.ean ?? variant.gtin ?? null); } - const STEP_FIELDS: ReadonlyArray> = [ - ['internalVariantId'], - ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], - [], - ]; + const STEP_FIELDS: ReadonlyArray> = allegroCategoryAccessEnabled + ? [ + ['internalVariantId'], + ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], + [], // Category — CategoryPicker, no static-field trigger + [], // Category parameters — validated dynamically below + [], + ] + : [['internalVariantId'], ['title', 'priceAmount', 'stock', 'dispatchPeriod', 'dispatchUnit'], []]; async function goNext(): Promise { const fields = STEP_FIELDS[stepIndex] ?? []; const valid = fields.length === 0 ? true : await form.trigger(fields); if (!valid) return; if (stepIndex === 1 && hasImageBlocker) return; // can't advance with a missing image + + // #1401 review — Category is a static `[]` STEP_FIELDS entry (CategoryPicker + // has no static-field trigger), so `categoryId` must be enforced manually here, + // mirroring `AllegroCreateOfferWizard`'s required `categoryId` field. Without + // this an operator can click through the Category step without picking one. + if (allegroCategoryAccessEnabled && stepIndex === categoryStepIndex) { + if (!form.getValues('categoryId')) { + form.setError('categoryId', { type: 'manual', message: 'Select a category to continue.' }); + return; + } + form.clearErrors('categoryId'); + } + + // #1384 — dynamic per-category Zod validation, mirrors + // `AllegroCreateOfferWizard`'s Step-3 gate. Skipped when the category has + // no parameters or the schema is still loading. + if ( + allegroCategoryAccessEnabled && + stepIndex === categoryParametersStepIndex && + categoryParameters.length > 0 + ) { + const paramValues = + (form.getValues('parameters') as CategoryParameterFormValues | undefined) ?? {}; + const result = buildParametersZodSchema(categoryParameters).safeParse(paramValues); + if (!result.success) { + form.clearErrors('parameters'); + for (const issue of result.error.issues) { + const paramId = String(issue.path[0] ?? ''); + if (paramId === '') continue; + form.setError(parametersFieldPath(paramId), { type: 'manual', message: issue.message }); + } + return; + } + form.clearErrors('parameters'); + } + setCompletedSteps((prev) => new Set(prev).add(stepIndex)); - setStepIndex((i) => Math.min(i + 1, ERLI_STEP_LABELS.length - 1)); + setStepIndex((i) => Math.min(i + 1, stepLabels.length - 1)); } function goBack(): void { @@ -236,6 +359,28 @@ export function ErliCreateOfferWizard({ // the `defaultVariantId` path where no product was interactively picked. if (imageBlockers.length > 0) return; + setStaleSchemaError(null); // clear from any prior submit + + // #1384 — serialize the Step-4 category-parameter values into the + // neutral `OfferParameter[]` shape, exactly like Allegro's wizard. + // `OfferBuilderService.buildOfferParameters` merges `overrides.parameters` + // server-side unchanged — no BE change needed (confirmed in ADR-031's plan). + let parameters: OfferParameter[] = []; + if (allegroCategoryAccessEnabled) { + try { + parameters = categoryParametersToOfferParameters( + (submitted.parameters as CategoryParameterFormValues | undefined) ?? {}, + categoryParameters, + ); + } catch (error) { + if (error instanceof MissingCategoryParameterSectionError) { + setStaleSchemaError(error.parameterName); + return; + } + throw error; + } + } + const request: CreateOfferRequest = { internalVariantId: submitted.internalVariantId, stock: submitted.stock, @@ -246,6 +391,7 @@ export function ErliCreateOfferWizard({ ...(submitted.categoryId ? { categoryId: submitted.categoryId } : {}), description: submitted.description ? submitted.description : null, imageUrls, + ...(parameters.length > 0 ? { parameters } : {}), platformParams: { dispatchTime: { period: submitted.dispatchPeriod, unit: submitted.dispatchUnit }, }, @@ -291,7 +437,7 @@ export function ErliCreateOfferWizard({ @@ -304,6 +450,22 @@ export function ErliCreateOfferWizard({ {mutation.error.message} ) : null} + {staleSchemaError !== null ? ( + +

+ Category parameter {staleSchemaError} is missing data that was added + in a recent update. Please reload this page to refetch the latest category schema. +

+
+ + +
+
+ ) : null}
- - - + {!allegroCategoryAccessEnabled ? ( + <> + + + +

+ Add Allegro category browsing to this connection to pick from a list instead + of typing a raw category id. Configure category browsing. +

+ + ) : null}
) : null} - {stepIndex === 2 ? ( + {allegroCategoryAccessEnabled && stepIndex === categoryStepIndex ? ( +
+ + Category + + ( + + )} + /> +

+ Browse the Allegro-borrowed category tree (ADR-023/ADR-031) and pick a leaf + category. +

+ {form.formState.errors.categoryId?.message ? ( + + ) : null} +
+ ) : null} + + {allegroCategoryAccessEnabled && stepIndex === categoryParametersStepIndex ? ( + categoryParametersQuery.isLoading ? ( +

+ Loading category parameters… +

+ ) : categoryParametersQuery.error ? ( + + {categoryParametersQuery.error.message} + + + ) : categoryParameters.length === 0 ? ( +

No additional parameters required for this category.

+ ) : ( + + ) + ) : null} + + {stepIndex === reviewStepIndex ? (
Variant
{values.variantLabel || values.internalVariantId}
@@ -499,7 +723,7 @@ export function ErliCreateOfferWizard({ ← Back ) : null} - {stepIndex < ERLI_STEP_LABELS.length - 1 ? ( + {stepIndex < stepLabels.length - 1 ? ( +
+ + Stored encrypted. You won't see it again after saving. + + + + ) : null} +
-
) : ( - )} diff --git a/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md b/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md index 10228a23c..89a40e3e1 100644 --- a/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md +++ b/docs/architecture/adrs/031-erli-allegro-category-catalog-via-client-credentials.md @@ -18,6 +18,8 @@ Erli owns its own small Allegro client-credentials HTTP client (`AllegroCategory **Correction (found during #1383 implementation)**: the connection-list DTO's `supportedCapabilities` field is populated from the static, per-`adapterKey` `AdapterMetadata.supportedCapabilities` declared at plugin registration — it is **not** computed by resolving each connection's live adapter instance and running the `is*` guards. It therefore cannot differ between two Erli connections regardless of whether either has Allegro app credentials configured, and the bulk-wizard's `connection.supportedCapabilities.includes('CategoryBrowser')` gate (#1367) is consequently **unaffected** by this feature (it stays `false` for Erli either way — a pre-existing bulk-wizard limitation, unchanged, and out of scope here per the plan's non-goals). The frontend single-offer wizard therefore needs its own, per-connection-instance-visible signal: a new non-secret `ErliConnectionConfig.allegroCategoryAccessEnabled?: boolean` field, written/cleared by the backend in the same operation that writes/clears `allegroClientId`/`allegroClientSecret`, and read directly by the FE from the connection's `config` (already returned verbatim by the generic connection-read endpoints — no DTO changes needed). This keeps the mechanism entirely inside Erli's own plugin-local config shape; it does not touch the generic capability system. +**Second correction (found during #1401 review)**: the sentence above — "written/cleared by the backend in the same operation that writes/clears `allegroClientId`/`allegroClientSecret`" — does not hold in the shipped implementation. No single backend endpoint accepts both the Allegro credential pair and `config.allegroCategoryAccessEnabled` in one request; `PUT /connections/:id/credentials` and `PATCH /connections/:id` (config) remain independent, generic, single-purpose routes for every platform, with zero cross-write coupling. `ErliCredentialsPanel` instead sequences two **FE-orchestrated** mutations from one Save click — credentials first, then the config flag — ordered to fail safe (credentials-write failure skips the config patch entirely; a config-patch failure after a successful credentials write leaves the flag at its prior value and surfaces an inline retryable error). See the component's module header and the PR #1401 description's "Atomicity design decision" section for the full analysis. This ADR's Decision section is corrected accordingly. + ## Alternatives considered - **Require a real Allegro seller `Connection`, resolve its taxonomy from there**: rejected — forces Erli-only operators to create and maintain an unrelated Allegro seller account/connection just to browse a public catalog; also reintroduces the "which of N Allegro connections is the owner" ambiguity noted in `category-mapping.repository.ts`'s existing "Ambiguous borrowed-taxonomy category mapping" log. @@ -37,6 +39,8 @@ Erli owns its own small Allegro client-credentials HTTP client (`AllegroCategory - Small, self-contained duplication of OAuth `client_credentials` + category-fetch logic between the Allegro and Erli packages (~100-150 lines) rather than one shared implementation. - Per-connection Allegro app credentials mean each operator manages their own Allegro Developer Portal app registration (extra setup step, mitigated by the optional/recommended framing in the connection wizard). - Category/parameter data is genuinely seller-independent, so a future multi-tenant SaaS deployment may still want the shared-credential alternative — deferred, not precluded, by this design. +- **FE wizard step topology deviates from the approved mockup, deliberately** (flagged in the #1401 review, confirmed here as an accepted scope cut, not an oversight): the mockup keeps a constant 5-step shape, with the Category step itself rendering the plain-text fallback when Allegro access is off. The shipped `ErliCreateOfferWizard` instead collapses to 3 steps in that state, folding category entry into "Offer details." Functionally equivalent, fully tested, and simpler to reason about than a step that changes shape in place — but explicitly noted here so it isn't re-discovered as an unstated deviation. +- **No credential-verification status indicator on save** (also flagged in the #1401 review, confirmed as an accepted scope cut): the mockup shows a live "Not verified yet" / "Verification failed" badge and `aria-invalid` on the Allegro Client ID/Secret fields. This is deferred because there is no synchronous Allegro credential-verification call in the backend today — the `client_credentials` token exchange only happens lazily, on the first category-catalog fetch, so the FE has nothing to poll or await at save time. Tracked as a fast-follow candidate for once a synchronous verification endpoint exists (would need its own issue). **Migration path (if applicable):** - Purely additive: existing Erli connections without `allegroClientId`/`allegroClientSecret` keep today's plain-text category-ID behavior unchanged.