@updraft-solutions/mcrit-sdk 0.1.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/README.md +240 -0
- package/dist/chunk-2DRKSYW4.cjs +99 -0
- package/dist/chunk-2M32EOYI.js +145 -0
- package/dist/chunk-AN55SAGU.js +1189 -0
- package/dist/chunk-D5DUVW34.js +176 -0
- package/dist/chunk-HQSF3WGF.cjs +14 -0
- package/dist/chunk-JGIAOBZI.js +99 -0
- package/dist/chunk-JRVRIKNM.cjs +168 -0
- package/dist/chunk-LVTDNDWE.cjs +124 -0
- package/dist/chunk-LXNCAKJZ.js +0 -0
- package/dist/chunk-MF7G76QS.cjs +176 -0
- package/dist/chunk-MI4YBENQ.cjs +145 -0
- package/dist/chunk-MOOGM3DW.cjs +1 -0
- package/dist/chunk-NQV2D3FJ.js +14 -0
- package/dist/chunk-TJXWL42D.js +124 -0
- package/dist/chunk-VYT5KQWF.js +168 -0
- package/dist/chunk-XKZQ5W7E.cjs +1189 -0
- package/dist/courses/index.cjs +7 -0
- package/dist/courses/index.d.cts +58 -0
- package/dist/courses/index.d.ts +58 -0
- package/dist/courses/index.js +7 -0
- package/dist/crew/index.cjs +10 -0
- package/dist/crew/index.d.cts +102 -0
- package/dist/crew/index.d.ts +102 -0
- package/dist/crew/index.js +10 -0
- package/dist/crew/vue.cjs +34 -0
- package/dist/crew/vue.d.cts +31 -0
- package/dist/crew/vue.d.ts +31 -0
- package/dist/crew/vue.js +34 -0
- package/dist/format/index.cjs +44 -0
- package/dist/format/index.d.cts +69 -0
- package/dist/format/index.d.ts +69 -0
- package/dist/format/index.js +44 -0
- package/dist/index.cjs +261 -0
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +261 -0
- package/dist/media/index.cjs +30 -0
- package/dist/media/index.d.cts +89 -0
- package/dist/media/index.d.ts +89 -0
- package/dist/media/index.js +30 -0
- package/dist/models-CY3MmvCg.d.cts +33801 -0
- package/dist/models-CY3MmvCg.d.ts +33801 -0
- package/dist/money/index.cjs +18 -0
- package/dist/money/index.d.cts +66 -0
- package/dist/money/index.d.ts +66 -0
- package/dist/money/index.js +18 -0
- package/dist/pagination/index.cjs +6 -0
- package/dist/pagination/index.d.cts +16 -0
- package/dist/pagination/index.d.ts +16 -0
- package/dist/pagination/index.js +6 -0
- package/dist/schemas/index.cjs +158 -0
- package/dist/schemas/index.d.cts +1019 -0
- package/dist/schemas/index.d.ts +1019 -0
- package/dist/schemas/index.js +158 -0
- package/dist/types/index.cjs +1 -0
- package/dist/types/index.d.cts +143 -0
- package/dist/types/index.d.ts +143 -0
- package/dist/types/index.js +1 -0
- package/package.json +171 -0
- package/src/courses/index.ts +202 -0
- package/src/crew/index.ts +255 -0
- package/src/crew/vue.ts +70 -0
- package/src/format/index.ts +265 -0
- package/src/index.ts +76 -0
- package/src/media/index.ts +224 -0
- package/src/money/index.ts +168 -0
- package/src/pagination/index.ts +27 -0
- package/src/schemas/common.ts +73 -0
- package/src/schemas/faq.ts +94 -0
- package/src/schemas/fee.ts +37 -0
- package/src/schemas/index.ts +6 -0
- package/src/schemas/models.ts +996 -0
- package/src/schemas/report.ts +306 -0
- package/src/schemas/safety-report.ts +163 -0
- package/src/types/api.ts +37 -0
- package/src/types/compliance.ts +44 -0
- package/src/types/index.ts +3 -0
- package/src/types/models.ts +124 -0
package/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# `@updraft-solutions/mcrit-sdk`
|
|
2
|
+
|
|
3
|
+
Shared TypeScript types, Zod schemas, and pure helpers for the MCRIT
|
|
4
|
+
aviation fleet management platform — consumed by the `mcrit-app` SPA and
|
|
5
|
+
the public `inflight-homepage` site so both speak the same API contract
|
|
6
|
+
and apply the same business logic.
|
|
7
|
+
|
|
8
|
+
The SDK is **runtime-agnostic and side-effect free**: no Vue, no Nuxt, no
|
|
9
|
+
Pinia, no HTTP client. Just types and pure functions that operate on the
|
|
10
|
+
resource shapes returned by the Laravel API.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @updraft-solutions/mcrit-sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Peer dependency:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add moment-timezone
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`moment-timezone` is only required if you use the `format` module. If you
|
|
27
|
+
want German or another non-English label output, also import the matching
|
|
28
|
+
moment locale **once** at your app entry:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// e.g. a Nuxt plugin or app/main.ts
|
|
32
|
+
import "moment/locale/de";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Subpath exports
|
|
38
|
+
|
|
39
|
+
Every helper is tree-shakeable. Import from the narrow subpath you need,
|
|
40
|
+
or from the root if you want everything in one place.
|
|
41
|
+
|
|
42
|
+
| Subpath | What's inside |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `@updraft-solutions/mcrit-sdk` | Re-exports everything below |
|
|
45
|
+
| `@updraft-solutions/mcrit-sdk/schemas` | Zod schemas for API resources |
|
|
46
|
+
| `@updraft-solutions/mcrit-sdk/types` | Plain TypeScript types (inferred or hand-written) |
|
|
47
|
+
| `@updraft-solutions/mcrit-sdk/money` | Dinero-based fee and price helpers |
|
|
48
|
+
| `@updraft-solutions/mcrit-sdk/courses` | Course variant and total-cost logic |
|
|
49
|
+
| `@updraft-solutions/mcrit-sdk/format` | Date, time, percentage, byte, membership formatters |
|
|
50
|
+
| `@updraft-solutions/mcrit-sdk/crew` | Crew-role constants, predicates, membership helpers |
|
|
51
|
+
| `@updraft-solutions/mcrit-sdk/media` | MIME-type classification + Spatie MediaLibrary mappers |
|
|
52
|
+
| `@updraft-solutions/mcrit-sdk/pagination` | Pagination meta math |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick examples
|
|
57
|
+
|
|
58
|
+
### Schemas & types
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { feeSchema, courseSchema } from "@updraft-solutions/mcrit-sdk/schemas";
|
|
62
|
+
import type { Course, Fee } from "@updraft-solutions/mcrit-sdk/types";
|
|
63
|
+
|
|
64
|
+
const fee = feeSchema.parse(apiResponse); // runtime validation
|
|
65
|
+
const course: Course = await loadCourse(); // type only
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Money
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { parseFee, displayFee, fromCents, formatToEuroString } from "@updraft-solutions/mcrit-sdk/money";
|
|
72
|
+
|
|
73
|
+
displayFee(fee, "gross"); // → "€ 1.234,56"
|
|
74
|
+
parseFee(fee)?.currentPrice("net"); // → Dinero instance
|
|
75
|
+
formatToEuroString(123456); // → "€ 1.234,56"
|
|
76
|
+
fromCents(50000).toFormat(); // → "500,00 €"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Courses
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { parseCourse } from "@updraft-solutions/mcrit-sdk/courses";
|
|
83
|
+
|
|
84
|
+
const variants = parseCourse(course)?.variants("gross") ?? [];
|
|
85
|
+
// [{ variant, fees: ParsedCourseFee[], totalCost: Dinero }, ...]
|
|
86
|
+
|
|
87
|
+
for (const v of variants) {
|
|
88
|
+
console.log(v.variant, v.totalCost.toFormat());
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`parseCourse` handles fees with pivot data nested under `pivot` OR
|
|
93
|
+
flattened directly on the fee object — both API shapes are accepted.
|
|
94
|
+
|
|
95
|
+
### Format
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import {
|
|
99
|
+
formatDate,
|
|
100
|
+
formatDateTime,
|
|
101
|
+
bookingDuration,
|
|
102
|
+
bytesToHuman,
|
|
103
|
+
timeUntil,
|
|
104
|
+
} from "@updraft-solutions/mcrit-sdk/format";
|
|
105
|
+
|
|
106
|
+
formatDate("2026-05-15"); // "15.05.2026"
|
|
107
|
+
formatDateTime(booking.etdZ, "Europe/Berlin"); // "15.05.2026 09:00"
|
|
108
|
+
bookingDuration(booking); // "01:30"
|
|
109
|
+
bytesToHuman(1_572_864); // "1.5 MB"
|
|
110
|
+
timeUntil("2026-05-10T12:00:00Z", "de"); // "vor 5 Tagen" (needs moment de locale loaded)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Crew
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import {
|
|
117
|
+
CREW_ROLES,
|
|
118
|
+
ROLE_ATTRIBUTES,
|
|
119
|
+
PIC_PRIORITY,
|
|
120
|
+
isInstructorClassRole,
|
|
121
|
+
hasMembershipRole,
|
|
122
|
+
defaultCrewRoleForMembership,
|
|
123
|
+
} from "@updraft-solutions/mcrit-sdk/crew";
|
|
124
|
+
|
|
125
|
+
PIC_PRIORITY; // ['examiner', 'instructor', 'pilot', 'student']
|
|
126
|
+
isInstructorClassRole("authorizing_instructor"); // true
|
|
127
|
+
defaultCrewRoleForMembership(membership); // "student" | "instructor" | "pilot"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Mirrors the backend `App\Enums\CrewMemberPosition`. See
|
|
131
|
+
`m-crit-api/docs/features/crew-composition.md` for the full data model.
|
|
132
|
+
|
|
133
|
+
### Media
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import {
|
|
137
|
+
mapMediaConversions,
|
|
138
|
+
getMediaType,
|
|
139
|
+
getMediaIcon,
|
|
140
|
+
getMediaDisplayName,
|
|
141
|
+
} from "@updraft-solutions/mcrit-sdk/media";
|
|
142
|
+
|
|
143
|
+
const gallery = mapMediaConversions(model.media);
|
|
144
|
+
// [{ href, thumbnail, mediaType, icon, previewable, ... }]
|
|
145
|
+
|
|
146
|
+
getMediaType("application/pdf"); // "document"
|
|
147
|
+
getMediaIcon("video/mp4"); // "mdi-video"
|
|
148
|
+
|
|
149
|
+
getMediaDisplayName("image/png"); // "Bild" (German default)
|
|
150
|
+
getMediaDisplayName("image/png", { image: "Picture" }); // "Picture" (i18n override)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Pagination
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { paginationMeta } from "@updraft-solutions/mcrit-sdk/pagination";
|
|
157
|
+
|
|
158
|
+
paginationMeta({ page: 2, itemsPerPage: 25 }, 137);
|
|
159
|
+
// { start: 26, end: 50, total: 137 }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## API shapes vs `*Like` shapes
|
|
165
|
+
|
|
166
|
+
The SDK distinguishes two kinds of types:
|
|
167
|
+
|
|
168
|
+
- **Strict shapes** (`Fee`, `Price`, `Course`, `CourseFee`, ...) are
|
|
169
|
+
`z.infer<>` of the corresponding Zod schemas — what the API actually
|
|
170
|
+
returns when fully loaded.
|
|
171
|
+
- **Permissive `*Like` shapes** (`FeeLike`, `PriceLike`, `CourseLike`,
|
|
172
|
+
`CourseFeeLike`) are accepted by the helpers (`parseFee`,
|
|
173
|
+
`parseCourse`, `displayFee`, ...) so callers can pass partial or
|
|
174
|
+
legacy payload shapes without casting.
|
|
175
|
+
|
|
176
|
+
Use the strict types in your own code; rely on `*Like` only as input to
|
|
177
|
+
SDK helpers.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Local development
|
|
182
|
+
|
|
183
|
+
This repo is a normal pnpm package. From the SDK root:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
pnpm install
|
|
187
|
+
pnpm typecheck # tsc --noEmit
|
|
188
|
+
pnpm build # tsup → dist/{esm,cjs,d.ts}
|
|
189
|
+
pnpm dev # tsup --watch
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Inside the MCRIT monorepo
|
|
193
|
+
|
|
194
|
+
The MCRIT working folder (`mcrit-app`, `inflight-homepage-nuxt-3`, and
|
|
195
|
+
this repo as sibling git checkouts) uses a pnpm workspace at the parent
|
|
196
|
+
folder. With `workspace:*` (or any matching version range) in the
|
|
197
|
+
consuming app's `package.json`, pnpm symlinks this directory into the
|
|
198
|
+
consumer's `node_modules` — edits show up immediately, no publish loop.
|
|
199
|
+
|
|
200
|
+
If consumers use the `source` export condition (Vite via
|
|
201
|
+
`resolve.conditions: ['source']`), they read `src/*.ts` directly and skip
|
|
202
|
+
the `tsup` build entirely during dev.
|
|
203
|
+
|
|
204
|
+
### Adding a new helper
|
|
205
|
+
|
|
206
|
+
1. Pick or create the appropriate subpath under `src/` (e.g. `src/money/`).
|
|
207
|
+
2. Keep the implementation pure — no framework imports, no I/O.
|
|
208
|
+
3. Add the file to the `entry` list in `tsup.config.ts`.
|
|
209
|
+
4. Add the subpath under `exports` in `package.json` (mirror the existing
|
|
210
|
+
pattern: `source` + `import.types` + `import.default` + `require.types`
|
|
211
|
+
+ `require.default`).
|
|
212
|
+
5. Re-export from `src/index.ts` if it should also be reachable from the
|
|
213
|
+
root.
|
|
214
|
+
6. `pnpm typecheck && pnpm build` — both must be green.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Releases
|
|
219
|
+
|
|
220
|
+
Publishes are automated. Push a tag matching `v*` to trigger
|
|
221
|
+
`.github/workflows/publish.yml`:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
npm version patch # bumps package.json and creates v0.1.1 commit + tag
|
|
225
|
+
git push && git push --tags
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The workflow verifies the tag matches `package.json`, runs
|
|
229
|
+
`pnpm typecheck && pnpm build`, then publishes to npm with provenance.
|
|
230
|
+
|
|
231
|
+
The `NPM_TOKEN` GitHub repository secret must be configured (an
|
|
232
|
+
"Automation" or "Granular" token from npmjs.com with publish rights to
|
|
233
|
+
`@updraft-solutions/mcrit-sdk`).
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
UNLICENSED — internal Updraft Solutions package. Not for redistribution
|
|
240
|
+
outside MCRIT projects.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }// src/money/index.ts
|
|
2
|
+
var _dinerojs = require('dinero.js'); var _dinerojs2 = _interopRequireDefault(_dinerojs);
|
|
3
|
+
var _lodashes = require('lodash-es');
|
|
4
|
+
_dinerojs2.default.globalLocale = "de-DE";
|
|
5
|
+
function money(amount) {
|
|
6
|
+
try {
|
|
7
|
+
let parsed = amount;
|
|
8
|
+
if (parsed && typeof parsed === "object") {
|
|
9
|
+
const obj = parsed;
|
|
10
|
+
if (obj.value && typeof obj.value === "object" && typeof obj.value.amount !== "undefined") {
|
|
11
|
+
parsed = obj.value.amount;
|
|
12
|
+
} else if (typeof obj.amount !== "undefined") {
|
|
13
|
+
parsed = obj.amount;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const int = parseInt(String(_nullishCoalesce(parsed, () => ( "0"))), 10);
|
|
17
|
+
if (Number.isNaN(int)) {
|
|
18
|
+
throw new Error("Invalid amount");
|
|
19
|
+
}
|
|
20
|
+
return _dinerojs2.default.call(void 0, { amount: int, currency: "EUR" });
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error("[mcrit-sdk/money] failed to parse amount:", amount, e);
|
|
23
|
+
return _dinerojs2.default.call(void 0, { amount: 0, currency: "EUR" });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function moneyFormatted(dinero) {
|
|
27
|
+
return dinero ? dinero.toFormat() : "";
|
|
28
|
+
}
|
|
29
|
+
function parsePrice(price) {
|
|
30
|
+
if (!price) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
...price,
|
|
35
|
+
net_total: money(_nullishCoalesce(_optionalChain([price, 'access', _ => _.net_total, 'optionalAccess', _2 => _2.amount]), () => ( 0))),
|
|
36
|
+
gross_total: money(_nullishCoalesce(_optionalChain([price, 'access', _3 => _3.gross_total, 'optionalAccess', _4 => _4.amount]), () => ( 0)))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function priceFor(parsed, netGross) {
|
|
40
|
+
if (netGross === "net") {
|
|
41
|
+
return parsed.net_total;
|
|
42
|
+
}
|
|
43
|
+
if (netGross === "gross") {
|
|
44
|
+
return parsed.gross_total;
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
function parseFee(fee) {
|
|
49
|
+
if (!fee) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
currentPrice(netGross = null) {
|
|
54
|
+
const parsed = parsePrice(_nullishCoalesce(fee.price, () => ( null)));
|
|
55
|
+
return parsed ? priceFor(parsed, netGross) : null;
|
|
56
|
+
},
|
|
57
|
+
upcomingPrice(netGross = null) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const upcoming = _lodashes.head.call(void 0,
|
|
60
|
+
_lodashes.sortBy.call(void 0,
|
|
61
|
+
_lodashes.filter.call(void 0, _nullishCoalesce(fee.prices, () => ( [])), (p) => {
|
|
62
|
+
const t = p.valid_from ? Date.parse(p.valid_from) : NaN;
|
|
63
|
+
return Number.isFinite(t) && t > now;
|
|
64
|
+
}),
|
|
65
|
+
(p) => Date.parse(_nullishCoalesce(p.valid_from, () => ( "")))
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
if (!upcoming) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const parsed = parsePrice(upcoming);
|
|
72
|
+
return parsed ? priceFor(parsed, netGross) : null;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function displayFee(fee, netGross = "gross", fallback = "Keine Geb\xFChr hinterlegt") {
|
|
77
|
+
const current = _optionalChain([parseFee, 'call', _5 => _5(fee), 'optionalAccess', _6 => _6.currentPrice, 'call', _7 => _7(netGross)]);
|
|
78
|
+
if (current && "toFormat" in current) {
|
|
79
|
+
return current.toFormat();
|
|
80
|
+
}
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
function fromCents(amount) {
|
|
84
|
+
return _dinerojs2.default.call(void 0, { amount, currency: "EUR" });
|
|
85
|
+
}
|
|
86
|
+
function formatToEuroString(amount) {
|
|
87
|
+
const cents = parseInt(String(_nullishCoalesce(amount, () => ( "0"))), 10);
|
|
88
|
+
return `\u20AC ${fromCents(Number.isNaN(cents) ? 0 : cents).setLocale("de-DE").toFormat("0,0.00")}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
exports.money = money; exports.moneyFormatted = moneyFormatted; exports.parsePrice = parsePrice; exports.parseFee = parseFee; exports.displayFee = displayFee; exports.fromCents = fromCents; exports.formatToEuroString = formatToEuroString;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/crew/index.ts
|
|
2
|
+
function hasMembershipRole(membership, roleName) {
|
|
3
|
+
return membership?.roles?.some((r) => r.name === roleName) ?? false;
|
|
4
|
+
}
|
|
5
|
+
function defaultCrewRoleForMembership(membership) {
|
|
6
|
+
if (hasMembershipRole(membership, "student")) {
|
|
7
|
+
return "student";
|
|
8
|
+
}
|
|
9
|
+
if (hasMembershipRole(membership, "instructor")) {
|
|
10
|
+
return "instructor";
|
|
11
|
+
}
|
|
12
|
+
return "pilot";
|
|
13
|
+
}
|
|
14
|
+
function createCrewValidator(config) {
|
|
15
|
+
function attributesOf(role) {
|
|
16
|
+
return config.positions[role];
|
|
17
|
+
}
|
|
18
|
+
function rolesFromCrew(crew) {
|
|
19
|
+
return crew.map((c) => c.role).filter((r) => typeof r === "string");
|
|
20
|
+
}
|
|
21
|
+
function membershipsWithSystemRole(crew, systemRole) {
|
|
22
|
+
return crew.filter(
|
|
23
|
+
(c) => c.membership?.roles?.some(
|
|
24
|
+
(r) => r.name === systemRole && (r.company_id == null || r.company_id === null)
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
function evaluateConstraint(constraint, crew) {
|
|
29
|
+
switch (constraint.kind) {
|
|
30
|
+
case "single_pic": {
|
|
31
|
+
if (crew.length === 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const roles = rolesFromCrew(crew);
|
|
35
|
+
for (const eligibleRole of config.pic_priority) {
|
|
36
|
+
const matches = roles.filter((r) => r === eligibleRole).length;
|
|
37
|
+
if (matches === 1) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (matches > 1) {
|
|
41
|
+
return {
|
|
42
|
+
kind: constraint.kind,
|
|
43
|
+
message_key: "crew.multiple_same_pic_role",
|
|
44
|
+
details: { role: eligibleRole }
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
kind: constraint.kind,
|
|
50
|
+
message_key: "crew.no_eligible_pic"
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
case "min_members": {
|
|
54
|
+
if (crew.length >= constraint.min) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
kind: constraint.kind,
|
|
59
|
+
message_key: "crew.min_members",
|
|
60
|
+
details: { min: constraint.min, actual: crew.length }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
case "when_crew_contains_role_requires_one_of": {
|
|
64
|
+
const roles = rolesFromCrew(crew);
|
|
65
|
+
if (!roles.includes(constraint.when_role)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const satisfied = constraint.required_roles.some((r) => roles.includes(r));
|
|
69
|
+
if (satisfied) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
kind: constraint.kind,
|
|
74
|
+
message_key: constraint.message_key,
|
|
75
|
+
details: {
|
|
76
|
+
when_role: constraint.when_role,
|
|
77
|
+
required_roles: constraint.required_roles
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
case "when_any_membership_has_system_role_requires_one_of": {
|
|
82
|
+
const flagged = membershipsWithSystemRole(crew, constraint.system_role);
|
|
83
|
+
if (flagged.length === 0) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const roles = rolesFromCrew(crew);
|
|
87
|
+
const satisfied = constraint.required_roles.some((r) => roles.includes(r));
|
|
88
|
+
if (satisfied) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
kind: constraint.kind,
|
|
93
|
+
message_key: constraint.message_key,
|
|
94
|
+
details: {
|
|
95
|
+
system_role: constraint.system_role,
|
|
96
|
+
required_roles: constraint.required_roles,
|
|
97
|
+
membership_ids: flagged.map((c) => c.membership_id).filter((id) => id != null)
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
isOnBoardRole: (role) => attributesOf(role)?.isOnBoard ?? false,
|
|
105
|
+
isInstructorClassRole: (role) => attributesOf(role)?.isInstructorClass ?? false,
|
|
106
|
+
isPicEligibleRole: (role) => attributesOf(role)?.picPriority != null,
|
|
107
|
+
resolvePic(crew) {
|
|
108
|
+
if (crew.length === 0) {
|
|
109
|
+
return { type: "none" };
|
|
110
|
+
}
|
|
111
|
+
const roles = rolesFromCrew(crew);
|
|
112
|
+
for (const eligibleRole of config.pic_priority) {
|
|
113
|
+
const indexes = [];
|
|
114
|
+
roles.forEach((r, i) => {
|
|
115
|
+
if (r === eligibleRole) {
|
|
116
|
+
indexes.push(i);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
if (indexes.length === 1) {
|
|
120
|
+
return { type: "ok", index: indexes[0], role: eligibleRole };
|
|
121
|
+
}
|
|
122
|
+
if (indexes.length > 1) {
|
|
123
|
+
return { type: "tie", role: eligibleRole };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { type: "none" };
|
|
127
|
+
},
|
|
128
|
+
evaluate(crew) {
|
|
129
|
+
const violations = [];
|
|
130
|
+
for (const constraint of config.constraints) {
|
|
131
|
+
const violation = evaluateConstraint(constraint, crew);
|
|
132
|
+
if (violation !== null) {
|
|
133
|
+
violations.push(violation);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { ok: violations.length === 0, violations };
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export {
|
|
142
|
+
hasMembershipRole,
|
|
143
|
+
defaultCrewRoleForMembership,
|
|
144
|
+
createCrewValidator
|
|
145
|
+
};
|