@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 +36 -12
- package/dist/module.json +1 -1
- package/dist/module.mjs +2 -2
- package/dist/runtime/composables/useDriverOrders.js +4 -2
- package/dist/runtime/composables/useFleet.js +4 -2
- package/dist/runtime/composables/useInvoice.js +4 -2
- package/dist/runtime/composables/useInvoiceActions.js +8 -1
- package/dist/runtime/composables/useOrder.js +4 -2
- package/dist/runtime/composables/useOrderActions.js +13 -4
- package/dist/runtime/composables/useOrderKpis.js +17 -2
- package/dist/runtime/composables/useOrderLocale.d.ts +3 -2
- package/dist/runtime/composables/useOrderLocale.js +14 -8
- package/dist/runtime/composables/useOrders.js +4 -2
- package/dist/runtime/composables/useQuote.js +4 -2
- package/dist/runtime/composables/useQuoteActions.js +15 -3
- package/dist/runtime/composables/useStorageActions.js +10 -4
- package/dist/runtime/composables/useStoragePeriod.js +4 -2
- package/dist/runtime/composables/useTenant.d.ts +6 -0
- package/dist/runtime/composables/useTenant.js +30 -0
- package/dist/runtime/locales/en.d.ts +1 -0
- package/dist/runtime/locales/en.js +2 -1
- package/dist/runtime/locales/sv.js +2 -1
- package/dist/runtime/server/migrations/002_add_multitenancy.sql +330 -0
- package/package.json +1 -1
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
|
|
75
|
+
### 3. Run the database migrations
|
|
75
76
|
|
|
76
|
-
Apply
|
|
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
|
-
**
|
|
88
|
+
> **Authentication → Hooks → Custom Access Token** → select `public.custom_access_token_hook`
|
|
84
89
|
|
|
85
|
-
The
|
|
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
|
-
> **
|
|
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
|
|
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
|
-
|
|
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
|
|
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
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.
|
|
37
|
-
{ code: "sv", file: "sv.
|
|
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:
|
|
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 =
|
|
14
|
-
|
|
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({
|
|
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,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;
|