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