@madebylars.com/mbl-order 0.1.0 → 1.0.1

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
@@ -17,6 +17,7 @@ Handles the full lifecycle: **Quote → Order → Dispatch → Delivery → Invo
17
17
 
18
18
  - **Two order types** — transport (A→B jobs) and storage (recurring warehouse rental)
19
19
  - **Full status lifecycle** — per order type, enforced via Supabase RLS
20
+ - **Multitenancy** — tenant isolation at the DB level via RLS; `tenant_id` resolved from JWT only, never from client input
20
21
  - **Role-based access** — customer / driver / dispatcher / admin extending `mbl-auth` RBAC
21
22
  - **Invoice generation** — draft invoices with VAT line items; accounting export is a future phase
22
23
  - **Fleet view** — active drivers with last-known GPS position (integrates with `mbl-whereabout`)
@@ -71,20 +72,24 @@ export default defineNuxtConfig({
71
72
  })
72
73
  ```
73
74
 
74
- ### 3. Run the database migration
75
+ ### 3. Run the database migrations
75
76
 
76
- Apply `src/runtime/server/migrations/001_transport_schema.sql` to your Supabase project.
77
+ Apply the two migration files to your Supabase project in order:
77
78
 
78
- **Via Supabase CLI:**
79
- ```bash
80
- supabase db push
81
79
  ```
80
+ src/runtime/server/migrations/001_transport_schema.sql ← tables, RLS, indexes
81
+ src/runtime/server/migrations/002_add_multitenancy.sql ← tenants, JWT hook, tenant isolation
82
+ ```
83
+
84
+ **Via Supabase SQL editor:** paste each file and run it in sequence.
85
+
86
+ After running `002_add_multitenancy.sql` you must also **register the Custom Access Token Hook** in the Supabase dashboard:
82
87
 
83
- **Or paste directly** into the Supabase SQL editor.
88
+ > **Authentication Hooks Custom Access Token** → select `public.custom_access_token_hook`
84
89
 
85
- The migration creates a dedicated `transport` schema with seven tables, indexes, RLS policies, and a role helper function. It is safe to run on a fresh project; it will fail if the schema already exists.
90
+ The hook injects `tenant_id` and `user_role` as top-level JWT claims. All RLS policies depend on these claims without the hook registered, no data will be accessible (fail closed by design).
86
91
 
87
- > **Important:** The RLS policies rely on `mbl-auth` storing the user role in `auth.users.app_metadata` as `{ "role": "customer" | "driver" | "dispatcher" | "admin" }`. Confirm this matches your `mbl-auth` setup before running.
92
+ > **Note on roles:** `user_role` is the JWT claim name (not `role`, which is reserved by Supabase). The fallback chain is: hook claim `user_role` in `app_metadata` → `'customer'`.
88
93
 
89
94
  ---
90
95
 
@@ -144,14 +149,26 @@ const { data: fleet } = await useFleet()
144
149
  const { data: orders } = await useDriverOrders(driverId)
145
150
  ```
146
151
 
152
+ ### Tenant
153
+
154
+ ```ts
155
+ const { data: tenant, tenantId, pending, error } = useTenant()
156
+ // tenant — full Tenant record (name, slug, locale, currency, vatStandard, …)
157
+ // tenantId — decoded from JWT access_token (never from URL or localStorage)
158
+ ```
159
+
160
+ `tenantId` is a computed ref that re-evaluates when the Supabase session changes. All data composables watch it and re-fetch automatically on login/logout.
161
+
147
162
  ### Locale
148
163
 
149
164
  ```ts
150
- const { t, formatCurrency, formatDate, locale } = useOrderLocale()
165
+ const { t, formatCurrency, formatDate, locale, setLocale } = useOrderLocale()
151
166
 
152
167
  t('mblOrder.status.in_transit') // → 'In transit' or 'Under transport'
153
168
  formatCurrency(1250) // → '£1,250.00' or '1 250,00 kr'
154
169
  formatDate('2026-06-10') // → '10 June 2026' or '10 juni 2026'
170
+
171
+ setLocale('sv') // switch locale for the session
155
172
  ```
156
173
 
157
174
  ### KPIs (for `mbl-graph`)
@@ -203,7 +220,7 @@ Supports periodic invoice generation (monthly or custom interval via `periodicBi
203
220
  | `dispatcher` | Assign jobs, manage queue, live fleet view |
204
221
  | `admin` | Full access, invoice generation, KPIs |
205
222
 
206
- Roles extend the `mbl-auth` RBAC system. The JWT `app_metadata.role` field is used by Supabase RLS to enforce access at the database level.
223
+ Roles extend the `mbl-auth` RBAC system. The `user_role` JWT claim (injected by the Custom Access Token Hook) is used by Supabase RLS to enforce access at the database level. The claim is a top-level JWT field — not nested under `app_metadata`.
207
224
 
208
225
  ---
209
226
 
@@ -231,7 +248,14 @@ If `@nuxtjs/i18n` is absent, use `useOrderLocale()` directly — it works standa
231
248
 
232
249
  ## Database schema
233
250
 
234
- Seven tables in the `transport` Supabase schema:
251
+ **Tenant schema (multitenancy):**
252
+
253
+ | Table | Purpose |
254
+ |---|---|
255
+ | `tenant.tenants` | Tenant registry — name, slug, locale, currency, VAT rate |
256
+ | `tenant.tenant_users` | Links `auth.users` to a tenant with a transport role |
257
+
258
+ **Transport schema (order data):**
235
259
 
236
260
  | Table | Purpose |
237
261
  |---|---|
@@ -243,7 +267,7 @@ Seven tables in the `transport` Supabase schema:
243
267
  | `transport.drivers` | Driver profiles linked to auth users |
