Merge pull request 'feat/ci-auto-merge' (#12) from feat/ci-auto-merge into master

Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/12
This commit is contained in:
kelly
2025-12-10 17:26:21 +00:00
11 changed files with 194 additions and 48 deletions

View File

@@ -45,6 +45,31 @@ steps:
when: when:
event: pull_request event: pull_request
# ===========================================
# AUTO-MERGE: Merge PR after all checks pass
# ===========================================
auto-merge:
image: alpine:latest
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- |
echo "Merging PR #${CI_COMMIT_PULL_REQUEST}..."
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"Do":"merge"}' \
"https://code.cannabrands.app/api/v1/repos/Creationshop/dispensary-scraper/pulls/${CI_COMMIT_PULL_REQUEST}/merge"
depends_on:
- typecheck-backend
- typecheck-cannaiq
- typecheck-findadispo
- typecheck-findagram
when:
event: pull_request
# =========================================== # ===========================================
# MASTER DEPLOY: Parallel Docker builds # MASTER DEPLOY: Parallel Docker builds
# =========================================== # ===========================================
@@ -64,11 +89,7 @@ steps:
from_secret: registry_password from_secret: registry_password
platforms: linux/amd64 platforms: linux/amd64
provenance: false provenance: false
build_args: build_args: APP_BUILD_VERSION=${CI_COMMIT_SHA:0:8},APP_GIT_SHA=${CI_COMMIT_SHA},APP_BUILD_TIME=${CI_PIPELINE_CREATED},CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
- APP_BUILD_VERSION=${CI_COMMIT_SHA}
- APP_GIT_SHA=${CI_COMMIT_SHA}
- APP_BUILD_TIME=${CI_PIPELINE_CREATED}
- CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
depends_on: [] depends_on: []
when: when:
branch: master branch: master

View File

@@ -47,6 +47,9 @@ RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Copy migrations for auto-migrate on startup
COPY migrations ./migrations
# Create local images directory for when MinIO is not configured # Create local images directory for when MinIO is not configured
RUN mkdir -p /app/public/images/products RUN mkdir -p /app/public/images/products

View File

