1
8
9import { z } from "zod";
10
11import { LIMITS } from "../constants.js";
12
13
14
15
16export const propertyTypeSchema = z.enum([
17 "title",
18 "text",
19 "rich_text",
20 "number",
21 "select",
22 "multi_select",
23 "status",
24 "date",
25 "date_range",
26 "person",
27 "email",
28 "phone",
29 "url",
30 "checkbox",
31 "files",
32 "created_time",
33 "created_by",
34 "last_edited_time",
35 "last_edited_by",
36 "relation",
37 "rollup",
38 "formula",
39 "button",
40 "unique_id",
41]);
42export type PropertyType = z.infer<typeof propertyTypeSchema>;
43
44export const selectOptionSchema = z
45 .object({
46 id: z.string().min(1),
47 name: z.string().min(1).max(100),
48 color: z
49 .string()
50 .regex(/^(default|gray|brown|orange|yellow|green|blue|purple|pink|red)(_bg)?$/),
51 position: z.string().min(1).optional(),
52 })
53 .strict();
54export type SelectOption = z.infer<typeof selectOptionSchema>;
55
56const optionsConfig = z.object({
57 options: z.array(selectOptionSchema).max(100),
58});
59
60
76
77
84export function optionIdForName(name: string): string {
85 const slug = name
86 .trim()
87 .toLowerCase()
88 .normalize("NFKD")
89 .replace(/[\u0300-\u036f]/g, "")
90 .replace(/[^a-z0-9]+/g, "-")
91 .replace(/^-+|-+$/g, "");
92 return slug ? `opt-${slug}` : `opt-${name.length}`;
93}
94
95
110export function normalizeSelectOptions(config: unknown, existingOptions?: unknown): unknown {
111 const cfg = (config ?? {}) as Record<string, unknown>;
112 const options = cfg.options;
113 if (!Array.isArray(options)) return config;
114 const byName = new Map<string, string>();
115 if (Array.isArray(existingOptions)) {
116 for (const o of existingOptions) {
117 const e = o as { id?: unknown; name?: unknown } | null;
118 if (e && typeof e.name === "string" && typeof e.id === "string" && e.id) {
119 byName.set(e.name, e.id);
120 }
121 }
122 }
123 let changed = false;
124 const next = options.map((o) => {
125 const opt = o as { id?: string; name?: string };
126 if (opt && typeof opt.name === "string" && (!opt.id || typeof opt.id !== "string" || opt.id.length === 0)) {
127 const id = byName.get(opt.name) ?? optionIdForName(opt.name);
128 changed = true;
129 return { ...opt, id };
130 }
131 return o;
132 });
133 return changed ? { ...cfg, options: next } : config;
134}
135
136const numberFormatSchema = z
137 .string()
138 .regex(/^(number|integer|decimal|percent|dollar|euro|pound|yen|cny|inr)$/);
139
140
144export const propertyConfigSchema = z.discriminatedUnion("type", [
145 z.object({ type: z.literal("title"), config: z.object({}).strict() }),
146 z.object({ type: z.literal("text"), config: z.object({}).strict() }),
147 z.object({ type: z.literal("rich_text"), config: z.object({}).strict() }),
148 z.object({
149 type: z.literal("number"),
150 config: z
151 .object({
152 format: numberFormatSchema.default("number"),
153 decimals: z.number().int().min(0).max(8).optional(),
154 prefix: z.string().max(16).optional(),
155 suffix: z.string().max(16).optional(),
156 })
157 .strict(),
158 }),
159 z.object({ type: z.literal("select"), config: optionsConfig }),
160 z.object({ type: z.literal("multi_select"), config: optionsConfig }),
161 z.object({ type: z.literal("status"), config: optionsConfig }),
162 z.object({
163 type: z.literal("date"),
164 config: z
165 .object({
166 format: z
167 .string()
168 .regex(/^(yyyy-mm-dd|yyyy\/mm\/dd|mm\/dd\/yyyy|dd\/mm\/yyyy|dd-mm-yyyy)$/),
169 timeEnabled: z.boolean().default(false),
170 })
171 .strict(),
172 }),
173 z.object({
174 type: z.literal("date_range"),
175 config: z
176 .object({
177 format: z
178 .string()
179 .regex(/^(yyyy-mm-dd|yyyy\/mm\/dd|mm\/dd\/yyyy|dd\/mm\/yyyy|dd-mm-yyyy)$/),
180 })
181 .strict(),
182 }),
183 z.object({
184 type: z.literal("person"),
185 config: z
186 .object({
187 allowMultiple: z.boolean().default(true),
188 })
189 .strict(),
190 }),
191 z.object({ type: z.literal("email"), config: z.object({}).strict() }),
192 z.object({ type: z.literal("phone"), config: z.object({}).strict() }),
193 z.object({ type: z.literal("url"), config: z.object({}).strict() }),
194 z.object({ type: z.literal("checkbox"), config: z.object({}).strict() }),
195 z.object({
196 type: z.literal("files"),
197 config: z
198 .object({
199 allowMultiple: z.boolean().default(true),
200 })
201 .strict(),
202 }),
203 z.object({ type: z.literal("created_time"), config: z.object({}).strict() }),
204 z.object({ type: z.literal("created_by"), config: z.object({}).strict() }),
205 z.object({ type: z.literal("last_edited_time"), config: z.object({}).strict() }),
206 z.object({ type: z.literal("last_edited_by"), config: z.object({}).strict() }),
207 z.object({
208 type: z.literal("relation"),
209 config: z
210 .object({
211 relationId: z.string().min(1),
212 })
213 .strict(),
214 }),
215 z.object({
216 type: z.literal("rollup"),
217 config: z
218 .object({
219
220 relationPropertyId: z.string().min(1),
221
222 targetPropertyId: z.string().min(1),
223 aggregate: z.enum(["count", "sum", "average", "min", "max", "percent", "unique"]),
224 })
225 .strict(),
226 }),
227 z.object({
228 type: z.literal("formula"),
229 config: z
230 .object({
231
232 expression: z.string().min(1).max(2000),
233 })
234 .strict(),
235 }),
236 z.object({
237 type: z.literal("button"),
238 config: z
239 .object({
240 label: z.string().min(1).max(64).optional(),
241 actions: z
242 .array(
243 z
244 .object({
245 type: z.enum([
246 "set_property",
247 "create_page",
248 "update_page",
249 "archive_page",
250 "duplicate_page",
251 "send_webhook",
252 "trigger_automation",
253 ]),
254 config: z.record(z.unknown()),
255 })
256 .strict(),
257 )
258 .min(1)
259 .max(10),
260 })
261 .strict(),
262 }),
263 z.object({
264 type: z.literal("unique_id"),
265 config: z
266 .object({
267 format: z.enum(["number", "uuid"]).default("number"),
268 prefix: z.string().max(16).optional(),
269 })
270 .strict(),
271 }),
272]);
273export type PropertyConfig = z.infer<typeof propertyConfigSchema>;
274
275
276export const databasePropertySchema = z
277 .object({
278 id: z.string().min(1).optional(),
279 name: z.string().min(1).max(100),
280 type: propertyTypeSchema,
281
282 position: z.string().min(1).optional(),
283 isTitle: z.boolean().default(false),
284 required: z.boolean().default(false),
285 config: z.record(z.unknown()).default({}),
286 default: z.unknown().optional(),
287 })
288 .strict()
289 .superRefine((val, ctx) => {
290 const result = propertyConfigSchema.safeParse({ type: val.type, config: val.config });
291 if (!result.success) {
292 for (const issue of result.error.issues) {
293 ctx.addIssue({
294 code: z.ZodIssueCode.custom,
295 message: `invalid config for property type "${val.type}": ${issue.message}`,
296 path: ["config", ...issue.path],
297 });
298 }
299 }
300 });
301export type DatabaseProperty = z.infer<typeof databasePropertySchema>;
302
303
304
305export const createDatabaseSchema = z
306 .object({
307 title: z.string().min(1).max(200),
308 icon: z.string().max(32).optional().nullable(),
309 description: z.string().max(500).optional().nullable(),
310
311 properties: z.array(databasePropertySchema).max(100).optional(),
312 })
313 .strict();
314export type CreateDatabase = z.infer<typeof createDatabaseSchema>;
315
316export const createPropertySchema = databasePropertySchema;
317export type CreateProperty = z.infer<typeof createPropertySchema>;
318
319
320
321
322export const viewTypeSchema = z.enum([
323 "table",
324 "list",
325 "board",
326 "gallery",
327 "calendar",
328 "timeline",
329 "chart",
330 "dashboard",
331 "form",
332]);
333export type ViewType = z.infer<typeof viewTypeSchema>;
334
335
336
337
338
339
340
341
342
343
344
345export const filterOperatorSchema = z.enum([
346 "=",
347 "!=",
348 "contains",
349 "doesNotContain",
350 "starts_with",
351 "ends_with",
352 ">",
353 ">=",
354 "<",
355 "<=",
356 "is_empty",
357 "is_not_empty",
358 "isBetween",
359 "isBefore",
360 "isAfter",
361 "isOnOrAfter",
362 "isOnOrBefore",
363 "isChecked",
364 "isUnchecked",
365 "has_files",
366 "has_no_files",
367]);
368export type FilterOperator = z.infer<typeof filterOperatorSchema>;
369
370
381export type FilterNode =
382 | { op: "and" | "or" | "not"; conditions: FilterNode[] }
383 | { propertyId: string; operator: FilterOperator; value?: unknown };
384
385export const filterNodeSchema: z.ZodType<FilterNode> = z.lazy(() =>
386 z.union([
387 z.object({
388 op: z.enum(["and", "or", "not"]),
389 conditions: z.array(filterNodeSchema).min(1).max(100),
390 }),
391 z
392 .object({
393 propertyId: z.string().min(1),
394 operator: filterOperatorSchema,
395 value: z.unknown().optional(),
396 })
397 .superRefine((val, ctx) => {
398 const isEmptyOp = val.operator === "is_empty" || val.operator === "is_not_empty";
399 if (!isEmptyOp && val.value === undefined) {
400 ctx.addIssue({
401 code: z.ZodIssueCode.custom,
402 message: `operator "${val.operator}" requires a value`,
403 });
404 }
405 }),
406 ]),
407);
408
409
410export const sortSpecSchema = z
411 .object({
412 propertyId: z.string().min(1),
413 direction: z.enum(["asc", "desc"]).default("asc"),
414 })
415 .strict();
416export type SortSpec = z.infer<typeof sortSpecSchema>;
417
418
419export const groupSpecSchema = z
420 .object({
421 propertyId: z.string().min(1),
422 direction: z.enum(["asc", "desc"]).default("asc"),
423 })
424 .strict();
425export type GroupSpec = z.infer<typeof groupSpecSchema>;
426
427
428
429export const createRecordSchema = z
430 .object({
431
433 properties: z.record(z.string(), z.unknown()),
434 })
435 .strict();
436export type CreateRecord = z.infer<typeof createRecordSchema>;
437
438export const updateRecordSchema = z
439 .object({
440 properties: z.record(z.string(), z.unknown()),
441 })
442 .strict();
443export type UpdateRecord = z.infer<typeof updateRecordSchema>;
444
445
450export const recordQuerySchema = z
451 .object({
452 viewId: z.string().min(1).optional(),
453 filter: filterNodeSchema.optional(),
454 sort: z.array(sortSpecSchema).max(20).optional(),
455 group: z.array(groupSpecSchema).max(5).optional(),
456
457 cursor: z.string().min(1).optional(),
458 pageSize: z.number().int().min(1).max(LIMITS.MAX_RECORDS_PER_PAGE).default(50),
459 })
460 .strict();
461export type RecordQuery = z.infer<typeof recordQuerySchema>;
462
463
464
465export const createViewSchema = z
466 .object({
467 name: z.string().min(1).max(100),
468 type: viewTypeSchema,
469 filter: filterNodeSchema.optional(),
470 sort: z.array(sortSpecSchema).max(20).optional(),
471 group: z.array(groupSpecSchema).max(5).optional(),
472
473 config: z.record(z.unknown()).default({}),
474 })
475 .strict();
476export type CreateView = z.infer<typeof createViewSchema>;
477