244
268
  | `transport.vehicles` | Vehicle registry |
245
269
 
246
- All tables have RLS enabled. See the migration file for full column definitions, indexes, and policies.
270
+ All transport tables carry a `tenant_id` column. RLS policies are fail-closed — a missing or unrecognised `tenant_id` in the JWT denies all access. See the migration files for full column definitions, indexes, and policies.
247
271
 
248
272
  ---
249
273
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mbl-order",
3
3
  "configKey": "mblOrder",
4
- "version": "0.1.0",
4
+ "version": "1.0.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -33,8 +33,8 @@ const module$1 = defineNuxtModule({
33
33
  register({
34
34
  langDir: resolver.resolve("./runtime/locales"),
35
35
  locales: [
36
- { code: "en", file: "en.ts" },
37
- { code: "sv", file: "sv.ts" }
36
+ { code: "en", file: "en.js" },
37
+ { code: "sv", file: "sv.js" }
38
38
  ]
39
39
  });
40
40
  }
@@ -1,10 +1,12 @@
1
1
  export function useDriverOrders(driverId) {
2
+ const { tenantId } = useTenant();
2
3
  return useAsyncData(`mbl-order:driver-orders:${driverId}`, async () => {
4
+ if (!tenantId.value) return [];
3
5
  const client = useSupabaseClient();
4
6
  const config = useRuntimeConfig();
5
7
  const schema = config.public.mblOrder.supabaseSchema;
6
- const { data, error } = await client.schema(schema).from("orders").select("*").eq("assigned_driver_id", driverId).in("status", ["assigned", "collected", "in_transit"]).order("scheduled_pickup_at", { ascending: true });
8
+ const { data, error } = await client.schema(schema).from("orders").select("*").eq("assigned_driver_id", driverId).eq("tenant_id", tenantId.value).in("status", ["assigned", "collected", "in_transit"]).order("scheduled_pickup_at", { ascending: true });
7
9
  if (error) throw error;
8
10
  return data ?? [];
9
- });
11
+ }, { watch: [tenantId] });
10
12
  }
@@ -1,5 +1,7 @@
1
1
  export function useFleet() {
2
+ const { tenantId } = useTenant();
2
3
  return useAsyncData("mbl-order:fleet", async () => {
4
+ if (!tenantId.value) return [];
3
5
  const client = useSupabaseClient();
4
6
  const config = useRuntimeConfig();
5
7
  const schema = config.public.mblOrder.supabaseSchema;
@@ -9,8 +11,8 @@ export function useFleet() {
9
11
  orders!assigned_driver_id (
10
12
  id, order_number, status, pickup_address, delivery_address
11
13
  )
12
- `);
14
+ `).eq("tenant_id", tenantId.value);
13
15
  if (error) throw error;
14
16
  return data ?? [];
15
- });
17
+ }, { watch: [tenantId] });
16
18
  }
@@ -1,10 +1,12 @@
1
1
  export function useInvoice(orderId) {
2
+ const { tenantId } = useTenant();
2
3
  return useAsyncData(`mbl-order:invoice:${orderId}`, async () => {
4
+ if (!tenantId.value) return null;
3
5
  const client = useSupabaseClient();
4
6
  const config = useRuntimeConfig();
5
7
  const schema = config.public.mblOrder.supabaseSchema;
6
- const { data, error } = await client.schema(schema).from("invoices").select("*").eq("order_id", orderId).order("created_at", { ascending: false }).limit(1).maybeSingle();
8
+ const { data, error } = await client.schema(schema).from("invoices").select("*").eq("order_id", orderId).eq("tenant_id", tenantId.value).order("created_at", { ascending: false }).limit(1).maybeSingle();
7
9
  if (error) throw error;
8
10
  return data;
9
- });
11
+ }, { watch: [tenantId] });
10
12
  }
