@updraft-solutions/mcrit-sdk 0.11.2 → 0.13.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.
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fboDutySchema, fboFlightSchema } from "./fbo";
3
+
4
+ /**
5
+ * Fixtures are copied from the API's FlightResource / DutyResource shapes, not
6
+ * invented, so a schema that drifts from the payload fails here rather than at
7
+ * runtime in the app.
8
+ */
9
+ const flightPayload = {
10
+ id: 44796, type: "ppr", uuid: "0f1b3d5e-0000-4000-8000-000000000001", flight_status: "pending",
11
+ company_id: 34, author_id: null,
12
+ ac_call: "DEABC", ac_reg: null, ac_type: "C172", operator: "PrivateWings",
13
+ etdZ: "2026-09-01T20:00:00.000000Z", etdZ_no_time: false, etaZ: null, etaZ_no_time: false,
14
+ departure_airport_id: 2753, destination_airport_id: null,
15
+ ifr: false, wx: false, landings: 1, remarks: null,
16
+ contact: { name: "Erika Muster", email: "erika@example.test", phone: null, company: null, fax: null },
17
+ ground_services: { stairs: false, hangar: false, car_access: false, lifter: false, rental_car: false, refueling: false, gpu: false },
18
+ acceptance_status: {
19
+ fbo: { status: "pending", is_required: true, decided_by_id: null, updated_at: "2026-08-26T10:00:00.000000Z" },
20
+ airport: { status: "unknown", is_required: false, decided_by_id: null, updated_at: null },
21
+ customs: { status: "unknown", is_required: false, decided_by_id: null, updated_at: null },
22
+ },
23
+ created_at: "2026-08-26T10:00:00.000000Z", updated_at: "2026-08-26T10:00:00.000000Z",
24
+ };
25
+
26
+ describe("fboFlightSchema", () => {
27
+ it("parses a PPR exactly as FlightResource emits it", () => {
28
+ expect(fboFlightSchema.parse(flightPayload).type).toBe("ppr");
29
+ });
30
+
31
+ it("rejects a status outside AcceptanceStatusData's vocabulary", () => {
32
+ const bad = { ...flightPayload, acceptance_status: { ...flightPayload.acceptance_status, fbo: { ...flightPayload.acceptance_status.fbo, status: "idle" } } };
33
+ expect(() => fboFlightSchema.parse(bad)).toThrow();
34
+ });
35
+
36
+ it("rejects the flight-school type — the FBO domain never holds a 'booking'", () => {
37
+ expect(() => fboFlightSchema.parse({ ...flightPayload, type: "booking" })).toThrow();
38
+ });
39
+ });
40
+
41
+ describe("fboDutySchema", () => {
42
+ it("accepts a userless, occupation-less duty — 27% of the migrated roster has no user", () => {
43
+ expect(fboDutySchema.parse({
44
+ id: 1, company_id: 35, user_id: null, author_id: null, occupation_id: null, scheduled_flight_id: null,
45
+ start: "2026-09-01T18:00:00.000000Z", end: "2026-09-01T23:00:00.000000Z", all_day: false, remarks: null,
46
+ created_at: null, updated_at: null,
47
+ }).user_id).toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,126 @@
1
+ import { z } from "zod";
2
+ import { zContactDTO } from "./common";
3
+ import { airportSchema } from "./models";
4
+
5
+ /**
6
+ * FBO domain — flights (scheduled movements and PPRs), duties, occupations and
7
+ * the per-company FBO profile. Shapes mirror the API's FBO resources exactly:
8
+ * `FlightResource`, `DutyResource`, `OccupationResource`, and the `fbo` block on
9
+ * `CompanyResource`.
10
+ *
11
+ * Distinct from `scheduledFlightSchema` in models.ts, which predates the FBO
12
+ * domain and does not describe these payloads (it has `booking_id`,
13
+ * `departure_time`, `flight_number` — none of which the API emits here).
14
+ */
15
+
16
+ /** One acceptance axis. `status` mirrors `AcceptanceStatusData`'s `#[In]` rule. */
17
+ export const fboAcceptanceAxisSchema = z.object({
18
+ status: z.enum(["pending", "accepted", "rejected", "unknown"]),
19
+ is_required: z.boolean(),
20
+ decided_by_id: z.number().nullable(),
21
+ updated_at: z.string().nullable(),
22
+ });
23
+
24
+ export const fboAcceptanceStatusSchema = z.object({
25
+ fbo: fboAcceptanceAxisSchema,
26
+ airport: fboAcceptanceAxisSchema,
27
+ customs: fboAcceptanceAxisSchema,
28
+ });
29
+
30
+ export const fboGroundServicesSchema = z.object({
31
+ stairs: z.boolean(),
32
+ hangar: z.boolean(),
33
+ car_access: z.boolean(),
34
+ lifter: z.boolean(),
35
+ rental_car: z.boolean(),
36
+ refueling: z.boolean(),
37
+ gpu: z.boolean(),
38
+ });
39
+
40
+ export const fboFlightTypeSchema = z.enum(["scheduledFlight", "ppr"]);
41
+ export const fboFlightStatusSchema = z.enum(["active", "pending", "cancelled"]);
42
+
43
+ export const fboFlightSchema = z.object({
44
+ id: z.number(),
45
+ type: fboFlightTypeSchema,
46
+ uuid: z.string(),
47
+ flight_status: fboFlightStatusSchema,
48
+ company_id: z.number(),
49
+ author_id: z.number().nullable(),
50
+
51
+ ac_call: z.string().nullable(),
52
+ ac_reg: z.string().nullable(),
53
+ ac_type: z.string().nullable(),
54
+ operator: z.string().nullable(),
55
+
56
+ etdZ: z.string().nullable(),
57
+ /** The filer gave a date but no time; `etdZ` then holds a placeholder midnight. */
58
+ etdZ_no_time: z.boolean(),
59
+ etaZ: z.string().nullable(),
60
+ etaZ_no_time: z.boolean(),
61
+
62
+ departure_airport_id: z.number().nullable(),
63
+ departure_airport: airportSchema.optional(),
64
+ destination_airport_id: z.number().nullable(),
65
+ destination_airport: airportSchema.optional(),
66
+
67
+ ifr: z.boolean(),
68
+ wx: z.boolean(),
69
+ landings: z.number().nullable(),
70
+ remarks: z.string().nullable(),
71
+
72
+ contact: zContactDTO,
73
+ ground_services: fboGroundServicesSchema,
74
+ acceptance_status: fboAcceptanceStatusSchema,
75
+
76
+ created_at: z.string(),
77
+ updated_at: z.string(),
78
+ });
79
+
80
+ export const fboOccupationSchema = z.object({
81
+ id: z.number(),
82
+ company_id: z.number(),
83
+ author_id: z.number().nullable(),
84
+ title: z.string(),
85
+ description: z.string().nullable(),
86
+ is_absence: z.boolean(),
87
+ created_at: z.string().nullable(),
88
+ updated_at: z.string().nullable(),
89
+ });
90
+
91
+ export const fboDutySchema = z.object({
92
+ id: z.number(),
93
+ company_id: z.number().nullable(),
94
+ user_id: z.number().nullable(),
95
+ author_id: z.number().nullable(),
96
+ occupation_id: z.number().nullable(),
97
+ occupation: fboOccupationSchema.optional(),
98
+ scheduled_flight_id: z.number().nullable(),
99
+ start: z.string(),
100
+ end: z.string(),
101
+ all_day: z.boolean(),
102
+ remarks: z.string().nullable(),
103
+ created_at: z.string().nullable(),
104
+ updated_at: z.string().nullable(),
105
+ });
106
+
107
+ /** Served by `GET companies/{id}/fbo/meta` so no client hardcodes the list. */
108
+ export const fboRoleSchema = z.enum(["fbo", "atc", "wx", "customs", "handling", "fuel", "operator"]);
109
+
110
+ /** The `fbo` block on the company payload — present iff the company holds a profile. */
111
+ export const fboCompanyProfileSchema = z.object({
112
+ role: fboRoleSchema,
113
+ flights_require_approval: z.boolean(),
114
+ notify_on_movement: z.boolean(),
115
+ has_duties: z.boolean(),
116
+ has_airplanes: z.boolean(),
117
+ });
118
+
119
+ export const fboDecisionAxisSchema = z.enum(["fbo", "airport", "customs"]);
120
+ export const fboDecisionSchema = z.enum(["accepted", "rejected"]);
121
+
122
+ /** `POST companies/{id}/fbo/flights/{flight}/decision` */
123
+ export const fboDecisionInputSchema = z.object({
124
+ axis: fboDecisionAxisSchema,
125
+ status: fboDecisionSchema,
126
+ });
@@ -10,3 +10,4 @@ export * from "./equipment";
10
10
  export * from "./newsletter";
11
11
  export * from "./location-map";
12
12
  export * from "./todo";
13
+ export * from "./fbo";
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import {
3
3
  BaseModel,
4
+ companyIdentitySchema,
4
5
  companySchema,
5
6
  contactAttributeSchema,
6
7
  mediaCollection,
@@ -14,14 +15,32 @@ import {
14
15
  import { equipmentSchema } from "./equipment";
15
16
  import { courseCheckSyllabusSchema } from "./training";
16
17
 
17
- export const baseMembershipSchema = z.object({
18
+ /**
19
+ * Identity-only membership — the shape nested in list rows (server:
20
+ * `MembershipIdentityResource`). It answers "who is this" and nothing else:
21
+ * the full nesting carries the company payload, i.e. the requesting user's
22
+ * whole permission list, repeated on every row.
23
+ */
24
+ export const membershipIdentitySchema = z.object({
18
25
  type: z.literal("membership"),
19
26
  id: z.number(),
20
27
  user_id: z.number(),
21
28
  user: userSchema.optional(),
22
29
  company_id: z.number(),
23
- company: companySchema.nullable().optional(),
24
30
  name: z.string(),
31
+ });
32
+
33
+ /**
34
+ * The membership as nested in another model's payload: identity, plus the
35
+ * identity-only company for the surfaces that list rows across companies
36
+ * (pre-paid packages, course enrollments).
37
+ */
38
+ export const nestedMembershipSchema = membershipIdentitySchema.extend({
39
+ company: companyIdentitySchema.nullable().optional(),
40
+ });
41
+
42
+ export const baseMembershipSchema = membershipIdentitySchema.extend({
43
+ company: companyIdentitySchema.nullable().optional(),
25
44
  phone: z.string().nullable().optional(),
26
45
  status: z.string(),
27
46
  instructor: z.boolean(),
@@ -92,7 +111,7 @@ export const feeSchema = BaseModel.extend({
92
111
  prices: z.array(priceSchema).optional(),
93
112
  provider: z.string().nullable().optional(),
94
113
  company_id: z.number().optional(),
95
- company: companySchema.optional(),
114
+ company: companyIdentitySchema.optional(),
96
115
  ident: z.string().optional(),
97
116
  category: z.string().optional(),
98
117
  subcategory: z.string().nullable().optional(),
@@ -273,7 +292,7 @@ export const airplanePermissionSchema = z.object({
273
292
  airplane_id: z.number(),
274
293
  airplane: airplaneSchema.optional(),
275
294
  membership_id: z.number(),
276
- membership: baseMembershipSchema.optional(),
295
+ membership: nestedMembershipSchema.optional(),
277
296
  notes: z.string().nullable().optional(),
278
297
  conditions: z.string().nullable().optional(),
279
298
  expires_at: z.string().nullable().optional(),
@@ -404,7 +423,7 @@ export const extendedAirplaneSchema = airplaneSchema.extend({
404
423
  })
405
424
  .optional(),
406
425
  foreflight_profile_url: z.string().optional(),
407
- company: companySchema.optional(),
426
+ company: companyIdentitySchema.optional(),
408
427
  latestPosition: aircraftPositionSchema.nullable().optional(),
409
428
  total_flight_time_minutes: z.number().nullable().optional(),
410
429
  elapsed_time: z.number().nullable().optional(),
@@ -476,6 +495,9 @@ export const bookingSchema = BaseModel.extend({
476
495
  author: userSchema.optional(),
477
496
  company_id: z.number(),
478
497
  company_name: z.string().nullable().optional(),
498
+ // Single-booking payloads only (BookingDetailResource): the dialog gates
499
+ // on this company, which a shared aircraft can make a partner company.
500
+ // List rows never carry it.
479
501
  company: companySchema.optional(),
480
502
  airplane: airplaneSchema.optional(),
481
503
  etdZ: z.string(),
@@ -494,6 +516,9 @@ export const bookingSchema = BaseModel.extend({
494
516
  .enum(["draft", "awaiting_signatures", "signed"])
495
517
  .nullable()
496
518
  .optional(),
519
+ // Single-booking payloads only (BookingDetailResource): neighbour lookups
520
+ // cost two queries per booking and only the dialog's prev/next navigation
521
+ // uses them.
497
522
  next_booking_id: z.number().nullable().optional(),
498
523
  previous_booking_id: z.number().nullable().optional(),
499
524
  maintenance: z.number().optional(),
@@ -793,7 +818,7 @@ export const flightLogSchema = z.object({
793
818
  ac_call: z.string().nullable().optional(),
794
819
  booking_id: z.number().nullable().optional(),
795
820
  membership_id: z.number().nullable().optional(),
796
- membership: baseMembershipSchema.optional(),
821
+ membership: nestedMembershipSchema.optional(),
797
822
  billing_profile_id: z.number().nullable().optional(),
798
823
  author_id: z.number(),
799
824
  author: userSchema.optional(),
@@ -843,7 +868,10 @@ export const invoiceSchema = z.object({
843
868
  id: z.number(),
844
869
  title: z.string(),
845
870
  membership_id: z.number(),
846
- membership: baseMembershipSchema.optional(),
871
+ // List rows (InvoiceListResource) carry the identity-only membership; the
872
+ // single-invoice payload carries the full one. Typed as the identity shape —
873
+ // the guaranteed part — with the rest passed through.
874
+ membership: nestedMembershipSchema.optional(),
847
875
  // InvoiceResource always emits `billing_profile_id` (non-optional key,
848
876
  // nullable FK value). `billingProfile` is a `whenLoaded('billingProfile')`
849
877
  // relation, so it stays optional. (Fix round 1: as of the original Task 16
@@ -854,7 +882,12 @@ export const invoiceSchema = z.object({
854
882
  billingProfile: billingProfileSchema.optional(),
855
883
  company_id: z.number().optional(),
856
884
  company: companySchema.optional(),
885
+ // Numbering: invoices are numbered in `sequence_number`, receipts in
886
+ // `receipt_number`. `document_number` is whichever applies — render that.
887
+ document_type: z.string().nullable().optional(),
857
888
  sequence_number: z.string().nullable().optional(),
889
+ receipt_number: z.string().nullable().optional(),
890
+ document_number: z.string().nullable().optional(),
858
891
  order_number: z.string().nullable().optional(),
859
892
  invoice_date: z.string().nullable().optional(),
860
893
  due_date: z.string().nullable().optional(),
@@ -1054,7 +1087,7 @@ export const courseEnrollmentSchema = z.object({
1054
1087
  created_at: z.string(),
1055
1088
  updated_at: z.string(),
1056
1089
  course: courseSchema.optional(),
1057
- membership: membershipSchema.optional(),
1090
+ membership: nestedMembershipSchema.optional(),
1058
1091
  mentor: userSchema.optional(),
1059
1092
  // Present when eager-loaded (BaseCourseEnrollmentResource). Lazy: both
1060
1093
  // schemas are declared later in this file; there is no true cycle.
@@ -1098,7 +1131,7 @@ export const eventSchema = z.object({
1098
1131
  type: z.literal("event"),
1099
1132
  id: z.number(),
1100
1133
  company_id: z.number(),
1101
- company: companySchema.optional(),
1134
+ company: companyIdentitySchema.optional(),
1102
1135
  title: z.string(),
1103
1136
  description: z.string().nullable().optional(),
1104
1137
  media: mediaCollection,
@@ -1427,7 +1460,7 @@ export const expenseItemSchema = z.object({
1427
1460
  // maximum length"). No true cycle: invoiceSchema does not reference
1428
1461
  // expenseItemSchema back.
1429
1462
  invoice: z.lazy((): z.ZodTypeAny => invoiceSchema).optional(),
1430
- membership: baseMembershipSchema.optional(),
1463
+ membership: nestedMembershipSchema.optional(),
1431
1464
  prePaidPackageAssignments: z
1432
1465
  .array(prePaidPackageAssignmentSchema)
1433
1466
  .optional(),
@@ -0,0 +1,16 @@
1
+ import type { z } from "zod";
2
+ import type * as Fbo from "../schemas/fbo";
3
+
4
+ export type FboAcceptanceAxis = z.infer<typeof Fbo.fboAcceptanceAxisSchema>;
5
+ export type FboAcceptanceStatus = z.infer<typeof Fbo.fboAcceptanceStatusSchema>;
6
+ export type FboGroundServices = z.infer<typeof Fbo.fboGroundServicesSchema>;
7
+ export type FboFlightType = z.infer<typeof Fbo.fboFlightTypeSchema>;
8
+ export type FboFlightStatus = z.infer<typeof Fbo.fboFlightStatusSchema>;
9
+ export type FboFlight = z.infer<typeof Fbo.fboFlightSchema>;
10
+ export type FboOccupation = z.infer<typeof Fbo.fboOccupationSchema>;
11
+ export type FboDuty = z.infer<typeof Fbo.fboDutySchema>;
12
+ export type FboRole = z.infer<typeof Fbo.fboRoleSchema>;
13
+ export type FboCompanyProfile = z.infer<typeof Fbo.fboCompanyProfileSchema>;
14
+ export type FboDecisionAxis = z.infer<typeof Fbo.fboDecisionAxisSchema>;
15
+ export type FboDecision = z.infer<typeof Fbo.fboDecisionSchema>;
16
+ export type FboDecisionInput = z.infer<typeof Fbo.fboDecisionInputSchema>;
@@ -7,3 +7,4 @@ export * from "./ai";
7
7
  export * from "./roles";
8
8
  export * from "./landing-fee";
9
9
  export * from "./operating-hours";
10
+ export * from "./fbo";
File without changes
File without changes