kabo-contracts 0.0.0-stage → 1.0.3

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/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # kabo-contracts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The data contracts shared by `kabobee-frontend` and `kabobee-backend`: [zod](https://zod.dev) schemas
4
+ and enums, in plain JavaScript. A rule written here is enforced in the browser (form errors) **and**
5
+ on the server (400 responses) with the same Bulgarian message.
6
+
7
+ ## What is in it
8
+
9
+ | File | Exports | Used by |
10
+ |---|---|---|
11
+ | `src/enums.js` | `DELIVERY_METHOD`, `PAYMENT_METHOD`, `INVOICE_TYPE`, `MESSAGE_TYPE`, `PRODUCT_CATEGORY`, `PRODUCT_AUDIENCE`, `CURRENCY`, `MAX_ITEM_QUANTITY` | both |
12
+ | `src/common.js` | `MESSAGES` (Bulgarian error texts), `EMAIL_REGEX`, `PHONE_NUMBER_REGEX`, building blocks (`requiredText`, `optionalText`, `econtId`, …) | both |
13
+ | `src/order.js` | `checkoutSchema` (checkout form), `createOrderSchema` (`POST /orders` = form + `products`) | form / API |
14
+ | `src/cart.js` | `cartItemSchema`, `cartItemsSchema`, `deliveryPriceRequestSchema` (`POST /econt/delivery/price`) | API |
15
+ | `src/message.js` | `contactMessageSchema`, `wholesaleMessageSchema` (forms), `createMessageSchema` (`POST /messages`) | form / API |
16
+ | `src/product.js` | `productInputSchema` (seed script), `productSchema` (`GET /products`) | both |
17
+
18
+ **Belongs here:** request bodies, response shapes and values that both sides must read the same way.
19
+ **Does not:** React or UI code (→ `@kabo/shared` in the frontend), business logic such as prices or
20
+ Econt calls (→ backend), secrets, environment config.
21
+
22
+ ## Install
23
+
24
+ Published on npmjs.com as [`kabo-contracts`](https://www.npmjs.com/package/kabo-contracts); no login needed.
25
+ Install it under the name the code imports, `@kabo/contracts`, with an npm alias:
26
+
27
+ ```bash
28
+ npm install @kabo/contracts@npm:kabo-contracts@^1.0.3
29
+ ```
30
+
31
+ ```json
32
+ "dependencies": { "@kabo/contracts": "npm:kabo-contracts@^1.0.3" }
33
+ ```
34
+
35
+ `^1.0.3` takes new patch and minor versions on the next `npm update`; a major version (breaking
36
+ change) needs an explicit bump. Plain ES modules, no build step. The backend (CommonJS) can
37
+ `require()` it on Node ≥ 20.19.
38
+
39
+ ## Use
40
+
41
+ ```js
42
+ // frontend – checkout form
43
+ import { zodResolver } from '@hookform/resolvers/zod';
44
+ import { checkoutSchema } from '@kabo/contracts';
45
+
46
+ const form = useForm({ defaultValues, resolver: zodResolver(checkoutSchema) });
47
+ ```
48
+
49
+ ```js
50
+ // backend – POST /orders
51
+ const { createOrderSchema } = require('@kabo/contracts');
52
+
53
+ const result = createOrderSchema.safeParse(req.body);
54
+ if (!result.success) {
55
+ return res.status(400).json({ message: result.error.issues[0].message, issues: result.error.issues });
56
+ }
57
+ const order = result.data; // trimmed strings, numeric Econt ids, unknown fields removed
58
+ ```
59
+
60
+ Editor types without TypeScript (VS Code reads JSDoc):
61
+
62
+ ```js
63
+ /** @param {import('@kabo/contracts').CreateOrder} order */
64
+ ```
65
+
66
+ ### Two rules worth knowing
67
+
68
+ - **All errors at once.** Zod skips an object's cross-field rules when any field fails, so the checkout
69
+ schema keeps its fields loose and checks every "required" rule in one `superRefine`. A customer who
70
+ submits an empty form sees every missing field, not just the first few.
71
+ - **An empty required field gets only "required"**, never "required" plus "invalid email".
72
+
73
+ ## Change a rule
74
+
75
+ 1. Edit the schema and its test in `test/`; `npm test`.
76
+ 2. Bump `version` in `package.json`, add a line to `CHANGELOG.md`, commit, `git tag vX.Y.Z`, push with tags.
77
+ The **Publish to npm** GitHub Action tests and publishes it (the tag must equal the version).
78
+ 3. `npm update @kabo/contracts` in the **backend first**, then the frontend (for a major version,
79
+ change the range in `package.json`, e.g. `npm:kabo-contracts@^2.0.0`).
80
+
81
+ | Change | Version | Example |
82
+ |---|---|---|
83
+ | Message text, bug fix | patch `1.0.1` | typo in an error message |
84
+ | New optional field, new enum value | minor `1.1.0` | `orders.store` |
85
+ | Field renamed, removed or newly required; enum value changed | major `2.0.0` | `phoneNumber` → `phone` – deploy backend before frontend |
86
+
87
+ ## Publishing setup (once)
88
+
89
+ Releases are published by `.github/workflows/publish.yml` with npm **trusted publishing**: no token,
90
+ nothing expires. Configured on npmjs.com → kabo-contracts → Settings → Trusted Publisher →
91
+ GitHub Actions (`bobikoki99` / `kabo-contracts` / `publish.yml`), with publishing access set to
92
+ "Require two-factor authentication and disallow tokens". Nobody needs to log in to npm locally.
93
+
94
+ The very first version was published with a 7-day token in the `NPM_TOKEN` secret, because trusted
95
+ publishing can only be enabled for a package that already exists; that secret is deleted afterwards.
96
+
97
+ ## Develop
98
+
99
+ ```bash
100
+ npm install
101
+ npm test
102
+ ```
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "kabo-contracts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.3",
4
+ "description": "Data contracts shared by the Kabo frontend and backend: zod schemas and enums",
5
+ "keywords": [
6
+ "kabo",
7
+ "zod",
8
+ "contracts"
9
+ ],
10
+ "license": "UNLICENSED",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/bobikoki99/kabo-contracts.git"
14
+ },
15
+ "homepage": "https://github.com/bobikoki99/kabo-contracts#readme",
16
+ "type": "module",
17
+ "exports": "./src/index.js",
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20.19"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "zod": "^4.6.7"
32
+ },
33
+ "devDependencies": {
34
+ "vitest": "^5.1.0"
35
+ }
36
+ }
package/src/cart.js ADDED
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+
3
+ import { MESSAGES, econtId, mongoId } from './common.js';
4
+ import { MAX_ITEM_QUANTITY } from './enums.js';
5
+
6
+ // What the client sends for each cart line; prices and weights always come from the database
7
+ export const cartItemSchema = z.object({
8
+ _id: mongoId,
9
+ quantity: z.number().int().min(1).max(MAX_ITEM_QUANTITY)
10
+ }, { error: MESSAGES.INVALID_CART_ITEM });
11
+
12
+ export const cartItemsSchema = z
13
+ .array(cartItemSchema, { error: MESSAGES.CART_EMPTY })
14
+ .min(1, MESSAGES.CART_EMPTY);
15
+
16
+ // POST /econt/delivery/price
17
+ export const deliveryPriceRequestSchema = z.object({
18
+ city: econtId.refine(city => city !== null, MESSAGES.REQUIRED),
19
+ items: cartItemsSchema
20
+ });
21
+
22
+ /** @typedef {import('zod').infer<typeof cartItemSchema>} CartItem */
23
+ /** @typedef {import('zod').infer<typeof deliveryPriceRequestSchema>} DeliveryPriceRequest */
package/src/common.js ADDED
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+
3
+ // Error messages are shown to customers next to the form fields, so they are in Bulgarian
4
+ export const MESSAGES = Object.freeze({
5
+ REQUIRED: 'Това поле е задължително.',
6
+ TOO_LONG: 'Текстът е твърде дълъг.',
7
+ INVALID_EMAIL: 'Невалиден имейл адрес.',
8
+ INVALID_PHONE: 'Въведете валиден български телефонен номер, напр. 0888 123 456.',
9
+ TERMS_REQUIRED: 'Необходимо е да приемете Общите условия.',
10
+ INVALID_CHOICE: 'Изберете една от възможностите.',
11
+ CART_EMPTY: 'Количката е празна.',
12
+ INVALID_CART_ITEM: 'Невалиден продукт в количката.'
13
+ });
14
+
15
+ export const MAX_TEXT_LENGTH = 300;
16
+
17
+ // One address with a dot in the domain; deliberately simple, the same rule everywhere
18
+ export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19
+
20
+ // Bulgarian mobile or landline number once spaces, dashes and brackets are removed:
21
+ // 0888123456, +359888123456, 00359888123456, 029876543
22
+ export const PHONE_NUMBER_REGEX = /^(\+359|00359|0)\d{8,9}$/;
23
+
24
+ export const MONGO_ID_REGEX = /^[0-9a-f]{24}$/i;
25
+
26
+ export const isEmail = (value) => EMAIL_REGEX.test(value);
27
+ export const isPhoneNumber = (value) => PHONE_NUMBER_REGEX.test(value.replace(/[\s\-()]/g, ''));
28
+
29
+ // Trimmed text that may be empty; missing becomes ''
30
+ export const optionalText = (max = MAX_TEXT_LENGTH) => z.string().trim().max(max, MESSAGES.TOO_LONG).default('');
31
+
32
+ // Trimmed text that must not be empty
33
+ export const requiredText = (max = MAX_TEXT_LENGTH) => z
34
+ .string({ error: MESSAGES.REQUIRED })
35
+ .trim()
36
+ .min(1, { error: MESSAGES.REQUIRED, abort: true }) // an empty field gets only "required"
37
+ .max(max, MESSAGES.TOO_LONG);
38
+
39
+ export const requiredEmail = (max = MAX_TEXT_LENGTH) => requiredText(max).refine(isEmail, MESSAGES.INVALID_EMAIL);
40
+
41
+ export const optionalPhoneNumber = (max = MAX_TEXT_LENGTH) => optionalText(max)
42
+ .refine(value => !value || isPhoneNumber(value), MESSAGES.INVALID_PHONE);
43
+
44
+ // Econt city and office ids: numbers, but forms hold them as '' until chosen.
45
+ // Anything that is not a positive number becomes null, so "required" can be checked separately.
46
+ // null is accepted as input too, so the parsed output can be parsed again (form → API).
47
+ export const econtId = z
48
+ .union([z.number(), z.string(), z.null()])
49
+ .optional()
50
+ .transform(value => (Number(value) > 0 ? Number(value) : null));
51
+
52
+ export const mongoId = z.string().regex(MONGO_ID_REGEX);
package/src/enums.js ADDED
@@ -0,0 +1,37 @@
1
+ // Values the frontend and backend must agree on. Stored in the database as-is,
2
+ // so changing a value is a breaking change (major version + data migration).
3
+
4
+ export const DELIVERY_METHOD = Object.freeze({
5
+ ECONT_OFFICE: 'econt-office',
6
+ ECONT_ADDRESS: 'econt-address'
7
+ });
8
+
9
+ export const PAYMENT_METHOD = Object.freeze({
10
+ ON_DELIVERY: 'on-delivery'
11
+ });
12
+
13
+ export const INVOICE_TYPE = Object.freeze({
14
+ NATURAL_PERSON: 'NP',
15
+ LEGAL_ENTITY: 'LE'
16
+ });
17
+
18
+ export const MESSAGE_TYPE = Object.freeze({
19
+ CONTACT: 'contact', // contacts page
20
+ WHOLESALE: 'wholesale' // for-resellers page
21
+ });
22
+
23
+ // Which storefront sells a product (products.category)
24
+ export const PRODUCT_CATEGORY = Object.freeze({
25
+ SUBLIMATOR: 'sublimator',
26
+ HONEY: 'honey'
27
+ });
28
+
29
+ // Who a sublimator model is for (products.audience)
30
+ export const PRODUCT_AUDIENCE = Object.freeze({
31
+ HOBBY: 'hobby',
32
+ PRO: 'pro'
33
+ });
34
+
35
+ export const CURRENCY = 'EUR';
36
+
37
+ export const MAX_ITEM_QUANTITY = 99;
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from './enums.js';
2
+ export * from './common.js';
3
+ export * from './cart.js';
4
+ export * from './order.js';
5
+ export * from './message.js';
6
+ export * from './product.js';
package/src/message.js ADDED
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+
3
+ import { MESSAGES, requiredText, requiredEmail, optionalText, optionalPhoneNumber } from './common.js';
4
+ import { MESSAGE_TYPE } from './enums.js';
5
+
6
+ const contactFields = {
7
+ name: requiredText(100),
8
+ email: requiredEmail(200),
9
+ phoneNumber: optionalPhoneNumber(30),
10
+ company: optionalText(150),
11
+ city: optionalText(100),
12
+ message: requiredText(5000)
13
+ };
14
+
15
+ // Contacts page form
16
+ export const contactMessageSchema = z.object(contactFields);
17
+
18
+ // For-resellers page form: the shop or company is required
19
+ export const wholesaleMessageSchema = z.object({ ...contactFields, company: requiredText(150) });
20
+
21
+ // POST /messages: either form, told apart by `type` (missing means a contact message)
22
+ export const createMessageSchema = z.preprocess(
23
+ body => (body && typeof body === 'object' && body.type === undefined ? { ...body, type: MESSAGE_TYPE.CONTACT } : body),
24
+ z.discriminatedUnion('type', [
25
+ contactMessageSchema.extend({ type: z.literal(MESSAGE_TYPE.CONTACT) }),
26
+ wholesaleMessageSchema.extend({ type: z.literal(MESSAGE_TYPE.WHOLESALE) })
27
+ ], { error: MESSAGES.INVALID_CHOICE })
28
+ );
29
+
30
+ /** @typedef {import('zod').infer<typeof contactMessageSchema>} ContactMessage */
31
+ /** @typedef {import('zod').infer<typeof wholesaleMessageSchema>} WholesaleMessage */
32
+ /** @typedef {import('zod').infer<typeof createMessageSchema>} CreateMessage */
package/src/order.js ADDED
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod';
2
+
3
+ import { MESSAGES, optionalText, econtId, isEmail, isPhoneNumber } from './common.js';
4
+ import { cartItemsSchema } from './cart.js';
5
+ import { DELIVERY_METHOD, PAYMENT_METHOD, INVOICE_TYPE } from './enums.js';
6
+
7
+ // The fields are deliberately loose (missing → '') and every "required" rule lives in
8
+ // checkCheckout below. Zod skips an object's refinements when any field fails, so this
9
+ // is what lets a customer see all missing fields at once after the first submit.
10
+
11
+ const invoiceDetailsFields = z.object({
12
+ invoiceType: z.enum(Object.values(INVOICE_TYPE), { error: MESSAGES.INVALID_CHOICE }).default(INVOICE_TYPE.NATURAL_PERSON),
13
+ // Natural person (NP)
14
+ firstName: optionalText(),
15
+ lastName: optionalText(),
16
+ nationalIdentityNumber: optionalText(), // ЕГН, optional on purpose (less personal data)
17
+ // Legal entity (LE)
18
+ companyName: optionalText(),
19
+ materialResponsiblePerson: optionalText(), // МОЛ
20
+ companyIdentificationNumber: optionalText(), // ЕИК
21
+ isVATRegistered: z.boolean().default(false),
22
+ vatNumber: optionalText(),
23
+ // Invoice address
24
+ city: econtId,
25
+ cityName: optionalText(),
26
+ regionName: optionalText(),
27
+ postCode: optionalText(),
28
+ countryCode: optionalText(),
29
+ countryName: optionalText(),
30
+ address: optionalText()
31
+ });
32
+
33
+ const checkoutFields = z.object({
34
+ // Contact
35
+ firstName: optionalText(),
36
+ lastName: optionalText(),
37
+ phoneNumber: optionalText(),
38
+ email: optionalText(),
39
+ // Delivery city (Econt city code + display data)
40
+ city: econtId,
41
+ cityName: optionalText(),
42
+ regionName: optionalText(),
43
+ postCode: optionalText(),
44
+ countryCode: optionalText(),
45
+ countryName: optionalText(),
46
+ // Delivery method: office fields or address
47
+ deliveryMethod: optionalText(),
48
+ office: econtId,
49
+ officeCode: optionalText(),
50
+ officeName: optionalText(),
51
+ officeAddress: optionalText(),
52
+ address: optionalText(),
53
+ // Payment and invoice
54
+ paymentMethod: z.enum(Object.values(PAYMENT_METHOD), { error: MESSAGES.INVALID_CHOICE }).default(PAYMENT_METHOD.ON_DELIVERY),
55
+ isInvoice: z.boolean().default(false),
56
+ invoiceDetails: invoiceDetailsFields.prefault({}),
57
+ termsAccepted: z.boolean().default(false)
58
+ });
59
+
60
+ const requireValue = (ctx, value, path, message = MESSAGES.REQUIRED) => {
61
+ if (value === '' || value === null) {
62
+ ctx.addIssue({ code: 'custom', path, message });
63
+ return false;
64
+ }
65
+ return true;
66
+ };
67
+
68
+ const checkCheckout = (order, ctx) => {
69
+ ['firstName', 'lastName'].forEach(field => requireValue(ctx, order[field], [field]));
70
+
71
+ if (requireValue(ctx, order.phoneNumber, ['phoneNumber']) && !isPhoneNumber(order.phoneNumber)) {
72
+ ctx.addIssue({ code: 'custom', path: ['phoneNumber'], message: MESSAGES.INVALID_PHONE });
73
+ }
74
+ if (requireValue(ctx, order.email, ['email']) && !isEmail(order.email)) {
75
+ ctx.addIssue({ code: 'custom', path: ['email'], message: MESSAGES.INVALID_EMAIL });
76
+ }
77
+
78
+ requireValue(ctx, order.city, ['city']);
79
+
80
+ if (requireValue(ctx, order.deliveryMethod, ['deliveryMethod']) && !Object.values(DELIVERY_METHOD).includes(order.deliveryMethod)) {
81
+ ctx.addIssue({ code: 'custom', path: ['deliveryMethod'], message: MESSAGES.INVALID_CHOICE });
82
+ }
83
+ if (order.deliveryMethod === DELIVERY_METHOD.ECONT_OFFICE) {
84
+ // The office is picked from a list that fills all three; any one missing means "no office chosen"
85
+ const hasOffice = order.office !== null && order.officeCode !== '' && order.officeName !== '';
86
+ requireValue(ctx, hasOffice ? order.office : null, ['office']);
87
+ }
88
+ if (order.deliveryMethod === DELIVERY_METHOD.ECONT_ADDRESS) {
89
+ requireValue(ctx, order.address, ['address']);
90
+ }
91
+
92
+ if (order.isInvoice) {
93
+ const invoice = order.invoiceDetails;
94
+ const at = field => ['invoiceDetails', field];
95
+
96
+ requireValue(ctx, invoice.city, at('city'));
97
+ requireValue(ctx, invoice.address, at('address'));
98
+
99
+ if (invoice.invoiceType === INVOICE_TYPE.NATURAL_PERSON) {
100
+ ['firstName', 'lastName'].forEach(field => requireValue(ctx, invoice[field], at(field)));
101
+ }
102
+ if (invoice.invoiceType === INVOICE_TYPE.LEGAL_ENTITY) {
103
+ ['companyName', 'materialResponsiblePerson', 'companyIdentificationNumber'].forEach(field => requireValue(ctx, invoice[field], at(field)));
104
+ if (invoice.isVATRegistered) {
105
+ requireValue(ctx, invoice.vatNumber, at('vatNumber'));
106
+ }
107
+ }
108
+ }
109
+
110
+ if (order.termsAccepted !== true) {
111
+ ctx.addIssue({ code: 'custom', path: ['termsAccepted'], message: MESSAGES.TERMS_REQUIRED });
112
+ }
113
+ };
114
+
115
+ // The checkout form (frontend): everything the customer fills in
116
+ export const checkoutSchema = checkoutFields.superRefine(checkCheckout);
117
+
118
+ // POST /orders: the checkout form plus the cart lines
119
+ export const createOrderSchema = checkoutFields
120
+ .extend({ products: cartItemsSchema })
121
+ .superRefine(checkCheckout);
122
+
123
+ /** @typedef {import('zod').infer<typeof checkoutSchema>} Checkout */
124
+ /** @typedef {import('zod').infer<typeof createOrderSchema>} CreateOrder */
package/src/product.js ADDED
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+
3
+ import { mongoId } from './common.js';
4
+ import { PRODUCT_CATEGORY, PRODUCT_AUDIENCE } from './enums.js';
5
+
6
+ // A product as stored by the seed script (kabobee-backend/scripts/seedPlaceholderModels.js).
7
+ // Sublimators use audience, warrantyMonths and inTheBox; honey products leave them out.
8
+ // `specs` keys differ per category (hives, powerSource… / honeyType, netWeight…), so they stay free-form.
9
+ export const productInputSchema = z.object({
10
+ modelKey: z.string().min(1),
11
+ category: z.enum(Object.values(PRODUCT_CATEGORY)),
12
+ name: z.string().min(1),
13
+ tagline: z.string().default(''),
14
+ price: z.number().positive(), // EUR incl. VAT
15
+ weight: z.number().positive(), // kg incl. packaging, used for Econt prices
16
+ sortOrder: z.number().int(),
17
+ specs: z.record(z.string(), z.string()).default({}),
18
+ highlights: z.array(z.string()).default([]),
19
+ briefSummary: z.string().default(''),
20
+ description: z.string().default(''),
21
+ mainImage: z.string().default(''),
22
+ imageUrls: z.array(z.string()).default([]),
23
+ videoUrl: z.string().default(''),
24
+ audience: z.enum(Object.values(PRODUCT_AUDIENCE)).optional(),
25
+ warrantyMonths: z.number().int().positive().optional(),
26
+ inTheBox: z.array(z.string()).optional(),
27
+ isPlaceholder: z.boolean().optional()
28
+ });
29
+
30
+ // A product as returned by GET /products and GET /products/:productId
31
+ export const productSchema = productInputSchema.extend({ _id: mongoId });
32
+
33
+ /** @typedef {import('zod').infer<typeof productSchema>} Product */