feat: Add Findagram and FindADispo consumer frontends

- Add findagram.co React frontend with product search, brands, categories
- Add findadispo.com React frontend with dispensary locator
- Wire findagram to backend /api/az/* endpoints
- Update category/brand links to route to /products with filters
- Add k8s manifests for both frontends
- Add multi-domain user support migrations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-05 16:10:15 -07:00
parent d120a07ed7
commit a0f8d3911c
179 changed files with 140234 additions and 600 deletions

View File

@@ -0,0 +1,172 @@
-- Migration: Multi-domain user support with extended profile fields
-- Adds domain tracking for findagram.co and findadispo.com users
-- Adds extended profile fields (first_name, last_name, phone, sms_enabled)
-- Add new columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS first_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS last_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS phone VARCHAR(20),
ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS domain VARCHAR(50) DEFAULT 'cannaiq.co';
-- Create index for domain-based queries
CREATE INDEX IF NOT EXISTS idx_users_domain ON users(domain);
-- Add domain column to wp_api_permissions
ALTER TABLE wp_api_permissions
ADD COLUMN IF NOT EXISTS domain VARCHAR(50) DEFAULT 'cannaiq.co';
-- Create index for domain-based permission queries
CREATE INDEX IF NOT EXISTS idx_wp_api_permissions_domain ON wp_api_permissions(domain);
-- Create findagram_users table for Find a Gram specific user data
CREATE TABLE IF NOT EXISTS findagram_users (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
-- Profile
display_name VARCHAR(100),
avatar_url TEXT,
bio TEXT,
-- Location preferences
preferred_city VARCHAR(100),
preferred_state VARCHAR(50),
location_lat DECIMAL(10, 8),
location_lng DECIMAL(11, 8),
-- Preferences
favorite_strains TEXT[], -- Array of strain types: hybrid, indica, sativa
favorite_categories TEXT[], -- flower, edibles, concentrates, etc.
price_alert_threshold DECIMAL(10, 2),
notifications_enabled BOOLEAN DEFAULT true,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
-- Create findadispo_users table for Find a Dispo specific user data
CREATE TABLE IF NOT EXISTS findadispo_users (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
-- Profile
display_name VARCHAR(100),
avatar_url TEXT,
-- Location preferences
preferred_city VARCHAR(100),
preferred_state VARCHAR(50),
location_lat DECIMAL(10, 8),
location_lng DECIMAL(11, 8),
search_radius_miles INTEGER DEFAULT 25,
-- Preferences
favorite_dispensary_ids INTEGER[], -- Array of dispensary IDs
deal_notifications BOOLEAN DEFAULT true,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
-- Create findagram_saved_searches table
CREATE TABLE IF NOT EXISTS findagram_saved_searches (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
-- Search criteria
query TEXT,
category VARCHAR(50),
brand VARCHAR(100),
strain_type VARCHAR(20),
min_price DECIMAL(10, 2),
max_price DECIMAL(10, 2),
min_thc DECIMAL(5, 2),
max_thc DECIMAL(5, 2),
city VARCHAR(100),
state VARCHAR(50),
-- Notification settings
notify_on_new BOOLEAN DEFAULT false,
notify_on_price_drop BOOLEAN DEFAULT false,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create findagram_favorites table
CREATE TABLE IF NOT EXISTS findagram_favorites (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
product_id INTEGER, -- References products table
dispensary_id INTEGER, -- References dispensaries table
-- Product snapshot at time of save
product_name VARCHAR(255),
product_brand VARCHAR(100),
product_price DECIMAL(10, 2),
product_image_url TEXT,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, product_id)
);
-- Create findagram_alerts table
CREATE TABLE IF NOT EXISTS findagram_alerts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
alert_type VARCHAR(50) NOT NULL, -- price_drop, back_in_stock, new_product
-- Target
product_id INTEGER,
brand VARCHAR(100),
category VARCHAR(50),
dispensary_id INTEGER,
-- Criteria
target_price DECIMAL(10, 2),
-- Status
is_active BOOLEAN DEFAULT true,
last_triggered_at TIMESTAMP,
trigger_count INTEGER DEFAULT 0,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for findagram tables
CREATE INDEX IF NOT EXISTS idx_findagram_users_user_id ON findagram_users(user_id);
CREATE INDEX IF NOT EXISTS idx_findadispo_users_user_id ON findadispo_users(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_saved_searches_user_id ON findagram_saved_searches(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_favorites_user_id ON findagram_favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_favorites_product_id ON findagram_favorites(product_id);
CREATE INDEX IF NOT EXISTS idx_findagram_alerts_user_id ON findagram_alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_alerts_active ON findagram_alerts(is_active) WHERE is_active = true;
-- Create view for admin user management across domains
CREATE OR REPLACE VIEW admin_users_view AS
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.phone,
u.sms_enabled,
u.role,
u.domain,
u.created_at,
u.updated_at,
CASE
WHEN u.domain = 'findagram.co' THEN fg.display_name
WHEN u.domain = 'findadispo.com' THEN fd.display_name
ELSE NULL
END as display_name,
CASE
WHEN u.domain = 'findagram.co' THEN fg.preferred_city
WHEN u.domain = 'findadispo.com' THEN fd.preferred_city
ELSE NULL
END as preferred_city,
CASE
WHEN u.domain = 'findagram.co' THEN fg.preferred_state
WHEN u.domain = 'findadispo.com' THEN fd.preferred_state
ELSE NULL
END as preferred_state
FROM users u
LEFT JOIN findagram_users fg ON u.id = fg.user_id AND u.domain = 'findagram.co'
LEFT JOIN findadispo_users fd ON u.id = fd.user_id AND u.domain = 'findadispo.com';
-- Update existing cannaiq users to have domain set
UPDATE users SET domain = 'cannaiq.co' WHERE domain IS NULL;

View File

@@ -0,0 +1,47 @@
-- Migration 036: Add fields for findadispo.com frontend
-- These fields are needed for the consumer-facing dispensary locator UI
-- This migration is idempotent - safe to run multiple times
-- Add hours as JSONB to support structured hours data
-- Example: {"monday": {"open": "09:00", "close": "21:00"}, "tuesday": {...}, ...}
-- Or simple: {"formatted": "Mon-Sat 9am-9pm, Sun 10am-6pm"}
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'hours') THEN
ALTER TABLE dispensaries ADD COLUMN hours JSONB DEFAULT NULL;
END IF;
END $$;
-- Add amenities as TEXT array
-- Example: ['Wheelchair Accessible', 'ATM', 'Online Ordering', 'Curbside Pickup', 'Delivery']
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'amenities') THEN
ALTER TABLE dispensaries ADD COLUMN amenities TEXT[] DEFAULT '{}';
END IF;
END $$;
-- Add description for the "About" section on dispensary detail pages
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'description') THEN
ALTER TABLE dispensaries ADD COLUMN description TEXT DEFAULT NULL;
END IF;
END $$;
-- Add image_url for the main dispensary hero image
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'image_url') THEN
ALTER TABLE dispensaries ADD COLUMN image_url TEXT DEFAULT NULL;
END IF;
END $$;
-- Add indexes for filtering (IF NOT EXISTS is supported for indexes in PG 9.5+)
CREATE INDEX IF NOT EXISTS idx_dispensaries_amenities ON dispensaries USING GIN (amenities);
-- Add comments for documentation (COMMENT is idempotent - re-running just updates the comment)
COMMENT ON COLUMN dispensaries.hours IS 'Store hours in JSONB format. Can be structured by day or formatted string.';
COMMENT ON COLUMN dispensaries.amenities IS 'Array of amenity tags like Wheelchair Accessible, ATM, Online Ordering, etc.';
COMMENT ON COLUMN dispensaries.description IS 'Description text for dispensary detail page About section.';
COMMENT ON COLUMN dispensaries.image_url IS 'URL to main dispensary image for hero/card display.';

View File

@@ -1,6 +1,6 @@
{
"name": "dutchie-menus-backend",
"version": "1.0.0",
"version": "1.5.1",
"description": "Backend API for Dutchie Menus scraper and management",
"main": "dist/index.js",
"scripts": {

View File

@@ -657,6 +657,193 @@ router.get('/products/:id/snapshots', async (req: Request, res: Response) => {
}
});
/**
* GET /api/dutchie-az/products/:id/similar
* Get similar products (same brand + category), limited to 4
* Returns products with lowest prices first
*/
router.get('/products/:id/similar', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// Use the exact SQL query provided
const { rows } = await query<{
product_id: number;
name: string;
brand_name: string;
image_url: string;
rec_min_price_cents: number;
}>(
`
WITH base AS (
SELECT id AS base_product_id, brand_name, category
FROM dutchie_products WHERE id = $1
),
latest_prices AS (
SELECT DISTINCT ON (dps.dutchie_product_id)
dps.dutchie_product_id, dps.rec_min_price_cents
FROM dutchie_product_snapshots dps
ORDER BY dps.dutchie_product_id, dps.crawled_at DESC
)
SELECT p.id AS product_id, p.name, p.brand_name, p.primary_image_url as image_url, lp.rec_min_price_cents
FROM dutchie_products p
JOIN base b ON p.category = b.category AND p.brand_name = b.brand_name
JOIN latest_prices lp ON lp.dutchie_product_id = p.id
WHERE p.id <> b.base_product_id AND lp.rec_min_price_cents IS NOT NULL
ORDER BY lp.rec_min_price_cents ASC
LIMIT 4
`,
[id]
);
// Transform to the expected response format
const similarProducts = rows.map((row) => ({
productId: row.product_id,
name: row.name,
brandName: row.brand_name,
imageUrl: row.image_url,
price: row.rec_min_price_cents ? row.rec_min_price_cents / 100 : null,
}));
res.json({ similarProducts });
} catch (error: any) {
console.error('Error fetching similar products:', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/products/:id/availability
* Get dispensaries that carry this product, with distance from user location
* Query params:
* - lat: User latitude (required)
* - lng: User longitude (required)
* - max_radius_miles: Maximum search radius in miles (optional, default 50)
*/
router.get('/products/:id/availability', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { lat, lng, max_radius_miles = '50' } = req.query;
// Validate required params
if (!lat || !lng) {
return res.status(400).json({ error: 'lat and lng query parameters are required' });
}
const userLat = parseFloat(lat as string);
const userLng = parseFloat(lng as string);
const maxRadius = parseFloat(max_radius_miles as string);
if (isNaN(userLat) || isNaN(userLng)) {
return res.status(400).json({ error: 'lat and lng must be valid numbers' });
}
// First get the product to find its external_product_id
const { rows: productRows } = await query(
`SELECT external_product_id, name, brand_name FROM dutchie_products WHERE id = $1`,
[id]
);
if (productRows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
const externalProductId = productRows[0].external_product_id;
// Find all dispensaries carrying this product (by external_product_id match)
// with distance calculation using Haversine formula
const { rows: offers } = await query<{
dispensary_id: number;
dispensary_name: string;
city: string;
state: string;
address: string;
latitude: number;
longitude: number;
menu_url: string;
stock_status: string;
rec_min_price_cents: number;
distance_miles: number;
}>(
`
WITH latest_snapshots AS (
SELECT DISTINCT ON (s.dutchie_product_id)
s.dutchie_product_id,
s.dispensary_id,
s.stock_status,
s.rec_min_price_cents,
s.crawled_at
FROM dutchie_product_snapshots s
JOIN dutchie_products p ON s.dutchie_product_id = p.id
WHERE p.external_product_id = $1
ORDER BY s.dutchie_product_id, s.crawled_at DESC
)
SELECT
d.id as dispensary_id,
COALESCE(d.dba_name, d.name) as dispensary_name,
d.city,
d.state,
d.address,
d.latitude,
d.longitude,
d.menu_url,
ls.stock_status,
ls.rec_min_price_cents,
-- Haversine distance formula (in miles)
(3959 * acos(
cos(radians($2)) * cos(radians(d.latitude)) *
cos(radians(d.longitude) - radians($3)) +
sin(radians($2)) * sin(radians(d.latitude))
)) as distance_miles
FROM latest_snapshots ls
JOIN dispensaries d ON ls.dispensary_id = d.id
WHERE d.latitude IS NOT NULL
AND d.longitude IS NOT NULL
HAVING (3959 * acos(
cos(radians($2)) * cos(radians(d.latitude)) *
cos(radians(d.longitude) - radians($3)) +
sin(radians($2)) * sin(radians(d.latitude))
)) <= $4
ORDER BY distance_miles ASC
`,
[externalProductId, userLat, userLng, maxRadius]
);
// Find the best (lowest) price for isBestPrice flag
const validPrices = offers
.filter(o => o.rec_min_price_cents && o.rec_min_price_cents > 0)
.map(o => o.rec_min_price_cents);
const bestPrice = validPrices.length > 0 ? Math.min(...validPrices) : null;
// Transform for frontend
const availability = offers.map(o => ({
dispensaryId: o.dispensary_id,
dispensaryName: o.dispensary_name,
city: o.city,
state: o.state,
address: o.address,
latitude: o.latitude,
longitude: o.longitude,
menuUrl: o.menu_url,
stockStatus: o.stock_status || 'unknown',
price: o.rec_min_price_cents ? o.rec_min_price_cents / 100 : null,
distanceMiles: Math.round(o.distance_miles * 10) / 10, // Round to 1 decimal
isBestPrice: bestPrice !== null && o.rec_min_price_cents === bestPrice,
}));
res.json({
productId: parseInt(id, 10),
productName: productRows[0].name,
brandName: productRows[0].brand_name,
totalCount: availability.length,
offers: availability,
});
} catch (error: any) {
console.error('Error fetching product availability:', error);
res.status(500).json({ error: error.message });
}
});
// ============================================================
// CATEGORIES
// ============================================================

View File

@@ -64,8 +64,13 @@ import { dutchieAZRouter, startScheduler as startDutchieAZScheduler, initializeD
import { trackApiUsage, checkRateLimit } from './middleware/apiTokenTracker';
import { startCrawlScheduler } from './services/crawl-scheduler';
import { validateWordPressPermissions } from './middleware/wordpressPermissions';
import { markTrustedDomains } from './middleware/trustedDomains';
// Apply WordPress permissions validation first (sets req.apiToken)
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
// These domains can access the API without authentication
app.use(markTrustedDomains);
// Apply WordPress permissions validation (sets req.apiToken for API key requests)
app.use(validateWordPressPermissions);
// Apply API tracking middleware globally

View File

@@ -0,0 +1,107 @@
import { Request, Response, NextFunction } from 'express';
/**
* List of trusted domains that can access the API without authentication.
* These are our own frontends that should have unrestricted access.
*/
const TRUSTED_DOMAINS = [
'cannaiq.co',
'www.cannaiq.co',
'findagram.co',
'www.findagram.co',
'findadispo.com',
'www.findadispo.com',
// Development domains
'localhost',
'127.0.0.1',
];
export interface TrustedDomainRequest extends Request {
isTrustedDomain?: boolean;
}
/**
* Extracts domain from Origin or Referer header
*/
function extractDomain(header: string): string | null {
try {
const url = new URL(header);
return url.hostname;
} catch {
return null;
}
}
/**
* Checks if the request comes from a trusted domain
*/
function isRequestFromTrustedDomain(req: Request): boolean {
const origin = req.get('origin');
const referer = req.get('referer');
// Check Origin header first (preferred for CORS requests)
if (origin) {
const domain = extractDomain(origin);
if (domain && TRUSTED_DOMAINS.includes(domain)) {
return true;
}
}
// Fallback to Referer header
if (referer) {
const domain = extractDomain(referer);
if (domain && TRUSTED_DOMAINS.includes(domain)) {
return true;
}
}
return false;
}
/**
* Middleware that marks requests from trusted domains.
* This allows the auth middleware to skip authentication for these requests.
*
* Trusted domains: cannaiq.co, findagram.co, findadispo.com
*
* Usage: Apply this middleware BEFORE the auth middleware
*/
export function markTrustedDomains(
req: TrustedDomainRequest,
res: Response,
next: NextFunction
) {
req.isTrustedDomain = isRequestFromTrustedDomain(req);
next();
}
/**
* Middleware that allows requests from trusted domains to bypass auth.
* Other requests must have a valid API key in x-api-key header.
*
* This replaces the standard auth middleware for public API endpoints.
*/
export function requireApiKeyOrTrustedDomain(
req: TrustedDomainRequest,
res: Response,
next: NextFunction
) {
// Allow trusted domains without authentication
if (req.isTrustedDomain) {
return next();
}
// Check for API key
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) {
return res.status(401).json({
error: 'Unauthorized',
message: 'API key required. Provide x-api-key header or access from a trusted domain.'
});
}
// If API key is provided, let the WordPress permissions middleware handle validation
// The WordPress middleware should have already validated the key if present
next();
}

View File

@@ -840,6 +840,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
d.longitude,
d.menu_type as platform,
d.menu_url,
d.hours,
d.amenities,
d.description,
d.image_url,
d.google_rating,
d.google_review_count,
COALESCE(pc.product_count, 0) as product_count,
COALESCE(pc.in_stock_count, 0) as in_stock_count,
pc.last_updated
@@ -885,6 +891,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
longitude: parseFloat(d.longitude)
} : null,
platform: d.platform,
hours: d.hours || null,
amenities: d.amenities || [],
description: d.description || null,
image_url: d.image_url || null,
rating: d.google_rating ? parseFloat(d.google_rating) : null,
review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null,
product_count: parseInt(d.product_count || '0', 10),
in_stock_count: parseInt(d.in_stock_count || '0', 10),
last_updated: d.last_updated,
@@ -935,6 +947,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
d.longitude,
d.menu_type as platform,
d.menu_url,
d.hours,
d.amenities,
d.description,
d.image_url,
d.google_rating,
d.google_review_count,
COALESCE(pc.product_count, 0) as product_count,
COALESCE(pc.in_stock_count, 0) as in_stock_count,
pc.last_updated
@@ -980,6 +998,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
longitude: parseFloat(d.longitude)
} : null,
platform: d.platform,
hours: d.hours || null,
amenities: d.amenities || [],
description: d.description || null,
image_url: d.image_url || null,
rating: d.google_rating ? parseFloat(d.google_rating) : null,
review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null,
product_count: parseInt(d.product_count || '0', 10),
in_stock_count: parseInt(d.in_stock_count || '0', 10),
last_updated: d.last_updated,

