@updraft-solutions/mcrit-sdk 0.14.11 → 0.14.13

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.
@@ -1,15 +1,22 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  fboActivitySchema,
4
+ fboCompanyGrantSchema,
4
5
  fboCompanyMemberSchema,
6
+ fboCompanySchema,
5
7
  fboDutyAffectedSchema,
6
8
  fboDutySchema,
7
9
  fboDutySeriesResponseSchema,
10
+ fboFlightDocumentSchema,
8
11
  fboFlightSchema,
9
12
  fboFlightStatusSchema,
13
+ fboGrantAbilitySchema,
10
14
  fboMaterialiseResultSchema,
15
+ fboMetaSchema,
11
16
  fboOccupationSchema,
12
17
  fboOperationsSchema,
18
+ fboPersonSchema,
19
+ fboPobSchema,
13
20
  fboRecurrenceSchema,
14
21
  fboRecurringFlightSchema,
15
22
  fboStatisticsSchema,
@@ -361,3 +368,224 @@ describe("fboMaterialiseResultSchema", () => {
361
368
  expect(fboMaterialiseResultSchema.parse(payload)).toEqual(payload);
362
369
  });
363
370
  });
371
+
372
+ const pobPayload = {
373
+ crew: true,
374
+ firstname: "Erika",
375
+ lastname: "Muster",
376
+ birth: "1979-04-02",
377
+ nationality: "DE",
378
+ document_id: "C01X00T47",
379
+ document_validity: "2031-04-01",
380
+ };
381
+
382
+ describe("fboPobSchema", () => {
383
+ it("parses a fully filled person on board", () => {
384
+ expect(fboPobSchema.parse(pobPayload).nationality).toBe("DE");
385
+ });
386
+
387
+ it("parses a person whose travel document is not known yet — every field but the names is optional data", () => {
388
+ const sparse = { ...pobPayload, birth: null, nationality: null, document_id: null, document_validity: null };
389
+ expect(fboPobSchema.parse(sparse).document_id).toBeNull();
390
+ });
391
+
392
+ it("rejects a missing lastname — the API requires both names on every entry", () => {
393
+ const { lastname, ...withoutLastname } = pobPayload;
394
+ expect(fboPobSchema.safeParse(withoutLastname).success).toBe(false);
395
+ });
396
+
397
+ it("rejects crew as a string — the flag decides which table half the declaration lists the person in", () => {
398
+ expect(fboPobSchema.safeParse({ ...pobPayload, crew: "1" }).success).toBe(false);
399
+ });
400
+ });
401
+
402
+ describe("fboPersonSchema", () => {
403
+ const personPayload = { ...pobPayload, id: 143, is_mine: true, created_at: "2026-08-26T10:00:00.000000Z" };
404
+
405
+ it("parses an address-book row the caller owns", () => {
406
+ const parsed = fboPersonSchema.parse(personPayload);
407
+ expect(parsed.id).toBe(143);
408
+ expect(parsed.is_mine).toBe(true);
409
+ });
410
+
411
+ it("parses a row visible only through the company, not owned by the caller", () => {
412
+ expect(fboPersonSchema.parse({ ...personPayload, is_mine: false }).is_mine).toBe(false);
413
+ });
414
+
415
+ it("rejects a row without an id — a template must be addressable to be edited or deleted", () => {
416
+ const { id, ...withoutId } = personPayload;
417
+ expect(fboPersonSchema.safeParse(withoutId).success).toBe(false);
418
+ });
419
+ });
420
+
421
+ const documentPayload = {
422
+ id: 9001,
423
+ uuid: "6f1b3d5e-0000-4000-8000-000000000042",
424
+ name: "General Declaration",
425
+ file_name: "gendec.pdf",
426
+ mime_type: "application/pdf",
427
+ size: 84213,
428
+ collection_name: "documents",
429
+ ima_file_id: null,
430
+ download_path: "/companies/34/fbo/flights/44796/documents/9001",
431
+ created_at: "2026-08-26T10:00:00.000000Z",
432
+ };
433
+
434
+ describe("fboFlightDocumentSchema", () => {
435
+ it("parses an uploaded movement document", () => {
436
+ expect(fboFlightDocumentSchema.parse(documentPayload).download_path).toBe(
437
+ "/companies/34/fbo/flights/44796/documents/9001",
438
+ );
439
+ });
440
+
441
+ it("parses an imported row whose source id is stamped as text", () => {
442
+ expect(fboFlightDocumentSchema.parse({ ...documentPayload, ima_file_id: "104" }).ima_file_id).toBe("104");
443
+ });
444
+
445
+ it("rejects a size sent as a string — the list renders it as a byte count", () => {
446
+ expect(fboFlightDocumentSchema.safeParse({ ...documentPayload, size: "84213" }).success).toBe(false);
447
+ });
448
+ });
449
+
450
+ describe("fboFlightSchema — POB, documents and the customs mail stamp", () => {
451
+ it("parses a movement whose payload carries none of the three keys — a colleague never receives POB", () => {
452
+ const parsed = fboFlightSchema.parse(flightPayload);
453
+ expect(parsed.pobs).toBeUndefined();
454
+ expect(parsed.documents_count).toBeUndefined();
455
+ expect(parsed.customs_mail_sent_at).toBeUndefined();
456
+ });
457
+
458
+ it("parses the author's own row: the POB list, the document count and an unsent customs mail", () => {
459
+ const own = { ...flightPayload, pobs: [pobPayload], documents_count: 2, customs_mail_sent_at: null };
460
+ const parsed = fboFlightSchema.parse(own);
461
+ expect(parsed.pobs).toHaveLength(1);
462
+ expect(parsed.documents_count).toBe(2);
463
+ expect(parsed.customs_mail_sent_at).toBeNull();
464
+ });
465
+
466
+ it("parses pobs: null — a movement whose list was never filled, or has been purged by retention", () => {
467
+ expect(fboFlightSchema.parse({ ...flightPayload, pobs: null }).pobs).toBeNull();
468
+ });
469
+
470
+ it("parses a stamped customs mail timestamp", () => {
471
+ const sent = { ...flightPayload, customs_mail_sent_at: "2026-08-28T09:15:00.000000Z" };
472
+ expect(fboFlightSchema.parse(sent).customs_mail_sent_at).toBe("2026-08-28T09:15:00.000000Z");
473
+ });
474
+
475
+ it("rejects a POB entry missing its names inside an otherwise valid movement", () => {
476
+ const bad = { ...flightPayload, pobs: [{ crew: false, firstname: "Erika" }] };
477
+ expect(fboFlightSchema.safeParse(bad).success).toBe(false);
478
+ });
479
+ });
480
+
481
+ /**
482
+ * Fixtures copied from `CompanyGrantResource`, the FBO meta endpoint and the
483
+ * attached-companies index, so a schema that drifts from the payload fails
484
+ * here rather than at runtime in the app.
485
+ */
486
+ const grantPayload = {
487
+ id: 12,
488
+ grantee: { id: 34, name: "Alpha Handling" },
489
+ target: { id: 35, name: "Bravo Charter" },
490
+ ability: "duties.view",
491
+ ability_label: "Dienstpläne einsehen",
492
+ granted_by: { id: 7, name: "Erika Muster" },
493
+ created_at: "2026-08-29T09:00:00.000000Z",
494
+ can: { delete: true },
495
+ };
496
+
497
+ describe("fboCompanyGrantSchema", () => {
498
+ it("parses a grant exactly as the register emits it", () => {
499
+ expect(fboCompanyGrantSchema.parse(grantPayload).ability).toBe("duties.view");
500
+ });
501
+
502
+ it("parses a migrated grant nobody signed — granted_by is null", () => {
503
+ expect(fboCompanyGrantSchema.parse({ ...grantPayload, granted_by: null }).granted_by).toBeNull();
504
+ });
505
+
506
+ it("parses a side whose company left the FBO domain — the ref is null, not missing", () => {
507
+ expect(fboCompanyGrantSchema.parse({ ...grantPayload, target: null }).target).toBeNull();
508
+ });
509
+
510
+ it("rejects an ability outside the register's vocabulary", () => {
511
+ expect(fboCompanyGrantSchema.safeParse({ ...grantPayload, ability: "flights.view" }).success).toBe(false);
512
+ });
513
+
514
+ it("rejects a row without its ability label — the UI has no copy of its own", () => {
515
+ const { ability_label: _label, ...withoutLabel } = grantPayload;
516
+ expect(fboCompanyGrantSchema.safeParse(withoutLabel).success).toBe(false);
517
+ });
518
+ });
519
+
520
+ describe("fboGrantAbilitySchema", () => {
521
+ it("accepts the one ability the register knows", () => {
522
+ expect(fboGrantAbilitySchema.parse("duties.view")).toBe("duties.view");
523
+ });
524
+
525
+ it("rejects an ability the client cannot render", () => {
526
+ expect(fboGrantAbilitySchema.safeParse("traffic.details").success).toBe(false);
527
+ });
528
+ });
529
+
530
+ describe("fboMetaSchema", () => {
531
+ const metaPayload = {
532
+ roles: ["fbo", "operator"],
533
+ root_id: 34,
534
+ can_decide: true,
535
+ can_view_own_flights: false,
536
+ grant_abilities: [{ value: "duties.view", label: "View duty rosters" }],
537
+ can_manage_grants: true,
538
+ companies: [{ id: 34, name: "Alpha Handling" }],
539
+ };
540
+
541
+ it("parses the meta payload as the API emits it", () => {
542
+ const parsed = fboMetaSchema.parse(metaPayload);
543
+ expect(parsed.grant_abilities?.[0]?.value).toBe("duties.view");
544
+ expect(parsed.can_manage_grants).toBe(true);
545
+ });
546
+
547
+ it("parses a payload from an API that predates the grant register — only roles is required", () => {
548
+ const parsed = fboMetaSchema.parse({ roles: [] });
549
+ expect(parsed.grant_abilities).toBeUndefined();
550
+ expect(parsed.can_view_own_flights).toBeUndefined();
551
+ });
552
+
553
+ it("rejects a grant ability option the client cannot render", () => {
554
+ const bad = { ...metaPayload, grant_abilities: [{ value: "duties.edit", label: "Edit" }] };
555
+ expect(fboMetaSchema.safeParse(bad).success).toBe(false);
556
+ });
557
+ });
558
+
559
+ describe("fboCompanySchema", () => {
560
+ const companyPayload = {
561
+ id: 35,
562
+ name: "Bravo Charter",
563
+ email: "ops@bravo.test",
564
+ phone: null,
565
+ description: null,
566
+ contact_name: "Erika Muster",
567
+ companyType: "interface",
568
+ is_ima_imported: true,
569
+ fbo: { role: "operator", flights_require_approval: true, notify_on_movement: false, has_duties: true, has_airplanes: false },
570
+ members_count: 4,
571
+ movements_count: 92,
572
+ grants_out_count: 1,
573
+ grants_in_count: 0,
574
+ can: { update: true, updateIdentity: true },
575
+ };
576
+
577
+ it("parses an attached company with both grant counts", () => {
578
+ const parsed = fboCompanySchema.parse(companyPayload);
579
+ expect(parsed.grants_out_count).toBe(1);
580
+ expect(parsed.grants_in_count).toBe(0);
581
+ });
582
+
583
+ it("parses a payload from an API that does not count grants yet", () => {
584
+ const { grants_out_count: _out, grants_in_count: _in, ...withoutCounts } = companyPayload;
585
+ expect(fboCompanySchema.parse(withoutCounts).grants_out_count).toBeUndefined();
586
+ });
587
+
588
+ it("parses a company with no FBO profile at all", () => {
589
+ expect(fboCompanySchema.parse({ ...companyPayload, fbo: null }).fbo).toBeNull();
590
+ });
591
+ });
@@ -64,6 +64,62 @@ export const fboRowCanSchema = z.object({
64
64
  delete: z.boolean(),
65
65
  });
