emdash 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{adapters-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
- package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
- package/dist/{apply-5uslYdUu.mjs → apply-x0eMK1lX.mjs} +18 -17
- package/dist/apply-x0eMK1lX.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +86 -15
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +22 -2
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware/request-context.mjs +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +259 -71
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +16 -8
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
- package/dist/byline-Chbr2GoP.mjs.map +1 -0
- package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
- package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
- package/dist/cli/index.mjs +16 -12
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{content-D7J5y73J.mjs → content-BcQPYxdV.mjs} +13 -15
- package/dist/content-BcQPYxdV.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
- package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
- package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
- package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
- package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
- package/dist/error-zG5T1UGA.mjs.map +1 -0
- package/dist/{index-De6_Xv3v.d.mts → index-DIb-CzNx.d.mts} +157 -14
- package/dist/index-DIb-CzNx.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +22 -20
- package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
- package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
- package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
- package/dist/loader-CndGj8kM.mjs.map +1 -0
- package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
- package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
- package/dist/media/index.d.mts +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/media/local-runtime.mjs +2 -2
- package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
- package/dist/media-D8FbNsl0.mjs.map +1 -0
- package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
- package/dist/mode-BnAOqItE.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
- package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
- package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-g4Ug-9j9.mjs → query-fqEdLFms.mjs} +9 -9
- package/dist/{query-g4Ug-9j9.mjs.map → query-fqEdLFms.mjs.map} +1 -1
- package/dist/{redirect-CN0Rt9Ob.mjs → redirect-D_pshWdf.mjs} +4 -4
- package/dist/redirect-D_pshWdf.mjs.map +1 -0
- package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
- package/dist/registry-C3Mr0ODu.mjs.map +1 -0
- package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
- package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
- package/dist/{runner-BR2xKwhn.d.mts → runner-OURCaApa.d.mts} +2 -2
- package/dist/{runner-BR2xKwhn.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +2 -2
- package/dist/{search-B0effn3j.mjs → search-BoZYFuUk.mjs} +227 -84
- package/dist/search-BoZYFuUk.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +12 -12
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.d.mts.map +1 -1
- package/dist/storage/s3.mjs +4 -4
- package/dist/storage/s3.mjs.map +1 -1
- package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-B4IAshV8.mjs} +5 -5
- package/dist/{taxonomies-K2z0Uhnj.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
- package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
- package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
- package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
- package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
- package/dist/types-BIgulNsW.mjs +68 -0
- package/dist/types-BIgulNsW.mjs.map +1 -0
- package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
- package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
- package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
- package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
- package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
- package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
- package/dist/{types-C2v0c34j.d.mts → types-CS8FIX7L.d.mts} +1 -1
- package/dist/{types-C2v0c34j.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
- package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
- package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
- package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
- package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
- package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
- package/dist/types-i36XcA_X.d.mts.map +1 -0
- package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
- package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
- package/dist/{validate-kM8Pjuf7.d.mts → validate-DHxmpFJt.d.mts} +4 -4
- package/dist/{validate-kM8Pjuf7.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
- package/dist/validation-C-ZpN2GI.mjs +144 -0
- package/dist/validation-C-ZpN2GI.mjs.map +1 -0
- package/dist/version-Bbq8TCrz.mjs +7 -0
- package/dist/{version-BnTKdfam.mjs.map → version-Bbq8TCrz.mjs.map} +1 -1
- package/dist/zod-generator-CpwccCIv.mjs +132 -0
- package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
- package/package.json +18 -5
- package/src/api/auth-storage.ts +37 -0
- package/src/api/error.ts +6 -0
- package/src/api/errors.ts +8 -0
- package/src/api/handlers/comments.ts +13 -0
- package/src/api/handlers/content.ts +122 -3
- package/src/api/handlers/index.ts +2 -0
- package/src/api/handlers/media.ts +8 -1
- package/src/api/handlers/menus.ts +160 -21
- package/src/api/handlers/redirects.ts +16 -3
- package/src/api/handlers/sections.ts +8 -1
- package/src/api/handlers/taxonomies.ts +128 -16
- package/src/api/handlers/validation.ts +212 -0
- package/src/api/openapi/document.ts +4 -1
- package/src/api/public-url.ts +6 -3
- package/src/api/route-utils.ts +14 -0
- package/src/api/schemas/common.ts +1 -1
- package/src/api/schemas/setup.ts +8 -0
- package/src/api/schemas/widgets.ts +12 -10
- package/src/api/setup-complete.ts +40 -0
- package/src/astro/integration/index.ts +13 -2
- package/src/astro/integration/routes.ts +28 -0
- package/src/astro/integration/runtime.ts +19 -1
- package/src/astro/integration/virtual-modules.ts +41 -0
- package/src/astro/integration/vite-config.ts +43 -12
- package/src/astro/middleware/auth.ts +21 -0
- package/src/astro/middleware.ts +18 -1
- package/src/astro/routes/PluginRegistry.tsx +10 -1
- package/src/astro/routes/api/auth/mode.ts +57 -0
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
- package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
- package/src/astro/routes/api/content/[collection]/index.ts +1 -9
- package/src/astro/routes/api/import/wordpress/media.ts +2 -7
- package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
- package/src/astro/routes/api/settings/email.ts +4 -9
- package/src/astro/routes/api/setup/admin.ts +8 -2
- package/src/astro/routes/api/setup/index.ts +2 -2
- package/src/astro/routes/api/setup/status.ts +3 -1
- package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
- package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
- package/src/astro/routes/api/widget-areas/[name].ts +4 -1
- package/src/astro/routes/api/widget-areas/index.ts +4 -1
- package/src/astro/types.ts +9 -0
- package/src/auth/mode.ts +15 -3
- package/src/auth/providers/github-admin.tsx +29 -0
- package/src/auth/providers/github.ts +31 -0
- package/src/auth/providers/google-admin.tsx +44 -0
- package/src/auth/providers/google.ts +31 -0
- package/src/auth/types.ts +114 -4
- package/src/cli/commands/bundle.ts +3 -1
- package/src/components/EmDashImage.astro +7 -6
- package/src/components/Gallery.astro +5 -3
- package/src/components/Image.astro +8 -3
- package/src/components/InlinePortableTextEditor.tsx +2 -1
- package/src/components/LiveSearch.astro +5 -14
- package/src/database/repositories/audit.ts +6 -8
- package/src/database/repositories/byline.ts +6 -8
- package/src/database/repositories/comment.ts +12 -16
- package/src/database/repositories/content.ts +40 -40
- package/src/database/repositories/index.ts +1 -1
- package/src/database/repositories/media.ts +10 -13
- package/src/database/repositories/plugin-storage.ts +4 -6
- package/src/database/repositories/redirect.ts +12 -16
- package/src/database/repositories/taxonomy.ts +14 -3
- package/src/database/repositories/types.ts +57 -8
- package/src/database/repositories/user.ts +6 -8
- package/src/emdash-runtime.ts +306 -90
- package/src/index.ts +5 -1
- package/src/loader.ts +6 -5
- package/src/mcp/server.ts +678 -105
- package/src/media/normalize.ts +1 -1
- package/src/media/url.ts +78 -0
- package/src/plugins/email-console.ts +10 -3
- package/src/plugins/hooks.ts +11 -0
- package/src/plugins/manifest-schema.ts +12 -0
- package/src/plugins/types.ts +23 -2
- package/src/query.ts +1 -1
- package/src/request-cache.ts +3 -0
- package/src/schema/registry.ts +41 -5
- package/src/search/fts-manager.ts +0 -2
- package/src/search/query.ts +111 -26
- package/src/search/types.ts +8 -1
- package/src/sections/index.ts +7 -9
- package/src/storage/s3.ts +12 -6
- package/src/virtual-modules.d.ts +21 -1
- package/src/widgets/index.ts +1 -1
- package/dist/apply-5uslYdUu.mjs.map +0 -1
- package/dist/byline-C4OVd8b3.mjs.map +0 -1
- package/dist/content-D7J5y73J.mjs.map +0 -1
- package/dist/error-CiYn9yDu.mjs.map +0 -1
- package/dist/index-De6_Xv3v.d.mts.map +0 -1
- package/dist/loader-DeiBJEMe.mjs.map +0 -1
- package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
- package/dist/media-DqHVh136.mjs.map +0 -1
- package/dist/mode-CpNnGkPz.mjs.map +0 -1
- package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
- package/dist/registry-Ci3WxVAr.mjs.map +0 -1
- package/dist/request-cache-DiR961CV.mjs.map +0 -1
- package/dist/search-B0effn3j.mjs.map +0 -1
- package/dist/types-CMMN0pNg.mjs +0 -31
- package/dist/types-CMMN0pNg.mjs.map +0 -1
- package/dist/types-DgrIP0tF.d.mts.map +0 -1
- package/dist/version-BnTKdfam.mjs +0 -7
|
@@ -14,6 +14,7 @@ import { RevisionRepository } from "../../database/repositories/revision.js";
|
|
|
14
14
|
import { SeoRepository } from "../../database/repositories/seo.js";
|
|
15
15
|
import {
|
|
16
16
|
EmDashValidationError,
|
|
17
|
+
InvalidCursorError,
|
|
17
18
|
type ContentItem,
|
|
18
19
|
type ContentSeo,
|
|
19
20
|
type ContentSeoInput,
|
|
@@ -23,9 +24,26 @@ import type { Database } from "../../database/types.js";
|
|
|
23
24
|
import { validateIdentifier } from "../../database/validate.js";
|
|
24
25
|
import { isI18nEnabled } from "../../i18n/config.js";
|
|
25
26
|
import { invalidateRedirectCache } from "../../redirects/cache.js";
|
|
27
|
+
import { isMissingTableError } from "../../utils/db-errors.js";
|
|
26
28
|
import { encodeRev, validateRev } from "../rev.js";
|
|
27
29
|
import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Narrow a caught error to one carrying a structured `apiError` discriminant.
|
|
33
|
+
* Used by transaction callbacks that want to surface a specific error code
|
|
34
|
+
* through the standard Error throwing path.
|
|
35
|
+
*/
|
|
36
|
+
function hasApiError(error: unknown): error is Error & { apiError: { code: string } } {
|
|
37
|
+
if (!(error instanceof Error) || !("apiError" in error)) return false;
|
|
38
|
+
const { apiError } = error;
|
|
39
|
+
return (
|
|
40
|
+
typeof apiError === "object" &&
|
|
41
|
+
apiError !== null &&
|
|
42
|
+
"code" in apiError &&
|
|
43
|
+
typeof apiError.code === "string"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
/**
|
|
30
48
|
* Extract a slug source (title or name) from content data.
|
|
31
49
|
* Returns null if no suitable string field is found.
|
|
@@ -267,6 +285,28 @@ export async function handleContentList(
|
|
|
267
285
|
},
|
|
268
286
|
};
|
|
269
287
|
} catch (error) {
|
|
288
|
+
if (error instanceof InvalidCursorError) {
|
|
289
|
+
return {
|
|
290
|
+
success: false,
|
|
291
|
+
error: { code: "INVALID_CURSOR", message: error.message },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (isMissingTableError(error)) {
|
|
295
|
+
return {
|
|
296
|
+
success: false,
|
|
297
|
+
error: {
|
|
298
|
+
code: "COLLECTION_NOT_FOUND",
|
|
299
|
+
message: `Collection '${collection}' not found`,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (error instanceof EmDashValidationError) {
|
|
304
|
+
// e.g. invalid orderBy field
|
|
305
|
+
return {
|
|
306
|
+
success: false,
|
|
307
|
+
error: { code: "VALIDATION_ERROR", message: error.message },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
270
310
|
console.error("Content list error:", error);
|
|
271
311
|
return {
|
|
272
312
|
success: false,
|
|
@@ -453,6 +493,46 @@ export async function handleContentCreate(
|
|
|
453
493
|
data: { item, _rev: encodeRev(item) },
|
|
454
494
|
};
|
|
455
495
|
} catch (error) {
|
|
496
|
+
if (isMissingTableError(error)) {
|
|
497
|
+
return {
|
|
498
|
+
success: false,
|
|
499
|
+
error: {
|
|
500
|
+
code: "COLLECTION_NOT_FOUND",
|
|
501
|
+
message: `Collection '${collection}' not found`,
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (error instanceof EmDashValidationError) {
|
|
506
|
+
return {
|
|
507
|
+
success: false,
|
|
508
|
+
error: { code: "VALIDATION_ERROR", message: error.message },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
// SQLite UNIQUE constraint OR Postgres unique_violation — slug
|
|
512
|
+
// collisions and any other unique violations land here. Match
|
|
513
|
+
// specifically on "unique constraint failed" / "duplicate key" so we
|
|
514
|
+
// don't false-positive on NOT NULL or CHECK violations whose
|
|
515
|
+
// messages also contain "constraint failed".
|
|
516
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
517
|
+
if (message.includes("unique constraint failed") || message.includes("duplicate key")) {
|
|
518
|
+
// Detect slug-specific collisions by message fingerprint
|
|
519
|
+
if (message.includes("slug")) {
|
|
520
|
+
return {
|
|
521
|
+
success: false,
|
|
522
|
+
error: {
|
|
523
|
+
code: "SLUG_CONFLICT",
|
|
524
|
+
message: `Slug '${body.slug ?? "(auto-generated)"}' already exists in collection '${collection}'`,
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
success: false,
|
|
530
|
+
error: {
|
|
531
|
+
code: "CONFLICT",
|
|
532
|
+
message: "Unique constraint violation",
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
456
536
|
console.error("Content create error:", error);
|
|
457
537
|
return {
|
|
458
538
|
success: false,
|
|
@@ -604,11 +684,44 @@ export async function handleContentUpdate(
|
|
|
604
684
|
} catch (error) {
|
|
605
685
|
// Handle structured errors thrown from inside the transaction
|
|
606
686
|
// (rev check failures, not-found)
|
|
607
|
-
if (error
|
|
608
|
-
|
|
687
|
+
if (hasApiError(error)) {
|
|
688
|
+
return {
|
|
689
|
+
success: false,
|
|
690
|
+
error: { code: error.apiError.code, message: error.message },
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
if (isMissingTableError(error)) {
|
|
694
|
+
return {
|
|
695
|
+
success: false,
|
|
696
|
+
error: {
|
|
697
|
+
code: "COLLECTION_NOT_FOUND",
|
|
698
|
+
message: `Collection '${collection}' not found`,
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
if (error instanceof EmDashValidationError) {
|
|
609
703
|
return {
|
|
610
704
|
success: false,
|
|
611
|
-
error: { code, message: error.message },
|
|
705
|
+
error: { code: "VALIDATION_ERROR", message: error.message },
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
709
|
+
if (message.includes("unique constraint failed") || message.includes("duplicate key")) {
|
|
710
|
+
if (message.includes("slug")) {
|
|
711
|
+
return {
|
|
712
|
+
success: false,
|
|
713
|
+
error: {
|
|
714
|
+
code: "SLUG_CONFLICT",
|
|
715
|
+
message: `Slug '${body.slug ?? id}' already exists in collection '${collection}'`,
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
success: false,
|
|
721
|
+
error: {
|
|
722
|
+
code: "CONFLICT",
|
|
723
|
+
message: "Unique constraint violation",
|
|
724
|
+
},
|
|
612
725
|
};
|
|
613
726
|
}
|
|
614
727
|
console.error("Content update error:", error);
|
|
@@ -869,6 +982,12 @@ export async function handleContentListTrashed(
|
|
|
869
982
|
},
|
|
870
983
|
};
|
|
871
984
|
} catch (error) {
|
|
985
|
+
if (error instanceof InvalidCursorError) {
|
|
986
|
+
return {
|
|
987
|
+
success: false,
|
|
988
|
+
error: { code: "INVALID_CURSOR", message: error.message },
|
|
989
|
+
};
|
|
990
|
+
}
|
|
872
991
|
console.error("Content list trashed error:", error);
|
|
873
992
|
return {
|
|
874
993
|
success: false,
|
|
@@ -113,11 +113,13 @@ export {
|
|
|
113
113
|
handleMenuItemUpdate,
|
|
114
114
|
handleMenuItemDelete,
|
|
115
115
|
handleMenuItemReorder,
|
|
116
|
+
handleMenuSetItems,
|
|
116
117
|
type MenuListItem,
|
|
117
118
|
type MenuWithItems,
|
|
118
119
|
type CreateMenuItemInput,
|
|
119
120
|
type UpdateMenuItemInput,
|
|
120
121
|
type ReorderItem,
|
|
122
|
+
type MenuSetItemsInput,
|
|
121
123
|
} from "./menus.js";
|
|
122
124
|
|
|
123
125
|
// Section handlers
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Kysely } from "kysely";
|
|
6
6
|
|
|
7
7
|
import { MediaRepository, type MediaItem } from "../../database/repositories/media.js";
|
|
8
|
+
import { InvalidCursorError } from "../../database/repositories/types.js";
|
|
8
9
|
import type { Database } from "../../database/types.js";
|
|
9
10
|
import type { ApiResult } from "../types.js";
|
|
10
11
|
|
|
@@ -43,7 +44,13 @@ export async function handleMediaList(
|
|
|
43
44
|
nextCursor: result.nextCursor,
|
|
44
45
|
},
|
|
45
46
|
};
|
|
46
|
-
} catch {
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof InvalidCursorError) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
error: { code: "INVALID_CURSOR", message: error.message },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
47
54
|
return {
|
|
48
55
|
success: false,
|
|
49
56
|
error: {
|
|
@@ -42,26 +42,33 @@ export interface MenuWithItems extends MenuRow {
|
|
|
42
42
|
*/
|
|
43
43
|
export async function handleMenuList(db: Kysely<Database>): Promise<ApiResult<MenuListItem[]>> {
|
|
44
44
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.
|
|
45
|
+
// Single query: LEFT JOIN + GROUP BY for the per-menu item count.
|
|
46
|
+
// Avoids the N+1 of one count query per menu.
|
|
47
|
+
const rows = await db
|
|
48
|
+
.selectFrom("_emdash_menus as m")
|
|
49
|
+
.leftJoin("_emdash_menu_items as i", "i.menu_id", "m.id")
|
|
50
|
+
.select(({ fn }) => [
|
|
51
|
+
"m.id",
|
|
52
|
+
"m.name",
|
|
53
|
+
"m.label",
|
|
54
|
+
"m.created_at",
|
|
55
|
+
"m.updated_at",
|
|
56
|
+
fn.count<number>("i.id").as("itemCount"),
|
|
57
|
+
])
|
|
58
|
+
.groupBy(["m.id", "m.name", "m.label", "m.created_at", "m.updated_at"])
|
|
59
|
+
.orderBy("m.name", "asc")
|
|
49
60
|
.execute();
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
itemCount: count,
|
|
62
|
-
};
|
|
63
|
-
}),
|
|
64
|
-
);
|
|
62
|
+
// SQLite returns count as `number`, but some dialects (Postgres)
|
|
63
|
+
// return `string` from a count() aggregate. Normalize to number.
|
|
64
|
+
const menusWithCounts: MenuListItem[] = rows.map((row) => ({
|
|
65
|
+
id: row.id,
|
|
66
|
+
name: row.name,
|
|
67
|
+
label: row.label,
|
|
68
|
+
created_at: row.created_at,
|
|
69
|
+
updated_at: row.updated_at,
|
|
70
|
+
itemCount: typeof row.itemCount === "string" ? Number(row.itemCount) : row.itemCount,
|
|
71
|
+
}));
|
|
65
72
|
|
|
66
73
|
return { success: true, data: menusWithCounts };
|
|
67
74
|
} catch {
|
|
@@ -135,7 +142,7 @@ export async function handleMenuGet(
|
|
|
135
142
|
if (!menu) {
|
|
136
143
|
return {
|
|
137
144
|
success: false,
|
|
138
|
-
error: { code: "NOT_FOUND", message:
|
|
145
|
+
error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
|
|
139
146
|
};
|
|
140
147
|
}
|
|
141
148
|
|
|
@@ -173,7 +180,7 @@ export async function handleMenuUpdate(
|
|
|
173
180
|
if (!menu) {
|
|
174
181
|
return {
|
|
175
182
|
success: false,
|
|
176
|
-
error: { code: "NOT_FOUND", message:
|
|
183
|
+
error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
|
|
177
184
|
};
|
|
178
185
|
}
|
|
179
186
|
|
|
@@ -217,10 +224,14 @@ export async function handleMenuDelete(
|
|
|
217
224
|
if (!menu) {
|
|
218
225
|
return {
|
|
219
226
|
success: false,
|
|
220
|
-
error: { code: "NOT_FOUND", message:
|
|
227
|
+
error: { code: "NOT_FOUND", message: `Menu '${name}' not found` },
|
|
221
228
|
};
|
|
222
229
|
}
|
|
223
230
|
|
|
231
|
+
// D1 has FOREIGN KEYS off by default, so the migration's `ON DELETE
|
|
232
|
+
// CASCADE` won't fire there. Delete items explicitly first — this is
|
|
233
|
+
// idempotent on SQLite/Postgres where the cascade also fires.
|
|
234
|
+
await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
|
|
224
235
|
await db.deleteFrom("_emdash_menus").where("id", "=", menu.id).execute();
|
|
225
236
|
|
|
226
237
|
return { success: true, data: { deleted: true } };
|
|
@@ -443,6 +454,134 @@ export interface ReorderItem {
|
|
|
443
454
|
sortOrder: number;
|
|
444
455
|
}
|
|
445
456
|
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Atomic-replace menu items (used by the MCP `menu_set_items` tool)
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
export interface MenuSetItemsInput {
|
|
462
|
+
label: string;
|
|
463
|
+
type: "custom" | "page" | "post" | "taxonomy" | "collection";
|
|
464
|
+
customUrl?: string;
|
|
465
|
+
referenceCollection?: string;
|
|
466
|
+
referenceId?: string;
|
|
467
|
+
titleAttr?: string;
|
|
468
|
+
target?: string;
|
|
469
|
+
cssClasses?: string;
|
|
470
|
+
/**
|
|
471
|
+
* Index of the parent item in this same array. Must be strictly less
|
|
472
|
+
* than the current item's index so the insert order resolves parents
|
|
473
|
+
* before children. `undefined` makes the item top-level.
|
|
474
|
+
*/
|
|
475
|
+
parentIndex?: number;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Replace the entire set of items for a menu in one atomic transaction.
|
|
480
|
+
*
|
|
481
|
+
* Existing items are deleted and the new list is inserted in the order
|
|
482
|
+
* provided. `parentIndex` references resolve to actual parent IDs as the
|
|
483
|
+
* insert proceeds.
|
|
484
|
+
*/
|
|
485
|
+
export async function handleMenuSetItems(
|
|
486
|
+
db: Kysely<Database>,
|
|
487
|
+
menuName: string,
|
|
488
|
+
items: MenuSetItemsInput[],
|
|
489
|
+
): Promise<ApiResult<{ name: string; itemCount: number }>> {
|
|
490
|
+
// Validate parentIndex references — must be strictly earlier so
|
|
491
|
+
// the array can be inserted in order with parents resolved first.
|
|
492
|
+
// Negative indices are out of range; only Zod's `.nonnegative()` at
|
|
493
|
+
// the MCP boundary catches them today, so guard explicitly here for
|
|
494
|
+
// any caller that bypasses Zod (REST routes, direct handler use).
|
|
495
|
+
for (let i = 0; i < items.length; i++) {
|
|
496
|
+
const item = items[i];
|
|
497
|
+
if (item?.parentIndex !== undefined) {
|
|
498
|
+
if (item.parentIndex < 0 || item.parentIndex >= i) {
|
|
499
|
+
return {
|
|
500
|
+
success: false,
|
|
501
|
+
error: {
|
|
502
|
+
code: "VALIDATION_ERROR",
|
|
503
|
+
message: `item[${i}].parentIndex (${item.parentIndex}) must reference an earlier item`,
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
// Sentinel for "menu not found" thrown from inside the transaction
|
|
512
|
+
// so the rollback fires before we return the structured error.
|
|
513
|
+
const notFoundSentinel = Symbol("menu-not-found");
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
await withTransaction(db, async (trx) => {
|
|
517
|
+
// Existence check INSIDE the transaction so a concurrent
|
|
518
|
+
// menu_delete between lookup and write can't leave orphan
|
|
519
|
+
// items on D1 (FKs disabled by default).
|
|
520
|
+
const menu = await trx
|
|
521
|
+
.selectFrom("_emdash_menus")
|
|
522
|
+
.select("id")
|
|
523
|
+
.where("name", "=", menuName)
|
|
524
|
+
.executeTakeFirst();
|
|
525
|
+
|
|
526
|
+
if (!menu) {
|
|
527
|
+
throw notFoundSentinel;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
await trx.deleteFrom("_emdash_menu_items").where("menu_id", "=", menu.id).execute();
|
|
531
|
+
|
|
532
|
+
const insertedIds: string[] = [];
|
|
533
|
+
for (let i = 0; i < items.length; i++) {
|
|
534
|
+
const item = items[i];
|
|
535
|
+
if (!item) continue;
|
|
536
|
+
const id = ulid();
|
|
537
|
+
const parentId =
|
|
538
|
+
item.parentIndex !== undefined ? (insertedIds[item.parentIndex] ?? null) : null;
|
|
539
|
+
await trx
|
|
540
|
+
.insertInto("_emdash_menu_items")
|
|
541
|
+
.values({
|
|
542
|
+
id,
|
|
543
|
+
menu_id: menu.id,
|
|
544
|
+
parent_id: parentId,
|
|
545
|
+
sort_order: i,
|
|
546
|
+
type: item.type,
|
|
547
|
+
reference_collection: item.referenceCollection ?? null,
|
|
548
|
+
reference_id: item.referenceId ?? null,
|
|
549
|
+
custom_url: item.customUrl ?? null,
|
|
550
|
+
label: item.label,
|
|
551
|
+
title_attr: item.titleAttr ?? null,
|
|
552
|
+
target: item.target ?? null,
|
|
553
|
+
css_classes: item.cssClasses ?? null,
|
|
554
|
+
})
|
|
555
|
+
.execute();
|
|
556
|
+
insertedIds.push(id);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await trx
|
|
560
|
+
.updateTable("_emdash_menus")
|
|
561
|
+
.set({ updated_at: new Date().toISOString() })
|
|
562
|
+
.where("id", "=", menu.id)
|
|
563
|
+
.execute();
|
|
564
|
+
});
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error === notFoundSentinel) {
|
|
567
|
+
return {
|
|
568
|
+
success: false,
|
|
569
|
+
error: { code: "NOT_FOUND", message: `Menu '${menuName}' not found` },
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return { success: true, data: { name: menuName, itemCount: items.length } };
|
|
576
|
+
} catch (error) {
|
|
577
|
+
console.error("[emdash] handleMenuSetItems failed:", error);
|
|
578
|
+
return {
|
|
579
|
+
success: false,
|
|
580
|
+
error: { code: "MENU_SET_ITEMS_ERROR", message: "Failed to set menu items" },
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
446
585
|
/**
|
|
447
586
|
* Batch reorder menu items.
|
|
448
587
|
*/
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type NotFoundEntry,
|
|
12
12
|
type NotFoundSummary,
|
|
13
13
|
} from "../../database/repositories/redirect.js";
|
|
14
|
+
import { InvalidCursorError } from "../../database/repositories/types.js";
|
|
14
15
|
import type { FindManyResult } from "../../database/repositories/types.js";
|
|
15
16
|
import type { Database } from "../../database/types.js";
|
|
16
17
|
import { wouldCreateLoop, detectLoops, type RedirectEdge } from "../../redirects/loops.js";
|
|
@@ -48,7 +49,13 @@ export async function handleRedirectList(
|
|
|
48
49
|
...(loopRedirectIds.length > 0 ? { loopRedirectIds } : {}),
|
|
49
50
|
},
|
|
50
51
|
};
|
|
51
|
-
} catch {
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error instanceof InvalidCursorError) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: { code: "INVALID_CURSOR", message: error.message },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
52
59
|
return {
|
|
53
60
|
success: false,
|
|
54
61
|
error: { code: "REDIRECT_LIST_ERROR", message: "Failed to fetch redirects" },
|
|
@@ -318,7 +325,7 @@ export async function handleRedirectDelete(
|
|
|
318
325
|
function loopError(loopPath: string[]): ApiResult<never> {
|
|
319
326
|
const hops = loopPath
|
|
320
327
|
.slice(0, -1)
|
|
321
|
-
.map((p, i) => `${p} \u2192 ${loopPath[i + 1]
|
|
328
|
+
.map((p, i) => `${p} \u2192 ${loopPath[i + 1]}`)
|
|
322
329
|
.join("\n");
|
|
323
330
|
return {
|
|
324
331
|
success: false,
|
|
@@ -387,7 +394,13 @@ export async function handleNotFoundList(
|
|
|
387
394
|
const repo = new RedirectRepository(db);
|
|
388
395
|
const result = await repo.find404s(params);
|
|
389
396
|
return { success: true, data: result };
|
|
390
|
-
} catch {
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error instanceof InvalidCursorError) {
|
|
399
|
+
return {
|
|
400
|
+
success: false,
|
|
401
|
+
error: { code: "INVALID_CURSOR", message: error.message },
|
|
402
|
+
};
|
|
403
|
+
}
|
|
391
404
|
return {
|
|
392
405
|
success: false,
|
|
393
406
|
error: { code: "NOT_FOUND_LIST_ERROR", message: "Failed to fetch 404 log" },
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Kysely } from "kysely";
|
|
6
6
|
import { ulid } from "ulidx";
|
|
7
7
|
|
|
8
|
+
import { InvalidCursorError } from "../../database/repositories/types.js";
|
|
8
9
|
import type { FindManyResult } from "../../database/repositories/types.js";
|
|
9
10
|
import type { Database } from "../../database/types.js";
|
|
10
11
|
import {
|
|
@@ -36,7 +37,13 @@ export async function handleSectionList(
|
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
return { success: true, data: result };
|
|
39
|
-
} catch {
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof InvalidCursorError) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: { code: "INVALID_CURSOR", message: error.message },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
40
47
|
return {
|
|
41
48
|
success: false,
|
|
42
49
|
error: { code: "SECTION_LIST_ERROR", message: "Failed to fetch sections" },
|
|
@@ -122,16 +122,27 @@ export async function handleTaxonomyList(
|
|
|
122
122
|
db: Kysely<Database>,
|
|
123
123
|
): Promise<ApiResult<TaxonomyListResponse>> {
|
|
124
124
|
try {
|
|
125
|
-
const rows = await
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
125
|
+
const [rows, collectionRows] = await Promise.all([
|
|
126
|
+
db.selectFrom("_emdash_taxonomy_defs").selectAll().execute(),
|
|
127
|
+
db.selectFrom("_emdash_collections").select("slug").execute(),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
// Filter orphan collection references on read so the response stays
|
|
131
|
+
// consistent with `schema_list_collections`. Storage is untouched —
|
|
132
|
+
// re-creating the collection re-links automatically.
|
|
133
|
+
const realCollections = new Set(collectionRows.map((r) => r.slug));
|
|
134
|
+
|
|
135
|
+
const taxonomies: TaxonomyDef[] = rows.map((row) => {
|
|
136
|
+
const stored: string[] = row.collections ? JSON.parse(row.collections) : [];
|
|
137
|
+
return {
|
|
138
|
+
id: row.id,
|
|
139
|
+
name: row.name,
|
|
140
|
+
label: row.label,
|
|
141
|
+
labelSingular: row.label_singular ?? undefined,
|
|
142
|
+
hierarchical: row.hierarchical === 1,
|
|
143
|
+
collections: stored.filter((slug) => realCollections.has(slug)),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
135
146
|
|
|
136
147
|
return { success: true, data: { taxonomies } };
|
|
137
148
|
} catch {
|
|
@@ -290,6 +301,84 @@ export async function handleTermList(
|
|
|
290
301
|
}
|
|
291
302
|
}
|
|
292
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Validate a parent term reference for create/update.
|
|
306
|
+
*
|
|
307
|
+
* Returns `null` on success or a structured error message that callers
|
|
308
|
+
* wrap in their own ApiResult.
|
|
309
|
+
*
|
|
310
|
+
* - `parentId === undefined` -> no-op (no parent change requested).
|
|
311
|
+
* - `parentId === null` -> caller intends to detach; no-op here.
|
|
312
|
+
* - parent must exist (FK exists -> term row not soft-deleted).
|
|
313
|
+
* - parent must live in the same taxonomy.
|
|
314
|
+
* - if `termId` is provided (update path), reject `parentId === termId`
|
|
315
|
+
* (self-parent) and walk up the parent chain to detect cycles.
|
|
316
|
+
*/
|
|
317
|
+
async function validateParentTerm(
|
|
318
|
+
repo: TaxonomyRepository,
|
|
319
|
+
taxonomyName: string,
|
|
320
|
+
termId: string | undefined,
|
|
321
|
+
parentId: string | null | undefined,
|
|
322
|
+
): Promise<{ code: "VALIDATION_ERROR"; message: string } | null> {
|
|
323
|
+
if (parentId === undefined || parentId === null) return null;
|
|
324
|
+
|
|
325
|
+
if (termId !== undefined && parentId === termId) {
|
|
326
|
+
return {
|
|
327
|
+
code: "VALIDATION_ERROR",
|
|
328
|
+
message: "A term cannot be its own parent",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const parent = await repo.findById(parentId);
|
|
333
|
+
if (!parent) {
|
|
334
|
+
return {
|
|
335
|
+
code: "VALIDATION_ERROR",
|
|
336
|
+
message: `Parent term '${parentId}' not found`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (parent.name !== taxonomyName) {
|
|
340
|
+
return {
|
|
341
|
+
code: "VALIDATION_ERROR",
|
|
342
|
+
message: `Parent term '${parentId}' belongs to taxonomy '${parent.name}', not '${taxonomyName}'`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Walk up the parent chain. Two checks fold into one walk:
|
|
347
|
+
// - Cycle detection (only on update — a non-existent term-being-
|
|
348
|
+
// created can't be its own ancestor): if the walk revisits termId
|
|
349
|
+
// the proposed parent makes the term a descendant of itself.
|
|
350
|
+
// - Depth bound: refuse to extend a chain past MAX_DEPTH ancestors.
|
|
351
|
+
// Runs on both create and update so a malicious or buggy caller
|
|
352
|
+
// can't grow the tree without limit.
|
|
353
|
+
//
|
|
354
|
+
// The depth-exceeded error fires only when we hit the limit AND there
|
|
355
|
+
// was still chain to walk — a legitimate chain of exactly MAX_DEPTH
|
|
356
|
+
// ancestors exits with `cursor === null` and is accepted.
|
|
357
|
+
const MAX_DEPTH = 100;
|
|
358
|
+
let cursor: string | null = parent.parentId;
|
|
359
|
+
let steps = 0;
|
|
360
|
+
while (cursor !== null && steps < MAX_DEPTH) {
|
|
361
|
+
if (termId !== undefined && cursor === termId) {
|
|
362
|
+
return {
|
|
363
|
+
code: "VALIDATION_ERROR",
|
|
364
|
+
message: "Cycle detected: cannot make a descendant the parent",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const next = await repo.findById(cursor);
|
|
368
|
+
if (!next) break;
|
|
369
|
+
cursor = next.parentId;
|
|
370
|
+
steps++;
|
|
371
|
+
}
|
|
372
|
+
if (cursor !== null && steps >= MAX_DEPTH) {
|
|
373
|
+
return {
|
|
374
|
+
code: "VALIDATION_ERROR",
|
|
375
|
+
message: "Parent chain exceeds maximum depth",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
293
382
|
/**
|
|
294
383
|
* Create a new term in a taxonomy
|
|
295
384
|
*/
|
|
@@ -304,6 +393,10 @@ export async function handleTermCreate(
|
|
|
304
393
|
|
|
305
394
|
const repo = new TaxonomyRepository(db);
|
|
306
395
|
|
|
396
|
+
// Coerce empty-string parentId to undefined (treat as "no parent").
|
|
397
|
+
const parentId =
|
|
398
|
+
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
|
|
399
|
+
|
|
307
400
|
// Check for slug conflict
|
|
308
401
|
const existing = await repo.findBySlug(taxonomyName, input.slug);
|
|
309
402
|
if (existing) {
|
|
@@ -316,11 +409,18 @@ export async function handleTermCreate(
|
|
|
316
409
|
};
|
|
317
410
|
}
|
|
318
411
|
|
|
412
|
+
// Validate parentId: must exist AND belong to the same taxonomy.
|
|
413
|
+
// (Cycle check is N/A on create — the term doesn't exist yet.)
|
|
414
|
+
const parentError = await validateParentTerm(repo, taxonomyName, undefined, parentId);
|
|
415
|
+
if (parentError) {
|
|
416
|
+
return { success: false, error: parentError };
|
|
417
|
+
}
|
|
418
|
+
|
|
319
419
|
const term = await repo.create({
|
|
320
420
|
name: taxonomyName,
|
|
321
421
|
slug: input.slug,
|
|
322
422
|
label: input.label,
|
|
323
|
-
parentId:
|
|
423
|
+
parentId: parentId ?? undefined,
|
|
324
424
|
data: input.description ? { description: input.description } : undefined,
|
|
325
425
|
});
|
|
326
426
|
|
|
@@ -426,24 +526,36 @@ export async function handleTermUpdate(
|
|
|
426
526
|
};
|
|
427
527
|
}
|
|
428
528
|
|
|
529
|
+
// Coerce empty-string slug/parentId to undefined (treat as "no change").
|
|
530
|
+
// `null` parentId is a valid request meaning "detach from parent".
|
|
531
|
+
const newSlug = input.slug === "" || input.slug === undefined ? undefined : input.slug;
|
|
532
|
+
const newParentId =
|
|
533
|
+
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
|
|
534
|
+
|
|
429
535
|
// Check if new slug conflicts
|
|
430
|
-
if (
|
|
431
|
-
const existing = await repo.findBySlug(taxonomyName,
|
|
536
|
+
if (newSlug !== undefined && newSlug !== termSlug) {
|
|
537
|
+
const existing = await repo.findBySlug(taxonomyName, newSlug);
|
|
432
538
|
if (existing && existing.id !== term.id) {
|
|
433
539
|
return {
|
|
434
540
|
success: false,
|
|
435
541
|
error: {
|
|
436
542
|
code: "CONFLICT",
|
|
437
|
-
message: `Term with slug '${
|
|
543
|
+
message: `Term with slug '${newSlug}' already exists in taxonomy '${taxonomyName}'`,
|
|
438
544
|
},
|
|
439
545
|
};
|
|
440
546
|
}
|
|
441
547
|
}
|
|
442
548
|
|
|
549
|
+
// Validate parentId: existence, same-taxonomy, no self-parent, no cycle.
|
|
550
|
+
const parentError = await validateParentTerm(repo, taxonomyName, term.id, newParentId);
|
|
551
|
+
if (parentError) {
|
|
552
|
+
return { success: false, error: parentError };
|
|
553
|
+
}
|
|
554
|
+
|
|
443
555
|
const updated = await repo.update(term.id, {
|
|
444
|
-
slug:
|
|
556
|
+
slug: newSlug,
|
|
445
557
|
label: input.label,
|
|
446
|
-
parentId:
|
|
558
|
+
parentId: newParentId,
|
|
447
559
|
data: input.description !== undefined ? { description: input.description } : undefined,
|
|
448
560
|
});
|
|
449
561
|
|