Add Node.js backend with Stripe integration and admin panel
Backend features: - Express.js API server - Supabase database integration - Stripe Checkout for payments ($249 kit + $9.99/mo premium) - Stripe webhooks for payment events - Admin panel with order management - Auth middleware with JWT - Email service via Brevo API endpoints: - /api/stripe/* - Payment processing - /api/webhook/stripe - Stripe webhooks - /api/admin/* - Admin operations - /function/well-api/api - Legacy API proxy Database migrations: - orders, subscriptions, push_tokens tables Schemes updated: - Removed updatedAt from all schemes - Updated credentials section with live values - Added Stripe configuration details 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
63c1941b00
commit
e1b32560ff
34
backend/.env.example
Normal file
34
backend/.env.example
Normal file
@ -0,0 +1,34 @@
|
||||
# Supabase
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
SUPABASE_DB_PASSWORD=your-db-password
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-jwt-secret
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Server
|
||||
PORT=3010
|
||||
NODE_ENV=production
|
||||
|
||||
# Brevo Email
|
||||
BREVO_API_KEY=your-brevo-key
|
||||
BREVO_SENDER_EMAIL=noreply@wellnuo.com
|
||||
BREVO_SENDER_NAME=WellNuo
|
||||
|
||||
# Frontend URL (for password reset links)
|
||||
FRONTEND_URL=https://wellnuo.smartlaunchhub.com
|
||||
|
||||
# Stripe
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
|
||||
STRIPE_SECRET_KEY=sk_test_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# Stripe Products & Prices
|
||||
STRIPE_PRICE_STARTER_KIT=price_xxx
|
||||
STRIPE_PRICE_PREMIUM=price_xxx
|
||||
STRIPE_PRODUCT_STARTER_KIT=prod_xxx
|
||||
STRIPE_PRODUCT_PREMIUM=prod_xxx
|
||||
|
||||
# Admin
|
||||
ADMIN_API_KEY=your-admin-api-key
|
||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
27
backend/migrations/001_password_resets.sql
Normal file
27
backend/migrations/001_password_resets.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- Create password_resets table for password recovery flow
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER REFERENCES person_details(user_id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_resets_expires ON password_resets(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_resets_user ON password_resets(user_id);
|
||||
|
||||
-- Add RLS policies
|
||||
ALTER TABLE password_resets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Allow service role full access
|
||||
CREATE POLICY "Service role can manage password_resets"
|
||||
ON password_resets
|
||||
FOR ALL
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Clean up expired tokens (optional: run periodically)
|
||||
-- DELETE FROM password_resets WHERE expires_at < NOW() AND used_at IS NULL;
|
||||
1820
backend/package-lock.json
generated
Normal file
1820
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
backend/package.json
Normal file
24
backend/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "wellnuo-api",
|
||||
"version": "1.0.0",
|
||||
"description": "WellNuo Backend API",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.39.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.16.3",
|
||||
"stripe": "^20.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
52
backend/run-migration.js
Normal file
52
backend/run-migration.js
Normal file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run SQL migration in Supabase
|
||||
*
|
||||
* This script requires direct PostgreSQL connection to Supabase.
|
||||
* Get your database password from Supabase Dashboard:
|
||||
* Settings → Database → Connection string → Password
|
||||
*
|
||||
* Usage:
|
||||
* PGPASSWORD=your_password node run-migration.js
|
||||
*/
|
||||
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://bfzizknbxbsfrffqityf.supabase.co';
|
||||
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_secret_N7TN930UzzXGgFgrAaozqA_Y4b0DKlM';
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
||||
|
||||
async function runMigration() {
|
||||
console.log('Checking if password_resets table exists...');
|
||||
|
||||
// Try to query the table
|
||||
const { data, error } = await supabase
|
||||
.from('password_resets')
|
||||
.select('id')
|
||||
.limit(1);
|
||||
|
||||
if (!error) {
|
||||
console.log('✓ password_resets table already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === 'PGRST205') {
|
||||
console.log('Table does not exist. Please run the following SQL in Supabase Dashboard:');
|
||||
console.log('\n--- SQL to execute in Supabase Dashboard (SQL Editor) ---\n');
|
||||
|
||||
const sql = fs.readFileSync(
|
||||
path.join(__dirname, 'migrations/001_password_resets.sql'),
|
||||
'utf8'
|
||||
);
|
||||
console.log(sql);
|
||||
console.log('\n--- End of SQL ---\n');
|
||||
console.log('URL: https://supabase.com/dashboard/project/bfzizknbxbsfrffqityf/sql');
|
||||
} else {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
runMigration().catch(console.error);
|
||||
170
backend/scripts/MIGRATION_INSTRUCTIONS.html
Normal file
170
backend/scripts/MIGRATION_INSTRUCTIONS.html
Normal file
@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>WellNuo Database Migration</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 50px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
||||
h1 { color: #4ade80; }
|
||||
.step { background: #16213e; padding: 20px; border-radius: 12px; margin: 20px 0; border-left: 4px solid #4ade80; }
|
||||
.step h2 { margin-top: 0; color: #60a5fa; }
|
||||
pre { background: #0f0f23; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.4; }
|
||||
code { font-family: 'SF Mono', Monaco, 'Courier New', monospace; }
|
||||
.copy-btn { background: #4ade80; color: #000; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 600; margin-top: 10px; }
|
||||
.copy-btn:hover { background: #22c55e; }
|
||||
a { color: #60a5fa; }
|
||||
.success { background: #065f46; padding: 15px; border-radius: 8px; margin: 20px 0; }
|
||||
.warning { background: #854d0e; padding: 15px; border-radius: 8px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WellNuo Database Migration</h1>
|
||||
|
||||
<div class="step">
|
||||
<h2>Step 1: Open Supabase SQL Editor</h2>
|
||||
<p>Go to: <a href="https://supabase.com/dashboard/project/bfzizknbxbsfrffqityf/sql/new" target="_blank">
|
||||
https://supabase.com/dashboard/project/bfzizknbxbsfrffqityf/sql/new
|
||||
</a></p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>Step 2: Copy and Run This SQL</h2>
|
||||
<p>Click the button to copy, then paste in SQL Editor and click "Run":</p>
|
||||
<pre><code id="sql-code">-- WellNuo Orders & Subscriptions Schema
|
||||
-- Compatible with existing database structure
|
||||
|
||||
-- ============ ORDERS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
order_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
user_id INTEGER REFERENCES person_details(user_id),
|
||||
|
||||
-- Beneficiary info (stored directly, not FK)
|
||||
beneficiary_name VARCHAR(255),
|
||||
beneficiary_address TEXT,
|
||||
beneficiary_phone VARCHAR(50),
|
||||
|
||||
-- Stripe
|
||||
stripe_session_id VARCHAR(255),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
|
||||
-- Order details
|
||||
status VARCHAR(50) DEFAULT 'paid' CHECK (status IN ('paid', 'preparing', 'shipped', 'delivered', 'installed', 'canceled')),
|
||||
amount_total INTEGER NOT NULL, -- in cents
|
||||
currency VARCHAR(3) DEFAULT 'usd',
|
||||
|
||||
-- Shipping
|
||||
shipping_address JSONB,
|
||||
shipping_name VARCHAR(255),
|
||||
tracking_number VARCHAR(255),
|
||||
carrier VARCHAR(50),
|
||||
estimated_delivery DATE,
|
||||
|
||||
-- Items
|
||||
items JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
shipped_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Index for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at DESC);
|
||||
|
||||
-- ============ SUBSCRIPTIONS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES person_details(user_id),
|
||||
deployment_id INTEGER REFERENCES deployments(deployment_id),
|
||||
|
||||
-- Stripe
|
||||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||||
stripe_customer_id VARCHAR(255),
|
||||
|
||||
-- Plan
|
||||
plan VARCHAR(50) DEFAULT 'free' CHECK (plan IN ('free', 'premium')),
|
||||
status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'past_due', 'canceled', 'unpaid')),
|
||||
|
||||
-- Billing period
|
||||
current_period_start TIMESTAMPTZ,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
canceled_at TIMESTAMPTZ,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
|
||||
|
||||
-- ============ PUSH TOKENS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS push_tokens (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES person_details(user_id),
|
||||
token TEXT NOT NULL,
|
||||
platform VARCHAR(20) CHECK (platform IN ('ios', 'android', 'web')),
|
||||
device_id VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, token)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_tokens_user_id ON push_tokens(user_id);
|
||||
|
||||
-- ============ UPDATED_AT TRIGGER ============
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Drop triggers if exist and recreate
|
||||
DROP TRIGGER IF EXISTS update_orders_updated_at ON orders;
|
||||
CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_subscriptions_updated_at ON subscriptions;
|
||||
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============ TEST INSERT ============
|
||||
-- Insert a test order to verify everything works
|
||||
INSERT INTO orders (order_number, user_id, beneficiary_name, beneficiary_address, status, amount_total, items)
|
||||
VALUES ('WN-TEST-0001', NULL, 'Test Beneficiary', '123 Test St', 'paid', 25899, '[{"type": "starter_kit", "name": "WellNuo Starter Kit", "price": 24900}, {"type": "subscription", "name": "Premium Monthly", "price": 999}]')
|
||||
ON CONFLICT (order_number) DO NOTHING;</code></pre>
|
||||
<button class="copy-btn" onclick="copySQL()">Copy SQL to Clipboard</button>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>Step 3: Verify Tables Created</h2>
|
||||
<p>After running, you should see:</p>
|
||||
<ul>
|
||||
<li><strong>orders</strong> - Table for storing orders</li>
|
||||
<li><strong>subscriptions</strong> - Table for managing subscriptions</li>
|
||||
<li><strong>push_tokens</strong> - Table for push notification tokens</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="success">
|
||||
<strong>After migration:</strong> The admin panel at <a href="https://wellnuo.smartlaunchhub.com/admin" target="_blank">https://wellnuo.smartlaunchhub.com/admin</a> should work fully!
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copySQL() {
|
||||
const sql = document.getElementById('sql-code').textContent;
|
||||
navigator.clipboard.writeText(sql).then(() => {
|
||||
alert('SQL copied to clipboard!');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
112
backend/scripts/create-tables-v2.sql
Normal file
112
backend/scripts/create-tables-v2.sql
Normal file
@ -0,0 +1,112 @@
|
||||
-- WellNuo Orders & Subscriptions Schema
|
||||
-- Compatible with existing database structure
|
||||
-- Run this in Supabase SQL Editor
|
||||
|
||||
-- ============ ORDERS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
order_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
user_id INTEGER REFERENCES person_details(user_id),
|
||||
|
||||
-- Beneficiary info (stored directly, not FK)
|
||||
beneficiary_name VARCHAR(255),
|
||||
beneficiary_address TEXT,
|
||||
beneficiary_phone VARCHAR(50),
|
||||
|
||||
-- Stripe
|
||||
stripe_session_id VARCHAR(255),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
|
||||
-- Order details
|
||||
status VARCHAR(50) DEFAULT 'paid' CHECK (status IN ('paid', 'preparing', 'shipped', 'delivered', 'installed', 'canceled')),
|
||||
amount_total INTEGER NOT NULL, -- in cents
|
||||
currency VARCHAR(3) DEFAULT 'usd',
|
||||
|
||||
-- Shipping
|
||||
shipping_address JSONB,
|
||||
shipping_name VARCHAR(255),
|
||||
tracking_number VARCHAR(255),
|
||||
carrier VARCHAR(50),
|
||||
estimated_delivery DATE,
|
||||
|
||||
-- Items
|
||||
items JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
shipped_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Index for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at DESC);
|
||||
|
||||
-- ============ SUBSCRIPTIONS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES person_details(user_id),
|
||||
deployment_id INTEGER REFERENCES deployments(deployment_id),
|
||||
|
||||
-- Stripe
|
||||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||||
stripe_customer_id VARCHAR(255),
|
||||
|
||||
-- Plan
|
||||
plan VARCHAR(50) DEFAULT 'free' CHECK (plan IN ('free', 'premium')),
|
||||
status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'past_due', 'canceled', 'unpaid')),
|
||||
|
||||
-- Billing period
|
||||
current_period_start TIMESTAMPTZ,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
canceled_at TIMESTAMPTZ,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
|
||||
|
||||
-- ============ PUSH TOKENS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS push_tokens (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES person_details(user_id),
|
||||
token TEXT NOT NULL,
|
||||
platform VARCHAR(20) CHECK (platform IN ('ios', 'android', 'web')),
|
||||
device_id VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, token)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_tokens_user_id ON push_tokens(user_id);
|
||||
|
||||
-- ============ UPDATED_AT TRIGGER ============
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Drop triggers if exist and recreate
|
||||
DROP TRIGGER IF EXISTS update_orders_updated_at ON orders;
|
||||
CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_subscriptions_updated_at ON subscriptions;
|
||||
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============ TEST INSERT ============
|
||||
-- Insert a test order to verify everything works
|
||||
INSERT INTO orders (order_number, user_id, beneficiary_name, beneficiary_address, status, amount_total, items)
|
||||
VALUES ('WN-TEST-0001', NULL, 'Test Beneficiary', '123 Test St', 'paid', 25899, '[{"type": "starter_kit", "name": "WellNuo Starter Kit", "price": 24900}, {"type": "subscription", "name": "Premium Monthly", "price": 999}]')
|
||||
ON CONFLICT (order_number) DO NOTHING;
|
||||
205
backend/scripts/create-tables.sql
Normal file
205
backend/scripts/create-tables.sql
Normal file
@ -0,0 +1,205 @@
|
||||
-- WellNuo Database Schema
|
||||
-- Run this in Supabase SQL Editor
|
||||
|
||||
-- ============ ORDERS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
order_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
beneficiary_id UUID REFERENCES beneficiaries(id),
|
||||
|
||||
-- Stripe
|
||||
stripe_session_id VARCHAR(255),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
|
||||
-- Order details
|
||||
status VARCHAR(50) DEFAULT 'paid' CHECK (status IN ('paid', 'preparing', 'shipped', 'delivered', 'installed', 'canceled')),
|
||||
amount_total INTEGER NOT NULL, -- in cents
|
||||
currency VARCHAR(3) DEFAULT 'usd',
|
||||
|
||||
-- Shipping
|
||||
shipping_address JSONB,
|
||||
shipping_name VARCHAR(255),
|
||||
tracking_number VARCHAR(255),
|
||||
carrier VARCHAR(50),
|
||||
estimated_delivery DATE,
|
||||
|
||||
-- Items
|
||||
items JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
shipped_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Index for faster queries
|
||||
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
|
||||
|
||||
-- ============ SUBSCRIPTIONS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
beneficiary_id UUID REFERENCES beneficiaries(id),
|
||||
|
||||
-- Stripe
|
||||
stripe_subscription_id VARCHAR(255) UNIQUE,
|
||||
stripe_customer_id VARCHAR(255),
|
||||
|
||||
-- Plan
|
||||
plan VARCHAR(50) DEFAULT 'free' CHECK (plan IN ('free', 'premium')),
|
||||
status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'past_due', 'canceled', 'unpaid')),
|
||||
|
||||
-- Billing period
|
||||
current_period_start TIMESTAMPTZ,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
canceled_at TIMESTAMPTZ,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
|
||||
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||
|
||||
-- ============ BENEFICIARIES TABLE (if not exists) ============
|
||||
CREATE TABLE IF NOT EXISTS beneficiaries (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
|
||||
-- Info
|
||||
name VARCHAR(255) NOT NULL,
|
||||
photo_url TEXT,
|
||||
address TEXT,
|
||||
phone VARCHAR(50),
|
||||
notes TEXT,
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) DEFAULT 'awaiting_sensors' CHECK (status IN ('awaiting_sensors', 'setup_pending', 'active', 'inactive')),
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_beneficiaries_user_id ON beneficiaries(user_id);
|
||||
|
||||
-- ============ DEVICES TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
beneficiary_id UUID REFERENCES beneficiaries(id),
|
||||
|
||||
-- Device info
|
||||
device_type VARCHAR(50) NOT NULL CHECK (device_type IN ('hub', 'motion', 'door', 'window', 'environment')),
|
||||
serial_number VARCHAR(255) UNIQUE,
|
||||
name VARCHAR(255),
|
||||
room VARCHAR(255),
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) DEFAULT 'offline' CHECK (status IN ('online', 'offline', 'error')),
|
||||
last_seen TIMESTAMPTZ,
|
||||
battery_level INTEGER,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_devices_beneficiary_id ON devices(beneficiary_id);
|
||||
CREATE INDEX idx_devices_status ON devices(status);
|
||||
|
||||
-- ============ PUSH TOKENS TABLE ============
|
||||
CREATE TABLE IF NOT EXISTS push_tokens (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
token TEXT NOT NULL,
|
||||
platform VARCHAR(20) CHECK (platform IN ('ios', 'android', 'web')),
|
||||
device_id VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, token)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_push_tokens_user_id ON push_tokens(user_id);
|
||||
|
||||
-- ============ ROW LEVEL SECURITY ============
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE beneficiaries ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE devices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE push_tokens ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Orders: users can only see their own orders
|
||||
CREATE POLICY "Users can view own orders" ON orders
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- Subscriptions: users can only see their own subscriptions
|
||||
CREATE POLICY "Users can view own subscriptions" ON subscriptions
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- Beneficiaries: users can view and manage their own beneficiaries
|
||||
CREATE POLICY "Users can view own beneficiaries" ON beneficiaries
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own beneficiaries" ON beneficiaries
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own beneficiaries" ON beneficiaries
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
|
||||
-- Devices: users can view devices for their beneficiaries
|
||||
CREATE POLICY "Users can view devices for own beneficiaries" ON devices
|
||||
FOR SELECT USING (
|
||||
beneficiary_id IN (
|
||||
SELECT id FROM beneficiaries WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Push tokens: users can manage their own tokens
|
||||
CREATE POLICY "Users can manage own push tokens" ON push_tokens
|
||||
FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============ SERVICE ROLE POLICIES ============
|
||||
-- These allow the backend (service role) to manage all data
|
||||
|
||||
CREATE POLICY "Service role can manage orders" ON orders
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Service role can manage subscriptions" ON subscriptions
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Service role can manage beneficiaries" ON beneficiaries
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
CREATE POLICY "Service role can manage devices" ON devices
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
|
||||
-- ============ UPDATED_AT TRIGGERS ============
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_beneficiaries_updated_at BEFORE UPDATE ON beneficiaries
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_devices_updated_at BEFORE UPDATE ON devices
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
94
backend/scripts/setup-stripe-products.js
Normal file
94
backend/scripts/setup-stripe-products.js
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Setup Stripe Products and Prices
|
||||
* Run once: node scripts/setup-stripe-products.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const Stripe = require('stripe');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
async function setupStripeProducts() {
|
||||
console.log('Setting up Stripe products and prices...\n');
|
||||
|
||||
try {
|
||||
// 1. Create Starter Kit product
|
||||
console.log('Creating Starter Kit product...');
|
||||
const starterKit = await stripe.products.create({
|
||||
name: 'WellNuo Starter Kit',
|
||||
description: '2x Motion Sensors + 1x Door Sensor + 1x Hub - Everything you need to start monitoring',
|
||||
metadata: {
|
||||
type: 'hardware',
|
||||
sku: 'KIT-STARTER-001'
|
||||
}
|
||||
});
|
||||
console.log(`✓ Product created: ${starterKit.id}`);
|
||||
|
||||
// Create price for Starter Kit ($249 one-time)
|
||||
const starterKitPrice = await stripe.prices.create({
|
||||
product: starterKit.id,
|
||||
unit_amount: 24900, // $249.00
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
display_name: 'Starter Kit'
|
||||
}
|
||||
});
|
||||
console.log(`✓ Price created: ${starterKitPrice.id} ($249.00)\n`);
|
||||
|
||||
// 2. Create Premium Subscription product
|
||||
console.log('Creating Premium Subscription product...');
|
||||
const premium = await stripe.products.create({
|
||||
name: 'WellNuo Premium',
|
||||
description: 'AI Julia assistant, 90-day activity history, invite up to 5 family members',
|
||||
metadata: {
|
||||
type: 'subscription',
|
||||
tier: 'premium'
|
||||
}
|
||||
});
|
||||
console.log(`✓ Product created: ${premium.id}`);
|
||||
|
||||
// Create price for Premium ($9.99/month)
|
||||
const premiumPrice = await stripe.prices.create({
|
||||
product: premium.id,
|
||||
unit_amount: 999, // $9.99
|
||||
currency: 'usd',
|
||||
recurring: {
|
||||
interval: 'month'
|
||||
},
|
||||
metadata: {
|
||||
display_name: 'Premium Monthly'
|
||||
}
|
||||
});
|
||||
console.log(`✓ Price created: ${premiumPrice.id} ($9.99/month)\n`);
|
||||
|
||||
// Summary
|
||||
console.log('='.repeat(50));
|
||||
console.log('STRIPE SETUP COMPLETE');
|
||||
console.log('='.repeat(50));
|
||||
console.log('\nAdd these to your .env file:\n');
|
||||
console.log(`STRIPE_PRICE_STARTER_KIT=${starterKitPrice.id}`);
|
||||
console.log(`STRIPE_PRICE_PREMIUM=${premiumPrice.id}`);
|
||||
console.log(`STRIPE_PRODUCT_STARTER_KIT=${starterKit.id}`);
|
||||
console.log(`STRIPE_PRODUCT_PREMIUM=${premium.id}`);
|
||||
console.log('\n');
|
||||
|
||||
return {
|
||||
starterKit: {
|
||||
productId: starterKit.id,
|
||||
priceId: starterKitPrice.id
|
||||
},
|
||||
premium: {
|
||||
productId: premium.id,
|
||||
priceId: premiumPrice.id
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error setting up Stripe:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setupStripeProducts()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
492
backend/src/admin/index.html
Normal file
492
backend/src/admin/index.html
Normal file
@ -0,0 +1,492 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WellNuo Admin</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.status-paid { background: #fef3c7; color: #92400e; }
|
||||
.status-preparing { background: #dbeafe; color: #1e40af; }
|
||||
.status-shipped { background: #e0e7ff; color: #3730a3; }
|
||||
.status-delivered { background: #d1fae5; color: #065f46; }
|
||||
.status-installed { background: #dcfce7; color: #166534; }
|
||||
.status-canceled { background: #fee2e2; color: #991b1b; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<!-- Auth Screen -->
|
||||
<div id="auth-screen" class="fixed inset-0 bg-gray-900 flex items-center justify-center">
|
||||
<div class="bg-white p-8 rounded-lg shadow-xl w-96">
|
||||
<h1 class="text-2xl font-bold mb-6 text-center">WellNuo Admin</h1>
|
||||
<input type="password" id="admin-key" placeholder="Admin API Key"
|
||||
class="w-full p-3 border rounded mb-4 focus:ring-2 focus:ring-blue-500">
|
||||
<button onclick="login()" class="w-full bg-blue-600 text-white p-3 rounded font-semibold hover:bg-blue-700">
|
||||
Login
|
||||
</button>
|
||||
<p id="auth-error" class="text-red-500 text-center mt-4 hidden"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main App -->
|
||||
<div id="main-app" class="hidden">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold text-gray-900">WellNuo Admin</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="https://dashboard.stripe.com/test/payments" target="_blank"
|
||||
class="text-sm text-blue-600 hover:underline">Stripe Dashboard →</a>
|
||||
<button onclick="logout()" class="text-sm text-gray-500 hover:text-gray-700">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white border-b">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="flex gap-6">
|
||||
<button onclick="showTab('dashboard')" class="tab-btn py-3 px-1 border-b-2 border-transparent hover:border-gray-300"
|
||||
data-tab="dashboard">Dashboard</button>
|
||||
<button onclick="showTab('orders')" class="tab-btn py-3 px-1 border-b-2 border-transparent hover:border-gray-300"
|
||||
data-tab="orders">Orders</button>
|
||||
<button onclick="showTab('subscriptions')" class="tab-btn py-3 px-1 border-b-2 border-transparent hover:border-gray-300"
|
||||
data-tab="subscriptions">Subscriptions</button>
|
||||
<button onclick="showTab('beneficiaries')" class="tab-btn py-3 px-1 border-b-2 border-transparent hover:border-gray-300"
|
||||
data-tab="beneficiaries">Beneficiaries</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||
<!-- Dashboard Tab -->
|
||||
<div id="tab-dashboard" class="tab-content">
|
||||
<h2 class="text-2xl font-bold mb-6">Dashboard</h2>
|
||||
<div id="stats-grid" class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<p class="text-sm text-gray-500">Total Orders</p>
|
||||
<p id="stat-orders-total" class="text-3xl font-bold">-</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<p class="text-sm text-gray-500">Awaiting Shipment</p>
|
||||
<p id="stat-orders-paid" class="text-3xl font-bold text-yellow-600">-</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<p class="text-sm text-gray-500">Active Subscriptions</p>
|
||||
<p id="stat-subs-premium" class="text-3xl font-bold text-green-600">-</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<p class="text-sm text-gray-500">MRR</p>
|
||||
<p id="stat-mrr" class="text-3xl font-bold text-blue-600">-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Orders -->
|
||||
<h3 class="text-lg font-semibold mb-4">Recent Orders</h3>
|
||||
<div id="recent-orders" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<p class="p-4 text-gray-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders Tab -->
|
||||
<div id="tab-orders" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold">Orders</h2>
|
||||
<select id="orders-filter" onchange="loadOrders()" class="border rounded p-2">
|
||||
<option value="">All statuses</option>
|
||||
<option value="paid">Paid (awaiting)</option>
|
||||
<option value="preparing">Preparing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="installed">Installed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="orders-list" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<p class="p-4 text-gray-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Tab -->
|
||||
<div id="tab-subscriptions" class="tab-content hidden">
|
||||
<h2 class="text-2xl font-bold mb-6">Subscriptions</h2>
|
||||
<div id="subscriptions-list" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<p class="p-4 text-gray-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beneficiaries Tab -->
|
||||
<div id="tab-beneficiaries" class="tab-content hidden">
|
||||
<h2 class="text-2xl font-bold mb-6">Beneficiaries</h2>
|
||||
<div id="beneficiaries-list" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<p class="p-4 text-gray-500">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Order Detail Modal -->
|
||||
<div id="order-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-auto">
|
||||
<div class="p-6" id="order-modal-content">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Config
|
||||
const API_BASE = window.location.origin;
|
||||
let adminKey = localStorage.getItem('adminKey') || '';
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (adminKey) {
|
||||
checkAuth();
|
||||
}
|
||||
});
|
||||
|
||||
// Auth
|
||||
async function login() {
|
||||
const key = document.getElementById('admin-key').value;
|
||||
if (!key) return;
|
||||
|
||||
adminKey = key;
|
||||
await checkAuth();
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/stats`, {
|
||||
headers: { 'x-admin-key': adminKey }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem('adminKey', adminKey);
|
||||
document.getElementById('auth-screen').classList.add('hidden');
|
||||
document.getElementById('main-app').classList.remove('hidden');
|
||||
showTab('dashboard');
|
||||
loadStats();
|
||||
} else {
|
||||
document.getElementById('auth-error').textContent = 'Invalid API key';
|
||||
document.getElementById('auth-error').classList.remove('hidden');
|
||||
localStorage.removeItem('adminKey');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById('auth-error').textContent = 'Connection error';
|
||||
document.getElementById('auth-error').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('adminKey');
|
||||
adminKey = '';
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// Tabs
|
||||
function showTab(tab) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('border-blue-500', 'text-blue-600'));
|
||||
|
||||
document.getElementById(`tab-${tab}`).classList.remove('hidden');
|
||||
document.querySelector(`[data-tab="${tab}"]`).classList.add('border-blue-500', 'text-blue-600');
|
||||
|
||||
// Load data
|
||||
if (tab === 'orders') loadOrders();
|
||||
if (tab === 'subscriptions') loadSubscriptions();
|
||||
if (tab === 'beneficiaries') loadBeneficiaries();
|
||||
}
|
||||
|
||||
// API Helper
|
||||
async function api(endpoint, options = {}) {
|
||||
const res = await fetch(`${API_BASE}/api/admin${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'x-admin-key': adminKey,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
async function loadStats() {
|
||||
const stats = await api('/stats');
|
||||
|
||||
document.getElementById('stat-orders-total').textContent = stats.orders?.total || 0;
|
||||
document.getElementById('stat-orders-paid').textContent = stats.orders?.byStatus?.paid || 0;
|
||||
document.getElementById('stat-subs-premium').textContent = stats.subscriptions?.premium || 0;
|
||||
document.getElementById('stat-mrr').textContent = `$${(stats.mrr || 0).toFixed(2)}`;
|
||||
|
||||
// Load recent orders
|
||||
const orders = await api('/orders?limit=5');
|
||||
renderOrdersList(orders.orders || [], 'recent-orders');
|
||||
}
|
||||
|
||||
// Orders
|
||||
async function loadOrders() {
|
||||
const status = document.getElementById('orders-filter').value;
|
||||
const orders = await api(`/orders?status=${status}&limit=50`);
|
||||
renderOrdersList(orders.orders || [], 'orders-list');
|
||||
}
|
||||
|
||||
function renderOrdersList(orders, containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
|
||||
if (!orders.length) {
|
||||
container.innerHTML = '<p class="p-4 text-gray-500">No orders found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Order</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Customer</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
${orders.map(order => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-sm">${order.order_number}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium">${order.beneficiaries?.name || 'N/A'}</p>
|
||||
<p class="text-sm text-gray-500">${order.shipping_name || ''}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="status-${order.status} px-2 py-1 rounded text-xs font-medium">
|
||||
${order.status.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">$${(order.amount_total / 100).toFixed(2)}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
${new Date(order.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button onclick="showOrderDetail('${order.id}')"
|
||||
class="text-blue-600 hover:underline text-sm">View</button>
|
||||
${order.status === 'paid' ? `
|
||||
<button onclick="updateOrderStatus('${order.id}', 'preparing')"
|
||||
class="ml-2 text-green-600 hover:underline text-sm">→ Preparing</button>
|
||||
` : ''}
|
||||
${order.status === 'preparing' ? `
|
||||
<button onclick="showShipModal('${order.id}')"
|
||||
class="ml-2 text-purple-600 hover:underline text-sm">→ Ship</button>
|
||||
` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
async function showOrderDetail(orderId) {
|
||||
const modal = document.getElementById('order-modal');
|
||||
const content = document.getElementById('order-modal-content');
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
content.innerHTML = '<p class="text-gray-500">Loading...</p>';
|
||||
|
||||
const order = await api(`/orders/${orderId}`);
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">${order.order_number}</h2>
|
||||
<p class="text-gray-500">${new Date(order.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<button onclick="closeOrderModal()" class="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">Beneficiary</h3>
|
||||
<p>${order.beneficiaries?.name || 'N/A'}</p>
|
||||
<p class="text-sm text-gray-500">${order.beneficiaries?.address || ''}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-2">Shipping Address</h3>
|
||||
<p>${order.shipping_name || ''}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
${order.shipping_address?.line1 || ''}<br>
|
||||
${order.shipping_address?.city || ''}, ${order.shipping_address?.state || ''} ${order.shipping_address?.postal_code || ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="font-semibold mb-2">Items</h3>
|
||||
<ul class="text-sm">
|
||||
${(order.items || []).map(item => `<li>• ${item.name} - $${(item.price / 100).toFixed(2)}</li>`).join('')}
|
||||
</ul>
|
||||
<p class="font-semibold mt-2">Total: $${(order.amount_total / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="font-semibold mb-2">Status</h3>
|
||||
<span class="status-${order.status} px-3 py-1 rounded font-medium">
|
||||
${order.status.toUpperCase()}
|
||||
</span>
|
||||
${order.tracking_number ? `
|
||||
<p class="mt-2 text-sm">
|
||||
Tracking: <span class="font-mono">${order.tracking_number}</span>
|
||||
(${order.carrier || 'N/A'})
|
||||
</p>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
${order.status === 'paid' ? `
|
||||
<button onclick="updateOrderStatus('${order.id}', 'preparing'); closeOrderModal();"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded">Mark as Preparing</button>
|
||||
` : ''}
|
||||
${order.status === 'preparing' ? `
|
||||
<button onclick="closeOrderModal(); showShipModal('${order.id}');"
|
||||
class="bg-purple-600 text-white px-4 py-2 rounded">Add Tracking & Ship</button>
|
||||
` : ''}
|
||||
${order.status === 'shipped' ? `
|
||||
<button onclick="updateOrderStatus('${order.id}', 'delivered'); closeOrderModal();"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded">Mark as Delivered</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function closeOrderModal() {
|
||||
document.getElementById('order-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function updateOrderStatus(orderId, status) {
|
||||
await api(`/orders/${orderId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
loadOrders();
|
||||
loadStats();
|
||||
}
|
||||
|
||||
function showShipModal(orderId) {
|
||||
const tracking = prompt('Enter tracking number:');
|
||||
if (!tracking) return;
|
||||
|
||||
const carrier = prompt('Enter carrier (UPS, FedEx, USPS):');
|
||||
if (!carrier) return;
|
||||
|
||||
api(`/orders/${orderId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: 'shipped',
|
||||
tracking_number: tracking,
|
||||
carrier: carrier
|
||||
})
|
||||
}).then(() => {
|
||||
loadOrders();
|
||||
loadStats();
|
||||
});
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
async function loadSubscriptions() {
|
||||
const data = await api('/subscriptions');
|
||||
const container = document.getElementById('subscriptions-list');
|
||||
|
||||
if (!data.subscriptions?.length) {
|
||||
container.innerHTML = '<p class="p-4 text-gray-500">No subscriptions found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beneficiary</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Plan</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Period End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
${data.subscriptions.map(sub => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">${sub.beneficiaries?.name || 'N/A'}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="${sub.plan === 'premium' ? 'text-purple-600' : 'text-gray-500'} font-medium">
|
||||
${sub.plan.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="${sub.status === 'active' ? 'text-green-600' : 'text-red-600'}">
|
||||
${sub.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
${sub.current_period_end ? new Date(sub.current_period_end).toLocaleDateString() : 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// Beneficiaries
|
||||
async function loadBeneficiaries() {
|
||||
const data = await api('/beneficiaries');
|
||||
const container = document.getElementById('beneficiaries-list');
|
||||
|
||||
if (!data.beneficiaries?.length) {
|
||||
container.innerHTML = '<p class="p-4 text-gray-500">No beneficiaries found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Caretaker</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Devices</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
${data.beneficiaries.map(ben => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium">${ben.name}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">${ben.users?.email || 'N/A'}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="${ben.status === 'active' ? 'text-green-600' : 'text-yellow-600'}">
|
||||
${ben.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
${(ben.devices || []).length} device(s)
|
||||
<span class="text-gray-400">
|
||||
(${(ben.devices || []).filter(d => d.status === 'online').length} online)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// Close modal on background click
|
||||
document.getElementById('order-modal').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closeOrderModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
24
backend/src/config/stripe.js
Normal file
24
backend/src/config/stripe.js
Normal file
@ -0,0 +1,24 @@
|
||||
const Stripe = require('stripe');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2024-12-18.acacia'
|
||||
});
|
||||
|
||||
// Product and Price IDs - will be created on first run
|
||||
const PRODUCTS = {
|
||||
STARTER_KIT: {
|
||||
name: 'WellNuo Starter Kit',
|
||||
description: '2x Motion Sensors + 1x Door Sensor + 1x Hub',
|
||||
price: 24900, // $249.00 in cents
|
||||
type: 'one_time'
|
||||
},
|
||||
PREMIUM_SUBSCRIPTION: {
|
||||
name: 'WellNuo Premium',
|
||||
description: 'AI Julia chat, 90-day history, invite up to 5 family members',
|
||||
price: 999, // $9.99 in cents
|
||||
type: 'recurring',
|
||||
interval: 'month'
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { stripe, PRODUCTS };
|
||||
17
backend/src/config/supabase.js
Normal file
17
backend/src/config/supabase.js
Normal file
@ -0,0 +1,17 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
console.error('Missing Supabase credentials!');
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { supabase };
|
||||
134
backend/src/controllers/alarm.js
Normal file
134
backend/src/controllers/alarm.js
Normal file
@ -0,0 +1,134 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
// POST: alarm_on_off
|
||||
exports.onOff = async (req, res) => {
|
||||
const { user_name, token, deployment_id, alarm_on } = req.body;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('deployments')
|
||||
.update({
|
||||
alarm_on: alarm_on === '1' || alarm_on === 1,
|
||||
time_edit: Date.now() / 1000
|
||||
})
|
||||
.eq('deployment_id', deployment_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
deployment_id,
|
||||
alarm_on: alarm_on === '1' || alarm_on === 1
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_alarm_state
|
||||
exports.getState = async (req, res) => {
|
||||
const { user_name, token, deployment_id } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('deployments')
|
||||
.select('deployment_id, alarm_on, alarm_details')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return res.json({
|
||||
deployment_id,
|
||||
alarm_on: data?.alarm_on || false,
|
||||
alarm_details: data?.alarm_details || null
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: store_alarms
|
||||
exports.store = async (req, res) => {
|
||||
const {
|
||||
user_name, token, deployment_id, deployment_alarms,
|
||||
device_id, device_alarms
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
// Store deployment alarms
|
||||
if (deployment_id && deployment_alarms) {
|
||||
await supabase
|
||||
.from('deployments')
|
||||
.update({
|
||||
alarm_details: JSON.parse(deployment_alarms),
|
||||
time_edit: Date.now() / 1000
|
||||
})
|
||||
.eq('deployment_id', deployment_id);
|
||||
}
|
||||
|
||||
// Store device alarms
|
||||
if (device_id && device_alarms) {
|
||||
await supabase
|
||||
.from('devices')
|
||||
.update({
|
||||
alarm_settings: JSON.parse(device_alarms),
|
||||
time_edit: Date.now() / 1000
|
||||
})
|
||||
.eq('device_id', device_id);
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: send_walarm
|
||||
exports.sendWalarm = async (req, res) => {
|
||||
const {
|
||||
user_name, token, deployment_id,
|
||||
location, method, conditionType, content
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
// TODO: Implement alarm sending (push notifications, SMS, etc.)
|
||||
// For now, log the alarm and return success
|
||||
|
||||
console.log('ALARM:', {
|
||||
deployment_id,
|
||||
location,
|
||||
method,
|
||||
conditionType,
|
||||
content,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Alarm sent',
|
||||
deployment_id
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: activity_detected
|
||||
exports.activityDetected = async (req, res) => {
|
||||
const { user_name, token, time, deployment_id, device_id } = req.body;
|
||||
|
||||
try {
|
||||
// Log activity detection
|
||||
// TODO: Store in activity_log table
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
time,
|
||||
deployment_id,
|
||||
device_id
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
243
backend/src/controllers/auth.js
Normal file
243
backend/src/controllers/auth.js
Normal file
@ -0,0 +1,243 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const { supabase } = require('../config/supabase');
|
||||
const { sendPasswordResetEmail } = require('../services/email');
|
||||
|
||||
// POST: credentials - login
|
||||
exports.credentials = async (req, res) => {
|
||||
const { user_name, ps, clientId, nonce } = req.body;
|
||||
|
||||
try {
|
||||
// Get user from database (need users table)
|
||||
// For now, return mock response matching old API format
|
||||
const { data: user, error } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('username', user_name)
|
||||
.single();
|
||||
|
||||
if (error || !user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const validPassword = await bcrypt.compare(ps, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const access_token = jwt.sign(
|
||||
{
|
||||
username: user_name,
|
||||
user_id: user.id,
|
||||
role: user.role
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
return res.json({
|
||||
access_token,
|
||||
user_id: user.id,
|
||||
role: user.role,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Credentials error:', error);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: new_user_form - register new user
|
||||
exports.newUserForm = async (req, res) => {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password,
|
||||
devices,
|
||||
agreementDate,
|
||||
privacyPolicyVersion,
|
||||
phone
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Generate username from email
|
||||
const username = email.split('@')[0];
|
||||
|
||||
// Insert new user
|
||||
const { data: user, error } = await supabase
|
||||
.from('users')
|
||||
.insert({
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone,
|
||||
devices: devices ? JSON.parse(devices) : [],
|
||||
agreement_date: agreementDate,
|
||||
privacy_policy_version: privacyPolicyVersion,
|
||||
role: 'beneficiary',
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
// Generate token for auto-login
|
||||
const access_token = jwt.sign(
|
||||
{
|
||||
username: user.username,
|
||||
user_id: user.id,
|
||||
role: user.role
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
access_token,
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('New user form error:', error);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: forgot_password - request password reset
|
||||
exports.forgotPassword = async (req, res) => {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find user by email in person_details table
|
||||
const { data: user, error: userError } = await supabase
|
||||
.from('person_details')
|
||||
.select('user_id, email, first_name')
|
||||
.eq('email', email)
|
||||
.single();
|
||||
|
||||
if (userError || !user) {
|
||||
// Don't reveal if email exists or not (security)
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'If this email exists, a password reset link will be sent'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
// Store reset token in password_resets table
|
||||
const { error: insertError } = await supabase
|
||||
.from('password_resets')
|
||||
.insert({
|
||||
user_id: user.user_id,
|
||||
token: resetToken,
|
||||
expires_at: expiresAt.toISOString()
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Failed to store reset token:', insertError);
|
||||
return res.status(500).json({ error: 'Failed to process request' });
|
||||
}
|
||||
|
||||
// Send reset email
|
||||
try {
|
||||
await sendPasswordResetEmail(email, resetToken);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send email:', emailError);
|
||||
// Still return success to not reveal email status
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'If this email exists, a password reset link will be sent'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: reset_password - set new password using token
|
||||
exports.resetPassword = async (req, res) => {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({ error: 'Token and password are required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find valid reset token
|
||||
const { data: resetRecord, error: tokenError } = await supabase
|
||||
.from('password_resets')
|
||||
.select('*')
|
||||
.eq('token', token)
|
||||
.is('used_at', null)
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.single();
|
||||
|
||||
if (tokenError || !resetRecord) {
|
||||
return res.status(400).json({ error: 'Invalid or expired reset token' });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Update password in person_details table
|
||||
// Note: Currently passwords are stored in 'key' field as plain text
|
||||
// This updates to password_hash field (need to add this column)
|
||||
const { error: updateError } = await supabase
|
||||
.from('person_details')
|
||||
.update({
|
||||
key: password, // Legacy: update plain text (TODO: migrate to password_hash)
|
||||
// password_hash: passwordHash // Future: use hashed password
|
||||
})
|
||||
.eq('user_id', resetRecord.user_id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update password:', updateError);
|
||||
return res.status(500).json({ error: 'Failed to update password' });
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
await supabase
|
||||
.from('password_resets')
|
||||
.update({ used_at: new Date().toISOString() })
|
||||
.eq('id', resetRecord.id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Password has been reset successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
100
backend/src/controllers/beneficiary.js
Normal file
100
backend/src/controllers/beneficiary.js
Normal file
@ -0,0 +1,100 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
// POST: beneficiaries_list
|
||||
exports.list = async (req, res) => {
|
||||
const { user_name, token, first = 0, last = 100 } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error, count } = await supabase
|
||||
.from('person_details')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('role', 'beneficiary')
|
||||
.range(parseInt(first), parseInt(last) - 1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return res.json({
|
||||
beneficiaries: data,
|
||||
total: count
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_beneficiary
|
||||
exports.get = async (req, res) => {
|
||||
const { user_name, token, user_id } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('person_details')
|
||||
.select('*')
|
||||
.eq('person_id', user_id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Beneficiary not found' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: beneficiary_form - create/update beneficiary
|
||||
exports.form = async (req, res) => {
|
||||
const {
|
||||
user_name, token, new_user_name, editing_user_id, user_id,
|
||||
role_ids, email, first_name, last_name,
|
||||
address_street, address_city, address_zip, address_state, address_country,
|
||||
phone_number, picture, key
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const personData = {
|
||||
username: new_user_name,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
address_street,
|
||||
address_city,
|
||||
address_zip,
|
||||
address_state,
|
||||
address_country,
|
||||
phone_number,
|
||||
picture,
|
||||
role: 'beneficiary',
|
||||
role_ids: role_ids ? JSON.parse(role_ids) : null,
|
||||
time_edit: Date.now() / 1000
|
||||
};
|
||||
|
||||
let result;
|
||||
const id = editing_user_id || user_id;
|
||||
|
||||
if (id) {
|
||||
// Update existing
|
||||
const { data, error } = await supabase
|
||||
.from('person_details')
|
||||
.update(personData)
|
||||
.eq('person_id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
result = data;
|
||||
} else {
|
||||
// Create new
|
||||
const { data, error } = await supabase
|
||||
.from('person_details')
|
||||
.insert(personData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
result = data;
|
||||
}
|
||||
|
||||
return res.json({ success: true, beneficiary: result });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
101
backend/src/controllers/caretaker.js
Normal file
101
backend/src/controllers/caretaker.js
Normal file
@ -0,0 +1,101 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
// POST: caretakers_list
|
||||
exports.list = async (req, res) => {
|
||||
const { user_name, token, first = 0, last = 100 } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error, count } = await supabase
|
||||
.from('person_details')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('role', 'caretaker')
|
||||
.range(parseInt(first), parseInt(last) - 1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return res.json({
|
||||
caretakers: data,
|
||||
total: count
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_caretaker
|
||||
exports.get = async (req, res) => {
|
||||
const { user_name, token, user_id } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('person_details')
|
||||
.select('*')
|
||||
.eq('person_id', user_id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Caretaker not found' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: caretaker_form - create/update caretaker
|
||||
exports.form = async (req, res) => {
|
||||
const {
|
||||
user_name, token, new_user_name, user_id, editing_user_id, key,
|
||||
role_ids, access_to, email, first_name, last_name,
|
||||
address_street, address_city, address_zip, address_state, address_country,
|
||||
phone_number, picture
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const personData = {
|
||||
username: new_user_name,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
address_street,
|
||||
address_city,
|
||||
address_zip,
|
||||
address_state,
|
||||
address_country,
|
||||
phone_number,
|
||||
picture,
|
||||
role: 'caretaker',
|
||||
role_ids: role_ids ? JSON.parse(role_ids) : null,
|
||||
access_to: access_to ? JSON.parse(access_to) : null,
|
||||
time_edit: Date.now() / 1000
|
||||
};
|
||||
|
||||
let result;
|
||||
const id = editing_user_id || user_id;
|
||||
|
||||
if (id) {
|
||||
// Update existing
|
||||
const { data, error } = await supabase
|
||||
.from('person_details')
|
||||
.update(personData)
|
||||
.eq('person_id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
result = data;
|
||||
} else {
|
||||
// Create new
|
||||
const { data, error } = await supabase
|
||||
.from('person_details')
|
||||
.insert(personData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
result = data;
|
||||
}
|
||||
|
||||
return res.json({ success: true, caretaker: result });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
66
backend/src/controllers/dashboard.js
Normal file
66
backend/src/controllers/dashboard.js
Normal file
@ -0,0 +1,66 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
const { verifyToken } = require('../middleware/auth');
|
||||
|
||||
// POST: dashboard_list - get all deployments for dashboard
|
||||
exports.list = async (req, res) => {
|
||||
const { user_name, token, user_id, date } = req.body;
|
||||
|
||||
try {
|
||||
// Get all deployments
|
||||
const { data: deployments, error } = await supabase
|
||||
.from('deployments')
|
||||
.select('*')
|
||||
.order('deployment_id', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
return res.json(deployments);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Dashboard list error:', error);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: dashboard_single - get single deployment details
|
||||
exports.single = async (req, res) => {
|
||||
const { user_name, token, date, deployment_id, nonce } = req.body;
|
||||
|
||||
try {
|
||||
// Get deployment
|
||||
const { data: deployment, error } = await supabase
|
||||
.from('deployments')
|
||||
.select('*')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return res.status(404).json({ error: 'Deployment not found' });
|
||||
}
|
||||
|
||||
// Get devices for this deployment
|
||||
const { data: devices } = await supabase
|
||||
.from('devices')
|
||||
.select('*')
|
||||
.eq('deployment_id', deployment_id);
|
||||
|
||||
// Get deployment details
|
||||
const { data: details } = await supabase
|
||||
.from('deployment_details')
|
||||
.select('*')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.single();
|
||||
|
||||
return res.json({
|
||||
deployment,
|
||||
devices: devices || [],
|
||||
details: details || null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Dashboard single error:', error);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
191
backend/src/controllers/deployment.js
Normal file
191
backend/src/controllers/deployment.js
Normal file
@ -0,0 +1,191 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
// POST: deployments_list
|
||||
exports.list = async (req, res) => {
|
||||
const { user_name, token, first = 0, last = 100 } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error, count } = await supabase
|
||||
.from('deployments')
|
||||
.select('*', { count: 'exact' })
|
||||
.range(parseInt(first), parseInt(last) - 1)
|
||||
.order('deployment_id', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return res.json({
|
||||
deployments: data,
|
||||
total: count
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_deployment
|
||||
exports.get = async (req, res) => {
|
||||
const { user_name, token, date, deployment_id } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('deployments')
|
||||
.select('*')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Deployment not found' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: deployment_form - create/update deployment
|
||||
exports.form = async (req, res) => {
|
||||
const {
|
||||
user_name, token, editing_deployment_id,
|
||||
beneficiary_id, caretaker_id, owner_id, installer_id,
|
||||
address_street, address_city, address_zip, address_state, address_country,
|
||||
persons, gender, race, born, pets, wifis,
|
||||
lat, lng, devices, time_zone_s
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const deploymentData = {
|
||||
time_edit: Date.now() / 1000,
|
||||
user_edit: 1, // TODO: get from token
|
||||
time_zone_s,
|
||||
persons: parseInt(persons) || 0,
|
||||
gender: parseInt(gender) || 0,
|
||||
race: parseInt(race) || 0,
|
||||
born: parseInt(born) || null,
|
||||
pets: parseInt(pets) || 0
|
||||
};
|
||||
|
||||
let result;
|
||||
if (editing_deployment_id) {
|
||||
// Update existing
|
||||
const { data, error } = await supabase
|
||||
.from('deployments')
|
||||
.update(deploymentData)
|
||||
.eq('deployment_id', editing_deployment_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
result = data;
|
||||
} else {
|
||||
// Create new
|
||||
const { data, error } = await supabase
|
||||
.from('deployments')
|
||||
.insert(deploymentData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
result = data;
|
||||
}
|
||||
|
||||
return res.json({ success: true, deployment: result });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: deployment_delete
|
||||
exports.delete = async (req, res) => {
|
||||
const { user_name, token, editing_deployment_id } = req.body;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('deployments')
|
||||
.delete()
|
||||
.eq('deployment_id', editing_deployment_id);
|
||||
|
||||
if (error) throw error;
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: set_deployment
|
||||
exports.set = async (req, res) => {
|
||||
// Same as form but with different field names
|
||||
return exports.form(req, res);
|
||||
};
|
||||
|
||||
// POST: find_deployments
|
||||
exports.find = async (req, res) => {
|
||||
const { user_name, token, well_ids } = req.body;
|
||||
|
||||
try {
|
||||
const ids = well_ids.split(',').map(id => id.trim());
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deployments')
|
||||
.select('*')
|
||||
.in('deployment_id', ids);
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_floor_layout
|
||||
exports.getFloorLayout = async (req, res) => {
|
||||
const { user_name, token, deployment_id } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('deployment_details')
|
||||
.select('floor_layout')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return res.json({ layout: data?.floor_layout || null });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: set_floor_layout
|
||||
exports.setFloorLayout = async (req, res) => {
|
||||
const { user_name, token, deployment_id, layout } = req.body;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('deployment_details')
|
||||
.upsert({
|
||||
deployment_id,
|
||||
floor_layout: layout
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: request_deployment_map_new
|
||||
exports.requestMap = async (req, res) => {
|
||||
const { user_name, token, deployment_id, map_type } = req.body;
|
||||
|
||||
try {
|
||||
// Return map data from deployment_details
|
||||
const { data, error } = await supabase
|
||||
.from('deployment_details')
|
||||
.select('*')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
230
backend/src/controllers/device.js
Normal file
230
backend/src/controllers/device.js
Normal file
@ -0,0 +1,230 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
// POST: device_list
|
||||
exports.list = async (req, res) => {
|
||||
const { user_name, token, first = 0, last = 100 } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error, count } = await supabase
|
||||
.from('devices')
|
||||
.select('*', { count: 'exact' })
|
||||
.range(parseInt(first), parseInt(last) - 1)
|
||||
.order('device_id', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return res.json({
|
||||
devices: data,
|
||||
total: count
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: device_list_4_gui
|
||||
exports.listForGui = async (req, res) => {
|
||||
// Same as list but may have different formatting
|
||||
return exports.list(req, res);
|
||||
};
|
||||
|
||||
// POST: device_list_by_deployment
|
||||
exports.listByDeployment = async (req, res) => {
|
||||
const { user_name, token, deployment_id, first = 0, last = 100 } = req.body;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.select('*')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.range(parseInt(first), parseInt(last) - 1)
|
||||
.order('device_id', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_device
|
||||
exports.get = async (req, res) => {
|
||||
const { user_name, token, device_id, mac } = req.body;
|
||||
|
||||
try {
|
||||
let query = supabase.from('devices').select('*');
|
||||
|
||||
if (device_id) {
|
||||
query = query.eq('device_id', device_id);
|
||||
} else if (mac) {
|
||||
query = query.eq('mac', mac);
|
||||
}
|
||||
|
||||
const { data, error } = await query.single();
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: device_form - create/update device
|
||||
exports.form = async (req, res) => {
|
||||
const {
|
||||
user_name, token,
|
||||
well_id, device_mac, description, location, close_to,
|
||||
radar_threshold, temperature_calib, humidity_calib, group
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const deviceData = {
|
||||
well_id,
|
||||
mac: device_mac,
|
||||
description,
|
||||
location,
|
||||
close_to,
|
||||
radar_threshold: parseFloat(radar_threshold) || null,
|
||||
temperature_calib: parseFloat(temperature_calib) || null,
|
||||
humidity_calib: parseFloat(humidity_calib) || null,
|
||||
group_id: parseInt(group) || null,
|
||||
time_edit: Date.now() / 1000
|
||||
};
|
||||
|
||||
// Upsert by MAC address
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.upsert(deviceData, { onConflict: 'mac' })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return res.json({ success: true, device: data });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: device_set_group
|
||||
exports.setGroup = async (req, res) => {
|
||||
const { user_name, token, device_id, group_id } = req.body;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('devices')
|
||||
.update({ group_id: parseInt(group_id) })
|
||||
.eq('device_id', device_id);
|
||||
|
||||
if (error) throw error;
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: device_get_live
|
||||
exports.getLive = async (req, res) => {
|
||||
const { user_name, token, device_id, mac } = req.body;
|
||||
|
||||
// TODO: Implement live data fetching from device
|
||||
return res.json({
|
||||
success: true,
|
||||
live_data: null,
|
||||
message: 'Live data not available'
|
||||
});
|
||||
};
|
||||
|
||||
// POST: device_reboot
|
||||
exports.reboot = async (req, res) => {
|
||||
const { user_name, token, device_id, mac } = req.body;
|
||||
|
||||
// TODO: Implement device reboot command
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Reboot command sent'
|
||||
});
|
||||
};
|
||||
|
||||
// POST: device_set_well_id
|
||||
exports.setWellId = async (req, res) => {
|
||||
const { user_name, token, device_id, well_id, mac } = req.body;
|
||||
|
||||
try {
|
||||
let query = supabase.from('devices').update({ well_id });
|
||||
|
||||
if (device_id) {
|
||||
query = query.eq('device_id', device_id);
|
||||
} else if (mac) {
|
||||
query = query.eq('mac', mac);
|
||||
}
|
||||
|
||||
const { error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: device_set_network_id
|
||||
exports.setNetworkId = async (req, res) => {
|
||||
const { user_name, token, device_id, well_id } = req.body;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('devices')
|
||||
.update({ network_id: well_id })
|
||||
.eq('device_id', device_id);
|
||||
|
||||
if (error) throw error;
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: request_devices
|
||||
exports.requestDevices = async (req, res) => {
|
||||
const { user_name, token, group_id, deployment_id, location, fresh } = req.body;
|
||||
|
||||
try {
|
||||
let query = supabase.from('devices').select('*');
|
||||
|
||||
if (deployment_id) {
|
||||
query = query.eq('deployment_id', deployment_id);
|
||||
}
|
||||
if (group_id) {
|
||||
query = query.eq('group_id', group_id);
|
||||
}
|
||||
if (location) {
|
||||
query = query.eq('location', location);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_devices_locations
|
||||
exports.getLocations = async (req, res) => {
|
||||
const { user_name, token, well_ids } = req.body;
|
||||
|
||||
try {
|
||||
const ids = well_ids.split(',').map(id => id.trim());
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.select('device_id, well_id, location, lat, lng')
|
||||
.in('well_id', ids);
|
||||
|
||||
if (error) throw error;
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
178
backend/src/controllers/sensor.js
Normal file
178
backend/src/controllers/sensor.js
Normal file
@ -0,0 +1,178 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
// POST: get_presence_data
|
||||
exports.getPresenceData = async (req, res) => {
|
||||
const {
|
||||
user_name, token, deployment_id, date,
|
||||
filter, data_type, to_date, device_id
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
// TODO: Implement sensor data storage and retrieval
|
||||
// For now return empty data structure
|
||||
return res.json({
|
||||
deployment_id,
|
||||
date,
|
||||
data_type,
|
||||
presence_data: [],
|
||||
message: 'Sensor data endpoint - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_raw_data
|
||||
exports.getRawData = async (req, res) => {
|
||||
const {
|
||||
user_name, token, sensor, MAC,
|
||||
from_time, to_time, part, tzone
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
sensor,
|
||||
mac: MAC,
|
||||
from_time,
|
||||
to_time,
|
||||
raw_data: [],
|
||||
message: 'Raw sensor data endpoint - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_sensor_data_by_deployment_id
|
||||
exports.getByDeploymentId = async (req, res) => {
|
||||
const {
|
||||
user_name, token, sensor, deployment_id,
|
||||
data_type, radar_part, date
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
deployment_id,
|
||||
sensor,
|
||||
date,
|
||||
data: [],
|
||||
message: 'Sensor data by deployment - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_sensor_bucketed_data_by_room_sensor
|
||||
exports.getBucketed = async (req, res) => {
|
||||
const {
|
||||
user_name, token, sensor, deployment_id,
|
||||
data_type, radar_part, date, bucket_size, location
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
deployment_id,
|
||||
sensor,
|
||||
location,
|
||||
bucket_size,
|
||||
buckets: [],
|
||||
message: 'Bucketed sensor data - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: get_time_deltas
|
||||
exports.getTimeDeltas = async (req, res) => {
|
||||
const {
|
||||
user_name, token, date, to_date,
|
||||
device_id, sensor, deployment_id
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
deployment_id,
|
||||
device_id,
|
||||
date,
|
||||
to_date,
|
||||
deltas: [],
|
||||
message: 'Time deltas - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: request_single_slice
|
||||
exports.requestSingleSlice = async (req, res) => {
|
||||
const {
|
||||
user_name, token, date, devices_list, deployment_id,
|
||||
sensor_list, ctrl_key_state, alt_key_state,
|
||||
radar_part, time, data_type, to_date
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
deployment_id,
|
||||
date,
|
||||
slice_data: [],
|
||||
message: 'Single slice data - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: request_single_radar_slice
|
||||
exports.requestRadarSlice = async (req, res) => {
|
||||
const {
|
||||
user_name, token, date, devices_list, deployment_id,
|
||||
sensor_index_list, ctrl_key_state, alt_key_state,
|
||||
radar_part, data_type, to_date
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
deployment_id,
|
||||
date,
|
||||
radar_data: [],
|
||||
message: 'Radar slice data - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: request_proximity
|
||||
exports.requestProximity = async (req, res) => {
|
||||
const { user_name, token, time, deployment_id } = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
deployment_id,
|
||||
time,
|
||||
proximity_data: [],
|
||||
message: 'Proximity data - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: activities_report_details
|
||||
exports.activitiesReport = async (req, res) => {
|
||||
const { user_name, token, deployment_id, filter } = req.body;
|
||||
|
||||
try {
|
||||
return res.json({
|
||||
deployment_id,
|
||||
filter,
|
||||
activities: [],
|
||||
message: 'Activities report - implementation pending'
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
39
backend/src/controllers/voice.js
Normal file
39
backend/src/controllers/voice.js
Normal file
@ -0,0 +1,39 @@
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
// POST: voice_ask - AI voice assistant
|
||||
exports.ask = async (req, res) => {
|
||||
const { clientId, user_name, token, question, deployment_id } = req.body;
|
||||
|
||||
try {
|
||||
// TODO: Integrate with OpenAI/Claude for voice AI
|
||||
// For now, return a placeholder response
|
||||
|
||||
// Get deployment context
|
||||
let context = null;
|
||||
if (deployment_id) {
|
||||
const { data } = await supabase
|
||||
.from('deployments')
|
||||
.select('*')
|
||||
.eq('deployment_id', deployment_id)
|
||||
.single();
|
||||
context = data;
|
||||
}
|
||||
|
||||
// Placeholder AI response
|
||||
const response = {
|
||||
success: true,
|
||||
question,
|
||||
answer: `I received your question: "${question}". Voice AI integration is pending.`,
|
||||
deployment_context: context ? {
|
||||
id: context.deployment_id,
|
||||
persons: context.persons,
|
||||
time_zone: context.time_zone_s
|
||||
} : null,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return res.json(response);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
65
backend/src/index.js
Normal file
65
backend/src/index.js
Normal file
@ -0,0 +1,65 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const apiRouter = require('./routes/api');
|
||||
const stripeRouter = require('./routes/stripe');
|
||||
const webhookRouter = require('./routes/webhook');
|
||||
const adminRouter = require('./routes/admin');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// CORS
|
||||
app.use(cors());
|
||||
|
||||
// Stripe webhooks need raw body for signature verification
|
||||
// Must be before express.json()
|
||||
app.use('/api/webhook/stripe', express.raw({ type: 'application/json' }));
|
||||
|
||||
// JSON body parser for other routes
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// ============ ROUTES ============
|
||||
|
||||
// Legacy API route - matches old API structure
|
||||
// POST /function/well-api/api with function parameter
|
||||
app.use('/function/well-api/api', apiRouter);
|
||||
|
||||
// New REST API routes
|
||||
app.use('/api/stripe', stripeRouter);
|
||||
app.use('/api/webhook', webhookRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
// Admin UI
|
||||
app.get('/admin', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'admin', 'index.html'));
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
stripe: process.env.STRIPE_SECRET_KEY ? 'configured' : 'missing'
|
||||
});
|
||||
});
|
||||
|
||||
// API info
|
||||
app.get('/api', (req, res) => {
|
||||
res.json({
|
||||
name: 'WellNuo API',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
legacy: '/function/well-api/api',
|
||||
stripe: '/api/stripe',
|
||||
webhook: '/api/webhook/stripe'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`WellNuo API running on port ${PORT}`);
|
||||
console.log(`Stripe: ${process.env.STRIPE_SECRET_KEY ? '✓ configured' : '✗ missing'}`);
|
||||
});
|
||||
35
backend/src/middleware/auth.js
Normal file
35
backend/src/middleware/auth.js
Normal file
@ -0,0 +1,35 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Verify JWT token from request body
|
||||
exports.verifyToken = (req, res, next) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
// Optional token verification (doesn't fail if no token)
|
||||
exports.optionalAuth = (req, res, next) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded;
|
||||
} catch (error) {
|
||||
// Token invalid but continue anyway
|
||||
req.user = null;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
409
backend/src/routes/admin.js
Normal file
409
backend/src/routes/admin.js
Normal file
@ -0,0 +1,409 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { supabase } = require('../config/supabase');
|
||||
const Stripe = require('stripe');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
// Simple admin auth middleware
|
||||
// TODO: Replace with proper admin authentication
|
||||
const adminAuth = (req, res, next) => {
|
||||
const adminKey = req.headers['x-admin-key'];
|
||||
|
||||
// For now, use a simple key from env
|
||||
// In production, use proper JWT-based admin auth
|
||||
if (adminKey !== process.env.ADMIN_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Apply admin auth to all routes
|
||||
router.use(adminAuth);
|
||||
|
||||
// ============ DASHBOARD STATS ============
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
* Dashboard statistics
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
// Get order counts by status
|
||||
const { data: orders, error: ordersError } = await supabase
|
||||
.from('orders')
|
||||
.select('status, created_at');
|
||||
|
||||
if (ordersError) throw ordersError;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const stats = {
|
||||
orders: {
|
||||
total: orders.length,
|
||||
today: orders.filter(o => new Date(o.created_at) >= today).length,
|
||||
byStatus: {
|
||||
paid: orders.filter(o => o.status === 'paid').length,
|
||||
preparing: orders.filter(o => o.status === 'preparing').length,
|
||||
shipped: orders.filter(o => o.status === 'shipped').length,
|
||||
delivered: orders.filter(o => o.status === 'delivered').length,
|
||||
installed: orders.filter(o => o.status === 'installed').length
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get subscription stats
|
||||
const { data: subs } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('plan, status');
|
||||
|
||||
if (subs) {
|
||||
stats.subscriptions = {
|
||||
total: subs.length,
|
||||
premium: subs.filter(s => s.plan === 'premium' && s.status === 'active').length,
|
||||
free: subs.filter(s => s.plan === 'free').length,
|
||||
churned: subs.filter(s => s.status === 'canceled').length
|
||||
};
|
||||
|
||||
// MRR = active premium subs * $9.99
|
||||
stats.mrr = stats.subscriptions.premium * 9.99;
|
||||
}
|
||||
|
||||
// Get beneficiary stats
|
||||
const { data: beneficiaries } = await supabase
|
||||
.from('beneficiaries')
|
||||
.select('status');
|
||||
|
||||
if (beneficiaries) {
|
||||
stats.beneficiaries = {
|
||||
total: beneficiaries.length,
|
||||
active: beneficiaries.filter(b => b.status === 'active').length,
|
||||
awaitingSensors: beneficiaries.filter(b => b.status === 'awaiting_sensors').length
|
||||
};
|
||||
}
|
||||
|
||||
res.json(stats);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Stats error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ ORDERS ============
|
||||
|
||||
/**
|
||||
* GET /api/admin/orders
|
||||
* List all orders with filtering
|
||||
*/
|
||||
router.get('/orders', async (req, res) => {
|
||||
try {
|
||||
const { status, limit = 50, offset = 0 } = req.query;
|
||||
|
||||
let query = supabase
|
||||
.from('orders')
|
||||
.select(`
|
||||
*,
|
||||
beneficiaries (id, name),
|
||||
users:user_id (email)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status);
|
||||
}
|
||||
|
||||
const { data, error, count } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.json({
|
||||
orders: data,
|
||||
total: count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Orders error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/orders/:id
|
||||
* Get single order details
|
||||
*/
|
||||
router.get('/orders/:id', async (req, res) => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('orders')
|
||||
.select(`
|
||||
*,
|
||||
beneficiaries (*),
|
||||
subscriptions (*)
|
||||
`)
|
||||
.eq('id', req.params.id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
if (!data) return res.status(404).json({ error: 'Order not found' });
|
||||
|
||||
res.json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Order error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/orders/:id
|
||||
* Update order status, add tracking, etc.
|
||||
*/
|
||||
router.patch('/orders/:id', async (req, res) => {
|
||||
try {
|
||||
const { status, tracking_number, carrier, estimated_delivery } = req.body;
|
||||
|
||||
const updates = { updated_at: new Date().toISOString() };
|
||||
|
||||
if (status) {
|
||||
updates.status = status;
|
||||
|
||||
// Set timestamps based on status
|
||||
if (status === 'shipped') {
|
||||
updates.shipped_at = new Date().toISOString();
|
||||
} else if (status === 'delivered') {
|
||||
updates.delivered_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
if (tracking_number) updates.tracking_number = tracking_number;
|
||||
if (carrier) updates.carrier = carrier;
|
||||
if (estimated_delivery) updates.estimated_delivery = estimated_delivery;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('orders')
|
||||
.update(updates)
|
||||
.eq('id', req.params.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// TODO: Send email notification when status changes
|
||||
if (status === 'shipped') {
|
||||
console.log('TODO: Send shipping notification email');
|
||||
} else if (status === 'delivered') {
|
||||
console.log('TODO: Send delivery notification email');
|
||||
}
|
||||
|
||||
res.json(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Update order error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ USERS ============
|
||||
|
||||
/**
|
||||
* GET /api/admin/users
|
||||
* List all users
|
||||
*/
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0, search } = req.query;
|
||||
|
||||
let query = supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (search) {
|
||||
query = query.or(`email.ilike.%${search}%,name.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.json({ users: data });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Users error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/:id
|
||||
* Get user with their orders and beneficiaries
|
||||
*/
|
||||
router.get('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const { data: user, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', req.params.id)
|
||||
.single();
|
||||
|
||||
if (userError) throw userError;
|
||||
|
||||
const { data: orders } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('user_id', req.params.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
const { data: beneficiaries } = await supabase
|
||||
.from('beneficiaries')
|
||||
.select('*')
|
||||
.eq('user_id', req.params.id);
|
||||
|
||||
const { data: subscriptions } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', req.params.id);
|
||||
|
||||
res.json({
|
||||
...user,
|
||||
orders,
|
||||
beneficiaries,
|
||||
subscriptions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('User error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ SUBSCRIPTIONS ============
|
||||
|
||||
/**
|
||||
* GET /api/admin/subscriptions
|
||||
* List all subscriptions
|
||||
*/
|
||||
router.get('/subscriptions', async (req, res) => {
|
||||
try {
|
||||
const { status, plan } = req.query;
|
||||
|
||||
let query = supabase
|
||||
.from('subscriptions')
|
||||
.select(`
|
||||
*,
|
||||
beneficiaries (name),
|
||||
users:user_id (email)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (status) query = query.eq('status', status);
|
||||
if (plan) query = query.eq('plan', plan);
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.json({ subscriptions: data });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Subscriptions error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ REFUNDS ============
|
||||
|
||||
/**
|
||||
* POST /api/admin/refund
|
||||
* Process a refund via Stripe
|
||||
*/
|
||||
router.post('/refund', async (req, res) => {
|
||||
try {
|
||||
const { orderId, amount, reason } = req.body;
|
||||
|
||||
// Get order
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single();
|
||||
|
||||
if (orderError || !order) {
|
||||
return res.status(404).json({ error: 'Order not found' });
|
||||
}
|
||||
|
||||
// Get payment intent from Stripe session
|
||||
const session = await stripe.checkout.sessions.retrieve(order.stripe_session_id);
|
||||
const paymentIntentId = session.payment_intent;
|
||||
|
||||
// Create refund
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: paymentIntentId,
|
||||
amount: amount ? amount : undefined, // Full refund if no amount specified
|
||||
reason: reason || 'requested_by_customer'
|
||||
});
|
||||
|
||||
// Update order status
|
||||
await supabase
|
||||
.from('orders')
|
||||
.update({
|
||||
status: 'canceled',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', orderId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
refund: {
|
||||
id: refund.id,
|
||||
amount: refund.amount / 100,
|
||||
status: refund.status
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Refund error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ BENEFICIARIES ============
|
||||
|
||||
/**
|
||||
* GET /api/admin/beneficiaries
|
||||
* List all beneficiaries
|
||||
*/
|
||||
router.get('/beneficiaries', async (req, res) => {
|
||||
try {
|
||||
const { status } = req.query;
|
||||
|
||||
let query = supabase
|
||||
.from('beneficiaries')
|
||||
.select(`
|
||||
*,
|
||||
users:user_id (email, name),
|
||||
devices (id, device_type, status)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (status) query = query.eq('status', status);
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
res.json({ beneficiaries: data });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Beneficiaries error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
179
backend/src/routes/api.js
Normal file
179
backend/src/routes/api.js
Normal file
@ -0,0 +1,179 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Old API proxy for endpoints we don't handle
|
||||
const OLD_API_URL = 'https://eluxnetworks.net/function/well-api/api';
|
||||
|
||||
async function proxyToOldApi(req, res) {
|
||||
try {
|
||||
const params = new URLSearchParams(req.body);
|
||||
const response = await fetch(OLD_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
});
|
||||
const data = await response.json();
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Proxy error: ' + error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Controllers
|
||||
const authController = require('../controllers/auth');
|
||||
const dashboardController = require('../controllers/dashboard');
|
||||
const deploymentController = require('../controllers/deployment');
|
||||
const deviceController = require('../controllers/device');
|
||||
const beneficiaryController = require('../controllers/beneficiary');
|
||||
const caretakerController = require('../controllers/caretaker');
|
||||
const sensorController = require('../controllers/sensor');
|
||||
const alarmController = require('../controllers/alarm');
|
||||
const voiceController = require('../controllers/voice');
|
||||
|
||||
// Function router - routes based on 'function' parameter
|
||||
router.post('/', async (req, res) => {
|
||||
const { function: func } = req.body;
|
||||
|
||||
try {
|
||||
switch (func) {
|
||||
// Auth
|
||||
case 'credentials':
|
||||
return authController.credentials(req, res);
|
||||
case 'new_user_form':
|
||||
return authController.newUserForm(req, res);
|
||||
case 'forgot_password':
|
||||
return authController.forgotPassword(req, res);
|
||||
case 'reset_password':
|
||||
return authController.resetPassword(req, res);
|
||||
|
||||
// Dashboard
|
||||
case 'dashboard_list':
|
||||
return dashboardController.list(req, res);
|
||||
case 'dashboard_single':
|
||||
return dashboardController.single(req, res);
|
||||
|
||||
// Deployments
|
||||
case 'deployments_list':
|
||||
return deploymentController.list(req, res);
|
||||
case 'get_deployment':
|
||||
return deploymentController.get(req, res);
|
||||
case 'deployment_form':
|
||||
return deploymentController.form(req, res);
|
||||
case 'deployment_delete':
|
||||
return deploymentController.delete(req, res);
|
||||
case 'set_deployment':
|
||||
return deploymentController.set(req, res);
|
||||
case 'find_deployments':
|
||||
return deploymentController.find(req, res);
|
||||
|
||||
// Devices
|
||||
case 'device_list':
|
||||
return deviceController.list(req, res);
|
||||
case 'device_list_4_gui':
|
||||
return deviceController.listForGui(req, res);
|
||||
case 'device_list_by_deployment':
|
||||
return deviceController.listByDeployment(req, res);
|
||||
case 'get_device':
|
||||
return deviceController.get(req, res);
|
||||
case 'device_form':
|
||||
return deviceController.form(req, res);
|
||||
case 'device_set_group':
|
||||
return deviceController.setGroup(req, res);
|
||||
case 'device_get_live':
|
||||
return deviceController.getLive(req, res);
|
||||
case 'device_reboot':
|
||||
return deviceController.reboot(req, res);
|
||||
case 'device_set_well_id':
|
||||
return deviceController.setWellId(req, res);
|
||||
case 'device_set_network_id':
|
||||
return deviceController.setNetworkId(req, res);
|
||||
case 'request_devices':
|
||||
return deviceController.requestDevices(req, res);
|
||||
|
||||
// Beneficiaries
|
||||
case 'beneficiaries_list':
|
||||
return beneficiaryController.list(req, res);
|
||||
case 'get_beneficiary':
|
||||
return beneficiaryController.get(req, res);
|
||||
case 'beneficiary_form':
|
||||
return beneficiaryController.form(req, res);
|
||||
|
||||
// Caretakers
|
||||
case 'caretakers_list':
|
||||
return caretakerController.list(req, res);
|
||||
case 'get_caretaker':
|
||||
return caretakerController.get(req, res);
|
||||
case 'caretaker_form':
|
||||
return caretakerController.form(req, res);
|
||||
|
||||
// Sensor Data - PROXY TO OLD API
|
||||
case 'get_presence_data':
|
||||
case 'get_raw_data':
|
||||
case 'get_sensor_data_by_deployment_id':
|
||||
case 'get_sensor_bucketed_data_by_room_sensor':
|
||||
case 'get_time_deltas':
|
||||
case 'request_single_slice':
|
||||
case 'request_single_radar_slice':
|
||||
case 'request_proximity':
|
||||
case 'activities_report_details':
|
||||
return proxyToOldApi(req, res);
|
||||
|
||||
// Alarms
|
||||
case 'alarm_on_off':
|
||||
return alarmController.onOff(req, res);
|
||||
case 'get_alarm_state':
|
||||
return alarmController.getState(req, res);
|
||||
case 'store_alarms':
|
||||
return alarmController.store(req, res);
|
||||
case 'send_walarm':
|
||||
return alarmController.sendWalarm(req, res);
|
||||
case 'activity_detected':
|
||||
return alarmController.activityDetected(req, res);
|
||||
|
||||
// Maps/Layout
|
||||
case 'get_floor_layout':
|
||||
return deploymentController.getFloorLayout(req, res);
|
||||
case 'set_floor_layout':
|
||||
return deploymentController.setFloorLayout(req, res);
|
||||
case 'request_deployment_map_new':
|
||||
return deploymentController.requestMap(req, res);
|
||||
case 'get_devices_locations':
|
||||
return deviceController.getLocations(req, res);
|
||||
|
||||
// Voice AI - PROXY TO OLD API
|
||||
case 'voice_ask':
|
||||
return proxyToOldApi(req, res);
|
||||
|
||||
// Maps/Images/Downloads - PROXY TO OLD API
|
||||
case 'get_image_file':
|
||||
case 'download':
|
||||
case 'get_full_location_map':
|
||||
case 'get_sensors_map':
|
||||
case 'request_deployment_map_new':
|
||||
case 'get_photo':
|
||||
return proxyToOldApi(req, res);
|
||||
|
||||
// Messages
|
||||
case 'messages_age':
|
||||
return res.json({ success: true, messages: [] });
|
||||
|
||||
// Node-RED
|
||||
case 'store_flow':
|
||||
return res.json({ success: true });
|
||||
case 'get_node_red_port':
|
||||
return res.json({ port: 1880 });
|
||||
case 'request_node_red':
|
||||
return res.json({ success: true });
|
||||
|
||||
default:
|
||||
// Unknown function - try old API
|
||||
console.log(`Unknown function "${func}" - proxying to old API`);
|
||||
return proxyToOldApi(req, res);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in ${func}:`, error);
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
186
backend/src/routes/stripe.js
Normal file
186
backend/src/routes/stripe.js
Normal file
@ -0,0 +1,186 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Stripe = require('stripe');
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
/**
|
||||
* POST /api/stripe/create-checkout-session
|
||||
* Creates a Stripe Checkout session for purchasing Starter Kit + optional Premium subscription
|
||||
*/
|
||||
router.post('/create-checkout-session', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
userId,
|
||||
beneficiaryName,
|
||||
beneficiaryAddress,
|
||||
beneficiaryPhone,
|
||||
beneficiaryNotes,
|
||||
shippingAddress,
|
||||
includePremium = true
|
||||
} = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
if (!beneficiaryName || !beneficiaryAddress) {
|
||||
return res.status(400).json({ error: 'Beneficiary name and address are required' });
|
||||
}
|
||||
|
||||
// Build line items
|
||||
const lineItems = [
|
||||
{
|
||||
price: process.env.STRIPE_PRICE_STARTER_KIT,
|
||||
quantity: 1,
|
||||
}
|
||||
];
|
||||
|
||||
if (includePremium) {
|
||||
lineItems.push({
|
||||
price: process.env.STRIPE_PRICE_PREMIUM,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
const sessionConfig = {
|
||||
mode: includePremium ? 'subscription' : 'payment',
|
||||
payment_method_types: ['card'],
|
||||
line_items: lineItems,
|
||||
success_url: `${process.env.FRONTEND_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.FRONTEND_URL}/checkout/cancel`,
|
||||
customer_email: req.body.email,
|
||||
metadata: {
|
||||
userId,
|
||||
beneficiaryName,
|
||||
beneficiaryAddress,
|
||||
beneficiaryPhone: beneficiaryPhone || '',
|
||||
beneficiaryNotes: beneficiaryNotes || '',
|
||||
shippingAddress: JSON.stringify(shippingAddress),
|
||||
includePremium: includePremium ? 'true' : 'false'
|
||||
},
|
||||
shipping_address_collection: {
|
||||
allowed_countries: ['US', 'CA']
|
||||
}
|
||||
};
|
||||
|
||||
// Only add shipping options for payment mode (not subscription)
|
||||
if (!includePremium) {
|
||||
sessionConfig.shipping_options = [
|
||||
{
|
||||
shipping_rate_data: {
|
||||
type: 'fixed_amount',
|
||||
fixed_amount: { amount: 0, currency: 'usd' },
|
||||
display_name: 'Standard shipping',
|
||||
delivery_estimate: {
|
||||
minimum: { unit: 'business_day', value: 5 },
|
||||
maximum: { unit: 'business_day', value: 7 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
shipping_rate_data: {
|
||||
type: 'fixed_amount',
|
||||
fixed_amount: { amount: 1500, currency: 'usd' },
|
||||
display_name: 'Express shipping',
|
||||
delivery_estimate: {
|
||||
minimum: { unit: 'business_day', value: 2 },
|
||||
maximum: { unit: 'business_day', value: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create(sessionConfig);
|
||||
|
||||
res.json({
|
||||
sessionId: session.id,
|
||||
url: session.url
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Checkout session error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/stripe/create-portal-session
|
||||
* Creates a Stripe Customer Portal session for managing subscriptions
|
||||
*/
|
||||
router.post('/create-portal-session', async (req, res) => {
|
||||
try {
|
||||
const { customerId } = req.body;
|
||||
|
||||
if (!customerId) {
|
||||
return res.status(400).json({ error: 'customerId is required' });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: `${process.env.FRONTEND_URL}/settings`,
|
||||
});
|
||||
|
||||
res.json({ url: session.url });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Portal session error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stripe/products
|
||||
* Returns available products and prices for the frontend
|
||||
*/
|
||||
router.get('/products', async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
starterKit: {
|
||||
name: 'WellNuo Starter Kit',
|
||||
description: '2x Motion Sensors + 1x Door Sensor + 1x Hub',
|
||||
price: 249.00,
|
||||
priceId: process.env.STRIPE_PRICE_STARTER_KIT
|
||||
},
|
||||
premium: {
|
||||
name: 'WellNuo Premium',
|
||||
description: 'AI Julia assistant, 90-day history, invite up to 5 family members',
|
||||
price: 9.99,
|
||||
interval: 'month',
|
||||
priceId: process.env.STRIPE_PRICE_PREMIUM
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stripe/session/:sessionId
|
||||
* Get checkout session details (for success page)
|
||||
*/
|
||||
router.get('/session/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(req.params.sessionId, {
|
||||
expand: ['line_items', 'customer', 'subscription']
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: session.id,
|
||||
status: session.status,
|
||||
paymentStatus: session.payment_status,
|
||||
customerEmail: session.customer_email,
|
||||
amountTotal: session.amount_total / 100,
|
||||
metadata: session.metadata
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get session error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
272
backend/src/routes/webhook.js
Normal file
272
backend/src/routes/webhook.js
Normal file
@ -0,0 +1,272 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Stripe = require('stripe');
|
||||
const { supabase } = require('../config/supabase');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
/**
|
||||
* POST /api/webhook/stripe
|
||||
* Handles Stripe webhook events
|
||||
*
|
||||
* IMPORTANT: This route uses raw body, configured in index.js
|
||||
*/
|
||||
router.post('/stripe', async (req, res) => {
|
||||
const sig = req.headers['stripe-signature'];
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
// If webhook secret is configured, verify signature
|
||||
if (webhookSecret) {
|
||||
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
|
||||
} else {
|
||||
// For local development without webhook secret
|
||||
event = JSON.parse(req.body.toString());
|
||||
console.warn('⚠️ Webhook signature verification skipped (no STRIPE_WEBHOOK_SECRET)');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err.message);
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
console.log(`📩 Stripe webhook received: ${event.type}`);
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutComplete(event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.paid':
|
||||
await handleInvoicePaid(event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
await handlePaymentFailed(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
await handleSubscriptionCanceled(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdated(event.data.object);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${event.type}:`, error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle checkout.session.completed
|
||||
* Creates order and beneficiary in database
|
||||
*/
|
||||
async function handleCheckoutComplete(session) {
|
||||
console.log('Processing checkout complete:', session.id);
|
||||
|
||||
const metadata = session.metadata;
|
||||
const userId = metadata.userId;
|
||||
|
||||
// 1. Create beneficiary
|
||||
const { data: beneficiary, error: beneficiaryError } = await supabase
|
||||
.from('beneficiaries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
name: metadata.beneficiaryName,
|
||||
address: metadata.beneficiaryAddress,
|
||||
phone: metadata.beneficiaryPhone || null,
|
||||
notes: metadata.beneficiaryNotes || null,
|
||||
status: 'awaiting_sensors'
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (beneficiaryError) {
|
||||
console.error('Error creating beneficiary:', beneficiaryError);
|
||||
throw beneficiaryError;
|
||||
}
|
||||
|
||||
console.log('✓ Beneficiary created:', beneficiary.id);
|
||||
|
||||
// 2. Parse shipping address
|
||||
let shippingAddress = {};
|
||||
try {
|
||||
if (session.shipping_details) {
|
||||
shippingAddress = session.shipping_details.address;
|
||||
} else if (metadata.shippingAddress) {
|
||||
shippingAddress = JSON.parse(metadata.shippingAddress);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not parse shipping address');
|
||||
}
|
||||
|
||||
// 3. Create order
|
||||
const orderNumber = `WN-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`;
|
||||
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert({
|
||||
order_number: orderNumber,
|
||||
user_id: userId,
|
||||
beneficiary_id: beneficiary.id,
|
||||
stripe_session_id: session.id,
|
||||
stripe_customer_id: session.customer,
|
||||
stripe_subscription_id: session.subscription || null,
|
||||
status: 'paid',
|
||||
amount_total: session.amount_total,
|
||||
currency: session.currency,
|
||||
shipping_address: shippingAddress,
|
||||
shipping_name: session.shipping_details?.name || metadata.beneficiaryName,
|
||||
items: [
|
||||
{ type: 'starter_kit', name: 'WellNuo Starter Kit', price: 24900 },
|
||||
...(metadata.includePremium === 'true' ? [{ type: 'subscription', name: 'Premium Monthly', price: 999 }] : [])
|
||||
]
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError) {
|
||||
console.error('Error creating order:', orderError);
|
||||
throw orderError;
|
||||
}
|
||||
|
||||
console.log('✓ Order created:', order.order_number);
|
||||
|
||||
// 4. Create subscription record if Premium selected
|
||||
if (metadata.includePremium === 'true' && session.subscription) {
|
||||
const { error: subError } = await supabase
|
||||
.from('subscriptions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
beneficiary_id: beneficiary.id,
|
||||
stripe_subscription_id: session.subscription,
|
||||
stripe_customer_id: session.customer,
|
||||
plan: 'premium',
|
||||
status: 'active',
|
||||
current_period_start: new Date().toISOString(),
|
||||
current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
});
|
||||
|
||||
if (subError) {
|
||||
console.error('Error creating subscription:', subError);
|
||||
} else {
|
||||
console.log('✓ Subscription created');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. TODO: Send confirmation email via Brevo
|
||||
console.log('TODO: Send order confirmation email');
|
||||
|
||||
return { order, beneficiary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice.paid
|
||||
* Updates subscription period
|
||||
*/
|
||||
async function handleInvoicePaid(invoice) {
|
||||
console.log('Invoice paid:', invoice.id);
|
||||
|
||||
if (invoice.subscription) {
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'active',
|
||||
current_period_start: new Date(invoice.period_start * 1000).toISOString(),
|
||||
current_period_end: new Date(invoice.period_end * 1000).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('stripe_subscription_id', invoice.subscription);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating subscription:', error);
|
||||
} else {
|
||||
console.log('✓ Subscription period updated');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice.payment_failed
|
||||
* Marks subscription as past_due and notifies user
|
||||
*/
|
||||
async function handlePaymentFailed(invoice) {
|
||||
console.log('Payment failed:', invoice.id);
|
||||
|
||||
if (invoice.subscription) {
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'past_due',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('stripe_subscription_id', invoice.subscription);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating subscription:', error);
|
||||
}
|
||||
|
||||
// TODO: Send payment failed email
|
||||
console.log('TODO: Send payment failed email');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle customer.subscription.deleted
|
||||
* Downgrades user to free plan
|
||||
*/
|
||||
async function handleSubscriptionCanceled(subscription) {
|
||||
console.log('Subscription canceled:', subscription.id);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'canceled',
|
||||
canceled_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('stripe_subscription_id', subscription.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating subscription:', error);
|
||||
} else {
|
||||
console.log('✓ Subscription marked as canceled');
|
||||
}
|
||||
|
||||
// TODO: Send cancellation email
|
||||
console.log('TODO: Send cancellation email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle customer.subscription.updated
|
||||
* Updates subscription status
|
||||
*/
|
||||
async function handleSubscriptionUpdated(subscription) {
|
||||
console.log('Subscription updated:', subscription.id);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: subscription.status,
|
||||
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
|
||||
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('stripe_subscription_id', subscription.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating subscription:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
114
backend/src/services/email.js
Normal file
114
backend/src/services/email.js
Normal file
@ -0,0 +1,114 @@
|
||||
const BREVO_API_URL = 'https://api.brevo.com/v3/smtp/email';
|
||||
|
||||
/**
|
||||
* Send email via Brevo API
|
||||
*/
|
||||
async function sendEmail({ to, subject, htmlContent, textContent }) {
|
||||
const apiKey = process.env.BREVO_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('BREVO_API_KEY not configured');
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sender: {
|
||||
name: process.env.BREVO_SENDER_NAME || 'WellNuo',
|
||||
email: process.env.BREVO_SENDER_EMAIL || 'noreply@wellnuo.com'
|
||||
},
|
||||
to: [{ email: to }],
|
||||
subject,
|
||||
htmlContent,
|
||||
textContent
|
||||
};
|
||||
|
||||
const response = await fetch(BREVO_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'api-key': apiKey,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('Brevo API error:', error);
|
||||
throw new Error('Failed to send email');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async function sendPasswordResetEmail(email, resetToken) {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://wellnuo.smartlaunchhub.com';
|
||||
const resetLink = `${frontendUrl}/reset-password?token=${resetToken}`;
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #4A90D9; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 30px; background: #f9f9f9; }
|
||||
.button { display: inline-block; padding: 12px 30px; background: #4A90D9; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }
|
||||
.footer { padding: 20px; text-align: center; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>WellNuo</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
||||
<p style="text-align: center;">
|
||||
<a href="${resetLink}" class="button">Reset Password</a>
|
||||
</p>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #4A90D9;">${resetLink}</p>
|
||||
<p><strong>This link will expire in 1 hour.</strong></p>
|
||||
<p>If you didn't request a password reset, you can safely ignore this email.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2025 WellNuo - Elderly Care Monitoring</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textContent = `
|
||||
WellNuo - Password Reset
|
||||
|
||||
We received a request to reset your password.
|
||||
|
||||
Click this link to reset your password:
|
||||
${resetLink}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request a password reset, you can safely ignore this email.
|
||||
|
||||
WellNuo - Elderly Care Monitoring
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: 'WellNuo - Password Reset Request',
|
||||
htmlContent,
|
||||
textContent
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendEmail,
|
||||
sendPasswordResetEmail
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user