@nixxie-cms/core 1.0.3 → 2.0.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/CHANGELOG.md +36 -0
- package/CHANGES-1.1.md +134 -0
- package/context/dist/nixxie-cms-core-context.cjs.js +4 -3
- package/context/dist/nixxie-cms-core-context.esm.js +3 -2
- package/dist/declarations/src/access.d.ts +2 -2
- package/dist/declarations/src/access.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/components/Navigation.d.ts +2 -2
- package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/context.d.ts +6 -6
- package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/Fields.d.ts +3 -3
- package/dist/declarations/src/admin-ui/utils/Fields.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/filters.d.ts +5 -5
- package/dist/declarations/src/admin-ui/utils/filters.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts +3 -3
- package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
- package/dist/declarations/src/admin-ui/utils/utils.d.ts +2 -2
- package/dist/declarations/src/admin-ui/utils/utils.d.ts.map +1 -1
- package/dist/declarations/src/context.d.ts +1 -1
- package/dist/declarations/src/context.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/bigInt/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/bigInt/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/bytes/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/bytes/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/calendarDay/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/calendarDay/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/checkbox/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/checkbox/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/decimal/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/decimal/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/file/index.d.ts +4 -4
- package/dist/declarations/src/fields/types/file/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/float/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/float/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/image/index.d.ts +4 -4
- package/dist/declarations/src/fields/types/image/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/integer/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/integer/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/json/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/json/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/multiselect/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/multiselect/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/password/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/password/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/index.d.ts +8 -8
- package/dist/declarations/src/fields/types/relationship/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/ComboboxMany.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/ComboboxSingle.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/relationship/views/types.d.ts +3 -3
- package/dist/declarations/src/fields/types/relationship/views/types.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/select/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/select/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/text/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/text/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/timestamp/index.d.ts +3 -3
- package/dist/declarations/src/fields/types/timestamp/index.d.ts.map +1 -1
- package/dist/declarations/src/fields/types/virtual/index.d.ts +7 -7
- package/dist/declarations/src/fields/types/virtual/index.d.ts.map +1 -1
- package/dist/declarations/src/helpers.d.ts +249 -13
- package/dist/declarations/src/helpers.d.ts.map +1 -1
- package/dist/declarations/src/index.d.ts +9 -4
- package/dist/declarations/src/index.d.ts.map +1 -1
- package/dist/declarations/src/internal-unstable/admin-ui/pages/ListPage/index.d.ts.map +1 -1
- package/dist/declarations/src/lib/admin-meta.d.ts +11 -11
- package/dist/declarations/src/lib/admin-meta.d.ts.map +1 -1
- package/dist/declarations/src/lib/core/access-control.d.ts +18 -18
- package/dist/declarations/src/lib/core/access-control.d.ts.map +1 -1
- package/dist/declarations/src/lib/core/cascade.d.ts +47 -0
- package/dist/declarations/src/lib/core/cascade.d.ts.map +1 -0
- package/dist/declarations/src/lib/core/initialise-lists.d.ts +27 -24
- package/dist/declarations/src/lib/core/initialise-lists.d.ts.map +1 -1
- package/dist/declarations/src/lib/env.d.ts +9 -0
- package/dist/declarations/src/lib/env.d.ts.map +1 -0
- package/dist/declarations/src/lib/system.d.ts +1 -1
- package/dist/declarations/src/lib/system.d.ts.map +1 -1
- package/dist/declarations/src/list-features.d.ts +162 -0
- package/dist/declarations/src/list-features.d.ts.map +1 -0
- package/dist/declarations/src/schema.d.ts +24 -23
- package/dist/declarations/src/schema.d.ts.map +1 -1
- package/dist/declarations/src/session.d.ts +75 -0
- package/dist/declarations/src/session.d.ts.map +1 -1
- package/dist/declarations/src/types/admin-meta.d.ts +11 -11
- package/dist/declarations/src/types/admin-meta.d.ts.map +1 -1
- package/dist/declarations/src/types/config/access-control.d.ts +42 -42
- package/dist/declarations/src/types/config/access-control.d.ts.map +1 -1
- package/dist/declarations/src/types/config/fields.d.ts +19 -19
- package/dist/declarations/src/types/config/fields.d.ts.map +1 -1
- package/dist/declarations/src/types/config/hooks.d.ts +131 -131
- package/dist/declarations/src/types/config/hooks.d.ts.map +1 -1
- package/dist/declarations/src/types/config/index.d.ts +190 -8
- package/dist/declarations/src/types/config/index.d.ts.map +1 -1
- package/dist/declarations/src/types/config/lists.d.ts +146 -108
- package/dist/declarations/src/types/config/lists.d.ts.map +1 -1
- package/dist/declarations/src/types/context.d.ts +507 -47
- package/dist/declarations/src/types/context.d.ts.map +1 -1
- package/dist/declarations/src/types/next-fields.d.ts +28 -28
- package/dist/declarations/src/types/next-fields.d.ts.map +1 -1
- package/dist/declarations/src/types/type-info.d.ts +3 -3
- package/dist/declarations/src/types/type-info.d.ts.map +1 -1
- package/dist/{express-455ae20c.cjs.js → express-84d534c2.cjs.js} +6 -6
- package/dist/{express-7559ca2d.esm.js → express-d0a4ce99.esm.js} +6 -6
- package/dist/{index-15c8f81e.esm.js → index-5d8b0b4e.esm.js} +363 -183
- package/dist/index-6055753b.cjs.js +393 -0
- package/dist/{index-42045902.cjs.js → index-ac29f382.cjs.js} +363 -185
- package/dist/index-f1703b7b.esm.js +386 -0
- package/dist/nixxie-cms-core.cjs.js +1388 -30
- package/dist/nixxie-cms-core.esm.js +1362 -24
- package/dist/{non-null-graphql-add6bb3d.cjs.js → non-null-graphql-4a44c122.cjs.js} +1 -1
- package/dist/{non-null-graphql-a84ed64d.esm.js → non-null-graphql-8c5feaae.esm.js} +1 -1
- package/dist/{resolve-hooks-165a9ce2.cjs.js → resolve-hooks-10a5f84c.cjs.js} +240 -6
- package/dist/{resolve-hooks-6813a045.esm.js → resolve-hooks-9e676794.esm.js} +238 -7
- package/dist/{system-a321642d.cjs.js → system-6b37a5f8.cjs.js} +33 -7
- package/dist/{system-03e49e4f.esm.js → system-e591d821.esm.js} +33 -7
- package/fields/dist/nixxie-cms-core-fields.cjs.js +29 -576
- package/fields/dist/nixxie-cms-core-fields.esm.js +18 -565
- package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.cjs.js +4 -2
- package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.esm.js +4 -2
- package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +1 -6
- package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +1 -6
- package/fields/types/password/dist/nixxie-cms-core-fields-types-password.cjs.js +4 -2
- package/fields/types/password/dist/nixxie-cms-core-fields-types-password.esm.js +4 -2
- package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.js +4 -3
- package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.esm.js +4 -3
- package/package.json +4 -4
- package/scripts/cli/dist/nixxie-cms-core-scripts-cli.cjs.js +4 -3
- package/scripts/cli/dist/nixxie-cms-core-scripts-cli.esm.js +4 -3
- package/scripts/dist/nixxie-cms-core-scripts.cjs.js +4 -3
- package/scripts/dist/nixxie-cms-core-scripts.esm.js +4 -3
- package/session/dist/nixxie-cms-core-session.cjs.js +286 -0
- package/session/dist/nixxie-cms-core-session.esm.js +279 -1
- package/src/access.ts +25 -25
- package/src/admin-ui/admin-meta-graphql.ts +5 -5
- package/src/admin-ui/components/CreateButtonLink.tsx +46 -46
- package/src/admin-ui/components/Navigation.tsx +3 -3
- package/src/admin-ui/context.tsx +6 -6
- package/src/admin-ui/utils/Fields.tsx +241 -241
- package/src/admin-ui/utils/actionData.ts +36 -36
- package/src/admin-ui/utils/filters.ts +148 -148
- package/src/admin-ui/utils/useCreateItem.ts +171 -171
- package/src/admin-ui/utils/utils.tsx +127 -127
- package/src/context.ts +1 -1
- package/src/fields/non-null-graphql.ts +115 -115
- package/src/fields/types/bigInt/index.ts +6 -6
- package/src/fields/types/bytes/index.ts +6 -6
- package/src/fields/types/calendarDay/index.ts +18 -19
- package/src/fields/types/checkbox/index.ts +6 -6
- package/src/fields/types/decimal/index.ts +6 -6
- package/src/fields/types/file/index.ts +8 -8
- package/src/fields/types/float/index.ts +6 -6
- package/src/fields/types/image/index.ts +8 -8
- package/src/fields/types/integer/index.ts +6 -6
- package/src/fields/types/json/index.ts +5 -5
- package/src/fields/types/multiselect/index.ts +7 -7
- package/src/fields/types/multiselect/views/index.tsx +149 -151
- package/src/fields/types/password/index.ts +6 -6
- package/src/fields/types/relationship/index.ts +13 -13
- package/src/fields/types/relationship/views/ComboboxMany.tsx +110 -110
- package/src/fields/types/relationship/views/ComboboxSingle.tsx +115 -115
- package/src/fields/types/relationship/views/ContextualActions.tsx +139 -139
- package/src/fields/types/relationship/views/index.tsx +492 -492
- package/src/fields/types/relationship/views/types.ts +46 -46
- package/src/fields/types/relationship/views/useApolloQuery.ts +185 -185
- package/src/fields/types/relationship/views/useFilter.tsx +109 -109
- package/src/fields/types/select/index.ts +6 -6
- package/src/fields/types/text/index.ts +6 -6
- package/src/fields/types/timestamp/index.ts +23 -21
- package/src/fields/types/virtual/index.ts +11 -11
- package/src/helpers.ts +773 -42
- package/src/index.ts +66 -24
- package/src/internal-unstable/admin-ui/pages/ItemPage/common.tsx +4 -4
- package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +5 -5
- package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +8 -8
- package/src/lib/admin-meta.ts +369 -369
- package/src/lib/context/createContext.ts +6 -0
- package/src/lib/core/access-control.ts +434 -434
- package/src/lib/core/cascade.ts +236 -0
- package/src/lib/core/initialise-lists.ts +49 -33
- package/src/lib/core/mutations/index.ts +7 -0
- package/src/lib/core/mutations/nested-mutation-many-input-resolvers.ts +145 -145
- package/src/lib/core/mutations/nested-mutation-one-input-resolvers.ts +71 -71
- package/src/lib/core/queries/output-field.ts +178 -178
- package/src/lib/env.ts +50 -0
- package/src/lib/id-field.ts +2 -2
- package/src/lib/system.ts +221 -207
- package/src/lib/typescript-schema-printer.ts +227 -227
- package/src/list-features.ts +476 -0
- package/src/schema.ts +92 -22
- package/src/session.ts +225 -0
- package/src/types/admin-meta.ts +218 -218
- package/src/types/config/access-control.ts +186 -186
- package/src/types/config/fields.ts +96 -96
- package/src/types/config/hooks.ts +529 -529
- package/src/types/config/index.ts +206 -7
- package/src/types/config/lists.ts +606 -565
- package/src/types/context.ts +592 -55
- package/src/types/next-fields.ts +31 -31
- package/src/types/type-info.ts +38 -38
- package/src/types/type-tests.ts +21 -21
package/src/helpers.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { collection, buildConfig, createAction } from './schema'
|
|
3
|
+
import { applyAccessFeatures, compileFeatureHooks } from './list-features'
|
|
4
|
+
import type { ListFeaturesConfig } from './list-features'
|
|
2
5
|
import { timestamp } from './fields/types/timestamp'
|
|
3
6
|
import { relationship } from './fields/types/relationship'
|
|
7
|
+
import { text } from './fields/types/text'
|
|
8
|
+
import { select } from './fields/types/select'
|
|
9
|
+
import { integer } from './fields/types/integer'
|
|
10
|
+
import { json } from './fields/types/json'
|
|
4
11
|
import { merge } from './fields/resolve-hooks'
|
|
5
12
|
import type {
|
|
6
|
-
|
|
13
|
+
BaseCollectionTypeInfo,
|
|
7
14
|
BaseNixxieTypeInfo,
|
|
8
|
-
|
|
15
|
+
CollectionConfig,
|
|
9
16
|
BaseFields,
|
|
10
17
|
NixxieConfigPre,
|
|
11
18
|
NixxieConfig,
|
|
12
|
-
|
|
19
|
+
CollectionHooks,
|
|
20
|
+
CollectionAccessControl,
|
|
13
21
|
ActionArgsConfig,
|
|
14
22
|
Action,
|
|
15
23
|
DeclaredAction,
|
|
@@ -63,22 +71,98 @@ export const validators = {
|
|
|
63
71
|
required: { isRequired: true } as const,
|
|
64
72
|
} as const
|
|
65
73
|
|
|
74
|
+
// ================================================================
|
|
75
|
+
// Access presets — readable shorthands for common access patterns
|
|
76
|
+
// ================================================================
|
|
77
|
+
//
|
|
78
|
+
// Spelling out an access-control object on every list gets repetitive. In
|
|
79
|
+
// practice 90% of lists fall into a handful of patterns, so we name them.
|
|
80
|
+
// Anything more bespoke can still drop down to a raw access object.
|
|
81
|
+
|
|
82
|
+
type AnyAccessArgs = { session?: any; context?: any }
|
|
83
|
+
|
|
84
|
+
const isSignedIn = ({ session }: AnyAccessArgs) => !!session
|
|
85
|
+
|
|
86
|
+
export const accessPresets: {
|
|
87
|
+
/** Everyone can do everything (equivalent to `allowAll`). */
|
|
88
|
+
public: CollectionAccessControl<any>
|
|
89
|
+
/** Any signed-in user can read & write; anonymous requests are denied. */
|
|
90
|
+
authenticated: CollectionAccessControl<any>
|
|
91
|
+
/** Anyone can read; only signed-in users can create/update/delete. */
|
|
92
|
+
publicReadAuthenticatedWrite: CollectionAccessControl<any>
|
|
93
|
+
/** Anyone can read; nothing can be written through the API. */
|
|
94
|
+
readOnly: CollectionAccessControl<any>
|
|
95
|
+
/**
|
|
96
|
+
* Rows are scoped to their owner. Signed-in users may create items, and may
|
|
97
|
+
* only read/update/delete rows whose `<field>` relationship points at the
|
|
98
|
+
* current session's `itemId`.
|
|
99
|
+
*
|
|
100
|
+
* @param field the relationship field linking a row to its owning user (default `'user'`)
|
|
101
|
+
*/
|
|
102
|
+
owner: (field?: string) => CollectionAccessControl<any>
|
|
103
|
+
} = {
|
|
104
|
+
public: () => true,
|
|
105
|
+
authenticated: {
|
|
106
|
+
operation: {
|
|
107
|
+
query: isSignedIn,
|
|
108
|
+
create: isSignedIn,
|
|
109
|
+
update: isSignedIn,
|
|
110
|
+
delete: isSignedIn,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
publicReadAuthenticatedWrite: {
|
|
114
|
+
operation: {
|
|
115
|
+
query: () => true,
|
|
116
|
+
create: isSignedIn,
|
|
117
|
+
update: isSignedIn,
|
|
118
|
+
delete: isSignedIn,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
readOnly: {
|
|
122
|
+
operation: {
|
|
123
|
+
query: () => true,
|
|
124
|
+
create: () => false,
|
|
125
|
+
update: () => false,
|
|
126
|
+
delete: () => false,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
owner(field = 'user') {
|
|
130
|
+
const scopeToOwner = ({ session }: AnyAccessArgs) => {
|
|
131
|
+
if (!session?.itemId) return false
|
|
132
|
+
return { [field]: { id: { equals: session.itemId } } }
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
operation: {
|
|
136
|
+
query: isSignedIn,
|
|
137
|
+
create: isSignedIn,
|
|
138
|
+
update: isSignedIn,
|
|
139
|
+
delete: isSignedIn,
|
|
140
|
+
},
|
|
141
|
+
filter: {
|
|
142
|
+
query: scopeToOwner,
|
|
143
|
+
update: scopeToOwner,
|
|
144
|
+
delete: scopeToOwner,
|
|
145
|
+
},
|
|
146
|
+
} as CollectionAccessControl<any>
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
66
150
|
// ================================================================
|
|
67
151
|
// Mixin system
|
|
68
152
|
// ================================================================
|
|
69
153
|
|
|
70
|
-
export type
|
|
154
|
+
export type CollectionMixin<CollectionTypeInfo extends BaseCollectionTypeInfo = BaseCollectionTypeInfo> = {
|
|
71
155
|
/** Fields added by this mixin — user fields take precedence on key conflicts */
|
|
72
156
|
fields?: Record<string, any>
|
|
73
157
|
/** Hooks merged with existing list hooks */
|
|
74
|
-
hooks?: Partial<
|
|
158
|
+
hooks?: Partial<CollectionHooks<CollectionTypeInfo>>
|
|
75
159
|
}
|
|
76
160
|
|
|
77
161
|
/**
|
|
78
162
|
* Adds `createdAt` and `updatedAt` timestamp fields.
|
|
79
163
|
* Both are hidden on create forms and read-only in item views.
|
|
80
164
|
*/
|
|
81
|
-
export function withTimestamps():
|
|
165
|
+
export function withTimestamps(): CollectionMixin {
|
|
82
166
|
return {
|
|
83
167
|
fields: {
|
|
84
168
|
createdAt: timestamp({
|
|
@@ -107,7 +191,7 @@ export function withTimestamps(): ListMixin {
|
|
|
107
191
|
* Items are not physically deleted — set `deletedAt` to the current time instead.
|
|
108
192
|
* Note: you must manually filter `deletedAt: null` in your queries/access control.
|
|
109
193
|
*/
|
|
110
|
-
export function withSoftDelete():
|
|
194
|
+
export function withSoftDelete(): CollectionMixin {
|
|
111
195
|
return {
|
|
112
196
|
fields: {
|
|
113
197
|
deletedAt: timestamp({
|
|
@@ -130,7 +214,7 @@ export function withSoftDelete(): ListMixin {
|
|
|
130
214
|
*
|
|
131
215
|
* @param userListKey - The list key for your user model (default: 'User')
|
|
132
216
|
*/
|
|
133
|
-
export function withAudit(userListKey = 'User'):
|
|
217
|
+
export function withAudit(userListKey = 'User'): CollectionMixin {
|
|
134
218
|
return {
|
|
135
219
|
fields: {
|
|
136
220
|
createdBy: relationship({
|
|
@@ -171,6 +255,611 @@ export function withAudit(userListKey = 'User'): ListMixin {
|
|
|
171
255
|
}
|
|
172
256
|
}
|
|
173
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Turn an arbitrary string into a URL-safe slug. Pure, dependency-free.
|
|
260
|
+
*/
|
|
261
|
+
function slugify(input: string): string {
|
|
262
|
+
return input
|
|
263
|
+
.toString()
|
|
264
|
+
.normalize('NFKD')
|
|
265
|
+
.replace(/[̀-ͯ]/g, '') // strip accents
|
|
266
|
+
.toLowerCase()
|
|
267
|
+
.trim()
|
|
268
|
+
.replace(/[^a-z0-9]+/g, '-') // non-alphanumerics → dashes
|
|
269
|
+
.replace(/^-+|-+$/g, '') // trim leading/trailing dashes
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Adds a URL-friendly `slug` field that is auto-generated from another field
|
|
274
|
+
* (e.g. `title`) when left blank, and normalised when provided.
|
|
275
|
+
*
|
|
276
|
+
* Solves a perennial papercut: there's no built-in slug field, so everyone
|
|
277
|
+
* re-implements the same `resolveInput` hook by hand.
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* defineCollection({ mixins: [withSlug({ from: 'title' })], fields: { title: text() } })
|
|
281
|
+
*/
|
|
282
|
+
export function withSlug(
|
|
283
|
+
opts: { from: string; field?: string; isIndexed?: 'unique' | true } = { from: 'title' }
|
|
284
|
+
): CollectionMixin {
|
|
285
|
+
const fieldKey = opts.field ?? 'slug'
|
|
286
|
+
const from = opts.from
|
|
287
|
+
return {
|
|
288
|
+
fields: {
|
|
289
|
+
[fieldKey]: text({
|
|
290
|
+
isIndexed: opts.isIndexed ?? 'unique',
|
|
291
|
+
ui: {
|
|
292
|
+
description: `URL slug — auto-generated from "${from}" when left blank.`,
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
},
|
|
296
|
+
hooks: {
|
|
297
|
+
resolveInput: ({ resolvedData, inputData }) => {
|
|
298
|
+
const provided = (resolvedData as any)[fieldKey]
|
|
299
|
+
if (typeof provided === 'string' && provided.length > 0) {
|
|
300
|
+
return { ...resolvedData, [fieldKey]: slugify(provided) }
|
|
301
|
+
}
|
|
302
|
+
const source = (inputData as any)?.[from] ?? (resolvedData as any)?.[from]
|
|
303
|
+
if (typeof source === 'string' && source.length > 0) {
|
|
304
|
+
return { ...resolvedData, [fieldKey]: slugify(source) }
|
|
305
|
+
}
|
|
306
|
+
return resolvedData
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Adds a draft → published → archived workflow: a `status` select plus a
|
|
314
|
+
* `publishedAt` timestamp that is stamped automatically the first time an item
|
|
315
|
+
* transitions to `published`.
|
|
316
|
+
*
|
|
317
|
+
* Publishing is otherwise left entirely to the developer; this bakes in the
|
|
318
|
+
* common content lifecycle so a blog/news/docs list works out of the box.
|
|
319
|
+
*/
|
|
320
|
+
export function withPublishing(
|
|
321
|
+
opts: { defaultStatus?: 'draft' | 'published' | 'archived' } = {}
|
|
322
|
+
): CollectionMixin {
|
|
323
|
+
const defaultStatus = opts.defaultStatus ?? 'draft'
|
|
324
|
+
return {
|
|
325
|
+
fields: {
|
|
326
|
+
status: select({
|
|
327
|
+
type: 'enum',
|
|
328
|
+
options: [
|
|
329
|
+
{ label: 'Draft', value: 'draft' },
|
|
330
|
+
{ label: 'Published', value: 'published' },
|
|
331
|
+
{ label: 'Archived', value: 'archived' },
|
|
332
|
+
],
|
|
333
|
+
defaultValue: defaultStatus,
|
|
334
|
+
ui: { displayMode: 'segmented-control' },
|
|
335
|
+
}),
|
|
336
|
+
publishedAt: timestamp({
|
|
337
|
+
ui: {
|
|
338
|
+
createView: { fieldMode: 'hidden' },
|
|
339
|
+
itemView: { fieldMode: 'read' },
|
|
340
|
+
listView: { fieldMode: 'read' },
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
},
|
|
344
|
+
hooks: {
|
|
345
|
+
resolveInput: ({ resolvedData, item }) => {
|
|
346
|
+
const nextStatus = (resolvedData as any).status ?? (item as any)?.status
|
|
347
|
+
const alreadyPublished = (item as any)?.publishedAt
|
|
348
|
+
const incomingPublishedAt = (resolvedData as any).publishedAt
|
|
349
|
+
if (nextStatus === 'published' && !alreadyPublished && incomingPublishedAt == null) {
|
|
350
|
+
return { ...resolvedData, publishedAt: new Date().toISOString() }
|
|
351
|
+
}
|
|
352
|
+
return resolvedData
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Adds standard SEO metadata fields, grouped together in the Admin UI.
|
|
360
|
+
* Drop-in for any content list that needs search/social previews.
|
|
361
|
+
*/
|
|
362
|
+
export function withSEO(): CollectionMixin {
|
|
363
|
+
return {
|
|
364
|
+
fields: {
|
|
365
|
+
metaTitle: text({
|
|
366
|
+
ui: { description: 'Title used by search engines and social previews.' },
|
|
367
|
+
}),
|
|
368
|
+
metaDescription: text({
|
|
369
|
+
ui: { displayMode: 'textarea', description: 'Short summary (~155 characters).' },
|
|
370
|
+
}),
|
|
371
|
+
ogImage: text({
|
|
372
|
+
ui: { description: 'Absolute URL to the social sharing image.' },
|
|
373
|
+
}),
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Adds an integer `order` field for manual sorting/drag-ordering of items.
|
|
380
|
+
*/
|
|
381
|
+
export function withSortable(opts: { field?: string } = {}): CollectionMixin {
|
|
382
|
+
const fieldKey = opts.field ?? 'order'
|
|
383
|
+
return {
|
|
384
|
+
fields: {
|
|
385
|
+
[fieldKey]: integer({
|
|
386
|
+
defaultValue: 0,
|
|
387
|
+
ui: { description: 'Manual sort position (lower comes first).' },
|
|
388
|
+
}),
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Adds self-referential `parent` / `children` relationships for tree-shaped content
|
|
395
|
+
* (categories, menus, pages), with cycle prevention: an item can never be moved under
|
|
396
|
+
* one of its own descendants.
|
|
397
|
+
*
|
|
398
|
+
* @param listKey - The key of the collection this mixin is applied to (needed for the
|
|
399
|
+
* self-referencing relationship and ancestor checks).
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* defineCollection({ mixins: [withTreeStructure('Category')], fields: { name: text() } })
|
|
403
|
+
*/
|
|
404
|
+
export function withTreeStructure(
|
|
405
|
+
listKey: string,
|
|
406
|
+
opts: { parentField?: string; childrenField?: string } = {}
|
|
407
|
+
): CollectionMixin {
|
|
408
|
+
const parentField = opts.parentField ?? 'parent'
|
|
409
|
+
const childrenField = opts.childrenField ?? 'children'
|
|
410
|
+
return {
|
|
411
|
+
fields: {
|
|
412
|
+
[parentField]: relationship({
|
|
413
|
+
ref: `${listKey}.${childrenField}`,
|
|
414
|
+
ui: { description: 'Parent item in the tree (leave empty for a root item).' },
|
|
415
|
+
}),
|
|
416
|
+
[childrenField]: relationship({
|
|
417
|
+
ref: `${listKey}.${parentField}`,
|
|
418
|
+
many: true,
|
|
419
|
+
ui: { displayMode: 'count', itemView: { fieldMode: 'read' } },
|
|
420
|
+
}),
|
|
421
|
+
},
|
|
422
|
+
hooks: {
|
|
423
|
+
validate: async args => {
|
|
424
|
+
const { operation, resolvedData, item, context, addValidationError } = args as any
|
|
425
|
+
if (operation !== 'update') return
|
|
426
|
+
const parentOp = resolvedData?.[parentField]
|
|
427
|
+
const newParentId = parentOp?.connect?.id
|
|
428
|
+
if (newParentId == null) return
|
|
429
|
+
|
|
430
|
+
if (String(newParentId) === String(item.id)) {
|
|
431
|
+
addValidationError(`"${parentField}" cannot point at the item itself`)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
// Walk up from the new parent — finding the item means we'd create a cycle.
|
|
435
|
+
const sudo = context.sudo()
|
|
436
|
+
let cursor: any = newParentId
|
|
437
|
+
for (let depth = 0; depth < 100 && cursor != null; depth++) {
|
|
438
|
+
const ancestor = await sudo.query[listKey].findOne({
|
|
439
|
+
where: { id: String(cursor) },
|
|
440
|
+
query: `id ${parentField} { id }`,
|
|
441
|
+
})
|
|
442
|
+
if (!ancestor) return
|
|
443
|
+
cursor = ancestor[parentField]?.id ?? null
|
|
444
|
+
if (cursor != null && String(cursor) === String(item.id)) {
|
|
445
|
+
addValidationError(
|
|
446
|
+
`"${parentField}" would create a cycle — the new parent is a descendant of this item`
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Multi-tenancy: adds a required `tenant` relationship that is auto-assigned from the
|
|
458
|
+
* session on create. Combine with `tenantAccessFilter()` in the collection's
|
|
459
|
+
* `access.filter` rules to scope every query/update/delete to the session's tenant.
|
|
460
|
+
*
|
|
461
|
+
* @example
|
|
462
|
+
* defineCollection({
|
|
463
|
+
* mixins: [withTenant({ ref: 'Tenant' })],
|
|
464
|
+
* access: {
|
|
465
|
+
* filter: {
|
|
466
|
+
* query: tenantAccessFilter(),
|
|
467
|
+
* update: tenantAccessFilter(),
|
|
468
|
+
* delete: tenantAccessFilter(),
|
|
469
|
+
* },
|
|
470
|
+
* },
|
|
471
|
+
* fields: { ... },
|
|
472
|
+
* })
|
|
473
|
+
*/
|
|
474
|
+
export function withTenant(
|
|
475
|
+
opts: {
|
|
476
|
+
/** Collection that holds tenants. @default 'Tenant' */
|
|
477
|
+
ref?: string
|
|
478
|
+
/** Field key for the tenant relationship. @default 'tenant' */
|
|
479
|
+
field?: string
|
|
480
|
+
/** Extract the tenant id from the session. @default session.data?.tenant?.id ?? session.tenantId */
|
|
481
|
+
getTenantId?: (session: any) => string | undefined
|
|
482
|
+
/** Allow items without a tenant (e.g. global records). @default false */
|
|
483
|
+
optional?: boolean
|
|
484
|
+
} = {}
|
|
485
|
+
): CollectionMixin {
|
|
486
|
+
const ref = opts.ref ?? 'Tenant'
|
|
487
|
+
const field = opts.field ?? 'tenant'
|
|
488
|
+
const getTenantId = opts.getTenantId ?? defaultGetTenantId
|
|
489
|
+
return {
|
|
490
|
+
fields: {
|
|
491
|
+
[field]: relationship({
|
|
492
|
+
ref,
|
|
493
|
+
ui: {
|
|
494
|
+
createView: { fieldMode: 'hidden' },
|
|
495
|
+
itemView: { fieldMode: 'read' },
|
|
496
|
+
},
|
|
497
|
+
}),
|
|
498
|
+
},
|
|
499
|
+
hooks: {
|
|
500
|
+
resolveInput: ({ operation, resolvedData, context }) => {
|
|
501
|
+
if (operation !== 'create') return resolvedData
|
|
502
|
+
if ((resolvedData as any)[field]) return resolvedData
|
|
503
|
+
const tenantId = getTenantId((context as any).session)
|
|
504
|
+
if (!tenantId) return resolvedData
|
|
505
|
+
return { ...resolvedData, [field]: { connect: { id: tenantId } } }
|
|
506
|
+
},
|
|
507
|
+
validate: async args => {
|
|
508
|
+
const { operation, resolvedData, addValidationError } = args as any
|
|
509
|
+
if (opts.optional || operation !== 'create') return
|
|
510
|
+
if (!resolvedData?.[field]) {
|
|
511
|
+
addValidationError(`"${field}" is required — no tenant found on the session`)
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function defaultGetTenantId(session: any): string | undefined {
|
|
519
|
+
return session?.data?.tenant?.id ?? session?.tenantId
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Access-control filter companion to `withTenant()`: limits matched items to the
|
|
524
|
+
* session's tenant. Returns `false` (no access) when the session has no tenant.
|
|
525
|
+
*/
|
|
526
|
+
export function tenantAccessFilter(
|
|
527
|
+
opts: {
|
|
528
|
+
field?: string
|
|
529
|
+
getTenantId?: (session: any) => string | undefined
|
|
530
|
+
} = {}
|
|
531
|
+
) {
|
|
532
|
+
const field = opts.field ?? 'tenant'
|
|
533
|
+
const getTenantId = opts.getTenantId ?? defaultGetTenantId
|
|
534
|
+
return ({ session }: { session?: any }) => {
|
|
535
|
+
const tenantId = getTenantId(session)
|
|
536
|
+
if (!tenantId) return false
|
|
537
|
+
return { [field]: { id: { equals: tenantId } } }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Adds `publishAt` / `unpublishAt` timestamps for scheduled publishing. Composes with
|
|
543
|
+
* `withPublishing()` (which provides the `status` field). The schedule is enacted by
|
|
544
|
+
* `applyScheduledPublishing()` — run it from a cron job:
|
|
545
|
+
*
|
|
546
|
+
* @example
|
|
547
|
+
* jobs: createJobs({ jobs: [{
|
|
548
|
+
* name: 'scheduled-publishing',
|
|
549
|
+
* schedule: '* * * * *',
|
|
550
|
+
* handler: async () => { await applyScheduledPublishing(getContext(), 'Post') },
|
|
551
|
+
* }]})
|
|
552
|
+
*/
|
|
553
|
+
export function withScheduledPublishing(): CollectionMixin {
|
|
554
|
+
return {
|
|
555
|
+
fields: {
|
|
556
|
+
publishAt: timestamp({
|
|
557
|
+
ui: { description: 'Automatically publish at this time (requires the scheduled-publishing job).' },
|
|
558
|
+
}),
|
|
559
|
+
unpublishAt: timestamp({
|
|
560
|
+
ui: { description: 'Automatically archive at this time (requires the scheduled-publishing job).' },
|
|
561
|
+
}),
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Enact `withScheduledPublishing()` schedules for one collection: publishes drafts whose
|
|
568
|
+
* `publishAt` has passed and archives published items whose `unpublishAt` has passed.
|
|
569
|
+
* Designed to be called from a cron job (see `withScheduledPublishing`).
|
|
570
|
+
*/
|
|
571
|
+
export async function applyScheduledPublishing(
|
|
572
|
+
context: any,
|
|
573
|
+
listKey: string,
|
|
574
|
+
opts: {
|
|
575
|
+
statusField?: string
|
|
576
|
+
draftValue?: string
|
|
577
|
+
publishedValue?: string
|
|
578
|
+
archivedValue?: string
|
|
579
|
+
} = {}
|
|
580
|
+
): Promise<{ published: number; archived: number }> {
|
|
581
|
+
const statusField = opts.statusField ?? 'status'
|
|
582
|
+
const draftValue = opts.draftValue ?? 'draft'
|
|
583
|
+
const publishedValue = opts.publishedValue ?? 'published'
|
|
584
|
+
const archivedValue = opts.archivedValue ?? 'archived'
|
|
585
|
+
const sudo = context.sudo()
|
|
586
|
+
const now = new Date()
|
|
587
|
+
|
|
588
|
+
const toPublish = await sudo.db[listKey].findMany({
|
|
589
|
+
where: { [statusField]: { equals: draftValue }, publishAt: { lte: now } },
|
|
590
|
+
})
|
|
591
|
+
for (const item of toPublish) {
|
|
592
|
+
await sudo.db[listKey].updateOne({
|
|
593
|
+
where: { id: String(item.id) },
|
|
594
|
+
data: { [statusField]: publishedValue },
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const toArchive = await sudo.db[listKey].findMany({
|
|
599
|
+
where: { [statusField]: { equals: publishedValue }, unpublishAt: { lte: now } },
|
|
600
|
+
})
|
|
601
|
+
for (const item of toArchive) {
|
|
602
|
+
await sudo.db[listKey].updateOne({
|
|
603
|
+
where: { id: String(item.id) },
|
|
604
|
+
data: { [statusField]: archivedValue },
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return { published: toPublish.length, archived: toArchive.length }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* i18n: adds a `locale` select plus a shared `translationKey` that groups an item with
|
|
613
|
+
* its translations (auto-generated on create when blank). Query an item's variants with
|
|
614
|
+
* `{ translationKey: { equals }, locale: { not: ... } }` — and once compound uniqueness
|
|
615
|
+
* is configured, pair (`translationKey`, `locale`) should be unique.
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* defineCollection({ mixins: [withLocale({ locales: ['en', 'ur', 'ar'] })], fields: { ... } })
|
|
619
|
+
*/
|
|
620
|
+
export function withLocale(opts: {
|
|
621
|
+
locales: readonly string[]
|
|
622
|
+
defaultLocale?: string
|
|
623
|
+
/** Field key for the grouping id. @default 'translationKey' */
|
|
624
|
+
groupField?: string
|
|
625
|
+
}): CollectionMixin {
|
|
626
|
+
if (!opts.locales?.length) throw new Error('withLocale requires at least one locale')
|
|
627
|
+
const groupField = opts.groupField ?? 'translationKey'
|
|
628
|
+
const defaultLocale = opts.defaultLocale ?? opts.locales[0]
|
|
629
|
+
return {
|
|
630
|
+
fields: {
|
|
631
|
+
locale: select({
|
|
632
|
+
type: 'enum',
|
|
633
|
+
options: opts.locales.map(locale => ({ label: locale, value: locale })),
|
|
634
|
+
defaultValue: defaultLocale,
|
|
635
|
+
validation: { isRequired: true },
|
|
636
|
+
}),
|
|
637
|
+
[groupField]: text({
|
|
638
|
+
isIndexed: true,
|
|
639
|
+
ui: {
|
|
640
|
+
description: 'Shared id linking this item with its translations.',
|
|
641
|
+
createView: { fieldMode: 'hidden' },
|
|
642
|
+
itemView: { fieldMode: 'read' },
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
},
|
|
646
|
+
hooks: {
|
|
647
|
+
resolveInput: ({ operation, resolvedData }) => {
|
|
648
|
+
if (operation !== 'create') return resolvedData
|
|
649
|
+
if ((resolvedData as any)[groupField]) return resolvedData
|
|
650
|
+
return { ...resolvedData, [groupField]: randomBytes(8).toString('hex') }
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Editorial approval: adds an approval status + a `pendingChanges` buffer where proposed
|
|
658
|
+
* edits wait for review instead of going live. Drive it with `submitForApproval()`,
|
|
659
|
+
* `approveChanges()` and `rejectChanges()` from custom mutations or the Admin UI.
|
|
660
|
+
*/
|
|
661
|
+
export function withApproval(): CollectionMixin {
|
|
662
|
+
return {
|
|
663
|
+
fields: {
|
|
664
|
+
approvalStatus: select({
|
|
665
|
+
type: 'enum',
|
|
666
|
+
options: [
|
|
667
|
+
{ label: 'None', value: 'none' },
|
|
668
|
+
{ label: 'Pending', value: 'pending' },
|
|
669
|
+
{ label: 'Approved', value: 'approved' },
|
|
670
|
+
{ label: 'Rejected', value: 'rejected' },
|
|
671
|
+
],
|
|
672
|
+
defaultValue: 'none',
|
|
673
|
+
ui: { createView: { fieldMode: 'hidden' }, itemView: { fieldMode: 'read' } },
|
|
674
|
+
}),
|
|
675
|
+
pendingChanges: json({
|
|
676
|
+
ui: {
|
|
677
|
+
createView: { fieldMode: 'hidden' },
|
|
678
|
+
itemView: { fieldMode: 'hidden' },
|
|
679
|
+
listView: { fieldMode: 'hidden' },
|
|
680
|
+
},
|
|
681
|
+
}),
|
|
682
|
+
approvalSubmittedAt: timestamp({
|
|
683
|
+
ui: { createView: { fieldMode: 'hidden' }, itemView: { fieldMode: 'read' } },
|
|
684
|
+
}),
|
|
685
|
+
approvalReviewedAt: timestamp({
|
|
686
|
+
ui: { createView: { fieldMode: 'hidden' }, itemView: { fieldMode: 'read' } },
|
|
687
|
+
}),
|
|
688
|
+
},
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Park proposed changes on an item for review (see `withApproval`). */
|
|
693
|
+
export async function submitForApproval(
|
|
694
|
+
context: any,
|
|
695
|
+
args: { listKey: string; itemId: string; changes: Record<string, unknown> }
|
|
696
|
+
): Promise<void> {
|
|
697
|
+
await context.sudo().db[args.listKey].updateOne({
|
|
698
|
+
where: { id: args.itemId },
|
|
699
|
+
data: {
|
|
700
|
+
pendingChanges: args.changes,
|
|
701
|
+
approvalStatus: 'pending',
|
|
702
|
+
approvalSubmittedAt: new Date().toISOString(),
|
|
703
|
+
},
|
|
704
|
+
})
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Apply an item's `pendingChanges` (running normal hooks/validation) and mark it
|
|
709
|
+
* approved. The buffered changes must be a valid update input for the collection.
|
|
710
|
+
*/
|
|
711
|
+
export async function approveChanges(
|
|
712
|
+
context: any,
|
|
713
|
+
args: { listKey: string; itemId: string }
|
|
714
|
+
): Promise<void> {
|
|
715
|
+
const sudo = context.sudo()
|
|
716
|
+
const item = await sudo.query[args.listKey].findOne({
|
|
717
|
+
where: { id: args.itemId },
|
|
718
|
+
query: 'id pendingChanges approvalStatus',
|
|
719
|
+
})
|
|
720
|
+
if (!item?.pendingChanges) throw new Error('approveChanges: the item has no pending changes')
|
|
721
|
+
await sudo.db[args.listKey].updateOne({
|
|
722
|
+
where: { id: args.itemId },
|
|
723
|
+
data: {
|
|
724
|
+
...item.pendingChanges,
|
|
725
|
+
pendingChanges: null,
|
|
726
|
+
approvalStatus: 'approved',
|
|
727
|
+
approvalReviewedAt: new Date().toISOString(),
|
|
728
|
+
},
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Discard an item's `pendingChanges` and mark it rejected. */
|
|
733
|
+
export async function rejectChanges(
|
|
734
|
+
context: any,
|
|
735
|
+
args: { listKey: string; itemId: string }
|
|
736
|
+
): Promise<void> {
|
|
737
|
+
await context.sudo().db[args.listKey].updateOne({
|
|
738
|
+
where: { id: args.itemId },
|
|
739
|
+
data: {
|
|
740
|
+
pendingChanges: null,
|
|
741
|
+
approvalStatus: 'rejected',
|
|
742
|
+
approvalReviewedAt: new Date().toISOString(),
|
|
743
|
+
},
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Adds an `expiresAt` timestamp for content with a shelf life. Pair with
|
|
749
|
+
* `purgeExpired()` in a cron job to physically remove (or just count) expired items.
|
|
750
|
+
*/
|
|
751
|
+
export function withExpiry(opts: { field?: string } = {}): CollectionMixin {
|
|
752
|
+
const field = opts.field ?? 'expiresAt'
|
|
753
|
+
return {
|
|
754
|
+
fields: {
|
|
755
|
+
[field]: timestamp({
|
|
756
|
+
ui: { description: 'After this time the item is considered expired.' },
|
|
757
|
+
}),
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Delete every item whose expiry has passed (runs normal delete hooks). Returns the
|
|
764
|
+
* number deleted. Designed for a cron job; see `withExpiry`.
|
|
765
|
+
*/
|
|
766
|
+
export async function purgeExpired(
|
|
767
|
+
context: any,
|
|
768
|
+
listKey: string,
|
|
769
|
+
opts: { field?: string } = {}
|
|
770
|
+
): Promise<number> {
|
|
771
|
+
const field = opts.field ?? 'expiresAt'
|
|
772
|
+
const sudo = context.sudo()
|
|
773
|
+
const expired = await sudo.db[listKey].findMany({ where: { [field]: { lte: new Date() } } })
|
|
774
|
+
for (const item of expired) {
|
|
775
|
+
await sudo.db[listKey].deleteOne({ where: { id: String(item.id) } })
|
|
776
|
+
}
|
|
777
|
+
return expired.length
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Adds a read-only `viewCount` integer. Increment it with `recordView()` — a direct,
|
|
782
|
+
* race-safe Prisma increment that bypasses hooks (a view is not an editorial change).
|
|
783
|
+
*/
|
|
784
|
+
export function withViewCount(opts: { field?: string } = {}): CollectionMixin {
|
|
785
|
+
const field = opts.field ?? 'viewCount'
|
|
786
|
+
return {
|
|
787
|
+
fields: {
|
|
788
|
+
[field]: integer({
|
|
789
|
+
defaultValue: 0,
|
|
790
|
+
ui: {
|
|
791
|
+
createView: { fieldMode: 'hidden' },
|
|
792
|
+
itemView: { fieldMode: 'read' },
|
|
793
|
+
},
|
|
794
|
+
}),
|
|
795
|
+
},
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Increment an item's view counter (see `withViewCount`). */
|
|
800
|
+
export async function recordView(
|
|
801
|
+
context: any,
|
|
802
|
+
listKey: string,
|
|
803
|
+
itemId: string,
|
|
804
|
+
opts: { field?: string } = {}
|
|
805
|
+
): Promise<void> {
|
|
806
|
+
const field = opts.field ?? 'viewCount'
|
|
807
|
+
const model = (context.prisma as any)?.[listKey[0].toLowerCase() + listKey.slice(1)]
|
|
808
|
+
if (!model) throw new Error(`recordView: collection "${listKey}" was not found in the Prisma client`)
|
|
809
|
+
await model.update({ where: { id: itemId }, data: { [field]: { increment: 1 } } })
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Companion to cascade rules: sweep records whose to-one relationship points at
|
|
814
|
+
* nothing (e.g. rows that pre-date a cascade rule, or were orphaned by `setNull`).
|
|
815
|
+
* Runs normal delete hooks (so cascade rules fire too). Designed for a cron job.
|
|
816
|
+
*
|
|
817
|
+
* @example
|
|
818
|
+
* jobs: createJobs({ jobs: [{
|
|
819
|
+
* name: 'orphan-cleanup',
|
|
820
|
+
* schedule: '0 4 * * *',
|
|
821
|
+
* handler: async () => { await cleanupOrphans(getContext(), { collection: 'Comment', field: 'post' }) },
|
|
822
|
+
* }]})
|
|
823
|
+
*/
|
|
824
|
+
export async function cleanupOrphans(
|
|
825
|
+
context: any,
|
|
826
|
+
options: {
|
|
827
|
+
/** Collection to sweep. */
|
|
828
|
+
collection: string
|
|
829
|
+
/** To-one relationship field that must be connected for the record to be kept. */
|
|
830
|
+
field: string
|
|
831
|
+
/** 'delete' removes orphans; 'softDelete' stamps `softDeleteField`. @default 'delete' */
|
|
832
|
+
action?: 'delete' | 'softDelete'
|
|
833
|
+
/** @default 'deletedAt' */
|
|
834
|
+
softDeleteField?: string
|
|
835
|
+
}
|
|
836
|
+
): Promise<number> {
|
|
837
|
+
const { collection, field, action = 'delete' } = options
|
|
838
|
+
const softDeleteField = options.softDeleteField ?? 'deletedAt'
|
|
839
|
+
const sudo = context.sudo()
|
|
840
|
+
const where =
|
|
841
|
+
action === 'softDelete' ? { [field]: null, [softDeleteField]: null } : { [field]: null }
|
|
842
|
+
|
|
843
|
+
let total = 0
|
|
844
|
+
for (;;) {
|
|
845
|
+
const orphans = await sudo.db[collection].findMany({ where, take: 100 })
|
|
846
|
+
if (!orphans.length) break
|
|
847
|
+
for (const orphan of orphans) {
|
|
848
|
+
if (action === 'delete') {
|
|
849
|
+
await sudo.db[collection].deleteOne({ where: { id: String(orphan.id) } })
|
|
850
|
+
} else {
|
|
851
|
+
await sudo.db[collection].updateOne({
|
|
852
|
+
where: { id: String(orphan.id) },
|
|
853
|
+
data: { [softDeleteField]: new Date().toISOString() },
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
total++
|
|
857
|
+
}
|
|
858
|
+
if (orphans.length < 100) break
|
|
859
|
+
}
|
|
860
|
+
return total
|
|
861
|
+
}
|
|
862
|
+
|
|
174
863
|
/**
|
|
175
864
|
* Factory for building reusable field group mixins.
|
|
176
865
|
*
|
|
@@ -183,28 +872,38 @@ export function withAudit(userListKey = 'User'): ListMixin {
|
|
|
183
872
|
*/
|
|
184
873
|
export function createMixin(
|
|
185
874
|
fields: Record<string, any>,
|
|
186
|
-
hooks?: Partial<
|
|
187
|
-
):
|
|
875
|
+
hooks?: Partial<CollectionHooks<any>>
|
|
876
|
+
): CollectionMixin {
|
|
188
877
|
return { fields, hooks }
|
|
189
878
|
}
|
|
190
879
|
|
|
191
880
|
// ================================================================
|
|
192
|
-
//
|
|
881
|
+
// defineCollection — a typed wrapper around list() with mixin support
|
|
193
882
|
// ================================================================
|
|
194
883
|
|
|
195
|
-
type DefineListConfig<
|
|
884
|
+
type DefineListConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<
|
|
885
|
+
CollectionConfig<CollectionTypeInfo>,
|
|
886
|
+
'access'
|
|
887
|
+
> & {
|
|
888
|
+
/**
|
|
889
|
+
* Controls what data can be read and changed. Optional here — unlike
|
|
890
|
+
* the lower-level `list()`, `defineCollection` defaults to `accessPresets.public`
|
|
891
|
+
* so a minimal list is genuinely one line. Set an explicit preset or access
|
|
892
|
+
* object the moment you need real rules.
|
|
893
|
+
*/
|
|
894
|
+
access?: CollectionConfig<CollectionTypeInfo>['access']
|
|
196
895
|
/**
|
|
197
896
|
* Mixins to apply to this list. Mixin fields are merged before user fields,
|
|
198
897
|
* so user fields always take precedence on key conflicts.
|
|
199
898
|
*/
|
|
200
|
-
mixins?:
|
|
201
|
-
}
|
|
899
|
+
mixins?: CollectionMixin[]
|
|
900
|
+
} & ListFeaturesConfig
|
|
202
901
|
|
|
203
902
|
/**
|
|
204
903
|
* Defines a list with optional mixins for reusable field groups and hooks.
|
|
205
904
|
*
|
|
206
905
|
* @example
|
|
207
|
-
* export const Post =
|
|
906
|
+
* export const Post = defineCollection({
|
|
208
907
|
* mixins: [withTimestamps()],
|
|
209
908
|
* fields: {
|
|
210
909
|
* title: text({ validation: validators.required }),
|
|
@@ -212,29 +911,60 @@ type DefineListConfig<ListTypeInfo extends BaseListTypeInfo> = ListConfig<ListTy
|
|
|
212
911
|
* },
|
|
213
912
|
* })
|
|
214
913
|
*/
|
|
215
|
-
export function
|
|
216
|
-
config: DefineListConfig<
|
|
217
|
-
):
|
|
218
|
-
const {
|
|
914
|
+
export function defineCollection<CollectionTypeInfo extends BaseCollectionTypeInfo>(
|
|
915
|
+
config: DefineListConfig<CollectionTypeInfo>
|
|
916
|
+
): CollectionConfig<CollectionTypeInfo> {
|
|
917
|
+
const {
|
|
918
|
+
mixins = [],
|
|
919
|
+
access,
|
|
920
|
+
computed,
|
|
921
|
+
constraints,
|
|
922
|
+
defaultFilter,
|
|
923
|
+
stateMachine,
|
|
924
|
+
policies,
|
|
925
|
+
events,
|
|
926
|
+
searchable,
|
|
927
|
+
versioned,
|
|
928
|
+
...listConfig
|
|
929
|
+
} = config
|
|
219
930
|
|
|
220
931
|
const mixinFields: Record<string, any> = {}
|
|
221
|
-
const mixinHooksList: Array<Partial<
|
|
932
|
+
const mixinHooksList: Array<Partial<CollectionHooks<any>>> = []
|
|
222
933
|
|
|
223
934
|
for (const mixin of mixins) {
|
|
224
935
|
if (mixin.fields) Object.assign(mixinFields, mixin.fields)
|
|
225
936
|
if (mixin.hooks) mixinHooksList.push(mixin.hooks)
|
|
226
937
|
}
|
|
227
938
|
|
|
228
|
-
|
|
939
|
+
// Feature hooks run AFTER mixin + user hooks so computed fields and constraint checks
|
|
940
|
+
// observe the final resolved data, and events/search/version snapshots fire last.
|
|
941
|
+
const featureHooks = compileFeatureHooks({
|
|
942
|
+
computed,
|
|
943
|
+
constraints,
|
|
944
|
+
stateMachine,
|
|
945
|
+
events,
|
|
946
|
+
searchable,
|
|
947
|
+
versioned,
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
const mergedHooks = mergeMixinHooks(
|
|
951
|
+
mixinHooksList,
|
|
952
|
+
listConfig.hooks as Partial<CollectionHooks<any>>,
|
|
953
|
+
featureHooks
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
const baseAccess = access ?? accessPresets.public
|
|
957
|
+
const finalAccess = applyAccessFeatures(baseAccess, { defaultFilter, policies })
|
|
229
958
|
|
|
230
|
-
return
|
|
959
|
+
return collection({
|
|
231
960
|
...listConfig,
|
|
961
|
+
access: finalAccess as CollectionConfig<CollectionTypeInfo>['access'],
|
|
232
962
|
fields: {
|
|
233
963
|
...mixinFields,
|
|
234
964
|
...listConfig.fields,
|
|
235
|
-
} as BaseFields<
|
|
236
|
-
hooks: mergedHooks as
|
|
237
|
-
} as
|
|
965
|
+
} as BaseFields<CollectionTypeInfo>,
|
|
966
|
+
hooks: mergedHooks as CollectionConfig<CollectionTypeInfo>['hooks'],
|
|
967
|
+
} as CollectionConfig<CollectionTypeInfo>)
|
|
238
968
|
}
|
|
239
969
|
|
|
240
970
|
type AnyResolveInput =
|
|
@@ -275,33 +1005,34 @@ function mergeResolveInput(
|
|
|
275
1005
|
}
|
|
276
1006
|
|
|
277
1007
|
/**
|
|
278
|
-
* Merge mixin hooks with the list's own hooks. Each
|
|
1008
|
+
* Merge mixin hooks with the list's own hooks. Each list hook may be either a function or a
|
|
279
1009
|
* `{ create, update, delete }` object — `merge` (from resolve-hooks) handles both forms for the
|
|
280
1010
|
* void hooks, and `mergeResolveInput` handles the data-threading `resolveInput` hook.
|
|
281
1011
|
*/
|
|
282
1012
|
function mergeMixinHooks(
|
|
283
|
-
mixinHooks: Array<Partial<
|
|
284
|
-
listHooks: Partial<
|
|
285
|
-
|
|
286
|
-
|
|
1013
|
+
mixinHooks: Array<Partial<CollectionHooks<any>>>,
|
|
1014
|
+
listHooks: Partial<CollectionHooks<any>> | undefined,
|
|
1015
|
+
featureHooks: Array<Partial<CollectionHooks<any>>> = []
|
|
1016
|
+
): Partial<CollectionHooks<any>> {
|
|
1017
|
+
if (mixinHooks.length === 0 && featureHooks.length === 0) return listHooks ?? {}
|
|
287
1018
|
|
|
288
|
-
const all = [...mixinHooks, ...(listHooks ? [listHooks] : [])]
|
|
289
|
-
const merged: Partial<
|
|
1019
|
+
const all = [...mixinHooks, ...(listHooks ? [listHooks] : []), ...featureHooks]
|
|
1020
|
+
const merged: Partial<CollectionHooks<any>> = {}
|
|
290
1021
|
|
|
291
1022
|
for (const hooks of all) {
|
|
292
1023
|
merged.resolveInput = mergeResolveInput(
|
|
293
1024
|
merged.resolveInput as AnyResolveInput | undefined,
|
|
294
1025
|
hooks.resolveInput as AnyResolveInput | undefined
|
|
295
|
-
) as
|
|
296
|
-
merged.validate = merge(merged.validate as any, hooks.validate as any) as
|
|
1026
|
+
) as CollectionHooks<any>['resolveInput']
|
|
1027
|
+
merged.validate = merge(merged.validate as any, hooks.validate as any) as CollectionHooks<any>['validate']
|
|
297
1028
|
merged.beforeOperation = merge(
|
|
298
1029
|
merged.beforeOperation as any,
|
|
299
1030
|
hooks.beforeOperation as any
|
|
300
|
-
) as
|
|
1031
|
+
) as CollectionHooks<any>['beforeOperation']
|
|
301
1032
|
merged.afterOperation = merge(
|
|
302
1033
|
merged.afterOperation as any,
|
|
303
1034
|
hooks.afterOperation as any
|
|
304
|
-
) as
|
|
1035
|
+
) as CollectionHooks<any>['afterOperation']
|
|
305
1036
|
}
|
|
306
1037
|
|
|
307
1038
|
return merged
|
|
@@ -324,7 +1055,7 @@ function mergeMixinHooks(
|
|
|
324
1055
|
export function defineConfig<TypeInfo extends BaseNixxieTypeInfo>(
|
|
325
1056
|
nixxieConfig: NixxieConfigPre<TypeInfo>
|
|
326
1057
|
): NixxieConfig<TypeInfo> {
|
|
327
|
-
return
|
|
1058
|
+
return buildConfig(nixxieConfig)
|
|
328
1059
|
}
|
|
329
1060
|
|
|
330
1061
|
// ================================================================
|
|
@@ -335,8 +1066,8 @@ export function defineConfig<TypeInfo extends BaseNixxieTypeInfo>(
|
|
|
335
1066
|
* Defines a list action. Typed alias for `action()`.
|
|
336
1067
|
*/
|
|
337
1068
|
export function defineAction<
|
|
338
|
-
|
|
339
|
-
Args extends ActionArgsConfig<
|
|
340
|
-
>(actionConfig: Action<
|
|
341
|
-
return
|
|
1069
|
+
CollectionTypeInfo extends BaseCollectionTypeInfo,
|
|
1070
|
+
Args extends ActionArgsConfig<CollectionTypeInfo> | undefined = undefined,
|
|
1071
|
+
>(actionConfig: Action<CollectionTypeInfo, Args>): DeclaredAction<CollectionTypeInfo> {
|
|
1072
|
+
return createAction(actionConfig)
|
|
342
1073
|
}
|