-
-
-
-
1300 BRO NET
-
Mon-Sun, 8am - 8pm
-
-
-
-
-
-
-
-
-
support@bronet.com.au
-
Response within 24 hours
-
-
-
-
-
-
-
-
-
Live Chat
-
Available in Customer Portal
-
-
+ {/* Quick Actions */}
+
- {/* Right Column - FAQ & Form */}
-
-
- Frequently Asked Questions
-
-
- How long does it take to switch?
+
+
+ {/* FAQ Section */}
+
+
+
+
Frequently Asked Questions
+
+
+
+ How long does it take to switch?
Most switches can be completed within 15-60 minutes if you have an existing NBN connection. For new connections, it usually takes 1-3 business days depending on NBN Co technician availability.
-
- Do I need a new modem?
+
+ Do I need a new modem?
- Not necessarily! If you have a BYO modem that is NBN compatible (VDSL for FTTN, or WAN port for FTTP/HFC), you can use it. We also sell pre-configured eero 6+ routers if you want an upgrade.
+ Not necessarily! If you have a BYO modem that is NBN compatible, you can use it. We also offer eero Wi-Fi 7 routers that you can purchase outright or pay off over time.
-
- What is CGNAT?
+
+ What is CGNAT?
- We use CGNAT (Carrier Grade NAT) to manage IPv4 addresses. This works fine for 99% of users. If you need port forwarding for hosting servers or specific gaming setups, you can add a Static IP for $5/mo in the portal.
+ We use CGNAT (Carrier Grade NAT) to manage IPv4 addresses. This works fine for 99% of users. If you need port forwarding, you can add a Static IP for $5/mo in the portal.
-
- Are there any cancellation fees?
+
+ Are there any cancellation fees?
Nope! We don't believe in locking you in. If you leave, you just pay for the remainder of your current billing month. No exit fees, ever.
+
+ What speeds can I get?
+
+ This depends on your connection type and location. Check your address on our coverage page to see what speeds are available. We offer plans from 50 Mbps up to 2000 Mbps.
+
+
+
+ How do I change my plan?
+
+ You can change your plan anytime from your customer dashboard. Plan changes are processed instantly and your new speed will be active within minutes. No fees apply.
+
+
+
+ I'm moving house. What do I do?
+
+ Let us know at least 2 weeks before your move date. We'll check NBN availability at your new address and arrange the transfer. If NBN isn't available, you can cancel without any fees.
+
+
+
+ Why is my speed slower than expected?
+
+ Speed can be affected by your modem, Wi-Fi interference, or the number of devices connected. Try a wired ethernet connection to test your actual line speed. If issues persist, run a speed test from your dashboard and contact us.
+
+
+
+ What payment methods do you accept?
+
+ We accept Visa, Mastercard, American Express, and BECS Direct Debit from Australian bank accounts. All payments are processed securely through Stripe.
+
+
+
+ When is my bill due?
+
+ Your bill is due on the same date each month based on when you signed up. You'll receive an email reminder 7 days before your payment is processed. View your billing date in your dashboard.
+
+
-
-
- Send us a message
+
+
+
+
+
+ {/* Contact Form */}
+
+
+
+
Send us a message
+
-
+
+
+ )}
-
+
);
-}
\ No newline at end of file
+}
diff --git a/client/src/pages/terms.tsx b/client/src/pages/terms.tsx
new file mode 100644
index 0000000..605cf33
--- /dev/null
+++ b/client/src/pages/terms.tsx
@@ -0,0 +1,194 @@
+export default function Terms() {
+ return (
+
+
Terms of Service
+
Last updated: December 2025
+
+
+
+ 1. Agreement to Terms
+
+ These Terms of Service ("Terms") constitute a legally binding agreement between you and BroNET Pty Ltd (ABN 12 345 678 901) ("BroNET", "we", "us", "our") governing your access to and use of our NBN internet services. By signing up for our services or using our website, you agree to be bound by these Terms.
+
+
+
+
+ 2. Service Description
+
+ BroNET provides residential and business NBN internet services as a retail service provider (RSP) operating on the NBN Co network. Our services include:
+
+
+ - NBN internet connectivity at various speed tiers
+ - Customer portal access for account management
+ - Technical support during business hours
+ - Optional modem/router supply
+
+
+ Service availability and speeds are subject to NBN Co infrastructure, network congestion, and your chosen plan. Actual speeds may vary from advertised maximum speeds.
+
+
+
+
+ 3. Acceptable Use Policy
+
+ You must use our services lawfully and responsibly. Prohibited activities include:
+
+
+ - Illegal activities, including copyright infringement and distribution of illegal content
+ - Sending spam, malware, or engaging in phishing
+ - Hacking, network intrusion, or denial-of-service attacks
+ - Excessive usage that degrades network performance for other users
+ - Reselling or sharing your service without authorization
+ - Bypassing security measures or authentication systems
+
+
+ Violations may result in service suspension or termination without refund.
+
+
+
+
+ 4. Account Responsibilities
+
+ You are responsible for:
+
+
+ - Maintaining the confidentiality of your account credentials
+ - All activities that occur under your account
+ - Providing accurate and current contact information
+ - Securing your home network with appropriate passwords and encryption
+ - Ensuring timely payment of all charges
+ - Notifying us immediately of unauthorized use
+
+
+
+
+ 5. Billing and Payment
+
+ 5.1 Charges: You agree to pay all charges for the services you select, including monthly plan fees, connection fees, hardware purchases, and any applicable usage charges. All prices are in Australian Dollars (AUD) and include GST unless otherwise stated.
+
+
+ 5.2 Billing Cycle: Services are billed monthly in advance. Your billing cycle begins on your activation date or the date specified in your plan.
+
+
+ 5.3 Payment Methods: We accept credit card, debit card, and direct debit. Failed payments may result in service suspension and late fees.
+
+
+ 5.4 Price Changes: We may change our prices with at least 30 days' written notice. Continued use of services after price changes constitutes acceptance.
+
+
+
+
+ 6. Service Level and Performance
+
+ 6.1 Typical Speeds: Advertised speeds represent maximum possible speeds. Actual speeds depend on various factors including NBN technology type, network congestion, distance from exchange, and in-home wiring.
+
+
+ 6.2 Availability: We aim to provide 24/7 service but cannot guarantee uninterrupted access. Scheduled maintenance will be notified in advance where possible.
+
+
+ 6.3 NBN Co Dependencies: Our service relies on NBN Co infrastructure. Outages or degradation caused by NBN Co are beyond our control.
+
+
+
+
+ 7. Contract Terms and Cancellation
+
+ 7.1 No Lock-in: Our standard plans have no minimum contract term. You may cancel at any time with 30 days' notice.
+
+
+ 7.2 Promotional Offers: Special promotions may include minimum terms. Early termination of promotional contracts may incur break fees as disclosed at sign-up.
+
+
+ 7.3 Cancellation Process: To cancel, submit a request through your customer portal or contact support. You remain responsible for charges until the end of your notice period.
+
+
+ 7.4 No Refunds: Fees paid are non-refundable except as required by the Australian Consumer Law or our service guarantee.
+
+
+
+
+ 8. Suspension and Termination
+
+ We may suspend or terminate your service immediately if:
+
+
+ - You violate these Terms or our Acceptable Use Policy
+ - Your account is overdue by more than 14 days
+ - You provide false or fraudulent information
+ - Required by law or court order
+ - To protect our network or other customers
+
+
+ We will attempt to notify you before suspension except in urgent circumstances.
+
+
+
+
+ 9. Limitation of Liability
+
+ To the maximum extent permitted by law:
+
+
+ - Our total liability for any claim is limited to the amount you paid in the preceding 3 months
+ - We are not liable for indirect, consequential, or incidental damages
+ - We are not responsible for data loss, business interruption, or lost profits
+ - We do not warrant that the service will be error-free or uninterrupted
+
+
+ Nothing in these Terms excludes guarantees or warranties under the Australian Consumer Law that cannot be excluded.
+
+
+
+
+ 10. Australian Consumer Law Rights
+
+ Our services come with guarantees that cannot be excluded under the Australian Consumer Law. You are entitled to a replacement or refund for a major failure and compensation for any other reasonably foreseeable loss or damage. You are also entitled to have the services supplied again if they fail to be of acceptable quality and the failure does not amount to a major failure.
+
+
+
+
+ 11. Telecommunications Consumer Protections
+
+ We comply with the Telecommunications Consumer Protections (TCP) Code. This includes requirements for billing accuracy, complaint handling, contract terms, and customer transfers. For information about your rights under the TCP Code, visit www.acma.gov.au.
+
+
+
+
+ 12. Dispute Resolution
+
+ 12.1 Internal Complaints: If you have a complaint, contact our support team at support@brointernet.com or 07 4276 6387. We aim to resolve complaints within 15 business days.
+
+
+ 12.2 External Resolution: If you are not satisfied with our response, you may escalate to the Telecommunications Industry Ombudsman (TIO) at www.tio.com.au or 1800 062 058.
+
+
+
+
+ 13. Governing Law
+
+ These Terms are governed by the laws of New South Wales, Australia. Any disputes will be subject to the exclusive jurisdiction of the courts of New South Wales.
+
+
+
+
+ 14. Changes to Terms
+
+ We may update these Terms with 30 days' notice by email or website notification. Material changes will be highlighted. Continued use of our services after the effective date constitutes acceptance of the new Terms.
+
+
+
+
+ 15. Contact Information
+
+
BroNET Pty Ltd
+
ABN: 12 345 678 901
+
Email: support@brointernet.com
+ Phone: 07 4276 6387
+ Hours: 8am - 8pm AEDT, 7 days
+ Post: PO Box 12345, Sydney NSW 2000
+
+
+
+
+ );
+}
diff --git a/generated-icon.png b/generated-icon.png
new file mode 100644
index 0000000..1b655a2
Binary files /dev/null and b/generated-icon.png differ
diff --git a/package-lock.json b/package-lock.json
index 42d69e7..dda4d0e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,11 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "@cloudflare/speedtest": "^1.7.0",
"@hookform/resolvers": "^3.10.0",
+ "@intercom/messenger-js-sdk": "^0.0.18",
"@jridgewell/trace-mapping": "^0.3.25",
+ "@ookla/speedtest-js-sdk": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -39,21 +42,27 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.60.5",
+ "@types/bcrypt": "^6.0.0",
+ "bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.39.3",
- "drizzle-zod": "^0.7.0",
+ "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
+ "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.545.0",
"memorystore": "^1.6.7",
"next-themes": "^0.4.6",
+ "openai": "^6.15.0",
+ "p-limit": "^7.2.0",
+ "p-retry": "^7.1.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pg": "^8.16.3",
@@ -63,7 +72,10 @@
"react-hook-form": "^7.66.0",
"react-resizable-panels": "^2.1.9",
"recharts": "^2.15.4",
+ "resend": "^4.0.0",
"sonner": "^2.0.7",
+ "stripe": "^20.0.0",
+ "stripe-replit-sync": "^1.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
@@ -71,7 +83,7 @@
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.25.76",
- "zod-validation-error": "^3.4.0"
+ "zod-validation-error": "^3.5.4"
},
"devDependencies": {
"@replit/vite-plugin-cartographer": "^0.4.4",
@@ -392,6 +404,20 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@cloudflare/speedtest": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/speedtest/-/speedtest-1.7.0.tgz",
+ "integrity": "sha512-fITLPVaprVMgK3v0JvMA4dJhlKffkTHfjNBQ+EYWPkIHVhGnfYSalsbh7pRF403abY69qbJGr7xCVfeRKttbPg==",
+ "license": "MIT",
+ "dependencies": {
+ "d3-scale": "^4.0.2",
+ "isomorphic-fetch": "^3.0.0",
+ "lodash.memoize": "^4.1.2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@@ -1329,6 +1355,29 @@
"react-hook-form": "^7.0.0"
}
},
+ "node_modules/@intercom/messenger-js-sdk": {
+ "version": "0.0.18",
+ "resolved": "https://registry.npmjs.org/@intercom/messenger-js-sdk/-/messenger-js-sdk-0.0.18.tgz",
+ "integrity": "sha512-OQbhnNh26cdI0ddIVh67JOGnSTFAHrbKF5atXuOeWpDF2Ups3O7Do1Oz42BrvQA/o0AZF+1Wqaxtc3kq70wc6w==",
+ "license": "MIT"
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1387,6 +1436,44 @@
"@types/pg": "8.11.6"
}
},
+ "node_modules/@one-ini/wasm": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+ "license": "MIT"
+ },
+ "node_modules/@ookla/speedtest-js-sdk": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@ookla/speedtest-js-sdk/-/speedtest-js-sdk-2.1.1.tgz",
+ "integrity": "sha512-tzFOJiuJvZzVgV14CSTO20jXog49UkhuLnHLmfhgftNvsLRdDdXw7G4GV9ViG1fZ33B+RpoX4UNqe0gbb1HVKg==",
+ "license": "UNLICENSED",
+ "dependencies": {
+ "axios": "^1.6.8",
+ "bowser": "^2.7.0",
+ "build-url": "^2.0.0",
+ "compare-versions": "^3.5.1",
+ "config": "^3.2.4",
+ "custom-event-polyfill": "^1.0.7",
+ "deepdash": "^5.0.1",
+ "detect-node": "^2.0.4",
+ "js-md5": "^0.7.3",
+ "lodash": "^4.17.21",
+ "mini-signals": "^1.2.0",
+ "object.fromentries": "^2.0.2",
+ "qs": "^6.11.1",
+ "regenerator-runtime": "^0.14.0"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4025,6 +4112,19 @@
"win32"
]
},
+ "node_modules/@selderee/plugin-htmlparser2": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
+ "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "selderee": "^0.11.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
@@ -4368,6 +4468,15 @@
"@babel/types": "^7.20.7"
}
},
+ "node_modules/@types/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@@ -4525,7 +4634,6 @@
"version": "20.19.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -4664,6 +4772,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/abbrev": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -4677,6 +4794,30 @@
"node": ">= 0.6"
}
},
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/aria-hidden": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz",
@@ -4689,12 +4830,64 @@
"node": ">=10"
}
},
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -4733,6 +4926,38 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.8.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.21.tgz",
@@ -4743,6 +4968,20 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -4782,6 +5021,21 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/bowser": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz",
+ "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
"node_modules/browserslist": {
"version": "4.27.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
@@ -4816,6 +5070,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4837,6 +5097,13 @@
"node": ">=6.14.2"
}
},
+ "node_modules/build-url": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/build-url/-/build-url-2.0.0.tgz",
+ "integrity": "sha512-LYvvOlDc9jT07wFXTQTKoQLYaXIJriVl/DgatTsSzY963+ip1O7M6G/jWBrlKKJ1L7HGD3oK+WykmOvbcSYXlQ==",
+ "deprecated": "This package is no longer maintained",
+ "license": "MIT"
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -4847,16 +5114,44 @@
}
},
"node_modules/call-bind": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
- "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
- "set-function-length": "^1.2.1"
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
@@ -4920,6 +5215,73 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/compare-versions": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz",
+ "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==",
+ "license": "MIT"
+ },
+ "node_modules/config": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/config/-/config-3.3.12.tgz",
+ "integrity": "sha512-Vmx389R/QVM3foxqBzXO8t2tUikYZP64Q6vQxGrsMpREeJc/aWRnPRERXWsYzOHAumx/AOoILWe6nU3ZJL+6Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "json5": "^2.2.3"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
"node_modules/connect-pg-simple": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
@@ -4975,12 +5337,32 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
+ "node_modules/custom-event-polyfill": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
+ "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
+ "license": "MIT"
+ },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -5102,6 +5484,57 @@
"node": ">=12"
}
},
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@@ -5140,6 +5573,25 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
+ "node_modules/deepdash": {
+ "version": "5.3.9",
+ "resolved": "https://registry.npmjs.org/deepdash/-/deepdash-5.3.9.tgz",
+ "integrity": "sha512-GRzJ0q9PDj2T+J2fX+b+TlUa2NlZ11l6vJ8LHNKVGeZ8CfxCuJaCychTq07iDRTvlfO8435jlvVS1QXBrW9kMg==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -5157,13 +5609,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/depd": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
- "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
"engines": {
- "node": ">= 0.8"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
}
},
"node_modules/destroy": {
@@ -5185,6 +5663,12 @@
"node": ">=8"
}
},
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "license": "MIT"
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -5200,6 +5684,61 @@
"csstype": "^3.0.2"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/drizzle-kit": {
"version": "0.31.4",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz",
@@ -5334,15 +5873,74 @@
}
},
"node_modules/drizzle-zod": {
- "version": "0.7.0",
- "resolved": "https://registry.npmjs.org/drizzle-zod/-/drizzle-zod-0.7.0.tgz",
- "integrity": "sha512-xgCRYYVEzRkeXTS33GSMgoowe3vKsMNBjSI+cwG1oLQVEhAWWbqtb/AAMlm7tkmV4fG/uJjEmWzdzlEmTgWOoQ==",
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/drizzle-zod/-/drizzle-zod-0.7.1.tgz",
+ "integrity": "sha512-nZzALOdz44/AL2U005UlmMqaQ1qe5JfanvLujiTHiiT8+vZJTBFhj3pY4Vk+L6UWyKFfNmLhk602Hn4kCTynKQ==",
"license": "Apache-2.0",
"peerDependencies": {
"drizzle-orm": ">=0.36.0",
"zod": ">=3.0.0"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "license": "MIT"
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/editorconfig": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
+ "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@one-ini/wasm": "0.1.1",
+ "commander": "^10.0.0",
+ "minimatch": "9.0.1",
+ "semver": "^7.5.3"
+ },
+ "bin": {
+ "editorconfig": "bin/editorconfig"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/editorconfig/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5381,6 +5979,12 @@
"embla-carousel": "8.6.0"
}
},
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -5404,14 +6008,91 @@
"node": ">=10.13.0"
}
},
- "node_modules/es-define-property": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
- "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.1",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
+ "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
"license": "MIT",
"dependencies": {
- "get-intrinsic": "^1.2.4"
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
},
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
"engines": {
"node": ">= 0.4"
}
@@ -5425,6 +6106,50 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@@ -5621,6 +6346,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/fast-deep-equal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
+ "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
+ "license": "MIT"
+ },
"node_modules/fast-equals": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz",
@@ -5681,6 +6412,73 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5764,6 +6562,44 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -5775,16 +6611,21 @@
}
},
"node_modules/get-intrinsic": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
- "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
- "has-proto": "^1.0.1",
- "has-symbols": "^1.0.3",
- "hasown": "^2.0.0"
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@@ -5801,6 +6642,36 @@
"node": ">=6"
}
},
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/get-tsconfig": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz",
@@ -5814,41 +6685,61 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
- "node_modules/gopd": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
- "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
- "license": "MIT",
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "license": "ISC",
"dependencies": {
- "get-intrinsic": "^1.1.3"
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "dev": true,
- "license": "ISC"
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
},
- "node_modules/has-property-descriptors": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
- "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"license": "MIT",
"dependencies": {
- "es-define-property": "^1.0.0"
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-proto": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
- "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5857,10 +6748,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-symbols": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
- "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5869,6 +6767,60 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -5881,6 +6833,41 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-to-text": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
+ "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@selderee/plugin-htmlparser2": "^0.11.0",
+ "deepmerge": "^4.3.1",
+ "dom-serializer": "^2.0.0",
+ "htmlparser2": "^8.0.2",
+ "selderee": "^0.11.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -5915,6 +6902,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -5924,6 +6917,20 @@
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -5942,6 +6949,386 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-network-error": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
+ "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/isomorphic-fetch": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
+ "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
+ "license": "MIT",
+ "dependencies": {
+ "node-fetch": "^2.6.1",
+ "whatwg-fetch": "^3.4.1"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -5952,6 +7339,42 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/js-beautify": {
+ "version": "1.15.4",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
+ "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
+ "license": "MIT",
+ "dependencies": {
+ "config-chain": "^1.1.13",
+ "editorconfig": "^1.0.4",
+ "glob": "^10.4.2",
+ "js-cookie": "^3.0.5",
+ "nopt": "^7.2.1"
+ },
+ "bin": {
+ "css-beautify": "js/bin/css-beautify.js",
+ "html-beautify": "js/bin/html-beautify.js",
+ "js-beautify": "js/bin/js-beautify.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/js-cookie": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/js-md5": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.7.3.tgz",
+ "integrity": "sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==",
+ "license": "MIT"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5975,13 +7398,76 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
"license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
- "engines": {
- "node": ">=6"
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/leac": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
+ "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
}
},
"node_modules/lightningcss": {
@@ -6251,6 +7737,60 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
+ "node_modules/lodash-es": {
+ "version": "4.17.22",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
+ "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.memoize": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -6292,6 +7832,15 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -6381,6 +7930,36 @@
"node": ">= 0.6"
}
},
+ "node_modules/mini-signals": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mini-signals/-/mini-signals-1.2.0.tgz",
+ "integrity": "sha512-alffqMkGCjjTSwvYMVLx+7QeJ6sTuxbXqBkP21my4iWU5+QpTQAJt3h7htA1OKm9F3BpMM0vnu72QIoiJakrLA==",
+ "license": "MIT"
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
+ "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -6451,12 +8030,40 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
+ "node_modules/node-addon-api": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
+ "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
"node_modules/node-gyp-build": {
- "version": "4.8.3",
- "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
- "integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==",
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
- "optional": true,
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
@@ -6470,6 +8077,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nopt": {
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^2.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
@@ -6490,10 +8112,57 @@
}
},
"node_modules/object-inspect": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
- "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==",
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
"license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
"engines": {
"node": ">= 0.4"
},
@@ -6529,6 +8198,93 @@
"node": ">= 0.8"
}
},
+ "node_modules/openai": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz",
+ "integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==",
+ "license": "Apache-2.0",
+ "bin": {
+ "openai": "bin/cli"
+ },
+ "peerDependencies": {
+ "ws": "^8.18.0",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "ws": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.2.0.tgz",
+ "integrity": "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-retry": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
+ "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
+ "license": "MIT",
+ "dependencies": {
+ "is-network-error": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/parseley": {
+ "version": "0.12.1",
+ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
+ "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
+ "license": "MIT",
+ "dependencies": {
+ "leac": "^0.6.0",
+ "peberminta": "^0.9.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -6575,6 +8331,37 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -6586,6 +8373,15 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
+ "node_modules/peberminta": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
+ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@@ -6635,6 +8431,22 @@
"node": ">=4.0.0"
}
},
+ "node_modules/pg-node-migrations": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/pg-node-migrations/-/pg-node-migrations-0.0.8.tgz",
+ "integrity": "sha512-44cMl9umOmCv0hzZyEcvjEq8Bm8u7mrzggZ06qXTJVSsMMB4j2OsjG+rSp+uzeKWyP2Vu0K9Ye2wKtjFUJwrdw==",
+ "license": "MIT",
+ "dependencies": {
+ "pg": "^8.6.0",
+ "sql-template-strings": "^2.2.2"
+ },
+ "bin": {
+ "pg-validate-migrations": "dist/bin/validate.js"
+ },
+ "engines": {
+ "node": ">10.17.0"
+ }
+ },
"node_modules/pg-numeric": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
@@ -6763,6 +8575,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -6866,6 +8687,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "license": "ISC"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -6879,6 +8706,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
@@ -7007,6 +8840,15 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
+ "node_modules/react-promise-suspense": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
+ "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^2.0.1"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -7156,13 +8998,128 @@
"decimal.js-light": "^2.4.1"
}
},
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+ "license": "MIT"
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/regexparam": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz",
"integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==",
"license": "MIT",
- "engines": {
- "node": ">=8"
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resend": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resend/-/resend-4.0.0.tgz",
+ "integrity": "sha512-rDX0rspl/XcmC2JV2V5obQvRX2arzxXUvNFUDMOv5ObBLR68+7kigCOysb7+dlkb0JE3erhQG0nHrbBt/ZCWIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-email/render": "0.0.17"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/resend/node_modules/@react-email/render": {
+ "version": "0.0.17",
+ "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz",
+ "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==",
+ "license": "MIT",
+ "dependencies": {
+ "html-to-text": "9.0.5",
+ "js-beautify": "^1.14.11",
+ "react-promise-suspense": "0.3.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ }
+ },
+ "node_modules/resend/node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resend/node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/resend/node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
}
},
"node_modules/resolve-pkg-maps": {
@@ -7217,6 +9174,25 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+ "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -7237,6 +9213,39 @@
],
"license": "MIT"
},
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -7249,6 +9258,18 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/selderee": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
+ "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
+ "license": "MIT",
+ "dependencies": {
+ "parseley": "^0.12.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -7339,22 +9360,317 @@
"node": ">= 0.4"
}
},
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
- "node_modules/side-channel": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
- "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/sonner": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+ "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/sql-template-strings": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/sql-template-strings/-/sql-template-strings-2.2.2.tgz",
+ "integrity": "sha512-UXhXR2869FQaD+GMly8jAMCRZ94nU5KcrFetZfWEMd+LVVG6y0ExgHAhatEcKZ/wk8YcKPdi+hiD2wm75lq3/Q==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
"license": "MIT",
"dependencies": {
- "call-bind": "^1.0.7",
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.4",
- "object-inspect": "^1.13.1"
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
@@ -7363,63 +9679,92 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/sonner": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
- "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
"license": "MIT",
- "peerDependencies": {
- "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
- "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
- }
- },
- "node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
- "license": "BSD-3-Clause",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
"engines": {
- "node": ">=0.10.0"
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
"engines": {
- "node": ">=0.10.0"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
}
},
- "node_modules/split2": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
- "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
- "license": "ISC",
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
"engines": {
- "node": ">= 10.x"
+ "node": ">=8"
}
},
- "node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "node_modules/stripe": {
+ "version": "20.0.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.0.0.tgz",
+ "integrity": "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==",
"license": "MIT",
+ "dependencies": {
+ "qs": "^6.11.0"
+ },
"engines": {
- "node": ">= 0.8"
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "@types/node": ">=16"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/stripe-replit-sync": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/stripe-replit-sync/-/stripe-replit-sync-1.0.0.tgz",
+ "integrity": "sha512-Bkbih3k+IX/GugN8bKWNpwDiI2xkSLOAR+P34W7pjaR8fXyVay/QqztbrvbHZcU1JlfXncmBhuODY5TYY6DXlw==",
+ "dependencies": {
+ "pg": "^8.16.3",
+ "pg-node-migrations": "0.0.8",
+ "ws": "^8.18.0",
+ "yesql": "^7.0.0"
+ },
+ "peerDependencies": {
+ "stripe": "> 11"
}
},
"node_modules/tailwind-merge": {
@@ -7492,6 +9837,12 @@
"node": ">=0.6"
}
},
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -7540,6 +9891,80 @@
"node": ">= 0.6"
}
},
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+ "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@@ -7566,11 +9991,28 @@
"node": ">= 0.8"
}
},
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -7790,6 +10232,128 @@
}
}
},
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-fetch": {
+ "version": "3.6.20",
+ "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
+ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
+ "license": "MIT"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+ "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/wouter": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz",
@@ -7804,6 +10368,97 @@
"react": ">=16.8.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
@@ -7841,6 +10496,24 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yesql": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/yesql/-/yesql-7.0.0.tgz",
+ "integrity": "sha512-sosfr7agy4ibLM7BvXBkM6BpBmKMGuBO8DUYQEuey+QqaqrgW+2bsSg6D050ocBYIz0PuHxUyehyzEztZTU4pw==",
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -7851,15 +10524,15 @@
}
},
"node_modules/zod-validation-error": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz",
- "integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==",
+ "version": "3.5.4",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
+ "integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
- "zod": "^3.18.0"
+ "zod": "^3.24.4"
}
}
}
diff --git a/package.json b/package.json
index 33c7ce8..1c381e1 100644
--- a/package.json
+++ b/package.json
@@ -12,8 +12,11 @@
"db:push": "drizzle-kit push"
},
"dependencies": {
+ "@cloudflare/speedtest": "^1.7.0",
"@hookform/resolvers": "^3.10.0",
+ "@intercom/messenger-js-sdk": "^0.0.18",
"@jridgewell/trace-mapping": "^0.3.25",
+ "@ookla/speedtest-js-sdk": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -42,21 +45,27 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.60.5",
+ "@types/bcrypt": "^6.0.0",
+ "bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"connect-pg-simple": "^10.0.0",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.39.3",
- "drizzle-zod": "^0.7.0",
+ "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
+ "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.545.0",
"memorystore": "^1.6.7",
"next-themes": "^0.4.6",
+ "openai": "^6.15.0",
+ "p-limit": "^7.2.0",
+ "p-retry": "^7.1.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pg": "^8.16.3",
@@ -66,7 +75,10 @@
"react-hook-form": "^7.66.0",
"react-resizable-panels": "^2.1.9",
"recharts": "^2.15.4",
+ "resend": "^4.0.0",
"sonner": "^2.0.7",
+ "stripe": "^20.0.0",
+ "stripe-replit-sync": "^1.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
@@ -74,7 +86,7 @@
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.25.76",
- "zod-validation-error": "^3.4.0"
+ "zod-validation-error": "^3.5.4"
},
"devDependencies": {
"@replit/vite-plugin-cartographer": "^0.4.4",
diff --git a/plans_cards_section.png b/plans_cards_section.png
new file mode 100644
index 0000000..6cd4d82
Binary files /dev/null and b/plans_cards_section.png differ
diff --git a/plans_full.png b/plans_full.png
new file mode 100644
index 0000000..891ed4c
Binary files /dev/null and b/plans_full.png differ
diff --git a/plans_page_view.png b/plans_page_view.png
new file mode 100644
index 0000000..b81fa00
Binary files /dev/null and b/plans_page_view.png differ
diff --git a/public/bronet-logo.svg b/public/bronet-logo.svg
new file mode 100644
index 0000000..a13a87c
--- /dev/null
+++ b/public/bronet-logo.svg
@@ -0,0 +1,10 @@
+
diff --git a/replit.md b/replit.md
new file mode 100644
index 0000000..2377213
--- /dev/null
+++ b/replit.md
@@ -0,0 +1,249 @@
+# BroNET - Australian NBN Internet Service Provider
+
+## Overview
+
+BroNET is a full-stack web application for an Australian NBN (National Broadband Network) internet service provider. The application allows customers to browse internet plans, check coverage availability, create accounts, manage subscriptions, submit support tickets, and view network status. It includes both a customer-facing website and an admin dashboard for incident management.
+
+## User Preferences
+
+Preferred communication style: Simple, everyday language.
+
+## System Architecture
+
+### Frontend Architecture
+- **Framework**: React 18 with TypeScript
+- **Routing**: Wouter (lightweight React router)
+- **State Management**: TanStack React Query for server state, React Context for auth state
+- **Styling**: Tailwind CSS v4 with shadcn/ui component library (New York style)
+- **Theme**: next-themes for dark/light mode support
+- **Build Tool**: Vite with custom plugins for Replit integration
+
+The frontend follows a page-based architecture with shared components:
+- Pages: Home, Plans, Coverage, Support, Auth, Dashboard, Admin, SignupWizard
+- Reusable UI components from shadcn/ui in `client/src/components/ui/`
+- Custom hooks for authentication (`use-user`) and mobile detection (`use-mobile`)
+
+### Backend Architecture
+- **Framework**: Express.js with TypeScript
+- **Database**: PostgreSQL with Drizzle ORM
+- **Session Management**: express-session with connect-pg-simple for persistent sessions
+- **API Design**: RESTful endpoints under `/api/` prefix
+
+The server handles:
+- User authentication (signup, login, logout, session management)
+- Support ticket CRUD operations
+- Network incident management
+- Contact form submissions
+
+### Data Storage
+- **ORM**: Drizzle ORM with PostgreSQL dialect
+- **Schema Location**: `shared/schema.ts` (shared between client and server)
+- **Migrations**: Drizzle Kit with `drizzle-kit push` command
+
+Database tables:
+- `users` - Customer accounts with plan associations
+- `tickets` - Support tickets with status tracking
+- `ticket_replies` - Threaded replies on tickets
+- `incidents` - Network status incidents
+- `contact_messages` - Public contact form submissions
+- `service_qualifications` - NBN service qualification results with LOC ID, CSA ID
+- `service_orders` - Customer service orders with NBN identifiers and status
+- `order_status_history` - Audit trail for order status changes
+
+### Authentication
+- Session-based authentication stored in PostgreSQL
+- Password hashing (implementation in storage layer)
+- Admin flag on user accounts for privileged access
+- Protected routes require valid session via middleware
+
+### Build System
+- Development: Vite dev server with HMR for frontend, tsx for backend
+- Production: Custom build script using esbuild for server bundling, Vite for client
+- Output: Combined `dist/` directory with server bundle and static assets
+
+## External Dependencies
+
+### Database
+- PostgreSQL database (required, connection via `DATABASE_URL` environment variable)
+- Drizzle ORM for type-safe database queries
+- connect-pg-simple for session storage
+
+### UI Components
+- shadcn/ui component library built on Radix UI primitives
+- Lucide React for icons
+- Embla Carousel for carousel components
+- cmdk for command palette functionality
+
+### Core Libraries
+- TanStack React Query for data fetching and caching
+- react-hook-form with zod for form validation
+- date-fns for date formatting
+- class-variance-authority for component variants
+
+### NBN Service Qualification
+The coverage checker uses a multi-source fallback system:
+1. **RapidAPI NBN** (primary) - Real-time NBN address lookup via RapidAPI
+2. **Admin Dataset** - Pre-loaded coverage data for known addresses
+3. **NBN Public API** - Direct NBN Co API (may be blocked from server)
+4. **Address Only** - Fallback mode with address validation only
+
+The RapidAPI integration returns:
+- Technology type (FTTP, FTTB, FTTC, HFC, Wireless, Satellite)
+- Maximum available speed tier
+- Service availability status
+
+### NBN Service Order System
+The application includes a complete NBN service order system:
+
+**Multi-Step Signup Wizard** (`/signup`):
+1. Address - Enter and verify service address with NBN coverage check
+2. Qualification - Perform formal service qualification (generates LOC ID, CSA ID, SQ Reference)
+3. Details - Enter contact information
+4. Plan - Select internet plan based on available technology/speeds
+5. Payment - Review order and pay via Stripe checkout
+6. Confirmation - Display order reference and NBN identifiers
+
+**NBN Identifiers**:
+- LOC ID (Location ID) - Unique NBN location identifier
+- CSA ID (Connectivity Serving Area) - NBN network area
+- AVC ID (Access Virtual Circuit) - Assigned when service is active
+- CVC ID (Connectivity Virtual Circuit) - Network connection ID
+- SQ Reference - Service qualification reference number
+
+**Service Order Flow**:
+pending → submitted → in_progress → provisioning → active
+
+**NBN Service Abstraction** (`server/nbnService.ts`):
+- Currently uses simulated responses by default
+- Supports multiple wholesale provider integrations
+- Automatic fallback to simulated mode on API errors
+
+### Aussie Broadband Nitrogen Integration (Primary)
+The application integrates with Aussie Broadband's Nitrogen platform for wholesale NBN operations:
+- **Client**: `server/nitrogenClient.ts` - REST API with token authentication
+- **Capabilities**: Location search, service qualification, order creation, appointment management
+- **Webhooks**: `/api/webhooks/nitrogen` endpoint for async order status updates
+- **Features**: Auto/manual appointment booking, infrastructure upgrades, service configuration
+
+### Superloop Connect API Integration (Secondary)
+The application integrates with Superloop's Connect API (API Version 8) for wholesale NBN operations:
+- **Client**: `server/superloopClient.ts` - OAuth 2.0 JWT assertion authentication
+- **Documentation**: https://connect-api-doc.superloop.com/
+
+**Core Capabilities**:
+- Location search with structured address parsing (streetNumber, streetName, streetType, suburb, state)
+- Service qualification with full NBN data
+- Order creation with comprehensive options
+- Appointment management
+- Service management (plan changes, cancellations, modulation)
+
+**September 2025 Updates - Generation 2 NTD Support**:
+The client supports high-speed plans (>1000 Mbps) with Gen 2 NTD upgrades:
+- `generationTwoNtds` - Array of available NTD upgrade options
+- `firstOrAdditionalNtdPlans` - Plans available when ordering first/additional NTD
+- `generationOneNtdPlans` - Plans for existing Gen 1 NTDs (speed capped at 1000 Mbps)
+- `generationTwoNtdPlans` - All plans available for Gen 2 NTDs
+- `infrastructures[].remainingDownstreamBandwidth` - FTTP capacity checking
+- Two-step process for high-speed plans: Order ≤1000 Mbps, then plan change to >1000 Mbps
+
+**API Request/Response Pattern**:
+- POST to `/request` endpoint returns 201 with Location header
+- Poll Location URL until 200 (202 = in progress)
+- Supports asynchronous operations for complex workflows
+
+**Methods Available**:
+- `searchLocation(address)` / `searchLocationStructured(request)` - Find NBN locations
+- `searchLocationEnhanced(address)` - Enhanced search returning parsed address components (suburb, state, postcode)
+- `qualifyLocation(locationId)` - Get service qualification with Gen 2 NTD data
+- `createOrder(order)` - Create service order with ntdOption, transfer fields
+- `getOrder(orderId)` / `listOrders(status)` - Order management
+- `getService(serviceId)` / `listServices()` - Service information
+- `getPlanChangeOptions(serviceId)` - Get available plan changes with NTD upgrade options
+- `changePlan(request)` - Request plan change with optional NTD upgrade
+- `qualifyAvc(avcId)` - AVC qualification for transfer orders
+- `modulateService(serviceId, action)` - Pause/resume service
+- `requestAppointmentSlots(orderId, dateFrom)` / `bookAppointment()` - Appointments
+- `cancelService(serviceId)` / `cancelOrder(orderId)` - Cancellations
+
+**Priority Order for NBN Operations:**
+1. Aussie Broadband Nitrogen (if configured)
+2. Superloop Connect API (if configured)
+3. Legacy NBN RSP API (if configured)
+4. Simulated responses (default)
+
+### Multi-Step Signup Wizard Flow (Superloop-inspired)
+The signup wizard uses a modern 4-step flow with two-column layout:
+
+**Steps:**
+1. Plan - Combined address verification + plan selection + router selection
+2. Connect - Contact details (name, email, phone, preferred date)
+3. Account - Create new account or login to existing
+4. Payment - Order review and Stripe checkout
+
+**UI Features:**
+- Two-column layout: Main content left, sticky order summary sidebar right
+- Order summary shows: selected plan, router option, address, technology, promo code field, totals
+- Radio-button style plan cards with typical evening speeds
+- Router options: Free BroNET Router (24mo), Premium WiFi 6 (36mo), BYO
+- Promo code support with apply/remove functionality
+- Mobile-responsive with collapsible bottom sheet for order summary
+- Defensive guards prevent navigation without required selections
+
+**Reusable Components:**
+- `AddressSearch` component with autocomplete, keyboard navigation, and debounced API calls
+- Used across plans page, signup wizard, and coverage page
+
+### Environment Variables Required
+- `DATABASE_URL` - PostgreSQL connection string
+- `SESSION_SECRET` - Secret key for session encryption (defaults to development value)
+- `RAPIDAPI_NBN_KEY` - RapidAPI key for NBN address lookup (nbnco-address-check API)
+- `STRIPE_SECRET_KEY` - Stripe secret key for payment processing
+- `NBN_RSP_API_KEY` - (Optional) Legacy NBN RSP API key
+
+### Aussie Broadband Nitrogen Credentials (Optional)
+- `NITROGEN_API_KEY` - Nitrogen API key from Aussie Broadband partner portal
+- `NITROGEN_API_SECRET` - Nitrogen API secret
+- `NITROGEN_TENANT_ID` - Your tenant ID in the Nitrogen platform
+- `NITROGEN_USE_SANDBOX` - Set to 'true' for sandbox environment
+- `NITROGEN_WEBHOOK_SECRET` - Secret for verifying webhook signatures
+
+### Superloop Connect Credentials (Optional)
+- `SUPERLOOP_CLIENT_ID` - Superloop API client ID
+- `SUPERLOOP_PRIVATE_KEY` - RSA private key for JWT signing (PEM format)
+- `SUPERLOOP_USE_SANDBOX` - Set to 'true' for sandbox environment
+
+### Superloop Webhook Events System
+The application receives and processes various event types from Superloop via webhooks:
+
+**Event Types Supported:**
+- `appointment` - Technician appointment scheduling, confirmation, completion, cancellation
+- `diagnostic` - Line diagnostic results and service health checks
+- `order` - Order status changes (submitted, accepted, provisioning, activated, cancelled)
+- `service` - Service lifecycle events (activated, suspended, cancelled)
+- `disruption` - Network outages and planned maintenance
+- `health` - Periodic service health status updates
+- `location_quote` - Infrastructure installation quotes and estimates
+
+**Webhook Endpoint:** `POST /api/webhooks/superloop`
+
+**Database Tables:**
+- `superloop_events` - All webhook events with acknowledgement tracking
+- `network_disruptions` - Aggregated network outage information
+- `service_health_records` - Service health diagnostics history
+- `appointment_slots` - Technician appointment scheduling
+
+**Admin Dashboard Features:**
+- Events tab with event type filtering
+- Event acknowledgement for tracking
+- Network disruption management (create, update, resolve)
+- Manual disruption creation for planned maintenance
+
+**API Endpoints:**
+- `GET /api/admin/events` - List Superloop events (filterable by type)
+- `POST /api/admin/events/:eventId/acknowledge` - Mark event as acknowledged
+- `GET /api/network/disruptions` - Public active disruptions
+- `GET /api/admin/disruptions` - All disruptions for admin
+- `POST /api/admin/disruptions` - Create manual disruption
+- `PATCH /api/admin/disruptions/:id` - Update disruption status
+- `GET /api/orders/:orderId/appointments` - Appointment slots for order
+- `GET /api/orders/:orderId/health` - Service health records for order
\ No newline at end of file
diff --git a/scripts/check-stripe.ts b/scripts/check-stripe.ts
new file mode 100644
index 0000000..05acca7
--- /dev/null
+++ b/scripts/check-stripe.ts
@@ -0,0 +1,10 @@
+import { getUncachableStripeClient } from '../server/stripeClient';
+async function main() {
+ const stripe = await getUncachableStripeClient();
+ const products = await stripe.products.list({ active: true, limit: 100 });
+ console.log("Stripe Products count:", products.data.length);
+ for (const p of products.data) {
+ console.log(`- ${p.name} (${p.id})`);
+ }
+}
+main().catch(console.error);
diff --git a/scripts/seed-stripe-products.ts b/scripts/seed-stripe-products.ts
new file mode 100644
index 0000000..3aea45d
--- /dev/null
+++ b/scripts/seed-stripe-products.ts
@@ -0,0 +1,168 @@
+import { getUncachableStripeClient } from '../server/stripeClient';
+
+interface PlanConfig {
+ name: string;
+ description: string;
+ priceMonthly: number;
+ metadata: {
+ speed: string;
+ upload: string;
+ typical: string;
+ planType: string;
+ freeModem: string;
+ modemType: string;
+ modemCommitment: string;
+ };
+}
+
+const fibrePlans: PlanConfig[] = [
+ {
+ name: "NBN 50/20",
+ description: "Perfect for small households with basic streaming and browsing. Includes free modem.",
+ priceMonthly: 6900,
+ metadata: { speed: "50", upload: "20", typical: "50 Mbps", planType: "fibre", freeModem: "true", modemType: "eero", modemCommitment: "24" }
+ },
+ {
+ name: "NBN 100/20",
+ description: "Great for families with multiple devices and HD streaming. Includes free modem.",
+ priceMonthly: 7900,
+ metadata: { speed: "100", upload: "20", typical: "98 Mbps", planType: "fibre", freeModem: "true", modemType: "eero", modemCommitment: "24" }
+ },
+ {
+ name: "NBN 250/25",
+ description: "Ideal for power users and 4K streaming on multiple devices. Includes free modem.",
+ priceMonthly: 8900,
+ metadata: { speed: "250", upload: "25", typical: "230 Mbps", planType: "fibre", freeModem: "true", modemType: "eero", modemCommitment: "24" }
+ },
+ {
+ name: "NBN 500/50",
+ description: "High-speed fibre for heavy users and large households. Includes free WiFi 7 modem.",
+ priceMonthly: 10900,
+ metadata: { speed: "500", upload: "50", typical: "480 Mbps", planType: "fibre", freeModem: "true", modemType: "eero7", modemCommitment: "24" }
+ },
+ {
+ name: "NBN 1000/50",
+ description: "Ultra-fast speeds for demanding households and home offices. Includes free WiFi 7 modem.",
+ priceMonthly: 12900,
+ metadata: { speed: "1000", upload: "50", typical: "900 Mbps", planType: "fibre", freeModem: "true", modemType: "eero7", modemCommitment: "24" }
+ },
+ {
+ name: "NBN 2000/200",
+ description: "Blazing fast speeds for ultra-connected smart homes. Includes free WiFi 7 modem.",
+ priceMonthly: 16900,
+ metadata: { speed: "2000", upload: "200", typical: "1800 Mbps", planType: "fibre", freeModem: "true", modemType: "eero7", modemCommitment: "24" }
+ },
+ {
+ name: "NBN 2000/400",
+ description: "Ultimate performance with maximum speeds for professionals. Includes free WiFi 7 modem.",
+ priceMonthly: 19900,
+ metadata: { speed: "2000", upload: "400", typical: "1800 Mbps", planType: "fibre", freeModem: "true", modemType: "eero7", modemCommitment: "24" }
+ }
+];
+
+const fixedWirelessPlans: PlanConfig[] = [
+ {
+ name: "Fixed Wireless 50",
+ description: "Entry-level wireless broadband for light users. Includes free modem.",
+ priceMonthly: 5900,
+ metadata: { speed: "50", upload: "10", typical: "47 Mbps", planType: "wireless", freeModem: "true", modemType: "eero", modemCommitment: "24" }
+ },
+ {
+ name: "Fixed Wireless 75",
+ description: "Balanced wireless plan for everyday streaming and gaming. Includes free modem.",
+ priceMonthly: 6900,
+ metadata: { speed: "75", upload: "10", typical: "70 Mbps", planType: "wireless", freeModem: "true", modemType: "eero", modemCommitment: "24" }
+ },
+ {
+ name: "Fixed Wireless Plus",
+ description: "Premium wireless tier with maximum available speeds. Includes free modem.",
+ priceMonthly: 7900,
+ metadata: { speed: "100", upload: "20", typical: "90 Mbps", planType: "wireless", freeModem: "true", modemType: "eero", modemCommitment: "24" }
+ }
+];
+
+async function seedProducts() {
+ console.log('Starting Stripe product seed...');
+
+ const stripe = await getUncachableStripeClient();
+
+ const allPlans = [...fibrePlans, ...fixedWirelessPlans];
+ const validPlanNames = allPlans.map(p => p.name);
+
+ // First, deactivate old products that no longer match our plans
+ console.log('Checking for outdated products...');
+ const existingProducts = await stripe.products.list({ active: true, limit: 100 });
+ for (const product of existingProducts.data) {
+ if (!validPlanNames.includes(product.name) &&
+ (product.metadata?.planType === 'fibre' || product.metadata?.planType === 'wireless')) {
+ console.log(`Deactivating outdated product: ${product.name}`);
+ await stripe.products.update(product.id, { active: false });
+ }
+ }
+
+ // Create or update products
+ for (const plan of allPlans) {
+ try {
+ const existingProducts = await stripe.products.search({
+ query: `name:'${plan.name}'`
+ });
+
+ let product;
+ if (existingProducts.data.length > 0) {
+ // Update existing product
+ product = existingProducts.data[0];
+ await stripe.products.update(product.id, {
+ description: plan.description,
+ metadata: plan.metadata,
+ active: true
+ });
+ console.log(`Updated product: ${plan.name}`);
+
+ // Check if price needs updating
+ const existingPrices = await stripe.prices.list({
+ product: product.id,
+ active: true
+ });
+
+ const currentPrice = existingPrices.data[0];
+ if (!currentPrice || currentPrice.unit_amount !== plan.priceMonthly) {
+ // Deactivate old price and create new one
+ if (currentPrice) {
+ await stripe.prices.update(currentPrice.id, { active: false });
+ }
+ const newPrice = await stripe.prices.create({
+ product: product.id,
+ unit_amount: plan.priceMonthly,
+ currency: 'aud',
+ recurring: { interval: 'month' }
+ });
+ console.log(` Updated price to $${plan.priceMonthly / 100}/mth (${newPrice.id})`);
+ } else {
+ console.log(` Price unchanged: $${plan.priceMonthly / 100}/mth`);
+ }
+ } else {
+ // Create new product
+ product = await stripe.products.create({
+ name: plan.name,
+ description: plan.description,
+ metadata: plan.metadata
+ });
+
+ const price = await stripe.prices.create({
+ product: product.id,
+ unit_amount: plan.priceMonthly,
+ currency: 'aud',
+ recurring: { interval: 'month' }
+ });
+
+ console.log(`Created: ${plan.name} (${product.id}) with price ${price.id}`);
+ }
+ } catch (error: any) {
+ console.error(`Failed to process ${plan.name}:`, error.message);
+ }
+ }
+
+ console.log('Stripe product seed complete!');
+}
+
+seedProducts().catch(console.error);
diff --git a/scripts/sync-stripe.ts b/scripts/sync-stripe.ts
new file mode 100644
index 0000000..7fb3e95
--- /dev/null
+++ b/scripts/sync-stripe.ts
@@ -0,0 +1,12 @@
+import { getStripeSync } from '../server/stripeClient';
+async function main() {
+ console.log("Getting Stripe sync...");
+ const stripeSync = await getStripeSync();
+ console.log("Starting backfill...");
+ await stripeSync.syncBackfill({ fullResync: true });
+ console.log("Backfill complete!");
+}
+main().catch((err) => {
+ console.error("Sync error:", err);
+ process.exit(1);
+});
diff --git a/server/db.ts b/server/db.ts
new file mode 100644
index 0000000..6d53804
--- /dev/null
+++ b/server/db.ts
@@ -0,0 +1,14 @@
+import { drizzle } from "drizzle-orm/node-postgres";
+import pg from "pg";
+import * as schema from "@shared/schema";
+
+const { Pool } = pg;
+
+if (!process.env.DATABASE_URL) {
+ throw new Error(
+ "DATABASE_URL must be set. Did you forget to provision a database?",
+ );
+}
+
+export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+export const db = drizzle(pool, { schema });
diff --git a/server/emailService.ts b/server/emailService.ts
new file mode 100644
index 0000000..749d661
--- /dev/null
+++ b/server/emailService.ts
@@ -0,0 +1,267 @@
+import { Resend } from 'resend';
+
+let connectionSettings: any;
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+async function getCredentials() {
+ const hostname = process.env.REPLIT_CONNECTORS_HOSTNAME;
+ const xReplitToken = process.env.REPL_IDENTITY
+ ? 'repl ' + process.env.REPL_IDENTITY
+ : process.env.WEB_REPL_RENEWAL
+ ? 'depl ' + process.env.WEB_REPL_RENEWAL
+ : null;
+
+ if (!xReplitToken) {
+ throw new Error('X_REPLIT_TOKEN not found for repl/depl');
+ }
+
+ connectionSettings = await fetch(
+ 'https://' + hostname + '/api/v2/connection?include_secrets=true&connector_names=resend',
+ {
+ headers: {
+ 'Accept': 'application/json',
+ 'X_REPLIT_TOKEN': xReplitToken
+ }
+ }
+ ).then(res => res.json()).then(data => data.items?.[0]);
+
+ if (!connectionSettings || (!connectionSettings.settings.api_key)) {
+ throw new Error('Resend not connected');
+ }
+ return { apiKey: connectionSettings.settings.api_key, fromEmail: connectionSettings.settings.from_email };
+}
+
+export async function getUncachableResendClient() {
+ const credentials = await getCredentials();
+ return {
+ client: new Resend(credentials.apiKey),
+ fromEmail: connectionSettings.settings.from_email
+ };
+}
+
+export async function sendNotifyMeConfirmation(toEmail: string): Promise
{
+ try {
+ const { client, fromEmail } = await getUncachableResendClient();
+
+ await client.emails.send({
+ from: fromEmail || 'BroNET ',
+ to: toEmail,
+ subject: "You're on the list! BroNET is coming soon",
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ 📡
+
+ |
+
+
+
+ BroNET
+ Australia's Next-Gen Internet
+ |
+
+
+ |
+
+
+
+
+ |
+
+ ✓ You're on the list!
+
+ |
+
+
+
+
+
+ Thanks for joining us!
+
+ You've been added to our exclusive launch notification list. We'll be the first to let you know when BroNET becomes available in your area.
+
+ |
+
+
+
+
+
+
+
+ |
+ What you'll get
+
+
+
+
+
+ |
+ ⚡
+ |
+
+ Up to 2000 Mbps
+ Ultra-fast speeds
+ |
+
+
+ |
+
+
+
+ |
+ 🔒
+ |
+
+ No lock-in
+ Month-to-month plans
+ |
+
+
+ |
+
+
+
+
+
+ |
+ 🇦🇺
+ |
+
+ Aussie support
+ Local customer care
+ |
+
+
+ |
+
+
+
+ |
+ ∞
+ |
+
+ Unlimited data
+ No caps or throttling
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+
+ Preview Our Plans
+
+
+ Check out what's coming your way
+
+ |
+
+
+
+
+
+
+
+ |
+ BroNET
+
+ © ${new Date().getFullYear()} BroNET. All rights reserved.
+ You received this email because you signed up at bronet.replit.app
+
+ |
+
+
+ |
+
+
+
+ |
+
+
+
+
+ `,
+ });
+
+ return true;
+ } catch (error: any) {
+ console.error('Failed to send confirmation email:', error?.message || error);
+ console.error('Full error:', JSON.stringify(error, null, 2));
+ return false;
+ }
+}
+
+export async function sendAdminNotification(subscriberEmail: string, source: string): Promise {
+ try {
+ const { client, fromEmail } = await getUncachableResendClient();
+
+ await client.emails.send({
+ from: fromEmail || 'BroNET ',
+ to: 'email@brointernet.com',
+ subject: `New BroNET Signup: ${subscriberEmail}`,
+ html: `
+
+
New Email Signup
+
+
+
+ | Email: |
+ ${escapeHtml(subscriberEmail)} |
+
+
+ | Source: |
+ ${escapeHtml(source)} |
+
+
+ | Time: |
+ ${new Date().toLocaleString('en-AU', { timeZone: 'Australia/Sydney' })} |
+
+
+
+
+ This is an automated notification from the BroNET website.
+
+
+ `,
+ });
+
+ return true;
+ } catch (error) {
+ console.error('Failed to send admin notification:', error);
+ return false;
+ }
+}
diff --git a/server/index.ts b/server/index.ts
index f4219e2..86115cc 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -2,16 +2,237 @@ import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes";
import { serveStatic } from "./static";
import { createServer } from "http";
+import { runMigrations } from 'stripe-replit-sync';
+import { getStripeSync } from './stripeClient';
+import { WebhookHandlers } from './webhookHandlers';
+import bcrypt from 'bcrypt';
const app = express();
const httpServer = createServer(app);
+// Create admin account if it doesn't exist
+async function ensureAdminAccount() {
+ try {
+ const { db } = await import('./db');
+ const { users } = await import('@shared/schema');
+ const { eq } = await import('drizzle-orm');
+
+ const adminEmail = 'admin@brointernet.com';
+ const adminPassword = process.env.ADMIN_PASSWORD || 'BroNet2025!';
+
+ // Check if admin exists
+ const existingAdmin = await db.select().from(users).where(eq(users.email, adminEmail)).limit(1);
+
+ if (existingAdmin.length === 0) {
+ console.log('Creating admin account...');
+ const hashedPassword = await bcrypt.hash(adminPassword, 10);
+ await db.insert(users).values({
+ email: adminEmail,
+ password: hashedPassword,
+ firstName: 'Admin',
+ lastName: 'User',
+ isAdmin: 1,
+ });
+ console.log('Admin account created: admin@brointernet.com');
+ } else if (existingAdmin[0].isAdmin !== 1) {
+ // Promote existing user to admin
+ await db.update(users).set({ isAdmin: 1 }).where(eq(users.email, adminEmail));
+ console.log('Promoted admin@brointernet.com to admin');
+ } else {
+ console.log('Admin account already exists');
+ }
+ } catch (error) {
+ console.error('Failed to ensure admin account:', error);
+ }
+}
+
+// Initialize admin account on startup
+(async () => {
+ await ensureAdminAccount();
+})();
+
declare module "http" {
interface IncomingMessage {
rawBody: unknown;
}
}
+async function initStripe() {
+ const databaseUrl = process.env.DATABASE_URL;
+ if (!databaseUrl) {
+ console.log('DATABASE_URL not set, skipping Stripe initialization');
+ return;
+ }
+
+ try {
+ console.log('Initializing Stripe schema...');
+ await runMigrations({ databaseUrl, schema: 'stripe' });
+ console.log('Stripe schema ready');
+
+ const stripeSync = await getStripeSync();
+
+ console.log('Setting up managed webhook...');
+ const webhookBaseUrl = `https://${process.env.REPLIT_DOMAINS?.split(',')[0]}`;
+ try {
+ const result = await stripeSync.findOrCreateManagedWebhook(
+ `${webhookBaseUrl}/api/stripe/webhook`
+ );
+ if (result?.webhook?.url) {
+ console.log(`Webhook configured: ${result.webhook.url}`);
+ } else {
+ console.log('Webhook setup completed (no URL returned)');
+ }
+ } catch (webhookError) {
+ console.log('Webhook setup skipped (will be configured on next restart)');
+ }
+
+ console.log('Syncing Stripe data...');
+ stripeSync.syncBackfill()
+ .then(() => console.log('Stripe data synced'))
+ .catch((err: any) => console.error('Error syncing Stripe data:', err));
+ } catch (error) {
+ console.error('Failed to initialize Stripe:', error);
+ }
+}
+
+// Initialize Stripe (wrapped in IIFE for CommonJS compatibility)
+(async () => {
+ await initStripe();
+})();
+
+app.post(
+ '/api/stripe/webhook',
+ express.raw({ type: 'application/json' }),
+ async (req, res) => {
+ const signature = req.headers['stripe-signature'];
+ if (!signature) {
+ return res.status(400).json({ error: 'Missing stripe-signature' });
+ }
+
+ try {
+ const sig = Array.isArray(signature) ? signature[0] : signature;
+ if (!Buffer.isBuffer(req.body)) {
+ console.error('Webhook body is not a Buffer');
+ return res.status(500).json({ error: 'Webhook processing error' });
+ }
+
+ await WebhookHandlers.processWebhook(req.body as Buffer, sig);
+
+ // Handle checkout.session.completed to update order status
+ try {
+ const { getUncachableStripeClient } = await import('./stripeClient');
+ const stripe = await getUncachableStripeClient();
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
+
+ if (webhookSecret) {
+ const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
+
+ if (event.type === 'checkout.session.completed') {
+ const session = event.data.object as any;
+ const orderId = session.metadata?.orderId;
+ const subscriptionId = session.subscription;
+
+ if (orderId) {
+ console.log(`Checkout completed for order: ${orderId}`);
+ const { db } = await import('./db');
+ const { serviceOrders, orderStatusHistory } = await import('@shared/schema');
+ const { eq } = await import('drizzle-orm');
+
+ await db.update(serviceOrders)
+ .set({
+ status: 'submitted',
+ stripeSessionId: session.id,
+ stripeSubscriptionId: subscriptionId || null,
+ updatedAt: new Date()
+ })
+ .where(eq(serviceOrders.id, orderId));
+
+ await db.insert(orderStatusHistory).values({
+ orderId,
+ status: 'submitted',
+ message: 'Payment completed - order submitted for processing',
+ updatedBy: 'stripe_webhook',
+ });
+
+ console.log(`Order ${orderId} updated to submitted status`);
+ }
+ }
+ }
+ } catch (webhookErr: any) {
+ console.log('Custom webhook handling skipped:', webhookErr.message);
+ }
+
+ res.status(200).json({ received: true });
+ } catch (error: any) {
+ console.error('Webhook error:', error.message);
+ res.status(400).json({ error: 'Webhook processing error' });
+ }
+ }
+);
+
+// Nitrogen webhook - must be BEFORE express.json() to get raw body for signature verification
+app.post(
+ '/api/webhooks/nitrogen',
+ express.raw({ type: 'application/json' }),
+ async (req, res) => {
+ try {
+ const { nitrogenClient } = await import('./nitrogenClient');
+ const { nbnService } = await import('./nbnService');
+
+ const signature = req.headers['x-nitrogen-signature'] as string;
+ const payload = Buffer.isBuffer(req.body) ? req.body.toString() : String(req.body);
+
+ if (signature && !nitrogenClient.verifyWebhookSignature(payload, signature)) {
+ console.warn("Invalid Nitrogen webhook signature");
+ return res.status(401).json({ error: "Invalid signature" });
+ }
+
+ const event = nitrogenClient.parseWebhookEvent(payload);
+ console.log(`Nitrogen webhook: ${event.eventType} for ${event.resourceType}/${event.resourceId}`);
+
+ const updateByNbnId = async (status: string, message: string) => {
+ const updated = await nbnService.updateOrderByNbnOrderId(event.resourceId, status, message, 'nitrogen');
+ if (!updated) {
+ console.warn(`No order found with nbnOrderId: ${event.resourceId}`);
+ }
+ return updated;
+ };
+
+ switch (event.eventType) {
+ case 'order.completed.event':
+ await updateByNbnId('active', 'Service connected successfully');
+ break;
+
+ case 'order.accepted.event':
+ await updateByNbnId('in_progress', 'Order accepted by NBN');
+ break;
+
+ case 'order.rejected.event':
+ case 'order.failed.event':
+ await updateByNbnId('failed', event.data?.reason || 'Order rejected');
+ break;
+
+ case 'order.cancelled.event':
+ await updateByNbnId('cancelled', event.data?.reason || 'Order cancelled');
+ break;
+
+ case 'order.appointment-required.event':
+ case 'order.appointment-reschedule-required.event':
+ await updateByNbnId('pending', 'Appointment required - please contact support');
+ break;
+
+ default:
+ console.log(`Unhandled Nitrogen event: ${event.eventType}`);
+ }
+
+ res.json({ received: true });
+ } catch (error: any) {
+ console.error("Nitrogen webhook error:", error);
+ res.status(500).json({ error: "Webhook processing failed" });
+ }
+ }
+);
+
app.use(
express.json({
verify: (req, _res, buf) => {
diff --git a/server/nbnService.ts b/server/nbnService.ts
new file mode 100644
index 0000000..6a4bcfb
--- /dev/null
+++ b/server/nbnService.ts
@@ -0,0 +1,669 @@
+import * as crypto from "crypto";
+import { db } from "./db";
+import { serviceQualifications, serviceOrders, orderStatusHistory } from "@shared/schema";
+import { eq } from "drizzle-orm";
+import { getSuperloopClient, SuperloopClient } from "./superloopClient";
+import { nitrogenClient, NitrogenClient } from "./nitrogenClient";
+
+export interface ServiceQualificationResult {
+ locId: string;
+ csaId?: string;
+ address: string;
+ postcode?: string;
+ suburb?: string;
+ state?: string;
+ technology: string;
+ maxDownload: number;
+ maxUpload: number;
+ bandwidthProfile?: string;
+ serviceClass: number;
+ newDevelopment: boolean;
+ sqReference: string;
+ validUntil: Date;
+ available: boolean;
+}
+
+export interface OrderSubmissionResult {
+ success: boolean;
+ nbnOrderId?: string;
+ avcId?: string;
+ cvcId?: string;
+ estimatedConnectionDate?: Date;
+ message?: string;
+}
+
+export interface OrderStatusResult {
+ status: string;
+ avcId?: string;
+ cvcId?: string;
+ estimatedConnectionDate?: Date;
+ actualConnectionDate?: Date;
+ message?: string;
+}
+
+function generateLocId(): string {
+ const prefix = "LOC";
+ const randomNum = Math.floor(Math.random() * 900000000) + 100000000;
+ return `${prefix}${randomNum}`;
+}
+
+function generateCsaId(): string {
+ const prefix = "CSA";
+ const randomNum = Math.floor(Math.random() * 90000) + 10000;
+ return `${prefix}${randomNum}`;
+}
+
+function generateSqReference(): string {
+ const timestamp = Date.now().toString(36).toUpperCase();
+ const random = Math.random().toString(36).substring(2, 8).toUpperCase();
+ return `SQ-${timestamp}-${random}`;
+}
+
+function generateOrderReference(): string {
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
+ const random = Math.random().toString(36).substring(2, 8).toUpperCase();
+ return `BRO-${date}-${random}`;
+}
+
+function generateAvcId(): string {
+ const random = Math.random().toString(36).substring(2, 10).toUpperCase();
+ return `AVC${random}`;
+}
+
+function generateCvcId(): string {
+ const random = Math.random().toString(36).substring(2, 8).toUpperCase();
+ return `CVC${random}`;
+}
+
+function generateNbnOrderId(): string {
+ const random = Math.floor(Math.random() * 9000000000) + 1000000000;
+ return `NBN${random}`;
+}
+
+function getTechnologySpecs(technology: string): { maxDownload: number; maxUpload: number; serviceClass: number } {
+ const techLower = technology.toLowerCase();
+
+ if (techLower.includes("fttp") || techLower.includes("fibre to the premises")) {
+ return { maxDownload: 2000, maxUpload: 500, serviceClass: 4 };
+ }
+ if (techLower.includes("fttc") || techLower.includes("fibre to the curb")) {
+ return { maxDownload: 1000, maxUpload: 50, serviceClass: 3 };
+ }
+ if (techLower.includes("fttb") || techLower.includes("fibre to the building")) {
+ return { maxDownload: 1000, maxUpload: 50, serviceClass: 3 };
+ }
+ if (techLower.includes("hfc") || techLower.includes("cable")) {
+ return { maxDownload: 1000, maxUpload: 50, serviceClass: 3 };
+ }
+ if (techLower.includes("wireless") || techLower.includes("fw")) {
+ return { maxDownload: 100, maxUpload: 20, serviceClass: 2 };
+ }
+ if (techLower.includes("satellite") || techLower.includes("sat")) {
+ return { maxDownload: 50, maxUpload: 10, serviceClass: 1 };
+ }
+
+ return { maxDownload: 100, maxUpload: 20, serviceClass: 2 };
+}
+
+function getBandwidthProfile(technology: string, maxDownload: number, maxUpload: number): string {
+ const serviceClass = getTechnologySpecs(technology).serviceClass;
+ return `TC${serviceClass}/${maxDownload}/${maxUpload}`;
+}
+
+export class NbnService {
+ private useRealApi: boolean = false;
+ private useSuperloop: boolean = false;
+ private useNitrogen: boolean = false;
+ private superloopClient: SuperloopClient | null = null;
+
+ constructor() {
+ this.useRealApi = !!process.env.NBN_RSP_API_KEY;
+
+ if (nitrogenClient.isConfigured()) {
+ this.useNitrogen = true;
+ console.log("Nitrogen API configured - using Aussie Broadband NBN integration");
+ } else {
+ const superloop = getSuperloopClient();
+ if (superloop.isConfigured()) {
+ this.useSuperloop = true;
+ this.superloopClient = superloop;
+ console.log("Superloop Connect API configured - using real NBN integration");
+ }
+ }
+ }
+
+ async performServiceQualification(
+ address: string,
+ technology: string,
+ postcode?: string,
+ suburb?: string,
+ state?: string,
+ userId?: string,
+ existingLocId?: string // LOC ID from RapidAPI if available
+ ): Promise {
+ if (this.useNitrogen) {
+ return this.nitrogenServiceQualification(address, technology, postcode, suburb, state, userId);
+ }
+ if (this.useSuperloop && this.superloopClient) {
+ return this.superloopServiceQualification(address, technology, postcode, suburb, state, userId);
+ }
+ if (this.useRealApi) {
+ return this.realServiceQualification(address, technology, postcode, suburb, state, userId);
+ }
+ return this.simulatedServiceQualification(address, technology, postcode, suburb, state, userId, existingLocId);
+ }
+
+ private async nitrogenServiceQualification(
+ address: string,
+ technology: string,
+ postcode?: string,
+ suburb?: string,
+ state?: string,
+ userId?: string
+ ): Promise {
+ try {
+ const locations = await nitrogenClient.searchLocation(address);
+
+ if (!locations || locations.length === 0) {
+ throw new Error("No locations found for address");
+ }
+
+ const location = locations[0];
+ const qualification = await nitrogenClient.performServiceQualification(location.id);
+
+ const serviceClass = typeof qualification.serviceClass === 'string'
+ ? parseInt(qualification.serviceClass) || 4
+ : qualification.serviceClass || 4;
+ const maxDownload = qualification.maxDownload || getTechnologySpecs(qualification.technologyType || technology).maxDownload;
+ const maxUpload = qualification.maxUpload || getTechnologySpecs(qualification.technologyType || technology).maxUpload;
+
+ const result: ServiceQualificationResult = {
+ locId: qualification.locId || location.locId || location.id,
+ csaId: location.id,
+ address: qualification.address || location.address,
+ postcode: qualification.postcode || location.postcode || postcode,
+ suburb: qualification.suburb || location.suburb || suburb,
+ state: qualification.state || location.state || state,
+ technology: qualification.technologyType || technology || 'FTTP',
+ maxDownload,
+ maxUpload,
+ bandwidthProfile: `TC${serviceClass}/${maxDownload}/${maxUpload}`,
+ serviceClass,
+ newDevelopment: false,
+ sqReference: qualification.id,
+ validUntil: qualification.expiresAt,
+ available: qualification.available !== false,
+ };
+
+ await db.insert(serviceQualifications).values({
+ userId: userId || null,
+ locId: result.locId,
+ csaId: result.csaId,
+ address: result.address,
+ postcode: result.postcode,
+ suburb: result.suburb,
+ state: result.state,
+ technology: result.technology,
+ maxDownload: result.maxDownload,
+ maxUpload: result.maxUpload,
+ bandwidthProfile: result.bandwidthProfile,
+ serviceClass: result.serviceClass,
+ newDevelopment: result.newDevelopment ? 1 : 0,
+ sqReference: result.sqReference,
+ validUntil: result.validUntil,
+ rawResponse: JSON.stringify({ nitrogen: true, qualification, location, nitrogenLocationId: location.id }),
+ });
+
+ return result;
+ } catch (error: any) {
+ console.error("Nitrogen qualification error:", error);
+ console.log("Falling back to simulated qualification");
+ return this.simulatedServiceQualification(address, technology, postcode, suburb, state, userId);
+ }
+ }
+
+ private async superloopServiceQualification(
+ address: string,
+ technology: string,
+ postcode?: string,
+ suburb?: string,
+ state?: string,
+ userId?: string
+ ): Promise {
+ if (!this.superloopClient) {
+ throw new Error("Superloop client not configured");
+ }
+
+ try {
+ // Use enhanced search to get parsed address components
+ const locations = await this.superloopClient.searchLocationEnhanced(address);
+
+ if (!locations || locations.length === 0) {
+ throw new Error("No locations found for address");
+ }
+
+ const location = locations[0];
+ const qualification = await this.superloopClient.qualifyLocation(location.id);
+
+ const validUntil = new Date();
+ validUntil.setDate(validUntil.getDate() + 30);
+
+ // Use qualification data as primary source, with location data as fallback
+ const result: ServiceQualificationResult = {
+ locId: qualification.locId || location.id,
+ csaId: location.id,
+ address: location.address || address,
+ postcode: location.postcode || postcode,
+ suburb: location.suburb || suburb,
+ state: location.state || state,
+ technology: qualification.technologyType || technology,
+ maxDownload: qualification.maxDownload,
+ maxUpload: qualification.maxUpload,
+ bandwidthProfile: `TC${qualification.serviceClass}/${qualification.maxDownload}/${qualification.maxUpload}`,
+ serviceClass: qualification.serviceClass,
+ newDevelopment: false,
+ sqReference: qualification.qualificationSearchId,
+ validUntil,
+ available: qualification.available,
+ };
+
+ await db.insert(serviceQualifications).values({
+ userId: userId || null,
+ locId: result.locId,
+ csaId: result.csaId,
+ address: result.address,
+ postcode: result.postcode,
+ suburb: result.suburb,
+ state: result.state,
+ technology: result.technology,
+ maxDownload: result.maxDownload,
+ maxUpload: result.maxUpload,
+ bandwidthProfile: result.bandwidthProfile,
+ serviceClass: result.serviceClass,
+ newDevelopment: result.newDevelopment ? 1 : 0,
+ sqReference: result.sqReference,
+ validUntil: result.validUntil,
+ rawResponse: JSON.stringify({ superloop: true, qualification, location, superloopLocationId: location.id }),
+ });
+
+ return result;
+ } catch (error: any) {
+ console.error("Superloop qualification error:", error);
+ console.log("Falling back to simulated qualification");
+ return this.simulatedServiceQualification(address, technology, postcode, suburb, state, userId);
+ }
+ }
+
+ private async simulatedServiceQualification(
+ address: string,
+ technology: string,
+ postcode?: string,
+ suburb?: string,
+ state?: string,
+ userId?: string,
+ existingLocId?: string // Use LOC ID from RapidAPI if available
+ ): Promise {
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ const specs = getTechnologySpecs(technology);
+ // Use existing LOC ID from RapidAPI if available, otherwise generate a simulated one
+ const locId = existingLocId || generateLocId();
+ const csaId = generateCsaId();
+ const sqReference = generateSqReference();
+ const validUntil = new Date();
+ validUntil.setDate(validUntil.getDate() + 30);
+
+ const result: ServiceQualificationResult = {
+ locId,
+ csaId,
+ address,
+ postcode,
+ suburb,
+ state,
+ technology,
+ maxDownload: specs.maxDownload,
+ maxUpload: specs.maxUpload,
+ bandwidthProfile: getBandwidthProfile(technology, specs.maxDownload, specs.maxUpload),
+ serviceClass: specs.serviceClass,
+ newDevelopment: false,
+ sqReference,
+ validUntil,
+ available: true,
+ };
+
+ await db.insert(serviceQualifications).values({
+ userId: userId || null,
+ locId: result.locId,
+ csaId: result.csaId,
+ address: result.address,
+ postcode: result.postcode,
+ suburb: result.suburb,
+ state: result.state,
+ technology: result.technology,
+ maxDownload: result.maxDownload,
+ maxUpload: result.maxUpload,
+ bandwidthProfile: result.bandwidthProfile,
+ serviceClass: result.serviceClass,
+ newDevelopment: result.newDevelopment ? 1 : 0,
+ sqReference: result.sqReference,
+ validUntil: result.validUntil,
+ rawResponse: JSON.stringify({ simulated: true, ...result }),
+ });
+
+ return result;
+ }
+
+ private async realServiceQualification(
+ address: string,
+ technology: string,
+ postcode?: string,
+ suburb?: string,
+ state?: string,
+ userId?: string
+ ): Promise {
+ throw new Error("Real NBN API not configured. Set NBN_RSP_API_KEY and NBN_RSP_CERT_PATH environment variables.");
+ }
+
+ async submitOrder(params: {
+ userId: string;
+ qualificationId?: string;
+ planId: string;
+ planName: string;
+ downloadSpeed: number;
+ uploadSpeed: number;
+ serviceAddress: string;
+ locId?: string;
+ csaId?: string;
+ technology?: string;
+ contactName: string;
+ contactEmail: string;
+ contactPhone: string;
+ preferredDate?: Date;
+ stripeSessionId?: string;
+ sqReference?: string;
+ }): Promise<{ orderId: string; orderReference: string; result: OrderSubmissionResult }> {
+ const orderReference = generateOrderReference();
+
+ let csaId = params.csaId;
+ if (!csaId && params.qualificationId) {
+ const [qualification] = await db.select().from(serviceQualifications)
+ .where(eq(serviceQualifications.id, params.qualificationId));
+ if (qualification) {
+ csaId = qualification.csaId || undefined;
+ }
+ }
+
+ const enrichedParams = { ...params, csaId };
+
+ let submissionResult: OrderSubmissionResult;
+ if (this.useNitrogen) {
+ submissionResult = await this.nitrogenSubmitOrder(enrichedParams);
+ } else if (this.useSuperloop && this.superloopClient) {
+ submissionResult = await this.superloopSubmitOrder(enrichedParams);
+ } else if (this.useRealApi) {
+ submissionResult = await this.realSubmitOrder(enrichedParams);
+ } else {
+ submissionResult = await this.simulatedSubmitOrder(enrichedParams);
+ }
+
+ const estimatedDate = submissionResult.estimatedConnectionDate || new Date();
+ if (!submissionResult.estimatedConnectionDate) {
+ estimatedDate.setDate(estimatedDate.getDate() + 7);
+ }
+
+ const [order] = await db.insert(serviceOrders).values({
+ userId: enrichedParams.userId,
+ qualificationId: enrichedParams.qualificationId,
+ orderReference,
+ nbnOrderId: submissionResult.nbnOrderId,
+ avcId: submissionResult.avcId,
+ cvcId: submissionResult.cvcId,
+ planId: enrichedParams.planId,
+ planName: enrichedParams.planName,
+ downloadSpeed: enrichedParams.downloadSpeed,
+ uploadSpeed: enrichedParams.uploadSpeed,
+ serviceAddress: enrichedParams.serviceAddress,
+ locId: enrichedParams.csaId || enrichedParams.locId,
+ technology: enrichedParams.technology,
+ status: submissionResult.success ? "submitted" : "failed",
+ contactName: enrichedParams.contactName,
+ contactEmail: enrichedParams.contactEmail,
+ contactPhone: enrichedParams.contactPhone,
+ preferredDate: enrichedParams.preferredDate,
+ estimatedConnectionDate: estimatedDate,
+ stripeSessionId: enrichedParams.stripeSessionId,
+ notes: submissionResult.message,
+ }).returning();
+
+ await db.insert(orderStatusHistory).values({
+ orderId: order.id,
+ status: submissionResult.success ? "submitted" : "failed",
+ message: submissionResult.message || "Order created",
+ updatedBy: "system",
+ });
+
+ return {
+ orderId: order.id,
+ orderReference,
+ result: submissionResult,
+ };
+ }
+
+ private async nitrogenSubmitOrder(params: any): Promise {
+ try {
+ const customerReference = `BRO-${params.userId.substring(0, 8)}-${Date.now().toString(36)}`;
+
+ const nitrogenLocationId = params.csaId || "";
+
+ if (!nitrogenLocationId) {
+ throw new Error("Nitrogen location ID not found in qualification data");
+ }
+
+ const products = await nitrogenClient.getProductCatalog();
+ const matchingProduct = products.find(p =>
+ p.downloadSpeed === params.downloadSpeed && p.uploadSpeed === params.uploadSpeed
+ ) || products.find(p => p.downloadSpeed >= params.downloadSpeed);
+
+ if (!matchingProduct) {
+ throw new Error("No matching product offer found for selected plan");
+ }
+
+ const orderResponse = await nitrogenClient.createOrder({
+ sourceType: 'NBN',
+ customerReference,
+ qualificationSearchId: params.sqReference || "",
+ locationId: nitrogenLocationId,
+ term: 1,
+ trafficClass: 'tc4',
+ productType: 'Access Only',
+ productOffer: {
+ id: matchingProduct.id,
+ components: [
+ { type: 'restoration_sla', option: 'standard' },
+ ],
+ },
+ ntdInstallation: 'no-action',
+ serviceConfiguration: {
+ dslStabilityProfile: 'standard',
+ layer3Options: {
+ authMethod: 'ipoe',
+ applyShapingRestriction: false,
+ unblockPorts: false,
+ },
+ },
+ });
+
+ const estimatedDate = new Date();
+ estimatedDate.setDate(estimatedDate.getDate() + 7);
+
+ return {
+ success: true,
+ nbnOrderId: orderResponse.id,
+ avcId: orderResponse.avcId,
+ estimatedConnectionDate: estimatedDate,
+ message: "Order submitted to Nitrogen. You will receive status updates via email.",
+ };
+ } catch (error: any) {
+ console.error("Nitrogen order submission error:", error);
+ console.log("Falling back to simulated order");
+ return this.simulatedSubmitOrder(params);
+ }
+ }
+
+ private async simulatedSubmitOrder(params: any): Promise {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ const estimatedDate = new Date();
+ estimatedDate.setDate(estimatedDate.getDate() + Math.floor(Math.random() * 7) + 5);
+
+ return {
+ success: true,
+ nbnOrderId: generateNbnOrderId(),
+ avcId: generateAvcId(),
+ cvcId: generateCvcId(),
+ estimatedConnectionDate: estimatedDate,
+ message: "Order submitted successfully. You will receive confirmation via email.",
+ };
+ }
+
+ private async superloopSubmitOrder(params: any): Promise {
+ if (!this.superloopClient) {
+ throw new Error("Superloop client not configured");
+ }
+
+ try {
+ const remoteOrderId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substring(2)}`;
+
+ const superloopLocationId = params.csaId || params.superloopLocationId || "";
+
+ const orderResponse = await this.superloopClient.createOrder({
+ sourceType: "nbn",
+ planName: params.planName,
+ term: 1,
+ trafficClass: "tc4",
+ qualificationSearchId: params.sqReference || "",
+ locationId: superloopLocationId,
+ remoteOrderId,
+ productType: "Access Only",
+ restorationSla: "Standard",
+ contactName: params.contactName,
+ contactPhone: params.contactPhone.replace(/\s/g, ""),
+ contactEmail: params.contactEmail,
+ aggregationMethod: "L2TP",
+ ntdInstallation: "nbn-tech",
+ customerReference: `BRO-${params.userId.substring(0, 8)}`,
+ });
+
+ const estimatedDate = new Date();
+ estimatedDate.setDate(estimatedDate.getDate() + 7);
+
+ return {
+ success: true,
+ nbnOrderId: `SL-${orderResponse.id}`,
+ avcId: orderResponse.avcId || undefined,
+ cvcId: undefined,
+ estimatedConnectionDate: estimatedDate,
+ message: `Order submitted via Superloop Connect. Reference: ${orderResponse.id}`,
+ };
+ } catch (error: any) {
+ console.error("Superloop order submission error:", error);
+ return {
+ success: false,
+ message: `Order submission failed: ${error.message}`,
+ };
+ }
+ }
+
+ private async realSubmitOrder(params: any): Promise {
+ throw new Error("Real NBN API not configured. Set NBN_RSP_API_KEY and NBN_RSP_CERT_PATH environment variables.");
+ }
+
+ async getOrderStatus(orderId: string): Promise {
+ const [order] = await db.select().from(serviceOrders).where(eq(serviceOrders.id, orderId));
+ if (!order) return null;
+
+ return {
+ status: order.status,
+ avcId: order.avcId || undefined,
+ cvcId: order.cvcId || undefined,
+ estimatedConnectionDate: order.estimatedConnectionDate || undefined,
+ actualConnectionDate: order.actualConnectionDate || undefined,
+ };
+ }
+
+ async updateOrderStatus(orderId: string, status: string, message?: string, updatedBy: string = "system"): Promise {
+ await db.update(serviceOrders)
+ .set({ status, updatedAt: new Date() })
+ .where(eq(serviceOrders.id, orderId));
+
+ await db.insert(orderStatusHistory).values({
+ orderId,
+ status,
+ message,
+ updatedBy,
+ });
+ }
+
+ async activateService(orderId: string): Promise {
+ const [order] = await db.select().from(serviceOrders).where(eq(serviceOrders.id, orderId));
+ if (!order) throw new Error("Order not found");
+
+ let avcId = order.avcId;
+ let cvcId = order.cvcId;
+
+ if (!avcId) {
+ avcId = generateAvcId();
+ cvcId = cvcId || generateCvcId();
+ }
+
+ await db.update(serviceOrders)
+ .set({
+ status: "active",
+ avcId,
+ cvcId,
+ actualConnectionDate: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(serviceOrders.id, orderId));
+
+ await db.insert(orderStatusHistory).values({
+ orderId,
+ status: "active",
+ message: `Service activated. AVC ID: ${avcId}`,
+ updatedBy: "system",
+ });
+ }
+
+ async getUserOrders(userId: string) {
+ return db.select().from(serviceOrders).where(eq(serviceOrders.userId, userId));
+ }
+
+ async getOrderHistory(orderId: string) {
+ return db.select().from(orderStatusHistory).where(eq(orderStatusHistory.orderId, orderId));
+ }
+
+ async getOrderByNbnOrderId(nbnOrderId: string) {
+ const [order] = await db.select().from(serviceOrders).where(eq(serviceOrders.nbnOrderId, nbnOrderId));
+ return order || null;
+ }
+
+ async updateOrderByNbnOrderId(nbnOrderId: string, status: string, message?: string, updatedBy: string = "system"): Promise {
+ const order = await this.getOrderByNbnOrderId(nbnOrderId);
+ if (!order) return false;
+
+ await db.update(serviceOrders)
+ .set({ status, updatedAt: new Date() })
+ .where(eq(serviceOrders.id, order.id));
+
+ await db.insert(orderStatusHistory).values({
+ orderId: order.id,
+ status,
+ message,
+ updatedBy,
+ });
+
+ return true;
+ }
+}
+
+export const nbnService = new NbnService();
diff --git a/server/nitrogenClient.ts b/server/nitrogenClient.ts
new file mode 100644
index 0000000..bd49641
--- /dev/null
+++ b/server/nitrogenClient.ts
@@ -0,0 +1,396 @@
+import crypto from 'crypto';
+
+interface NitrogenConfig {
+ apiKey: string;
+ apiSecret: string;
+ tenantId: string;
+ baseUrl: string;
+ webhookSecret?: string;
+}
+
+interface TokenResponse {
+ access_token: string;
+ token_type: string;
+ expires_in: number;
+}
+
+interface LocationSearchResult {
+ id: string;
+ address: string;
+ gnafId?: string;
+ state: string;
+ postcode: string;
+ suburb: string;
+ locId?: string;
+}
+
+interface Infrastructure {
+ infrastructureId: string;
+ type: string;
+ status: string;
+ ports?: {
+ portId: number;
+ status: string;
+ hasActiveService: boolean;
+ avcId?: string;
+ }[];
+ infrastructureUpgradeOptions?: string[];
+ productsRequiringInfrastructureUpgrade?: string[];
+}
+
+interface ServiceQualificationResult {
+ id: string;
+ locationId: string;
+ locId: string;
+ address: string;
+ postcode: string;
+ suburb: string;
+ state: string;
+ serviceClass: string;
+ technologyType: string;
+ maxDownload: number;
+ maxUpload: number;
+ available: boolean;
+ alternativeTechnology?: string;
+ infrastructures: Infrastructure[];
+ availableProducts: string[];
+ installationTypes?: string[];
+ expiresAt: Date;
+ potsPresent?: boolean;
+}
+
+interface ProductOffer {
+ id: string;
+ name: string;
+ downloadSpeed: number;
+ uploadSpeed: number;
+ components: {
+ type: string;
+ required: boolean;
+ defaultOption: string;
+ availableOptions: string[];
+ }[];
+}
+
+interface OrderCreateParams {
+ sourceType: 'NBN';
+ customerReference: string;
+ qualificationSearchId: string;
+ locationId: string;
+ term?: number;
+ trafficClass?: string;
+ productType?: string;
+ productOffer: {
+ id: string;
+ components?: {
+ type: string;
+ option: string;
+ }[];
+ };
+ infrastructureId?: string;
+ portId?: number;
+ infrastructureUpgradeType?: string | null;
+ transferType?: 'SERVICE_TRANSFER' | 'CONNECT_OUTSTANDING' | null;
+ avcId?: string | null;
+ ntdInstallation?: 'no-action' | 'tech' | 'dispatch';
+ dispatchOptions?: {
+ sameAddressAsLocation?: boolean;
+ contactName: string;
+ contactPhone: string;
+ authorityToLeave?: boolean;
+ deliveryInstructions?: string;
+ address?: {
+ line1: string;
+ line2?: string;
+ suburb: string;
+ state: string;
+ postcode: string;
+ };
+ };
+ serviceConfiguration?: {
+ dslStabilityProfile?: 'standard' | 'stable';
+ layer3Options?: {
+ authMethod?: 'ipoe' | 'pppoe';
+ applyShapingRestriction?: boolean;
+ unblockPorts?: boolean;
+ };
+ };
+ bookedAppointmentId?: string;
+ potsServiceOwner?: boolean;
+ potsWaiver?: boolean;
+}
+
+interface OrderResponse {
+ id: string;
+ status: string;
+ subStatus: string;
+ customerReference: string;
+ locationId: string;
+ serviceId?: string;
+ avcId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface AppointmentSlot {
+ id: string;
+ startTime: string;
+ endTime: string;
+ demandType: string;
+}
+
+interface AppointmentResponse {
+ id: string;
+ status: string;
+ startTime: string;
+ endTime: string;
+ demandType: string;
+}
+
+export class NitrogenClient {
+ private config: NitrogenConfig;
+ private accessToken: string | null = null;
+ private tokenExpiry: number = 0;
+
+ constructor() {
+ this.config = {
+ apiKey: process.env.NITROGEN_API_KEY || '',
+ apiSecret: process.env.NITROGEN_API_SECRET || '',
+ tenantId: process.env.NITROGEN_TENANT_ID || '',
+ baseUrl: process.env.NITROGEN_USE_SANDBOX === 'true'
+ ? 'https://api-sandbox.nitrogen.aussiebroadband.com.au/v1'
+ : 'https://api.nitrogen.aussiebroadband.com.au/v1',
+ webhookSecret: process.env.NITROGEN_WEBHOOK_SECRET,
+ };
+ }
+
+ isConfigured(): boolean {
+ return !!(this.config.apiKey && this.config.apiSecret && this.config.tenantId);
+ }
+
+ private async getAccessToken(): Promise {
+ if (this.accessToken && Date.now() < this.tokenExpiry) {
+ return this.accessToken;
+ }
+
+ const response = await fetch(`${this.config.baseUrl}/auth/token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ api_key: this.config.apiKey,
+ api_secret: this.config.apiSecret,
+ tenant_id: this.config.tenantId,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Nitrogen auth failed: ${error}`);
+ }
+
+ const data: TokenResponse = await response.json();
+ this.accessToken = data.access_token;
+ this.tokenExpiry = Date.now() + (data.expires_in * 1000) - 60000;
+
+ return this.accessToken;
+ }
+
+ private async request(
+ method: string,
+ endpoint: string,
+ body?: any
+ ): Promise {
+ const token = await this.getAccessToken();
+
+ const response = await fetch(`${this.config.baseUrl}${endpoint}`, {
+ method,
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ 'X-Tenant-ID': this.config.tenantId,
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ let errorDetail = errorText;
+ try {
+ const errorJson = JSON.parse(errorText);
+ errorDetail = errorJson.message || errorJson.error || errorText;
+ } catch {}
+ throw new Error(`Nitrogen API error (${response.status}): ${errorDetail}`);
+ }
+
+ return response.json();
+ }
+
+ async searchLocation(address: string): Promise {
+ const results = await this.request<{ locations: LocationSearchResult[] }>(
+ 'GET',
+ `/locations/search?q=${encodeURIComponent(address)}`
+ );
+ return results.locations || [];
+ }
+
+ async getLocation(locationId: string): Promise {
+ return this.request('GET', `/locations/${locationId}`);
+ }
+
+ async performServiceQualification(
+ locationId: string,
+ sqType: 'STANDARD' | 'NFAS' = 'STANDARD',
+ avcId?: string
+ ): Promise {
+ const body: any = {
+ locationId,
+ sqType,
+ };
+
+ if (avcId) {
+ body.avcId = avcId;
+ }
+
+ const result = await this.request(
+ 'POST',
+ '/service-qualifications',
+ body
+ );
+
+ result.expiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000);
+
+ return result;
+ }
+
+ async getProductCatalog(): Promise {
+ const result = await this.request<{ products: ProductOffer[] }>(
+ 'GET',
+ '/product-catalog'
+ );
+ return result.products || [];
+ }
+
+ async getProductOffer(productOfferId: string): Promise {
+ return this.request('GET', `/product-catalog/${productOfferId}`);
+ }
+
+ async createOrder(params: OrderCreateParams): Promise {
+ const payload = {
+ sourceType: params.sourceType || 'NBN',
+ customerReference: params.customerReference,
+ qualificationSearchId: params.qualificationSearchId,
+ locationId: params.locationId,
+ term: params.term || 1,
+ trafficClass: params.trafficClass || 'tc4',
+ productType: params.productType || 'Access Only',
+ productOffer: params.productOffer,
+ infrastructureId: params.infrastructureId,
+ portId: params.portId,
+ infrastructureUpgradeType: params.infrastructureUpgradeType,
+ transferType: params.transferType,
+ avcId: params.avcId,
+ ntdInstallation: params.ntdInstallation || 'no-action',
+ dispatchOptions: params.dispatchOptions,
+ serviceConfiguration: params.serviceConfiguration || {
+ dslStabilityProfile: 'standard',
+ layer3Options: {
+ authMethod: 'ipoe',
+ applyShapingRestriction: false,
+ unblockPorts: false,
+ },
+ },
+ bookedAppointmentId: params.bookedAppointmentId,
+ potsServiceOwner: params.potsServiceOwner,
+ potsWaiver: params.potsWaiver,
+ };
+
+ Object.keys(payload).forEach((key) => {
+ if (payload[key as keyof typeof payload] === undefined) {
+ delete payload[key as keyof typeof payload];
+ }
+ });
+
+ return this.request('POST', '/orders', payload);
+ }
+
+ async getOrder(orderId: string): Promise {
+ return this.request('GET', `/orders/${orderId}`);
+ }
+
+ async cancelOrder(orderId: string, reason?: string): Promise {
+ return this.request('POST', `/orders/${orderId}/cancel`, { reason });
+ }
+
+ async getAvailableAppointments(
+ locationId: string,
+ demandType: string,
+ fromDate?: string,
+ toDate?: string
+ ): Promise {
+ let url = `/appointments/available?locationId=${locationId}&demandType=${demandType}`;
+ if (fromDate) url += `&fromDate=${fromDate}`;
+ if (toDate) url += `&toDate=${toDate}`;
+
+ const result = await this.request<{ slots: AppointmentSlot[] }>('GET', url);
+ return result.slots || [];
+ }
+
+ async reserveAppointment(slotId: string): Promise {
+ return this.request('POST', '/appointments/reserve', { slotId });
+ }
+
+ async getAppointment(appointmentId: string): Promise {
+ return this.request('GET', `/appointments/${appointmentId}`);
+ }
+
+ async rescheduleAppointment(
+ appointmentId: string,
+ newSlotId: string
+ ): Promise {
+ return this.request(
+ 'POST',
+ `/appointments/${appointmentId}/reschedule`,
+ { slotId: newSlotId }
+ );
+ }
+
+ async cancelAppointment(appointmentId: string): Promise {
+ await this.request('POST', `/appointments/${appointmentId}/cancel`, {});
+ }
+
+ verifyWebhookSignature(payload: string, signature: string): boolean {
+ if (!this.config.webhookSecret) {
+ console.warn('Nitrogen webhook secret not configured');
+ return false;
+ }
+
+ const expectedSignature = crypto
+ .createHmac('sha256', this.config.webhookSecret)
+ .update(payload)
+ .digest('hex');
+
+ return crypto.timingSafeEqual(
+ Buffer.from(signature),
+ Buffer.from(expectedSignature)
+ );
+ }
+
+ parseWebhookEvent(payload: string): {
+ eventType: string;
+ resourceType: string;
+ resourceId: string;
+ data: any;
+ } {
+ const event = JSON.parse(payload);
+ return {
+ eventType: event.eventType,
+ resourceType: event.resourceType,
+ resourceId: event.resourceId,
+ data: event.data,
+ };
+ }
+}
+
+export const nitrogenClient = new NitrogenClient();
diff --git a/server/replit_integrations/batch/index.ts b/server/replit_integrations/batch/index.ts
new file mode 100644
index 0000000..4d7efd0
--- /dev/null
+++ b/server/replit_integrations/batch/index.ts
@@ -0,0 +1,7 @@
+export {
+ batchProcess,
+ batchProcessWithSSE,
+ isRateLimitError,
+ type BatchOptions,
+} from "./utils";
+
diff --git a/server/replit_integrations/batch/utils.ts b/server/replit_integrations/batch/utils.ts
new file mode 100644
index 0000000..ee594d9
--- /dev/null
+++ b/server/replit_integrations/batch/utils.ts
@@ -0,0 +1,182 @@
+import pLimit from "p-limit";
+import pRetry from "p-retry";
+
+/**
+ * Batch Processing Utilities
+ *
+ * This module provides a generic batch processing function with built-in
+ * rate limiting and automatic retries. Use it for any task that requires
+ * processing multiple items through an LLM or external API.
+ *
+ * USAGE:
+ * ```typescript
+ * import { batchProcess, isRateLimitError } from "./replit_integrations/batch";
+ *
+ * const results = await batchProcess(
+ * artworks,
+ * async (artwork) => {
+ * // Your custom LLM logic here
+ * const response = await openai.chat.completions.create({
+ * model: "gpt-5.1",
+ * messages: [{ role: "user", content: `Categorize: ${artwork.name}` }],
+ * response_format: { type: "json_object" },
+ * });
+ * return JSON.parse(response.choices[0]?.message?.content || "{}");
+ * },
+ * { concurrency: 2, retries: 5 }
+ * );
+ * ```
+ */
+
+export interface BatchOptions {
+ /** Max concurrent requests (default: 2) */
+ concurrency?: number;
+ /** Max retry attempts for rate limit errors (default: 7) */
+ retries?: number;
+ /** Initial retry delay in ms (default: 2000) */
+ minTimeout?: number;
+ /** Max retry delay in ms (default: 128000) */
+ maxTimeout?: number;
+ /** Callback for progress updates */
+ onProgress?: (completed: number, total: number, item: unknown) => void;
+}
+
+/**
+ * Check if an error is a rate limit or quota violation.
+ * Use this in custom error handling if needed.
+ */
+export function isRateLimitError(error: unknown): boolean {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ return (
+ errorMsg.includes("429") ||
+ errorMsg.includes("RATELIMIT_EXCEEDED") ||
+ errorMsg.toLowerCase().includes("quota") ||
+ errorMsg.toLowerCase().includes("rate limit")
+ );
+}
+
+/**
+ * Process items in batches with rate limiting and automatic retries.
+ *
+ * @param items - Array of items to process
+ * @param processor - Async function to process each item (write your LLM logic here)
+ * @param options - Concurrency and retry settings
+ * @returns Promise resolving to array of results in the same order as input
+ *
+ * @example
+ * // Process CSV artwork data with custom categorization
+ * const categorized = await batchProcess(
+ * csvRows,
+ * async (row) => {
+ * const response = await openai.chat.completions.create({
+ * model: "gpt-5.1", // the newest OpenAI model
+ * messages: [{ role: "user", content: `Categorize artwork: ${row.name}` }],
+ * response_format: { type: "json_object" },
+ * });
+ * return { ...row, category: JSON.parse(response.choices[0]?.message?.content || "{}") };
+ * }
+ * );
+ */
+export async function batchProcess(
+ items: T[],
+ processor: (item: T, index: number) => Promise,
+ options: BatchOptions = {}
+): Promise {
+ const {
+ concurrency = 2,
+ retries = 7,
+ minTimeout = 2000,
+ maxTimeout = 128000,
+ onProgress,
+ } = options;
+
+ const limit = pLimit(concurrency);
+ let completed = 0;
+
+ const promises = items.map((item, index) =>
+ limit(() =>
+ pRetry(
+ async () => {
+ try {
+ const result = await processor(item, index);
+ completed++;
+ onProgress?.(completed, items.length, item);
+ return result;
+ } catch (error: unknown) {
+ if (isRateLimitError(error)) {
+ throw error; // Rethrow to trigger p-retry
+ }
+ // For non-rate-limit errors, abort immediately
+ throw new pRetry.AbortError(
+ error instanceof Error ? error : new Error(String(error))
+ );
+ }
+ },
+ { retries, minTimeout, maxTimeout, factor: 2 }
+ )
+ )
+ );
+
+ return Promise.all(promises);
+}
+
+/**
+ * Process items sequentially with SSE progress streaming.
+ * Use this when you need real-time progress updates to the client.
+ *
+ * @param items - Array of items to process
+ * @param processor - Async function to process each item
+ * @param sendEvent - Function to send SSE events to the client
+ * @param options - Retry settings (concurrency is always 1 for sequential)
+ */
+export async function batchProcessWithSSE(
+ items: T[],
+ processor: (item: T, index: number) => Promise,
+ sendEvent: (event: { type: string; [key: string]: unknown }) => void,
+ options: Omit = {}
+): Promise {
+ const { retries = 5, minTimeout = 1000, maxTimeout = 15000 } = options;
+
+ sendEvent({ type: "started", total: items.length });
+
+ const results: R[] = [];
+ let errors = 0;
+
+ for (let index = 0; index < items.length; index++) {
+ const item = items[index];
+ sendEvent({ type: "processing", index, item });
+
+ try {
+ const result = await pRetry(
+ () => processor(item, index),
+ {
+ retries,
+ minTimeout,
+ maxTimeout,
+ factor: 2,
+ onFailedAttempt: (error) => {
+ if (!isRateLimitError(error)) {
+ throw new pRetry.AbortError(
+ error instanceof Error ? error : new Error(String(error))
+ );
+ }
+ },
+ }
+ );
+ results.push(result);
+ sendEvent({ type: "progress", index, result });
+ } catch (error) {
+ errors++;
+ results.push(undefined as R); // Placeholder for failed items
+ sendEvent({
+ type: "progress",
+ index,
+ error: error instanceof Error ? error.message : "Processing failed",
+ });
+ }
+ }
+
+ sendEvent({ type: "complete", processed: items.length, errors });
+ return results;
+}
+
diff --git a/server/replit_integrations/chat/index.ts b/server/replit_integrations/chat/index.ts
new file mode 100644
index 0000000..822d8f7
--- /dev/null
+++ b/server/replit_integrations/chat/index.ts
@@ -0,0 +1,3 @@
+export { registerChatRoutes } from "./routes";
+export { chatStorage, type IChatStorage } from "./storage";
+
diff --git a/server/replit_integrations/chat/routes.ts b/server/replit_integrations/chat/routes.ts
new file mode 100644
index 0000000..c2b0dd2
--- /dev/null
+++ b/server/replit_integrations/chat/routes.ts
@@ -0,0 +1,886 @@
+import type { Express, Request, Response } from "express";
+import OpenAI from "openai";
+import { chatStorage } from "./storage";
+import { checkNBNAvailability } from "../../services/sq";
+import { storage } from "../../storage";
+
+// Define the tools for OpenAI function calling
+const ALEX_TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = [
+ {
+ type: "function",
+ function: {
+ name: "check_nbn_coverage",
+ description: "Check NBN availability and speed tiers at an Australian address. Use this when the user provides an address and wants to know if NBN is available.",
+ parameters: {
+ type: "object",
+ properties: {
+ address: {
+ type: "string",
+ description: "The full Australian address to check, e.g. '123 Main Street, Sydney NSW 2000'"
+ }
+ },
+ required: ["address"]
+ }
+ }
+ },
+ {
+ type: "function",
+ function: {
+ name: "get_plan_details",
+ description: "Get details about BroNET internet plans. Use this when the user asks about plans, pricing, or speeds.",
+ parameters: {
+ type: "object",
+ properties: {
+ plan_type: {
+ type: "string",
+ enum: ["all", "everyday", "extra_value", "family_max", "lightspeed", "hyperspeed"],
+ description: "The specific plan to get details for, or 'all' for all plans"
+ }
+ },
+ required: ["plan_type"]
+ }
+ }
+ },
+ {
+ type: "function",
+ function: {
+ name: "get_account_info",
+ description: "Get the logged-in user's account information including their current plan and service status. Only use this when the user asks about their own account, plan, or service.",
+ parameters: {
+ type: "object",
+ properties: {},
+ required: []
+ }
+ }
+ },
+ {
+ type: "function",
+ function: {
+ name: "create_support_ticket",
+ description: "Create a support ticket for the user. Use this when the user wants to report an issue, request help, or needs to escalate to human support.",
+ parameters: {
+ type: "object",
+ properties: {
+ subject: {
+ type: "string",
+ description: "A brief summary of the issue"
+ },
+ description: {
+ type: "string",
+ description: "Detailed description of the issue or request"
+ }
+ },
+ required: ["subject", "description"]
+ }
+ }
+ },
+ {
+ type: "function",
+ function: {
+ name: "check_network_outages",
+ description: "Check for any current network outages or incidents affecting BroNET services. Use this when users report connectivity issues or ask about outages.",
+ parameters: {
+ type: "object",
+ properties: {},
+ required: []
+ }
+ }
+ },
+ {
+ type: "function",
+ function: {
+ name: "get_billing_info",
+ description: "Get the user's billing information including next payment date, current charges, and recent invoices. Only use for logged-in users asking about their bill or payments.",
+ parameters: {
+ type: "object",
+ properties: {},
+ required: []
+ }
+ }
+ },
+ {
+ type: "function",
+ function: {
+ name: "get_usage_data",
+ description: "Get the user's internet usage data for the current and recent billing periods. Use when users ask about their data usage or how much they've downloaded.",
+ parameters: {
+ type: "object",
+ properties: {},
+ required: []
+ }
+ }
+ },
+ {
+ type: "function",
+ function: {
+ name: "check_service_status",
+ description: "Check the current status of the user's internet service connection. Use when users ask if their service is active, connected, or having issues.",
+ parameters: {
+ type: "object",
+ properties: {},
+ required: []
+ }
+ }
+ }
+];
+
+// Context for tool execution
+interface ToolContext {
+ userId?: string;
+}
+
+// Function to execute tool calls with user context
+async function executeToolCall(toolName: string, args: Record, context: ToolContext = {}): Promise {
+ switch (toolName) {
+ case "check_nbn_coverage": {
+ try {
+ const result = await checkNBNAvailability(args.address, '', undefined, undefined);
+ if (result.error || !result.available) {
+ return JSON.stringify({
+ available: false,
+ address: args.address,
+ message: result.error || "NBN service is not currently available at this address.",
+ suggestion: "Please verify the address is correct or contact support for assistance."
+ });
+ }
+ return JSON.stringify({
+ available: true,
+ address: result.formattedAddress || args.address,
+ technology: result.technology || "NBN",
+ maxSpeed: result.maxTier || "Contact for details",
+ message: `NBN is available via ${result.technology || 'NBN'}. Maximum speed: ${result.maxTier || 'Contact for details'}.`
+ });
+ } catch (error) {
+ return JSON.stringify({
+ available: false,
+ error: "Unable to check coverage at this time. Please try the coverage checker at /coverage."
+ });
+ }
+ }
+ case "get_plan_details": {
+ const plans = {
+ everyday: { name: "Everyday", speed: "25Mbps", promo: "$45/month for 6 months", regular: "$72/month", description: "Great for casual browsing and email" },
+ extra_value: { name: "Extra Value", speed: "50Mbps", promo: "$65/month for 6 months", regular: "$85/month", description: "Perfect for HD streaming" },
+ family_max: { name: "Family Max", speed: "500Mbps", promo: "$69/month for 6 months", regular: "$95/month", description: "Ideal for families - RECOMMENDED", recommended: true },
+ lightspeed: { name: "Lightspeed", speed: "1000Mbps", promo: "$85/month for 6 months", regular: "$109/month", description: "Ultra-fast for power users" },
+ hyperspeed: { name: "Hyperspeed", speed: "2000Mbps", promo: "$145/month for 6 months", regular: "$165/month", description: "Maximum speed available" }
+ };
+
+ if (args.plan_type === "all") {
+ return JSON.stringify({ plans: Object.values(plans), note: "All plans include unlimited data and no lock-in contracts" });
+ }
+ const plan = plans[args.plan_type as keyof typeof plans];
+ return plan ? JSON.stringify(plan) : JSON.stringify({ error: "Plan not found" });
+ }
+ case "get_account_info": {
+ if (!context.userId) {
+ return JSON.stringify({
+ error: "User not logged in",
+ message: "Please log in to view your account information. You can log in at /auth or check your dashboard at /dashboard."
+ });
+ }
+ try {
+ const user = await storage.getUser(context.userId);
+ if (!user) {
+ return JSON.stringify({ error: "User not found" });
+ }
+ // Get user's tickets
+ const tickets = await storage.getTicketsByUser(context.userId);
+ const openTickets = tickets.filter(t => t.status !== 'resolved' && t.status !== 'closed');
+
+ const fullName = `${user.firstName} ${user.lastName}`.trim();
+
+ return JSON.stringify({
+ name: fullName,
+ email: user.email,
+ plan: user.planId || "No active plan",
+ serviceAddress: user.serviceAddress || "Not set",
+ accountStatus: "Active",
+ openTickets: openTickets.length,
+ message: `Account found for ${fullName}. ${openTickets.length > 0 ? `You have ${openTickets.length} open support ticket(s).` : ''}`
+ });
+ } catch (error) {
+ return JSON.stringify({
+ error: "Unable to retrieve account information",
+ message: "Please check your dashboard at /dashboard for account details."
+ });
+ }
+ }
+ case "create_support_ticket": {
+ if (!context.userId) {
+ return JSON.stringify({
+ error: "User not logged in",
+ message: "Please log in to create a support ticket. You can log in at /auth or create a ticket from your dashboard at /dashboard."
+ });
+ }
+ try {
+ const ticket = await storage.createTicket({
+ userId: context.userId,
+ subject: args.subject,
+ description: args.description
+ });
+ return JSON.stringify({
+ success: true,
+ ticketId: ticket.id,
+ message: `Support ticket #${ticket.id} has been created. Our team will respond within 24 hours. You can track your ticket in your dashboard at /dashboard.`
+ });
+ } catch (error) {
+ return JSON.stringify({
+ error: "Failed to create ticket",
+ message: "Unable to create the support ticket. Please try again or submit through your dashboard at /dashboard."
+ });
+ }
+ }
+ case "check_network_outages": {
+ try {
+ const allIncidents = await storage.getIncidents();
+ // Filter for active incidents (not resolved)
+ const incidents = allIncidents.filter(inc =>
+ inc.status === 'investigating' || inc.status === 'identified' || inc.status === 'monitoring'
+ );
+ if (incidents.length === 0) {
+ return JSON.stringify({
+ hasOutages: false,
+ message: "All systems are operating normally. There are no current network outages or incidents affecting BroNET services.",
+ checkTime: new Date().toISOString()
+ });
+ }
+ const activeIncidents = incidents.map(inc => ({
+ title: inc.title,
+ severity: inc.severity,
+ status: inc.status,
+ affectedAreas: inc.affectedAreas,
+ description: inc.description
+ }));
+ return JSON.stringify({
+ hasOutages: true,
+ count: incidents.length,
+ incidents: activeIncidents,
+ message: `There ${incidents.length === 1 ? 'is' : 'are'} currently ${incidents.length} active incident(s) affecting BroNET services.`,
+ moreInfo: "Visit /support for full network status updates."
+ });
+ } catch (error) {
+ return JSON.stringify({
+ error: "Unable to check network status",
+ message: "Please visit /support to view the current network status."
+ });
+ }
+ }
+ case "get_billing_info": {
+ if (!context.userId) {
+ return JSON.stringify({
+ error: "User not logged in",
+ message: "Please log in to view your billing information. You can access billing details in your dashboard at /dashboard."
+ });
+ }
+ try {
+ const user = await storage.getUser(context.userId);
+ if (!user) {
+ return JSON.stringify({ error: "User not found" });
+ }
+
+ // Get billing info based on plan pricing
+ const planPrices: Record = {
+ 'everyday': 72,
+ 'extra_value': 85,
+ 'family_max': 95,
+ 'lightspeed': 109,
+ 'hyperspeed': 165
+ };
+
+ const currentPlan = user.planId || 'none';
+ const monthlyAmount = planPrices[currentPlan] || 0;
+
+ if (currentPlan === 'none') {
+ return JSON.stringify({
+ plan: 'No active plan',
+ message: "No active subscription found. Visit /plans to sign up for BroNET internet.",
+ manageAt: "Visit /signup to get started."
+ });
+ }
+
+ return JSON.stringify({
+ plan: currentPlan.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
+ monthlyAmount: `$${monthlyAmount}/month`,
+ paymentMethod: user.stripeCustomerId ? "Card on file" : "Not set up",
+ message: `Your ${currentPlan.replace('_', ' ')} plan costs $${monthlyAmount}/month.`,
+ note: "For your exact billing date, payment history, and invoices, please check your dashboard.",
+ manageAt: "Visit /dashboard to view billing details and manage payment methods."
+ });
+ } catch (error) {
+ return JSON.stringify({
+ error: "Unable to retrieve billing information",
+ message: "Please check your dashboard at /dashboard for billing details."
+ });
+ }
+ }
+ case "get_usage_data": {
+ if (!context.userId) {
+ return JSON.stringify({
+ error: "User not logged in",
+ message: "Please log in to view your usage data. You can access usage stats in your dashboard at /dashboard."
+ });
+ }
+ try {
+ const user = await storage.getUser(context.userId);
+ if (!user) {
+ return JSON.stringify({ error: "User not found" });
+ }
+
+ if (!user.planId) {
+ return JSON.stringify({
+ message: "No active plan found. Sign up at /plans to start using BroNET internet.",
+ dataLimit: "N/A"
+ });
+ }
+
+ // All BroNET plans include unlimited data
+ return JSON.stringify({
+ plan: user.planId.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
+ dataLimit: "Unlimited",
+ message: "Your BroNET plan includes unlimited data - download and stream as much as you like!",
+ note: "For detailed usage statistics and history, please check your modem's admin panel or visit your dashboard.",
+ viewDetails: "Visit /dashboard for account management."
+ });
+ } catch (error) {
+ return JSON.stringify({
+ error: "Unable to retrieve usage data",
+ message: "Please check your dashboard at /dashboard for usage information."
+ });
+ }
+ }
+ case "check_service_status": {
+ if (!context.userId) {
+ return JSON.stringify({
+ error: "User not logged in",
+ message: "Please log in to check your service status. You can view your connection status in your dashboard at /dashboard."
+ });
+ }
+ try {
+ const user = await storage.getUser(context.userId);
+ if (!user) {
+ return JSON.stringify({ error: "User not found" });
+ }
+
+ // Check if user has an active service
+ const hasActivePlan = !!user.planId;
+
+ if (!hasActivePlan) {
+ return JSON.stringify({
+ status: "No active service",
+ message: "You don't have an active internet plan. Visit /plans to sign up for BroNET internet.",
+ action: "Sign up at /signup to get connected."
+ });
+ }
+
+ // Check for any outages affecting this user's area
+ const allIncidents = await storage.getIncidents();
+ const activeIncidents = allIncidents.filter(inc =>
+ inc.status === 'investigating' || inc.status === 'identified' || inc.status === 'monitoring'
+ );
+
+ // Safely extract suburb from address for matching
+ let userSuburb = '';
+ if (user.serviceAddress) {
+ const addressParts = user.serviceAddress.split(',');
+ if (addressParts.length > 1 && addressParts[1]) {
+ userSuburb = addressParts[1].trim().toLowerCase();
+ }
+ }
+
+ const areaIncidents = activeIncidents.filter(inc => {
+ if (!userSuburb || !inc.affectedAreas) return false;
+ return inc.affectedAreas.toLowerCase().includes(userSuburb);
+ });
+
+ if (areaIncidents.length > 0) {
+ return JSON.stringify({
+ status: "Potential issues",
+ connectionHealth: "Degraded",
+ activeIncidents: areaIncidents.length,
+ message: `There may be network issues in your area. ${areaIncidents.length} incident(s) could be affecting your connection.`,
+ troubleshooting: "Try restarting your modem. If issues persist, our team is working to resolve the outage.",
+ moreInfo: "Visit /support for full network status."
+ });
+ }
+
+ return JSON.stringify({
+ status: "Active",
+ connectionHealth: "Good",
+ plan: user.planId?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
+ serviceAddress: user.serviceAddress || "Not set",
+ lastChecked: new Date().toISOString(),
+ message: "Your internet service is active and operating normally. No issues detected.",
+ troubleshooting: "If you're experiencing issues, try restarting your modem or create a support ticket."
+ });
+ } catch (error) {
+ return JSON.stringify({
+ error: "Unable to check service status",
+ message: "Please check your dashboard at /dashboard for service information."
+ });
+ }
+ }
+ default:
+ return JSON.stringify({ error: "Unknown function" });
+ }
+}
+
+// Rate limiting map: IP -> { count, resetTime }
+const rateLimitMap = new Map();
+const RATE_LIMIT_MAX = 20; // Max requests per window
+const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
+
+// Initialize OpenAI client (may be null if not configured)
+let openai: OpenAI | null = null;
+try {
+ if (process.env.AI_INTEGRATIONS_OPENAI_API_KEY && process.env.AI_INTEGRATIONS_OPENAI_BASE_URL) {
+ openai = new OpenAI({
+ apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
+ baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
+ });
+ }
+} catch (error) {
+ console.warn("OpenAI client initialization failed, will use fallback mode:", error);
+}
+
+// BroNET FAQ knowledge base for fallback mode
+const BRONET_FAQ = {
+ plans: {
+ nbn25: { name: "Basic", speed: "25Mbps", price: "$59/month", description: "Perfect for browsing and email" },
+ nbn50: { name: "Standard", speed: "50Mbps", price: "$69/month", description: "Great for streaming and working from home" },
+ nbn100: { name: "Fast", speed: "100Mbps", price: "$79/month", description: "Ideal for households with multiple users" },
+ nbn250: { name: "Superfast", speed: "250Mbps", price: "$99/month", description: "Perfect for heavy usage and 4K streaming" },
+ nbn1000: { name: "Ultra", speed: "1000Mbps", price: "$129/month", description: "Ultimate speed for power users and gamers" },
+ },
+ modems: {
+ eero7: { name: "eero 7", price: "$299", description: "Reliable mesh WiFi coverage for most homes" },
+ eero_pro7: { name: "eero Pro 7", price: "$599", description: "Premium mesh WiFi with maximum coverage and speed" },
+ },
+ support: "24/7 support available via dashboard support tickets. Check your dashboard for account details and billing.",
+ coverage: "BroNET services all NBN-connected areas in Australia. Use our coverage checker to verify availability at your address.",
+ billing: "Monthly billing cycles. Payment methods and invoice history available in your dashboard.",
+};
+
+// Secret redaction patterns
+const SECRET_PATTERNS = [
+ /\b[A-Za-z0-9]{32,}\b/g, // API keys (long alphanumeric)
+ /\bsk-[A-Za-z0-9]{20,}\b/g, // OpenAI-style keys
+ /\bpassword[:\s]*[^\s]+/gi, // Password mentions
+ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, // Credit card numbers
+];
+
+function redactSecrets(text: string): string {
+ let redacted = text;
+ SECRET_PATTERNS.forEach(pattern => {
+ redacted = redacted.replace(pattern, '[REDACTED]');
+ });
+ return redacted;
+}
+
+function checkRateLimit(ip: string): boolean {
+ const now = Date.now();
+ const record = rateLimitMap.get(ip);
+
+ if (!record || now > record.resetTime) {
+ rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
+ return true;
+ }
+
+ if (record.count >= RATE_LIMIT_MAX) {
+ return false;
+ }
+
+ record.count++;
+ return true;
+}
+
+function getFallbackResponse(message: string): string {
+ const lowerMsg = message.toLowerCase();
+
+ // Plans query
+ if (lowerMsg.includes('plan') || lowerMsg.includes('price') || lowerMsg.includes('cost')) {
+ const planList = Object.values(BRONET_FAQ.plans)
+ .map(p => `${p.name} (${p.speed}): ${p.price} - ${p.description}`)
+ .join('\n');
+ return `G'day! Here are BroNET's NBN plans:\n\n${planList}\n\nYou can view more details and sign up at /plans`;
+ }
+
+ // Modem query
+ if (lowerMsg.includes('modem') || lowerMsg.includes('router') || lowerMsg.includes('eero')) {
+ const modemList = Object.values(BRONET_FAQ.modems)
+ .map(m => `${m.name}: ${m.price} - ${m.description}`)
+ .join('\n');
+ return `We offer premium mesh WiFi routers:\n\n${modemList}\n\nCheck out /modems for more information.`;
+ }
+
+ // Coverage query
+ if (lowerMsg.includes('coverage') || lowerMsg.includes('available') || lowerMsg.includes('area')) {
+ return `${BRONET_FAQ.coverage} Visit /coverage to check if NBN is available at your address.`;
+ }
+
+ // Support query
+ if (lowerMsg.includes('support') || lowerMsg.includes('help') || lowerMsg.includes('contact')) {
+ return `${BRONET_FAQ.support} You can also visit /support for FAQs and contact information.`;
+ }
+
+ // Billing query
+ if (lowerMsg.includes('bill') || lowerMsg.includes('payment') || lowerMsg.includes('invoice')) {
+ return `${BRONET_FAQ.billing} For account-specific details, please log in to your dashboard at /dashboard.`;
+ }
+
+ // Default response
+ return `G'day! I'm BroNET's customer support assistant. I can help you with:\n\n• NBN Plans & Pricing\n• Modem & Router Options\n• Coverage & Availability\n• Account & Billing\n• Technical Support\n\nWhat would you like to know about?`;
+}
+
+export function registerChatRoutes(app: Express): void {
+ // Check AI configuration status
+ app.get("/api/chat/config-status", (req: Request, res: Response) => {
+ const isConfigured = !!openai;
+ const saveLogs = process.env.SAVE_CHAT_LOGS === 'true';
+ res.json({
+ isConfigured,
+ saveLogs,
+ mode: isConfigured ? 'ai' : 'fallback',
+ provider: isConfigured ? 'OpenAI (Replit AI Integrations)' : 'Built-in FAQ'
+ });
+ });
+ // Get all conversations (Admin only)
+ app.get("/api/conversations", async (req: Request, res: Response) => {
+ try {
+ // Require authentication and admin access
+ if (!req.session?.userId) {
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+
+ // Check if user is admin (assuming user object has isAdmin property)
+ // For now, just return user's own conversations for security
+ const conversations = await chatStorage.getUserConversations(req.session.userId);
+ res.json(conversations);
+ } catch (error) {
+ console.error("Error fetching conversations:", error);
+ res.status(500).json({ error: "Failed to fetch conversations" });
+ }
+ });
+
+ // Get single conversation with messages (Owner or Admin only)
+ app.get("/api/conversations/:id", async (req: Request, res: Response) => {
+ try {
+ if (!req.session?.userId) {
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+
+ const id = parseInt(req.params.id);
+ const conversation = await chatStorage.getConversation(id);
+
+ if (!conversation) {
+ return res.status(404).json({ error: "Conversation not found" });
+ }
+
+ // Verify ownership - user must own this conversation
+ if (conversation.userId && conversation.userId !== req.session.userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ const messages = await chatStorage.getMessagesByConversation(id);
+ res.json({ ...conversation, messages });
+ } catch (error) {
+ console.error("Error fetching conversation:", error);
+ res.status(500).json({ error: "Failed to fetch conversation" });
+ }
+ });
+
+ // Create new conversation
+ app.post("/api/conversations", async (req: Request, res: Response) => {
+ try {
+ const { title } = req.body;
+ const userId = req.session?.userId || null;
+ const conversation = await chatStorage.createConversation(title || "New Chat", userId);
+ res.status(201).json(conversation);
+ } catch (error) {
+ console.error("Error creating conversation:", error);
+ res.status(500).json({ error: "Failed to create conversation" });
+ }
+ });
+
+ // Delete conversation
+ app.delete("/api/conversations/:id", async (req: Request, res: Response) => {
+ try {
+ const id = parseInt(req.params.id);
+ await chatStorage.deleteConversation(id);
+ res.status(204).send();
+ } catch (error) {
+ console.error("Error deleting conversation:", error);
+ res.status(500).json({ error: "Failed to delete conversation" });
+ }
+ });
+
+ // Enhanced chat stream endpoint with AI and fallback modes
+ app.post("/api/chat/stream", async (req: Request, res: Response) => {
+ try {
+ const { conversationId, message, systemPrompt, history } = req.body;
+ const userId = req.session?.userId;
+ const ip = req.ip || req.socket.remoteAddress || 'unknown';
+ const saveLogs = process.env.SAVE_CHAT_LOGS === 'true';
+
+ // Rate limiting check
+ if (!checkRateLimit(ip)) {
+ return res.status(429).json({
+ error: "Too many requests. Please wait a moment before trying again."
+ });
+ }
+
+ // Redact secrets from user message
+ const sanitizedMessage = redactSecrets(message);
+
+ let convId = conversationId;
+
+ // Create or get conversation (save only if logging enabled)
+ if (!convId && saveLogs) {
+ const conversation = await chatStorage.createConversation("Support Chat", userId || null);
+ convId = conversation.id;
+ }
+
+ // Save user message only if logging enabled
+ if (convId && saveLogs) {
+ await chatStorage.createMessage(convId, "user", sanitizedMessage);
+ }
+
+ // Set up SSE
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+
+ // Send conversation ID and mode
+ res.write(`data: ${JSON.stringify({
+ conversationId: convId || null,
+ mode: openai ? 'ai' : 'fallback'
+ })}\n\n`);
+
+ let fullResponse = "";
+
+ if (openai) {
+ // AI mode: Use OpenAI with function calling
+ try {
+ // Build conversation history for context
+ const chatMessages: Array = [];
+
+ if (systemPrompt) {
+ chatMessages.push({ role: "system", content: systemPrompt });
+ }
+
+ // Use history from request if provided (allows memory without database logging)
+ if (history && Array.isArray(history) && history.length > 0) {
+ chatMessages.push(...history.map((m: { role: string; content: string }) => ({
+ role: m.role as "user" | "assistant",
+ content: m.content,
+ })));
+ // Add the current message
+ chatMessages.push({ role: "user", content: sanitizedMessage });
+ } else if (convId && saveLogs) {
+ // Fall back to database history if available
+ const messages = await chatStorage.getMessagesByConversation(convId);
+ chatMessages.push(...messages.map((m) => ({
+ role: m.role as "user" | "assistant",
+ content: m.content,
+ })));
+ } else {
+ // No history, just add current message
+ chatMessages.push({ role: "user", content: sanitizedMessage });
+ }
+
+ // First call - check if we need to use tools
+ const initialResponse = await openai.chat.completions.create({
+ model: "gpt-4.1-mini",
+ messages: chatMessages,
+ tools: ALEX_TOOLS,
+ tool_choice: "auto",
+ max_completion_tokens: 1024,
+ });
+
+ const assistantMessage = initialResponse.choices[0]?.message;
+
+ // Check if there are tool calls
+ if (assistantMessage?.tool_calls && assistantMessage.tool_calls.length > 0) {
+ // Execute all tool calls
+ const toolResults: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
+
+ for (const toolCall of assistantMessage.tool_calls) {
+ const args = JSON.parse(toolCall.function.arguments);
+ const result = await executeToolCall(toolCall.function.name, args, { userId });
+
+ // Send status update to client
+ const statusMap: Record = {
+ 'check_nbn_coverage': 'Checking NBN coverage...',
+ 'get_plan_details': 'Looking up plan details...',
+ 'get_account_info': 'Retrieving your account info...',
+ 'create_support_ticket': 'Creating support ticket...'
+ };
+ res.write(`data: ${JSON.stringify({ status: statusMap[toolCall.function.name] || 'Processing...' })}\n\n`);
+
+ toolResults.push({
+ role: "tool",
+ tool_call_id: toolCall.id,
+ content: result,
+ });
+ }
+
+ // Add assistant message with tool calls and tool results
+ chatMessages.push(assistantMessage);
+ chatMessages.push(...toolResults);
+
+ // Get final response with tool results
+ const finalStream = await openai.chat.completions.create({
+ model: "gpt-4.1-mini",
+ messages: chatMessages,
+ stream: true,
+ max_completion_tokens: 1024,
+ });
+
+ for await (const chunk of finalStream) {
+ const content = chunk.choices[0]?.delta?.content || "";
+ if (content) {
+ fullResponse += content;
+ res.write(`data: ${JSON.stringify({ content })}\n\n`);
+ }
+ }
+ } else {
+ // No tool calls, stream the response directly
+ if (assistantMessage?.content) {
+ fullResponse = assistantMessage.content;
+ // Simulate streaming for consistent UX
+ const words = fullResponse.split(' ');
+ for (let i = 0; i < words.length; i++) {
+ const chunk = (i === 0 ? '' : ' ') + words[i];
+ res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
+ await new Promise(resolve => setTimeout(resolve, 15));
+ }
+ }
+ }
+ } catch (error) {
+ console.error("AI mode failed, falling back to FAQ:", error);
+ // Fall back to FAQ mode on AI error
+ fullResponse = getFallbackResponse(sanitizedMessage);
+ res.write(`data: ${JSON.stringify({ content: fullResponse })}\n\n`);
+ }
+ } else {
+ // Fallback mode: Use built-in FAQ
+ fullResponse = getFallbackResponse(sanitizedMessage);
+ // Simulate streaming for consistent UX
+ const words = fullResponse.split(' ');
+ for (let i = 0; i < words.length; i++) {
+ const chunk = (i === 0 ? '' : ' ') + words[i];
+ res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
+ // Small delay to simulate streaming
+ await new Promise(resolve => setTimeout(resolve, 20));
+ }
+ }
+
+ // Save assistant message only if logging enabled
+ if (convId && saveLogs) {
+ await chatStorage.createMessage(convId, "assistant", fullResponse);
+ }
+
+ res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
+ res.end();
+ } catch (error) {
+ console.error("Error in chat stream:", error);
+ if (res.headersSent) {
+ res.write(`data: ${JSON.stringify({ error: "Failed to get response" })}\n\n`);
+ res.end();
+ } else {
+ res.status(500).json({ error: "Failed to process chat" });
+ }
+ }
+ });
+
+ // Export conversation to support ticket
+ app.post("/api/chat/export-ticket", async (req: Request, res: Response) => {
+ try {
+ const userId = req.session?.userId;
+ if (!userId) {
+ return res.status(401).json({ error: "Must be logged in to export to ticket" });
+ }
+
+ const { conversationId, subject } = req.body;
+ if (!conversationId) {
+ return res.status(400).json({ error: "Conversation ID required" });
+ }
+
+ // Get conversation messages
+ const messages = await chatStorage.getMessagesByConversation(conversationId);
+
+ // Format conversation as ticket description
+ const description = messages
+ .map(m => `[${m.role.toUpperCase()}]: ${m.content}`)
+ .join('\n\n');
+
+ // This assumes there's a storage.createTicket method from the main storage
+ // We'll need to import and use it
+ res.json({
+ success: true,
+ description,
+ subject: subject || 'Chat Support Conversation'
+ });
+ } catch (error) {
+ console.error("Error exporting to ticket:", error);
+ res.status(500).json({ error: "Failed to export conversation" });
+ }
+ });
+
+ // Send message and get AI response (streaming)
+ app.post("/api/conversations/:id/messages", async (req: Request, res: Response) => {
+ try {
+ const conversationId = parseInt(req.params.id);
+ const { content } = req.body;
+
+ // Save user message
+ await chatStorage.createMessage(conversationId, "user", content);
+
+ // Get conversation history for context
+ const messages = await chatStorage.getMessagesByConversation(conversationId);
+ const chatMessages = messages.map((m) => ({
+ role: m.role as "user" | "assistant",
+ content: m.content,
+ }));
+
+ // Set up SSE
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+
+ // Stream response from OpenAI
+ const stream = await openai.chat.completions.create({
+ model: "gpt-5.1",
+ messages: chatMessages,
+ stream: true,
+ max_completion_tokens: 2048,
+ });
+
+ let fullResponse = "";
+
+ for await (const chunk of stream) {
+ const content = chunk.choices[0]?.delta?.content || "";
+ if (content) {
+ fullResponse += content;
+ res.write(`data: ${JSON.stringify({ content })}\n\n`);
+ }
+ }
+
+ // Save assistant message
+ await chatStorage.createMessage(conversationId, "assistant", fullResponse);
+
+ res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
+ res.end();
+ } catch (error) {
+ console.error("Error sending message:", error);
+ // Check if headers already sent (SSE streaming started)
+ if (res.headersSent) {
+ res.write(`data: ${JSON.stringify({ error: "Failed to send message" })}\n\n`);
+ res.end();
+ } else {
+ res.status(500).json({ error: "Failed to send message" });
+ }
+ }
+ });
+}
+
diff --git a/server/replit_integrations/chat/storage.ts b/server/replit_integrations/chat/storage.ts
new file mode 100644
index 0000000..84b1e4c
--- /dev/null
+++ b/server/replit_integrations/chat/storage.ts
@@ -0,0 +1,48 @@
+import { db } from "../../db";
+import { conversations, messages } from "@shared/schema";
+import { eq, desc } from "drizzle-orm";
+
+export interface IChatStorage {
+ getConversation(id: number): Promise;
+ getAllConversations(): Promise<(typeof conversations.$inferSelect)[]>;
+ getUserConversations(userId: string): Promise<(typeof conversations.$inferSelect)[]>;
+ createConversation(title: string, userId: string | null): Promise;
+ deleteConversation(id: number): Promise;
+ getMessagesByConversation(conversationId: number): Promise<(typeof messages.$inferSelect)[]>;
+ createMessage(conversationId: number, role: string, content: string): Promise;
+}
+
+export const chatStorage: IChatStorage = {
+ async getConversation(id: number) {
+ const [conversation] = await db.select().from(conversations).where(eq(conversations.id, id));
+ return conversation;
+ },
+
+ async getAllConversations() {
+ return db.select().from(conversations).orderBy(desc(conversations.createdAt));
+ },
+
+ async getUserConversations(userId: string) {
+ return db.select().from(conversations).where(eq(conversations.userId, userId)).orderBy(desc(conversations.createdAt));
+ },
+
+ async createConversation(title: string, userId: string | null) {
+ const [conversation] = await db.insert(conversations).values({ title, userId }).returning();
+ return conversation;
+ },
+
+ async deleteConversation(id: number) {
+ await db.delete(messages).where(eq(messages.conversationId, id));
+ await db.delete(conversations).where(eq(conversations.id, id));
+ },
+
+ async getMessagesByConversation(conversationId: number) {
+ return db.select().from(messages).where(eq(messages.conversationId, conversationId)).orderBy(messages.createdAt);
+ },
+
+ async createMessage(conversationId: number, role: string, content: string) {
+ const [message] = await db.insert(messages).values({ conversationId, role, content }).returning();
+ return message;
+ },
+};
+
diff --git a/server/replit_integrations/image/client.ts b/server/replit_integrations/image/client.ts
new file mode 100644
index 0000000..bb5bc8a
--- /dev/null
+++ b/server/replit_integrations/image/client.ts
@@ -0,0 +1,59 @@
+import fs from "node:fs";
+import OpenAI, { toFile } from "openai";
+import { Buffer } from "node:buffer";
+
+export const openai = new OpenAI({
+ apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
+ baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
+});
+
+/**
+ * Generate an image and return as Buffer.
+ * Uses gpt-image-1 model via Replit AI Integrations.
+ */
+export async function generateImageBuffer(
+ prompt: string,
+ size: "1024x1024" | "512x512" | "256x256" = "1024x1024"
+): Promise {
+ const response = await openai.images.generate({
+ model: "gpt-image-1",
+ prompt,
+ size,
+ });
+ const base64 = response.data[0]?.b64_json ?? "";
+ return Buffer.from(base64, "base64");
+}
+
+/**
+ * Edit/combine multiple images into a composite.
+ * Uses gpt-image-1 model via Replit AI Integrations.
+ */
+export async function editImages(
+ imageFiles: string[],
+ prompt: string,
+ outputPath?: string
+): Promise {
+ const images = await Promise.all(
+ imageFiles.map((file) =>
+ toFile(fs.createReadStream(file), file, {
+ type: "image/png",
+ })
+ )
+ );
+
+ const response = await openai.images.edit({
+ model: "gpt-image-1",
+ image: images,
+ prompt,
+ });
+
+ const imageBase64 = response.data[0]?.b64_json ?? "";
+ const imageBytes = Buffer.from(imageBase64, "base64");
+
+ if (outputPath) {
+ fs.writeFileSync(outputPath, imageBytes);
+ }
+
+ return imageBytes;
+}
+
diff --git a/server/replit_integrations/image/index.ts b/server/replit_integrations/image/index.ts
new file mode 100644
index 0000000..2ad0d29
--- /dev/null
+++ b/server/replit_integrations/image/index.ts
@@ -0,0 +1,3 @@
+export { registerImageRoutes } from "./routes";
+export { openai, generateImageBuffer, editImages } from "./client";
+
diff --git a/server/replit_integrations/image/routes.ts b/server/replit_integrations/image/routes.ts
new file mode 100644
index 0000000..a62fbae
--- /dev/null
+++ b/server/replit_integrations/image/routes.ts
@@ -0,0 +1,31 @@
+import type { Express, Request, Response } from "express";
+import { openai } from "./client";
+
+export function registerImageRoutes(app: Express): void {
+ app.post("/api/generate-image", async (req: Request, res: Response) => {
+ try {
+ const { prompt, size = "1024x1024" } = req.body;
+
+ if (!prompt) {
+ return res.status(400).json({ error: "Prompt is required" });
+ }
+
+ const response = await openai.images.generate({
+ model: "gpt-image-1",
+ prompt,
+ n: 1,
+ size: size as "1024x1024" | "512x512" | "256x256",
+ });
+
+ const imageData = response.data[0];
+ res.json({
+ url: imageData.url,
+ b64_json: imageData.b64_json,
+ });
+ } catch (error) {
+ console.error("Error generating image:", error);
+ res.status(500).json({ error: "Failed to generate image" });
+ }
+ });
+}
+
diff --git a/server/routes.ts b/server/routes.ts
index ae68e01..eada514 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -1,16 +1,2249 @@
-import type { Express } from "express";
+import express, { type Express } from "express";
import { createServer, type Server } from "http";
+import session from "express-session";
+import connectPgSimple from "connect-pg-simple";
+import crypto from "crypto";
import { storage } from "./storage";
+import { db } from "./db";
+import { sql } from "drizzle-orm";
+import {
+ insertUserSchema,
+ insertTicketSchema,
+ insertIncidentSchema,
+ insertContactMessageSchema,
+ insertNbnDatasetSchema,
+ insertPlanSchema,
+} from "@shared/schema";
+import { validateAddress } from "./services/nominatim";
+import { checkNBNAvailability, getSQMode, generateAddressHash } from "./services/sq";
+import bcrypt from "bcrypt";
+import { registerChatRoutes } from "./replit_integrations/chat";
+
+declare module 'express-session' {
+ interface SessionData {
+ userId: number;
+ }
+}
+
+// Middleware to require authentication
+function requireAuth(req: any, res: any, next: any) {
+ if (!req.session?.userId) {
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+ next();
+}
export async function registerRoutes(
httpServer: Server,
app: Express
): Promise {
- // put application routes here
- // prefix all routes with /api
+
+ // Set up session middleware
+ const PgStore = connectPgSimple(session);
+ const isProduction = process.env.REPLIT_DEPLOYMENT === "1" || process.env.NODE_ENV === "production";
+
+ // Trust proxy for Replit's reverse proxy
+ if (isProduction) {
+ app.set('trust proxy', 1);
+ }
+
+ app.use(
+ session({
+ store: new PgStore({
+ conString: process.env.DATABASE_URL,
+ createTableIfMissing: true,
+ }),
+ secret: process.env.SESSION_SECRET || "bronet-dev-secret-key",
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ secure: isProduction, // Use secure cookies in production
+ httpOnly: true,
+ maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
+ sameSite: isProduction ? 'none' : 'lax', // Required for cross-site cookies
+ },
+ })
+ );
+
+ // ============ AUTH ROUTES ============
+
+ app.post("/api/auth/signup", async (req, res) => {
+ try {
+ const data = insertUserSchema.parse(req.body);
+
+ // Check if email already exists
+ const existing = await storage.getUserByEmail(data.email);
+ if (existing) {
+ return res.status(400).json({ message: "Email already registered" });
+ }
+
+ // Hash password with bcrypt
+ const hashedPassword = await bcrypt.hash(data.password, 10);
+
+ const user = await storage.createUser({
+ ...data,
+ password: hashedPassword,
+ });
+
+ req.session.userId = user.id;
+ req.session.save((err) => {
+ if (err) {
+ console.error("Session save error:", err);
+ return res.status(500).json({ message: "Session error" });
+ }
+ res.json({ user: { ...user, password: undefined } });
+ });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ app.post("/api/auth/login", async (req, res) => {
+ try {
+ const { email, password } = req.body;
+
+ const user = await storage.getUserByEmail(email);
+ if (!user) {
+ return res.status(401).json({ message: "Invalid credentials" });
+ }
+
+ // Verify password with bcrypt
+ const isValid = await bcrypt.compare(password, user.password);
+ if (!isValid) {
+ return res.status(401).json({ message: "Invalid credentials" });
+ }
+
+ req.session.userId = user.id;
+ req.session.save((err) => {
+ if (err) {
+ console.error("Session save error:", err);
+ return res.status(500).json({ message: "Session error" });
+ }
+ res.json({ user: { ...user, password: undefined } });
+ });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ app.post("/api/auth/logout", (req, res) => {
+ req.session.destroy(() => {
+ res.json({ message: "Logged out" });
+ });
+ });
+
+ app.get("/api/auth/me", async (req, res) => {
+ if (!req.session?.userId) {
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+
+ const user = await storage.getUser(req.session.userId);
+ if (!user) {
+ return res.status(404).json({ message: "User not found" });
+ }
+
+ res.json({ user: { ...user, password: undefined } });
+ });
+
+ app.patch("/api/auth/profile", requireAuth, async (req, res) => {
+ try {
+ const updates = req.body;
+ delete updates.password; // Don't allow password updates via this endpoint
+
+ const user = await storage.updateUser(req.session.userId!, updates);
+ res.json({ user: { ...user, password: undefined } });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ // ============ USER MANAGEMENT ROUTES ============
+
+ app.post("/api/user/plan", requireAuth, async (req, res) => {
+ try {
+ const { planId } = req.body;
+
+ if (!planId || typeof planId !== 'string') {
+ return res.status(400).json({ message: "Plan ID is required" });
+ }
+
+ const user = await storage.getUser(req.session.userId!);
+ if (!user) {
+ return res.status(404).json({ message: "User not found" });
+ }
+
+ const oldPlanId = user.planId;
+ const updatedUser = await storage.updateUser(req.session.userId!, { planId });
+
+ await storage.createBillingRecord({
+ userId: req.session.userId!,
+ amount: "0.00",
+ description: `Plan changed from ${oldPlanId || 'None'} to ${planId}`,
+ planId,
+ });
+
+ res.json({ user: { ...updatedUser, password: undefined } });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ app.put("/api/user/address", requireAuth, async (req, res) => {
+ try {
+ const { serviceAddress } = req.body;
+
+ if (typeof serviceAddress !== 'string') {
+ return res.status(400).json({ message: "Service address is required" });
+ }
+
+ const updatedUser = await storage.updateUser(req.session.userId!, { serviceAddress });
+ res.json({ user: { ...updatedUser, password: undefined } });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ app.post("/api/user/password", requireAuth, async (req, res) => {
+ try {
+ const { oldPassword, newPassword } = req.body;
+
+ if (!oldPassword || !newPassword) {
+ return res.status(400).json({ message: "Both old and new passwords are required" });
+ }
+
+ if (newPassword.length < 6) {
+ return res.status(400).json({ message: "New password must be at least 6 characters" });
+ }
+
+ const user = await storage.getUser(req.session.userId!);
+ if (!user) {
+ return res.status(404).json({ message: "User not found" });
+ }
+
+ const isValid = await bcrypt.compare(oldPassword, user.password);
+ if (!isValid) {
+ return res.status(401).json({ message: "Current password is incorrect" });
+ }
+
+ const hashedPassword = await bcrypt.hash(newPassword, 10);
+ await storage.updateUser(req.session.userId!, { password: hashedPassword });
+
+ res.json({ message: "Password updated successfully" });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ app.get("/api/billing", requireAuth, async (req, res) => {
+ try {
+ const history = await storage.getBillingHistory(req.session.userId!);
+ res.json({ history });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ TICKET ROUTES ============
+
+ app.get("/api/tickets", requireAuth, async (req, res) => {
+ try {
+ const tickets = await storage.getTickets(req.session.userId!);
+ res.json({ tickets });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ app.post("/api/tickets", requireAuth, async (req, res) => {
+ try {
+ const data = insertTicketSchema.parse({
+ ...req.body,
+ userId: req.session.userId,
+ });
+
+ const ticket = await storage.createTicket(data);
+ res.status(201).json({ ticket });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ app.get("/api/tickets/:id/replies", requireAuth, async (req, res) => {
+ try {
+ const replies = await storage.getTicketReplies(req.params.id);
+ res.json({ replies });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ INCIDENT ROUTES ============
+
+ app.get("/api/incidents", async (_req, res) => {
+ try {
+ const incidents = await storage.getIncidents();
+ res.json({ incidents });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ app.post("/api/incidents", requireAuth, async (req, res) => {
+ try {
+ const data = insertIncidentSchema.parse(req.body);
+ const incident = await storage.createIncident(data);
+ res.status(201).json({ incident });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ app.patch("/api/incidents/:id/resolve", requireAuth, async (req, res) => {
+ try {
+ await storage.resolveIncident(req.params.id);
+ res.json({ message: "Incident resolved" });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ // ============ CONTACT MESSAGE ROUTES ============
+
+ app.get("/api/messages", requireAuth, async (req, res) => {
+ try {
+ const messages = await storage.getContactMessages(req.session.userId!);
+ res.json({ messages });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ app.post("/api/messages", async (req, res) => {
+ try {
+ const data = insertContactMessageSchema.parse({
+ ...req.body,
+ userId: req.session.userId || null,
+ });
+
+ const message = await storage.createContactMessage(data);
+ res.status(201).json({ message });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ // ============ USAGE DATA (Mock for now) ============
+
+ app.get("/api/usage", requireAuth, async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const seed = userId.charCodeAt(0);
+ const usage = [
+ { month: 'Current', download: 450 + (seed % 100), upload: 45 + (seed % 10) },
+ { month: 'Last Month', download: 420 + (seed % 100), upload: 40 + (seed % 10) },
+ { month: '2 Months Ago', download: 380 + (seed % 100), upload: 35 + (seed % 10) },
+ ];
+ res.json({ usage });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ INTERCOM ROUTES ============
+
+ // Generate HMAC hash for Intercom Identity Verification
+ app.get("/api/intercom/token", async (req, res) => {
+ try {
+ const secret = process.env.INTERCOM_IDENTITY_SECRET;
+ if (!secret) {
+ return res.status(500).json({ error: "Intercom identity verification not configured" });
+ }
+
+ // If user is logged in, include their info
+ if (req.session?.userId) {
+ const user = await storage.getUser(req.session.userId);
+ if (user) {
+ const userId = String(user.id);
+ const userHash = crypto.createHmac('sha256', secret).update(userId).digest('hex');
+ const createdAt = user.joinedAt ? Math.floor(new Date(user.joinedAt).getTime() / 1000) : undefined;
+ return res.json({
+ user_hash: userHash,
+ user_id: userId,
+ email: user.email,
+ name: `${user.firstName} ${user.lastName}`.trim(),
+ created_at: createdAt
+ });
+ }
+ }
+
+ // For anonymous users, no user_hash needed
+ return res.json({ anonymous: true });
+ } catch (error: any) {
+ console.error('Intercom token error:', error);
+ res.status(500).json({ error: "Failed to generate token" });
+ }
+ });
+
+ // ============ COVERAGE CHECK ROUTES ============
+
+ // Intercom-friendly NBN lookup endpoint (for custom actions/bots)
+ app.get("/api/intercom/nbn-lookup", async (req, res) => {
+ // Set proper headers for Intercom
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+
+ try {
+ const address = req.query.address as string;
+
+ if (!address || address.length < 5) {
+ return res.status(200).json({
+ success: false,
+ message: "Please provide a full Australian address to check NBN availability."
+ });
+ }
+
+ // Import and use the NBN availability check
+ const sqResult = await checkNBNAvailability(
+ address,
+ '',
+ undefined,
+ undefined
+ );
+
+ if (sqResult.error || !sqResult.available) {
+ return res.json({
+ success: false,
+ address: address,
+ message: sqResult.error || "NBN service is not currently available at this address.",
+ suggestion: "Please check the address is correct or contact our support team for assistance."
+ });
+ }
+
+ // Format a friendly response for Intercom
+ const response = {
+ success: true,
+ address: sqResult.formattedAddress || address,
+ technology: sqResult.technology || "NBN",
+ maxSpeed: sqResult.maxTier || "Contact for details",
+ available: sqResult.available,
+ message: `Great news! NBN is available at this address via ${sqResult.technology || 'NBN'}. Maximum speed available: ${sqResult.maxTier || 'Contact for details'}.`,
+ plans_url: "https://bronet-site.replit.app/plans",
+ signup_url: "https://bronet-site.replit.app/signup"
+ };
+
+ res.json(response);
+ } catch (error: any) {
+ console.error('Intercom NBN lookup error:', error);
+ res.json({
+ success: false,
+ message: "Sorry, I couldn't check that address right now. Please try again or contact our support team."
+ });
+ }
+ });
+
+ // Address autocomplete suggestions
+ app.get("/api/coverage/suggest", async (req, res) => {
+ try {
+ const query = req.query.q as string;
+ if (!query || query.length < 3) {
+ return res.json({ suggestions: [] });
+ }
+
+ // Try Superloop location search first (if configured)
+ try {
+ const { getSuperloopClient } = await import('./superloopClient');
+ const client = getSuperloopClient();
+
+ if (client.isConfigured()) {
+ const locations = await client.searchLocationEnhanced(query);
+ const suggestions = locations.map(loc => ({
+ displayName: loc.description || loc.address,
+ address: loc.description || loc.address,
+ suburb: loc.suburb,
+ state: loc.state,
+ postcode: loc.postcode,
+ locationId: loc.id,
+ }));
+ return res.json({ suggestions, source: 'superloop' });
+ }
+ } catch (superloopError) {
+ console.warn('Superloop location search failed, falling back to Nominatim:', superloopError);
+ }
+
+ // Fallback to Nominatim
+ const { searchAddresses } = await import("./services/nominatim");
+ const suggestions = await searchAddresses(query);
+ res.json({ suggestions, source: 'nominatim' });
+ } catch (error: any) {
+ console.error('Address suggestion error:', error);
+ res.json({ suggestions: [] });
+ }
+ });
+
+ // Get coverage system status
+ app.get("/api/coverage/status", async (_req, res) => {
+ try {
+ const mode = getSQMode();
+ const datasetCount = (await storage.getAllNbnDataset()).length;
+
+ let configuredMode = mode;
+ if (mode === 'none' && datasetCount > 0) {
+ configuredMode = 'dataset';
+ }
+
+ res.json({
+ mode: configuredMode,
+ wholesaleConfigured: mode === 'wholesale_api',
+ datasetRecords: datasetCount,
+ addressValidationEnabled: true,
+ });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Perform coverage check
+ app.post("/api/coverage/check", async (req, res) => {
+ try {
+ const { address } = req.body;
+
+ if (!address || typeof address !== 'string' || address.trim().length < 5) {
+ return res.status(400).json({ message: "Valid address is required" });
+ }
+
+ // Check if address looks like it came from NBN API (contains LOT or is uppercase formatted)
+ const isNbnFormattedAddress = /^(LOT\s+\d+\s+)?\d+\s+[A-Z]+.*\d{4}$/i.test(address.trim());
+
+ let addressResult: any;
+ let addressForNBN: string;
+
+ if (isNbnFormattedAddress) {
+ // Skip Nominatim validation for NBN-formatted addresses - use directly
+ const postcodeMatch = address.match(/\b(\d{4})\b/);
+ const stateMatch = address.match(/\b(NSW|VIC|QLD|WA|SA|TAS|NT|ACT)\b/i);
+ const suburbMatch = address.match(/\b([A-Z]{2,})\s+(NSW|VIC|QLD|WA|SA|TAS|NT|ACT)\s+\d{4}/i);
+
+ addressResult = {
+ success: true,
+ normalizedAddress: address.trim(),
+ postcode: postcodeMatch?.[1],
+ state: stateMatch?.[1]?.toUpperCase(),
+ suburb: suburbMatch?.[1],
+ };
+ addressForNBN = address.trim();
+ } else {
+ // Step 1: Validate and normalize address using Nominatim
+ addressResult = await validateAddress(address);
+
+ if (!addressResult.success || !addressResult.normalizedAddress) {
+ return res.status(400).json({
+ success: false,
+ message: addressResult.error || "Address validation failed",
+ });
+ }
+
+ // Use original address for NBN API to preserve street number
+ const inputMatch = address.trim().match(/^(\d+[A-Za-z]?)\s+(.+)/);
+ addressForNBN = addressResult.normalizedAddress;
+
+ if (inputMatch) {
+ const streetNumber = inputMatch[1];
+ if (!addressResult.normalizedAddress.startsWith(streetNumber)) {
+ addressForNBN = `${streetNumber} ${addressResult.normalizedAddress}`;
+ }
+ }
+ }
+
+ const inputMatch = address.trim().match(/^(\d+[A-Za-z]?)\s+(.+)/);
+
+ const sqResult = await checkNBNAvailability(
+ addressForNBN,
+ addressResult.postcode || '',
+ addressResult.latitude,
+ addressResult.longitude
+ );
+
+ // Step 3: Store in coverage history
+ if (req.session?.userId) {
+ await storage.createCoverageCheck({
+ userId: req.session.userId,
+ inputAddress: address.trim(),
+ normalizedAddress: addressResult.normalizedAddress,
+ latitude: addressResult.latitude || null,
+ longitude: addressResult.longitude || null,
+ postcode: addressResult.postcode || null,
+ technology: sqResult.technology || null,
+ maxTier: sqResult.maxTier || null,
+ available: sqResult.available !== undefined ? (sqResult.available ? 1 : 0) : null,
+ source: sqResult.source,
+ rawResponse: sqResult.rawResponse ? JSON.stringify(sqResult.rawResponse) : null,
+ });
+ }
+
+ // Step 4: Return result - prefer RapidAPI address details when available
+ let finalAddress = sqResult.formattedAddress || addressResult.normalizedAddress;
+ const inputStreetNumber = inputMatch ? inputMatch[1] : null;
+
+ // Clean up the address - if RapidAPI returned address already contains the street number, use it as-is
+ // Otherwise, prepend the user's street number
+ if (inputStreetNumber && finalAddress) {
+ // Check if the address already contains the street number somewhere
+ const addressContainsNumber = finalAddress.match(new RegExp(`\\b${inputStreetNumber}\\b`));
+ if (!addressContainsNumber && !finalAddress.match(/^\d+/)) {
+ finalAddress = `${inputStreetNumber} ${finalAddress}`;
+ }
+ }
+
+ // Clean up address formatting (remove "Australia" suffix, normalize spacing)
+ finalAddress = finalAddress?.replace(/\s+Australia$/i, '').replace(/\s+/g, ' ').trim();
+
+ const finalSuburb = sqResult.locality || addressResult.suburb;
+ const finalPostcode = sqResult.postcode || addressResult.postcode;
+ const finalState = sqResult.state || addressResult.state;
+
+ res.json({
+ success: true,
+ result: {
+ normalizedAddress: finalAddress,
+ postcode: finalPostcode,
+ suburb: finalSuburb,
+ state: finalState,
+ technology: sqResult.technology,
+ maxTier: sqResult.maxTier,
+ available: sqResult.available,
+ source: sqResult.source,
+ locId: sqResult.locId,
+ },
+ });
+
+ } catch (error: any) {
+ console.error('Coverage check error:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || "Failed to check coverage",
+ });
+ }
+ });
+
+ // Get coverage check history
+ app.get("/api/coverage/history", requireAuth, async (req, res) => {
+ try {
+ const checks = await storage.getCoverageChecks(req.session.userId!);
+ res.json({ checks });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ SUPERLOOP CONNECT API ROUTES ============
+
+ // Check if Superloop is configured
+ app.get("/api/superloop/status", async (_req, res) => {
+ try {
+ const { getSuperloopClient } = await import('./superloopClient');
+ const client = getSuperloopClient();
+ res.json({
+ configured: client.isConfigured(),
+ mode: client.isConfigured() ? 'live' : 'simulated'
+ });
+ } catch (error: any) {
+ res.json({ configured: false, mode: 'simulated' });
+ }
+ });
+
+ // Superloop location search (address autocomplete)
+ app.get("/api/superloop/locations", async (req, res) => {
+ try {
+ const query = req.query.q as string;
+ if (!query || query.length < 3) {
+ return res.json({ locations: [] });
+ }
+
+ const { getSuperloopClient } = await import('./superloopClient');
+ const client = getSuperloopClient();
+
+ if (!client.isConfigured()) {
+ return res.json({ locations: [], mode: 'simulated' });
+ }
+
+ const locations = await client.searchLocationEnhanced(query);
+ res.json({
+ locations: locations.map(loc => ({
+ id: loc.id,
+ address: loc.description || loc.address,
+ suburb: loc.suburb,
+ state: loc.state,
+ postcode: loc.postcode
+ })),
+ mode: 'live'
+ });
+ } catch (error: any) {
+ console.error('Superloop location search error:', error);
+ res.json({ locations: [], error: error.message });
+ }
+ });
+
+ // Superloop full service qualification
+ app.post("/api/superloop/qualify", async (req, res) => {
+ try {
+ const { locationId } = req.body;
+
+ if (!locationId) {
+ return res.status(400).json({ message: "Location ID is required" });
+ }
+
+ const { getSuperloopClient } = await import('./superloopClient');
+ const client = getSuperloopClient();
+
+ if (!client.isConfigured()) {
+ return res.status(400).json({
+ message: "Superloop API not configured",
+ mode: 'simulated'
+ });
+ }
+
+ const qualification = await client.qualifyLocation(locationId);
+
+ res.json({
+ success: true,
+ qualification: {
+ locId: qualification.locId,
+ locationId: qualification.locationId,
+ qualificationSearchId: qualification.qualificationSearchId,
+ remoteQualificationSearchId: qualification.remoteQualificationSearchId,
+ technologyType: qualification.technologyType,
+ serviceClass: qualification.serviceClass,
+ maxDownload: qualification.maxDownload,
+ maxUpload: qualification.maxUpload,
+ available: qualification.available,
+ region: qualification.region,
+ poi: qualification.poi,
+ poiName: qualification.poiName,
+ hasActivePOTS: qualification.hasActivePOTS,
+ serviceType: qualification.serviceType,
+ generationTwoNtds: qualification.generationTwoNtds,
+ firstOrAdditionalNtdPlans: qualification.firstOrAdditionalNtdPlans,
+ generationOneNtdPlans: qualification.generationOneNtdPlans,
+ generationTwoNtdPlans: qualification.generationTwoNtdPlans,
+ infrastructures: qualification.infrastructures,
+ infrastructureInstallationOptions: qualification.infrastructureInstallationOptions,
+ plans: qualification.plans,
+ },
+ mode: 'live'
+ });
+ } catch (error: any) {
+ console.error('Superloop qualification error:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || "Service qualification failed"
+ });
+ }
+ });
+
+ // Superloop create order
+ app.post("/api/superloop/orders", requireAuth, async (req, res) => {
+ try {
+ const { getSuperloopClient } = await import('./superloopClient');
+ const client = getSuperloopClient();
+
+ if (!client.isConfigured()) {
+ return res.status(400).json({
+ message: "Superloop API not configured",
+ mode: 'simulated'
+ });
+ }
+
+ const {
+ qualificationSearchId,
+ locationId,
+ planName,
+ term,
+ trafficClass,
+ restorationSla,
+ contactName,
+ contactPhone,
+ contactEmail,
+ aggregationMethod,
+ ntdInstallation,
+ ntdOption,
+ infrastructureId,
+ portId,
+ avcIdForTransfer,
+ transferType,
+ customerReference,
+ } = req.body;
+
+ const orderResponse = await client.createOrder({
+ sourceType: 'nbn',
+ qualificationSearchId,
+ locationId,
+ planName,
+ term: term || 1,
+ trafficClass: trafficClass || 'tc4',
+ restorationSla: restorationSla || 'Standard',
+ contactName,
+ contactPhone,
+ contactEmail,
+ aggregationMethod: aggregationMethod || 'L2TP',
+ ntdInstallation: ntdInstallation || 'nbn-tech',
+ ntdOption,
+ infrastructureId,
+ portId,
+ avcIdForTransfer,
+ transferType,
+ customerReference: customerReference || `BRO-${req.session.userId}`,
+ });
+
+ res.json({
+ success: true,
+ order: orderResponse,
+ mode: 'live'
+ });
+ } catch (error: any) {
+ console.error('Superloop order creation error:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message || "Order creation failed"
+ });
+ }
+ });
+
+ // Superloop get order status
+ app.get("/api/superloop/orders/:orderId", requireAuth, async (req, res) => {
+ try {
+ const { getSuperloopClient } = await import('./superloopClient');
+ const client = getSuperloopClient();
+
+ if (!client.isConfigured()) {
+ return res.status(400).json({ message: "Superloop API not configured" });
+ }
+
+ const orderId = parseInt(req.params.orderId);
+ if (isNaN(orderId)) {
+ return res.status(400).json({ message: "Invalid order ID" });
+ }
+
+ const order = await client.getOrder(orderId);
+ res.json({ success: true, order });
+ } catch (error: any) {
+ console.error('Superloop get order error:', error);
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Superloop AVC qualification (for transfer orders)
+ app.post("/api/superloop/avc-qualify", async (req, res) => {
+ try {
+ const { avcId } = req.body;
+
+ if (!avcId) {
+ return res.status(400).json({ message: "AVC ID is required" });
+ }
+
+ const { getSuperloopClient } = await import('./superloopClient');
+ const client = getSuperloopClient();
+
+ if (!client.isConfigured()) {
+ return res.status(400).json({ message: "Superloop API not configured" });
+ }
+
+ const result = await client.qualifyAvc(avcId);
+ res.json({ success: true, qualification: result });
+ } catch (error: any) {
+ console.error('Superloop AVC qualification error:', error);
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ SUPERLOOP WEBHOOK EVENTS ============
+
+ // Superloop webhook endpoint (receives events from Superloop)
+ app.post("/api/webhooks/superloop", async (req, res) => {
+ try {
+ const { superloopWebhookHandler } = await import('./superloopWebhookHandler');
+
+ const event = {
+ eventId: req.body.eventId || req.body.id || `evt-${Date.now()}`,
+ eventType: req.body.eventType || req.body.type,
+ eventSubtype: req.body.eventSubtype || req.body.subtype,
+ timestamp: req.body.timestamp || new Date().toISOString(),
+ data: req.body.data || req.body,
+ };
+
+ if (!event.eventType) {
+ return res.status(400).json({ message: "Event type is required" });
+ }
+
+ const result = await superloopWebhookHandler.processEvent(event);
+
+ if (result.success) {
+ res.json({ received: true, message: result.message });
+ } else {
+ res.status(500).json({ received: false, message: result.message });
+ }
+ } catch (error: any) {
+ console.error('Superloop webhook error:', error);
+ res.status(500).json({ received: false, message: error.message });
+ }
+ });
+
+ // Get recent Superloop events (admin)
+ app.get("/api/admin/events", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { superloopWebhookHandler } = await import('./superloopWebhookHandler');
+ const limit = parseInt(req.query.limit as string) || 50;
+ const eventType = req.query.type as string | undefined;
+
+ const events = await superloopWebhookHandler.getRecentEvents(limit, eventType);
+ res.json({ events });
+ } catch (error: any) {
+ console.error('Get events error:', error);
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Acknowledge an event (admin)
+ app.post("/api/admin/events/:eventId/acknowledge", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { superloopWebhookHandler } = await import('./superloopWebhookHandler');
+ await superloopWebhookHandler.acknowledgeEvent(req.params.eventId, req.session.userId!);
+ res.json({ success: true });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get active network disruptions (public)
+ app.get("/api/network/disruptions", async (_req, res) => {
+ try {
+ const { superloopWebhookHandler } = await import('./superloopWebhookHandler');
+ const disruptions = await superloopWebhookHandler.getActiveDisruptions();
+ res.json({ disruptions });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get all disruptions (admin)
+ app.get("/api/admin/disruptions", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { networkDisruptions } = await import('@shared/schema');
+ const { db } = await import('./db');
+ const disruptions = await db.select().from(networkDisruptions).orderBy(networkDisruptions.startedAt);
+ res.json({ disruptions });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Create manual disruption (admin)
+ app.post("/api/admin/disruptions", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { networkDisruptions } = await import('@shared/schema');
+ const { db } = await import('./db');
+
+ const [disruption] = await db.insert(networkDisruptions).values({
+ title: req.body.title,
+ description: req.body.description,
+ severity: req.body.severity || 'medium',
+ status: req.body.status || 'active',
+ affectedAreas: req.body.affectedAreas ? JSON.stringify(req.body.affectedAreas) : null,
+ affectedTechnologies: req.body.affectedTechnologies ? JSON.stringify(req.body.affectedTechnologies) : null,
+ estimatedResolution: req.body.estimatedResolution ? new Date(req.body.estimatedResolution) : null,
+ startedAt: req.body.startedAt ? new Date(req.body.startedAt) : new Date(),
+ source: 'manual',
+ }).returning();
+
+ res.json({ success: true, disruption });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Update disruption (admin)
+ app.patch("/api/admin/disruptions/:id", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { networkDisruptions } = await import('@shared/schema');
+ const { db } = await import('./db');
+ const { eq } = await import('drizzle-orm');
+
+ const updates: any = { updatedAt: new Date() };
+ if (req.body.title) updates.title = req.body.title;
+ if (req.body.description !== undefined) updates.description = req.body.description;
+ if (req.body.severity) updates.severity = req.body.severity;
+ if (req.body.status) updates.status = req.body.status;
+ if (req.body.affectedAreas) updates.affectedAreas = JSON.stringify(req.body.affectedAreas);
+ if (req.body.affectedTechnologies) updates.affectedTechnologies = JSON.stringify(req.body.affectedTechnologies);
+ if (req.body.estimatedResolution) updates.estimatedResolution = new Date(req.body.estimatedResolution);
+ if (req.body.resolvedAt) updates.resolvedAt = new Date(req.body.resolvedAt);
+ if (req.body.status === 'resolved' && !req.body.resolvedAt) updates.resolvedAt = new Date();
+
+ const [disruption] = await db.update(networkDisruptions)
+ .set(updates)
+ .where(eq(networkDisruptions.id, req.params.id))
+ .returning();
+
+ res.json({ success: true, disruption });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get appointment slots for an order
+ app.get("/api/orders/:orderId/appointments", requireAuth, async (req, res) => {
+ try {
+ const { appointmentSlots, serviceOrders } = await import('@shared/schema');
+ const { db } = await import('./db');
+ const { eq } = await import('drizzle-orm');
+
+ const [order] = await db.select().from(serviceOrders)
+ .where(eq(serviceOrders.id, req.params.orderId))
+ .limit(1);
+
+ if (!order || order.userId !== req.session.userId) {
+ return res.status(404).json({ message: "Order not found" });
+ }
+
+ const appointments = await db.select().from(appointmentSlots)
+ .where(eq(appointmentSlots.orderId, req.params.orderId))
+ .orderBy(appointmentSlots.slotDate);
+
+ res.json({ appointments });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get service health records
+ app.get("/api/orders/:orderId/health", requireAuth, async (req, res) => {
+ try {
+ const { serviceHealthRecords, serviceOrders } = await import('@shared/schema');
+ const { db } = await import('./db');
+ const { eq, desc } = await import('drizzle-orm');
+
+ const [order] = await db.select().from(serviceOrders)
+ .where(eq(serviceOrders.id, req.params.orderId))
+ .limit(1);
+
+ if (!order || order.userId !== req.session.userId) {
+ return res.status(404).json({ message: "Order not found" });
+ }
+
+ const records = await db.select().from(serviceHealthRecords)
+ .where(eq(serviceHealthRecords.orderId, req.params.orderId))
+ .orderBy(desc(serviceHealthRecords.recordedAt))
+ .limit(50);
+
+ res.json({ records });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ ADMIN: NBN DATASET MANAGEMENT ============
+
+ // Get all dataset records
+ app.get("/api/admin/nbn-dataset", requireAuth, async (req, res) => {
+ try {
+ const dataset = await storage.getAllNbnDataset();
+ res.json({ dataset });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Upload dataset (CSV/JSON)
+ app.post("/api/admin/nbn-dataset/upload", requireAuth, async (req, res) => {
+ try {
+ const { data, replace } = req.body;
+
+ if (!Array.isArray(data)) {
+ return res.status(400).json({ message: "Data must be an array" });
+ }
+
+ // Replace existing dataset if requested
+ if (replace) {
+ await storage.deleteAllNbnDataset();
+ }
+
+ // Process and insert records
+ const records = data.map((item: any) => {
+ // Generate address hash if address and postcode provided
+ let addressHash = item.addressHash;
+ if (!addressHash && item.normalizedAddress && item.postcode) {
+ addressHash = generateAddressHash(item.normalizedAddress, item.postcode);
+ }
+
+ return {
+ addressHash: addressHash || null,
+ locid: item.locid || null,
+ normalizedAddress: item.normalizedAddress || null,
+ postcode: item.postcode || null,
+ technology: item.technology || 'Unknown',
+ maxTier: item.maxTier || 'Unknown',
+ notes: item.notes || null,
+ };
+ });
+
+ await storage.bulkCreateNbnDataset(records);
+
+ res.json({
+ success: true,
+ message: `Imported ${records.length} records`,
+ recordsImported: records.length,
+ });
+ } catch (error: any) {
+ console.error('Dataset upload error:', error);
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ // Delete all dataset records
+ app.delete("/api/admin/nbn-dataset", requireAuth, async (req, res) => {
+ try {
+ await storage.deleteAllNbnDataset();
+ res.json({ message: "Dataset cleared" });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ MODEM ENQUIRY ROUTES ============
+
+ // Submit modem enquiry
+ app.post("/api/modems/enquiry", async (req, res) => {
+ try {
+ const { name, email, phone, product, quantity, message } = req.body;
+
+ if (!name || !email || !product) {
+ return res.status(400).json({ message: "Name, email, and product are required" });
+ }
+
+ const enquiry = await storage.createModemEnquiry({
+ userId: req.session?.userId || null,
+ name,
+ email,
+ phone: phone || null,
+ product,
+ quantity: quantity || 1,
+ message: message || null,
+ status: "pending",
+ });
+
+ res.status(201).json({ enquiry });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ // Get user's modem enquiries
+ app.get("/api/modems/enquiry", requireAuth, async (req, res) => {
+ try {
+ const enquiries = await storage.getModemEnquiries(req.session.userId!);
+ res.json({ enquiries });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get all modem enquiries (admin only)
+ app.get("/api/admin/modems/enquiry", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const enquiries = await storage.getAllModemEnquiries();
+ res.json({ enquiries });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Update enquiry status (admin only)
+ app.patch("/api/admin/modems/enquiry/:id", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const { status } = req.body;
+ const enquiry = await storage.updateModemEnquiryStatus(req.params.id, status);
+ res.json({ enquiry });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ // Get all tickets with user info (admin only)
+ app.get("/api/admin/tickets", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const tickets = await storage.getAllTickets();
+
+ const ticketsWithUserInfo = await Promise.all(
+ tickets.map(async (ticket) => {
+ const ticketUser = await storage.getUser(ticket.userId);
+ return {
+ ...ticket,
+ userEmail: ticketUser?.email || 'Unknown',
+ userName: ticketUser ? `${ticketUser.firstName} ${ticketUser.lastName}` : 'Unknown',
+ };
+ })
+ );
+
+ res.json({ tickets: ticketsWithUserInfo });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get single ticket with replies and user info (admin only)
+ app.get("/api/admin/tickets/:id", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const ticket = await storage.getTicket(req.params.id);
+ if (!ticket) {
+ return res.status(404).json({ message: "Ticket not found" });
+ }
+
+ const ticketUser = await storage.getUser(ticket.userId);
+ const replies = await storage.getTicketReplies(req.params.id);
+
+ const repliesWithUserInfo = await Promise.all(
+ replies.map(async (reply) => {
+ if (reply.userId) {
+ const replyUser = await storage.getUser(reply.userId);
+ return {
+ ...reply,
+ userName: replyUser ? `${replyUser.firstName} ${replyUser.lastName}` : 'Unknown',
+ };
+ }
+ return { ...reply, userName: reply.isStaff ? 'Staff' : 'Unknown' };
+ })
+ );
+
+ res.json({
+ ticket: {
+ ...ticket,
+ userEmail: ticketUser?.email || 'Unknown',
+ userName: ticketUser ? `${ticketUser.firstName} ${ticketUser.lastName}` : 'Unknown',
+ },
+ replies: repliesWithUserInfo,
+ });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Add admin reply to ticket (admin only)
+ app.post("/api/admin/tickets/:id/reply", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { message } = req.body;
+ if (!message || typeof message !== 'string' || message.trim().length === 0) {
+ return res.status(400).json({ message: "Message is required" });
+ }
+
+ const ticket = await storage.getTicket(req.params.id);
+ if (!ticket) {
+ return res.status(404).json({ message: "Ticket not found" });
+ }
+
+ const reply = await storage.createTicketReply({
+ ticketId: req.params.id,
+ userId: req.session.userId!,
+ message: message.trim(),
+ isStaff: 1,
+ });
+
+ await storage.updateTicket(req.params.id, { status: ticket.status === 'open' ? 'in_progress' : ticket.status });
+
+ res.status(201).json({ reply: { ...reply, userName: `${user.firstName} ${user.lastName}` } });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Update ticket status (admin only)
+ app.patch("/api/admin/tickets/:id/status", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { status } = req.body;
+ const validStatuses = ['open', 'in_progress', 'resolved', 'closed'];
+ if (!status || !validStatuses.includes(status)) {
+ return res.status(400).json({ message: "Invalid status. Must be: open, in_progress, resolved, or closed" });
+ }
+
+ const ticket = await storage.getTicket(req.params.id);
+ if (!ticket) {
+ return res.status(404).json({ message: "Ticket not found" });
+ }
+
+ const updatedTicket = await storage.updateTicket(req.params.id, { status });
+ res.json({ ticket: updatedTicket });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get all contact messages (admin only)
+ app.get("/api/admin/messages", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const messages = await storage.getAllContactMessages();
+ res.json({ messages });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ ADMIN ORDER MANAGEMENT ============
+
+ // Get all orders (admin only)
+ app.get("/api/admin/orders", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const orders = await storage.getAllOrders();
+ res.json({ orders });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get single order (admin only)
+ app.get("/api/admin/orders/:id", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const order = await storage.getOrder(req.params.id);
+ if (!order) {
+ return res.status(404).json({ message: "Order not found" });
+ }
+ const history = await storage.getOrderHistory(req.params.id);
+ res.json({ order, history });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Update order status (admin only)
+ app.patch("/api/admin/orders/:id/status", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const { status, message } = req.body;
+ if (!status) {
+ return res.status(400).json({ message: "Status is required" });
+ }
+ const validStatuses = ['pending', 'submitted', 'in_progress', 'provisioning', 'active', 'cancelled', 'failed', 'on_hold'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({ message: "Invalid status" });
+ }
+ const order = await storage.updateOrderStatus(req.params.id, status, `admin:${user.email}`, message);
+ if (!order) {
+ return res.status(404).json({ message: "Order not found" });
+ }
+ res.json({ order });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get order history (admin only)
+ app.get("/api/admin/orders/:id/history", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const history = await storage.getOrderHistory(req.params.id);
+ res.json({ history });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Update order AVC ID (admin only)
+ app.patch("/api/admin/orders/:id/avc", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { avcId } = req.body;
+ if (!avcId || typeof avcId !== 'string') {
+ return res.status(400).json({ message: "AVC ID is required" });
+ }
+
+ const order = await storage.updateOrderAvcId(req.params.id, avcId);
+ if (!order) {
+ return res.status(404).json({ message: "Order not found" });
+ }
+
+ res.json({ order });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Delete order (admin only, cancelled orders only)
+ app.delete("/api/admin/orders/:id", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const order = await storage.getOrder(req.params.id);
+ if (!order) {
+ return res.status(404).json({ message: "Order not found" });
+ }
+
+ if (order.status !== 'cancelled') {
+ return res.status(400).json({ message: "Only cancelled orders can be deleted" });
+ }
+
+ await storage.deleteOrder(req.params.id);
+ res.json({ success: true, message: "Order deleted" });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Export orders as CSV (admin only)
+ app.get("/api/admin/orders/export", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const orderIds = req.query.ids ? (req.query.ids as string).split(',') : null;
+ let orders = await storage.getAllOrders();
+
+ if (orderIds && orderIds.length > 0) {
+ orders = orders.filter(o => orderIds.includes(o.id));
+ }
+
+ const csvHeaders = [
+ 'Order Reference',
+ 'Status',
+ 'Plan Name',
+ 'Contact Name',
+ 'Contact Email',
+ 'Contact Phone',
+ 'Service Address',
+ 'LOC ID',
+ 'AVC ID',
+ 'Technology',
+ 'Download Speed',
+ 'Upload Speed',
+ 'Created At',
+ 'Updated At'
+ ];
+
+ const escapeCSV = (value: any) => {
+ if (value === null || value === undefined) return '';
+ const str = String(value);
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+ };
+
+ const csvRows = orders.map(order => [
+ escapeCSV(order.orderReference),
+ escapeCSV(order.status),
+ escapeCSV(order.planName),
+ escapeCSV(order.contactName),
+ escapeCSV(order.contactEmail),
+ escapeCSV(order.contactPhone),
+ escapeCSV(order.serviceAddress),
+ escapeCSV(order.locId),
+ escapeCSV(order.avcId),
+ escapeCSV(order.technology),
+ escapeCSV(order.downloadSpeed),
+ escapeCSV(order.uploadSpeed),
+ escapeCSV(order.createdAt),
+ escapeCSV(order.updatedAt)
+ ].join(','));
+
+ const csv = [csvHeaders.join(','), ...csvRows].join('\n');
+
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', `attachment; filename="orders-export-${new Date().toISOString().split('T')[0]}.csv"`);
+ res.send(csv);
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Bulk update order statuses (admin only)
+ app.post("/api/admin/orders/bulk-status", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { orderIds, status, message } = req.body;
+
+ if (!Array.isArray(orderIds) || orderIds.length === 0) {
+ return res.status(400).json({ message: "Order IDs are required" });
+ }
+
+ if (!status || typeof status !== 'string') {
+ return res.status(400).json({ message: "Status is required" });
+ }
+
+ const validStatuses = ['pending', 'submitted', 'in_progress', 'provisioning', 'active', 'cancelled', 'failed', 'on_hold'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({ message: "Invalid status" });
+ }
+
+ const results = [];
+ for (const orderId of orderIds) {
+ try {
+ const order = await storage.getOrder(orderId);
+ if (order) {
+ await storage.updateOrderStatus(orderId, status, message || `Bulk status update to ${status}`, 'admin');
+ results.push({ orderId, success: true });
+ } else {
+ results.push({ orderId, success: false, error: "Order not found" });
+ }
+ } catch (err: any) {
+ results.push({ orderId, success: false, error: err.message });
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ res.json({
+ success: true,
+ message: `Updated ${successCount} of ${orderIds.length} orders`,
+ results
+ });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ ADMIN USER MANAGEMENT ============
+
+ // Get all users (admin only)
+ app.get("/api/admin/users", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const users = await storage.getAllUsers();
+ const sanitizedUsers = users.map(u => ({ ...u, password: undefined }));
+ res.json({ users: sanitizedUsers });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Disable user account (admin only)
+ app.patch("/api/admin/users/:id/disable", requireAuth, async (req, res) => {
+ try {
+ const adminUser = await storage.getUser(req.session.userId!);
+ if (!adminUser || adminUser.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const targetUser = await storage.getUser(req.params.id);
+ if (!targetUser) {
+ return res.status(404).json({ message: "User not found" });
+ }
+
+ if (targetUser.isAdmin === 1) {
+ return res.status(400).json({ message: "Cannot disable admin accounts" });
+ }
+
+ const updated = await storage.disableUser(req.params.id);
+ res.json({ user: { ...updated, password: undefined } });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Enable user account (admin only)
+ app.patch("/api/admin/users/:id/enable", requireAuth, async (req, res) => {
+ try {
+ const adminUser = await storage.getUser(req.session.userId!);
+ if (!adminUser || adminUser.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const targetUser = await storage.getUser(req.params.id);
+ if (!targetUser) {
+ return res.status(404).json({ message: "User not found" });
+ }
+
+ const updated = await storage.enableUser(req.params.id);
+ res.json({ user: { ...updated, password: undefined } });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Reset user password (admin only)
+ app.post("/api/admin/users/:id/reset-password", requireAuth, async (req, res) => {
+ try {
+ const adminUser = await storage.getUser(req.session.userId!);
+ if (!adminUser || adminUser.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const targetUser = await storage.getUser(req.params.id);
+ if (!targetUser) {
+ return res.status(404).json({ message: "User not found" });
+ }
+
+ const newPassword = crypto.randomBytes(8).toString('hex');
+ const hashedPassword = await bcrypt.hash(newPassword, 10);
+
+ await storage.updateUser(req.params.id, { password: hashedPassword });
+
+ res.json({
+ message: "Password reset successfully",
+ newPassword,
+ userEmail: targetUser.email
+ });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // ============ EMAIL SIGNUP ROUTES ============
+
+ // Subscribe to email notifications (coming soon page)
+ app.post("/api/email-signup", async (req, res) => {
+ try {
+ const { email, source } = req.body;
+
+ if (!email || typeof email !== 'string') {
+ return res.status(400).json({ message: "Email is required" });
+ }
+
+ // Basic email validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return res.status(400).json({ message: "Invalid email format" });
+ }
+
+ // Check if already subscribed
+ const existing = await storage.getEmailSignupByEmail(email);
+ if (existing) {
+ return res.json({ message: "You're already on the list!", alreadySubscribed: true });
+ }
+
+ const signupSource = source || "coming-soon";
+ await storage.createEmailSignup({
+ email,
+ source: signupSource,
+ });
+
+ // Send confirmation email to subscriber and admin notification
+ const { sendNotifyMeConfirmation, sendAdminNotification } = await import("./emailService");
+
+ // Send emails and log results
+ console.log(`[Email] Attempting to send emails to ${email}...`);
+ try {
+ const [confirmResult, adminResult] = await Promise.all([
+ sendNotifyMeConfirmation(email),
+ sendAdminNotification(email, signupSource)
+ ]);
+ console.log(`[Email] Results - Confirmation: ${confirmResult}, Admin: ${adminResult}`);
+ } catch (emailErr) {
+ console.error("[Email] Sending error:", emailErr);
+ }
+
+ res.status(201).json({ message: "You're on the list!", success: true });
+ } catch (error: any) {
+ console.error("Email signup error:", error);
+ res.status(500).json({ message: "Failed to subscribe. Please try again." });
+ }
+ });
+
+ // Register AI chat routes
+ registerChatRoutes(app);
+
+ // ============ STRIPE CHECKOUT ROUTES ============
+
+ // Get Stripe products and prices
+ app.get("/api/stripe/products", async (req, res) => {
+ try {
+ const { getUncachableStripeClient } = await import('./stripeClient');
+ const stripe = await getUncachableStripeClient();
+
+ // Fetch products and prices directly from Stripe API
+ const [products, prices] = await Promise.all([
+ stripe.products.list({ active: true, limit: 100 }),
+ stripe.prices.list({ active: true, limit: 100, expand: ['data.product'] }),
+ ]);
+
+ // Build products with their prices
+ const productsMap = new Map();
+ for (const product of products.data) {
+ productsMap.set(product.id, {
+ id: product.id,
+ name: product.name,
+ description: product.description,
+ metadata: product.metadata,
+ prices: []
+ });
+ }
+
+ for (const price of prices.data) {
+ const productId = typeof price.product === 'string' ? price.product : price.product.id;
+ if (productsMap.has(productId)) {
+ productsMap.get(productId).prices.push({
+ id: price.id,
+ unit_amount: price.unit_amount,
+ currency: price.currency,
+ recurring: price.recurring,
+ });
+ }
+ }
+
+ // Sort by price
+ const result = Array.from(productsMap.values())
+ .filter(p => p.prices.length > 0)
+ .sort((a, b) => (a.prices[0]?.unit_amount || 0) - (b.prices[0]?.unit_amount || 0));
+
+ res.json({ products: result });
+ } catch (error: any) {
+ console.error("Error fetching products:", error);
+ res.status(500).json({ message: "Failed to fetch products" });
+ }
+ });
+
+ // Create checkout session with order
+ app.post("/api/stripe/checkout", requireAuth, async (req, res) => {
+ try {
+ const {
+ priceId,
+ planName,
+ planId,
+ serviceAddress,
+ locId,
+ csaId,
+ sqReference,
+ avcId,
+ technology,
+ downloadSpeed,
+ uploadSpeed,
+ contactName,
+ contactEmail,
+ contactPhone,
+ preferredDate,
+ routerOption,
+ promoCode,
+ locationId,
+ qualificationSearchId,
+ ntdOption
+ } = req.body;
+
+ if (!priceId) {
+ return res.status(400).json({ message: "Price ID is required" });
+ }
+
+ if (!serviceAddress || !contactName || !contactEmail || !contactPhone) {
+ return res.status(400).json({ message: "Order details are required (address, name, email, phone)" });
+ }
+
+ if (!locId) {
+ return res.status(400).json({ message: "NBN service qualification is required. Please verify your address first." });
+ }
+
+ const user = await storage.getUser(req.session.userId!);
+ if (!user) {
+ return res.status(401).json({ message: "User not found" });
+ }
+
+ const { getUncachableStripeClient } = await import('./stripeClient');
+ const stripe = await getUncachableStripeClient();
+
+ let customerId = user.stripeCustomerId;
+ if (!customerId) {
+ const customer = await stripe.customers.create({
+ email: user.email,
+ name: `${user.firstName} ${user.lastName}`,
+ metadata: { userId: user.id }
+ });
+ await storage.updateUser(user.id, { stripeCustomerId: customer.id });
+ customerId = customer.id;
+ }
+
+ // Create order with pending_payment status BEFORE checkout
+ const orderRef = `BRO-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
+
+ const { serviceOrders, orderStatusHistory } = await import('@shared/schema');
+ const [order] = await db.insert(serviceOrders).values({
+ userId: req.session.userId!,
+ orderReference: orderRef,
+ planId: planId || planName?.toLowerCase().replace(/\s+/g, '') || 'unknown',
+ planName: planName || 'Unknown Plan',
+ downloadSpeed: downloadSpeed || 0,
+ uploadSpeed: uploadSpeed || 0,
+ serviceAddress,
+ locId,
+ avcId: avcId || null,
+ technology: technology || null,
+ status: 'pending_payment',
+ contactName,
+ contactEmail,
+ contactPhone,
+ preferredDate: preferredDate ? new Date(preferredDate) : null,
+ notes: JSON.stringify({ routerOption, promoCode, csaId, sqReference, locationId, qualificationSearchId, ntdOption }),
+ }).returning();
+
+ const orderId = order.id;
+ const orderReference = orderRef;
+
+ await db.insert(orderStatusHistory).values({
+ orderId: order.id,
+ status: 'pending_payment',
+ message: 'Order created - awaiting payment',
+ updatedBy: 'system',
+ });
+
+ const baseUrl = `https://${process.env.REPLIT_DOMAINS?.split(',')[0]}`;
+ const session = await stripe.checkout.sessions.create({
+ customer: customerId,
+ payment_method_types: ['card', 'au_becs_debit'],
+ line_items: [{ price: priceId, quantity: 1 }],
+ mode: 'subscription',
+ currency: 'aud',
+ success_url: `${baseUrl}/dashboard?checkout=success&plan=${encodeURIComponent(planName || '')}&orderId=${orderId}`,
+ cancel_url: `${baseUrl}/signup?checkout=cancelled`,
+ metadata: {
+ userId: user.id,
+ planName: planName || 'Unknown Plan',
+ orderId,
+ orderReference,
+ locId,
+ }
+ });
+
+ res.json({ url: session.url, orderId, orderReference });
+ } catch (error: any) {
+ console.error("Checkout error:", error);
+ res.status(500).json({ message: error.message || "Checkout failed" });
+ }
+ });
+
+ // Get Stripe publishable key
+ app.get("/api/stripe/config", async (req, res) => {
+ try {
+ const { getStripePublishableKey } = await import('./stripeClient');
+ const publishableKey = await getStripePublishableKey();
+ res.json({ publishableKey });
+ } catch (error: any) {
+ res.status(500).json({ message: "Stripe not configured" });
+ }
+ });
+
+ // Customer portal
+ app.post("/api/stripe/portal", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.stripeCustomerId) {
+ return res.status(400).json({ message: "No billing account found" });
+ }
+
+ const { getUncachableStripeClient } = await import('./stripeClient');
+ const stripe = await getUncachableStripeClient();
+
+ const baseUrl = `https://${process.env.REPLIT_DOMAINS?.split(',')[0]}`;
+ const session = await stripe.billingPortal.sessions.create({
+ customer: user.stripeCustomerId,
+ return_url: `${baseUrl}/dashboard`,
+ });
+
+ res.json({ url: session.url });
+ } catch (error: any) {
+ console.error("Portal error:", error);
+ res.status(500).json({ message: "Failed to open billing portal" });
+ }
+ });
+
+ // =========== NBN Service Order Routes ===========
+
+ // Perform service qualification (enhanced coverage check)
+ app.post("/api/orders/qualify", async (req, res) => {
+ try {
+ const { address, technology, postcode, suburb, state, locId } = req.body;
+
+ if (!address || !technology) {
+ return res.status(400).json({ message: "Address and technology are required" });
+ }
+
+ const { nbnService } = await import('./nbnService');
+ const result = await nbnService.performServiceQualification(
+ address,
+ technology,
+ postcode,
+ suburb,
+ state,
+ req.session?.userId,
+ locId // Pass LOC ID from RapidAPI if available
+ );
+
+ res.json({ success: true, qualification: result });
+ } catch (error: any) {
+ console.error("Qualification error:", error);
+ res.status(500).json({ message: error.message || "Service qualification failed" });
+ }
+ });
+
+ // Submit a service order
+ app.post("/api/orders", requireAuth, async (req, res) => {
+ try {
+ const {
+ qualificationId,
+ planId,
+ planName,
+ downloadSpeed,
+ uploadSpeed,
+ serviceAddress,
+ locId,
+ csaId,
+ sqReference,
+ technology,
+ contactName,
+ contactEmail,
+ contactPhone,
+ preferredDate,
+ stripeSessionId,
+ } = req.body;
+
+ if (!planId || !planName || !serviceAddress || !contactName || !contactEmail || !contactPhone) {
+ return res.status(400).json({ message: "Missing required fields" });
+ }
+
+ const { nbnService } = await import('./nbnService');
+ const result = await nbnService.submitOrder({
+ userId: req.session.userId!,
+ qualificationId,
+ planId,
+ planName,
+ downloadSpeed: downloadSpeed || 0,
+ uploadSpeed: uploadSpeed || 0,
+ serviceAddress,
+ locId,
+ csaId,
+ sqReference,
+ technology,
+ contactName,
+ contactEmail,
+ contactPhone,
+ preferredDate: preferredDate ? new Date(preferredDate) : undefined,
+ stripeSessionId,
+ });
+
+ res.json({
+ ...result.result,
+ orderId: result.orderId,
+ orderReference: result.orderReference,
+ });
+ } catch (error: any) {
+ console.error("Order submission error:", error);
+ res.status(500).json({ message: error.message || "Order submission failed" });
+ }
+ });
+
+ // Get user's orders
+ app.get("/api/orders", requireAuth, async (req, res) => {
+ try {
+ const { nbnService } = await import('./nbnService');
+ const orders = await nbnService.getUserOrders(req.session.userId!);
+ res.json({ orders });
+ } catch (error: any) {
+ console.error("Get orders error:", error);
+ res.status(500).json({ message: "Failed to fetch orders" });
+ }
+ });
+
+ // Get single order details
+ app.get("/api/orders/:orderId", requireAuth, async (req, res) => {
+ try {
+ const { nbnService } = await import('./nbnService');
+ const status = await nbnService.getOrderStatus(req.params.orderId);
+
+ if (!status) {
+ return res.status(404).json({ message: "Order not found" });
+ }
+
+ const history = await nbnService.getOrderHistory(req.params.orderId);
+ res.json({ order: status, history });
+ } catch (error: any) {
+ console.error("Get order error:", error);
+ res.status(500).json({ message: "Failed to fetch order" });
+ }
+ });
+
+ // Admin: Update order status
+ app.patch("/api/orders/:orderId/status", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { status, message } = req.body;
+ if (!status) {
+ return res.status(400).json({ message: "Status is required" });
+ }
+
+ const { nbnService } = await import('./nbnService');
+ await nbnService.updateOrderStatus(req.params.orderId, status, message, "admin");
+
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Update order status error:", error);
+ res.status(500).json({ message: "Failed to update order status" });
+ }
+ });
+
+ // Admin: Activate service (assign AVC ID)
+ app.post("/api/orders/:orderId/activate", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const { nbnService } = await import('./nbnService');
+ await nbnService.activateService(req.params.orderId);
+
+ res.json({ success: true, message: "Service activated" });
+ } catch (error: any) {
+ console.error("Activate service error:", error);
+ res.status(500).json({ message: error.message || "Failed to activate service" });
+ }
+ });
+
+ // Note: Nitrogen webhook is registered in index.ts before express.json() middleware
+
+ // ============ ADMIN: ANALYTICS ============
+
+ app.get("/api/admin/analytics", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user?.isAdmin) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const allUsers = await storage.getAllUsers();
+ const allOrders = await storage.getAllOrders();
+
+ const totalCustomers = allUsers.filter(u => u.isAdmin !== 1).length;
+
+ const thirtyDaysAgo = new Date();
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+
+ const recentSignups = allUsers.filter(u =>
+ u.isAdmin !== 1 && new Date(u.joinedAt) >= thirtyDaysAgo
+ );
+
+ const signupsByDate: Record = {};
+ for (let i = 29; i >= 0; i--) {
+ const date = new Date();
+ date.setDate(date.getDate() - i);
+ const dateKey = date.toISOString().split('T')[0];
+ signupsByDate[dateKey] = 0;
+ }
+ recentSignups.forEach(u => {
+ const dateKey = new Date(u.joinedAt).toISOString().split('T')[0];
+ if (signupsByDate[dateKey] !== undefined) {
+ signupsByDate[dateKey]++;
+ }
+ });
+ const signupsTrend = Object.entries(signupsByDate).map(([date, count]) => ({
+ date,
+ signups: count
+ }));
+
+ const totalOrders = allOrders.length;
+ const activeOrders = allOrders.filter(o => o.status === 'active').length;
+
+ const ordersByStatus: Record = {};
+ allOrders.forEach(o => {
+ ordersByStatus[o.status] = (ordersByStatus[o.status] || 0) + 1;
+ });
+
+ const ordersByPlan: Record = {};
+ allOrders.forEach(o => {
+ const planKey = o.planName || 'Unknown';
+ ordersByPlan[planKey] = (ordersByPlan[planKey] || 0) + 1;
+ });
+
+ const AVERAGE_PLAN_PRICE = 89;
+ const estimatedRevenue = activeOrders * AVERAGE_PLAN_PRICE;
+
+ res.json({
+ totalCustomers,
+ newSignups30Days: recentSignups.length,
+ signupsTrend,
+ totalOrders,
+ activeOrders,
+ ordersByStatus: Object.entries(ordersByStatus).map(([status, count]) => ({ status, count })),
+ ordersByPlan: Object.entries(ordersByPlan).map(([plan, count]) => ({ plan, count })),
+ estimatedMonthlyRevenue: estimatedRevenue,
+ });
+ } catch (error: any) {
+ console.error("Analytics error:", error);
+ res.status(500).json({ message: "Failed to fetch analytics" });
+ }
+ });
+
+ // ============ PLANS ROUTES ============
+
+ // Get all active plans (public)
+ app.get("/api/plans", async (_req, res) => {
+ try {
+ const plans = await storage.getPlans();
+ res.json({ plans });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Get all plans including inactive (admin only)
+ app.get("/api/admin/plans", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+ const plans = await storage.getAllPlansAdmin();
+ res.json({ plans });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
+
+ // Create plan (admin only)
+ app.post("/api/admin/plans", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const data = insertPlanSchema.parse(req.body);
+
+ const existingPlan = await storage.getPlan(data.id);
+ if (existingPlan) {
+ return res.status(400).json({ message: "Plan with this ID already exists" });
+ }
+
+ const plan = await storage.createPlan(data);
+ res.status(201).json({ plan });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
+
+ // Update plan (admin only)
+ app.patch("/api/admin/plans/:id", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const existingPlan = await storage.getPlan(req.params.id);
+ if (!existingPlan) {
+ return res.status(404).json({ message: "Plan not found" });
+ }
+
+ const updates = req.body;
+ delete updates.id;
+
+ const plan = await storage.updatePlan(req.params.id, updates);
+ res.json({ plan });
+ } catch (error: any) {
+ res.status(400).json({ message: error.message });
+ }
+ });
- // use storage to perform CRUD operations on the storage interface
- // e.g. storage.insertUser(user) or storage.getUserByUsername(username)
+ // Delete plan (admin only)
+ app.delete("/api/admin/plans/:id", requireAuth, async (req, res) => {
+ try {
+ const user = await storage.getUser(req.session.userId!);
+ if (!user || user.isAdmin !== 1) {
+ return res.status(403).json({ message: "Admin access required" });
+ }
+
+ const existingPlan = await storage.getPlan(req.params.id);
+ if (!existingPlan) {
+ return res.status(404).json({ message: "Plan not found" });
+ }
+
+ await storage.deletePlan(req.params.id);
+ res.json({ success: true, message: "Plan deleted" });
+ } catch (error: any) {
+ res.status(500).json({ message: error.message });
+ }
+ });
return httpServer;
}
diff --git a/server/services/nominatim.ts b/server/services/nominatim.ts
new file mode 100644
index 0000000..1b2f794
--- /dev/null
+++ b/server/services/nominatim.ts
@@ -0,0 +1,226 @@
+import { storage } from "../storage";
+import type { AddressCache } from "@shared/schema";
+
+// Rate limiting: Max 1 request per second to Nominatim
+const RATE_LIMIT_MS = 1000;
+let lastRequestTime = 0;
+
+export interface AddressValidationResult {
+ success: boolean;
+ normalizedAddress?: string;
+ latitude?: string;
+ longitude?: string;
+ postcode?: string;
+ suburb?: string;
+ state?: string;
+ error?: string;
+}
+
+async function delay(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function callNominatim(inputAddress: string): Promise {
+ // Rate limiting
+ const now = Date.now();
+ const timeSinceLastRequest = now - lastRequestTime;
+ if (timeSinceLastRequest < RATE_LIMIT_MS) {
+ await delay(RATE_LIMIT_MS - timeSinceLastRequest);
+ }
+ lastRequestTime = Date.now();
+
+ // Call Nominatim API with Australian country bias
+ const url = `https://nominatim.openstreetmap.org/search?` +
+ `q=${encodeURIComponent(inputAddress)}` +
+ `&format=json` +
+ `&addressdetails=1` +
+ `&limit=1` +
+ `&countrycodes=au`;
+
+ const response = await fetch(url, {
+ headers: {
+ 'User-Agent': 'BroNET-ISP/1.0 (https://bronet.example.com; support@bronet.example.com)',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Nominatim API error: ${response.status}`);
+ }
+
+ return await response.json();
+}
+
+export interface AddressSuggestion {
+ displayName: string;
+ address: string;
+ suburb?: string;
+ state?: string;
+ postcode?: string;
+}
+
+export async function searchAddresses(query: string): Promise {
+ try {
+ if (!query || query.trim().length < 3) {
+ return [];
+ }
+
+ // Try NBN address check API for suggestions (returns accurate NBN addresses)
+ const rapidApiKey = process.env.RAPIDAPI_NBN_KEY;
+ if (rapidApiKey) {
+ try {
+ let cleanKey = rapidApiKey.trim().replace(/^['"]|['"]$/g, '');
+
+ const url = `https://nbnco-address-check.p.rapidapi.com/nbn_address?address=${encodeURIComponent(query)}`;
+
+ const response = await fetch(url, {
+ headers: {
+ 'x-rapidapi-host': 'nbnco-address-check.p.rapidapi.com',
+ 'x-rapidapi-key': cleanKey,
+ },
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+
+ // If we get a valid address result, use it as a suggestion
+ if (data.addressDetail && data.addressDetail.formattedAddress) {
+ const addr = data.addressDetail;
+ const formattedAddress = addr.formattedAddress.replace(/\s+Australia$/i, '');
+
+ return [{
+ displayName: formattedAddress,
+ address: formattedAddress,
+ suburb: addr.locality || '',
+ state: addr.address2?.match(/([A-Z]{2,3})\s+\d{4}/)?.[1] || '',
+ postcode: addr.address2?.match(/\d{4}/)?.[0] || '',
+ }];
+ }
+ }
+ } catch (rapidError) {
+ console.error('RapidAPI address suggestion error:', rapidError);
+ }
+ }
+
+ // Fallback to Nominatim
+ const now = Date.now();
+ const timeSinceLastRequest = now - lastRequestTime;
+ if (timeSinceLastRequest < RATE_LIMIT_MS) {
+ await delay(RATE_LIMIT_MS - timeSinceLastRequest);
+ }
+ lastRequestTime = Date.now();
+
+ const url = `https://nominatim.openstreetmap.org/search?` +
+ `q=${encodeURIComponent(query + ' Australia')}` +
+ `&format=json` +
+ `&addressdetails=1` +
+ `&limit=5` +
+ `&countrycodes=au`;
+
+ const response = await fetch(url, {
+ headers: {
+ 'User-Agent': 'BroNET-ISP/1.0 (https://bronet.example.com; support@bronet.example.com)',
+ },
+ });
+
+ if (!response.ok) {
+ return [];
+ }
+
+ const results = await response.json();
+
+ return results.map((result: any) => {
+ const addr = result.address || {};
+ const parts = [
+ addr.house_number,
+ addr.road,
+ addr.suburb || addr.city || addr.town,
+ addr.state,
+ addr.postcode,
+ ].filter(Boolean);
+
+ return {
+ displayName: result.display_name,
+ address: parts.join(', '),
+ suburb: addr.suburb || addr.city || addr.town,
+ state: addr.state,
+ postcode: addr.postcode,
+ };
+ });
+ } catch (error) {
+ console.error('Address search error:', error);
+ return [];
+ }
+}
+
+export async function validateAddress(inputAddress: string): Promise {
+ try {
+ const normalizedInput = inputAddress.toLowerCase().trim();
+
+ // Check cache first
+ const cached = await storage.getAddressCacheByInput(normalizedInput);
+ if (cached) {
+ return {
+ success: true,
+ normalizedAddress: cached.normalizedAddress,
+ latitude: cached.latitude || undefined,
+ longitude: cached.longitude || undefined,
+ postcode: cached.postcode || undefined,
+ suburb: cached.suburb || undefined,
+ state: cached.state || undefined,
+ };
+ }
+
+ // Call Nominatim
+ const results = await callNominatim(inputAddress);
+
+ if (!results || results.length === 0) {
+ return {
+ success: false,
+ error: "Address not found. Please check the address and try again.",
+ };
+ }
+
+ const result = results[0];
+ const address = result.address || {};
+
+ // Build normalized address
+ const addressParts = [
+ address.house_number,
+ address.road,
+ address.suburb || address.city,
+ address.state,
+ address.postcode,
+ 'Australia',
+ ].filter(Boolean);
+
+ const normalizedAddress = addressParts.join(', ');
+
+ // Cache the result
+ await storage.createAddressCache({
+ inputAddress: normalizedInput,
+ normalizedAddress,
+ latitude: result.lat || null,
+ longitude: result.lon || null,
+ postcode: address.postcode || null,
+ suburb: address.suburb || address.city || null,
+ state: address.state || null,
+ rawResponse: JSON.stringify(result),
+ });
+
+ return {
+ success: true,
+ normalizedAddress,
+ latitude: result.lat,
+ longitude: result.lon,
+ postcode: address.postcode,
+ suburb: address.suburb || address.city,
+ state: address.state,
+ };
+ } catch (error: any) {
+ console.error('Nominatim validation error:', error);
+ return {
+ success: false,
+ error: error.message || "Failed to validate address",
+ };
+ }
+}
diff --git a/server/services/sq.ts b/server/services/sq.ts
new file mode 100644
index 0000000..7f5ba60
--- /dev/null
+++ b/server/services/sq.ts
@@ -0,0 +1,457 @@
+import { storage } from "../storage";
+import crypto from "crypto";
+
+export interface SQResult {
+ source: 'wholesale_api' | 'dataset' | 'address_only' | 'nbn_public_api' | 'rapidapi';
+ available?: boolean;
+ technology?: string;
+ maxTier?: string;
+ rawResponse?: any;
+ error?: string;
+ // Address details from RapidAPI when available
+ formattedAddress?: string;
+ locality?: string;
+ postcode?: string;
+ state?: string;
+ // NBN Location ID from API when available
+ locId?: string;
+}
+
+function generateAddressHash(normalizedAddress: string, postcode: string): string {
+ const input = `${normalizedAddress.toLowerCase().trim()}|${postcode}`;
+ return crypto.createHash('sha256').update(input).digest('hex');
+}
+
+/**
+ * Check NBN availability using wholesale API (Mode A)
+ */
+async function checkWholesaleAPI(
+ normalizedAddress: string,
+ postcode: string,
+ latitude?: string,
+ longitude?: string
+): Promise {
+ const baseUrl = process.env.NBN_WHOLESALE_BASE_URL;
+ const apiKey = process.env.NBN_WHOLESALE_API_KEY;
+
+ if (!baseUrl || !apiKey) {
+ return {
+ source: 'wholesale_api',
+ error: 'Wholesale API not configured',
+ };
+ }
+
+ try {
+ // Parse additional headers if provided
+ const additionalHeaders: Record = {};
+ const headersEnv = process.env.NBN_WHOLESALE_HEADERS;
+ if (headersEnv) {
+ try {
+ Object.assign(additionalHeaders, JSON.parse(headersEnv));
+ } catch (e) {
+ console.error('Failed to parse NBN_WHOLESALE_HEADERS:', e);
+ }
+ }
+
+ // Make request to wholesale API
+ const response = await fetch(baseUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`,
+ ...additionalHeaders,
+ },
+ body: JSON.stringify({
+ address: normalizedAddress,
+ postcode,
+ latitude,
+ longitude,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Wholesale API returned ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ // Map response fields (adjust based on actual API structure)
+ // This is a generic mapping - real APIs will vary
+ const technology = data.technology || data.nbnTechnology || data.techType || 'Unknown';
+ const maxTier = data.maxTier || data.maxSpeed || data.downloadSpeed || 'Unknown';
+ const available = data.available !== false && data.serviceClass !== 'Zero';
+
+ return {
+ source: 'wholesale_api',
+ available,
+ technology,
+ maxTier,
+ rawResponse: data,
+ };
+ } catch (error: any) {
+ console.error('Wholesale API error:', error);
+ return {
+ source: 'wholesale_api',
+ error: error.message || 'Wholesale API request failed',
+ };
+ }
+}
+
+/**
+ * Check NBN availability using NBN Co's official places API
+ * This uses the same API as the NBN website address checker
+ */
+async function checkPublicNBNAPI(
+ normalizedAddress: string
+): Promise {
+ try {
+ // Step 1: Search for the location ID using NBN's places API
+ const encodedAddress = encodeURIComponent(normalizedAddress);
+ const searchResponse = await fetch(
+ `https://places.nbnco.net.au/places/v2/autocomplete?query=${encodedAddress}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Referer': 'https://www.nbnco.com.au/',
+ },
+ }
+ );
+
+ if (!searchResponse.ok) {
+ throw new Error(`NBN Places API returned ${searchResponse.status}`);
+ }
+
+ const searchData = await searchResponse.json();
+
+ // Check if we got any suggestions
+ if (!searchData.suggestions || searchData.suggestions.length === 0) {
+ return {
+ source: 'nbn_public_api',
+ error: 'No matching addresses found',
+ };
+ }
+
+ // Get the first matching location
+ const locationId = searchData.suggestions[0].id;
+
+ // Step 2: Get detailed info for this location
+ const detailResponse = await fetch(
+ `https://places.nbnco.net.au/places/v1/details/${locationId}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Referer': 'https://www.nbnco.com.au/',
+ },
+ }
+ );
+
+ if (!detailResponse.ok) {
+ throw new Error(`NBN Details API returned ${detailResponse.status}`);
+ }
+
+ const detailData = await detailResponse.json();
+ const addressDetail = detailData.addressDetail;
+
+ if (!addressDetail) {
+ return {
+ source: 'nbn_public_api',
+ error: 'No service details available for this address',
+ };
+ }
+
+ // Map technology types to friendly names
+ const techTypeMap: Record = {
+ 'FTTP': 'Fibre to the Premises',
+ 'FTTC': 'Fibre to the Curb',
+ 'FTTN': 'Fibre to the Node',
+ 'FTTB': 'Fibre to the Building',
+ 'HFC': 'Hybrid Fibre Coaxial',
+ 'Wireless': 'Fixed Wireless',
+ 'WIRELESS': 'Fixed Wireless',
+ 'Satellite': 'Satellite',
+ 'SATELLITE': 'Satellite',
+ };
+
+ // Parse speed tier from programType or reasonCode
+ const techType = addressDetail.techType || addressDetail.techChangeStatus || 'Unknown';
+ const serviceStatus = addressDetail.serviceStatus || '';
+ const reasonCode = addressDetail.reasonCode || '';
+
+ // Determine max speed based on technology
+ let maxTier = 'Contact for details';
+ if (techType === 'FTTP') {
+ maxTier = '2000 Mbps';
+ } else if (techType === 'FTTC' || techType === 'HFC') {
+ maxTier = '1000 Mbps';
+ } else if (techType === 'FTTN' || techType === 'FTTB') {
+ maxTier = '100 Mbps';
+ } else if (techType === 'Wireless' || techType === 'WIRELESS') {
+ maxTier = '75 Mbps';
+ } else if (techType === 'Satellite' || techType === 'SATELLITE') {
+ maxTier = '25 Mbps';
+ }
+
+ const isAvailable = serviceStatus === 'available' ||
+ reasonCode === 'FTTP_SA' ||
+ reasonCode === 'HFC_CT' ||
+ addressDetail.serviceType === 'Fixed Line';
+
+ return {
+ source: 'nbn_public_api',
+ available: isAvailable,
+ technology: techTypeMap[techType] || techType,
+ maxTier,
+ rawResponse: { search: searchData, detail: detailData },
+ };
+ } catch (error: any) {
+ console.error('Public NBN API error:', error);
+ return {
+ source: 'nbn_public_api',
+ error: error.message || 'Public NBN API request failed',
+ };
+ }
+}
+
+/**
+ * Check NBN availability using RapidAPI NBN Address Search
+ */
+async function checkRapidAPI(
+ normalizedAddress: string
+): Promise {
+ let apiKey = process.env.RAPIDAPI_NBN_KEY;
+
+ if (!apiKey) {
+ return {
+ source: 'rapidapi',
+ error: 'RapidAPI key not configured',
+ };
+ }
+
+ // Clean up API key - extract just the key if entire curl command was pasted
+ if (apiKey.includes('x-rapidapi-key:')) {
+ const match = apiKey.match(/x-rapidapi-key[:\s]+([a-zA-Z0-9]+)/);
+ if (match) apiKey = match[1];
+ }
+ // Remove any leading/trailing whitespace or quotes
+ apiKey = apiKey.trim().replace(/^['"]|['"]$/g, '');
+
+ try {
+ const encodedAddress = encodeURIComponent(normalizedAddress);
+ const response = await fetch(
+ `https://nbnco-address-check.p.rapidapi.com/nbn_address?address=${encodedAddress}`,
+ {
+ method: 'GET',
+ headers: {
+ 'x-rapidapi-host': 'nbnco-address-check.p.rapidapi.com',
+ 'x-rapidapi-key': apiKey,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 429) {
+ return {
+ source: 'rapidapi',
+ error: 'Rate limit exceeded, please try again later',
+ };
+ }
+ throw new Error(`RapidAPI returned ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ // Check for API-level errors
+ if (data.messages || data.error) {
+ return {
+ source: 'rapidapi',
+ error: data.messages || data.error || 'API error',
+ };
+ }
+
+ // Parse response - RapidAPI returns addressDetail and servingArea objects
+ const addressDetail = data.addressDetail;
+ const servingArea = data.servingArea;
+
+ if (!addressDetail && !servingArea) {
+ return {
+ source: 'rapidapi',
+ error: 'No matching addresses found',
+ };
+ }
+
+ // Map technology types
+ const techTypeMap: Record = {
+ 'FTTP': 'Fibre to the Premises',
+ 'FTTC': 'Fibre to the Curb',
+ 'FTTN': 'Fibre to the Node',
+ 'FTTB': 'Fibre to the Building',
+ 'HFC': 'Hybrid Fibre Coaxial',
+ 'WIRELESS': 'Fixed Wireless',
+ 'SATELLITE': 'Satellite',
+ };
+
+ // Get technology type from addressDetail or servingArea
+ const techType = addressDetail?.techType || servingArea?.techType || 'Unknown';
+
+ // Check availability from servingArea status or addressDetail
+ const serviceStatus = servingArea?.serviceStatus || addressDetail?.serviceStatus || '';
+ const isAvailable = serviceStatus === 'available' ||
+ serviceStatus === 'in_construction' ||
+ addressDetail?.serviceType === 'Fixed line';
+
+ // Determine max speed based on technology
+ let maxTier = 'Contact for details';
+ if (techType === 'FTTP') maxTier = '2000 Mbps';
+ else if (techType === 'FTTC' || techType === 'HFC') maxTier = '1000 Mbps';
+ else if (techType === 'FTTN' || techType === 'FTTB') maxTier = '100 Mbps';
+ else if (techType === 'WIRELESS') maxTier = '75 Mbps';
+ else if (techType === 'SATELLITE') maxTier = '25 Mbps';
+
+ // Extract address details from RapidAPI response
+ const addressSplit = data.addressSplitDetails || {};
+ const formattedAddress = addressDetail?.formattedAddress ||
+ (addressSplit.address1 ? `${addressSplit.address1}, ${addressSplit.locality || ''} ${addressSplit.state || ''} ${addressSplit.postcode || ''}`.trim() : undefined);
+
+ // Extract LOC ID from RapidAPI response
+ // The API may return it as id, locId, locationId, or in addressDetail
+ const locId = addressDetail?.id ||
+ addressDetail?.locId ||
+ addressDetail?.locationId ||
+ data.id ||
+ data.locId ||
+ data.locationId ||
+ undefined;
+
+ return {
+ source: 'rapidapi',
+ available: isAvailable,
+ technology: techTypeMap[techType] || techType,
+ maxTier,
+ rawResponse: data,
+ formattedAddress,
+ locality: addressDetail?.locality || addressSplit.locality || servingArea?.description,
+ postcode: addressSplit.postcode || undefined,
+ state: addressSplit.state || undefined,
+ locId,
+ };
+ } catch (error: any) {
+ console.error('RapidAPI NBN error:', error.message);
+ return {
+ source: 'rapidapi',
+ error: error.message || 'RapidAPI request failed',
+ };
+ }
+}
+
+/**
+ * Check NBN availability using admin dataset (Mode B)
+ */
+async function checkDataset(
+ normalizedAddress: string,
+ postcode: string
+): Promise {
+ try {
+ const addressHash = generateAddressHash(normalizedAddress, postcode);
+
+ // Try exact hash match first
+ let match = await storage.getNbnDatasetByHash(addressHash);
+
+ // If no exact match, try postcode-based matches
+ if (!match) {
+ const postcodeMatches = await storage.getNbnDatasetByPostcode(postcode);
+ if (postcodeMatches.length > 0) {
+ // Use first postcode match as fallback
+ match = postcodeMatches[0];
+ }
+ }
+
+ if (!match) {
+ return {
+ source: 'dataset',
+ error: 'No availability data found for this address',
+ };
+ }
+
+ return {
+ source: 'dataset',
+ available: true,
+ technology: match.technology,
+ maxTier: match.maxTier,
+ rawResponse: match,
+ };
+ } catch (error: any) {
+ console.error('Dataset lookup error:', error);
+ return {
+ source: 'dataset',
+ error: error.message || 'Dataset lookup failed',
+ };
+ }
+}
+
+/**
+ * Main SQ check function - tries wholesale API first, then dataset, then address-only
+ */
+export async function checkNBNAvailability(
+ normalizedAddress: string,
+ postcode: string,
+ latitude?: string,
+ longitude?: string
+): Promise {
+ // Try wholesale API first (Mode A)
+ if (process.env.NBN_WHOLESALE_BASE_URL && process.env.NBN_WHOLESALE_API_KEY) {
+ const result = await checkWholesaleAPI(normalizedAddress, postcode, latitude, longitude);
+ if (!result.error) {
+ return result;
+ }
+ console.log('Wholesale API failed, trying dataset fallback');
+ }
+
+ // Try RapidAPI NBN search (if configured)
+ if (process.env.RAPIDAPI_NBN_KEY) {
+ const rapidResult = await checkRapidAPI(normalizedAddress);
+ if (!rapidResult.error) {
+ return rapidResult;
+ }
+ console.log('RapidAPI failed:', rapidResult.error, '- trying dataset');
+ }
+
+ // Try admin dataset (Mode B)
+ const datasetCount = await storage.getAllNbnDataset();
+ if (datasetCount.length > 0) {
+ const result = await checkDataset(normalizedAddress, postcode);
+ if (!result.error) {
+ return result;
+ }
+ console.log('Dataset lookup failed, trying public NBN API');
+ }
+
+ // Try public NBN API (Mode C) - no configuration required
+ const publicResult = await checkPublicNBNAPI(normalizedAddress);
+ if (!publicResult.error) {
+ return publicResult;
+ }
+ console.log('Public NBN API failed, returning address-only');
+
+ // All modes failed - return address-only validation
+ return {
+ source: 'address_only',
+ };
+}
+
+/**
+ * Get current SQ configuration mode
+ */
+export function getSQMode(): 'wholesale_api' | 'rapidapi' | 'dataset' | 'nbn_public_api' | 'none' {
+ if (process.env.NBN_WHOLESALE_BASE_URL && process.env.NBN_WHOLESALE_API_KEY) {
+ return 'wholesale_api';
+ }
+ if (process.env.RAPIDAPI_NBN_KEY) {
+ return 'rapidapi';
+ }
+ // Public NBN API is always available as fallback
+ return 'nbn_public_api';
+}
+
+export { generateAddressHash };
diff --git a/server/storage.ts b/server/storage.ts
index ee25bd1..afed8ae 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -1,38 +1,555 @@
-import { type User, type InsertUser } from "@shared/schema";
-import { randomUUID } from "crypto";
-
-// modify the interface with any CRUD methods
-// you might need
+import {
+ users,
+ tickets,
+ ticketReplies,
+ incidents,
+ contactMessages,
+ addressCache,
+ nbnDataset,
+ coverageChecks,
+ modemEnquiries,
+ emailSignups,
+ billingHistory,
+ usageRecords,
+ serviceOrders,
+ orderStatusHistory,
+ plans,
+ type User,
+ type InsertUser,
+ type Ticket,
+ type InsertTicket,
+ type TicketReply,
+ type InsertTicketReply,
+ type Incident,
+ type InsertIncident,
+ type ContactMessage,
+ type InsertContactMessage,
+ type AddressCache,
+ type InsertAddressCache,
+ type NbnDataset,
+ type InsertNbnDataset,
+ type CoverageCheck,
+ type InsertCoverageCheck,
+ type ModemEnquiry,
+ type InsertModemEnquiry,
+ type EmailSignup,
+ type InsertEmailSignup,
+ type BillingHistory,
+ type InsertBillingHistory,
+ type UsageRecord,
+ type InsertUsageRecord,
+ type ServiceOrder,
+ type InsertServiceOrder,
+ type OrderStatusHistory,
+ type InsertOrderStatusHistory,
+ type Plan,
+ type InsertPlan,
+} from "@shared/schema";
+import { db } from "./db";
+import { eq, and, desc, or } from "drizzle-orm";
export interface IStorage {
+ // Users
getUser(id: string): Promise;
- getUserByUsername(username: string): Promise;
+ getUserByEmail(email: string): Promise;
createUser(user: InsertUser): Promise;
+ updateUser(id: string, data: Partial): Promise;
+ getAllUsers(): Promise;
+ disableUser(id: string): Promise;
+ enableUser(id: string): Promise;
+
+ // Tickets
+ getTickets(userId: string): Promise;
+ getAllTickets(): Promise;
+ getTicket(id: string): Promise;
+ createTicket(ticket: InsertTicket): Promise;
+ updateTicket(id: string, data: Partial): Promise;
+
+ // Ticket Replies
+ getTicketReplies(ticketId: string): Promise;
+ createTicketReply(reply: InsertTicketReply): Promise;
+
+ // Incidents
+ getIncidents(): Promise;
+ createIncident(incident: InsertIncident): Promise;
+ resolveIncident(id: string): Promise;
+
+ // Contact Messages
+ getContactMessages(userId: string): Promise;
+ getAllContactMessages(): Promise;
+ createContactMessage(message: InsertContactMessage): Promise;
+
+ // Address Cache
+ getAddressCacheByInput(inputAddress: string): Promise;
+ createAddressCache(cache: InsertAddressCache): Promise;
+
+ // NBN Dataset
+ getNbnDatasetByPostcode(postcode: string): Promise;
+ getNbnDatasetByHash(addressHash: string): Promise;
+ getNbnDatasetByLocid(locid: string): Promise;
+ getAllNbnDataset(): Promise;
+ createNbnDataset(data: InsertNbnDataset): Promise;
+ bulkCreateNbnDataset(data: InsertNbnDataset[]): Promise;
+ deleteAllNbnDataset(): Promise;
+
+ // Coverage Checks
+ getCoverageChecks(userId: string): Promise;
+ createCoverageCheck(check: InsertCoverageCheck): Promise;
+
+ // Modem Enquiries
+ getModemEnquiries(userId: string): Promise;
+ getAllModemEnquiries(): Promise;
+ createModemEnquiry(enquiry: InsertModemEnquiry): Promise;
+ updateModemEnquiryStatus(id: string, status: string): Promise;
+
+ // Email Signups
+ getEmailSignupByEmail(email: string): Promise;
+ createEmailSignup(signup: InsertEmailSignup): Promise;
+
+ // Billing History
+ getBillingHistory(userId: string): Promise;
+ createBillingRecord(record: InsertBillingHistory): Promise;
+
+ // Usage Records
+ getUsageRecords(userId: string): Promise;
+ createUsageRecord(record: InsertUsageRecord): Promise;
+
+ // Service Orders (Admin)
+ getAllOrders(): Promise;
+ getOrder(id: string): Promise;
+ getOrdersByUser(userId: string): Promise;
+ updateOrderStatus(id: string, status: string, updatedBy: string, message?: string): Promise;
+ updateOrderAvcId(id: string, avcId: string): Promise;
+ getOrderHistory(orderId: string): Promise;
+ deleteOrder(id: string): Promise;
+
+ // Plans
+ getPlans(): Promise;
+ getAllPlansAdmin(): Promise;
+ getPlan(id: string): Promise;
+ createPlan(plan: InsertPlan): Promise;
+ updatePlan(id: string, data: Partial): Promise;
+ deletePlan(id: string): Promise;
}
-export class MemStorage implements IStorage {
- private users: Map;
+export class DatabaseStorage implements IStorage {
+ // Users
+ async getUser(id: string): Promise {
+ const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
+ return result[0];
+ }
- constructor() {
- this.users = new Map();
+ async getUserByEmail(email: string): Promise {
+ const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
+ return result[0];
}
- async getUser(id: string): Promise {
- return this.users.get(id);
+ async createUser(user: InsertUser): Promise {
+ const [newUser] = await db.insert(users).values(user).returning();
+ return newUser;
+ }
+
+ async updateUser(id: string, data: Partial): Promise {
+ const [updated] = await db
+ .update(users)
+ .set(data)
+ .where(eq(users.id, id))
+ .returning();
+ return updated;
+ }
+
+ async getAllUsers(): Promise {
+ return await db.select().from(users).orderBy(desc(users.joinedAt));
+ }
+
+ async disableUser(id: string): Promise {
+ const [updated] = await db
+ .update(users)
+ .set({ disabled: 1 })
+ .where(eq(users.id, id))
+ .returning();
+ return updated;
+ }
+
+ async enableUser(id: string): Promise {
+ const [updated] = await db
+ .update(users)
+ .set({ disabled: 0 })
+ .where(eq(users.id, id))
+ .returning();
+ return updated;
+ }
+
+ // Tickets
+ async getTickets(userId: string): Promise {
+ return await db
+ .select()
+ .from(tickets)
+ .where(eq(tickets.userId, userId))
+ .orderBy(desc(tickets.createdAt));
+ }
+
+ async getAllTickets(): Promise {
+ return await db
+ .select()
+ .from(tickets)
+ .orderBy(desc(tickets.createdAt));
+ }
+
+ async getTicket(id: string): Promise {
+ const result = await db.select().from(tickets).where(eq(tickets.id, id)).limit(1);
+ return result[0];
+ }
+
+ async createTicket(ticket: InsertTicket): Promise {
+ const [newTicket] = await db.insert(tickets).values(ticket).returning();
+ return newTicket;
+ }
+
+ async updateTicket(id: string, data: Partial): Promise {
+ const [updated] = await db
+ .update(tickets)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(tickets.id, id))
+ .returning();
+ return updated;
+ }
+
+ // Ticket Replies
+ async getTicketReplies(ticketId: string): Promise {
+ return await db
+ .select()
+ .from(ticketReplies)
+ .where(eq(ticketReplies.ticketId, ticketId))
+ .orderBy(ticketReplies.createdAt);
+ }
+
+ async createTicketReply(reply: InsertTicketReply): Promise {
+ const [newReply] = await db.insert(ticketReplies).values(reply).returning();
+ return newReply;
+ }
+
+ // Incidents
+ async getIncidents(): Promise {
+ return await db
+ .select()
+ .from(incidents)
+ .orderBy(desc(incidents.createdAt));
+ }
+
+ async createIncident(incident: InsertIncident): Promise