66
66
 
67
+ /**
68
+ * One person on board a movement — the per-element shape of the flight's
69
+ * `pobs` list and, minus the row identity, of an address-book person.
70
+ * `birth`/`document_validity` are plain `Y-m-d` dates (no time); `nationality`
71
+ * is an uppercase ISO-3166-1 alpha-2 code. Every field but the names is
72
+ * optional data a filer may not have to hand yet.
73
+ *
74
+ * Passport data: it is only ever in a payload the API decided the caller may
75
+ * see (the movement's own author, or a root decider) — never cached to disk
76
+ * on the client.
77
+ */
78
+ export const fboPobSchema = z.object({
79
+ /** Crew member rather than passenger — the customs declaration lists the two apart. */
80
+ crew: z.boolean(),
81
+ firstname: z.string(),
82
+ lastname: z.string(),
83
+ birth: z.string().nullable(),
84
+ nationality: z.string().nullable(),
85
+ document_id: z.string().nullable(),
86
+ document_validity: z.string().nullable(),
87
+ });
88
+
89
+ /**
90
+ * An address-book person — a saved template the POB editor fills a row from,
91
+ * so a document number never has to be retyped. Same field set as
92
+ * `fboPobSchema` plus the row's own identity; `is_mine` says whether the
93
+ * CALLER owns it (theirs to edit or delete) rather than it merely being
94
+ * visible through the company they are filing at.
95
+ */
96
+ export const fboPersonSchema = fboPobSchema.extend({
97
+ id: z.number(),
98
+ is_mine: z.boolean(),
99
+ created_at: z.string().nullable(),
100
+ });
101
+
102
+ /**
103
+ * A document hanging on a movement — General Declaration, passports, customs
104
+ * paperwork. `download_path` is a RELATIVE API path, never a fetchable file
105
+ * URL: the bytes live on a private disk and are streamed back through the
106
+ * authorised endpoint, so every fetch re-runs the caller's authority.
107
+ * `ima_file_id` is set only on the rows imported from the predecessor system,
108
+ * and is a STRING there — the importer stamps the source id as text.
109
+ */
110
+ export const fboFlightDocumentSchema = z.object({
111
+ id: z.number(),
112
+ uuid: z.string().nullable(),
113
+ name: z.string(),
114
+ file_name: z.string(),
115
+ mime_type: z.string().nullable(),
116
+ size: z.number(),
117
+ collection_name: z.string(),
118
+ ima_file_id: z.string().nullable().optional(),
119
+ download_path: z.string().nullable(),
120
+ created_at: z.string().nullable(),
121
+ });
122
+
67
123
  export const fboFlightSchema = z.object({
68
124
  id: z.number(),
69
125
  type: fboFlightTypeSchema,
@@ -104,6 +160,21 @@ export const fboFlightSchema = z.object({
104
160
  batch_uuid: z.string().nullable(),
105
161
  /** Count of live rows sharing `batch_uuid`; `1` when `batch_uuid` is null. */
106
162
  legs_count: z.number(),
163
+ /**
164
+ * Persons on board. Own-row tier, stricter than `fbo_notes`: the key is
165
+ * present only for the movement's own AUTHOR or a root decider — a
166
+ * colleague reading the same row never receives it. `.optional()` covers
167
+ * the omitted key, `.nullable()` a movement whose list was never filled
168
+ * (or has been purged by retention).
169
+ */
170
+ pobs: z.array(fboPobSchema).nullable().optional(),
171
+ /**
172
+ * How many documents hang on the movement. The files themselves are never
173
+ * listed here — they are fetched, and authorised, one endpoint deeper.
174
+ */
175
+ documents_count: z.number().nullable().optional(),
176
+ /** When the customs pre-declaration was last mailed; `null` while it never was. */
177
+ customs_mail_sent_at: z.string().nullable().optional(),
107
178
  /**
108
179
  * Root tier — present only when the requesting user holds `fbo.flight.decide`
109
180
  * for the row's company; absent (key omitted) for everyone else, including
@@ -365,3 +436,102 @@ export const fboMaterialiseResultSchema = z.object({
365
436
  skipped_existing: z.number(),
366
437
  skipped_closed: z.number(),
367
438
  });
439
+
440
+ /**
441
+ * What one company may exercise on another company's data inside the same FBO
442
+ * tree — the vocabulary of the root's grant register. Mirrors the API enum
443
+ * exactly; a value outside it means the register grew an ability this client
444
+ * does not know how to render, and failing here is the point.
445
+ */
446
+ export const fboGrantAbilitySchema = z.enum(["duties.view"]);
447
+
448
+ /**
449
+ * One entry of `GET fbo/meta`'s `grant_abilities` — the ability vocabulary
450
+ * with the label the API already translated, so no client hardcodes copy for
451
+ * an ability it may not know.
452
+ */
453
+ export const fboGrantAbilityOptionSchema = z.object({
454
+ value: fboGrantAbilitySchema,
455
+ label: z.string(),
456
+ });
457
+
458
+ /**
459
+ * A company side of a grant. Rendered as `{id, name}` rather than a full
460
+ * company payload: a grant is a relation between two companies, not a view of
461
+ * either. Null only when that company stopped taking part in the FBO domain.
462
+ */
463
+ export const fboGrantCompanyRefSchema = z.object({
464
+ id: z.number(),
465
+ name: z.string().nullable(),
466
+ });
467
+
468
+ /**
469
+ * One row of the root's company-grant register — `CompanyGrantResource`.
470
+ * `granted_by` is null for a grant the migration wrote rather than a person.
471
+ * `can.delete` is always true on a row that reached the client at all (only
472
+ * `fbo.company.manage` at the tree root can read the register), but it is
473
+ * read as a gate like every other row's, never assumed.
474
+ */
475
+ export const fboCompanyGrantSchema = z.object({
476
+ id: z.number(),
477
+ grantee: fboGrantCompanyRefSchema.nullable(),
478
+ target: fboGrantCompanyRefSchema.nullable(),
479
+ ability: fboGrantAbilitySchema,
480
+ ability_label: z.string(),
481
+ granted_by: z.object({ id: z.number(), name: z.string() }).nullable(),
482
+ created_at: z.string().nullable(),
483
+ can: z.object({ delete: z.boolean() }).optional(),
484
+ });
485
+
486
+ /** `POST companies/{id}/fbo/company-grants` — the register's write shape. */
487
+ export const fboCompanyGrantInputSchema = z.object({
488
+ grantee_company_id: z.number(),
489
+ target_company_id: z.number(),
490
+ ability: fboGrantAbilitySchema,
491
+ });
492
+
493
+ /**
494
+ * `GET companies/{id}/fbo/meta` — the FBO vocabulary and the caller's
495
+ * root-evaluated capabilities, served so no client hardcodes either.
496
+ *
497
+ * Every key but `roles` is optional: the endpoint is the one surface an
498
+ * own-author filer can reach before anything else is known, and it has grown
499
+ * keys over time — a client pinned to an older API must still parse it.
500
+ *
501
+ * `can_view_own_flights` is the TIER, not the bare permission: true only when
502
+ * this caller's sight of movements is the own-author one (false for a decider
503
+ * or an operator, who hold the wider `fbo.flight.viewAny`).
504
+ */
505
+ export const fboMetaSchema = z.object({
506
+ roles: z.array(z.string()),
507
+ root_id: z.number().nullable().optional(),
508
+ can_decide: z.boolean().optional(),
509
+ can_view_own_flights: z.boolean().optional(),
510
+ grant_abilities: z.array(fboGrantAbilityOptionSchema).optional(),
511
+ can_manage_grants: z.boolean().optional(),
512
+ companies: z.array(fboGrantCompanyRefSchema).optional(),
513
+ });
514
+
515
+ /**
516
+ * A company attached to the FBO as its root's admin reads it —
517
+ * `CompanyResource` under `GET companies/{fbo}/fbo/companies`. The three
518
+ * counts are aggregates the index adds (`withCount`) and are null on any
519
+ * payload that did not compute them; `grants_out_count`/`grants_in_count`
520
+ * are additionally optional, being newer than the surface itself.
521
+ */
522
+ export const fboCompanySchema = z.object({
523
+ id: z.number(),
524
+ name: z.string(),
525
+ email: z.string().nullable(),
526
+ phone: z.string().nullable(),
527
+ description: z.string().nullable(),
528
+ contact_name: z.string().nullable(),
529
+ companyType: z.string(),
530
+ is_ima_imported: z.boolean(),
531
+ fbo: fboCompanyProfileSchema.nullable(),
532
+ members_count: z.number().nullable(),
533
+ movements_count: z.number().nullable(),
534
+ grants_out_count: z.number().nullable().optional(),
535
+ grants_in_count: z.number().nullable().optional(),
536
+ can: z.object({ update: z.boolean(), updateIdentity: z.boolean() }),
537
+ });
package/src/types/fbo.ts CHANGED
@@ -7,6 +7,9 @@ export type FboGroundServices = z.infer<typeof Fbo.fboGroundServicesSchema>;
7
7
  export type FboFlightType = z.infer<typeof Fbo.fboFlightTypeSchema>;
8
8
  export type FboFlightStatus = z.infer<typeof Fbo.fboFlightStatusSchema>;
9
9
  export type FboOperations = z.infer<typeof Fbo.fboOperationsSchema>;
10
+ export type FboPob = z.infer<typeof Fbo.fboPobSchema>;
11
+ export type FboPerson = z.infer<typeof Fbo.fboPersonSchema>;
12
+ export type FboFlightDocument = z.infer<typeof Fbo.fboFlightDocumentSchema>;
10
13
  export type FboFlight = z.infer<typeof Fbo.fboFlightSchema>;
11
14
  export type FboOccupation = z.infer<typeof Fbo.fboOccupationSchema>;
12
15
  export type FboDuty = z.infer<typeof Fbo.fboDutySchema>;
@@ -33,3 +36,10 @@ export type FboActivityChanges = z.infer<typeof Fbo.fboActivityChangesSchema>;
33
36
  export type FboActivity = z.infer<typeof Fbo.fboActivitySchema>;
34
37
  export type FboRecurringFlight = z.infer<typeof Fbo.fboRecurringFlightSchema>;
35
38
  export type FboMaterialiseResult = z.infer<typeof Fbo.fboMaterialiseResultSchema>;
39
+ export type FboGrantAbility = z.infer<typeof Fbo.fboGrantAbilitySchema>;
40
+ export type FboGrantAbilityOption = z.infer<typeof Fbo.fboGrantAbilityOptionSchema>;
41
+ export type FboGrantCompanyRef = z.infer<typeof Fbo.fboGrantCompanyRefSchema>;
42
+ export type FboCompanyGrant = z.infer<typeof Fbo.fboCompanyGrantSchema>;
43
+ export type FboCompanyGrantInput = z.infer<typeof Fbo.fboCompanyGrantInputSchema>;
44
+ export type FboMeta = z.infer<typeof Fbo.fboMetaSchema>;
45
+ export type FboCompany = z.infer<typeof Fbo.fboCompanySchema>;