@flowselections/mailbox-orders 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,85 +1,169 @@
1
1
  // Server functions voor de Mailbox-bestellingen module
2
2
  import { createServerFn } from "@tanstack/react-start";
3
3
  import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
4
+ import { createClient } from "@supabase/supabase-js";
4
5
  import { z } from "zod";
5
- // ─── Settings ────────────────────────────────────────────────────────────────
6
- export const getMailboxSettings = createServerFn({ method: "GET" })
7
- .middleware([requireSupabaseAuth])
8
- .handler(async ({ context }) => {
9
- const { data, error } = await context.supabase
10
- .from("mailbox_settings")
11
- .select("*")
12
- .limit(1)
13
- .maybeSingle();
6
+ // ─── Actieve mailbox (uit gedeelde e-mail-auth module) ──────────────────────
7
+ // Geen auth-middleware: de RPC is SECURITY DEFINER en EXECUTE staat ook aan
8
+ // `anon`. Zo blijft de status-banner werken als de bearer-token tijdelijk
9
+ // ontbreekt of verlopen is — anders crasht de UI met een 401.
10
+ export const getActiveOrdersMailbox = createServerFn({ method: "GET" })
11
+ .handler(async () => {
12
+ const url = process.env.SUPABASE_URL;
13
+ const key = process.env.SUPABASE_PUBLISHABLE_KEY;
14
+ if (!url || !key)
15
+ return { connected: false, error: "missing_env" };
16
+ const sb = createClient(url, key, {
17
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
18
+ });
19
+ const { data, error } = await sb.rpc("email_auth_for_purpose", {
20
+ _purpose: "orders",
21
+ _direction: "receive",
22
+ });
14
23
  if (error)
15
- throw new Error(error.message);
16
- return { settings: data };
24
+ return { connected: false, error: error.message };
25
+ const row = Array.isArray(data) ? data[0] : null;
26
+ if (!row)
27
+ return { connected: false };
28
+ return {
29
+ connected: true,
30
+ id: row.id,
31
+ provider: row.provider,
32
+ account_email: row.account_email,
33
+ display_name: (row.display_name ?? null),
34
+ status: (row.status ?? null),
35
+ last_error: (row.last_error ?? null),
36
+ last_verified_at: (row.last_verified_at ?? null),
37
+ needs_reauth: !!row.needs_reauth,
38
+ };
17
39
  });
18
- const SettingsInput = z.object({
19
- imap_host: z.string().max(255),
20
- imap_port: z.number().int().min(1).max(65535),
21
- imap_username: z.string().max(255),
22
- imap_use_tls: z.boolean(),
23
- folder: z.string().max(255),
24
- polling_enabled: z.boolean(),
25
- });
26
- export const saveMailboxSettings = createServerFn({ method: "POST" })
27
- .middleware([requireSupabaseAuth])
28
- .inputValidator((input) => SettingsInput.parse(input))
29
- .handler(async ({ data, context }) => {
30
- const { data: existing } = await context.supabase
31
- .from("mailbox_settings").select("id").limit(1).maybeSingle();
32
- if (existing?.id) {
33
- const { error } = await context.supabase
34
- .from("mailbox_settings").update(data).eq("id", existing.id);
35
- if (error)
36
- throw new Error(error.message);
37
- }
38
- else {
39
- const { error } = await context.supabase
40
- .from("mailbox_settings").insert({ ...data, singleton: true });
41
- if (error)
42
- throw new Error(error.message);
40
+ // ─── Event ingest helpers ────────────────────────────────────────────────────
41
+ function pickStr(obj, ...keys) {
42
+ for (const k of keys) {
43
+ const v = k.split(".").reduce((a, p) => (a == null ? a : a[p]), obj);
44
+ if (typeof v === "string" && v.length > 0)
45
+ return v;
43
46
  }
44
- return { ok: true };
45
- });
46
- async function invokeMailboxImap(supabase, action) {
47
- const { data, error } = await supabase.functions.invoke("mailbox-imap", {
48
- body: { action },
49
- });
50
- if (error)
51
- throw new Error(error.message ?? "Edge function aanroep mislukt");
52
- return data;
47
+ return null;
53
48
  }
54
- export const testMailboxConnection = createServerFn({ method: "POST" })
55
- .middleware([requireSupabaseAuth])
56
- .handler(async ({ context }) => {
57
- try {
58
- return await invokeMailboxImap(context.supabase, "test");
49
+ async function ingestSingleEvent(supabaseAdmin, connectionId, ev) {
50
+ const p = ev.payload ?? {};
51
+ const externalId = ev.external_id ?? pickStr(p, "message_id", "messageId", "id") ?? ev.id;
52
+ // Skip als deze external_id al bestaat
53
+ if (externalId) {
54
+ const { data: existing } = await supabaseAdmin
55
+ .from("mailbox_messages")
56
+ .select("id")
57
+ .eq("external_id", externalId)
58
+ .maybeSingle();
59
+ if (existing?.id) {
60
+ return { inserted: false, messageId: existing.id };
61
+ }
59
62
  }
60
- catch (e) {
61
- return { ok: false, error: e?.message ?? "Onbekende fout" };
63
+ const fromEmail = pickStr(p, "from_email", "fromEmail", "from.address", "from.email", "sender.address") ?? "";
64
+ const fromName = pickStr(p, "from_name", "fromName", "from.name", "sender.name");
65
+ const subject = pickStr(p, "subject", "title") ?? "";
66
+ const bodyText = pickStr(p, "body_text", "bodyText", "text", "body.text", "snippet");
67
+ const bodyHtml = pickStr(p, "body_html", "bodyHtml", "html", "body.html");
68
+ const receivedAt = pickStr(p, "received_at", "receivedAt", "date", "internalDate") ?? ev.received_at ?? new Date().toISOString();
69
+ const { data: inserted, error: insErr } = await supabaseAdmin
70
+ .from("mailbox_messages")
71
+ .insert({
72
+ external_id: externalId,
73
+ message_id: pickStr(p, "message_id", "messageId") ?? externalId,
74
+ from_email: fromEmail,
75
+ from_name: fromName,
76
+ subject,
77
+ body_text: bodyText,
78
+ body_html: bodyHtml,
79
+ received_at: receivedAt,
80
+ status: "new",
81
+ })
82
+ .select("id")
83
+ .single();
84
+ if (insErr) {
85
+ // Duplicate race → markeer alsnog als consumed
86
+ if (insErr.code === "23505") {
87
+ return { inserted: false, messageId: null };
88
+ }
89
+ throw new Error(insErr.message);
62
90
  }
91
+ return { inserted: true, messageId: inserted.id };
92
+ }
93
+ // ─── Ingest één specifiek event (vanuit realtime subscription) ───────────────
94
+ export const ingestEmailEvent = createServerFn({ method: "POST" })
95
+ .middleware([requireSupabaseAuth])
96
+ .inputValidator((input) => z.object({ eventId: z.string().uuid() }).parse(input))
97
+ .handler(async ({ data }) => {
98
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
99
+ const { data: ev, error: evErr } = await supabaseAdmin
100
+ .from("email_auth_events")
101
+ .select("id, connection_id, external_id, payload, received_at, consumed_at")
102
+ .eq("id", data.eventId)
103
+ .maybeSingle();
104
+ if (evErr)
105
+ throw new Error(evErr.message);
106
+ if (!ev)
107
+ return { ok: false, error: "Event niet gevonden" };
108
+ if (ev.consumed_at)
109
+ return { ok: true, inserted: 0, autoProcessed: 0, autoApproved: 0 };
110
+ const res = await ingestSingleEvent(supabaseAdmin, ev.connection_id, ev);
111
+ await supabaseAdmin
112
+ .from("email_auth_events")
113
+ .update({ consumed_at: new Date().toISOString() })
114
+ .eq("id", ev.id);
115
+ // AI-verwerking uitgeschakeld — wacht op externe model-koppeling
116
+ const autoProcessed = 0;
117
+ const autoApproved = 0;
118
+ return { ok: true, inserted: res.inserted ? 1 : 0, autoProcessed, autoApproved };
63
119
  });
64
- // ─── Polling ─────────────────────────────────────────────────────────────────
65
- export const pollMailbox = createServerFn({ method: "POST" })
120
+ // ─── Backfill: haal alle nog niet-consumed events op voor 'orders/receive' ───
121
+ export const backfillRecentEvents = createServerFn({ method: "POST" })
66
122
  .middleware([requireSupabaseAuth])
67
123
  .handler(async ({ context }) => {
68
- try {
69
- const res = await invokeMailboxImap(context.supabase, "poll");
70
- let autoProcessed = 0;
71
- let autoApproved = 0;
72
- if (res.ok && (res.inserted ?? 0) > 0) {
73
- const { autoProcessNewMessagesInternal } = await import("./mailbox-auto.server");
74
- const r = await autoProcessNewMessagesInternal();
75
- autoProcessed = r.processed;
76
- autoApproved = r.approved;
124
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
125
+ // Welke connection is gekoppeld aan orders/receive?
126
+ const { data: active, error: actErr } = await context.supabase.rpc("email_auth_for_purpose", {
127
+ _purpose: "orders",
128
+ _direction: "receive",
129
+ });
130
+ if (actErr)
131
+ return { ok: false, error: actErr.message };
132
+ const row = Array.isArray(active) ? active[0] : null;
133
+ if (!row?.id)
134
+ return { ok: false, error: "Geen actieve koppeling voor orders/receive" };
135
+ const { data: events, error: evErr } = await supabaseAdmin
136
+ .from("email_auth_events")
137
+ .select("id, connection_id, external_id, payload, received_at")
138
+ .eq("connection_id", row.id)
139
+ .is("consumed_at", null)
140
+ .order("received_at", { ascending: true })
141
+ .limit(200);
142
+ if (evErr)
143
+ return { ok: false, error: evErr.message };
144
+ let inserted = 0;
145
+ const consumedIds = [];
146
+ for (const ev of events ?? []) {
147
+ try {
148
+ const res = await ingestSingleEvent(supabaseAdmin, ev.connection_id, ev);
149
+ if (res.inserted)
150
+ inserted++;
151
+ consumedIds.push(ev.id);
152
+ }
153
+ catch {
154
+ // Fout → niet markeren als consumed, volgende ronde opnieuw proberen
77
155
  }
78
- return { ok: !!res.ok, inserted: res.inserted ?? 0, autoProcessed, autoApproved, error: res.error };
79
156
  }
80
- catch (e) {
81
- return { ok: false, error: e?.message ?? "Onbekende fout", inserted: 0 };
157
+ if (consumedIds.length > 0) {
158
+ await supabaseAdmin
159
+ .from("email_auth_events")
160
+ .update({ consumed_at: new Date().toISOString() })
161
+ .in("id", consumedIds);
82
162
  }
163
+ // AI-verwerking uitgeschakeld — wacht op externe model-koppeling
164
+ const autoProcessed = 0;
165
+ const autoApproved = 0;
166
+ return { ok: true, inserted, autoProcessed, autoApproved };
83
167
  });
84
168
  // ─── Messages & Proposals ────────────────────────────────────────────────────
85
169
  export const listMessages = createServerFn({ method: "GET" })
@@ -103,112 +187,19 @@ export const parseMessage = createServerFn({ method: "POST" })
103
187
  .from("mailbox_messages").select("*").eq("id", data.messageId).single();
104
188
  if (msgErr || !msg)
105
189
  throw new Error("Bericht niet gevonden");
190
+ // AI-parsing is uitgeschakeld tot de externe model-koppeling klaar is.
191
+ // Het bericht blijft bewaard; status gaat naar "failed" met een duidelijke melding.
192
+ const msg2 = msg;
106
193
  await supabaseAdmin.from("mailbox_messages")
107
- .update({ status: "parsing", error_message: null }).eq("id", msg.id);
108
- try {
109
- // Templatevelden ophalen
110
- const { data: fields } = await supabaseAdmin
111
- .from("mailbox_order_template_fields")
112
- .select("*")
113
- .order("sort_order", { ascending: true });
114
- const templateFields = (fields ?? []).filter((f) => f.ai_enabled);
115
- // Databron-catalogi
116
- const [{ data: products }, { data: customers }, { data: suppliers }] = await Promise.all([
117
- supabaseAdmin.from("products").select("id, product, product_type").order("product").range(0, 1999),
118
- supabaseAdmin.from("customers").select("id, company_name, email").eq("is_active", true).order("company_name").range(0, 4999),
119
- supabaseAdmin.from("suppliers").select("id, name").order("name").range(0, 999),
120
- ]);
121
- const catalog = (products ?? []).map((p) => ({ id: p.id, name: p.product }));
122
- const plantTypeSet = new Set();
123
- (products ?? []).forEach((p) => { if (p.product_type)
124
- plantTypeSet.add(p.product_type); });
125
- const plantTypes = Array.from(plantTypeSet).sort().map((v) => ({ id: v, name: v }));
126
- // Bouw spec met allowed values per database-veld
127
- const spec = templateFields.map((f) => {
128
- let allowed;
129
- if (f.source === "database") {
130
- switch (f.field_type) {
131
- case "customer":
132
- allowed = (customers ?? []).map((c) => ({ id: c.id, name: c.company_name }));
133
- break;
134
- case "product":
135
- allowed = catalog;
136
- break;
137
- case "plant_type":
138
- allowed = plantTypes;
139
- break;
140
- case "supplier":
141
- allowed = (suppliers ?? []).map((s) => ({ id: s.id, name: s.name }));
142
- break;
143
- }
144
- }
145
- return {
146
- key: f.key,
147
- label: f.label,
148
- field_type: f.field_type,
149
- ai_hint: f.ai_hint,
150
- allowed,
151
- };
152
- });
153
- const { parseOrderEmail } = await import("./mailbox-ai.server");
154
- const parsed = await parseOrderEmail(msg.body_text ?? msg.body_html ?? "", msg.subject ?? "", spec, catalog);
155
- // Backwards-compat: leid klant/leverdatum/notes af voor de bestaande kolommen
156
- const customerId = parsed.fields["customer"]?.value;
157
- const deliveryDate = parsed.fields["delivery_date"]?.value;
158
- const notesValue = parsed.fields["notes"]?.value;
159
- const matchedCustomerId = typeof customerId === "string" && customerId.length === 36 ? customerId : null;
160
- // Fallback: match op e-mail als AI niets vond
161
- let finalCustomerId = matchedCustomerId;
162
- if (!finalCustomerId && msg.from_email) {
163
- const { data: customer } = await supabaseAdmin
164
- .from("customers").select("id").eq("email", msg.from_email).maybeSingle();
165
- finalCustomerId = customer?.id ?? null;
166
- }
167
- const { data: proposal, error: propErr } = await supabaseAdmin
168
- .from("mailbox_order_proposals").insert({
169
- message_id: msg.id,
170
- matched_customer_id: finalCustomerId,
171
- confidence: parsed.overall_confidence,
172
- delivery_date: typeof deliveryDate === "string" ? deliveryDate : null,
173
- notes: typeof notesValue === "string" ? notesValue : null,
174
- parsed_payload: parsed,
175
- status: "pending",
176
- }).select("id").single();
177
- if (propErr)
178
- throw new Error(propErr.message);
179
- // Sla alle veldwaarden op
180
- const fieldRows = Object.entries(parsed.fields).map(([key, v]) => ({
181
- proposal_id: proposal.id,
182
- field_key: key,
183
- value: v.value === undefined ? null : v.value,
184
- ai_confidence: v.confidence,
185
- ai_filled: v.value !== null && v.value !== undefined,
186
- needs_review: v.needs_review,
187
- }));
188
- if (fieldRows.length > 0) {
189
- await supabaseAdmin.from("mailbox_proposal_field_values").insert(fieldRows);
190
- }
191
- if (parsed.lines.length > 0) {
192
- const linesToInsert = parsed.lines.map((l, idx) => ({
193
- proposal_id: proposal.id,
194
- product_id: l.product_id,
195
- raw_product_text: l.raw_product_text,
196
- quantity: l.quantity,
197
- unit: l.unit,
198
- match_confidence: l.match_confidence,
199
- sort_order: idx,
200
- }));
201
- await supabaseAdmin.from("mailbox_proposal_lines").insert(linesToInsert);
202
- }
203
- await supabaseAdmin.from("mailbox_messages")
204
- .update({ status: "parsed" }).eq("id", msg.id);
205
- return { ok: true, proposalId: proposal.id };
206
- }
207
- catch (e) {
208
- await supabaseAdmin.from("mailbox_messages")
209
- .update({ status: "failed", error_message: e?.message ?? "Parsefout" }).eq("id", msg.id);
210
- return { ok: false, error: e?.message ?? "Parsefout" };
211
- }
194
+ .update({
195
+ status: "failed",
196
+ error_message: "AI-parsing is tijdelijk uitgeschakeld — wacht op externe model-koppeling.",
197
+ })
198
+ .eq("id", msg2.id);
199
+ return {
200
+ ok: false,
201
+ error: "AI-parsing is tijdelijk uitgeschakeld wacht op externe model-koppeling.",
202
+ };
212
203
  });
213
204
  export const listProposals = createServerFn({ method: "GET" })
214
205
  .middleware([requireSupabaseAuth])
@@ -250,7 +241,6 @@ export const approveProposal = createServerFn({ method: "POST" })
250
241
  }).parse(input))
251
242
  .handler(async ({ data, context }) => {
252
243
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
253
- // Bouw custom_fields map vanuit alle templatevelden
254
244
  const customFields = {};
255
245
  (data.fieldValues ?? []).forEach((fv) => {
256
246
  customFields[fv.field_key] = fv.value;
@@ -280,7 +270,6 @@ export const approveProposal = createServerFn({ method: "POST" })
280
270
  const { error: itemsErr } = await supabaseAdmin.from("order_items").insert(items);
281
271
  if (itemsErr)
282
272
  throw new Error(itemsErr.message);
283
- // Sla finale veldwaarden op (overschrijft AI-waarden)
284
273
  if (data.fieldValues && data.fieldValues.length > 0) {
285
274
  for (const fv of data.fieldValues) {
286
275
  await supabaseAdmin.from("mailbox_proposal_field_values").upsert({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowselections/mailbox-orders",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "private": false,
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -46,7 +46,7 @@
46
46
  "@cloudflare/vite-plugin": "^1.25.5",
47
47
  "@eslint/js": "^9.32.0",
48
48
  "@flowselections/core": "^1.0.11",
49
- "@lovable.dev/vite-tanstack-config": "^1.2.0",
49
+ "@lovable.dev/vite-tanstack-config": "2.5.3",
50
50
  "@rsbuild/core": "^1.0.0",
51
51
  "@tailwindcss/vite": "^4.2.1",
52
52
  "@tanstack/react-query": "^5.83.0",
@@ -71,24 +71,25 @@
71
71
  "typescript": "^5.8.3",
72
72
  "typescript-eslint": "^8.56.1",
73
73
  "vite": "^7.3.1",
74
- "vite-tsconfig-paths": "^6.0.2"
74
+ "vite-tsconfig-paths": "^6.0.2",
75
+ "nitro": "3.0.260603-beta"
75
76
  },
76
77
  "flowselections": {
77
78
  "moduleId": "Mailbox",
78
79
  "pages": [
79
80
  {
80
81
  "export": "MailboxInboxPage",
81
- "route": "mail-box-voor-bestellingen-erp-kwekers/inbox",
82
+ "route": "mailbox-orders",
82
83
  "title": "Inbox"
83
84
  },
84
85
  {
85
86
  "export": "MailboxProposalsPage",
86
- "route": "mail-box-voor-bestellingen-erp-kwekers/voorstellen",
87
+ "route": "mailbox-orders/voorstellen",
87
88
  "title": "Voorstellen"
88
89
  },
89
90
  {
90
91
  "export": "MailboxProcessedPage",
91
- "route": "mail-box-voor-bestellingen-erp-kwekers/verwerkt",
92
+ "route": "mailbox-orders/verwerkt",
92
93
  "title": "Verwerkt"
93
94
  }
94
95
  ]
@@ -1,15 +0,0 @@
1
- # Module assets
2
-
3
- Raster-afbeeldingen (PNG, JPEG) die deze module nodig heeft, worden hier geplaatst.
4
-
5
- De setup CLI kopieert de inhoud van `public/` naar:
6
- `shell/public/flowselections-assets/<module-id>/`
7
-
8
- In de module-code verwijs je naar de afbeelding via een pad-string (GEEN import):
9
- ```ts
10
- const MODULE_LOGO = '/flowselections-assets/<module-id>/logo.png'
11
- ```
12
-
13
- Vervang `<module-id>` door de id die je in `src/index.ts` als moduleConfig-id hebt opgegeven.
14
-
15
- Zie ook: AGENTS.md sectie "Afbeeldingen"