@@ -27,8 +27,8 @@ router.get('/brands', async (req: Request, res: Response) => {
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states, array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
COUNT(DISTINCT d.id) as store_count, COUNT(DISTINCT d.id) as store_count,
COUNT(DISTINCT sp.id) as sku_count, COUNT(DISTINCT sp.id) as sku_count,
ROUND(AVG(sp.price_rec)::numeric, 2) FILTER (WHERE sp.price_rec > 0) as avg_price_rec, ROUND(AVG(sp.price_rec) FILTER (WHERE sp.price_rec > 0)::numeric, 2) as avg_price_rec,
ROUND(AVG(sp.price_med)::numeric, 2) FILTER (WHERE sp.price_med > 0) as avg_price_med ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
FROM store_products sp FROM store_products sp
JOIN dispensaries d ON sp.dispensary_id = d.id JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != '' WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
@@ -154,10 +154,9 @@ router.get('/pricing', async (req: Request, res: Response) => {
SELECT SELECT
sp.category_raw as category, sp.category_raw as category,
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price, MIN(sp.price_rec) as min_price,
MAX(sp.price_rec) as max_price, MAX(sp.price_rec) as max_price,
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
FILTER (WHERE sp.price_rec > 0) as median_price,
COUNT(*) as product_count COUNT(*) as product_count
FROM store_products sp FROM store_products sp
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0 WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
@@ -169,7 +168,7 @@ router.get('/pricing', async (req: Request, res: Response) => {
SELECT SELECT
d.state, d.state,
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price, MIN(sp.price_rec) as min_price,
MAX(sp.price_rec) as max_price, MAX(sp.price_rec) as max_price,
COUNT(DISTINCT sp.id) as product_count COUNT(DISTINCT sp.id) as product_count
FROM store_products sp FROM store_products sp

View File

@@ -183,8 +183,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' }); return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
} }
const jobId = await createProxyTestJob(mode, concurrency); const { jobId, totalProxies } = await createProxyTestJob(mode, concurrency);
res.json({ jobId, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` }); res.json({ jobId, total: totalProxies, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
} catch (error: any) { } catch (error: any) {
console.error('Error starting proxy test job:', error); console.error('Error starting proxy test job:', error);
res.status(500).json({ error: error.message || 'Failed to start proxy test job' }); res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
@@ -195,8 +195,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => { router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
try { try {
const concurrency = parseInt(req.query.concurrency as string) || 10; const concurrency = parseInt(req.query.concurrency as string) || 10;
const jobId = await createProxyTestJob('failed', concurrency); const { jobId, totalProxies } = await createProxyTestJob('failed', concurrency);
res.json({ jobId, mode: 'failed', concurrency, message: 'Retesting failed proxies...' }); res.json({ jobId, total: totalProxies, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
} catch (error: any) { } catch (error: any) {
console.error('Error starting failed proxy test:', error); console.error('Error starting failed proxy test:', error);
res.status(500).json({ error: error.message || 'Failed to start proxy test job' }); res.status(500).json({ error: error.message || 'Failed to start proxy test job' });

View File

@@ -14,23 +14,36 @@ router.get('/', async (req: AuthRequest, res) => {
try { try {
const { search, domain } = req.query; const { search, domain } = req.query;
let query = ` // Check which columns exist (schema-tolerant)
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at const columnsResult = await pool.query(`
FROM users SELECT column_name FROM information_schema.columns
WHERE 1=1 WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`; `);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// Build column list based on what exists
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// Search by email, first_name, or last_name // Search by email (and optionally first_name, last_name if they exist)
if (search && typeof search === 'string') { if (search && typeof search === 'string') {
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`; const searchClauses = ['email ILIKE $' + paramIndex];
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
query += ` AND (${searchClauses.join(' OR ')})`;
params.push(`%${search}%`); params.push(`%${search}%`);
paramIndex++; paramIndex++;
} }
// Filter by domain // Filter by domain (if column exists)
if (domain && typeof domain === 'string') { if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
query += ` AND domain = $${paramIndex}`; query += ` AND domain = $${paramIndex}`;
params.push(domain); params.push(domain);
paramIndex++; paramIndex++;
@@ -50,8 +63,22 @@ router.get('/', async (req: AuthRequest, res) => {
router.get('/:id', async (req: AuthRequest, res) => { router.get('/:id', async (req: AuthRequest, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Check which columns exist (schema-tolerant)
const columnsResult = await pool.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
const result = await pool.query(` const result = await pool.query(`
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at SELECT ${selectCols.join(', ')}
FROM users FROM users
WHERE id = $1 WHERE id = $1
`, [id]); `, [id]);

View File

@@ -273,6 +273,29 @@ router.post('/deregister', async (req: Request, res: Response) => {
*/ */
router.get('/workers', async (req: Request, res: Response) => { router.get('/workers', async (req: Request, res: Response) => {
try { try {
// Check if worker_registry table exists
const tableCheck = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'worker_registry'
) as exists
`);
if (!tableCheck.rows[0].exists) {
// Return empty result if table doesn't exist yet
return res.json({
success: true,
workers: [],
summary: {
active_count: 0,
idle_count: 0,
offline_count: 0,
total_count: 0,
active_roles: 0
}
});
}
const { status, role, include_terminated = 'false' } = req.query; const { status, role, include_terminated = 'false' } = req.query;
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'"; let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";

View File

@@ -39,7 +39,12 @@ export async function cleanupOrphanedJobs(): Promise<void> {
export type ProxyTestMode = 'all' | 'failed' | 'inactive'; export type ProxyTestMode = 'all' | 'failed' | 'inactive';
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<number> { export interface CreateJobResult {
jobId: number;
totalProxies: number;
}
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<CreateJobResult> {
// Check for existing running jobs first // Check for existing running jobs first
const existingJob = await getActiveProxyTestJob(); const existingJob = await getActiveProxyTestJob();
if (existingJob) { if (existingJob) {
@@ -79,7 +84,7 @@ export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrenc
console.error(`❌ Proxy test job ${jobId} failed:`, err); console.error(`❌ Proxy test job ${jobId} failed:`, err);
}); });
return jobId; return { jobId, totalProxies };
} }
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> { export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {

View File

@@ -10,6 +10,17 @@
import { pool } from '../db/pool'; import { pool } from '../db/pool';
// Helper to check if a table exists
async function tableExists(tableName: string): Promise<boolean> {
const result = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = $1
) as exists
`, [tableName]);
return result.rows[0].exists;
}
export type TaskRole = export type TaskRole =
| 'store_discovery' | 'store_discovery'
| 'entry_point_discovery' | 'entry_point_discovery'
@@ -270,6 +281,11 @@ class TaskService {
* List tasks with filters * List tasks with filters
*/ */
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> { async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
// Return empty list if table doesn't exist
if (!await tableExists('worker_tasks')) {
return [];
}
const conditions: string[] = []; const conditions: string[] = [];
const params: (string | number | string[])[] = []; const params: (string | number | string[])[] = [];
let paramIndex = 1; let paramIndex = 1;
@@ -323,21 +339,41 @@ class TaskService {
* Get capacity metrics for all roles * Get capacity metrics for all roles
*/ */
async getCapacityMetrics(): Promise<CapacityMetrics[]> { async getCapacityMetrics(): Promise<CapacityMetrics[]> {
const result = await pool.query( // Return empty metrics if worker_tasks table doesn't exist
`SELECT * FROM v_worker_capacity` if (!await tableExists('worker_tasks')) {
); return [];
return result.rows as CapacityMetrics[]; }
try {
const result = await pool.query(
`SELECT * FROM v_worker_capacity`
);
return result.rows as CapacityMetrics[];
} catch {
// View may not exist
return [];
}
} }
/** /**
* Get capacity metrics for a specific role * Get capacity metrics for a specific role
*/ */
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> { async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
const result = await pool.query( // Return null if worker_tasks table doesn't exist
`SELECT * FROM v_worker_capacity WHERE role = $1`, if (!await tableExists('worker_tasks')) {
[role] return null;
); }
return (result.rows[0] as CapacityMetrics) || null;
try {
const result = await pool.query(
`SELECT * FROM v_worker_capacity WHERE role = $1`,
[role]
);
return (result.rows[0] as CapacityMetrics) || null;
} catch {
// View may not exist
return null;
}
} }
/** /**
@@ -463,12 +499,6 @@ class TaskService {
* Get task counts by status for dashboard * Get task counts by status for dashboard
*/ */
async getTaskCounts(): Promise<Record<TaskStatus, number>> { async getTaskCounts(): Promise<Record<TaskStatus, number>> {
const result = await pool.query(
`SELECT status, COUNT(*) as count
FROM worker_tasks
GROUP BY status`
);
const counts: Record<TaskStatus, number> = { const counts: Record<TaskStatus, number> = {
pending: 0, pending: 0,
claimed: 0, claimed: 0,
@@ -478,6 +508,17 @@ class TaskService {
stale: 0, stale: 0,
}; };
// Return empty counts if table doesn't exist
if (!await tableExists('worker_tasks')) {
return counts;
}
const result = await pool.query(
`SELECT status, COUNT(*) as count
FROM worker_tasks
GROUP BY status`
);
for (const row of result.rows) { for (const row of result.rows) {
const typedRow = row as { status: TaskStatus; count: string }; const typedRow = row as { status: TaskStatus; count: string };
counts[typedRow.status] = parseInt(typedRow.count, 10); counts[typedRow.status] = parseInt(typedRow.count, 10);

View File

@@ -320,7 +320,7 @@ class ApiClient {
} }
async testAllProxies() { async testAllProxies() {
return this.request<{ jobId: number; message: string }>('/api/proxies/test-all', { return this.request<{ jobId: number; total: number; message: string }>('/api/proxies/test-all', {
method: 'POST', method: 'POST',
}); });
} }

View File

@@ -96,7 +96,8 @@ export function Proxies() {
try { try {
const response = await api.testAllProxies(); const response = await api.testAllProxies();
setNotification({ message: 'Proxy testing job started', type: 'success' }); setNotification({ message: 'Proxy testing job started', type: 'success' });
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: proxies.length, passed_proxies: 0, failed_proxies: 0 }); // Use response.total if available, otherwise proxies.length, but immediately poll for accurate count
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: response.total || proxies.length || 0, passed_proxies: 0, failed_proxies: 0 });
} catch (error: any) { } catch (error: any) {
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' }); setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
} }

View File

@@ -7,7 +7,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../../../lib/api'; import { api } from '../../../lib/api';
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2 } from 'lucide-react'; import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
interface SeoPage { interface SeoPage {
id: number; id: number;
@@ -47,11 +47,31 @@ export function PagesTab() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [generatingId, setGeneratingId] = useState<number | null>(null); const [generatingId, setGeneratingId] = useState<number | null>(null);
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
loadPages(); loadPages();
checkAiProvider();
}, [typeFilter, search]); }, [typeFilter, search]);
async function checkAiProvider() {
try {
const data = await api.getSettings();
const settings = data.settings || [];
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
setHasActiveAiProvider(!!hasProvider);
} catch (error) {
console.error('Failed to check AI provider:', error);
setHasActiveAiProvider(false);
}
}
async function loadPages() { async function loadPages() {
setLoading(true); setLoading(true);
try { try {
@@ -188,12 +208,18 @@ export function PagesTab() {
<td className="px-3 sm:px-4 py-3"> <td className="px-3 sm:px-4 py-3">
<button <button
onClick={() => handleGenerate(page.id)} onClick={() => handleGenerate(page.id)}
disabled={generatingId === page.id} disabled={generatingId === page.id || hasActiveAiProvider === false}
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 disabled:opacity-50" className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
title="Generate content" hasActiveAiProvider === false
? 'bg-gray-100 text-gray-400'
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
}`}
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
> >
{generatingId === page.id ? ( {generatingId === page.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
) : hasActiveAiProvider === false ? (
<AlertCircle className="w-3.5 h-3.5" />
) : ( ) : (
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5" />
)} )}