View File

@@ -9,14 +9,36 @@ const router = Router();
router.use(authMiddleware);
router.use(requireRole('admin', 'superadmin'));
// Get all users
// Get all users with search and filter
router.get('/', async (req: AuthRequest, res) => {
try {
const result = await pool.query(`
SELECT id, email, role, created_at, updated_at
const { search, domain } = req.query;
let query = `
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
FROM users
ORDER BY created_at DESC
`);
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// Search by email, first_name, or last_name
if (search && typeof search === 'string') {
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// Filter by domain
if (domain && typeof domain === 'string') {
query += ` AND domain = $${paramIndex}`;
params.push(domain);
paramIndex++;
}
query += ` ORDER BY created_at DESC`;
const result = await pool.query(query, params);
res.json({ users: result.rows });
} catch (error) {
console.error('Error fetching users:', error);
@@ -29,7 +51,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT id, email, role, created_at, updated_at
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
FROM users
WHERE id = $1
`, [id]);
@@ -48,7 +70,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
// Create user
router.post('/', async (req: AuthRequest, res) => {
try {
const { email, password, role } = req.body;
const { email, password, role, first_name, last_name, phone, domain } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
@@ -60,6 +82,12 @@ router.post('/', async (req: AuthRequest, res) => {
return res.status(400).json({ error: 'Invalid role. Must be: admin, analyst, or viewer' });
}
// Check for valid domain
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
if (domain && !validDomains.includes(domain)) {
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
}
// Check if email already exists
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
if (existing.rows.length > 0) {
@@ -70,10 +98,10 @@ router.post('/', async (req: AuthRequest, res) => {
const passwordHash = await bcrypt.hash(password, 10);
const result = await pool.query(`
INSERT INTO users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id, email, role, created_at, updated_at
`, [email, passwordHash, role || 'viewer']);
INSERT INTO users (email, password_hash, role, first_name, last_name, phone, domain)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
`, [email, passwordHash, role || 'viewer', first_name || null, last_name || null, phone || null, domain || 'cannaiq.co']);
res.status(201).json({ user: result.rows[0] });
} catch (error) {
@@ -86,7 +114,7 @@ router.post('/', async (req: AuthRequest, res) => {
router.put('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
const { email, password, role } = req.body;
const { email, password, role, first_name, last_name, phone, domain } = req.body;
// Check if user exists
const existing = await pool.query('SELECT id FROM users WHERE id = $1', [id]);
@@ -100,6 +128,12 @@ router.put('/:id', async (req: AuthRequest, res) => {
return res.status(400).json({ error: 'Invalid role' });
}
// Check for valid domain
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
if (domain && !validDomains.includes(domain)) {
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
}
// Prevent non-superadmin from modifying superadmin users
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
@@ -132,6 +166,27 @@ router.put('/:id', async (req: AuthRequest, res) => {
values.push(role);
}
// Handle profile fields (allow setting to null with explicit undefined check)
if (first_name !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(first_name || null);
}
if (last_name !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(last_name || null);
}
if (phone !== undefined) {
updates.push(`phone = $${paramIndex++}`);
values.push(phone || null);
}
if (domain !== undefined) {
updates.push(`domain = $${paramIndex++}`);
values.push(domain);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
@@ -143,7 +198,7 @@ router.put('/:id', async (req: AuthRequest, res) => {
UPDATE users
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, email, role, created_at, updated_at
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
`, values);
res.json({ user: result.rows[0] });