@@ -1,14 +1,20 @@
1
1
  export function useInvoiceActions() {
2
+ const { tenantId } = useTenant();
2
3
  const client = useSupabaseClient();
3
4
  const config = useRuntimeConfig();
4
5
  const schema = config.public.mblOrder.supabaseSchema;
6
+ function noTenant() {
7
+ return { data: null, error: new Error("No tenant in session \u2014 cannot perform invoice action") };
8
+ }
5
9
  async function generateInvoice(payload) {
10
+ if (!tenantId.value) return noTenant();
6
11
  try {
7
12
  const { data, error } = await client.schema(schema).from("invoices").insert({
8
13
  order_id: payload.orderId,
9
14
  due_at: payload.dueAt ?? null,
10
15
  status: "draft",
11
16
  currency: config.public.mblOrder.currency,
17
+ tenant_id: tenantId.value,
12
18
  accounting_provider: null,
13
19
  external_id: null,
14
20
  // TODO: compute from order lines
@@ -23,12 +29,13 @@ export function useInvoiceActions() {
23
29
  }
24
30
  }
25
31
  async function markPaid(invoiceId, paidAt) {
32
+ if (!tenantId.value) return noTenant();
26
33
  try {
27
34
  const { data, error } = await client.schema(schema).from("invoices").update({
28
35
  status: "paid",
29
36
  paid_at: paidAt ?? (/* @__PURE__ */ new Date()).toISOString(),
30
37
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
31
- }).eq("id", invoiceId).select().single();
38
+ }).eq("id", invoiceId).eq("tenant_id", tenantId.value).select().single();
32
39
  if (error) throw error;
33
40
  return { data, error: null };
34
41
  } catch (err) {
@@ -1,5 +1,7 @@
1
1
  export function useOrder(id) {
2
+ const { tenantId } = useTenant();
2
3
  return useAsyncData(`mbl-order:order:${id}`, async () => {
4
+ if (!tenantId.value) return null;
3
5
  const client = useSupabaseClient();
4
6
  const config = useRuntimeConfig();
5
7
  const schema = config.public.mblOrder.supabaseSchema;
@@ -11,8 +13,8 @@ export function useOrder(id) {
11
13
  storage_periods (*),
12
14
  drivers (*),
13
15
  vehicles (*)
14
- `).eq("id", id).single();
16
+ `).eq("id", id).eq("tenant_id", tenantId.value).single();
15
17
  if (error) throw error;
16
18
  return data;
17
- });
19
+ }, { watch: [tenantId] });
18
20
  }
@@ -1,12 +1,18 @@
1
1
  export function useOrderActions() {
2
+ const { tenantId } = useTenant();
2
3
  const client = useSupabaseClient();
3
4
  const config = useRuntimeConfig();
4
5
  const schema = config.public.mblOrder.supabaseSchema;
6
+ function noTenant() {
7
+ return { data: null, error: new Error("No tenant in session \u2014 cannot perform order action") };
8
+ }
5
9
  async function createOrder(payload) {
10
+ if (!tenantId.value) return noTenant();
6
11
  try {
7
12
  const { data, error } = await client.schema(schema).from("orders").insert({
8
13
  /* TODO: map payload to snake_case */
9
- ...payload
14
+ ...payload,
15
+ tenant_id: tenantId.value
10
16
  }).select().single();
11
17
  if (error) throw error;
12
18
  return { data, error: null };
@@ -15,8 +21,9 @@ export function useOrderActions() {
15
21
  }
16
22
  }
17
23
  async function updateStatus(orderId, status) {
24
+ if (!tenantId.value) return noTenant();
18
25
  try {
19
- const { data, error } = await client.schema(schema).from("orders").update({ status, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", orderId).select().single();
26
+ const { data, error } = await client.schema(schema).from("orders").update({ status, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", orderId).eq("tenant_id", tenantId.value).select().single();
20
27
  if (error) throw error;
21
28
  return { data, error: null };
22
29
  } catch (err) {
@@ -24,13 +31,14 @@ export function useOrderActions() {
24
31
  }
25
32
  }
26
33
  async function assignDriver(orderId, driverId, vehicleId) {
34
+ if (!tenantId.value) return noTenant();
27
35
  try {
28
36
  const { data, error } = await client.schema(schema).from("orders").update({
29
37
  assigned_driver_id: driverId,
30
38
  assigned_vehicle_id: vehicleId ?? null,
31
39
  status: "assigned",
32
40
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
33
- }).eq("id", orderId).select().single();
41
+ }).eq("id", orderId).eq("tenant_id", tenantId.value).select().single();
34
42
  if (error) throw error;
35
43
  return { data, error: null };
36
44
  } catch (err) {
@@ -38,13 +46,14 @@ export function useOrderActions() {
38
46
  }
39
47
  }
40
48
  async function cancelOrder(orderId, reason) {
49
+ if (!tenantId.value) return noTenant();
41
50
  try {
42
51
  const { data, error } = await client.schema(schema).from("orders").update({
43
52
  status: "draft",
44
53
  // placeholder — actual cancel status TBD
45
54
  notes: reason ?? null,
46
55
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
47
- }).eq("id", orderId).select().single();
56
+ }).eq("id", orderId).eq("tenant_id", tenantId.value).select().single();
48
57
  if (error) throw error;
49
58
  return { data, error: null };
50
59
  } catch (err) {
@@ -1,10 +1,25 @@
1
1
  export function useOrderKpis(filters) {
2
+ const { tenantId } = useTenant();
2
3
  const key = `mbl-order:kpis:${JSON.stringify(filters ?? {})}`;
3
4
  return useAsyncData(key, async () => {
5
+ if (!tenantId.value) {
6
+ return {
7
+ totalOrders: 0,
8
+ activeOrders: 0,
9
+ deliveredOrders: 0,
10
+ cancelledOrders: 0,
11
+ averageDeliveryTimeHours: null,
12
+ totalRevenue: 0,
13
+ pendingInvoices: 0,
14
+ overdueInvoices: 0,
15
+ activeStorageContracts: 0,
16
+ fleetUtilisationPercent: null
17
+ };
18
+ }
4
19
  const client = useSupabaseClient();
5
20
  const config = useRuntimeConfig();
6
21
  const schema = config.public.mblOrder.supabaseSchema;
7
- const { data, error } = await client.schema(schema).from("orders").select("status, type, created_at");
22
+ const { data, error } = await client.schema(schema).from("orders").select("status, type, created_at").eq("tenant_id", tenantId.value);
8
23
  if (error) throw error;
9
24
  const rows = data ?? [];
10
25
  const kpis = {
@@ -26,5 +41,5 @@ export function useOrderKpis(filters) {
26
41
  // TODO: compute from fleet + active orders
27
42
  };
28
43
  return kpis;
29
- });
44
+ }, { watch: [tenantId] });
30
45
  }
@@ -1,7 +1,8 @@
1
1
  import type { Locale } from '../../types.js';
2
2
  export declare function useOrderLocale(): {
3
3
  t: (key: string) => string;
4
- formatCurrency: (amount: number) => string;
4
+ formatCurrency: (amount: number, currencyOverride?: string) => string;
5
5
  formatDate: (dateStr: string) => string;
6
- locale: Locale;
6
+ locale: any;
7
+ setLocale: (newLocale: Locale) => void;
7
8
  };
@@ -10,23 +10,29 @@ function resolve(obj, path) {
10
10
  }
11
11
  export function useOrderLocale() {
12
12
  const config = useRuntimeConfig();
13
- const locale = config.public.mblOrder?.locale ?? "en";
14
- const messages = LOCALES[locale] ?? LOCALES.en;
13
+ const locale = useState(
14
+ "mbl-order:locale",
15
+ () => config.public.mblOrder?.locale ?? "en"
16
+ );
17
+ const messages = computed(() => LOCALES[locale.value] ?? LOCALES.en);
18
+ function setLocale(newLocale) {
19
+ locale.value = newLocale;
20
+ }
15
21
  function t(key) {
16
- return resolve(messages, key);
22
+ return resolve(messages.value, key);
17
23
  }
18
- function formatCurrency(amount) {
19
- const currency = config.public.mblOrder?.currency ?? "GBP";
20
- const intlLocale = locale === "sv" ? "sv-SE" : "en-GB";
24
+ function formatCurrency(amount, currencyOverride) {
25
+ const currency = currencyOverride ?? (config.public.mblOrder?.currency ?? "GBP");
26
+ const intlLocale = locale.value === "sv" ? "sv-SE" : "en-GB";
21
27
  return new Intl.NumberFormat(intlLocale, { style: "currency", currency }).format(amount);
22
28
  }
23
29
  function formatDate(dateStr) {
24
- const intlLocale = locale === "sv" ? "sv-SE" : "en-GB";
30
+ const intlLocale = locale.value === "sv" ? "sv-SE" : "en-GB";
25
31
  return new Intl.DateTimeFormat(intlLocale, {
26
32
  day: "numeric",
27
33
  month: "long",
28
34
  year: "numeric"
29
35
  }).format(new Date(dateStr));
30
36
  }
31
- return { t, formatCurrency, formatDate, locale };
37
+ return { t, formatCurrency, formatDate, locale, setLocale };
32
38
  }
@@ -1,11 +1,13 @@
1
1
  export function useOrders(filters) {
2
+ const { tenantId } = useTenant();
2
3
  const key = `mbl-order:orders:${JSON.stringify(filters ?? {})}`;
3
4
  return useAsyncData(key, async () => {
5
+ if (!tenantId.value) return [];
4
6
  const client = useSupabaseClient();
5
7
  const config = useRuntimeConfig();
6
8
  const schema = config.public.mblOrder.supabaseSchema;
7
- const { data, error } = await client.schema(schema).from("orders").select("*");
9
+ const { data, error } = await client.schema(schema).from("orders").select("*").eq("tenant_id", tenantId.value);
8
10
  if (error) throw error;
9
11
  return data ?? [];
10
- });
12
+ }, { watch: [tenantId] });
11
13
  }
@@ -1,10 +1,12 @@
1
1
  export function useQuote(orderId) {
2
+ const { tenantId } = useTenant();
2
3
  return useAsyncData(`mbl-order:quote:${orderId}`, async () => {
4
+ if (!tenantId.value) return null;
3
5
  const client = useSupabaseClient();
4
6
  const config = useRuntimeConfig();
5
7
  const schema = config.public.mblOrder.supabaseSchema;
6
- const { data, error } = await client.schema(schema).from("quotes").select("*").eq("order_id", orderId).order("created_at", { ascending: false }).limit(1).maybeSingle();
8
+ const { data, error } = await client.schema(schema).from("quotes").select("*").eq("order_id", orderId).eq("tenant_id", tenantId.value).order("created_at", { ascending: false }).limit(1).maybeSingle();
7
9
  if (error) throw error;
8
10
  return data;
9
- });
11
+ }, { watch: [tenantId] });
10
12
  }
@@ -1,10 +1,20 @@
1
1
  export function useQuoteActions() {
2
+ const { tenantId } = useTenant();
2
3
  const client = useSupabaseClient();
3
4
  const config = useRuntimeConfig();
4
5
  const schema = config.public.mblOrder.supabaseSchema;
6
+ function noTenant() {
7
+ return { data: null, error: new Error("No tenant in session \u2014 cannot perform quote action") };
8
+ }
5
9
  async function createQuote(payload) {
10
+ if (!tenantId.value) return noTenant();
6
11
  try {
7
- const { data, error } = await client.schema(schema).from("quotes").insert({ order_id: payload.orderId, expires_at: payload.expiresAt, notes: payload.notes ?? null }).select().single();
12
+ const { data, error } = await client.schema(schema).from("quotes").insert({
13
+ order_id: payload.orderId,
14
+ expires_at: payload.expiresAt,
15
+ notes: payload.notes ?? null,
16
+ tenant_id: tenantId.value
17
+ }).select().single();
8
18
  if (error) throw error;
9
19
  return { data, error: null };
10
20
  } catch (err) {
@@ -12,8 +22,9 @@ export function useQuoteActions() {
12
22
  }
13
23
  }
14
24
  async function acceptQuote(quoteId) {
25
+ if (!tenantId.value) return noTenant();
15
26
  try {
16
- const { data, error } = await client.schema(schema).from("quotes").update({ accepted_at: (/* @__PURE__ */ new Date()).toISOString(), updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", quoteId).select().single();
27
+ const { data, error } = await client.schema(schema).from("quotes").update({ accepted_at: (/* @__PURE__ */ new Date()).toISOString(), updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", quoteId).eq("tenant_id", tenantId.value).select().single();
17
28
  if (error) throw error;
18
29
  return { data, error: null };
19
30
  } catch (err) {
@@ -21,8 +32,9 @@ export function useQuoteActions() {
21
32
  }
22
33
  }
23
34
  async function expireQuote(quoteId) {
35
+ if (!tenantId.value) return noTenant();
24
36
  try {
25
- const { data, error } = await client.schema(schema).from("quotes").update({ rejected_at: (/* @__PURE__ */ new Date()).toISOString(), updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", quoteId).select().single();
37
+ const { data, error } = await client.schema(schema).from("quotes").update({ rejected_at: (/* @__PURE__ */ new Date()).toISOString(), updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", quoteId).eq("tenant_id", tenantId.value).select().single();
26
38
  if (error) throw error;
27
39
  return { data, error: null };
28
40
  } catch (err) {
@@ -1,12 +1,17 @@
1
1
  export function useStorageActions() {
2
+ const { tenantId } = useTenant();
2
3
  const client = useSupabaseClient();
3
4
  const config = useRuntimeConfig();
4
5
  const schema = config.public.mblOrder.supabaseSchema;
6
+ function noTenant() {
7
+ return { data: null, error: new Error("No tenant in session \u2014 cannot perform storage action") };
8
+ }
5
9
  async function extendPeriod(payload) {
10
+ if (!tenantId.value) return noTenant();
6
11
  try {
7
- const { data: existing, error: fetchError } = await client.schema(schema).from("storage_periods").select("id").eq("order_id", payload.orderId).order("start_date", { ascending: false }).limit(1).single();
12
+ const { data: existing, error: fetchError } = await client.schema(schema).from("storage_periods").select("id").eq("order_id", payload.orderId).eq("tenant_id", tenantId.value).order("start_date", { ascending: false }).limit(1).single();
8
13
  if (fetchError) throw fetchError;
9
- const { data, error } = await client.schema(schema).from("storage_periods").update({ end_date: payload.newEndDate, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", existing.id).select().single();
14
+ const { data, error } = await client.schema(schema).from("storage_periods").update({ end_date: payload.newEndDate, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", existing.id).eq("tenant_id", tenantId.value).select().single();
10
15
  if (error) throw error;
11
16
  return { data, error: null };
12
17
  } catch (err) {
@@ -14,13 +19,14 @@ export function useStorageActions() {
14
19
  }
15
20
  }
16
21
  async function endPeriod(orderId, endDate) {
22
+ if (!tenantId.value) return noTenant();
17
23
  try {
18
- const { data: existing, error: fetchError } = await client.schema(schema).from("storage_periods").select("id").eq("order_id", orderId).order("start_date", { ascending: false }).limit(1).single();
24
+ const { data: existing, error: fetchError } = await client.schema(schema).from("storage_periods").select("id").eq("order_id", orderId).eq("tenant_id", tenantId.value).order("start_date", { ascending: false }).limit(1).single();
19
25
  if (fetchError) throw fetchError;
20
26
  const { data, error } = await client.schema(schema).from("storage_periods").update({
21
27
  end_date: endDate ?? (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
22
28
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
23
- }).eq("id", existing.id).select().single();
29
+ }).eq("id", existing.id).eq("tenant_id", tenantId.value).select().single();
24
30
  if (error) throw error;
25
31
  return { data, error: null };
26
32
  } catch (err) {
@@ -1,10 +1,12 @@
1
1
  export function useStoragePeriod(orderId) {
2
+ const { tenantId } = useTenant();
2
3
  return useAsyncData(`mbl-order:storage-period:${orderId}`, async () => {
4
+ if (!tenantId.value) return null;
3
5
  const client = useSupabaseClient();
4
6
  const config = useRuntimeConfig();
5
7
  const schema = config.public.mblOrder.supabaseSchema;
6
- const { data, error } = await client.schema(schema).from("storage_periods").select("*").eq("order_id", orderId).order("start_date", { ascending: false }).limit(1).maybeSingle();
8
+ const { data, error } = await client.schema(schema).from("storage_periods").select("*").eq("order_id", orderId).eq("tenant_id", tenantId.value).order("start_date", { ascending: false }).limit(1).maybeSingle();
7
9
  if (error) throw error;
8
10
  return data;
9
- });
11
+ }, { watch: [tenantId] });
10
12
  }
@@ -0,0 +1,6 @@
1
+ export declare function useTenant(): {
2
+ data: any;
3
+ pending: any;
4
+ error: any;
5
+ tenantId: any;
6
+ };
@@ -0,0 +1,30 @@
1
+ function decodeTenantId(accessToken) {
2
+ try {
3
+ const payload = JSON.parse(
4
+ atob(accessToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
5
+ );
6
+ return payload.tenant_id ?? null;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+ export function useTenant() {
12
+ const session = useSupabaseSession();
13
+ const client = useSupabaseClient();
14
+ const tenantId = computed(() => {
15
+ const token = session.value?.access_token;
16
+ if (!token) return null;
17
+ return decodeTenantId(token);
18
+ });
19
+ const { data, pending, error } = useAsyncData(
20
+ "mbl-order:tenant",
21
+ async () => {
22
+ if (!tenantId.value) return null;
23
+ const { data: data2, error: error2 } = await client.schema("tenant").from("tenants").select("*").eq("id", tenantId.value).single();
24
+ if (error2) throw error2;
25
+ return data2;
26
+ },
27
+ { watch: [tenantId] }
28
+ );
29
+ return { data, pending, error, tenantId };
30
+ }
@@ -34,6 +34,7 @@ declare const en: {
34
34
  readonly driverUnavailable: "Driver is not available";
35
35
  readonly invoiceAlreadyExists: "An invoice already exists for this order";
36
36
  readonly periodNotFound: "No active storage period found";
37
+ readonly noTenant: "Tenant not found — please sign in again";
37
38
  };
38
39
  readonly invoice: {
39
40
  readonly title: "Invoice";
@@ -33,7 +33,8 @@ const en = {
33
33
  quoteExpired: "This quote has expired",
34
34
  driverUnavailable: "Driver is not available",
35
35
  invoiceAlreadyExists: "An invoice already exists for this order",
36
- periodNotFound: "No active storage period found"
36
+ periodNotFound: "No active storage period found",
37
+ noTenant: "Tenant not found \u2014 please sign in again"
37
38
  },
38
39
  invoice: {
39
40
  title: "Invoice",
@@ -33,7 +33,8 @@ const sv = {
33
33
  quoteExpired: "Offerten har g\xE5tt ut",
34
34
  driverUnavailable: "F\xF6raren \xE4r inte tillg\xE4nglig",
35
35
  invoiceAlreadyExists: "En faktura finns redan f\xF6r denna order",
36
- periodNotFound: "Ingen aktiv lagringsperiod hittades"
36
+ periodNotFound: "Ingen aktiv lagringsperiod hittades",
37
+ noTenant: "Klient hittades inte \u2014 v\xE4nligen logga in igen"
37
38
  },
38
39
  invoice: {
39
40
  title: "Faktura",
@@ -0,0 +1,330 @@
1
+ -- mbl-order: multitenancy
2
+ -- Apply AFTER 001_transport_schema.sql
3
+ --
4
+ -- ─── MANUAL STEP REQUIRED ────────────────────────────────────────────────────
5
+ -- After running this migration, register the hook in the Supabase dashboard:
6
+ -- Authentication → Hooks → Customize Access Token (JWT) Claims
7
+ -- → Select function: public.custom_access_token_hook
8
+ --
9
+ -- Without this hook, transport.current_tenant_id() always returns NULL and
10
+ -- all RLS policies deny every request (fail closed — not fail open).
11
+ -- ─────────────────────────────────────────────────────────────────────────────
12
+
13
+ -- ─── Tenant schema ───────────────────────────────────────────────────────────
14
+
15
+ CREATE SCHEMA IF NOT EXISTS tenant;
16
+
17
+ -- ─── Tenants ─────────────────────────────────────────────────────────────────
18
+
19
+ CREATE TABLE tenant.tenants (
20
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
21
+ name text NOT NULL,
22
+ slug text NOT NULL UNIQUE,
23
+ locale text NOT NULL DEFAULT 'en' CHECK (locale IN ('en', 'sv')),
24
+ currency text NOT NULL DEFAULT 'GBP' CHECK (currency IN ('GBP', 'SEK')),
25
+ vat_standard numeric NOT NULL DEFAULT 0.20,
26
+ created_at timestamptz NOT NULL DEFAULT now()
27
+ );
28
+
29
+ ALTER TABLE tenant.tenants ENABLE ROW LEVEL SECURITY;
30
+
31
+ -- ─── Tenant users ─────────────────────────────────────────────────────────────
32
+ -- Stores the many-to-one relationship between auth users and tenants.
33
+ -- Also stores the transport role for this user within the tenant.
34
+ -- mbl-auth must insert here when provisioning a new user to a tenant.
35
+
36
+ CREATE TABLE tenant.tenant_users (
37
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
38
+ tenant_id uuid NOT NULL REFERENCES tenant.tenants(id) ON DELETE CASCADE,
39
+ role text NOT NULL DEFAULT 'customer'
40
+ CHECK (role IN ('customer', 'driver', 'dispatcher', 'admin')),
41
+ created_at timestamptz NOT NULL DEFAULT now(),
42
+ PRIMARY KEY (user_id, tenant_id)
43
+ );
44
+
45
+ ALTER TABLE tenant.tenant_users ENABLE ROW LEVEL SECURITY;
46
+
47
+ -- Users can read their own membership row (needed by useTenant() composable)
48
+ CREATE POLICY "tenant_users_own_read" ON tenant.tenant_users
49
+ FOR SELECT USING (user_id = auth.uid());
50
+
51
+ -- Authenticated users can read only their own tenant (defined after tenant_users exists)
52
+ CREATE POLICY "tenants_own_read" ON tenant.tenants
53
+ FOR SELECT USING (
54
+ id IN (SELECT tenant_id FROM tenant.tenant_users WHERE user_id = auth.uid())
55
+ );
56
+
57
+ -- ─── Custom Access Token Hook ─────────────────────────────────────────────────
58
+ -- Injects tenant_id and user_role as top-level JWT claims on every login/refresh.
59
+ -- MUST be registered manually in the Supabase dashboard (see top of file).
60
+ -- The hook function must reside in the public schema (Supabase requirement).
61
+ --
62
+ -- Claims injected:
63
+ -- tenant_id — uuid string — used by transport.current_tenant_id()
64
+ -- user_role — text — used by transport.current_user_role()
65
+ -- ('user_role' avoids overwriting Supabase's own 'role' claim)
66
+
67
+ CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
68
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$
69
+ DECLARE
70
+ v_claims jsonb;
71
+ v_tenant_id uuid;
72
+ v_role text;
73
+ BEGIN
74
+ SELECT tenant_id, role INTO v_tenant_id, v_role
75
+ FROM tenant.tenant_users
76
+ WHERE user_id = (event->>'user_id')::uuid;
77
+
78
+ v_claims := event->'claims';
79
+
80
+ IF v_tenant_id IS NOT NULL THEN
81
+ v_claims := jsonb_set(v_claims, '{tenant_id}', to_jsonb(v_tenant_id::text));
82
+ v_claims := jsonb_set(v_claims, '{user_role}', to_jsonb(coalesce(v_role, 'customer')));
83
+ END IF;
84
+
85
+ RETURN jsonb_set(event, '{claims}', v_claims);
86
+ END;
87
+ $$;
88
+
89
+ -- Hook must be called by supabase_auth_admin only
90
+ GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
91
+ REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM authenticated, anon, public;
92
+
93
+ -- ─── Add tenant_id to all transport tables ───────────────────────────────────
94
+
95
+ ALTER TABLE transport.orders
96
+ ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenant.tenants(id);
97
+
98
+ ALTER TABLE transport.order_lines
99
+ ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenant.tenants(id);
100
+
101
+ ALTER TABLE transport.quotes
102
+ ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenant.tenants(id);
103
+
104
+ ALTER TABLE transport.invoices
105
+ ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenant.tenants(id);
106
+
107
+ ALTER TABLE transport.storage_periods
108
+ ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenant.tenants(id);
109
+
110
+ ALTER TABLE transport.drivers
111
+ ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenant.tenants(id);
112
+
113
+ ALTER TABLE transport.vehicles
114
+ ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenant.tenants(id);
115
+
116
+ -- ─── Indexes ─────────────────────────────────────────────────────────────────
117
+
118
+ CREATE INDEX ON transport.orders (tenant_id);
119
+ CREATE INDEX ON transport.orders (tenant_id, customer_id);
120
+ CREATE INDEX ON transport.orders (tenant_id, status);
121
+ CREATE INDEX ON transport.drivers (tenant_id);
122
+ CREATE INDEX ON transport.invoices (tenant_id);
123
+ CREATE INDEX ON transport.vehicles (tenant_id);
124
+ CREATE INDEX ON transport.order_lines (tenant_id);
125
+ CREATE INDEX ON transport.quotes (tenant_id);
126
+ CREATE INDEX ON transport.storage_periods (tenant_id);
127
+
128
+ -- ─── Update helper functions ─────────────────────────────────────────────────
129
+
130
+ -- Reads tenant_id from the hook-injected top-level JWT claim.
131
+ -- PostgREST sets request.jwt.claim.<name> per individual claim (fast path).
132
+ -- Falls back to parsing the full request.jwt.claims JSON string.
133
+ -- Returns NULL if claim is absent → all policies fail closed.
134
+ CREATE OR REPLACE FUNCTION transport.current_tenant_id()
135
+ RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER AS $$
136
+ SELECT CASE
137
+ WHEN current_setting('request.jwt.claim.tenant_id', true) <> ''
138
+ THEN current_setting('request.jwt.claim.tenant_id', true)::uuid
139
+ WHEN current_setting('request.jwt.claims', true) <> ''
140
+ THEN (current_setting('request.jwt.claims', true)::jsonb->>'tenant_id')::uuid
141
+ ELSE NULL
142
+ END
143
+ $$;
144
+
145
+ -- Reads user_role from the hook-injected JWT claim.
146
+ -- Falls back to app_metadata.role for backward compatibility with mbl-auth
147
+ -- deployments that have not yet enabled the Custom Access Token Hook.
148
+ CREATE OR REPLACE FUNCTION transport.current_user_role()
149
+ RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $$
150
+ SELECT coalesce(
151
+ CASE WHEN current_setting('request.jwt.claim.user_role', true) <> ''
152
+ THEN current_setting('request.jwt.claim.user_role', true) END,
153
+ CASE WHEN current_setting('request.jwt.claims', true) <> ''
154
+ THEN current_setting('request.jwt.claims', true)::jsonb->>'user_role' END,
155
+ CASE WHEN current_setting('request.jwt.claims', true) <> ''
156
+ THEN current_setting('request.jwt.claims', true)::jsonb->'app_metadata'->>'role' END,
157
+ 'customer'
158
+ )
159
+ $$;
160
+
161
+ -- ─── Replace RLS policies with tenant-scoped versions ────────────────────────
162
+ -- Drop all policies created in 001_transport_schema.sql and recreate with
163
+ -- tenant isolation added to every condition.
164
+
165
+ -- Orders
166
+ DROP POLICY IF EXISTS orders_select ON transport.orders;
167
+ DROP POLICY IF EXISTS orders_insert ON transport.orders;
168
+ DROP POLICY IF EXISTS orders_update ON transport.orders;
169
+
170
+ CREATE POLICY orders_select ON transport.orders FOR SELECT USING (
171
+ tenant_id = transport.current_tenant_id()
172
+ AND (
173
+ transport.current_user_role() IN ('admin', 'dispatcher', 'driver')
174
+ OR customer_id = auth.uid()
175
+ )
176
+ );
177
+
178
+ CREATE POLICY orders_insert ON transport.orders FOR INSERT WITH CHECK (
179
+ tenant_id = transport.current_tenant_id()
180
+ AND transport.current_user_role() IN ('customer', 'dispatcher', 'admin')
181
+ );
182
+
183
+ CREATE POLICY orders_update ON transport.orders FOR UPDATE
184
+ USING (tenant_id = transport.current_tenant_id())
185
+ WITH CHECK (
186
+ tenant_id = transport.current_tenant_id()
187
+ AND (
188
+ transport.current_user_role() IN ('dispatcher', 'admin')
189
+ OR (
190
+ transport.current_user_role() = 'driver'
191
+ AND assigned_driver_id IN (
192
+ SELECT id FROM transport.drivers
193
+ WHERE user_id = auth.uid()
194
+ AND tenant_id = transport.current_tenant_id()
195
+ )
196
+ )
197
+ )
198
+ );
199
+
200
+ -- Order lines
201
+ DROP POLICY IF EXISTS order_lines_select ON transport.order_lines;
202
+ DROP POLICY IF EXISTS order_lines_insert ON transport.order_lines;
203
+
204
+ CREATE POLICY order_lines_select ON transport.order_lines FOR SELECT USING (
205
+ tenant_id = transport.current_tenant_id()
206
+ );
207
+
208
+ CREATE POLICY order_lines_insert ON transport.order_lines FOR INSERT WITH CHECK (
209
+ tenant_id = transport.current_tenant_id()
210
+ AND transport.current_user_role() IN ('dispatcher', 'admin')
211
+ );
212
+
213
+ -- Quotes
214
+ DROP POLICY IF EXISTS quotes_select ON transport.quotes;
215
+ DROP POLICY IF EXISTS quotes_insert ON transport.quotes;
216
+ DROP POLICY IF EXISTS quotes_update ON transport.quotes;
217
+
218
+ CREATE POLICY quotes_select ON transport.quotes FOR SELECT USING (
219
+ tenant_id = transport.current_tenant_id()
220
+ AND (
221
+ transport.current_user_role() IN ('admin', 'dispatcher')
222
+ OR order_id IN (
223
+ SELECT id FROM transport.orders
224
+ WHERE customer_id = auth.uid()
225
+ AND tenant_id = transport.current_tenant_id()
226
+ )
227
+ )
228
+ );
229
+
230
+ CREATE POLICY quotes_insert ON transport.quotes FOR INSERT WITH CHECK (
231
+ tenant_id = transport.current_tenant_id()
232
+ AND transport.current_user_role() IN ('dispatcher', 'admin')
233
+ );
234
+
235
+ CREATE POLICY quotes_update ON transport.quotes FOR UPDATE
236
+ USING (tenant_id = transport.current_tenant_id())
237
+ WITH CHECK (
238
+ tenant_id = transport.current_tenant_id()
239
+ AND (
240
+ transport.current_user_role() IN ('dispatcher', 'admin')
241
+ OR order_id IN (
242
+ SELECT id FROM transport.orders
243
+ WHERE customer_id = auth.uid()
244
+ AND tenant_id = transport.current_tenant_id()
245
+ )
246
+ )
247
+ );
248
+
249
+ -- Storage periods
250
+ DROP POLICY IF EXISTS storage_periods_select ON transport.storage_periods;
251
+ DROP POLICY IF EXISTS storage_periods_write ON transport.storage_periods;
252
+
253
+ CREATE POLICY storage_periods_select ON transport.storage_periods FOR SELECT USING (
254
+ tenant_id = transport.current_tenant_id()
255
+ );
256
+
257
+ CREATE POLICY storage_periods_write ON transport.storage_periods FOR ALL
258
+ USING (tenant_id = transport.current_tenant_id())
259
+ WITH CHECK (
260
+ tenant_id = transport.current_tenant_id()
261
+ AND transport.current_user_role() IN ('dispatcher', 'admin')
262
+ );
263
+
264
+ -- Invoices
265
+ DROP POLICY IF EXISTS invoices_select ON transport.invoices;
266
+ DROP POLICY IF EXISTS invoices_write ON transport.invoices;
267
+
268
+ CREATE POLICY invoices_select ON transport.invoices FOR SELECT USING (
269
+ tenant_id = transport.current_tenant_id()
270
+ AND (
271
+ transport.current_user_role() IN ('admin', 'dispatcher')
272
+ OR order_id IN (
273
+ SELECT id FROM transport.orders
274
+ WHERE customer_id = auth.uid()
275
+ AND tenant_id = transport.current_tenant_id()
276
+ )
277
+ )
278
+ );
279
+
280
+ CREATE POLICY invoices_write ON transport.invoices FOR ALL
281
+ USING (tenant_id = transport.current_tenant_id())
282
+ WITH CHECK (
283
+ tenant_id = transport.current_tenant_id()
284
+ AND transport.current_user_role() IN ('dispatcher', 'admin')
285
+ );
286
+
287
+ -- Drivers
288
+ DROP POLICY IF EXISTS drivers_select ON transport.drivers;
289
+ DROP POLICY IF EXISTS drivers_write ON transport.drivers;
290
+
291
+ CREATE POLICY drivers_select ON transport.drivers FOR SELECT USING (
292
+ tenant_id = transport.current_tenant_id()
293
+ AND (
294
+ transport.current_user_role() IN ('admin', 'dispatcher')
295
+ OR user_id = auth.uid()
296
+ )
297
+ );
298
+
299
+ CREATE POLICY drivers_write ON transport.drivers FOR ALL
300
+ USING (tenant_id = transport.current_tenant_id())
301
+ WITH CHECK (
302
+ tenant_id = transport.current_tenant_id()
303
+ AND transport.current_user_role() = 'admin'
304
+ );
305
+
306
+ -- Vehicles
307
+ DROP POLICY IF EXISTS vehicles_select ON transport.vehicles;
308
+ DROP POLICY IF EXISTS vehicles_write ON transport.vehicles;
309
+
310
+ CREATE POLICY vehicles_select ON transport.vehicles FOR SELECT
311
+ TO authenticated USING (tenant_id = transport.current_tenant_id());
312
+
313
+ CREATE POLICY vehicles_write ON transport.vehicles FOR ALL
314
+ USING (tenant_id = transport.current_tenant_id())
315
+ WITH CHECK (
316
+ tenant_id = transport.current_tenant_id()
317
+ AND transport.current_user_role() = 'admin'
318
+ );
319
+
320
+ -- ─── Dev tenant seed ─────────────────────────────────────────────────────────
321
+
322
+ INSERT INTO tenant.tenants (id, name, slug, locale, currency, vat_standard)
323
+ VALUES (
324
+ '00000000-0000-0000-0000-000000000001',
325
+ 'Dev Transport Co',
326
+ 'dev',
327
+ 'en',
328
+ 'GBP',
329
+ 0.20
330
+ ) ON CONFLICT (id) DO NOTHING;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madebylars.com/mbl-order",
3
- "version": "0.1.0",
3
+ "version": "1.0.1",
4
4
  "description": "Nuxt 4 transport-vertical order management module for the MadeByLars ecosystem",
5
5
  "repository": {
6
6
  "type": "git",